해시 테이블
해시 테이블(Hash Table)은 키(Key)를 해시 함수로 변환해 배열 인덱스에 직접 매핑하는 자료구조입니다. 평균 O(1)의 삽입·탐색·삭제를 제공하며, 대부분의 언어에서 딕셔너리·맵의 내부 구현으로 사용됩니다.
핵심 개념
해시 함수
임의 크기의 키를 고정 범위의 정수(해시값)로 변환합니다. 좋은 해시 함수는 결정론적(같은 입력 → 같은 출력), 균등 분포, 빠른 계산 속도를 갖춥니다. 개념적으로는 hash(key) % capacity로 버킷 인덱스를 결정하고, 실제 구현은 비트 연산 등으로 조금씩 다릅니다.
핵심 개념
충돌(Collision)
서로 다른 두 키가 동일한 해시 인덱스로 매핑되는 현상입니다. 비둘기집 원리에 의해 키 공간이 배열 크기보다 크면 충돌은 반드시 발생합니다. 충돌 해결 방법으로 체이닝(Chaining)과 오픈 어드레싱(Open Addressing)이 있습니다.
핵심 개념
체이닝 (Chaining)
같은 버킷에 충돌한 키들을 연결 리스트로 연결합니다. 버킷이 연결 리스트의 헤드를 가리킵니다. 삽입은 O(1)이고 탐색은 버킷의 리스트 길이에 비례합니다. Java의 HashMap이 이 방식을 사용하며, OpenJDK 구현에서는 한 버킷이 길어지고 전체 용량이 64 이상이면 트리(Red-Black Tree)로 전환합니다.
핵심 개념
오픈 어드레싱 (Open Addressing)
충돌 시 다른 빈 버킷을 탐색합니다. Linear Probing은 index+1, +2, ...를 순서대로 탐색합니다. Quadratic Probing은 index+1², +2², ...으로 탐색합니다. Double Hashing은 두 번째 해시 함수로 보폭을 결정합니다. 일반적으로 메모리 지역성이 좋아 캐시 효율이 높은 편입니다.
핵심 개념
부하율 (Load Factor)
저장된 항목 수 / 버킷 수로 계산합니다. 부하율이 높을수록 충돌이 잦아져 성능이 O(n)에 가까워집니다. Java HashMap의 기본 부하율은 0.75로, 이를 초과하면 배열을 2배로 늘리고 전체를 재해싱(Rehashing)합니다.
핵심 개념
삭제와 Tombstone
오픈 어드레싱에서 단순 삭제(null 처리)는 탐색 체인을 끊어 이후 항목을 찾지 못하게 합니다. 이를 방지하기 위해 삭제된 슬롯에 Tombstone(묘비) 마커를 남겨 탐색은 계속하되 삽입 시에는 재사용합니다. 체이닝에서는 이 문제가 없습니다.
해시 테이블이 만들어진 이유
해시 테이블은 키를 해시 함수로 정수로 바꿔 배열 위치를 곧바로 계산하는 자료구조입니다. 정렬된 배열이나 트리가 키를 비교하며 찾는 것과 달리, 위치를 계산해 바로 접근해서 평균적으로 상수 시간에 넣고 찾을 수 있습니다. 대신 서로 다른 키가 같은 위치로 갈 수 있다는 충돌을 다뤄야 합니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
해시 충돌은 어떻게 해결하나요?
한 버킷에 여러 키를 연결 리스트 같은 구조로 모으는 체이닝과, 충돌 시 다른 빈 버킷을 찾아가는 오픈 어드레싱이 있습니다. Java HashMap은 체이닝 방식을 씁니다.
면접 질문
해시 테이블이 이진 탐색 트리보다 항상 좋은가요?
평균 탐색은 해시 테이블이 빠르지만 키 순서나 범위 검색은 못 합니다. 정렬된 순회나 범위 조회가 필요하면 트리가 낫습니다.
면접 질문
리사이즈(rehash)는 왜 필요하고 비용은 어떤가요?
부하율이 높아지면 충돌이 늘어 성능이 떨어져서 버킷 수를 늘립니다. 모든 항목의 위치를 다시 계산하니 그 순간은 O(n)이지만 드물게 일어나서 분할 상환하면 삽입은 평균 상수 시간입니다.
면접 질문
해시 충돌 공격이란 무엇이고 어떻게 막나요?
공격자가 같은 버킷으로 가는 키를 일부러 만들어 연산 하나를 O(n)으로 느리게 만드는 공격입니다. 해시 함수에 무작위 시드를 넣거나, Java처럼 긴 체인을 트리로 바꿔 최악을 줄이는 방법이 있습니다.
실제 시스템에서는
함께 보면 좋은 토픽
참고 자료
Java — HashMap 사용 및 내부 동작 이해
/* ── HashMap 기본 사용 ──────────────────────────────────── */ Map<String, Integer> map = new HashMap<>(); // 삽입 — 평균 O(1) map.put("apple", 3); map.put("banana", 5); map.put("cherry", 2); // 탐색 — 평균 O(1) int count = map.getOrDefault("apple", 0); // 3 // 존재 확인 boolean has = map.containsKey("banana"); // true // 삭제 — 평균 O(1) map.remove("cherry"); // 순회 (순서 보장 없음) for (Map.Entry<String, Integer> e : map.entrySet()) { System.out.println(e.getKey() + " → " + e.getValue()); } /* ── 빈도 카운팅 패턴 ───────────────────────────────────── */ int[] nums = {1, 2, 2, 3, 3, 3}; Map<Integer, Integer> freq = new HashMap<>(); for (int n : nums) { freq.merge(n, 1, Integer::sum); // 없으면 1, 있으면 +1 } // freq: {1=1, 2=2, 3=3} /* ── HashMap 내부 구조 파라미터 ─────────────────────────── */ // 기본 용량 = 16 (항상 2의 거듭제곱) // 기본 부하율 = 0.75 // 임계값 = 용량 * 부하율 = 12 // 13번째 삽입으로 size가 임계값 12를 초과 → 용량 32로 리사이징 + 전체 재해싱 // 초기 용량 예측 가능 시 직접 지정으로 리사이징 방지 Map<String, Integer> presized = new HashMap<>(1 << 7); // 128 (2의 거듭제곱) /* ── 키로 사용할 클래스의 필수 규칙 ────────────────────── */ public class Point { private final int x, y; @Override public int hashCode() { // Objects.hash는 null 안전, 여러 필드 조합에 적합 return Objects.hash(x, y); } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Point)) return false; Point p = (Point) o; return x == p.x && y == p.y; } // hashCode와 equals를 반드시 함께 재정의해야 합니다. // equals()가 true면 hashCode()도 같아야 하는 규약 때문입니다. }
HashMap은 key.hashCode()로 버킷 인덱스를 계산하고 equals()로 동등성을 비교합니다.
hashCode()와 equals()를 함께 재정의하지 않으면 같은 논리적 키가 서로 다른 버킷에 들어가 탐색 실패가 발생합니다.
merge(key, 1, Integer::sum)은 key가 없으면 1로 초기화하고, 있으면 현재 값에 1을 더합니다. 빈도 카운팅의 관용구입니다.
초기 용량을 예상 항목 수 / 0.75보다 크게 설정하면 리사이징을 방지해 성능을 높일 수 있습니다.
Java — 체이닝 해시 테이블 직접 구현
/* ── 체이닝 해시 테이블 구현 ────────────────────────────── */ public class ChainingHashTable<K, V> { private static final int DEFAULT_CAP = 16; private static final float LOAD_FACTOR = 0.75f; private LinkedList<Entry<K,V>>[] buckets; private int size; private static class Entry<K, V> { K key; V value; Entry(K k, V v) { key = k; value = v; } } @SuppressWarnings("unchecked") public ChainingHashTable() { buckets = new LinkedList[DEFAULT_CAP]; } private int bucketIdx(K key) { // & 0x7FFFFFFF으로 음수 해시값을 양수로 변환 return (key.hashCode() & 0x7FFFFFFF) % buckets.length; } public void put(K key, V value) { int idx = bucketIdx(key); if (buckets[idx] == null) buckets[idx] = new LinkedList<>(); // 같은 키 존재 시 값 갱신 for (Entry<K,V> e : buckets[idx]) { if (e.key.equals(key)) { e.value = value; return; } } buckets[idx].add(new Entry<>(key, value)); size++; // 부하율 초과 시 리사이징 if ((float) size / buckets.length > LOAD_FACTOR) resize(); } public V get(K key) { int idx = bucketIdx(key); if (buckets[idx] == null) return null; for (Entry<K,V> e : buckets[idx]) { if (e.key.equals(key)) return e.value; } return null; } @SuppressWarnings("unchecked") private void resize() { LinkedList<Entry<K,V>>[] old = buckets; buckets = new LinkedList[old.length * 2]; // 2배 확장 size = 0; for (LinkedList<Entry<K,V>> bucket : old) { if (bucket == null) continue; for (Entry<K,V> e : bucket) put(e.key, e.value); // 재해싱 } } }
버킷 인덱스 계산 시 hashCode()가 음수일 수 있으므로 & 0x7FFFFFFF로 부호 비트를 제거한 뒤 나머지를 구합니다.
삽입 시 같은 키가 이미 있으면 값을 갱신하고 size를 늘리지 않습니다.
부하율이 0.75를 초과하면 배열을 2배로 늘리고 모든 항목을 새 인덱스로 재해싱합니다.
리사이징 자체는 O(n)이지만 분할 상환 분석으로 삽입은 평균 O(1)입니다.
Java의 실제 HashMap은 보조 해시(secondary hashing)를 적용해 상위 비트도 인덱스 계산에 반영함으로써 충돌을 줄입니다.
Q1
해시 테이블에서 해시 함수의 역할은?
Q2
체이닝(Chaining) 방식이 해시 충돌을 해결하는 방법은?
Q3
오픈 어드레싱(Open Addressing) 방식과 체이닝의 차이는?