지식
Algorithm
해시 테이블 (Hash Table)
키-값 쌍을 O(1) 평균으로 저장·조회하는 해시 테이블의 구조, 충돌 해결 전략, Python dict 내부 동작까지 알아봅니다.
지난 글에서 정렬 데이터를 평균 O(log n)에 처리하는 스킵 리스트를 살펴봤습니다. 이번에는 더 빠른 O(1) 평균 탐색·삽입·삭제를 제공하는 **해시 테이블(Hash Table)**을 다룹니다. Python의 dict와 set, Java의 HashMap, JavaScript의 객체 리터럴 등이 모두 해시 테이블 기반입니다.
해시 테이블의 구조
해시 테이블은 **해시 함수(hash function)**로 키를 정수 인덱스로 변환해 배열의 해당 위치(버킷)에 값을 저장합니다.
인덱스 = hash(key) % capacity
이상적인 경우 모든 연산이 O(1)이지만, **충돌(collision)**이 발생하면 성능이 저하됩니다.
로드 팩터 (Load Factor)
로드 팩터 α = 저장된 원소 수 n / 배열 용량 capacity
α가 높을수록 충돌이 잦아져 성능이 저하됩니다. 일반적으로 α < 0.75를 유지하며, 초과 시 rehashing(용량 2배 확장 + 전체 재삽입)을 수행합니다.
충돌 해결 전략
1. 체이닝 (Chaining)
같은 버킷의 원소들을 연결 리스트로 연결합니다.
class HashTableChaining:
def __init__(self, capacity=16):
self._cap = capacity
self._buckets = [[] for _ in range(capacity)]
self._size = 0
def _index(self, key):
return hash(key) % self._cap
def put(self, key, val):
idx = self._index(key)
for i, (k, v) in enumerate(self._buckets[idx]):
if k == key:
self._buckets[idx][i] = (key, val)
return
self._buckets[idx].append((key, val))
self._size += 1
if self._size / self._cap > 0.75:
self._resize()
def get(self, key):
for k, v in self._buckets[self._index(key)]:
if k == key:
return v
raise KeyError(key)
def _resize(self):
old_buckets = self._buckets
self._cap *= 2
self._buckets = [[] for _ in range(self._cap)]
self._size = 0
for bucket in old_buckets:
for k, v in bucket:
self.put(k, v)
2. 개방 주소법 (Open Addressing)
충돌 시 다른 빈 슬롯을 탐사합니다.
class HashTableOpenAddressing:
DELETED = object() # tombstone
def __init__(self, capacity=16):
self._cap = capacity
self._slots = [None] * capacity
self._size = 0
def _probe(self, key, i):
"""선형 탐사: (h + i) % cap"""
return (hash(key) + i) % self._cap
def put(self, key, val):
for i in range(self._cap):
idx = self._probe(key, i)
slot = self._slots[idx]
if slot is None or slot is self.DELETED:
self._slots[idx] = (key, val)
self._size += 1
return
if slot[0] == key:
self._slots[idx] = (key, val)
return
raise OverflowError("해시 테이블이 가득 찼습니다")
def get(self, key):
for i in range(self._cap):
idx = self._probe(key, i)
slot = self._slots[idx]
if slot is None:
raise KeyError(key)
if slot is not self.DELETED and slot[0] == key:
return slot[1]
raise KeyError(key)
def delete(self, key):
for i in range(self._cap):
idx = self._probe(key, i)
slot = self._slots[idx]
if slot is None:
raise KeyError(key)
if slot is not self.DELETED and slot[0] == key:
self._slots[idx] = self.DELETED # tombstone
self._size -= 1
return
raise KeyError(key)
Python dict의 내부 동작
Python 3.6+ dict는 개방 주소법 기반 해시 테이블입니다.
- 초기 capacity = 8
- 로드 팩터 임계값 = 2/3 (약 0.67)
- 삽입 순서 보장 (Python 3.7+)
# dict의 해시 테이블 특성 확인
d = {}
for i in range(100):
d[f"key_{i}"] = i
# 실제 크기는 가장 가까운 2의 제곱수
import sys
print(sys.getsizeof(d)) # 버킷 배열 크기 포함
# 해시 충돌 확인 (정수는 자기 자신이 해시값)
print(hash("hello")) # 고정되지 않음 (Python은 PYTHONHASHSEED 사용)
print(hash(42)) # 42
print(hash(42.0)) # 42 (42 == 42.0 이므로)
복잡도 정리
| 연산 | 평균 | 최악 |
|---|---|---|
| 조회 get | O(1) | O(n) |
| 삽입 put | O(1) amortized | O(n) |
| 삭제 delete | O(1) | O(n) |
| 공간 | O(n) | O(n) |
최악 O(n)은 모든 키가 동일한 버킷으로 해시되는 경우지만, 좋은 해시 함수 사용 시 실용적으로 무시 가능합니다.
실전 팁
# 빈도 세기 — Counter 활용
from collections import Counter
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
freq = Counter(words)
print(freq.most_common(2)) # [('apple', 3), ('banana', 2)]
# defaultdict — 키 없을 때 기본값 자동 생성
from collections import defaultdict
graph = defaultdict(list)
graph[1].append(2)
graph[1].append(3)
print(dict(graph)) # {1: [2, 3]}
# 두 리스트의 교집합
a, b = [1, 2, 3, 4], [3, 4, 5, 6]
common = set(a) & set(b) # O(min(len(a), len(b)))
정리
- 해시 테이블은 해시 함수로 키를 인덱스로 변환해 평균 O(1) 접근을 제공합니다.
- 로드 팩터 α < 0.75를 유지하는 것이 성능의 핵심입니다.
- 체이닝은 로드 팩터 1 이상도 허용하고, 개방 주소법은 캐시 친화적입니다.
- Python
dict는 개방 주소법 기반으로 삽입 순서를 보장합니다.
지난 글: 스킵 리스트
다음 글: 해시 함수
읽어주셔서 감사합니다. 😊