해시 테이블 구현 (Hash Table Implementation)
한눈에 보기
키를 해시 함수로 인덱스에 대응시켜 평균 O(1) 연산을 제공하는 해시 테이블을 직접 구현한다. 체이닝과 개방 주소법, 적재율과 재해싱, 파이썬 구현 코드를 정리한다.
최종 업데이트 2026.09.17
해시 테이블(Hash Table)은 키를 해시 함수에 통과시켜 얻은 숫자를 배열 인덱스로 삼아 값을 저장하는 자료구조다. 배열의 인덱스 접근이 O(1)이므로, 키만 알면 어디에 있는지 계산 한 번으로 찾아낼 수 있다.
파이썬의 dict와 set, 자바의 HashMap이 전부 이것이다. 코딩테스트에서는 이미 만들어진 걸 쓰면 되지만, "왜 평균 O(1)인지"와 "언제 O(n)으로 나빠지는지"를 설명하지 못하면 면접에서 막힌다.
키를 인덱스로 바꾼다
해시 함수는 임의의 키를 정해진 범위의 정수로 바꾼다. 그 값을 버킷 개수로 나눈 나머지를 취하면 배열 인덱스가 된다.
충돌을 해결하는 두 가지 방법
dict는 개방 주소법을, 자바 HashMap은 체이닝(항목이 많아지면 트리로 전환)을 쓴다.| 구분 | 체이닝 | 개방 주소법 |
|---|---|---|
| 충돌 처리 | 버킷마다 리스트를 매단다 | 다른 빈 칸을 찾아 넣는다 |
| 적재율 | 1을 넘어도 동작 | 1을 넘을 수 없다 |
| 메모리 | 포인터·노드 오버헤드 | 배열만 쓰므로 조밀하다 |
| 캐시 효율 | 노드가 흩어져 나쁘다 | 연속 배열이라 좋다 |
| 삭제 | 리스트에서 빼면 끝 | 탐색이 끊기지 않게 표시가 필요 |
| 구현 난이도 | 쉽다 | 삭제 처리가 까다롭다 |
개방 주소법에서 삭제가 까다로운 이유는 이렇다. apple(2번)과 cherry(3번으로 밀림)가 있을 때 apple을 그냥 지워 2번을 비우면, cherry를 찾을 때 2번이 비어 있는 걸 보고 "없구나" 하며 탐색을 멈춘다. 그래서 삭제된 자리라는 표식(tombstone)을 남겨 탐색이 계속되게 해야 한다.
적재율과 리사이징
적재율(load factor)은 저장된 항목 수를 버킷 수로 나눈 값이다. 이 값이 커질수록 충돌이 잦아지고 성능이 떨어진다.
그래서 적재율이 임계값(보통 0.75)을 넘으면 버킷 배열을 두 배로 늘리고 모든 항목을 다시 배치한다. 이것을 리해싱(rehashing)이라고 한다. 버킷 개수가 바뀌면 % capacity 결과도 바뀌므로 기존 위치를 그대로 옮길 수 없고 전부 다시 계산해야 한다.
리사이징 한 번은 O(n)이라 비싸지만 자주 일어나지 않는다. n번 삽입하는 동안 리사이징 총비용을 나눠보면 삽입 하나당 O(1)이 된다. 이것을 분할 상환 O(1)이라고 부른다.
시간 복잡도
| 연산 | 평균 | 최악 |
|---|---|---|
| 삽입 | O(1) | O(n) |
| 조회 | O(1) | O(n) |
| 삭제 | O(1) | O(n) |
| 공간 | O(n) | |
최악의 O(n)은 모든 키가 같은 버킷으로 몰릴 때 생긴다. 해시 함수가 나쁘거나, 공격자가 일부러 충돌하는 키를 골라 넣는 경우다. 실제로 이를 노린 해시 충돌 공격(HashDoS)이 있어서 요즘 언어들은 실행할 때마다 해시 시드를 무작위로 바꾼다.
자바 HashMap은 한 버킷의 항목이 8개를 넘으면 리스트를 균형 이진 트리로 바꿔 최악을 O(log n)으로 낮춘다.
구현 코드 예시
class HashMap:
"""체이닝 방식의 간단한 해시맵."""
def __init__(self, capacity=8):
self.capacity = capacity
self.size = 0
self.buckets = [[] for _ in range(capacity)]
def _index(self, key):
return hash(key) % self.capacity
def put(self, key, value):
bucket = self.buckets[self._index(key)]
for i, (k, _) in enumerate(bucket):
if k == key:
bucket[i] = (key, value) # 이미 있으면 값만 교체
return
bucket.append((key, value))
self.size += 1
if self.size > self.capacity * 0.75: # 적재율 초과 → 확장
self._resize()
def get(self, key, default=None):
for k, v in self.buckets[self._index(key)]:
if k == key:
return v
return default
def remove(self, key):
bucket = self.buckets[self._index(key)]
for i, (k, _) in enumerate(bucket):
if k == key:
bucket.pop(i)
self.size -= 1
return True
return False
def _resize(self):
old = self.buckets
self.capacity *= 2
self.buckets = [[] for _ in range(self.capacity)]
for bucket in old: # 전부 다시 배치해야 한다
for k, v in bucket:
self.buckets[hash(k) % self.capacity].append((k, v))
버킷 안에서 k == key로 한 번 더 비교하는 것에 주목하자. 해시값이 같아도 키가 다를 수 있으므로, 해시 일치는 후보를 좁힐 뿐 최종 판정은 키 자체의 비교다.
좋은 해시 함수의 조건
- 균등 분포: 키들이 버킷 전체에 고르게 흩어져야 한다. 한쪽으로 몰리면 충돌이 폭증한다.
- 결정성: 같은 키는 언제나 같은 값을 내야 한다. 그래서 변경 가능한 객체는 키가 될 수 없다. 파이썬에서 리스트를 dict 키로 쓰면
TypeError가 나는 이유다. - 빠른 계산: 해시 계산이 느리면 O(1)의 의미가 없다.
- 눈사태 효과: 키가 조금만 달라도 해시값이 크게 달라져야 한다.
자바에서 equals()를 재정의하면 hashCode()도 반드시 함께 재정의해야 하는 이유가 여기 있다. 같다고 판정되는 두 객체가 서로 다른 해시값을 가지면 다른 버킷으로 가서 영영 못 찾는다.
자주 하는 실수
- 해시가 같으면 키도 같다고 가정한다. 버킷 안에서 실제 키 비교가 반드시 필요하다.
- 가변 객체를 키로 쓴다. 넣은 뒤 내용을 바꾸면 해시값이 달라져 찾을 수 없게 된다. 파이썬 튜플은 되지만 리스트는 안 되는 이유다.
- 리사이징 때 위치를 그대로 옮긴다. 버킷 수가 바뀌면 나머지 연산 결과가 달라진다. 전부 다시 계산해야 한다.
- 개방 주소법에서 삭제한 칸을 그냥 비운다. 뒤로 밀려 있던 항목을 못 찾게 된다. 삭제 표식이 필요하다.
equals만 재정의하고hashCode를 빼먹는다. 자바 면접 단골이자 실무 버그 단골이다.- 순서를 기대한다. 해시 테이블은 원래 순서를 보장하지 않는다. 파이썬
dict가 3.7부터 삽입 순서를 유지하는 것은 구현상의 추가 보장일 뿐 해시 테이블의 성질이 아니다.
면접에서 이렇게 물어본다
- 해시 테이블이 평균 O(1)인 이유와 최악에 O(n)이 되는 조건을 설명해 보라.
- 충돌은 왜 피할 수 없는가.
- 체이닝과 개방 주소법의 장단점을 비교해 보라.
- 적재율이란 무엇이며 리사이징은 언제 어떻게 일어나는가.
- 리사이징이 O(n)인데도 삽입이 평균 O(1)인 이유는 무엇인가.
- 좋은 해시 함수의 조건은 무엇인가.
equals와hashCode를 함께 재정의해야 하는 이유를 설명해 보라.- 가변 객체를 키로 쓰면 안 되는 이유는 무엇인가.
알고리즘에서 같이 읽기
- 이분 탐색 (Binary Search)정렬된 배열에서 탐색 범위를 절반씩 줄여 O(log n)에 값을 찾는 이분 탐색의 원리와 동작 과정을 그림으로 정리했다. lower bound와 upper bound, 코딩테스트 단골인 파라메트릭 서치와 회전 정렬 배열, 구현 코드와 자주 하는 실수까지 다룬다.
- 비트마스크 (Bitmask)정수의 각 비트로 집합의 포함 여부를 표현하는 비트마스크 기법을 다룬다. 기본 비트 연산 정리, 부분집합 열거, 외판원 순회 동적 계획법 구현과 주의점을 정리한다.
- DFS와 BFS (Depth First Search, Breadth First Search)그래프의 모든 정점을 방문하는 DFS와 BFS의 원리와 방문 순서 차이를 스택과 큐로 설명하고, O(V + E) 복잡도, 구현 코드, 문제 유형별 선택 기준을 정리한다.
- 다익스트라 알고리즘 (Dijkstra's Algorithm)음수 간선이 없는 그래프에서 한 정점으로부터의 최단 거리를 구하는 다익스트라 알고리즘의 그리디 원리, 우선순위 큐 구현, 복잡도 비교와 한계를 정리한다.
이 개념, 내 코드에서는 어떻게 물어볼까요
데브핏(DevFit)에 채용공고 URL을 붙여 넣고 레포지토리를 연결하면, 내가 쓴 커밋에서 예상 질문과 어필 포인트를 만듭니다. 하루 10회까지 무료입니다. 합불 판정이나 지원자 랭킹은 만들지 않고, 최종 판단은 사람이 합니다.
공고 넣고 시작하기