해시 테이블(Hash Table)은 키(key)와 값(value)을 쌍으로 저장하는 자료구조입니다. 해시 테이블의 핵심은 키를 입력으로 받아서 해시 함수(Hash Function)를 통해 고유한 인덱스를 생성하고, 이 인덱스를 배열의 위치로 사용해 값을 저장하는 것입니다. 이로 인해 빠른 검색, 삽입 및 삭제를 가능하게 합니다.
해시 테이블을 이해하는 데 중요한 개념은 다음과 같습니다:
해시 테이블은 빠른 검색, 삽입 및 삭제를 제공하므로, 데이터베이스, 캐싱, 심볼 테이블, 맵 등과 같은 다양한 애플리케이션에서 사용됩니다. 다음은 해시 테이블의 몇 가지 일반적인 사용 사례입니다:
해시 테이블의 단점은 충돌 해결 메커니즘이 추가적인 오버헤드를 발생시킬 수 있다는 것입니다. 또한, 해시 테이블의 크기가 고정되어 있어 메모리를 효율적으로 사용하기 어렵습니다. 그러나 충돌을 최소화하고, 적절한 크기를 선택하면 해시 테이블은 매우 빠른 검색, 삽입 및 삭제를 제공하는 효율적인 자료구조로 작동합니다.
"버킷"은 각 해시 값에 대응하는 배열을 의미하고, "슬롯"은 배열 내의 개별 위치를 나타냅니다.
결론적으로, 이 두 용어는 서로 바꿔 사용되기도 하며, 일부 참고자료에서는 약간의 의미 차이가 있을 수 있습니다. 이를 이해하고 문맥에 따라 적절한 용어를 사용하는 것이 중요합니다.
장점:
단점:
적합한 상황:
부적합한 상황:
주의사항:
이러한 사항들을 염두에 두고 해시 테이블을 사용하면, 효율적이고 안정적인 성능을 얻을 수 있습니다.
4-1. Chaining 기법
4-2. Linear Probing 기법
해쉬 테이블 구현에 사용될 SHA-256 미리보기
import hashlib
data = 'test'.encode() # 스트링을 꼭 인코딩 시켜 사용한다.
hash_object = hashlib.sha256()
hash_object.update(data)
hex_dig = hash_object.hexdigest()
print (hex_dig) # 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
len('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08')
# 64
print('test'.encode()) # 인코딩시 스트링 앞에 b가 붙는다.
# b'test'
Chaining 기법을 적용한 해쉬 테이블
import hashlib
hash_table = list([0 for i in range(16)])
def get_key(data):
hash_object = hashlib.sha256()
hash_object.update(data.encode())
hex_dig = hash_object.hexdigest()
return int(hex_dig, 16)
def hash_function(key):
return key % 16
def save_data(data, value):
index_key = get_key(data)
hash_address = hash_function(index_key)
if hash_table[hash_address] != 0:
for index in range(len(hash_table[hash_address])):
if hash_table[hash_address][index][0] == index_key:
hash_table[hash_address][index][1] = value
return
hash_table[hash_address].append([index_key, value])
else:
hash_table[hash_address] = [[index_key, value]]
def read_data(data):
index_key = get_key(data)
hash_address = hash_function(index_key)
if hash_table[hash_address] != 0:
for index in range(len(hash_table[hash_address])):
if hash_table[hash_address][index][0] == index_key:
return hash_table[hash_address][index][1]
return None
else:
return None
print (hash('Dave') % 8)
print (hash('Dd') % 8)
print (hash('Dg') % 8)
print (hash('Df') % 8)
print (hash('Di') % 8)
# 5
# 2
# 3
# 5
# 2
save_data('Di', '8880008880')
save_data('Dd', '1201023010')
save_data('Data', '3301023010')
read_data('Dd')
# '1201023010'
hash_table
# [0,
# 0,
# [[599905164450819986, '1201023010'], [2979456850781706866, '8880008880']],
# 0,
# 0,
# [[-6126081656233692051, '8880008880']],
# [[-794735671643054914, '3301023010']],
# 0]
Linear Probing 기법을 적용한 해쉬 테이블
import hashlib
hash_table = list([0 for i in range(16)])
def get_key(data):
hash_object = hashlib.sha256()
hash_object.update(data.encode())
hex_dig = hash_object.hexdigest()
return int(hex_dig, 16)
def hash_function(key):
return key % 16
def save_data(data, value):
index_key = get_key(data)
hash_address = hash_function(index_key)
if hash_table[hash_address] != 0:
for index in range(hash_address, len(hash_table)):
if hash_table[index] == 0:
hash_table[index] = [index_key, value]
return
elif hash_table[index][0] == index_key:
hash_table[index][1] = value
return
else:
hash_table[hash_address] = [index_key, value]
def read_data(data):
index_key = get_key(data)
hash_address = hash_function(index_key)
if hash_table[hash_address] != 0:
for index in range(hash_address, len(hash_table)):
if hash_table[index] == 0:
return None
elif hash_table[index][0] == index_key:
return hash_table[index][1]
else:
return None
print (hash('dd') % 8)
print (hash('dg') % 8)
# save_data('dd', '7777777777')
# save_data('dg', '8888888888')
# read_data('dd')
# read_data('dg')
# '8888888888'
hash_table
# [0,
# 0,
# 0,
# 0,
# 0,
# 0,
# 0,
# [1434459913659973535, '7777777777'],
# [-4344317883337336537, '8888888888'],
# 0]
Chaining 기법 적용
class LinkedListNode {
constructor(key, value, next = null) {
this.key = key;
this.value = value;
this.next = next;
}
}
class HashTable {
constructor(size = 50) {
this.size = size;
this.buckets = new Array(size).fill(null);
}
hash(key) {
let hashValue = 0;
for (const char of key) {
hashValue += char.charCodeAt(0);
}
return hashValue % this.size;
}
set(key, value) {
const index = this.hash(key);
if (!this.buckets[index]) {
this.buckets[index] = new LinkedListNode(key, value);
return;
}
let currentNode = this.buckets[index];
while (currentNode !== null) {
// When met same key
if (currentNode.key === key) {
currentNode.value = value;
return;
}
if (currentNode.next === null) {
currentNode.next = new LinkedListNode(key, value);
return;
}
currentNode = currentNode.next;
}
}
get(key) {
const index = this.hash(key);
let currentNode = this.buckets[index];
while (currentNode !== null) {
if (currentNode.key === key) {
return currentNode.value;
}
currentNode = currentNode.next;
}
return undefined;
}
delete(key) {
const index = this.hash(key);
if (this.buckets[index] === null) {
return;
}
if (currentNode.key === key) {
this.buckets[index] = currentNode.next;
return;
}
let previousNode = currentNode;
let currentNode = currentNode.next;
while (currentNode !== null) {
if (currentNode.key === key) {
previousNode.next = currentNode.next;
return;
}
previousNode = currentNode;
currentNode = currentNode.next;
}
return;
}
}
// Test cases
const testHashTable = new HashTable();
testHashTable.set("apple", "delicious");
testHashTable.set("banana", "tasty");
testHashTable.set("orange", "sweet");
console.log(testHashTable.get("apple")); // 출력: "delicious"
console.log(testHashTable.get("banana")); // 출력: "tasty"
console.log(testHashTable.get("orange")); // 출력: "sweet"
console.log(testHashTable.get("grape")); // 출력: undefined
testHashTable.set("banana", "very tasty");
console.log(testHashTable.get("banana")); // 출력: "very tasty"
testHashTable.delete("orange");
console.log(testHashTable.get("orange")); // 출력: undefined
testHashTable.set("watermelon", "juicy");
console.log(testHashTable.get("watermelon")); // 출력: "juicy"
testHashTable.delete("apple");
console.log(testHashTable.get("apple")); // 출력: undefined
위 구현된 해시 테이블에서는 연결 리스트를 사용하여 충돌을 처리하고 있습니다. 이러한 구조에서 버킷은 해시 테이블의 각 인덱스를 나타내며, 슬롯은 연결 리스트의 각 노드를 의미합니다.
예를 들어, 아래와 같은 데이터를 저장한다고 가정해 보겠습니다.
hashTable.set("firstName", "John");
hashTable.set("lastName", "Doe");
hashTable.set("age", 30);
hashTable.set("city", "New York");
추상적으로 표현하면, 해시 테이블의 구조는 다음과 같이 됩니다.
buckets: [
null,
{ key: "firstName", value: "John", next: { key: "city", value: "New York", next: null } },
null,
{ key: "lastName", value: "Doe", next: null },
null,
{ key: "age", value: 30, next: null },
...
]
이 구조에서는:
이 구조를 사용하면, 같은 버킷에 여러 개의 슬롯을 저장할 수 있으며, 충돌이 발생해도 연결 리스트를 통해 여러 키-값 쌍을 저장할 수 있습니다.
체이닝(Chaining) 방식을 사용할 상황:
선형 탐사 방식의 해시 테이블
class HashTable {
constructor(size = 50) {
this.size = size;
this.buckets = new Array(size).fill(null);
}
hash(key) {
let index = 0;
for (let char of key) {
index += char.charCodeAt(0);
}
return index % this.size;
}
isCollision(index, key) {
return this.buckets[index] !== null && this.buckets[index].key !== key;
}
set(key, value) {
let index = this.hash(key);
while (this.isCollision(index, key)) {
index = (index + 1) % this.size;
}
this.buckets[index] = { key, value };
}
get(key) {
let index = this.hash(key);
while (this.buckets[index] !== null) {
if (this.buckets[index].key === key) {
return this.buckets[index].value;
}
index = (index + 1) % this.size;
}
return undefined;
}
delete(key) {
let index = this.hash(key);
while (this.buckets[index] !== null) {
if (this.buckets[index].key === key) {
this.buckets[index] = null;
}
index = (index + 1) % this.size;
}
}
}
// Test cases
const hashTable = new HashTable();
hashTable.set("firstName", "John");
hashTable.set("lastName", "Doe");
hashTable.set("email", "john.doe@example.com");
hashTable.set("age", 30);
console.log(hashTable.get("firstName")); // 출력: "John"
console.log(hashTable.get("lastName")); // 출력: "Doe"
console.log(hashTable.get("email")); // 출력: "john.doe@example.com"
console.log(hashTable.get("age")); // 출력: 30
hashTable.set("lastName", "Smith");
console.log(hashTable.get("lastName")); // 출력: "Smith"
hashTable.delete("lastName");
console.log(hashTable.get("lastName")); // 출력: undefined
// 충돌 테스트
hashTable.set("collision1", "value1");
hashTable.set("collision2", "value2");
hashTable.set("collision3", "value3");
console.log(hashTable.get("collision1")); // 출력: "value1"
console.log(hashTable.get("collision2")); // 출력: "value2"
console.log(hashTable.get("collision3")); // 출력: "value3"
hashTable.delete("collision2");
console.log(hashTable.get("collision1")); // 출력: "value1"
console.log(hashTable.get("collision2")); // 출력: undefined
console.log(hashTable.get("collision3")); // 출력: "value3"
선형 탐사(Linear Probing) 방식과 체이닝(Chaining) 방식은 해시 테이블에서 충돌을 처리하는 방법입니다. 어떤 방식을 사용할지 결정하는 데는 여러 요인이 있지만, 주요한 기준은 충돌의 발생 확률, 메모리 사용량 및 성능에 관한 요구사항입니다.
선형 탐사(Linear Probing) 방식을 사용할 상황:
해쉬 테이블의 경우, 일반적인 경우를 기대하고 만들기 때문에, 시간 복잡도는 O(1) 이라고 말할 수 있음
검색에서 배열과 해쉬 테이블의 시간 복잡도 비교