맵(Map)과 해시 테이블(Hash Table)

2026. 5. 25. 19:03·DSA

맵(Map)은 키-값 쌍을 저장하는 추상 자료형(ADT)으로, 같은 키를 가진 쌍은 최대 하나만 존재합니다. 전화번호와 이름, 단어와 빈도수, 사용자 ID와 프로필처럼 "키로 값을 찾는" 모든 곳에 쓰입니다.

 

해시 테이블은 Map을 구현한 자료구조 중 가장 흔히 쓰이는 형태로, 해시 함수를 통해 키를 배열의 인덱스로 변환해 평균 $O(1)$ 시간에 데이터에 접근합니다. Python의 dict, Java의 HashMap, C++의 unordered_map이 모두 해시 테이블 기반입니다.

Hash table

핵심 개념

  • 해시 함수(Hash Function): 키를 배열 인덱스로 변환하는 함수
  • 버킷(Bucket): 해시 테이블의 각 슬롯
  • 해시 충돌(Hash Collision): 서로 다른 키가 같은 인덱스로 해시되는 현상
  • 로드 팩터(Load Factor): 저장된 원소 수 / 테이블 크기

해시 함수

좋은 해시 함수의 조건

  1. 균등 분포: 키들이 테이블 전체에 고르게 분산
  2. 빠른 계산: $O(1)$ 시간에 계산 가능
  3. 결정적: 같은 키는 항상 같은 값 반환

일반적인 해시 함수들

def division_hash(key, table_size):
    """나눗셈 해시"""
    return key % table_size

def multiplication_hash(key, table_size):
    """곱셈 해시"""
    A = 0.6180339887  # (√5 - 1) / 2
    return int(table_size * ((key * A) % 1))

def string_hash(s, table_size):
    """문자열 해시 (djb2 알고리즘)"""
    hash_value = 5381
    for char in s:
        hash_value = ((hash_value << 5) + hash_value) + ord(char)
    return hash_value % table_size

def polynomial_hash(s, table_size):
    """다항식 해시"""
    p = 31  # 소수
    hash_value = 0
    p_pow = 1
    for char in s:
        hash_value = (hash_value + ord(char) * p_pow) % table_size
        p_pow = (p_pow * p) % table_size
    return hash_value

참고 영상

해시 테이블의 동작 — 키-밸류 저장, 해시 충돌, 체이닝·선형 탐사, 리사이징 — 은 그림으로 보면 한 번에 잡힙니다.

 

맵(Map)과 해시 테이블(Hash Table) 핵심만 모아보기 (YouTube)

 

충돌 해결 방법

1. 개별 체이닝 (Separate Chaining)

Hash table chaining

같은 인덱스로 해시되는 키들을 연결 리스트로 묶어 한 버킷에 함께 저장합니다. 충돌이 발생해도 테이블 외부에 데이터를 매달 수 있어 로드 팩터가 1을 넘어도 동작하지만, 한 버킷의 리스트가 길어지면 검색 시간이 그만큼 늘어나는 것이 단점입니다.

class Node:
    """체이닝용 연결 리스트 노드"""
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.next = None
        
class HashTableChaining:
    def __init__(self, size=10):
        self.size = size
        self.table = [None] * size  # 각 슬롯이 연결 리스트의 head
        self.count = 0
        
    def _hash(self, key):
        """해시 함수"""
        if isinstance(key, str):
            return sum(ord(c) for c in key) % self.size
        return key % self.size
        
    def put(self, key, value):
        """키-값 쌍 삽입"""
        index = self._hash(key)
        node = self.table[index]
        
        # 기존 키가 있는지 확인
        while node is not None:
            if node.key == key:
                node.value = value  # 값 업데이트
                return
            node = node.next
            
        # 새 노드를 리스트 맨 앞에 삽입 (head insertion)
        new_node = Node(key, value)
        new_node.next = self.table[index]
        self.table[index] = new_node
        self.count += 1
        
    def get(self, key):
        """값 조회"""
        index = self._hash(key)
        node = self.table[index]
        while node is not None:
            if node.key == key:
                return node.value
            node = node.next
        raise KeyError(f"Key '{key}' not found")
        
    def delete(self, key):
        """키-값 쌍 삭제"""
        index = self._hash(key)
        node = self.table[index]
        prev = None
        
        while node is not None:
            if node.key == key:
                if prev is None:
                    self.table[index] = node.next  # 첫 번째 노드 삭제
                else:
                    prev.next = node.next
                self.count -= 1
                return node.value
            prev = node
            node = node.next
            
        raise KeyError(f"Key '{key}' not found")
        
    def load_factor(self):
        """로드 팩터 계산"""
        return self.count / self.size
        
    def display(self):
        """테이블 출력"""
        for i in range(self.size):
            node = self.table[i]
            if node is not None:
                items = []
                while node is not None:
                    items.append(f"({node.key}, {node.value})")
                    node = node.next
                print(f"Bucket {i}: {' -> '.join(items)}")

2. 개방 주소법 (Open Addressing)

충돌이 발생하면 같은 테이블 안에서 다른 빈 슬롯을 찾아 거기에 저장합니다. 외부 자료구조 없이 배열만으로 동작하지만, 로드 팩터가 1을 넘을 수 없고 데이터가 특정 영역에 뭉치는 클러스터링 문제가 생깁니다. 빈 슬롯을 찾아가는 방식(탐사 함수)에 따라 선형 탐사·이차 탐사·더블 해싱으로 나뉩니다.

선형 탐사 (Linear Probing)

Hash table linear probing

충돌이 일어난 인덱스의 바로 다음 칸(+1)부터 차례로 빈 슬롯을 찾습니다. 구현이 가장 간단하지만, 충돌이 모인 위치 주변에 데이터가 길게 늘어붙는 1차 클러스터링이 발생해 점점 검색이 느려집니다.

class HashTableLinearProbing:
    def __init__(self, size=10):
        self.size = size
        self.keys = [None] * size
        self.values = [None] * size
        self.count = 0

    def _hash(self, key):
        if isinstance(key, str):
            return sum(ord(c) for c in key) % self.size
        return key % self.size

    def _probe(self, key):
        """선형 탐사로 빈 슬롯 또는 키 위치 찾기"""
        index = self._hash(key)
        original_index = index
        while self.keys[index] is not None:
            if self.keys[index] == key:
                return index
            index = (index + 1) % self.size

            # 테이블이 가득 찬 경우
            if index == original_index:
                raise Exception("Hash table is full")
        return index

    def put(self, key, value):
        if self.count >= self.size:
            raise Exception("Hash table is full")
        index = self._probe(key)

        # 새로운 키인 경우
        if self.keys[index] is None:
            self.count += 1
        self.keys[index] = key
        self.values[index] = value

    def get(self, key):
        index = self._hash(key)
        original_index = index
        while self.keys[index] is not None:
            if self.keys[index] == key:
                return self.values[index]
            index = (index + 1) % self.size
            if index == original_index:
                break
        raise KeyError(f"Key '{key}' not found")

    def delete(self, key):
        index = self._hash(key)
        original_index = index
        while self.keys[index] is not None:
            if self.keys[index] == key:
                value = self.values[index]
                self.keys[index] = None
                self.values[index] = None
                self.count -= 1

                # 삭제 후 재해싱 (클러스터링 방지)
                self._rehash_after_delete(index)
                return value
            index = (index + 1) % self.size
            if index == original_index:
                break
        raise KeyError(f"Key '{key}' not found")

    def _rehash_after_delete(self, deleted_index):
        """삭제 후 클러스터링 해결을 위한 재해싱"""
        index = (deleted_index + 1) % self.size
        while self.keys[index] is not None:
            key_to_rehash = self.keys[index]
            value_to_rehash = self.values[index]
            self.keys[index] = None
            self.values[index] = None
            self.count -= 1
            self.put(key_to_rehash, value_to_rehash)
            index = (index + 1) % self.size

이차 탐사 (Quadratic Probing)

충돌 시 +1, +4, +9, +16... 처럼 제곱수만큼 떨어진 위치를 탐사합니다. 선형 탐사의 1차 클러스터링은 완화되지만, 같은 인덱스로 해시된 키들은 같은 경로를 따라가는 2차 클러스터링이 남습니다.

class HashTableQuadraticProbing:
    def __init__(self, size=10):
        self.size = size
        self.keys = [None] * size
        self.values = [None] * size
        self.count = 0

    def _hash(self, key):
        if isinstance(key, str):
            return sum(ord(c) for c in key) % self.size
        return key % self.size

    def _probe(self, key):
        """이차 탐사로 빈 슬롯 또는 키 위치 찾기"""
        index = self._hash(key)
        original_index = index
        i = 0
        while self.keys[index] is not None:
            if self.keys[index] == key:
                return index
            i += 1
            index = (original_index + i * i) % self.size

            # 무한 루프 방지
            if i >= self.size:
                raise Exception("Hash table is full or cannot find slot")
        return index

    def put(self, key, value):
        if self.count >= self.size:
            raise Exception("Hash table is full")
        index = self._probe(key)
        if self.keys[index] is None:
            self.count += 1
        self.keys[index] = key
        self.values[index] = value

더블 해싱 (Double Hashing)

두 번째 해시 함수의 결과만큼 점프하면서 탐사합니다. 키마다 점프 폭이 달라지기 때문에 1차·2차 클러스터링이 모두 줄어드는 대신, 해시 함수를 두 번 계산해야 하는 비용이 있습니다.

class HashTableDoubleHashing:
    def __init__(self, size=10):
        self.size = size
        self.keys = [None] * size
        self.values = [None] * size
        self.count = 0

    def _hash1(self, key):
        """첫 번째 해시 함수"""
        if isinstance(key, str):
            return sum(ord(c) for c in key) % self.size
        return key % self.size

    def _hash2(self, key):
        """두 번째 해시 함수"""
        if isinstance(key, str):
            hash_val = sum(ord(c) for c in key)
        else:
            hash_val = key
        return 7 - (hash_val % 7)  # 7은 테이블 크기보다 작은 소수

    def _probe(self, key):
        """더블 해싱으로 빈 슬롯 또는 키 위치 찾기"""
        index = self._hash1(key)
        step = self._hash2(key)
        original_index = index
        while self.keys[index] is not None:
            if self.keys[index] == key:
                return index
            index = (index + step) % self.size
            if index == original_index:
                raise Exception("Hash table is full")
        return index

    def put(self, key, value):
        if self.count >= self.size:
            raise Exception("Hash table is full")

        index = self._probe(key)
        if self.keys[index] is None:
            self.count += 1
        self.keys[index] = key
        self.values[index] = value

동적 리사이징

Hash table resize

데이터가 일정 비율 이상 차면(보통 로드 팩터 0.75 전후) 테이블 크기를 두 배로 늘리고 모든 원소를 새 크기에 맞춰 다시 해시합니다. 테이블이 좁아져서 충돌이 잦아지는 문제를 근본적으로 해결하는 방법이며, 충돌 해결 방식과 무관하게 함께 쓰입니다. 줄이는 방향도 가능해서 데이터가 빠진 뒤 일정 수준 이하가 되면 크기를 줄이기도 합니다.

class ResizableHashTable:
    def __init__(self, initial_size=8):
        self.size = initial_size
        self.table = [[] for _ in range(self.size)]
        self.count = 0

    def _hash(self, key):
        if isinstance(key, str):
            return sum(ord(c) for c in key) % self.size
        return key % self.size

    def _resize(self, new_size):
        """테이블 크기 조정"""
        old_table = self.table
        self.size = new_size
        self.table = [[] for _ in range(self.size)]
        old_count = self.count
        self.count = 0

        # 모든 원소를 새 테이블에 재삽입
        for bucket in old_table:
            for key, value in bucket:
                self.put(key, value)

    def put(self, key, value):
        # 로드 팩터가 0.75를 초과하면 크기 두 배로 증가
        if self.count / self.size > 0.75:
            self._resize(self.size * 2)
        index = self._hash(key)
        bucket = self.table[index]

        for i, (k, v) in enumerate(bucket):
            if k == key:
                bucket[i] = (key, value)
                return
        bucket.append((key, value))
        self.count += 1

    def delete(self, key):
        index = self._hash(key)
        bucket = self.table[index]

        for i, (k, v) in enumerate(bucket):
            if k == key:
                del bucket[i]
                self.count -= 1
                # 로드 팩터가 0.25 미만이면 크기 절반으로 감소
                if self.count / self.size < 0.25 and self.size > 8:
                    self._resize(self.size // 2)
                return v
        raise KeyError(f"Key '{key}' not found")

시간복잡도

연산 평균 최악
삽입 $O(1)$ $O(n)$
삭제 $O(1)$ $O(n)$
검색 $O(1)$ $O(n)$

활용 예시

1. 단어 빈도 계산

def word_frequency(text):
    word_count = {}
    words = text.lower().split()
    for word in words:
        word_count[word] = word_count.get(word, 0) + 1
    return word_count

text = "hello world hello python world"
print(word_frequency(text))  # {'hello': 2, 'world': 2, 'python': 1}

2. 두 수의 합 찾기

def two_sum(nums, target):
    """두 수의 합이 target인 인덱스 찾기"""
    num_to_index = {}
    for i, num in enumerate(nums):
        complement = target - num
        if complement in num_to_index:
            return [num_to_index[complement], i]
        num_to_index[num] = i
    return []

nums = [2, 7, 11, 15]
target = 9
print(two_sum(nums, target))  # [0, 1]

3. 그룹 애너그램

def group_anagrams(strs):
    """애너그램끼리 그룹화"""
    anagram_groups = {}

    for s in strs:
        # 정렬된 문자열을 키로 사용
        key = ''.join(sorted(s))
        if key not in anagram_groups:
            anagram_groups[key] = []
        anagram_groups[key].append(s)

    return list(anagram_groups.values())

strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
print(group_anagrams(strs))  # [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]

4. LRU 캐시 (해시 테이블 + 연결 리스트)

class Node:
    """이중 연결 리스트 노드"""
    def __init__(self, key=None, value=None):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None
        
class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.cache = {}  # 해시 테이블: key -> Node
        
        # dummy head/tail로 경계 조건 단순화
        self.head = Node()
        self.tail = Node()
        self.head.next = self.tail
        self.tail.prev = self.head
        
    def _add_to_front(self, node):
        """노드를 head 직후에 삽입 (가장 최근)"""
        node.prev = self.head
        node.next = self.head.next
        self.head.next.prev = node
        self.head.next = node
        
    def _remove(self, node):
        """노드를 리스트에서 제거"""
        node.prev.next = node.next
        node.next.prev = node.prev
        
    def get(self, key):
        if key not in self.cache:
            return -1
        node = self.cache[key]
        # 최근 접근이므로 맨 앞으로 이동
        self._remove(node)
        self._add_to_front(node)
        return node.value
        
    def put(self, key, value):
        if key in self.cache:
            # 기존 키: 값 업데이트 + 맨 앞으로
            node = self.cache[key]
            node.value = value
            self._remove(node)
            self._add_to_front(node)
        else:
            # 새 키
            if len(self.cache) >= self.capacity:
                # 용량 초과: tail 직전 노드 (가장 오래된 것) 제거
                lru = self.tail.prev
                self._remove(lru)
                del self.cache[lru.key]
            new_node = Node(key, value)
            self.cache[key] = new_node
            self._add_to_front(new_node)

해시 테이블 vs 다른 자료구조

자료구조 검색 시간 삽입 시간 삭제 시간 메모리 사용량 순서 유지
해시 테이블 $O(1)$ avg $O(1)$ avg $O(1)$ avg 높음 없음
배열 $O(n)$ $O(n)$ $O(n)$ 낮음 있음
연결 리스트 $O(n)$ $O(1)$ $O(1)$ 중간 있음
이진 탐색 트리 $O(\log n)$ $O(\log n)$ $O(\log n)$ 중간 있음

장단점

장점

  • 빠른 연산: 평균 $O(1)$ 시간복잡도
  • 유연한 키: 다양한 데이터 타입을 키로 사용 가능
  • 동적 크기: 필요에 따라 크기 조정 가능
  • 메모리 효율적: 필요한 만큼만 공간 사용

단점

  • 순서 없음: 삽입 순서나 정렬 순서 보장 안됨
  • 해시 충돌: 성능 저하 원인
  • 메모리 오버헤드: 추가 공간 필요
  • 최악의 경우: $O(n)$ 시간복잡도 가능

실제 활용 분야

  • 데이터베이스: 인덱싱, 조인 연산
  • 캐싱: 웹 캐시, CPU 캐시
  • 컴파일러: 심볼 테이블, 변수 관리
  • 네트워크: 라우팅 테이블, DNS
  • 보안: 패스워드 저장, 체크섬
  • 빅데이터: 분산 해싱, 샤딩

Map과 해시 테이블의 관계

Map은 ADT(Abstract Data Type)다. 키-값 매핑이라는 동작 인터페이스만 정의할 뿐, 어떻게 구현하는지는 명시하지 않는다. 해시 테이블은 그 Map을 구현한 데이터 구조이며, 트리 기반(Red-Black Tree 같은) 방식으로도 Map을 구현할 수 있다. 해시 테이블이 사실상 표준처럼 쓰이는 이유는 평균 O(1) 접근 성능이다. 다만 충돌 해결 방식은 언어마다 다르다 — Python의 dict는 개방 주소법, Java의 HashMap은 체이닝을 쓴다 (JDK 8부터는 한 버킷의 노드가 일정 수를 넘으면 Red-Black Tree로 전환한다).

'DSA' 카테고리의 다른 글

우선순위 큐(Priority Queue)와 힙(Heap)  (0) 2026.05.24
정렬(Sorting) 알고리즘 6가지 — 원리·복잡도·선택 가이드  (0) 2026.05.24
'DSA' 카테고리의 다른 글
  • 우선순위 큐(Priority Queue)와 힙(Heap)
  • 정렬(Sorting) 알고리즘 6가지 — 원리·복잡도·선택 가이드
onebrotravel
onebrotravel
  • onebrotravel
    매일을 여행처럼
    onebrotravel
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • Embedded N
      • OS N
      • DSA
      • DevTools
      • Infra
      • Projects
  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
onebrotravel
맵(Map)과 해시 테이블(Hash Table)
상단으로

티스토리툴바