저장되는 데이터는 키(key)와 값(value)이 하나의 쌍을 이루는 자료구조를 테이블이라고 한다. 테이블에서 키(key)가 존재하지 않는 '값'은 저장할 수 없다. 그리고 모든 키는 중복되지 않는다. 테이블은 사전구조 혹은 맵(map)이라 불리기도 한다.
탐색연산에 O(1)의 시간복잡도를 가진다.
ex) 아파트의 우편함 - 호수(key), 우편물(value)
ex) 사전(dictionary) - 단어(key), 단어에 대한 설명 혹은 내용(value)
typedef struct_empInfo {
int empNum; //직원의 고유번호 : key
int age; // 직원의 나이 : value
} EmpInfo;
EmpInfo empInfoArr[1000]; // 직원들의 정보를 저장하기 위해 선언된 배열
위의 배열을 테이블이라고 하기에는 조금 부족한 감이 있다. 왜냐하면 테이블은 키를 결정하였다면, 이를 기반으로 데이터를 단번에 찾을 수 있어야 하기 때문이다. 즉, 테이블에서 키(key)는 데이터를 찾는 도구이다.
EmpInfo ei;
empInfoArr[ei.empNum] = ei; // 저장
ei = empInfoArr[eNum]; // 탐색
테이블의 키(key)값인 empNum을 인덱스 값으로 하여 그 위치에 데이터를 저장할 수 있어야 한다.
여기서 문제는 직원이 999999999999이라면 매우 큰 배열이 필요하게 되는데 어떤 문제가 생길까?
이 문제들을 해결해주는 것은 해시 함수이다.
Hash Table은 연관배열 구조를 이용하여 키(key)에 값(value)을 저장하는 자료구조이다. 키(key)는 해시함수를 통해 해시로 변경이 되며 해시는 값과 매칭되어 저장소에 저장이 된다.
연관배열 구조(associative array) : 키 1개와 값 1개가 1:1로 연관되어 있는 자료구조이다. 따라서 키(key)를 이용하여 값을 도출할 수 있다.
typedef struct_empInfo {
int empNum; //직원의 고유번호 : key
int age; // 직원의 나이 : value
} EmpInfo;
int getHashValue(int empNum) {
return empNum % 100;
}
위의 코드와는 다르게 직원의 고유번호를 입사년도 네자리와 입사 순서 네자리로 구성한다고 예를 들어보자. 언젠가는 천명을 넘어설 수 있겠지만 현재에는 백명 이상을 채용할지도 모르는 상황이다. 따라서 배열 길이의 최소화를 위해 노력한다는 가정으로 getHashValue함수를 통해 해시를 얻는다.
hash function을 통해서 넓은 범위의 키를 좁은 범위의 키로 변경하였다. 하지만 20210103과 20120003의 고유번호를 가진 직원이 있다면 03이라는 값은 해시를 가지게 되는 문제가 생기게 된다. 이러한 상황을 충돌(Collision)이라고 한다.
충돌만 아니라면 해시 테이블은 삽입,삭제,탐색 과정에서 모두 평균적으로 O(1)의 시간복잡도를 가지고 있는 효율적인 자료구조이다.
John Smith와 Sandra Dee의 hash가 같다. 이러한 상황을 Hash Collision이라고 한다. 비둘기 집의 원리로 이러한 상황은 필연적으로 나타난다.
닫힌 어드레싱 방법(closed addressing method)이다. 닫힌 어드레싱 방법은 무슨일이 있어도(충돌이 발생해도) 자신의 자리에 저장한다는 뜻이다.
이것이 가능하게 하려면 값을 저장할 자리를 여러개 마련하는 수밖에 없다. 여러 개의 자리를 마련하는 방법으로는 배열을 이용하는 방법과 연결 리스트를 이용하는 방법이 있다.
하지만 배열은 충돌이 발생하지 않을 경우 메모리 낭비가 심하고 충돌의 최대 횟수를 결정해야 하는 부담이 있으므로 연결 리스트를 이용하여 슬롯을 연결하는 방법이 닫힌 어드레싱 방법을 대표한다.
Sandra Dee의 값이 153에 들어가려고 하는데 이미 값이 있다. 그래서 기존의 John Smith값 뒤에 연결시켰다.
즉, 체이닝은 자료 저장시, 저장소(bucket)에서 충돌이 일어나면 해당 값을 기존 값과 연결시키는 기법이다.
상대적으로 적은 메모리를 사용한다는 장점이 있지만 한 해시에 자료들이 계속 연결된다면(쏠림현상이 생긴다면) 검색 효율이 낮아진다는 단점이 있다.
동기화 지원 여부의 차이가 있다.
// 해시테이블의 put
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
// 해시맵의 put
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
병렬 처리를 하면서 자원의 동기화를 고려해야 하는 상황이라면 해시테이블(HashTable)을 사용해야 하며, 병렬 처리를 하지 않거나 자원의 동기화를 고려하지 않는 상황이라면 해시맵(HashMap)을 사용하면 된다.
충돌이 발생하면 다른 자리에 대신 저장한다. 열린 어드레싱 방법은 해시를 찾는 규칙에 따라 3가지로 구분된다.
충돌이 발생했을 때 그 옆자리가 비었는지 살펴보고, 비었을 경우 그 자리에 대신 저장하는 방법이다. 옆자리가 비어있지 않을 경우, 한칸 더 이동을 해서 자리를 살피게 된다. 즉 f(k)+1 -> f(k)+2 -> f(k)+3 ...으로 전개된다. 하지만 선형 조사법은 충돌의 횟수가 증가함에 따라서 클러스터 현상이 발생한다는 단점이 있다.
선형 조사법의 단점을 극복하기 위해서 고안된 방법이다. 충돌이 발생하면 좀 멀리서 빈 공간을 찾는 방법이다. f(k)+1^2 -> f(k)+2^2 -> f(k)+3^2...순으로 전개된다.
이차 조사법도 해시 값이 같다면 충돌 발생시에 빈 슬롯을 찾기 위해서 접근하는 위치가 늘 동일하다는 문제점이 있다. 선형 조사법 보다는 낫지만 접근이 진행되는 슬롯을 중심으로 클러스터 현상이 발생할 확률은 여전히 높다.
이차 조사법은 좀 멀리서 빈 공간을 찾기는 하지만 그 빈 공간을 선택하는 방법은 한칸, 네칸, 아홉칸..과 같이 규칙적이다. 이렇게 빈 공간을 찾는 방법을 불규칙적으로 구성하는 것이 이중해시 방법이다. 두개의 해시 함수를 쓰기 때문에 붙여진 이름이다.