해시법은 검색과 더불어 데이터의 추가와 삭제도 효율적으로 할 수 있는 방법.
검색(인덱싱)이 빠른 자료구조는 연속형 자료구조이고, 연결형 자료구조에 비해 삽입(추가)와 삭제가 느리다.
ex) c++ STL vector 자료구조
- 인덱싱: O(1)
- 삽입 및 삭제: O(n)
삽입 및 삭제가 빠른 자료구조는 연결형 자료구조이고, 연속형 자료구조에 비해 검색이 느리다(인덱싱 불가).
ex) 노드들이 연결된 링크드 리스트, 노드는 value 필드와 다음 노드를 가리키는 포인터 필드를 가짐.
=> Trade-off 관계
해시법은 데이터를 저장할 위치(인덱스)를 간단한 연산으로 구하는 것. 검색 뿐만 아니라 추가, 삭제도 효율적으로 수행할 수 있다. 예를 들어 배열의 각 요소 값으로 키 값이 있을 때 각 요솟수를 특정 수로 나눈 나머지를 해시 값(hash value)라고 할 수 있다. 이 해시 값은 데이터에 접근할 때 사용한다.
해시 값이 인덱스가 되도록 원래의 키 값을 저장한 배열이 아래의 해시 테이블(hash table)이다.
이 해시 테이블에 35를 추가하는 경우, 35를 13으로 나눈 나머지는 9이므로 해시 테이블의 9번째 위치에 값(35)를 저장한다.
이렇게 키 값(35)을 가지고 해시 값(9)를 만드는 과정을 해시 함수(hash function)라고 한다. 보통은 앞서 보였듯 나머지 연산 또는 이런 나머지 연산을 다시 응용한 연산을 사용. 그리고 해시 테이블의 각 요소를 버킷(bucket)이라고 한다.
위 해시 테이블 배열에 새로운 값 18을 추가하는 경우 고려해보면, 18을 13으로 나눈 나머지인 해시 값은 5이고 인덱스가 5인 버킷에 키 값을 저장해야 한다. 하지만 위 그림과 같이 이 버킷은 이미 채워져 있다. 이렇듯 키 값과 해시 값의 대응 관계가 반드시 1 대 1이라는 보증은 없으며, 이렇게 저장할 버킷이 중복되는 현상을 충돌(collision)이라 한다.
결국 해시 함수는 가능하면 해시 값이 치우치지 않도록(충돌이 발생되지 않도록) 고르게 분포된 값을 만들어야 한다.
체인법은 같은 해시 값을 갖는 데이터를 체인 모양으로 연결 리스트에서 연결하는 방법으로 오픈 해시법(open hashing)이라고도 한다.
배열(해시 테이블)의 각 버킷에 저장하는 값은 그 인덱스를 해시 값으로 하는 연결 리스트의 첫 번째 노드(node)에 대한 참조이다. 반면 데이터가 없는 버킷은 널(null)을 가리킨다.
cf) Java의 Hash Code란, 객체를 식별할 수 있는 유니크한 값을 말한다. 메모리에 생성된 객체의 주소를 정수로 변환한 형태를 얘기하는데, 이 정수는 중복되지 않는 고유의 값이다. Object Class에서 hashCode()를 가지고 있어서 객체를 생성하여 확인해보면 Hash Code 값을 확인 할 수 있다. 실질적으로 Java에서 정의된 hashCode() 메소드는 아래와 같이 설명된다.
1. 변경되지 않은 한 객체의 hashCode() 메소드 호출결과는 항상 같은 integer 값이어야 한다. 2. equals 메소드가 같다고 판별한 두 객체의 hashCode() 호출 결과는 똑같은 integer 값이어야 한다. 3. 그러나 java.lang.Object.equals 메소드가 다르다고 두 객체의 hashCode() 값이 반드시 달라야 하는 것은 아니다. (참조: https://dbjh.tistory.com/36)
public class ChainHash<K, V> {
// 해시를 구성하는 노드
class Node<K, V>{
private K key; // 키 값
private V data; // 데이터
private Node<K, V> next; // 다음 노드에 대한 참조
// 생성자
Node(K key, V data, Node<K, V> next){
this.key = key;
this.data = data;
this.next = next;
}
// 키 값 반환
K getKey(){
return key;
}
// 데이터 반환
V getValue(){
return data;
}
// 키의 해시 코드 반환
public int hashCode(){
return key.hashCode();
}
}
...
}
자기 참조형 클래스(next 필드의 자료형이 같은 Node 클래스)인 Node<K, V>에서 필드 next에 대입되는 값은 체인에 있는 다음 노드에 대한 참조이다. 하지만 다음 노드가 없을 경우에는 데이터가 없는 버킷과 마찬가지로 널이 대입된다.
table[0]..table[size-1]
로 접근. 생성자가 호출된 직후 배열 table의 모든 요소는 널(null)을 참조하며, 모든 버킷이 비어 있는 상태가 됨.
public class ChainHash<K, V> {
// 해시를 구성하는 노드
class Node<K, V>{
private K key; // 키 값
private V data; // 데이터
private Node<K, V> next; // 배열의 각 요소는 버켓, 다음 노드에 대한 참조
// 생성자
Node(K key, V data, Node<K, V> next){
this.key = key;
this.data = data;
this.next = next;
}
// 키 값 반환
K getKey(){
return key;
}
// 데이터 반환
V getValue(){
return data;
}
// 키의 해시 값 반환 (key -> hash value, hash value가 hash table의 index로)
public int hashCode(){
return key.hashCode();
}
}
// 필드
private int size; // hash table size
private Node<K, V>[] table; // hash table
// 생성자
public ChainHash(int capacity){
try{
table = new Node[capacity];
this.size = capacity;
}catch(OutOfMemoryError e){
this.size = 0;
}
}
// 해시 값(hash value) 구하기
public int hashValue(Object key){
return key.hashCode() % size;
// key의 고유 해시코드를 테이블 크기로 나누어 해시 값을 생성.
}
/* ADD
1. 해시 함수가 키 값을 해시 값으로 변환
2. 해시 값을 인덱스로 하는 버킷을 선택
3. 버킷이 가리키는 연결 리스트를 처음부터 순서대로 검색합니다. 키 값과 같은 값을 찾으면 키 값이 이미 등록된 상태이므로 추가 실패.
끝까지 스캔하여 찾지 못하면 리스트의 맨 앞 위치에 노드를 삽입.
=> 시간 복잡도: O(k) (k: 해당 해쉬 값을 갖는 키 들의 갯수)
*/
// 키 값: key, 데이터: data를 갖는 요소 추가.
public int add(K key, V data){
int hash = hashValue(key); // 해당 키의 해시값 구하기
Node<K, V> p = table[hash]; // 노드 선택
while(p != null){
if(p.getKey().equals(key)) // 해당 키 값이 이미 등록되어있다면,
return 1;
else
p = p.next;
}
Node<K, V> temp = new Node<K, V>(key, data, table[hash]); // 기존에 테이블 요소에서 삽입된 참조를 자신 노드가 참조.
table[hash] = temp; // 새로 만든 노드를 버킷(table[hase])이 참조.
return 0;
}
/* SEARCH
1. 해시 함수가 키 값을 해시 값으로 변환.
2. 해시 값을 인덱스로 하는 버킷을 선택.
3. 선택한 버킷(노드)의 연결 리스트를 처음부터 순서대로 검색. 키 값과 같은 값을 찾으면 검색 성공. 끝까지 스캔한 결과 찾지 못해면 실패.
=> 시간 복잡도: O(k) (k: 해당 해쉬 값을 갖는 키 들의 갯수)
*/
// 키 값 key를 갖는 요소 검색(데이터를 반환)
public V search(K key){
int hash = hashValue(key); // 검색한 데이터의 해시 값
Node<K, V> p = table[hash]; // 노드 선택
while(p != null){
if(p.getKey().equals(key))
return p.getValue();
else
p = p.next;
}
// 루프를 끝까지 돌면 자연스럽게 검색내용이 없다는 뜻.
return null;
}
/* REMOVE
1. 해시 함수가 키 값을 해시 값으로 변환
2. 해시 값을 인덱스로 하는 버킷 선택
3. 버킷이 가리키는 연결 리스트를 처음부터 순서대로 검색. 발견 시 삭제, 발견을 못하면 삭제 실패.
=> 시간 복잡도: O(k) (k: 해당 해쉬 값을 갖는 키 들의 갯수)
*/
public int remove(K key){
int hash = hashValue(key);
Node<K, V> p = table[hash];
Node<K, V> pp = null;
while(p != null){
if(p.getKey().equals(key)){
if(pp==null)
table[hash] = p.next;
else
pp.next = p.next;
return 0;
}
pp = p;
p = p.next;
}
return 1; // 해당 키 값 없음 -> 삭제 실패.
}
/* DUMP
해시 테이블의 모든 내용을 출력하는 dump 메서드
*/
public void dump(){
for(int i=0; i<size; i++){
Node<K, V> p = table[i];
System.out.printf("%02d ", i); // i번째 버킷
while(p != null){
System.out.printf("-> key: %s, value: %s", p.getKey(), p.getValue());
p = p.next;
}
System.out.println();
}
}
}