← Cấu trúc dữ liệu & Giải thuật

Bài 6 · Nâng cao · 22 phút

Bảng băm

Biên soạn bởi Nguyễn Anh Tuấn

Bảng băm (hash table): hàm băm biến khoá thành chỉ số để tra cứu gần như O(1); xử lý va chạm (collision) bằng chaining; vì sao Map và Set nhanh.

Bài Cây nhị phân tìm kiếm cho ta tra cứu O(log n) mà vẫn giữ thứ tự. Nhưng nhiều lúc mèo con chẳng cần thứ tự - chỉ muốn hỏi “khoá này có chưa?” hoặc “lấy giá trị của khoá này” thật nhanh. Có cách nhanh hơn cả O(log n) không?

Có: bảng băm (hash table), trung bình O(1). Hình dung một dãy ngăn tủ đánh số. Thay vì dò từng ngăn, ta có một hàm băm nhận khoá và nói ngay “khoá này thuộc ngăn số mấy” - đi thẳng tới đó, không cần tìm.

  • Bảng băm tra cứu theo khoá gần như tức thì - trung bình O(1).
  • Bí quyết: hàm băm tính thẳng ra vị trí cần tới, không phải dò.
  • Đánh đổi: bảng băm KHÔNG giữ thứ tự (khác cây nhị phân tìm kiếm).

Một hàm băm (hash function) biến khoá (chuỗi, số…) thành một con số. Lấy số đó chia lấy dư cho số xô (bucket) là ra chỉ số xô để đặt khoá. Tra cứu cũng tính y vậy rồi tới thẳng xô đó. Hàm băm đồ chơi của ta: cộng mã ký tự.

  • hash(khoá) → số; số mod (số xô) → chỉ số xô.
  • Đặt và tra đều dùng cùng công thức, nên luôn tới đúng xô.
  • Một hàm băm tốt phải rải khoá ĐỀU ra các xô, và tính nhanh.

Vì nhiều khoá hơn số xô, kiểu gì cũng có hai khoá rơi cùng một xô - gọi là va chạm (collision). Với hàm cộng mã, mọi từ đảo chữ đều va chạm: catact cùng tổng 312, cùng xô. Cách hoá giải phổ biến là chaining: mỗi xô giữ một danh sách các khoá (đúng danh sách liên kết ở bài 2), va chạm thì nối thêm vào chuỗi:

bang-bam.ts · va chạm rơi chung một xô, nối thành chuỗi

function hashCode(key: string): number {
  let sum = 0;
  for (let i = 0; i < key.length; i++) sum += key.charCodeAt(i);
  return sum;
}

const xo: string[][] = Array.from({ length: 8 }, () => []); // 8 xo, moi xo mot danh sach
const them = (key: string) => xo[hashCode(key) % 8].push(key);

them('cat'); them('dog'); them('act'); // 'cat' & 'act' dao chu -> cung tong

console.log(hashCode('cat') % 8); // xo cua 'cat'
console.log(hashCode('act') % 8); // xo cua 'act' -> trung! (va cham)
console.log(xo[0]);               // hai khoa noi thanh chuoi trong xo 0
console.log(hashCode('fox') % 8); // 'fox' di xo khac

Kết quả khi chạy

0
0
[ 'cat', 'act' ]
5

Khi tra một khoá, ta tới đúng xô rồi đi trong chuỗi của xô đó để so. Xô ngắn thì gần như tức thì; xô dài thì chậm dần.

  • Va chạm: hai khoá khác nhau cùng chỉ số xô - không tránh hết được.
  • Chaining: mỗi xô là một danh sách; va chạm thì nối thêm.
  • Tra cứu = tới xô + đi trong chuỗi của xô đó.

Gõ một khoá rồi bấm Thêm để xem hàm băm chọn xô nào; thử thêm cat rồi act để thấy va chạm nối chuỗi. Bấm Tìm để xem phải so mấy lần trong xô:

Khoá mèo đi qua hàm băm ra chỉ số 3, trỏ vào mảng bucket; bucket 3 chứa chuỗi hai entry do va chạm (chaining).
Khoá đi qua hàm băm ra chỉ số bucket; hai khoá trùng index thì nối chuỗi (chaining).
|

hash("fox") = 333 → 333 mod 8 = xô 5

0
cat act
1
trống
2
dog
3
trống
4
trống
5
trống
6
trống
7
trống
Số khoá: 3 Số xô: 8 Hệ số tải: 0.38

Thêm cat rồi act (đảo chữ) để thấy va chạm: cùng giá trị băm nên rơi chung một xô, nối thành chuỗi. Xô càng dài thì tra cứu càng chậm - đó là vì sao cần hàm băm rải đều.

  • Cùng giá trị băm → cùng xô → nối vào chuỗi (chaining).
  • Tìm trong xô ngắn tốn ít phép so; xô dài tốn nhiều.
  • Hệ số tải càng cao thì các xô càng dài.

Nếu hàm băm rải khoá đều và số xô đủ nhiều, mỗi xô chỉ có một hai khoá, nên tra cứu gần như O(1). Để giữ điều đó khi thêm nhiều khoá, bảng băm theo dõi hệ số tải (load factor) = số khoá / số xô; vượt ngưỡng thì tăng số xô và rải lại (rehash).

Trung thực

Hàm băm cộng mã ký tự ở đây là đồ chơi - dễ va chạm tới mức mọi từ đảo chữ chung xô. Bản thật (SipHash trong Python, V8) rải đều hơn nhiều và chống cả kẻ cố tình tạo va chạm. Nếu hàm băm quá dở, mọi khoá dồn vào MỘT xô, bảng băm tụt thành một danh sách liên kết và tra cứu thành O(n) - mất sạch ưu thế.
  • Hàm băm tốt + đủ xô → mỗi xô ngắn → O(1) trung bình.
  • Hệ số tải cao → xô dài → cần tăng số xô (rehash).
  • Hàm băm dở dồn hết vào một xô → O(n), như danh sách liên kết.

Mèo con giờ có hai cách tra cứu nhanh, cho hai nhu cầu khác nhau: bảng băm (O(1), không thứ tự - hợp khi chỉ cần “có/không” hay lấy theo khoá) và cây nhị phân tìm kiếm (O(log n), giữ thứ tự). Đây cũng là lý do Map/Set (JS) và dict (Python) nhanh đến vậy: chúng là bảng băm.

Bước tiếp theo

Tới giờ dữ liệu của ta là dãy hoặc cây. Nhưng rất nhiều thứ trong đời là một mạng lưới quan hệ: bạn bè, đường đi, trang web nối nhau. Để mô hình hoá và đi qua chúng, ta cần Đồ thị & duyệt (BFS/DFS).

Câu hỏi thường gặp

Về tra cứu “có/không” và lấy theo khoá thì có: trung bình O(1) so với O(log n) của BST. Nhưng bảng băm KHÔNG giữ thứ tự - bạn không thể hỏi “phần tử nhỏ nhất” hay “liệt kê tăng dần” một cách rẻ. Cần thứ tự thì dùng BST; chỉ cần tra nhanh thì dùng bảng băm.

Không - nó quá dễ va chạm (mọi từ đảo chữ như cat/act đều cùng giá trị). Bài dùng nó cho dễ hiểu. Thực tế Python và V8 (JavaScript) dùng hàm băm mạnh như SipHash, rải khoá đều hơn nhiều và chống được kẻ cố tình tạo va chạm. Ý TƯỞNG thì y hệt: khoá → số → xô.

Không, về nguyên tắc. Nếu có nhiều khoá hơn số xô thì kiểu gì cũng có hai khoá chung xô (nguyên lý chuồng bồ câu). Mục tiêu không phải xoá sạch va chạm mà là giữ cho mỗi xô NGẮN, bằng hàm băm tốt và đủ số xô.

Là số khoá chia cho số xô. Tải càng cao thì xô càng dài, tra cứu càng chậm. Khi tải vượt một ngưỡng (thường ~0,75), bảng băm tự TĂNG SỐ XÔ rồi rải lại toàn bộ khoá (rehash) để xô ngắn lại - giữ O(1) trung bình.

Có: open addressing (địa chỉ mở) - khi xô đã có khoá, ta dò sang xô kế tiếp theo một quy tắc cho tới khi gặp chỗ trống. Python dùng cách này. Chaining (mỗi xô một danh sách) dễ hiểu hơn nên bài này dùng nó.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Hàm băm (hash function) trong bảng băm dùng để làm gì?

  1. 1

    Tính xô bằng tay

    Hàm băm cộng mã ASCII (a=97 … z=122). Với bảng 8 xô, tính chỉ số xô của khoá be (b=98, e=101).

    Hoàn thành khi: hash(be) = 98 + 101 = 199; 199 mod 8 = 7. Vậy be vào xô 7.

  2. 2

    Tìm một va chạm

    Vẫn hàm băm cộng mã, tìm hai từ tiếng Anh KHÁC nhau nhưng cùng giá trị băm (gợi ý: đảo chữ).

    Hoàn thành khi: Ví dụ hợp lý: catact (đều = 312), hoặc doggod. Hai khoá khác nhau, cùng tổng.

  3. 3

    Vẽ chaining

    Thêm lần lượt cat, dog, act, fish vào bảng 8 xô, vẽ các xô và khoá trong mỗi xô.

    Hoàn thành khi: Xô 0: cat → act; xô 2: dog → fish; các xô khác trống. (Đảo chữ và trùng tổng nên chung xô.)

  4. 4

    Đếm bước tra cứu

    Trong bảng vừa vẽ, tìm act và tìm owl tốn bao nhiêu phép so khoá trong xô?

    Hoàn thành khi: Tìm act: xô 0 có cat, act - so 2 lần mới thấy. Tìm owl: vào xô của nó (rỗng) - 0 lần, kết luận không có.

  5. 5

    Khi nào O(n)

    Mô tả một tình huống khiến bảng băm tra cứu chậm như O(n), và nêu cách khắc phục.

    Hoàn thành khi: Khi hàm băm dở dồn (gần) hết khoá vào một xô - xô thành danh sách dài. Khắc phục: hàm băm tốt hơn và tăng số xô (giảm hệ số tải).

  6. 6

    Băm hay cây?

    Cho 2 yêu cầu, chọn bảng băm hay BST: (1) kiểm tra một username đã tồn tại chưa; (2) liệt kê toàn bộ điểm thi theo thứ tự tăng dần.

    Hoàn thành khi: (1) Bảng băm (chỉ cần có/không, O(1)). (2) BST/cấu trúc có thứ tự (cần duyệt tăng dần).