배움 __IL/자료구조&알고리즘

Hash Table 정리

Mo_bi!e 2024. 2. 19. 19:21

Hash Table

탄생 배경

Direct Access Table 인 배열인덱스 방식으로, key-value쌍을 가져올 때 시간효율은 O(1)이므로 빠르다. 하지만 사용하지 않는 index에 대한 공간낭비가 상존함

 

1. 해시 함수의 조건

(1) 의의

특정 값을 원하는 범위의 자연수로 바꿔주는 함수

 

(2) 조건

1) 한 해시테이블의 해시 함수는 결정론적이어야 한다

같은 key 는 같은 결과가 나와야함

 

2) 결과 해시값이 치우치지 않고 고르게 나온다

각 리턴 값이 나올 확률이 비슷해야한다

 

3) 빠르게 계산 할 수있어야한다.

해시테이블은 연산할 때 마다 해시함수 사용함. 본 함수가 비효율적이면 해시테이블도 비효율적임

 

2. 해시함수 만들기

(1) 나누기 방법

자연수 key를 해시테이블의 크기로 나눈 나머지를 리턴하는 해시 함수

def hash_function_remainder(key, array_size):
  """해시 테이블의 key를 나누기 방법으로 0 ~ array_size - 1 범위의 자연수로 바꿔주는 함수"""
    return key % array_size

print(hash_function_remainder(40, 200))
print(hash_function_remainder(120, 200))
print(hash_function_remainder(788, 200))
print(hash_function_remainder(2307, 200))

#40
#120
#188
#107

 

(2) 곱하기 방법

- 순서

key : 300 / 배열크기 : 50

 

1) 0 < a < 1 사이 a값 정함 (a 는 0.5555)

2) 0.5555 * 300 = 166.65 (a 값 * key) / 이 경우 166.65 에서 소수부분만 남김 => 0.65

3) 0.65 * 50 = 32.5 (소수부분 * 배열 크기) / 이 경우 32.5 에서 정수부분만 남김 => 32

 

이렇게 연산하는 목적은 해수 함수값이 배열의 크기 보다 값이 적게(50 아래) 나오게끔 함 (1의 배수 아래의 곱은 무조건 곱하는 수보다 아래로)

def hash_function_multiplication(key, array_size, a):
  """해시 테이블의 key를 곱셈 방법으로 0 ~ array_size - 1 범위의 자연수로 바꿔주는 함수"""
    temp = a * key # a와 key를 곱한다
    temp = temp - int(temp) # a와 key를 곱한 값의 소숫점 오른쪽 부분만 저장한다

    return int(array_size * temp) # temp와 배열 크기를 곱한 수의 자연수 부분만 리턴한다

print(hash_function_multiplication(40, 200, 0.61426212))
print(hash_function_multiplication(120, 200, 0.61426212))
print(hash_function_multiplication(788, 200, 0.61426212))
print(hash_function_multiplication(2307, 200, 0.61426212))

#114
#142
#7
#20

 

 

3. 해시테이블 충돌과 Chaining 

(1)해시테이블 충돌

1) 충돌(Collision) 이란

서로 다른 key해시함수에 넣었을 때 같은 값이 나옴 -> 이미 사용한 해시테이블 인덱스를 또 사용할 때

 

2) Chaining

해시테이블을 다루기 위해서는 Collision 을 다룰 수 있어야함

 

- Collision 해결방법 : Chaining 

배열 인덱스에 더블리 링크드 리스트로 저장해서 충돌 해결

 

- Chaining 주요 아이디어

링크드리스트의 data 필드를 key와 value로 저장

class Node:
    """링크드 리스트의 노드 클래스"""
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.next = None  # 다음 노드에 대한 레퍼런스
        self.prev = None  # 전 노드에 대한 레퍼런스

 

각 동작 별 시간 복잡도

동작 (Operation) 시간 복잡도
탐색 (search) O(n)
저장 (save) O(n)
삭제 (delete) O(n)

 

 

연산 (Operation) 부분 단계들 각 부분 단계 시간 복잡도
탐색 (search) 1. 해시 함수 계산
2. 배열 인덱스 접근
3. 링크드 리스트 탐색
O(1+1+n)
저장 (save) 1. 해시 함수 계산
2. 배열 인덱스 접근
3. 링크드 리스트 탐색

4. 탐색한 노드 수정 or 링크드 리스트 앞에 노드 삽입
O(1+1+n+1)
삭제 (delete) 1. 해시 함수 계산
2. 배열 인덱스 접근
3. 링크드 리스트 탐색

4. 탐색한 노드 삭제
O(1+1+n+1)

 

 

평균시간복잡도

“해시 테이블 삽입, 삭제, 탐색 연산들은 최악의 경우 O(n)이 걸리지만, 평균적으로는 O(1)이 걸린다”

 

 

파이썬으로 구현

더보기
class Node:
    """링크드 리스트의 노드 클래스"""

    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.next = None  # 다음 노드에 대한 레퍼런스
        self.prev = None  # 전 노드에 대한 레퍼런스


class LinkedList:
    """링크드 리스트 클래스"""

    def __init__(self):
        self.head = None  # 링크드 리스트의 가장 앞 노드
        self.tail = None  # 링크드 리스트의 가장 뒤 노드

    def find_node_with_key(self, key):
        """링크드 리스트에서 주어진 데이터를 갖고있는 노드를 리턴한다. 단, 해당 노드가 없으면 None을 리턴한다"""
        iterator = self.head  # 링크드 리스트를 돌기 위해 필요한 노드 변수

        while iterator is not None:
            if iterator.key == key:
                return iterator

            iterator = iterator.next

        return None

    def append(self, key, value):
        """링크드 리스트 추가 연산 메소드"""
        new_node = Node(key, value)

        # 빈 링크드 리스트라면 head와 tail을 새로 만든 노드로 지정
        if self.head is None:
            self.head = new_node
            self.tail = new_node
        # 이미 노드가 있으면
        else:
            self.tail.next = new_node  # 마지막 노드의 다음 노드로 추가
            new_node.prev = self.tail
            self.tail = new_node  # 마지막 노드 업데이

    def delete(self, node_to_delete):
        """더블리 링크드 리스트 삭제 연산 메소드"""

        # 링크드 리스트에서 마지막 남은 데이터를 삭제할 때
        if node_to_delete is self.head and node_to_delete is self.tail:
            self.tail = None
            self.head = None

        # 링크드 리스트 가장 앞 데이터 삭제할 때
        elif node_to_delete is self.head:
            self.head = self.head.next
            self.head.prev = None

        # 링크드 리스트 가장 뒤 데이터 삭제할 떄
        elif node_to_delete is self.tail:
            self.tail = self.tail.prev
            self.tail.next = None

        # 두 노드 사이에 있는 데이터 삭제할 때
        else:
            node_to_delete.prev.next = node_to_delete.next
            node_to_delete.next.prev = node_to_delete.prev

        return node_to_delete.value

    def __str__(self):
        """링크드 리스트를 문자열로 표현해서 리턴하는 메소드"""
        res_str = ""

        # 링크드 리스트 안에 모든 노드를 돌기 위한 변수. 일단 가장 앞 노드로 정의한다.
        iterator = self.head

        # 링크드 리스트 끝까지 돈다
        while iterator is not None:
            # 각 노드의 데이터를 리턴하는 문자열에 더해준다
            res_str += "{}: {}\n".format(iterator.key, iterator.value)
            iterator = iterator.next  # 다음 노드로 넘어간다

        return res_str

class HashTable_best:
    def __init__(self, capacity):
        self._capacity = capacity  # 파이썬 리스트 수용 크기 저장
        self._table = [LinkedList() for _ in range(self._capacity)]  # 파이썬 리스트 인덱스에 반 링크드 리스트 저장

    def _hash_function(self, key):
        """
        주어진 key에 나누기 방법을 사용해서 해시된 값을 리턴하는 메소드
        주의 사항: key는 파이썬 불변 타입이어야 한다.
        """
        return hash(key) % self._capacity

    def _get_linked_list_for_key(self, key):
        """주어진 key에 대응하는 인덱스에 저장된 링크드 리스트를 리턴하는 메소드"""
        hashed_index = self._hash_function(key)
        return self._table[hashed_index]

    def _look_up_node(self, key):
        """파라미터로 받은 key를 갖고 있는 노드를 리턴하는 메소드"""
        linked_list = self._get_linked_list_for_key(key)
        return linked_list.find_node_with_key(key)

    def look_up_value(self, key):
        """
        주어진 key에 해당하는 데이터를 리턴하는 메소드
        """
        return self._look_up_node(key).value

    def insert(self, key, value):
        """
        새로운 key - 데이터 쌍을 삽입시켜주는 메소드
        이미 해당 key에 저장된 데이터가 있으면 해당 key에 대응하는 데이터를 바꿔준다
        """
        existing_node = self._look_up_node(key)  # 이미 저장된 key인지 확인한다

        if existing_node is not None:
            existing_node.value = value  # 이미 저장된 key면 데이터만 바꿔주고
        else:
            # 없는 키면 새롭게 삽입시켜준다
            linked_list = self._get_linked_list_for_key(key)
            linked_list.append(key, value)

    def delete_by_key(self, key):

        node = self._look_up_node(key) # 예와 처리 신경쓰자!!

        """주어진 key에 해당하는 key - value 쌍을 삭제하는 메소드"""
        if node is not None:
            self._get_linked_list_for_key(key).delete(node)

    def __str__(self):
        """해시 테이블 문자열 메소드"""
        res_str = ""

        for linked_list in self._table:
            res_str += str(linked_list)

        return res_str[:-1]
        
# 삽입 탐색 문제
# print(test_scores)
# test_scores = HashTable_best(50) # 시험 점수를 담을 해시 테이블 인스턴스 생성

# 여러 학생들 이름과 시험 점수 삽입
# test_scores.insert("현승", 85)
# test_scores.insert("영훈", 90)
# test_scores.insert("동욱", 87)
# test_scores.insert("지웅", 99)
# test_scores.insert("신의", 88)
# test_scores.insert("규식", 97)
# test_scores.insert("태호", 90)
#
# print(test_scores)
#
# # key인 이름으로 특정 학생 시험 점수 검색
# print(test_scores.look_up_value("현승"))
# print(test_scores.look_up_value("태호"))
# print(test_scores.look_up_value("영훈"))
#
# # 학생들 시험 점수 수정
# test_scores.insert("현승", 10)
# test_scores.insert("태호", 20)
# test_scores.insert("영훈", 30)
#
# print(test_scores)

# 삭제 문제
test_scores3 = HashTable_best(50) # 시험 점수를 담을 해시 테이블 인스턴스 생성

# 여러 학생들 이름과 시험 점수 삽입
test_scores3.insert("현승", 85)
test_scores3.insert("영훈", 90)
test_scores3.insert("동욱", 87)
test_scores3.insert("지웅", 99)
test_scores3.insert("신의", 88)
test_scores3.insert("규식", 97)
test_scores3.insert("태호", 90)

# 학생들 시험 점수 삭제
test_scores3.delete_by_key("태호")
test_scores3.delete_by_key("지웅")
test_scores3.delete_by_key("신의")
test_scores3.delete_by_key("현승")
test_scores3.delete_by_key("규식")

print(test_scores3)

4. 해시테이블 충돌 Open Addressing

(1) Open Addressing 정의

기존의 Chaining 방법과과 달리 충돌이 있는 경우 선형탐사 혹은 제곱탐사를 이용하여 비어있는 인덱스에 삽입함

여기서 선형탐사란 충돌이 있는 경우 빈 인덱스선형적으로 순서대하나씩 찾는 방법

제곱탐사란 선형탐사처럼 하나씩이 아니라, 제곱으로 [1의 제곱(1) 2의 제곱(4) 3의제곱(9) 4의제곱(16)] 이용해서 인덱스를 찾음

 

(2) 탐색 / 삭제 연산

1) 공통점

선형(제곱) 탐사 방식으로 삽입했다면 선형탐사로 탐색하거나 삭제하면됨

 

2) 탐색 연산 시 주의점

선형탐사로 탐색했음에도 비어있는 인덱스를 찾았다는 것은 곳 데이터가 처음 부터 저장되어 있지 않음을 염두

 

3) 삭제 연산 시 주의점

선형탐사로 탐색하다가 실질적으로 존재하는 인덱스와 달리 중간에 비어있는 경우 존재하지만 없다고 판단하는 경우가 있음

이를 대비하기 위해 삭제한 부분은 "DELETED" 기록이 중요함

 

(3) 시간복잡도

연산 시간 복잡도 (최악의 경우)
삽입 O(n)
탐색 O(n)
삭제 O(n)

 

 

(4) 평균시간 복잡도

1) load factor

해시 테이블 연산 분석 시 load factor 는 중요함

a = load factor / n = 배열 크기 / m = 데이터 쌍 수

 

α = n / m

 

2)

해시 테이블에서 평균적 탐사 횟수는 1 / 1-a 보다 적음

배열(n) 100개 중 데이터(m)가 50개가 채워졌다고 하더라도 a 는 0.5임

그렇다면 기대값은 2보다 작기 때문에 탐사를 하더라도 평균 2개의 인덱스만 확인해도 빈칸을 찾을 수 있음

 

만약 탐사를 10개보다 작게 하겠다고 한다면 load factor가 0.9 가 넘지않게끔 약속하면 됨