"충돌은 막아야 할 버그가 아니라 관리해야 할 확실성이야. 가능한 키가 슬롯보다 많으니 두 키가 같은 자리에 떨어지는 건 반드시 일어나. 남는 질문은 하나뿐이야. 그때 뭘 할 거냐."
충돌은 보장돼
테이블의 슬롯 수는 정해져 있는데 가능한 키의 우주는 사실상 무한해. 비둘기집 원리에 따라 서로 다른 두 키가 언젠가는 같은 슬롯으로 해시될 수밖에 없지. 그래서 해시맵 설계의 목표는 충돌을 피하는 게 아니야. 그건 불가능하니까. 충돌을 깔끔하게 풀어내는 거지. 고전적인 전략이 둘 있는데, 서로 정반대 거래를 해.
체이닝: 각 슬롯에 작은 리스트
분리 체이닝(separate chaining)은 슬롯마다 거기로 해시된 키를 담는 작은 리스트, 즉 "체인"을 두는 방식이야. 넣을 때는 슬롯의 리스트에 append하고, 찾을 때는 슬롯으로 해시한 뒤 그 짧은 리스트를 훑어. 리스트가 짧게 유지되는 한, 그러니까 해시가 괜찮고 부하가 낮은 한 조회는 O(1)이야. 체이닝은 구조가 단순하고, 부하가 높아져도 완만하게 나빠지고(체인이 조금 길어질 뿐이지), 해시 함수가 그저 그래도 잘 버텨. 대신 리스트와 포인터에 메모리가 더 들고, 노드가 흩어져 있어서 캐시에 불리해.
개방 주소법: 모두가 배열 안에 산다
개방 주소법(open addressing)은 모든 항목을 테이블 배열에 직접 저장해. 곁다리 리스트가 없지. 충돌이 나면 정해진 규칙대로 다음 빈 슬롯을 탐사해. 선형 탐사는 slot+1, slot+2를 차례로 확인하고, 제곱 탐사는 점점 커지는 간격으로 건너뛰고, 이중 해싱은 두 번째 해시로 보폭을 정해. 조회도 똑같은 탐사 순서를 따라가다가 키를 찾거나 빈 슬롯을 만나면 멈춰. 전부 한 배열에 붙어 있고 포인터를 쫓을 일이 없으니 캐시에 유리하고 메모리도 덜 먹어. 대신 테이블이 차오르면 가파르게 나빠져. 탐사 순서가 길어지는데 이걸 클러스터링이라고 불러. 삭제도 까다로워. 슬롯을 그냥 비워버리면 탐사 사슬이 끊기니까 "tombstone"으로 표시해 둬야 해.
Python은 실제로 뭘 쓸까
CPython의 현재 dict는 개방 주소법 계열의 compact한 테이블을 써. 충돌이 나면 정해진 탐사 순서로 다른 자리를 확인하고. 다만 이건 Python이라는 언어 전체의 유일한 구현 계약이 아니라 CPython의 구현 세부사항이라는 걸 짚고 가자. 반면 dict의 삽입 순서 보존은 Python 3.7부터 언어 차원의 보장이야. 그러니까 이 동작은 구현 설명과 확실히 구분해서 기억해 둬.