Bảng băm
Bảng băm (hash table) là cấu trúc dữ liệu lưu các cặp khóa–giá trị (key–value) và cho phép thêm, tìm, xóa trong trung bình. Thay vì duyệt cả mảng mất để tìm một phần tử, bảng băm "nhảy thẳng" tới vị trí cần tìm nhờ một hàm băm biến khóa thành chỉ số. Nhờ đó nhiều bài toán tưởng như cần (đếm tần suất, tìm cặp có tổng cho trước, đếm giá trị phân biệt...) giải được trong .
Trong C++ ta dùng unordered_map / unordered_set; trong Python là dict / set / collections.Counter.
Ý tưởng / Trực giác
Giả sử cần kiểm tra nhanh "giá trị đã xuất hiện chưa". Cách ngây thơ là duyệt toàn mảng mỗi lần hỏi: một truy vấn. Bảng băm làm khác:
- Có một mảng (gọi là bucket) gồm ô.
- Một hàm băm biến khóa thành một chỉ số trong . Ví dụ đơn giản .
- Khóa được lưu ở ô số . Muốn tìm , ta chỉ tính rồi nhìn đúng ô đó — không cần duyệt cả mảng.
Vì sao trung bình là ? Nếu hàm băm phân tán khóa đều ra các ô, mỗi ô chỉ chứa rất ít khóa, nên tính rồi đọc ô là gần như tức thì. Vấn đề duy nhất là va chạm (collision): hai khóa khác nhau có cùng . Khi đó cùng một ô chứa nhiều khóa, thường được giải quyết bằng danh sách móc nối (chaining) — mỗi ô giữ một danh sách các khóa rơi vào nó. Nếu tải (số khóa / số ô) được giữ nhỏ, mỗi danh sách rất ngắn nên thao tác vẫn trung bình.
Điểm mấu chốt: bảng băm không giữ thứ tự các khóa (khác với cây tìm kiếm cân bằng). Đổi lại, nó nhanh hơn: trung bình thay vì .
Hình ảnh trực quan: hàm băm và va chạm
Chèn các khóa vào bảng có ô, với :
h(5) = 5 % 5 = 0
h(12) = 12 % 5 = 2
h(19) = 19 % 5 = 4
h(7) = 7 % 5 = 2 <-- va chạm với 12 (cùng ô 2)
Bucket sau khi chèn (chaining):
ô 0 | -> [5]
ô 1 | (rỗng)
ô 2 | -> [12] -> [7] <-- hai khóa cùng ô, nối thành danh sách
ô 3 | (rỗng)
ô 4 | -> [19]
Muốn tìm khóa : tính , nhảy thẳng tới ô 2, duyệt danh sách ngắn [12] -> [7], thấy . Ta không đụng tới ô 0, 1, 3, 4.
Trong thực tế thư viện chuẩn dùng hàm băm phức tạp hơn rất nhiều và tự rehash (tăng , băm lại) khi tải quá cao, nên ta chỉ cần dùng unordered_map / dict mà không phải tự cài.
Ví dụ chạy tay: đếm tần suất bằng hash map
Đếm số lần xuất hiện mỗi giá trị trong dãy a = [4, 2, 4, 4, 2, 7]. Dùng unordered_map<int,int> cnt. Duyệt trái sang phải, mỗi bước tăng cnt[a[i]]:
i=0 a[i]=4 cnt = {4:1}
i=1 a[i]=2 cnt = {4:1, 2:1}
i=2 a[i]=4 cnt = {4:2, 2:1}
i=3 a[i]=4 cnt = {4:3, 2:1}
i=4 a[i]=2 cnt = {4:3, 2:2}
i=5 a[i]=7 cnt = {4:3, 2:2, 7:1}
^^^
giá trị 4 xuất hiện 3 lần
Mỗi lần cnt[a[i]]++ chỉ mất trung bình, nên đếm xong toàn dãy chỉ — so với cách ngây thơ "với mỗi giá trị, duyệt lại cả mảng" tốn .
Cài đặt
Hash map (từ điển khóa–giá trị) — C++
#include <unordered_map>
using namespace std;
unordered_map<string, int> mp;
mp["alice"] = 10; // thêm / gán
mp["bob"]++; // nếu chưa có, tự tạo "bob" với giá trị 0 rồi tăng
cout << mp["alice"]; // 10
if (mp.count("alice")) { // count() trả 0 hoặc 1: kiểm tra tồn tại
// ... có khóa "alice"
}
// LƯU Ý: mp["x"] sẽ TẠO khóa "x" (giá trị 0) nếu chưa có.
// Muốn chỉ kiểm tra mà không tạo, dùng mp.count() hoặc mp.find().
for (auto& [key, val] : mp) cout << key << ": " << val << "\n";
mp.erase("alice"); // xóa khóa
Hash set (tập hợp) — C++
#include <unordered_set>
using namespace std;
unordered_set<int> st;
st.insert(1); st.insert(2); st.insert(1); // 1 chỉ được lưu một lần
cout << st.size(); // 2 -> đếm giá trị phân biệt
cout << st.count(5); // 0 -> kiểm tra 5 có trong tập không
Đếm tần suất & ứng dụng Two Sum — C++
// Đếm tần suất
unordered_map<int,int> cnt;
for (int i = 0; i < n; i++) cnt[a[i]]++;
// Two Sum: tìm i, j sao cho a[i] + a[j] = target, trong O(N)
// idx[v] = chỉ số (1-based) của giá trị v đã gặp.
unordered_map<int,int> idx;
for (int i = 0; i < n; i++) {
int need = target - a[i]; // số cần để ghép với a[i]
if (idx.count(need)) { // đã gặp "need" trước đó?
cout << idx[need] << " " << i + 1 << "\n"; // tìm thấy cặp
return;
}
idx[a[i]] = i + 1; // ghi nhận a[i] để các bước sau tra
}
cout << "IMPOSSIBLE\n";
Python
from collections import Counter
mp = {}
mp["alice"] = 10
print(mp.get("charlie", 0)) # 0 nếu không tồn tại (KHÔNG tạo khóa mới)
cnt = Counter([4, 2, 4, 4, 2, 7])
print(cnt[4]) # 3
print(cnt.most_common(1)) # [(4, 3)]
st = set()
st.add(1); st.add(2); st.add(1)
print(len(st)) # 2 -> số giá trị phân biệt
print(1 in st) # True
Độ phức tạp
| Thao tác | Trung bình | Xấu nhất | Lý do |
|---|---|---|---|
| Thêm / Tìm / Xóa | Trung bình hàm băm phân tán đều, danh sách mỗi ô rất ngắn; xấu nhất mọi khóa va chạm vào một ô tạo danh sách dài . | ||
| Duyệt toàn bộ | Đi qua tất cả các khóa đã lưu. | ||
| Bộ nhớ | Lưu khóa cộng mảng bucket; thực tế nhỉnh hơn map do hệ số ô trống. |
So với cây cân bằng (map/set): unordered_map trung bình nhanh hơn map , nhưng mất thứ tự và xấu nhất mỗi thao tác nếu bị tấn công va chạm. Nếu cần duyệt khóa theo thứ tự tăng dần, hãy dùng map / set.
⚠️ Lỗi thường gặp
operator[]âm thầm tạo khóa. Trong C++,if (mp[x] > 0)hay chỉ cần đọcmp[x]cũng tạo khóaxvới giá trị 0 nếu chưa có, làmmp.size()phình ra và đếm sai. Muốn chỉ kiểm tra, dùngmp.count(x)hoặcmp.find(x) != mp.end(). Pythondict[x]thì ngược lại: némKeyErrornếu thiếu — dùngmp.get(x, 0)hoặcdefaultdict/Counter.Tràn số khi giá trị/tổng vượt
int. Đếm cặp bằng hay tổng các tần suất nhân nhau dễ vượt . Dùnglong longcho biến tích lũy. Ví dụ với phần tử trùng nhau, số cặp là — với đã hơn , trànint.Tin rằng
unordered_mapluôn . Trên Codeforces và nhiều judge, người ta cố tình tạo bộ test "anti-hash" khiến mọi khóa va chạm, biếnunordered_map<int,...>thành mỗi thao tác → TLE. Cách chống: trộn khóa bằng một hằng số ngẫu nhiên (custom hash), hoặc đơn giản dùngmap/ sắp xếp khi vừa phải.Băm trực tiếp
pair/tupletrong C++ không biên dịch.unordered_map<pair<int,int>, int>báo lỗi vì không có hàm băm sẵn chopair. Phải tự viết hàm băm, hoặc ghép hai số thành một khóa ((long long)x * BASE + y), hoặc dùngmap<pair<int,int>, int>.So sánh số thực hay xâu dài làm khóa. Khóa
doublerất rủi ro vì sai số dấu phẩy động khiến hai giá trị "bằng nhau về toán học" lại khác bit. Với khóa là xâu dài, mỗi lần băm phải đọc cả xâu nên thao tác là , không còn thuần.Quên reset bảng giữa các test. Trong bài nhiều test, để sót dữ liệu cũ trong
unordered_maptừ test trước làm sai kết quả. Hãy.clear()ở đầu mỗi test.
Biến thể / Mở rộng
unordered_mapvới khóa tổ hợp: ghép nhiều trường thành một khóa duy nhất (xâu nối, hoặc số gộp bit) khi cần đếm theo nhiều thuộc tính.- Hash của xâu (polynomial hashing): băm cả một xâu thành một số để so sánh xâu trong — kỹ thuật riêng, mạnh trong bài xử lý xâu.
- Bảng đếm bằng mảng: khi khóa là số nguyên nhỏ trong , một mảng
int cnt[K+1]còn nhanh và chắc chắn hơnunordered_map(không lo va chạm, truy cập thật sự).
Bài tập luyện
- Cặp Bằng Nhau (eqpairs) — (Học việc) Đếm số cặp — dùng map tần suất rồi cộng ; nhớ dùng
long long. - Phép Đảo Chữ (spellanagram) — (Học việc) Kiểm tra anagram bằng cách so sánh bảng tần suất 26 chữ cái của hai xâu.
- Số Phân Biệt (distnum) — (Nghiệp dư) Đếm số giá trị phân biệt trong dãy — bài tập kinh điển cho
unordered_set. - Tổng Hai Giá Trị (sum2vals) — (Nghiệp dư) Two Sum: với mỗi tra
target - a_itrong hash map đã thấy để tìm cặp trong . - Băm Xáo Trộn (shufhash) — (Nghiệp dư) Kiểm tra hash hợp lệ: tìm cửa sổ con của có bảng tần suất chữ cái trùng với mật khẩu — kết hợp đếm tần suất và cửa sổ trượt.