23. hash table

Posted by aliontory on June 17, 2024 · 6 mins read

2024-6-17-23. Hash Table

Hash Table

Hash Table은 Key와 Data의 쌍을 저장하는 자료구조로, 키값을 통해 O(1)시간에 insert, search, delete를 수행한다.
각 키값은 고유하다.

  • A[“kreplach”] = “tasty stuffed dough”

어떻게 O(1)시간에 데이터에 접근할 수 있을까?
키값이 0~1000의 정수이고, 데이터는 키값 자체라고 하자.
이때 1001크기의 int배열을 할당하고 0으로 초기화한 뒤
  • 정수 n이 insert되면 n번 인덱스 값을 1로 수정
  • 정수 n이 delete되면 n번 인덱스 값을 0으로 수정
  • 정수 n search시 n번 인덱스가 1인지를 리턴
위와 같은 방법으로 O(1)시간에 insert, delete, search를 수행할 수 있다.

이러한 방법은 다음과 같은 단점이 있다.
  • 낭비되는 메모리가 너무 크다. 예를 들어, 가능한 입력값이 1000개지만 실제로 저장되는 원소 개수는 10개 정도라면, 99%의 메모리가 낭비된다.
  • 키값으로 0이상의 정수만 사용 가능하다.

이를 해결하기 위해 키값을 배열 인덱스 값으로 변환하는 Hash Function이 필요하다.
해시 함수 : 키값 집합 → 배열 인덱스



좋은 해시 함수의 특성

  1. 0이상의 정수를 리턴해야 한다.
  2. O(1)시간에 빠르게 계산되어야 한다.
  3. 낭비하는 공간이 없어야 한다.
    • 즉, 해시 함수가 리턴할 수 있는 최대 정수가 n이라면, 0<=k<=n 인 임의의 k에 대해, 해시 함수가 k를 리턴하도록 하는 입력 가능한 키값이 존재해야 한다.
    • 예를 들어, 0부터 10까지의 정수를 리턴하는 해시 함수인데 7을 리턴하도록 하는 키값이 없다면, 해당 공간이 낭비되므로 좋은 해시 함수가 아니다.
  4. 충돌이 최소로 발생해야 한다.
    • 가능한 입력 키값의 개수가 실제로 저장되는 원소 개수에 비해 훨씬 클 수 있다. 해시 함수가 이러한 키값들마다 인덱스를 하나씩 대응시킨다면, 메모리 공간이 너무 많이 필요할 것이다.
    • 메모리 낭비를 줄이기 위해, 해시 함수의 치역(인덱스)은 정의역(키값)보다 작다. 이로 인해 서로 다른 키값이 해시 함수에 의해 같은 인덱스로 대응되는 경우가 발생한다. 이것이 충돌이다.
    • 이러한 충돌을 해결하고 충돌 키값을 저장하는 방법이 있지만, 이러한 해결 뒤에는 해시 테이블의 수행 속도 저하가 뒤따른다. 따라서 충돌을 최대한 피해야 한다.
    • 충돌을 줄이기 위해서는, 키를 인덱스 값들에 최대한 고르게 대응시켜야 한다. 특정 인덱스에 키값이 몰리면 안 된다.

이러한 특성을 잘 충족하는 해시 함수를 찾고 증명하는 것은 쉽지 않다. 아래에서 단순한 해시 함수에 대해서만 간략히 살펴볼 것이다.


0이상 정수 키값의 해시 함수

키값이 0이상 정수일 경우, 해시 함수는 다음과 같이 작성할 수 있다.
  • Hash(x) = x % TableSize

TableSize는 소수로 설정하는 게 좋다.
입력 키값이 완전한 랜덤일 때는 소수가 아니여도 상관없지만, 일반적으로 입력 키값은 특정한 패턴을 가진다. 특히, 대부분의 입력 키값이 k의 배수인 경우가 많다.
이런 경우, 만약 TableSize도 k의 배수라면, k의 배수인 인덱스에 키값들이 몰리게 된다.

예를 들어, int변수의 주소값이 키이고, 이 값들이 4의 배수라고 하자. 만약 TableSize가 8이라면, 오직 인덱스 0또는 4만이 사용된다. 반면, TableSize로 7등의 소수를 사용한다면, 키값들이 인덱스에 고루 저장된다.

TableSize를 소수로 설정한다면, 오직 TableSize==k인 경우만 TableSize가 k의 배수가 된다.


문자열의 해시 함수

문자열에 대한 해시 함수로, 각 문자의 ASCII값을 모두 더하는 것을 생각해 볼 수 있다. 그러나 이는 효율적인 해시 함수가 될 수 없다.
  • 크기가 3인 문자열을 저장한다면, 아스키 코드 기준 1283 종류의 입력이 가능하다. 이러한 입력에 대해 인덱스가 0~10000인 해시 테이블을 준비했다고 하자. 그런데 문자열을 다 더했을 때 최댓값은 128 * 3 = 384  밖에 안 된다. 그 뒤의 공간 385~10000은 쓰이지 않는다.
  • 키값에 특정 문자가 자주 나올 수 있다. 예를 들어 a, b, c가 자주 나온다고 하자. 이때, 위 해시 함수는 “abc”, “bca”, “cab” 에 모두 같은 인덱스를 대응시켜 버린다.

이를 해결하기 위해, 다음과 같이 글자 자리마다 다른 값을 곱해줄 수 있다.
  • Hash(“abc”) = (‘a’*1282 + ‘b’*1281 + ‘c’) % TableSize
일반적으로 128개의 아스키 코드중 알파벳 26개 정도만이 자주 사용되므로, 곱하는 값을 좀 더 줄이는 것이 좋다.
  • Hash(“abc”) = (‘a’*322 + ‘b’*321 + ‘c’) % TableSize


충돌을 해결하는 방법

해시 함수에서 충돌이란, 서로 다른 키가 하나의 인덱스 값으로 대응되는 것이다.

예를 들어, 해시 함수가
  • Hash(x) = x % 17
인 경우, 키값이 18와 35라면
18 % 17 == 35 % 17 == 1
두 키값이 인덱스 1로 대응되는 충돌이 발생한다.

배열의 한 인덱스에 두 개의 값을 저장할 순 없다. 이러한 충돌 문제를 해결하기 위해, 다음과 같은 방법을 사용한다.
  1. Separate Chaining
  2. Closed Hashing


Separate Chaining


배열에 linked list의 head를 저장하는 방법이다. key, data 쌍은 linked list 노드로 감싸져서 저장된다.
키값을 insert하려는데, 해당 키값에 대응하는 인덱스에 이미 다른 키값이 존재한다면, linked list의 insert를 수행하여 새로 끼워 넣으면 된다.

linked list대신 AVL Tree 등을 사용할 수도 있다.


Closed Hashing

Separate Chaining 방법은 각 키마다 포인터를 위한 공간을 추가로 할당한다. Closed Hashing은 이러한 추가적인 공간 할당 대신 Hash Table에서 사용되지 않는 공간을 활용하여 메모리를 절약한다.


키값을 insert할 때, 대응하는 인덱스의 자리가 비어 있다면, 해당 키값과 데이터를 그 자리에 바로 저장한다.
만약 자리가 차 있다면, 그 다음 자리에 저장한다. 다음 자리도 차 있다면, 빈 자리가 나올 때까지 앞으로 탐색하여, 찾아낸 빈 자리에 저장한다.
해시 테이블의 끝까지 빈 자리가 안 나온다면, 해시 테이블의 시작부터 빈 자리를 탐색한다.

search에서는, 찾으려는 키값에 대응하는 인덱스의 자리가 비어 있다면, 키값이 해시 테이블에 없는 것이다.
자리에 다른 키값이 있다면, 빈 자리가 나올 때까지 다음 자리를 탐색한다. 찾으려는 키값이 빈 자리가 나올 때까지 나오지 않는다면, 해당 키값이 없는 것이다.

이 방법을 사용한다면 delete시에 주의가 필요하다. 만약 키값 a, b, c가 충돌하고, a, b, c가 순서대로 저장되었는데, b를 delete한다면 c를 찾지 못하게 된다.
이를 해결하기 위해, delete한 자리를 그냥 빈 자리로 만드는 대신, 이 자리가 delete되었다는 표시를 남긴다.

Closed Hashing에서 충돌이 발생하다 보면, 저장된 키값들이 특정 공간에 몰리게 된다. 이 몰린 덩어리는 그 크기가 클수록 그 덩어리 안에 insert될 확률도 높아지므로, 덩어리는 점차 빠르게 커지게 된다. 이는 search 속도를 저하시킨다.
이를 해결하기 위해, Closed Hashing에 Quadratic Probing이나 Double Hashing 방법을 적용한다.

  • Quadratic Probing
    • 기본 closed hashing에서는 인덱스 i에서 k번째로 충돌한 키값을  i+k 인덱스에 저장했다. Quadratic Probing에서는 i + k2에 저장한다.
    • 예를 들어, Hash(a) = Hash(b) = Hash(c) = Hash(d) = Hash(e) = 10 이라 하자.
    • a, b, c, d, e를 순서대로 insert 한다면, a는 10, b는 11, c는 14, d는 19, e는 26에 저장된다.
  • Double Hashing
    • 또다른 해시 함수 Hash2(x)를 준비한다.
    • 충돌이 발생하면, Hash2 값만큼 더해준 인덱스에 값을 저장한다.
    • 해당 자리도 차 있다면, 빈 자리가 나올 때까지 Hash2를 더한다.