Bài 25.218 phút đọc
Va chạm
Sau bài này bạn sẽ làm được
- Giải thích vì sao va chạm không thể tránh
- Ước lượng số va chạm bằng nghịch lý ngày sinh
- Nêu hai hướng xử lý và đánh đổi của mỗi hướng
- Chọn hướng phù hợp cho một bài toán cụ thể
Va chạm không phải dấu hiệu hàm băm của bạn tồi. Nó là điều không thể tránh, và với một bảng băm dùng bình thường thì phần lớn các ô có va chạm. Toàn bộ vấn đề là xử lý chúng cho rẻ.
#Nguyên lý chuồng bồ câu
Va chạm
Hai khóa khác nhau cho cùng một chỉ số ô. Nó xảy ra khi hai giá trị băm khác nhau nhưng cùng dư, hoặc khi hai giá trị băm bằng nhau.
/* Chuồng bồ câu: n con chim, m chuồng, n > m
thì có ít nhất một chuồng chứa từ hai con trở lên.
Dạng mạnh: có ít nhất một chuồng chứa ít nhất trần(n/m) con.
Với 10000 khóa và 4096 ô: trần(10000/4096) = 3.
Vậy chắc chắn có ô chứa ít nhất 3 khóa, dù hàm băm hoàn hảo tới đâu. */#Nghịch lý ngày sinh
Câu hỏi quen thuộc: trong một phòng bao nhiêu người thì xác suất có hai người trùng ngày sinh vượt quá một nửa. Câu trả lời gây ngạc nhiên: chỉ 23 người.
ngay-sinh.c
#include <stdio.h>
/* Xác suất KHÔNG có cặp nào trùng, với n khóa và m ô. */
static double p_khong_trung(long n, long m)
{
double p = 1.0;
for (long i = 0; i < n; ++i)
p *= (double)(m - i) / (double)m;
return p;
}
int main(void)
{
printf("%8s %10s %14s\n", "so khoa", "so o", "P(co va cham)");
struct { long n, m; } thu[] = {
{ 23, 365 },
{ 50, 365 },
{ 10, 1000 },
{ 100, 1000 },
{ 1000, 1000000 },
{ 77163, 1000000 },
};
for (size_t i = 0; i < sizeof thu / sizeof thu[0]; ++i)
printf("%8ld %10ld %14.4f\n",
thu[i].n, thu[i].m,
1.0 - p_khong_trung(thu[i].n, thu[i].m));
return 0;
}terminal
gcc -std=c17 ngay-sinh.c -o t && ./t
so khoa so o P(co va cham)
23 365 0.5073
50 365 0.9704
10 1000 0.0441
100 1000 0.9940
1000 1000000 0.3933
77163 1000000 1.0000#Có bao nhiêu va chạm trong thực tế
phan-bo.c
#include <math.h>
#include <stdio.h>
int main(void)
{
printf("%8s %8s %10s %12s %14s\n",
"n khoa", "m o", "he so tai", "o trong (%)", "chuoi TB");
struct { long n, m; } thu[] = {
{ 1000, 1024 },
{ 10000, 16384 },
{ 100000, 131072 },
{ 1000, 100 },
{ 1000, 10000 },
};
for (size_t i = 0; i < sizeof thu / sizeof thu[0]; ++i) {
double n = (double)thu[i].n;
double m = (double)thu[i].m;
/* Số ô trống kỳ vọng: m * (1 - 1/m)^n, xấp xỉ m * e^(-n/m) */
double trong = m * pow(1.0 - 1.0 / m, n);
double khac_0 = m - trong;
printf("%8ld %8ld %10.2f %12.1f %14.2f\n",
thu[i].n, thu[i].m, n / m, 100.0 * trong / m, n / khac_0);
}
return 0;
}terminal
gcc -std=c17 phan-bo.c -o t -lm && ./t
n khoa m o he so tai o trong (%) chuoi TB
1000 1024 0.98 37.6 1.57
10000 16384 0.61 54.3 1.34
100000 131072 0.76 46.6 1.43
1000 100 10.00 0.0 10.00
1000 10000 0.10 90.5 1.05#Hai hướng xử lý
Hướng 1: chaining, để va chạm ra ngoài bảng
/* Mỗi ô là đầu một danh sách liên kết.
Va chạm thì thêm vào danh sách đó.
o 0 -> NULL
o 1 -> "an" -> "ba" -> NULL
o 2 -> NULL
o 3 -> "cuc" -> NULL
o 4 -> "do" -> "em" -> "ga" -> NULL */Hướng 2: open addressing, tìm ô khác trong bảng
/* Ô bị chiếm thì dò sang ô kế tiếp theo một quy tắc.
o 0: trong
o 1: "an" <- hash("an") = 1
o 2: "ba" <- hash("ba") = 1, bi chiem, do sang 2
o 3: "cuc"
o 4: trong */| Tiêu chí | Chaining | Open addressing |
|---|---|---|
| Độ khó cài đặt | Dễ | Khó, nhất là phép xóa |
| Bộ nhớ | Thêm 8 byte con trỏ mỗi phần tử, cộng chi phí malloc | Không thêm gì, nhưng cần ô trống dư |
| Thân thiện bộ nhớ đệm | Kém, mỗi bước một lần nhảy | Tốt, các ô cạnh nhau |
| Hệ số tải chịu được | Trên 1 vẫn chạy được | Phải giữ dưới 0,7 |
| Xóa | Dễ, gỡ khỏi danh sách | Cần bia mộ, xem Bài 25.4 |
| Trường hợp xấu nhất | O(n) nhưng suy giảm mượt | O(n) và suy giảm rất gắt |
| Số lần cấp phát | Một lần cho mỗi phần tử | Chỉ khi rehash |
#Chọn hướng nào
| Thư viện hoặc ngôn ngữ | Hướng dùng | Ghi chú |
|---|---|---|
| Từ điển của Python | Open addressing | Dò giả ngẫu nhiên, giữ thêm thứ tự chèn từ phiên bản 3.7 |
| HashMap của Java | Chaining | Chuyển ô sang cây đỏ đen khi chuỗi dài quá 8 |
| unordered_map của C++ | Chaining | Chuẩn ngôn ngữ gần như bắt buộc, vì nó yêu cầu con trỏ tới phần tử phải bền |
| HashMap của Rust | Open addressing | Mẫu swiss table, dò theo nhóm 16 ô cùng lúc |
| Bảng băm của nhân Linux | Chaining | Dùng danh sách liên kết đơn nhúng kiểu list_head |
Ba cách khác ít gặp hơn
| Cách | Ý tưởng | Đặc điểm |
|---|---|---|
| Băm Cuckoo | Hai hàm băm, mỗi khóa có đúng hai ô hợp lệ, chèn thì đẩy khóa cũ sang ô kia của nó | Tìm luôn là O(1) tuyệt đối, chèn có thể phải rehash |
| Băm Robin Hood | Open addressing, nhưng khóa nào đã dò xa hơn thì được ưu tiên giữ chỗ | Giảm mạnh khoảng cách dò xấu nhất |
| Băm hoàn hảo | Biết trước toàn bộ tập khóa, xây hàm băm không va chạm nào | Chỉ dùng được với tập khóa tĩnh, ví dụ từ khóa của ngôn ngữ |
Tự làm thử
- Cài hàm tính xác suất có va chạm và kiểm rằng với 365 ô thì ngưỡng một nửa rơi vào 23 khóa.
- Kiểm quy tắc căn bậc hai: với
mbằng 100, 10 nghìn và một triệu, so số khóa thực tế với1.18 * sqrt(m). - Cài hàm tính số ô trống kỳ vọng, rồi so với số đo được thật khi băm mười nghìn từ vào bảng 16384 ô.
- Vẽ bảng số lần dò trung bình theo hệ số tải cho dò tuyến tính, với
atừ 0,1 tới 0,99. - Với bài toán bạn đang có, viết ra ba lý do chọn chaining và ba lý do chọn open addressing, rồi quyết định.
Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.
Tóm tắt
- Va chạm không thể tránh, vì miền khóa vô hạn còn miền giá trị băm hữu hạn. Nguyên lý chuồng bồ câu.
- Nghịch lý ngày sinh nói va chạm xuất hiện sớm hơn nhiều so với trực giác: khoảng
1.18 * căn bậc hai của mkhóa là đủ. - Với hệ số tải quanh 0,75 thì khoảng 37 tới 50 phần trăm ô vẫn trống và chuỗi trung bình chỉ dài khoảng 1,4.
- Chaining suy giảm mượt khi hệ số tải tăng, còn open addressing suy giảm rất gắt và bắt buộc phải giữ tải dưới 0,7.
- Chaining dễ cài và dễ xóa, open addressing nhanh hơn và tiết kiệm bộ nhớ. Cả hai đều xuất hiện trong thư viện thật.