0Pricing
Coding Interview Prep · 강의

해시 함수 내부와 충돌 처리

Python이 객체를 해시하는 방식, 개방 주소법과 체이닝으로 충돌을 해결하는 방식, 평균 O(1)이 O(n)으로 악화될 수 있는 이유를 이해합니다.

해시 함수 내부와 충돌 처리은(는) CoddyKit의 무료 Coding Interview Prep 강의입니다. 이것은 4개 중 1번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Coding Interview Prep 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Coding Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.

해시 맵이란 무엇인가요?

해시 맵(파이썬에서는 사전)은 모든 키를 기반 배열의 정수 인덱스로 변환하는 해시 함수를 사용해 키를 값에 대응시킵니다. 이상적인 해시 함수는 키를 배열 전체에 고르게 분산하므로 조회, 삽입, 삭제를 평균 O(1)에 수행할 수 있습니다. 기반 배열을 해시 테이블 또는 버킷 배열이라고 합니다.

파이썬의 dict는 매우 최적화된 해시 맵입니다. 내부 구조를 이해하면 최악의 경우 동작을 추론하고 적절한 키를 선택하는 데 도움이 됩니다.

# Python dict is a hash map
hm = {}
hm['alice'] = 95
hm['bob']   = 87
hm['carol'] = 91

print(hm['alice'])          # O(1) lookup: 95
print('bob' in hm)          # O(1) membership: True
del hm['bob']               # O(1) deletion
print(hm)                   # {'alice': 95, 'carol': 91}

해시 함수와 __hash__ 메서드

파이썬은 __hash__(key)를 호출해 키에서 정수를 계산한 다음, 그 정수를 테이블 크기로 나눈 나머지를 사용해 버킷 인덱스를 찾습니다. int, str, tuple과 같은 내장 형식에는 빠른 내장 해시 구현이 있습니다. list와 dict는 해시할 수 없습니다. 변경 가능하며, 변경하면 저장된 해시가 무효화되기 때문입니다.

좋은 해시 함수는 키를 고르게 분산하고 결정적이며 계산이 빠릅니다. 파이썬의 문자열 해시는 실행마다 무작위화됩니다(보안 기능). 테스트에서 재현성을 확보하기 위해 PYTHONHASHSEED=0을 사용하면 이를 비활성화할 수 있습니다.

# Built-in hash in Python
print(hash(42))           # integer hashes to itself (CPython)
print(hash('hello'))      # string hash (randomised per run)
print(hash((1, 2, 3)))    # tuple hash: depends on contents

# Unhashable types
try:
    hash([1, 2, 3])       # lists are mutable -> not hashable
except TypeError as e:
    print('Error:', e)

# Custom class: define __hash__ and __eq__
class Point:
    def __init__(self, x, y): self.x = x; self.y = y
    def __hash__(self): return hash((self.x, self.y))
    def __eq__(self, other): return self.x == other.x and self.y == other.y

points = {Point(1, 2): 'A', Point(3, 4): 'B'}
print(points[Point(1, 2)])  # 'A'

충돌: 두 키가 같은 버킷으로 해시될 때

충돌은 서로 다른 두 키가 같은 버킷 인덱스를 만들 때 발생합니다. 충돌은 피할 수 없습니다(비둘기집 원리: 키는 무한히 많지만 버킷은 유한합니다). 대표적인 두 가지 해결 전략은 체이닝과 개방 주소 지정입니다. 파이썬은 의사 난수 탐사를 사용하는 개방 주소 지정의 변형을 사용합니다.

체이닝은 각 버킷에 연결 리스트(또는 동적 배열)를 저장합니다. 해당 버킷에서 충돌한 모든 키가 하나의 연결을 이룹니다. 개방 주소 지정은 탐사 순서에 따라 다음 빈 버킷을 찾습니다.

# Simplified chaining hash map
class ChainingHashMap:
    def __init__(self, capacity=8):
        self.capacity = capacity
        self.buckets  = [[] for _ in range(capacity)]

    def _idx(self, key):
        return hash(key) % self.capacity

    def put(self, key, val):
        bucket = self.buckets[self._idx(key)]
        for i, (k, v) in enumerate(bucket):
            if k == key:
                bucket[i] = (key, val)
                return
        bucket.append((key, val))

    def get(self, key):
        for k, v in self.buckets[self._idx(key)]:
            if k == key:
                return v
        return None

hm = ChainingHashMap()
hm.put('a', 1); hm.put('b', 2)
print(hm.get('a'))  # 1
print(hm.get('c'))  # None

개방 주소 지정: 선형 탐사

선형 탐사에서는 인덱스 i에서 충돌이 발생하면 빈 슬롯을 찾을 때까지 i+1, i+2, ...를 확인합니다(배열의 끝에 도달하면 처음으로 돌아갑니다). 조회할 때도 같은 순서로 탐사해 키를 찾아야 합니다. 삭제할 때는 탐사 연결을 끊지 않도록 슬롯을 비우는 대신 ‘삭제 표시’를 남겨야 합니다.

가장 큰 단점은 군집화입니다. 채워진 슬롯의 군집이 형성되면 해당 영역에 삽입이 일어날 때마다 군집이 확장되어 성능이 O(n)에 가까워집니다.

class LinearProbingHashMap:
    DELETED = object()  # tombstone sentinel

    def __init__(self, capacity=8):
        self.capacity = capacity
        self.keys  = [None] * capacity
        self.vals  = [None] * capacity
        self.size  = 0

    def _probe(self, key):
        idx = hash(key) % self.capacity
        while self.keys[idx] is not None and self.keys[idx] != key:
            idx = (idx + 1) % self.capacity
        return idx

    def put(self, key, val):
        idx = self._probe(key)
        if self.keys[idx] is None:
            self.size += 1
        self.keys[idx] = key
        self.vals[idx] = val

    def get(self, key):
        idx = self._probe(key)
        if self.keys[idx] == key:
            return self.vals[idx]
        return None

hm = LinearProbingHashMap()
hm.put('x', 10); hm.put('y', 20)
print(hm.get('x'))  # 10

적재율과 크기 조정

적재율은 저장된 항목 수를 전체 용량으로 나눈 비율입니다. α = n/m입니다. α가 커질수록 충돌 확률이 높아지고 성능이 저하됩니다. 파이썬의 사전은 적재율이 약 2/3를 초과하면 용량을 두 배로 늘립니다. 크기를 조정할 때는 기존의 모든 항목을 새롭고 더 큰 테이블에 다시 해시해야 합니다. 이는 O(n) 연산이지만 드물게 발생하므로 삽입 비용은 분할 상환 관점에서 O(1)을 유지합니다.

import sys

d = {}
prev_size = sys.getsizeof(d)
for i in range(30):
    d[i] = i
    new_size = sys.getsizeof(d)
    if new_size != prev_size:
        print(f'Resized at n={i+1}: {prev_size} -> {new_size} bytes')
        prev_size = new_size

평균 O(1)과 최악의 경우 O(n)

좋은 해시 함수를 사용하면 충돌이 드물고 예상 연결 길이는 n과 관계없이 일정합니다. 따라서 평균적인 경우의 조회, 삽입, 삭제는 O(1)입니다. 그러나 모든 키가 같은 버킷으로 매핑되는 의도적으로 악의적인 입력과 같은 최악의 경우에는 모든 연산이 O(n)으로 저하됩니다. 파이썬의 무작위화된 해시 시드는 이러한 공격을 완화하지만 이론적으로 최악의 경우를 없애지는 못합니다.

면접에서는 ‘충돌 때문에 평균 O(1), 최악의 경우 O(n)입니다’라고 설명하세요.

# Python randomised hash seed prevents worst-case hash-flooding
import os
print('PYTHONHASHSEED:', os.environ.get('PYTHONHASHSEED', 'random'))
# By default Python randomises the hash of strings each run
# This prevents an attacker from crafting keys that all collide
# To reproduce results in testing: PYTHONHASHSEED=0 python script.py

파이썬의 사전, 기본값 사전, 빈도 계산기 비교

파이썬에는 알아 두어야 할 세 가지 해시 맵 변형이 있습니다. dict는 범용 맵이며, 없는 키에 접근하면 KeyError가 발생합니다. defaultdict(factory)는 없는 키에 접근할 때 기본값을 반환하므로 목록을 모으거나 개수를 셀 때 유용합니다. Counter는 해시 가능한 객체의 개수를 세는 특수 목적 하위 클래스이며, 카운터끼리 산술 연산을 수행하는 기능도 지원합니다.

from collections import defaultdict, Counter

# defaultdict for grouping
groups = defaultdict(list)
for word in ['apple', 'ant', 'banana', 'bee', 'avocado']:
    groups[word[0]].append(word)
print(dict(groups))
# {'a': ['apple','ant','avocado'], 'b': ['banana','bee']}

# Counter for frequency
c = Counter('abracadabra')
print(c.most_common(3))  # [('a',5),('b',2),('r',2)]
print(c['a'] - Counter('aa')['a'])  # counter subtraction

해시 맵과 해시 집합 비교

해시 집합은 연결된 값 없이 키만 저장하며, 포함 여부 확인, 삽입, 삭제를 O(1)에 수행할 수 있습니다. 파이썬의 set이 해시 집합입니다. 연결된 데이터를 저장하지 않고 ‘이 요소가 존재하는가?’만 확인하면 집합을 사용하세요. 키에 값(개수, 결과 등)을 연결해야 한다면 사전을 사용하세요.

# set for membership testing
visited = set()
for node in [1, 3, 5, 3, 7, 1]:
    if node not in visited:
        print('New node:', node)
        visited.add(node)

# Set operations: union, intersection, difference
A = {1, 2, 3, 4}
B = {3, 4, 5, 6}
print('Union:', A | B)         # {1,2,3,4,5,6}
print('Intersection:', A & B)  # {3,4}
print('Difference:', A - B)    # {1,2}

해시 맵 처음부터 구현하기(면접용 버전)

면접관은 때때로 기본적인 해시 맵을 구현하라고 요구합니다. 핵심 구성 요소는 고정 크기 버킷 배열(16 또는 1024를 사용), 체이닝을 위한 각 버킷의 (키, 값) 쌍 목록, 해시 함수(파이썬의 내장 해시와 용량을 이용한 나머지 계산), 그리고 적재율이 0.7을 초과할 때의 크기 조정입니다. 크기 조정과 적재율을 먼저 언급하면 깊이 있는 지식을 보여 줄 수 있습니다.

class HashMap:
    def __init__(self, capacity=16):
        self.capacity = capacity
        self.size     = 0
        self.buckets  = [[] for _ in range(capacity)]

    def _hash(self, key):
        return hash(key) % self.capacity

    def put(self, key, val):
        b = self.buckets[self._hash(key)]
        for i, (k, v) in enumerate(b):
            if k == key:
                b[i] = (key, val)
                return
        b.append((key, val))
        self.size += 1
        if self.size / self.capacity > 0.7:
            self._resize()

    def get(self, key, default=None):
        for k, v in self.buckets[self._hash(key)]:
            if k == key:
                return v
        return default

    def _resize(self):
        old = self.buckets
        self.capacity *= 2
        self.buckets = [[] for _ in range(self.capacity)]
        self.size = 0
        for bucket in old:
            for k, v in bucket:
                self.put(k, v)

hm = HashMap()
for i in range(20):
    hm.put(i, i * 2)
print(hm.get(10))   # 20
print(hm.capacity)  # should have resized

해시 맵이 작동하지 않는 경우: 해시할 수 없는 키

해시 가능한 객체만 사전 키가 될 수 있습니다. 파이썬에서 객체가 해시 가능하려면 __hash__ 메서드와 __eq__ 메서드가 있어야 하며, 수명 동안 해시 값이 변하지 않아야 합니다. 목록, 집합, 사전은 변경 가능하므로 해시할 수 없습니다. 키로 사용할 때 목록과 집합을 대신할 수 있는 해시 가능한 자료형으로 튜플과 불변 집합이 있습니다.

흔한 면접 함정은 애너그램을 그룹화할 때 정렬된 목록이 아니라 정렬된 튜플을 사전 키로 사용해야 한다는 점입니다.

from collections import defaultdict

def groupAnagrams(strs):
    groups = defaultdict(list)
    for s in strs:
        key = tuple(sorted(s))  # tuple is hashable; list is not
        groups[key].append(s)
    return list(groups.values())

print(groupAnagrams(['eat','tea','tan','ate','nat','bat']))
# [['eat','tea','ate'], ['tan','nat'], ['bat']]

요약: 해시 맵의 시간 복잡도

해시 맵은 삽입, 삭제, 조회를 평균 O(1)에 수행할 수 있으며, 이는 최적의 면접 풀이를 구성하는 기반입니다. 핵심 가정은 좋은 해시 함수가 키를 고르게 분산하고, 적재율이 제한된 범위에 머무르며(크기 조정으로 이를 유지합니다), 키 객체가 변경 불가능하고 해시 가능하다는 것입니다. 이러한 가정이 성립하면 해시 맵은 O(n) 선형 탐색을 O(1) 조회로 바꾸어, 두 수의 합과 같은 문제를 O(n²)이 아니라 O(n)에 해결할 수 있게 합니다.

이해도 확인

이 단원에서 배운 자료구조 및 알고리즘 — 코딩 면접 대비 개념을 얼마나 이해했는지 확인해 보세요.

단원 복습

이 단원에서는 다음을 배웠습니다. 해시 맵은 해시 함수를 사용해 키를 버킷 인덱스에 대응시키며 평균 O(1) 연산을 수행합니다. 충돌은 체이닝(버킷마다 연결 리스트 사용) 또는 개방 주소 지정(다음 빈 슬롯 탐사)으로 해결합니다. 그리고 변경 불가능하고 해시 가능한 객체만 사전 키가 될 수 있으므로, 시퀀스를 키로 사용해야 할 때는 목록 대신 튜플을 사용해야 합니다. 다음 단원에서는 두 수의 합과 그 다양한 면접 변형 문제를 풉니다.

자주 묻는 질문

“해시 함수 내부와 충돌 처리” 강의는 무료인가요?

네 — “해시 함수 내부와 충돌 처리” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Coding Interview Prep 강의 전체를 잠금 해제할 수 있습니다. Coding Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.

“해시 함수 내부와 충돌 처리”에서 뭘 배우나요?

Python이 객체를 해시하는 방식, 개방 주소법과 체이닝으로 충돌을 해결하는 방식, 평균 O(1)이 O(n)으로 악화될 수 있는 이유를 이해합니다. 브라우저에서 직접 실행하는 실습 코드로 Coding Interview Prep을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

Coding Interview Prep을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 Coding Interview Prep은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 1번째 강의입니다.

“해시 함수 내부와 충돌 처리” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 Coding Interview Prep 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 Coding Interview Prep 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 해시 함수 내부와 충돌 처리
  2. 두 수의 합과 다양한 변형
  3. 빈도 계산과 그룹화
  4. 가장 긴 연속 수열과 LRU 캐시
← Coding Interview Prep(으)로 돌아가기