1. 해시 테이블 (Hash Table)
해시 테이블은 (key, value)로 데이터를 저장하는 자료구조이다. 해시 테이블의 핵심은 해시 함수인데, 해시 함수 (Hash Function)는 임의의 크기를 가지는 데이터를 고정된 크기의 데이터로 매핑하는 것이다. 예를 들어 ABC (3글자), 1324BC (6글자), AF32B (5글자)의 데이터를 2바이트의 고정 크기 값으로 매핑한다고 하자.
key value
ABC (3글자) ➔ A1 (2바이트)
1324BC (6글자) ➔ CB (2바이트)
AF32B (5글자) ➔ D5 (2바이트)
여기서 매핑 전 원래 데이터의 값을 키(key), 매핑 후 데이터의 값을 해시값(hash value), 화살표 역할을 하는 것이 해시 함수이다. 이처럼 해시 함수를 사용해 매핑하는 과정을 해싱(hashing)이라고 한다.
2. 해시 값의 충돌
해시 함수를 이용해 key 값을 고정된 길이의 value 값으로 매핑한다고 했다. 하지만 아무리 좋은 해시 함수라도 충돌은 발생하게 된다. 보통 해시함수는 많은 키값을 소수의 해쉬값으로 변환(many-to-one 대응)하기 때문에 서로 다른 key에 대해 동일한 value를 내는 해시충돌(collision)이 발생하게 된다. 아래 그림에서는 윤아와 서현의 해시 값이 2로 같은 값이 되어 충돌이 발생했다.
해시충돌이 발생할 가능성이 있음에도 해시테이블을 쓰는 이유는 적은 리소스로 많은 데이터를 효율적으로 관리할 수 있기 때문이다. 해시함수는 언제나 동일한 해시값을 리턴하고, 해당 해시값만 알면 해시테이블의 크기에 상관없이 데이터에 대단히 빠르게 접근할 수 있다. 또한, 해시함수는 계산이 간단한 함수(상수시간)로 작동하기 때문에 매우 효율적이다. 해시는 충돌이 일어나지 않으면 데이터 삽입, 삭제, 탐색을 O(1) 시간안에 수행할 수 있다.
3. 해시 값의 충돌 해결
해시값의 출동 문제를 해결하기 위해 분리 연결법(Separate Chaining)과 개방 주소법(Open Addressing)을 사용한다.
먼저 아까와 같은 예시에서 윤아와 서현을 해싱한 결과는 충동한다고 가정하자.
분리 연결법(Separate Chaining)
개별 체이닝은 충돌 발생 시 아래 그림과 같이 linked list로 연결하는 방법이다. 충돌이 발생한 윤아와 서현은 윤아의 다음 아이템이 서현인 형태로 연결 리스트 형태로 연결된다.
① 키의 해시 값을 계산한다.
② 해시 값을 이용해 배열의 인덱스를 구한다.
③ 같은 인덱스가 있으면 연결 리스트로 연결한다.
개별 체이닝 방식은 해시 테이블의 확장이 필요없고 간단하게 구현이 가능하며, 손쉽게 삭제할 수 있다는 장점이 있다. 하지만 데이터의 수가 많아지면 동일한 버킷에 chaining되는 데이터가 많아지며 그에 따라 캐시의 효율성이 감소한다는 단점이 있다.
개별 체이닝 방식을 사용하면 버킷의 길이를 n, 키의 개수를 m이라고 했을 때 평균적으로 한 개의 해시 당 $\alpha = (m/n)$개의 키가 들어있을 것이다.
- Insertion
충돌이 일어났을 때, 해당 해시(Hash)가 가진 연결리스트의 Head에 자료를 저장할 경우, O(1)의 시간복잡도를 가진다. 해당 해시(Hash)를 산출하고 저장하면서 기존 값(value)를 연결하는 행위만 하면 되기 때문이다. 반면 Tail에 자료를 저장할 경우, O(α)의 시간 복잡도를 가진다. 해당 해시(Hash)를 저장할 때 모든 연결리스트를 지나서 Tail에 접근해야 하기 때문이다. 최악의 경우, O(n)의 시간 복잡도를 가진다. 한 개의 해시(Hash)에 모든 자료가 연결되어 있을 수 있기 때문이다.
- Deletion & Search
산출된 Hash의 연결리스트를 차례로 살펴보아야 하므로 O(α)의 시간 복잡도를 가진다. 최악의 경우 O(n)의 시간복잡도를 가진다. 한 개의 해시(Hash)에 모든 자료가 연결되어 있을 수 있기 때문이다. 이 경우 모든 자료를 다 살펴보아야 한다.
개방 주소법(Open Addressing)
파이썬에는 보통 오픈 어드레싱 방법으로 구현한다. 오픈 어드레싱 방식은 충돌 발생 시 탐사(probing)을 통해 빈 공간을 찾는 방식이다. 이 때문에 모든 원소가 반드시 자신의 해시값과 일치하는 주소에 저장된다는 보장이 없다.
① 키의 해시 값을 계산한다.
② 해시 값을 이용해 배열의 인덱스를 구한다.
③ 같은 인덱스가 있으면 probing을 통해 새로운 위치를 찾아서 넣는다.
사실상 무한정 저장할 수 있는 체이닝 방식과 달리, 오픈 어드레싱은 전체 버킷 개수 이상은 저장할 수 없다. 따라서 일정 이상이 채워지면 더 큰 크기의 또 다른 버킷을 생성한 후 여기에 새롭게 복사하는 리해싱(rehashing) 작업이 일어난다.
Probing의 방법에는 선형 탐사(Linear probing), 제곱 탐사(Quadratic probing), 이중해싱(double hashing)이 있다.
선형 탐사 (linear probing) 은 특정 위치가 선점되어 있으면 바로 그다음 위치를 확인하는 식이다. 다시 말해 가장 가까운 다음 빈 위치를 찾아 삽입하는 것이다. 위 그림에서도 윤아 다음에 서현의 해시값이 2로 충돌이 발생했고, 다음번 빈 위치인 3에 서현이 들어가게 된다.
하지만 선형탐사는 해시 테이블에 데이터들이 고르게 분포되지 않고 뭉친다는 단점이 있다. 해시 테이블 여기저기에 연속된 데이터 그룹이 생기는 것은 클러스터링(clustering)이라고 하는데, 이 그룹이 점점 커지면 다른 그룹과 서로 합쳐지는 primary clustering문제가 발생한다. 그러면 테이블에 특정 위치에 데이터가 몰리게 되고, 이러한 클러스터링 현상은 데이터 탐색 시간을 오래 걸리게 한다.
제곱 탐사(Quadratic probing) 는 고정 폭으로 이동하는 선형 탐사와 달리 그 폭이 제곱수로 늘어난다. 충돌이 일어나면 $1^{2}$칸을 옮기고, 여기에서도 충돌이 일어나면 이번엔 $2^{2}$칸, 그 다음엔 $3^{2}$칸 옮기는 방식이다.
하지만 제곱탐사는 여러 개의 서로 다른 키들이 동일한 초기 해시값을 가지면 다음 탐사 위치 또한 동일하기 때문에 효율성이 떨어진다. (secondary clustering 문제)
이중해싱(double hashing) 은 탐사의 규칙성을 없애버려서 clustering을 방지하는 기법이다. 2개의 해시함수를 준비해서 하나는 최초의 해시값을 얻을 때, 또 다른 하나는 해시충돌이 일어났을 때 탐사 이동폭을 얻기 위해 사용한다. 이렇게 되면 최초 해시값이 같더라도 탐사 이동폭이 달라지고, 탐사 이동폭이 같더라도 최초 해시값이 달라져 primary, secondary clustering을 모두 완화할 수 있다고 한다.
- Insertion & Deletion & Search
삽입, 삭제, 검색 모두 대상이 되는 Hash를 찾아가는 과정에 따라 시간복잡도가 계산이 된다. 해시함수를 통해 얻은 Hash가 비어있지 않으면 다음 버킷을 찾아가야 한다. 이 찾아가는 횟수가 많아지면 많아질 수록 시간복잡도가 증가한다. 최상의 경우 O(1) ~ 최악의 경우 (O(n))
4. 해시 테이블 시간 복잡도
해시 테이블은 탐색, 삽입, 삭제가 이루어진다.
Insertion(삽입): 최소 O(1), 최대 O(N)
저장의 시간복잡도는 O(1)이다. 키를 해시함수에 넣어 나온 해시값에 해당하는 저장소에 넣으면 되기 때문이다. 이때 해시함수의 시간복잡도는 함께 고려하지 않는다. 하지만, 최악의 경우 해시 충돌로 인해 모든 bucket의 value들을 찾아봐야 하는 경우도 있기 때문이 O(n)이 될 수 있다.
Deletion(삭제): 최소 O(1), 최대 O(N)
저장되어 있는 값을 삭제할 때는 저장소에서 해당 key와 매칭되는 값(value)를 찾아서 삭제하면 된다. 삭제의 시간복잡도는 O(1)이다. 키(key)의 해시값(value)을 찾아서 삭제하면 되기 때문이다. 이때 해시함수의 시간복잡도는 함께 고려하지 않는다. 하지만, 최악의 경우 해시 충돌로 인해 모든 bucket의 value들을 찾아봐야 하는 경우도 있기 때문에 O(n)이 될 수 있다.
Search(검색): 최소 O(1), 최대 O(N)
키(key)로 값(value)를 찾아내는 과정은 Deletion 과정과 비슷한다. 키로 해시값(value)를 찾는다. 검색의 시간복잡도는 O(1)이다. 키(key)의 해시값을 구하고 그 해시값과 동일한 것을 찾으면 된다. 이때 해시함수의 시간복잡도는 함께 고려하지 않는다. 하지만, 최악의 경우 해시 충돌로 인해 모든 bucket의 value들을 찾아봐야 하는 경우도 있기 때문에 O(n)이 될 수 있다.
5. 해시 테이블의 활용
해싱은 키를 가지고 빠르게 value에 접근하고 조작할 수 있는 장점이 있어서 최적의 검색이 필요한 분야에 사용된다. 이외에도 해시 함수는 손실 압축, 무작위화 함수, 암호 등과 관련이 깊다.
6. Hash Table with Python
파이썬에는 딕셔너리가 있어서 굳이 만들 필요는 없지만 해시 테이블의 연산들을 다음과 같이 만들 수 있다.
먼저 체인법으로 구현한 해시 테이블이다.
import hashlib
"""해시를 구성하는 노드"""
class Node:
def __init__(self, key, value, next):
self.key = key # 키
self.value = value # 값
self.next = next # 뒤쪽 노드를 참조
"""체인법 해시 클래스 구현"""
class ChainedHash:
def __init__(self, capacity):
self.capacity = capacity # 해시 테이블의 크기를 지정
self.table = [None] * self.capacity # 해시 테이블(리스트)을 선언
# 해시값 구하기
def hash_value(self, key):
# 나머지로 해시 값 구하기
if isinstance(key, int):
return key % self.capacity
return(int(hashlib.sha256(str(key).encode()).hexdigest(), 16) % self.capacity)
# 키가 key인 원소를 검색하여 값을 반환
def search(self, key):
hash = self.hash_value(key) # 검색하는 키의 해시값 구하기
p = self.table[hash] # 해당 해시값의 위치 반환
while p is not None:
if p.key == key:
return p.value # 검색 성공
p = p.next # 뒤쪽 노드를 주목
return None # 검색 실패
# 키가 key이고 값이 value인 원소를 삽입
def add(self, key, value):
hash = self.hash_value(key) # 삽입하는 키의 해시값
p = self.table[hash] # 주목하는 노드
while p is not None:
if p.key == key:
return False # 삽입 실패
p = p.next # 뒤쪽 노드에 주목
temp = Node(key, value, self.table[hash])
self.table[hash] = temp # 노드를 삽입
return True # 삽입 성공
# 키가 key인 원소를 삭제
def remove(self, key):
hash = self.hash_value(key) # 삭제할 키의 해시값
p = self.table[hash] # 주목하고 있는 노드
pp = None # 바로 앞 주목 노드
while p is not None:
if p.key == key: # key를 발견하면 아래를 실행
if pp is None:
self.table[hash] = p.next
else:
pp.next = p.next
return True # key 삭제 성공
pp = p
p = p.next # 뒤쪽 노드에 주목
return False # 삭제 실패(key가 존재하지 않음)
# 해시 테이블 프린트
def dump(self) -> None:
for i in range(self.capacity):
p = self.table[i]
print(i, end='')
while p is not None:
print(f' → {p.key} ({p.value})', end='') # 해시 테이블에 있는 키와 값을 출력
p = p.next
print()
아래는 오픈 주소법으로 구현한 해시 테이블이다.
from enum import Enum
import hashlib
# 버킷의 속성
class Status(Enum):
OCCUPIED = 0 # 데이터를 저장
EMPTY = 1 # 비어 있음
DELETED = 2 # 삭제 완료
"""해시를 구성하는 버킷"""
class Bucket:
def __init__(self, key, value, stat):
# 초기화
self.key = key # 키
self.value = value # 값
self.stat = stat # 속성
# 모든 필드에 값을 설정
def set(self, key, value, stat):
self.key = key # 키
self.value = value # 값
self.stat = stat # 속성
def set_status(self, stat):
self.stat = stat
"""오픈 주소법을 구현하는 해시 클래스"""
class OpenHash:
def __init__(self, capacity):
self.capacity = capacity # 해시 테이블의 크기를 지정
self.table = [Bucket()] * self.capacity # 해시 테이블
# 해시값 구하기
def hash_value(self, key):
if isinstance(key, int):
return key % self.capacity
return(int(hashlib.md5(str(key).encode()).hexdigest(), 16)
% self.capacity)
# 재해시값 구하기
def rehash_value(self, key):
return(self.hash_value(key) + 1) % self.capacity
# 키가 key인 버킷을 검색
def search_node(self, key):
hash = self.hash_value(key) # 검색하는 키의 해시값
p = self.table[hash] # 버킷을 주목
for i in range(self.capacity):
if p.stat == Status.EMPTY:
break
elif p.stat == Status.OCCUPIED and p.key == key:
return p
hash = self.rehash_value(hash) # 재해시
p = self.table[hash]
return None
# 키가 key인 갖는 원소를 검색하여 값을 반환
def search(self, key) :
p = self.search_node(key)
if p is not None:
return p.value # 검색 성공
else:
return None # 검색 실패
# 키가 key이고 값이 value인 요소를 추가
def add(self, key, value):
if self.search(key) is not None:
return False # 이미 등록된 키
hash = self.hash_value(key) # 추가하는 키의 해시값
p = self.table[hash] # 버킷을 주목
for i in range(self.capacity):
if p.stat == Status.EMPTY or p.stat == Status.DELETED:
self.table[hash] = Bucket(key, value, Status.OCCUPIED)
return True
hash = self.rehash_value(hash) # 재해시
p = self.table[hash]
return False # 해시 테이블이 가득 참
# 키가 key인 갖는 요소를 삭제
def remove(self, key):
p = self.search_node(key) # 버킷을 주목
if p is None:
return False # 이 키는 등록되어 있지 않음
p.set_status(Status.DELETED)
return True
# 해시 테이블을 덤프
def dump(self):
for i in range(self.capacity):
print(f'{i:2} ', end='')
if self.table[i].stat == Status.OCCUPIED:
print(f'{self.table[i].key} ({self.table[i].value})')
elif self.table[i].stat == Status.EMPTY:
print('-- 미등록 --')
elif self.table[i] .stat == Status.DELETED:
print('-- 삭제 완료 --')