cv.cm

코딩 · 자료 구조

해시 테이블이란? 동작 원리, 코드, 시간 복잡도

7분 분량

핵심 답변

해시 테이블은 키-값 쌍을 버킷 배열에 저장합니다. 해시 함수가 키를 숫자로 바꾸고, 그 숫자를 배열 크기로 나눈 나머지가 버킷을 정하므로 조회·삽입·삭제가 평균 O(1)입니다. 두 키가 같은 버킷에 들어가는 충돌이 일어나면 버킷마다 작은 리스트를 두거나(체이닝) 비어 있는 다른 슬롯을 찾아 나섭니다(개방 주소법). 파이썬의 dict와 set이 해시 테이블입니다.

해시 생성 열기

해시 테이블이 해결하는 문제

사용자 레코드 100만 건을 저장해 두고 이메일로 하나를 찾아야 한다고 해 봅시다. 리스트를 훑으면 O(n)입니다. 리스트를 정렬해 두면 이진 탐색으로 O(log n)에 찾을 수 있지만 삽입이 느려집니다. 해시 테이블은 레코드가 몇 개든 평균 O(1)에 찾으며, 삽입과 삭제도 똑같이 빠릅니다.

그래서 해시 테이블은 어디에나 있습니다. 프로그래밍 언어의 딕셔너리와 집합, 데이터베이스 해시 인덱스, 캐시, 컴파일러의 심볼 테이블, 중복 제거 등에 쓰입니다.

쉬운 말로 설명하는 원리

번호가 붙은 갈고리 100개가 있는 보관소를 상상해 보세요. 모든 갈고리를 뒤져 내 코트를 찾는 대신, 직원이 이름으로 갈고리 번호를 계산해 곧장 그곳으로 갑니다. 이 번호를 계산하는 함수가 해시 함수입니다. 좋은 해시 함수는 빠르고, 같은 키에는 항상 같은 번호를 주며, 서로 다른 키를 갈고리 전체에 고르게 퍼뜨립니다.

  1. h = hash(key)를 계산합니다.
  2. 버킷을 고릅니다: index = h % capacity.
  3. 그 버킷 안에서만 키를 찾습니다.

예제: 체이닝으로 충돌 처리하기

버킷이 4개인 테이블과 간단한 장난감 해시 함수(글자 순서 번호의 합, a = 1, b = 2, ...)를 사용합니다.

키해시 (글자 합)버킷 (해시 % 4)삽입 후 버킷 내용
cat3 + 1 + 20 = 240버킷 0: [cat]
dog4 + 15 + 7 = 262버킷 2: [dog]
act1 + 3 + 20 = 240버킷 0: [cat, act]
bee2 + 5 + 5 = 120버킷 0: [cat, act, bee]

act와 bee는 cat과 충돌합니다. 이제 bee를 조회하려면 버킷 0의 항목 세 개를 확인해야 합니다. 해시 함수가 나쁘거나(글자 합은 순서를 무시합니다) 버킷이 너무 적으면 체인이 길어져 테이블이 느려집니다. 실제 해시 함수는 키의 모든 비트를 섞고, 실제 테이블은 너무 차면 크기를 키웁니다.

체이닝을 사용한 파이썬 구현

각 버킷은 [key, value] 쌍의 리스트입니다. 항목 수가 버킷 수의 0.75배(로드 팩터)를 넘으면 테이블은 용량을 두 배로 늘리고 모든 항목을 다시 삽입합니다.

class HashTable:
    def __init__(self, capacity=8):
        self.buckets = [[] for _ in range(capacity)]
        self.size = 0
    def _bucket(self, key):
        return self.buckets[hash(key) % len(self.buckets)]
    def put(self, key, value):
        bucket = self._bucket(key)
        for pair in bucket:
            if pair[0] == key:
                pair[1] = value        # update existing key
                return
        bucket.append([key, value])
        self.size += 1
        if self.size > 0.75 * len(self.buckets):
            self._resize()
    def get(self, key, default=None):
        for k, v in self._bucket(key):
            if k == key:
                return v
        return default
    def delete(self, key):
        bucket = self._bucket(key)
        for i, (k, _) in enumerate(bucket):
            if k == key:
                bucket.pop(i)
                self.size -= 1
                return True
        return False
    def _resize(self):
        old = [pair for bucket in self.buckets for pair in bucket]
        self.buckets = [[] for _ in range(2 * len(self.buckets))]
        self.size = 0
        for k, v in old:
            self.put(k, v)
t = HashTable()
for i, word in enumerate(["red", "green", "blue", "cyan", "pink", "gold", "gray"]):
    t.put(word, i)
t.put("red", 100)
t.delete("blue")
print(t.get("red"), t.get("blue"), t.size, len(t.buckets))  # 100 None 6 16

충돌 해결 전략 비교

전략동작 방식장점단점
분리 체이닝각 버킷이 항목의 리스트(또는 트리)를 가짐단순함, 삭제가 쉬움, 높은 로드에도 잘 버팀항목마다 추가 메모리, 포인터 추적
선형 탐사충돌하면 다음 슬롯, 그다음 슬롯을 차례로 시도캐시 친화적, 컴팩트함클러스터가 생김, 삭제에 툼스톤 필요
이차 탐사 / 이중 해싱점점 커지는 간격 또는 두 번째 해시 값만큼 건너뜀선형 탐사보다 클러스터가 적음더 복잡함, 여전히 툼스톤 필요

CPython의 dict는 섭동(perturbation)을 적용한 탐사 순서의 개방 주소법을 쓰며, 파이썬 3.7부터는 순회가 삽입 순서를 따르도록 보장합니다. Java의 HashMap은 체이닝을 쓰고 긴 체인을 균형 트리로 바꿉니다.

시간 복잡도와 공간 복잡도

연산평균최악이유
조회O(1)O(n)최악의 경우 모든 키가 한 버킷에 충돌함
삽입분할 상환 O(1)O(n)가끔 리사이즈하며 모든 항목을 다시 삽입함
삭제O(1)O(n)조회와 같음
순서대로 순회(키 정렬)O(n log n)O(n log n)해시 테이블은 정렬 순서를 유지하지 않으므로 직접 정렬해야 함
공간O(n)O(n)버킷 배열은 n보다 상수 배 크게 유지됨

평균 O(1)은 좋은 해시 함수와 제한된 로드 팩터에 달려 있습니다. 파이썬은 프로세스마다 문자열 해시를 무작위화(해시 시딩)하므로, 공격자가 충돌하는 키를 대량으로 보내 서버를 요청당 O(n)으로 느리게 만들기 어렵습니다.

흔한 실수

  • 가변 객체를 키로 쓰거나, 클래스에 __hash__ 없이 __eq__만 정의하는 실수. 같은 객체는 해시도 같아야 합니다.
  • 정렬된 순서를 기대하는 실수. 해시 테이블은 빠른 조회를 줄 뿐 순서는 주지 않습니다. 범위 질의나 최솟값이 필요하면 정렬된 리스트나 균형 트리(이진 탐색 트리)를 쓰세요.
  • 실행 간에 hash() 값이 같다고 믿는 실수: PYTHONHASHSEED를 고정하지 않으면 문자열 해시는 파이썬 프로세스마다 달라집니다. 안정적인 해시가 필요하면 hashlib를 쓰세요.
  • 해시 테이블을 보안 도구로 착각하는 실수. 해시 테이블은 빠른 비암호학적 해시를 사용합니다. 비밀번호에는 전용 비밀번호 해싱 함수가 필요합니다.

언제 쓰는가와 면접 단골 활용

"이거 본 적 있나?" 또는 "이 키의 값은 무엇인가?"에 빠르게 답해야 할 때마다 해시 테이블을 쓰세요. 순서, 범위, 가장 가까운 키도 필요하다면 이진 탐색 트리나 정렬된 배열을 쓰세요.

대표 문제: 투 섬(각 숫자의 인덱스를 저장하고 target - x를 조회), 빈도 세기(collections.Counter), 정렬한 글자로 애너그램 묶기, 중복 탐지, 동적 계획법의 메모이제이션이 있습니다.

def two_sum(nums, target):
    seen = {}                      # value -> index
    for i, x in enumerate(nums):
        if target - x in seen:
            return seen[target - x], i
        seen[x] = i
    return None
print(two_sum([2, 7, 11, 15], 9))  # (0, 1)

자주 묻는 질문

해시 테이블 조회는 왜 O(1)인가요?

해시와 버킷 인덱스를 계산하는 데는 상수 시간이 걸리고, 좋은 해시 함수와 제한된 로드 팩터가 있으면 각 버킷에는 평균적으로 몇 개의 항목만 들어 있어서 버킷 안의 검색도 상수 시간이기 때문입니다.

로드 팩터란 무엇인가요?

저장된 항목 수를 버킷 수로 나눈 값입니다. 이 값이 임계값(보통 0.5~0.75)을 넘으면 테이블은 더 큰 버킷 배열을 할당하고 모든 항목을 다시 삽입합니다.

파이썬의 dict는 해시 테이블인가요?

네. dict와 set은 개방 주소법으로 C로 구현된 해시 테이블입니다. 파이썬 3.7부터 dict는 삽입 순서도 유지합니다.

해시 테이블과 해시 맵은 어떻게 다른가요?

대부분의 맥락에서 같은 뜻으로, 해싱에 기반한 키-값 구조를 가리킵니다. 일부 언어에서는 Java의 HashMap과 오래된 동기화 클래스 Hashtable처럼 특정 클래스의 이름으로 쓰기도 합니다.

해시 테이블에 중복 키를 둘 수 있나요?

아니요. 이미 있는 키를 삽입하면 값이 교체됩니다. 키 하나에 여러 값을 저장하려면 값을 리스트로 만들거나 collections.defaultdict(list)를 사용하세요.

코딩 튜토리얼 전체