해싱: 키 값에 직접 산술적인 연산을 적용하여 항목이 저장되어 있는 테이블의 주소를 계산하여 항목에 접근 -> 해시 테이블을 이용한 탐색
해시 테이블(hash table): 키 값의 연산에 의해 직접 접근이 가능항 구조
사전 구조(dictionary): 맵(map)이나 테이블(table)로 불리기도 함
1. 영어 단어나 사람의 이름 같은 탐색 키(search key)
2. 단어의 정의나 주소 또는 전화 번호 같은 탐색 키와 관련된 값(value)
객체: 일련의 (key,value) 쌍의 집합
연산:
add(key,value) ::= (key,value)를 사전에 추가함
delete(key) ::= key에 해당되는 (key,value)를 찾아서 삭제, 탐색 성공 시 value, 실패 시 NULL을 반환
search(key) ::= key에 해당하는 value를 찾아서 반환, 탐색 실패 시 NULL 반환
add(key, value)
index <- hash_function(key)
ht[index] <- value
search(key)
index <- hash_function(key)
return ht[index]
좋은 해시 함수의 조건
1. 충돌이 적어야 함
2. 해시 함수 값이 해시 테이블의 주소 영역 내에서 고르게 분포되어야 함
3. 계산이 빨라야 함
제산 함수: 나머지 연산자(mod)를 사용하여 탐색 키를 해시 테이블의 크기로 나눈 나머지를 해시 주소로 사용하는 방법
h(k) = k mod M // 0 ~ M-1까지의 숫자가 해시 테이블을 위한 유효한 인덱스가 됨
int hash_function(int key){
int hash_index = key % M;
if(hash_index < 0){
hash_index += M;
}
return hash_index;
}
홀딩 함수: 팀색 키를 여러 부분으로 나누어 모두 더한 값을 해시 주소로 사용
탐색 키를 나누고 더하는 방법에는 이동 폴딩(shift folding)과 경계 폴딩(boundary folding)이 대표적
이동 폴딩: 탐색 키를 여러 부분으로 나눈 값들을 더하여 해시 주소로 사용
경계 폴딩: 탐색 키의 이웃한 부분을 거꾸로 더하여 해시 주소로 사용
폴딩(folding): 탐색 키를 몇 개의 부분으로 나누어 이를 더하거나 비트별로 XOR 같은 부울 연산을 하는 것
ex) 32비트 키를 2개의 16비트로 나누어 비트별로 XOR 연산하는 코드
hash_index = (short)(key ^ (key>>16))
중간 제곱 합수: 탐색 키를 제곱한 다음, 중간의 몇 비트를 취해서 해시 주소를 생성
비트 추출 방법: 해시 테이블의 크기가 M=일 떄 탐색 키를 이진수로 간주하여 임의의 위치의 k개의 비트를 해시 주소롤 사용하는 것
숫자 분석 방법: 숫자로 구성된 키에서 각각의 위치에 있는 수의 특징을 미리 알고 있을 때 유용, 키의 각각의 위치에 있는 숫자 중에서 편중되지 않은 수들을 해시 테이블의 크기에 적합한 만큼 조합하여 해시 주소로 사용하는 방법
int hash_function(char* key){
int hash_index = 0;
while(*key){
hash_index = g*hash_index + *key++; // 보통 g는 31을 사용
}
return hash_index;
}
충돌(collision): 서로 다른 탐색 키를 갖는 항목들이 같은 해시 주소를 가지는 현상
선형 조사법: 특정 버켓에서 충돌이 발생하면 해시 테이블이 비어 있는 버켓을 찾는 방법
// 조사되는 위치
h(k),h(k)+1,h(k)+2,h(k)+3,...
ex) 크기가 7인 해시 테이블과 h(k) = k mode 7인 해시 함수로 사용한다고 가정할 때, 8, 1, 9, 6, 13의 순으로 탐색 키를 저장하면
1단계 (8): h(8) = 8 mod 7 = 1(저장)
2단계 (1): h(1) = 1 mod 7 = 1(충돌 발생)
(h(1)+1) mod 7 = 2(저장)
3단계 (9): h(9) = 9 mod 7 = 2(충돌 발생)
(h(9)+1) mod 7 = 3(저장)
4단계 (6): h(6) = 6 mod 7 = 6(저장)
5단계(13): h(13) = 13 mod 7 = 6(충돌 발생)
(h(13)+1) mod 7 = 0(저장)
#define KEY_SIZE 10 // 탐색 키의 최대 길이
#define TABLE_SIZE 13 // 해시 테이블의 크기(소수)
typedef struct{
char key[KEY_SIZE];
// 다른 필요한 필드를 여기에 넣음
}element;
element hash_table[TABLE_SIZE]; // 해시 테이블
초기화 함수: 각 버켓을 공백 상태를 만드는 것
// 해시 테이블의 각 요소를 초기화하는 함수
void init_table(element ht[]){
for(int i=0;i<TABLE_SIZE;i++){
ht[i].key[0] = NULL; // 문자열이 탐색 키이므로 탐색 키의 첫 번째 문자가 NULL 값이면 버켓이 비어 있는 것으로 생각할 수 있음
}
}
해시 함수
// 해시 테이블에 탐색 키를 삽입하기 전 탐색 키를 정수로 바꿔주는 해시 함수 필요
// 문자로 된 탐색 키를 숫자로 변환
int transform(char *key){
int number =0;
while(*key){ // 간단한 덧셈 방식 사용 자연수 생성
number += *key++;
}
return number;
}
int hash_function(char *key){
return transform(key) % TABLE_SIZE;
}
ex) 탐색 키가 "do", "for", "if", "case", "else", "return", "function" 일 때, 문자열에서 정수로의 변환 과정을 거쳐 해시 주소를 구해보면
탐색 키 | 덧셈식 변환 과정 | 덧셈 합계 | 해시 주소 |
---|---|---|---|
do | 100+111 | 211 | 3 |
for | 102+111+114 | 327 | 2 |
if | 105+102 | 207 | 12 |
case | 99+97+115+101 | 412 | 9 |
else | 101+108+115+101 | 425 | 9 |
return | 114+101+116+117+114+110 | 672 | 9 |
function | 102+117+110+99+116+105+111+110 | 870 | 12 |
삽입 함수
1. 탐색 키에 대해 해시 주소를 계산
2. 그 주소가 비어 있는지를 검사해서 비어 있지 않으면 먼저 그 주소에 저장된 탐색 키와 현재 삽입하려고 하는 탐색 키가 동일한지를 체크
3. 동일하면 탐색 키 중복되었다는 것을 화면에 출력하고 복귀
4. 동일하지 않으면 현재 주소를 나타내는 변수 i를 증가하여 다음 버켓을 가리키도록 함
5. 증가된 주소가 시작 주소로 되돌아온 경우에는 다른 모든 버켓을 조사했는데도 빈 버켓이 없는 경우이므로 더 이상 삽입이 불가능한 오류 상태임을 알리고 복귀
#define empty(e) (strlen(e.key) == 0) // 현재 버켓이 비어 있는지를 검사하는 함수
#define equal(e1, e2) (!strcmp(e1.key,e2.key)) // 두 개의 항목이 동일한지를 검사하는 함수
// 선형 조사법을 이용하여 테이블에 키를 삽입하고,
// 테이블이 가득 찬 경우 종료
void hash_lp_add(element item, element ht[]){
int i;
int hash_value = i = hash_function(item.key); // 탐색 키에 대하여 해시 주소를 계산
while(!empty(ht[i])){ // 그 주소가 비어 있는지 검사
if(equal(item,ht[i])){ // 그 주소에 저장된 탐색 키와 현재 삽입하려는 탐색 키가 동일한지 검사
fprintf(stderr,"Duplicate key.\n");
return;
}
i = (i+1) % TABLE_SIZE; // 저장된 탐색 키가 종복되지 않았을 경우 현재 주소를 나타내는 변수 i를 증가시켜 다음 버켓을 가리키도록 함
if( i == hash_value){
fprintf(stderr,"Table is full\n");
return;
}
}
ht[i] = item;
}
탐색 함수
1. 탐색 키에 해시 함수를 적용시켜 계산된 주소에서 항목을 찾지 못하면 해당 항목을 찾을 때까지 연속 버켓을 탐색
2. 탐색하다가 시작 주소로 되돌아오면 해당 항목이 테이블에 없다고 결론 내릴 수 있음
// 선형 조사법을 이용하여 테이블에 저장된 키를 탐색
void hash_lp_search(element item, element ht[]){
int i;
int hash_value = i = hash_function(item.key);
while(!empty(ht[i])){
if(equal(item,ht[i])){
fprintf(stderr,"Search Success: Location = %d\n",i);
return;
}
i = (i+1) %TABLE_SIZE;
if(i == hash_value){
fprintf(stderr,"Search fail\n");
return;
}
}
fprintf(stderr,"Search fail\n");
}
// 해싱 테이블의 내용을 출력
void hash_lp_print(element ht[]){
for(int i=0;i<TABLE_SIZE;i++){
printf("[%d] %s\n",i,ht[i].key);
}
}
// 해싱 테이블을 사용한 예제
int main(){
element tmp;
int op;
while(1){
printf("Press 0~2 (0=input, 1=search, 2=quit)\n");
scanf("%d",&op);
if(op == 2) break;
printf("Input key=");
scanf("%s",tmp.key);
if(op == 0){
hash_lp_add(tmp, hash_table);
}else if (op == 1){
hash_lp_search(tmp, hash_table);
}
hash_lp_print(hash_table);
}
}
삭제 함수
이차 조사법(quadratic probing): 선형 조사법과 유사하지만, 다음 조사할 위치를 아래의 식에 의해 결정
void hash_qp_add(element item, element ht[]){
int i, inc = 0;
int hash_value = i = hash_function(item.key);
// printf("hash_address=%d\n",i);
while(!empty(ht[i])){
if(equal(item,ht[i])){
fprintf(stderr,"Duplicate key.\n");
return;
}
// 다음 조사 위치를 찾는 부분 변경
i = (hash_value + inc*inc) % TABLE_SIZE;
inc = inc +1;
if( i == hash_value){
fprintf(stderr,"Table is full\n");
return;
}
}
ht[i] = item;
}
이중 해싱법(double hashing) 또는 재해싱(rehashing): 오버플로가 발생함에따라 항목을 저장할 다음 위치를 결정할 때, 원래 해시 함수와 다른 별개의 해시 함수를 이용하는 방법
step = C - (k mod C) // 이런 형태의 함수는 [1...C] 사이의 값을 생성, C는 보통 테이블 크기인 M보다 약간 작은 소수
ex) 탐색 키가 8, 1, 9, 6, 13이고, 첫 번째 해시 함수가 h(k) = k mod 7이고, 오버플로 발생 시 해시 함수가 h'(k) = 5 - (k mod 5) 일 때, 삽입 예
1단계 (8): h(8) = 8 mod 7 = 1(저장)
2단계 (1): h(1) = 1 mod 7 = 1(충돌 발생)
(h(1) + h'(1)) mod 7 = (1 + 5 - (1 mod 5)) mod 7 = 5(저장)
3단계 (9): h(9) = 9 mod 7 = 2(저장)
4단계 (6): h(6) = 6 mod 7 = 6(저장)
5단계 (13): h(13) = 13 mod 7 = 6(충돌 발생)
(h(13) + h'(13)) mod 7 = (6 + 5 - (13 mod 5)) mod 7 = 1(충돌 발생)
(h(13)+2*h'(13)) mod 7 = (6+2*2) mod 7 = 3(저장)
void hash_qp_add(element item, element ht[]){
int i;
int hash_value = i = hash_function1item.key);
int inc = hash_function2(item.key);
while(!empty(ht[i])){
if(equal(item,ht[i])){
fprintf(stderr,"Duplicate key.\n");
return;
}
// 다음 조사 위치를 찾는 부분 변경
i = (i + inc) % TABLE_SIZE;
if( i == hash_value){
fprintf(stderr,"Table is full\n");
return;
}
}
ht[i] = item;
}
ex) 탐색 키가 8, 1, 9, 6, 13이고, 해시 함수가 h(k) = k mod 7 일 때 삽입 예
1단계 (8): h(8) = 8 mod 7 = 1(저장)
2단계 (1): h(1) = 1 mod 7 = 1(충돌 발생 -> 새로운 노드 생성 저장)
3단계 (9): h(9) = 9 mod 7 = 2(저장)
4단계 (6): h(6) = 6 mod 7 = 6(저장)
5단계 (13): h(13) = 13 mod 7 = 6(충돌 발생 -> 새로운 노드 생성 저장)
연결 리스트의 어디에 새로운 항목을 삽입해야할지 결정해야 함
-> 중복이 허용된다면, 연결리스트의 처음에 삽입하는 것이 능률적
-> 중복이 허용되지 않는다면, 연결리스트의 마지막에 삽입하는 것이 자연스러움
문자열 키에 대한 체이닝 해싱 구현을 위한 구조체
#define KEY_SIZE 10 // 탐색 키의 최대 길이
#define TABLE_SIZE 13 // 해싱 테이블의 크기(소수)
typedef struct{
char key[KEY_SIZE];
// 다른 필요한 필드
}element;
typedef struct ListNode{
element item;
struct ListNode *link;
}ListNode;
ListNode *hash_table[TABLE_SIZE];
삽입 함수
1. 탐색 키가 버켓으로 들어오면 먼저 동적 메모리 할당을 이용하여 연결 리스트의 노드를 생성
2. 이 새로운 노드에 탐색 키를 복사
3. 버켓에 연결되어 있는 기존의 연결 리스트에서 동일한 탐색 키가 존재하는지 검사
4. 동일한 키가 있을 경우 오류 메시지 출력하고 복귀
4. 동일한 키가 없을 경우 연결 리스트의 맨 끝에 새로운 탐색 키를 포함하는 새로운 노드를 연결
5. 만약 기존의 연결 리스트가 없을 경우 해시 테이블의 포인터에 새로운 노드를 연결
#define equal(e1,e2) (!strcmp(e1.key, e2.key)
// 체이닝을 이용하여 테이블에 키 삽입
void hash_chain_add(element item, ListNode *ht[]){
int hash_value = hash_function(item.key);
ListNode *ptr; // 새로운 노드
ListNode *node_before = NULL; // 이전 노드
ListNode *node = ht[hash_value]; // 현재 노드
for(;node;node_before = node, node = node->link){
if(equal(node->item,item){
fprintf(stderr,"Already key\n");
return;
}
}
ptr = (ListNode*)malloc(sizeof(ListNode));
ptr->item = item;
ptr->link = NULL;
if(node_befor){ // 기존의 연결 리스트가 있다면
node_before->link = node;
}else{ // 기존의 연결 리스트가 없으면
ht[hash_value] = ptr;
}
}
// 체이닝을 이용하여 테이블에 저장된 키를 탐색
void hash_chain_find(element item, ListNode *ht[]){
ListNode *node;
int hash_value = hash_function(item.key);
for(node= ht[hash_value];node;node = node->link){
if(equal(node->item, item){
printf("Find key\n");
return;
}
}
printf("Not Found key\n");
}
node_before 포인터가 필요한 이유
해싱에서 중요한 연산은 탐색 연산
-> 해시 테이블에 자료를 추가하거나 자료를 꺼내거나 자료를 삭제하는 연산들은 모두 탐색 연산을 사용하게 됨
이상적인 해싱
-> 충돌이 전혀 일어나지 않는다믄 가정하에 가능
-> 시간복잡도
해싱 성능 분석
-> 해시 테이블이 얼마나 채워져 있는지를 나타내는 척도를 정의
-> 해시 테이블의 적재 밀도(loading density) 또는 적재 비율(loading factor)은 현재 저장된 항목의 개수 n과 해시 테이블의 크기 M의 비율
-> 가 0이면 해시 테이블이 비어 있음을 의미함
-> 의 최댓값은 충돌 해결 방법에 따라 달라지는데 선형 조사법에서는 해시 테이블이 가득 찬다면 각 버켓당 하나의 항목이 저장될 것이기 때문에 1이 되고, 체이닝에서는 저장할 수 있는 항목의 수가 해시 테이블의 크기를 넘어 설 수 있기 때문에 최대값을 가지지 않음
선형 조사법
-> 실패한 탐색:
-> 성공항 탐색:
체이닝
-> 실패한 탐색:
-> 성공한 탐색:
=> 체이닝의 경우 가 증가하더라도 성능이 급격하게 떨어지지 않음. 하지만, 효율성을 위해 낮게 유지할 필요가 있음
탐색 방법 | 탐색 | 삽입 | 삭제 | |
순차 탐색 | O(n) | O(1) | O(n) | |
이진 탐색 | O(logn) | O(logn + n) | O(logn + n) | |
이진 탐색 트리 | 균형 트리 | O(logn) | O(logn) | O(logn) |
경사 트리 | O(n) | O(n) | O(n) | |
해싱 | 최선의 경우 | O(1) | O(1) | O(1) |
최악의 경우 | O(n) | O(n) | O(n) |
<참고자료>
천인국 · 공용해 · 하상호 지음,『C언어로 쉽게 풀어쓴 자료구조』, 생능출판(2016)