Hash Table

อยากรู้เร็ว ๆ ว่าข้อมูลชิ้นหนึ่งอยู่ในคอลเลกชันหรือไม่ — hash table ทำได้ในเวลา Θ(1) โดยเฉลี่ย

ทำไมต้องมี hash table

ตัวอย่าง: ระบบลานจอดรถบันทึกทะเบียนรถเข้า–ออก อยากเช็คว่ารถคันนี้อยู่ในลานหรือไม่

โครงสร้างข้อมูลเวลาค้นหา
ArrayO(n)
Sorted ArrayO(log n)
Stack / Queue / Linked listO(n)
Binary Search TreeO(log n)
Hash TableΘ(1) โดยเฉลี่ย

Direct-address table

เอาค่า key ไปเป็น index ของ array ตรง ๆ

void insert(int key, String data) { a[key] = data; }
void delete(int key)              { a[key] = null; }
String search(int key)            { return a[key]; }

ค้นหาได้ O(1) แต่มีปัญหาใหญ่:

  • ถ้า key เป็นเลขทะเบียน 11000–11341 ต้องจอง array ใหญ่ถึง 11341 ช่อง
  • ข้อมูลจริงมีนิดเดียว แต่ช่องว่างเปล่าเต็มไปหมด → เปลืองหน่วยความจำมหาศาล

วิธีบรรเทา: แม็ป key ให้เล็กลงก่อน

key อยู่ในช่วง 150000–159999
array index = key - 150000       // เหลือ 0–9999

นี่คือรูปแบบง่ายที่สุดของ hash function

Hashing คืออะไร

key  →  [ hash function ]  →  hash value (index ในตาราง)

Hash function

ตัวอย่าง: ตารางขนาด 10

f(key) = key % 10

11000 % 10 = 0
11001 % 10 = 1
…
11339 % 10 = 9

แล้วถ้ามี 1 กับ 11 ล่ะ? ทั้งคู่ได้ index 1 เหมือนกัน — เรียกว่า collision (การชนกัน)

Hash function ที่ไม่ดี

ตัวอย่างที่ 1: เลือกหลักที่ 3 และ 8 ของเบอร์โทร

hash(053920299) = 39
hash(053943811) = 31

ตัวอย่างที่ 2: ใช้ 3 หลักแรกของเบอร์โทรทั้งประเทศ → คนจังหวัดเดียวกันตกช่องเดียวกันหมด ชนกันเละ

ตัวอย่างที่ 3: ใช้ 2 หลักแรกของรหัสนักศึกษา (ปีที่เข้า) → นักศึกษาปีเดียวกันหลายพันคนตกช่องเดียวกัน

หลักการ: ต้องเข้าใจพฤติกรรมของ key ก่อน แล้วออกแบบ f ให้กระจายค่าอย่างสม่ำเสมอ (uniform)

Perfect hash function = แม็ปแบบหนึ่งต่อหนึ่ง ไม่มี collision เลย ทำได้ต่อเมื่อรู้ key ทั้งหมดล่วงหน้า (Minimal perfect hash = ขนาดตารางเท่ากับจำนวน key พอดี)

Uniform / Division / Multiplication

Uniform hash function

ถ้า key กระจายสม่ำเสมอในช่วง [0, X) และตารางมีขนาด m

hash(key) = key × (m / X)

ตัวอย่าง

X = 10, m = 5, key = 5
hash(5) = 5 × 5/10 = 2

Division method (นิยมที่สุด)

hash(key) = key % m

Multiplication method

ให้ A เป็นเศษส่วน 0 < A < 1

hash(key) = m × ( key×A - ⌊key×A⌋ )   // เอาเฉพาะส่วนทศนิยมของ key×A

Hashing สตริง

ข้อมูลจริงมักไม่ใช่ตัวเลข เช่นชื่อคน ทะเบียนรถ เลขพาสปอร์ต — ต้องแปลงเป็นตัวเลขก่อน

int hash1(String str){
    int sum = 0;
    for(int i = 0; i < str.length(); i++){
        // บวกค่า ASCII
        sum += str.charAt(i);
    }
    return sum % SIZE_OF_TABLE;
}

ตัวอย่าง

hash1("Tan Ah Teck")
 = (84+97+110+32+65+104+32+84+101+99+107) % 11
 = 915 % 11
 = 0
ปัญหาของ hash1: ผลรวมไม่สนใจลำดับตัวอักษร
Adian  → 65+100+105+97+110 = 477
Diana  → 68+105+97+110+97  = 477
Nadia  → 78+97+100+105+97  = 477
คำที่สลับตัวอักษรกันจะชนกันหมด

แก้ด้วยการ "เลื่อน" ผลรวมก่อนบวกตัวถัดไป ให้ตำแหน่งมีผลต่อค่า:

int hash2(String str){
    int sum = 0;
    for(int i = 0; i < str.length(); i++){
        sum = (sum * 37) + str.charAt(i);
    }
    return sum % SIZE_OF_TABLE;
}
Adian → 127,033,209
Diana → 132,898,473
Nadia → 151,238,781      // ต่างกันแล้ว

Collision resolution — ภาพรวม

กลุ่มวิธีแนวคิด
Close addressingSeparate Chainingชนแล้วต่อ chain — เก็บเป็น linked list ในช่องเดียวกัน
Open addressingLinear Probingชนแล้วเลื่อนไปช่องถัดไป
Quadratic Probingชนแล้วกระโดดเป็นกำลังสอง
Double Hashingใช้ hash ตัวที่สองกำหนดระยะกระโดด

Separate Chaining

ช่องที่ชนกันเก็บเป็น linked list ต่อกันไป

f(key) = key % 10

index 0:  10  →  null
index 1:   1  →  11  →  null
index 3:  43  →  53  →  null
f(key) = key % 10 · ค่าที่ชนกันต่อกันเป็น linked list index 0 index 1 index 2 index 3 10 → null 1 11 → null null (ว่าง) 43 53 → null 1 กับ 11 ชนกัน 43 กับ 53 ชนกัน ค้นหา = O(ความยาว list) · load factor ∝ = n/m คือความยาวเฉลี่ย

Separate chaining — ชนแล้วต่อ chain ไม่ย้ายช่อง

เวลาค้นหา = O(ความยาวของ list) — ถ้า list ยาวมากก็ช้าเท่า linked list ธรรมดา

Linear Probing

ชนแล้วเลื่อนไปช่องว่างถัดไปเรื่อย ๆ วนกลับมาต้นตารางเมื่อถึงช่องสุดท้าย

ตาราง 7 ช่อง, hash(k) = k % 7

ใส่hashผลลัพธ์
1818%7 = 4ลง A[4]
1414%7 = 0ลง A[0]
2121%7 = 0ชน! เลื่อนไป A[1]
11%7 = 1ชน! เลื่อนไป A[2]
3535%7 = 0ชน 0,1,2 → ลง A[3]
A[0]=14  A[1]=21  A[2]=1  A[3]=35  A[4]=18
ตาราง 7 ช่อง · hash(k) = k % 7 · ชนแล้วเลื่อนไปช่องว่างถัดไป A[0] A[1] A[2] A[3] A[4] A[5] A[6] ใส่ 18 18 hash = 4 ลงตรง ๆ ใส่ 14 14 18 hash = 0 ลงตรง ๆ ใส่ 21 14 21 18 hash = 0 ชน → เลื่อนไป A[1] ใส่ 1 14 21 1 18 hash = 1 ชน → เลื่อนไป A[2] ใส่ 35 14 21 1 35 18 hash = 0 ชน 3 ครั้ง → A[3]

ลบ 21 แล้วต้องทำเครื่องหมาย deleted ไม่งั้นค้นหา 35 จะหยุดที่ช่องว่างแล้วสรุปผิดว่าไม่เจอ

ค้นหา 35: เริ่มที่ 0 แล้วไล่ไปเรื่อย ๆ เจอที่ probe ที่ 4

ค้นหา 8 (hash = 1): ไล่จนเจอช่องว่าง จึงสรุปว่าไม่มี — ใช้ 5 probes

ปัญหาตอนลบ — ถ้าลบ 21 แล้วปล่อยช่องว่างเปล่า การค้นหา 35 จะหยุดที่ช่องว่างนั้นแล้วสรุปผิดว่าไม่เจอ
วิธีแก้: เวลาลบให้ทำเครื่องหมายช่องนั้นว่า "deleted" (เช่นใส่ -1 ถ้าค่าจริงเป็นจำนวนเต็มบวก) ไม่ใช่ทำให้ว่างจริง

เวลาแทรกค่าใหม่ ถ้าเจอช่องที่ถูกทำเครื่องหมาย deleted ต้องไล่ตรวจต่อให้แน่ใจว่าไม่มีค่าเดิมอยู่แล้ว จากนั้นค่อยใส่ในช่องว่างช่องแรกที่เจอ

Quadratic Probing

ชนแล้วกระโดดเป็นระยะกำลังสอง

hash(key)
(hash(key) + 1)  % m
(hash(key) + 4)  % m
(hash(key) + 9)  % m
…
(hash(key) + k²) % m

ตาราง 7 ช่อง, hash(k) = k % 7

ใส่ 18 → 18%7 = 4  → A[4]
ใส่ 3  → 3%7  = 3  → A[3]
ใส่ 38 → 38%7 = 3  → ชน!
       (3+1)%7 = 4     → ชนอีก (18 อยู่)
       (3+4)%7 = 0     → ว่าง → ลง A[0]
ถ้ากระโดดครบ m ครั้ง (เท่ากับขนาดตาราง) แล้วยังชนอยู่ ให้หยุด

Double Hashing

ใช้ hash ตัวที่สองกำหนดระยะกระโดด ทำให้ key ต่างกันมีลำดับ probe ต่างกัน

hash(key)
(hash(key) + 1 × hash2(key)) % m
(hash(key) + 2 × hash2(key)) % m
(hash(key) + 3 × hash2(key)) % m
…

hash2 เรียกว่า secondary hash function

Load factor (∝)

∝ = n / m

n = จำนวน key ทั้งหมดที่ใส่ในตาราง
m = ขนาดตาราง (จำนวนช่อง)
ตัวอย่างผล
n=10, m=1000000.01list สั้น → ค้นหาเร็ว (แต่เปลืองหน่วยความจำ)
n=100000000, m=1010000000list ยาวมาก → ค้นหาช้า
การกระจายที่ดีขึ้นกับ 2 อย่าง: hash function ที่เลือก และ ความเข้าใจพฤติกรรมของ key เช่นรหัสนักศึกษา 540552032 ควรตัด 54 (ปีที่เข้า) ทิ้ง แล้วใช้ตัวเลขหลังแทน