Collision Resolutions
해시 테이블(Hash Table)은 키(Key)를 해시 함수(Hash Function)에 적용하여 특정 인덱스(Index)에 데이터를 저장하는 자료구조이다.
그러나 서로 다른 키가 같은 해시 인덱스로 매핑되는 경우가 발생할 수 있으며, 이를 충돌(Collision) 이라고 한다.
해시 함수는 임의 크기의 데이터를 고정된 크기의 값으로 매핑한다. 해시 테이블의 크기가 제한되어 있기 때문에, 서로 다른 키들이 같은 해시 값(버킷 인덱스)을 가지는 ‘충돌’이 불가피하게 발생한다. 이는 ‘비둘기집 원리(Pigeonhole Principle)‘에 의해 증명된다 - 가능한 키의 수가 해시 테이블의 크기보다 크면 충돌은 반드시 발생한다.
해시 함수를 hash(key) % 5
로 정의하고, 아래 데이터를 저장한다고 가정하자.
Key1과 Key2가 동일한 인덱스(2)에 저장되려 하면 충돌이 발생한다.
해시 테이블의 충돌 해결 방법은 다양한 상황과 요구사항에 따라 선택할 수 있다. 각 방법은 고유한 장단점을 가지고 있으며, 구현의 복잡성, 메모리 사용량, 성능 특성이 다르다. 응용 프로그램의 특성과 데이터 패턴, 그리고 성능 요구사항을 고려하여 적절한 충돌 해결 방법을 선택하는 것이 중요하다.
최근 연구에서는 여러 기법을 결합한 하이브리드 접근 방식도 등장하고 있으며, 머신 러닝을 활용하여 데이터 패턴에 맞게 해시 함수를 최적화하는 방법도 연구되고 있다.
Collision Resolution (충돌 해결 방법) 개요
충돌이 발생할 경우, 데이터를 올바르게 저장하고 검색할 수 있도록 해결하는 방법이 필요하다.
해시 테이블에서 충돌을 해결하는 방법에는 “체이닝(Chaining)” 과 “오픈 어드레싱(Open Addressing)” 기법이 대표적이다.
충돌 해결 방법
해시 충돌을 해결하기 위한 다양한 기법들을 크게 두 가지 범주로 나눌 수 있다:
- 체이닝(Chaining)
- 개방 주소법(Open Addressing)
체이닝(Chaining)
체이닝은 각 해시 버킷에 연결 리스트(linked list)나 다른 자료구조를 사용하여 충돌이 발생한 항목들을 저장하는 방법이다.
작동 방식:
- 키의 해시 값을 계산한다.
- 계산된 해시 값(인덱스)에 해당하는 버킷에 키-값 쌍을 저장한다.
- 충돌이 발생하면, 같은 버킷에 있는 연결 리스트에 새 항목을 추가한다.
구현 예제 (Python):
|
|
체이닝의 변형:
- 단순 연결 리스트(Singly Linked List): 가장 기본적인 구현 방식.
- 이중 연결 리스트(Doubly Linked List): 양방향 탐색이 가능.
- 동적 배열(Dynamic Array): 메모리 지역성이 좋아 캐시 효율이 높다.
- 균형 이진 트리(Balanced Binary Tree): 최악의 경우 검색 시간을 O(log n)으로 보장.
- 자체 균형 이진 탐색 트리(Self-balancing BST): AVL 트리나 레드-블랙 트리를 사용.
장점:
- 구현이 간단.
- 해시 테이블이 절대 가득 차지 않는다(연결 리스트가 계속 확장될 수 있으므로).
- 삭제 연산이 비교적 간단하다.
단점:
- 각 노드마다 포인터를 저장해야 하므로 추가 메모리가 필요하다.
- 연결 리스트가 길어지면 검색 시간이 O(n)까지 증가할 수 있다.
- 캐시 지역성(cache locality)이 좋지 않을 수 있다.
개방 주소법(Open Addressing)
개방 주소법은 모든 키-값 쌍을 해시 테이블 배열 자체에 직접 저장하는 방법. 충돌이 발생하면 다른 빈 슬롯을 찾아 데이터를 저장하다.
주요 탐사(Probing) 방법:
선형 탐사(Linear Probing)
해시 충돌이 발생하면 순차적으로 다음 슬롯을 확인하여 빈 공간을 찾는다.
|
|
장점:
- 캐시 지역성이 좋아 메모리 효율이 높다.
- 체이닝보다 메모리 오버헤드가 적다.
단점:
- ‘클러스터링(clustering)’ 현상: 연속된 슬롯이 채워져 충돌 가능성 증가
- 해시 테이블 로드 팩터(load factor)가 증가하면 성능이 급격히 저하된다.
제곱 탐사(Quadratic Probing)
선형 탐사의 클러스터링 문제를 완화하기 위해, 충돌 발생 시 1², 2², 3², … 간격으로 다음 위치를 탐색한다.
|
|
장점:
- 선형 탐사보다 클러스터링이 적게 발생.
단점:
- 여전히 2차 클러스터링(secondary clustering) 문제가 있다.
- 테이블이 충분히 크지 않으면 모든 슬롯을 탐색하지 못할 수 있다.
이중 해싱(Double Hashing)
충돌 발생 시 두 번째 해시 함수를 사용하여 다음 위치를 결정하는 방법.
|
|
장점:
- 클러스터링 문제를 크게 줄일 수 있다.
- 충돌이 많이 발생하는 경우 더 효율적.
단점:
- 두 번째 해시 함수 계산으로 인한 추가 비용이 발생.
- 두 번째 해시 함수는 0을 반환하지 않아야 하며, 테이블 크기와 서로소여야 한다.
로빈 후드 해싱(Robin Hood Hashing)
개방 주소법의 변형으로, ‘부자에게서 빼앗아 가난한 자에게 주는’ 원칙에 기반한다. 각 키의 ‘탐색 거리(probe distance)‘를 기록하고, 새 항목이 기존 항목보다 탐색 거리가 더 길면 자리를 바꾼다.
|
|
장점:
- 최악의 경우 검색 시간이 개선.
- 클러스터링을 효과적으로 관리.
단점:
- 구현이 더 복잡.
- 삭제 연산이 까다로울 수 있다.
쿠쿠 해싱(Cuckoo Hashing)
두 개 이상의 해시 함수를 사용하며, 충돌 시 기존 항목을 다른 위치로 강제 이동시키는 방식.
|
|
장점:
- 최악의 경우 조회 시간이 O(1).
- 높은 로드 팩터에서도 좋은 성능을 유지.
단점:
- 재해싱이 필요할 수 있으며, 이는 비용이 큰 연산.
- 해시 함수의 선택이 중요.
- 로드 팩터는 일반적으로 50% 이하로 유지해야 한다.
점프 해싱(Hopscotch Hashing)
각 키가 자신의 ‘이웃 영역(neighborhood)’ 내에 위치하도록 보장하는 방법. 충돌이 발생하면 이웃 영역 내에서 자리를 바꿔가며 공간을 확보한다.
장점:
- 높은 로드 팩터에서도 좋은 성능을 유지.
- 캐시 지역성이 좋다.
단점:
- 구현이 복잡.
- 이웃 영역의 크기 선택이 중요.
확장성 관리
해시 테이블이 너무 가득 차면 성능이 저하된다. 이를 관리하기 위한 방법으로는:
재해싱(Rehashing) 로드 팩터가 특정 임계값을 초과하면 더 큰 테이블을 생성하고 모든 항목을 재삽입한다.
점진적 재해싱(Incremental Rehashing) 전체 테이블을 한 번에 재해싱하는 대신, 작은 단위로 나누어 시간을 분산시킨다. Redis와 같은 시스템에서 사용.
해시 함수의 선택
좋은 해시 함수는 충돌을 최소화하기 위해 중요하다.
좋은 해시 함수의 특성:
- 균등 분포(Uniform Distribution): 키를 해시 테이블 전체에 고르게 분산시킨다.
- 계산 효율성: 빠르게 계산할 수 있어야 한다.
- 결정론적(Deterministic): 같은 키는 항상 같은 해시 값을 가져야 한다.
- 충돌 최소화: 서로 다른 키가 같은 해시 값을 가질 확률을 최소화해야 한다.
널리 사용되는 해시 함수로는:
- 문자열을 위한 djb2, fnv1a
- 일반 데이터를 위한 MurmurHash, xxHash
- 암호화 관련 용도의 SHA-256, Blake2
충돌 해결 방법 비교
해결 방법 | 장점 | 단점 | 적합한 상황 | 평균 성능 | 최악 성능 |
---|---|---|---|---|---|
체이닝 | 구현 간단, 삭제 쉬움, 로드 팩터 제한 없음 | 추가 메모리 필요, 캐시 지역성 낮음 | 데이터 크기가 가변적이고 예측 불가능한 경우 | O(1 + α) | O(n) |
선형 탐사 | 캐시 지역성 좋음, 메모리 효율적 | 클러스터링 발생, 삭제 복잡 | 작은 크기 해시 테이블, 캐시 성능 중요한 경우 | O(1/(1-α)) | O(n) |
제곱 탐사 | 클러스터링 감소, 구현 간단 | 2차 클러스터링 존재, 모든 슬롯 탐색 못할 수 있음 | 중간 크기 테이블, 선형 탐사보다 일관된 성능 필요한 경우 | O(1/(1-α)) | O(n) |
이중 해싱 | 클러스터링 최소화, 효율적인 공간 활용 | 두 번째 해시 계산 비용, 구현 복잡 | 대규모 해시 테이블, 충돌이 많이 예상되는 경우 | O(1/(1-α)) | O(n) |
로빈 후드 해싱 | 탐색 시간 분산 효과, 최악 케이스 개선 | 구현 복잡, 삭제 어려움 | 검색 성능의 일관성이 중요한 경우 | O(log n) | O(log n) |
쿠쿠 해싱 | 최악 조회 O(1), 삭제 간단 | 로드 팩터 제한, 재해싱 필요, 구현 복잡 | 조회 성능이 중요하고 일정한 경우 | O(1) | O(1)* |
점프 해싱 | 높은 로드 팩터에서 좋은 성능, 캐시 지역성 | 구현 매우 복잡, 튜닝 필요 | 높은 로드 팩터와 일관된 성능이 필요한 경우 | O(1) | O(1)* |
*재해싱이 필요한 경우를 제외하고
여기서 α는 로드 팩터(load factor)로, 해시 테이블에 저장된 항목 수를 테이블 크기로 나눈 값.
실제 구현 사례
- Java HashMap: 체이닝 방식을 사용하며, 버킷당 항목이 많으면 연결 리스트에서 균형 트리로 전환합니다.
- Python dict: 개방 주소법의 변형을 사용합니다.
- Redis: 점진적 재해싱을 사용한 체이닝 방식을 구현합니다.
- Google’s SwissTable/FlatMap: 로빈 후드 해싱의 변형을 사용합니다.