Bài 25.124 phút đọc
Hàm băm
Sau bài này bạn sẽ làm được
- Nêu ba yêu cầu của một hàm băm tốt
- Cài djb2 và FNV-1a
- Đo số va chạm của ba hàm băm trên cùng bộ dữ liệu
- Biết vì sao không nên tự nghĩ ra hàm băm mới
Hàm băm biến một khóa bất kỳ thành một số nguyên. Viết được một hàm như vậy thì dễ, viết được một hàm tốt thì khó, và bài này cho thấy ngay cả những hàm nổi tiếng cũng có góc tối mà bạn phải biết.
#Ba yêu cầu của một hàm băm tốt
| Yêu cầu | Nghĩa là gì | Vi phạm thì sao |
|---|---|---|
| Xác định | Cùng khóa luôn cho cùng giá trị băm trong một lần chạy | Không tìm lại được thứ vừa lưu vào |
| Phân bố đều | Các khóa khác nhau rải đều trên toàn miền giá trị | Nhiều khóa dồn vào một ô, tra cứu tụt về O(n) |
| Nhanh | Chi phí tính tỷ lệ tuyến tính với độ dài khóa, hằng số nhỏ | Hàm băm tốn hơn cả việc tìm tuyến tính |
#Hàm băm ngây thơ và vì sao nó hỏng
ngay-tho.c
/* Cộng mã của mọi ký tự. Ai cũng nghĩ ra cách này đầu tiên. */
unsigned long hash_tong(const char *s)
{
unsigned long h = 0;
for (; *s != '\0'; ++s)
h += (unsigned char)*s;
return h;
}terminal
# 10000 từ tiếng Anh, bảng 4096 ô
./dem-va-cham tu-dien.txt 4096
hash_tong : 3906 o trong (95.4%), o dong nhat co 187 khoa djb2 : 1094 o trong (26.7%), o dong nhat co 9 khoa fnv1a : 1101 o trong (26.9%), o dong nhat co 8 khoa ly thuyet (phan bo deu) : ~1098 o trong (26.8%)
Con số 187 khóa trong một ô nghĩa là mọi lần tra cứu rơi vào ô đó phải duyệt 187 phần tử. Toàn bộ lợi thế của bảng băm biến mất. Còn djb2 và FNV-1a đều cho số ô trống gần đúng bằng lý thuyết phân bố đều.
#djb2
djb2
Hàm băm do Daniel J. Bernstein công bố trên nhóm tin comp.lang.c năm 1991. Nó chỉ có ba dòng, chạy rất nhanh, và phân bố đủ tốt cho hầu hết bài toán thực tế.
djb2.c
unsigned long hash_djb2(const char *s)
{
unsigned long h = 5381;
for (; *s != '\0'; ++s)
h = h * 33 + (unsigned char)*s; /* h = ((h << 5) + h) + c */
return h;
}
/* Biến thể djb2a dùng phép hoặc loại trừ thay vì cộng.
Trộn bit tốt hơn một chút với chuỗi ngắn. */
unsigned long hash_djb2a(const char *s)
{
unsigned long h = 5381;
for (; *s != '\0'; ++s)
h = h * 33 ^ (unsigned char)*s;
return h;
}#FNV-1a
fnv.c
#include <stdint.h>
/* FNV-1a 32 bit. Do Fowler, Noll và Vo thiết kế.
Đặc điểm: phép hoặc loại trừ TRƯỚC, phép nhân SAU. */
uint32_t hash_fnv1a(const char *s)
{
uint32_t h = 2166136261u; /* offset basis */
for (; *s != '\0'; ++s) {
h ^= (unsigned char)*s;
h *= 16777619u; /* FNV prime = 2^24 + 2^8 + 0x93 */
}
return h;
}
/* Bản 64 bit, dùng khi cần miền giá trị rộng hơn. */
uint64_t hash_fnv1a_64(const void *du_lieu, size_t n)
{
const unsigned char *p = du_lieu;
uint64_t h = 14695981039346656037u;
for (size_t i = 0; i < n; ++i) {
h ^= p[i];
h *= 1099511628211u;
}
return h;
}| hash_tong | djb2 | FNV-1a | |
|---|---|---|---|
| Số dòng | 3 | 3 | 4 |
| Phép tính mỗi byte | 1 cộng | 1 nhân, 1 cộng | 1 hoặc loại trừ, 1 nhân |
| Hiệu ứng tuyết lở | Không có | Khá | Tốt |
| Chuỗi ngắn dưới 4 ký tự | Rất tệ | Trung bình | Tốt |
| Băm dữ liệu nhị phân | Tệ | Được | Tốt |
| Chống tấn công cố ý | Không | Không | Không |
#Cái bẫy khi số bucket là lũy thừa của hai
Đây là chỗ ngay cả djb2 cũng lộ ra khuyết điểm, và nó là một trong những cái bẫy tinh vi nhất của cả chương.
Phép toán rất đơn giản
/* 33 mod 32 = 1
Nên với mọi h: (h * 33) mod 32 = (h * 1) mod 32 = h mod 32
Suy ra: h_moi mod 32 = (h_cu * 33 + c) mod 32 = (h_cu + c) mod 32
Nghĩa là 5 bit THẤP của djb2 chính là 5 bit thấp của
(5381 + tổng mã các ký tự), tức đúng bằng hàm băm ngây thơ! */terminal
./bay-djb2
khoa djb2 % 32 (5381 + tong) % 32 abc 11 11 bca 11 11 cab 11 11 hello 25 25 world 13 13 chao 0 0 nguyen 27 27 tat ca deu khop: djb2 % 32 KHONG hon gi hash_tong % 32
# Nhưng với mod 64 thì không còn suy biến, vì 33 mod 64 = 33
./bay-djb2 64
abc djb2 % 64 = 11 bca djb2 % 64 = 43 cab djb2 % 64 = 43
#Đo số va chạm
dem-va-cham.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef unsigned long (*HamBam)(const char *);
typedef struct { const char *ten; HamBam f; } Muc;
static void do_mot_ham(const Muc *m, char **tu, size_t n_tu, size_t n_o)
{
size_t *dem = calloc(n_o, sizeof *dem);
if (dem == NULL) return;
for (size_t i = 0; i < n_tu; ++i)
++dem[m->f(tu[i]) % n_o];
size_t trong = 0, dong_nhat = 0;
double tu_so = 0.0;
for (size_t i = 0; i < n_o; ++i) {
if (dem[i] == 0) ++trong;
if (dem[i] > dong_nhat) dong_nhat = dem[i];
tu_so += (double)dem[i] * ((double)dem[i] + 1.0) / 2.0;
}
/* Hệ số chất lượng của Bob Jenkins: bằng 1.0 khi phân bố giống hệt
ngẫu nhiên đều, lớn hơn 1 khi bị gom cụm. */
double n = (double)n_tu;
double m_o = (double)n_o;
double mau = n / (2.0 * m_o) * (n + 2.0 * m_o - 1.0);
double chat_luong = tu_so / mau;
printf("%-10s: %5zu o trong (%4.1f%%), o dong nhat %3zu khoa, chat luong %.3f\n",
m->ten, trong, 100.0 * trong / m_o, dong_nhat, chat_luong);
}
int main(int argc, char **argv)
{
if (argc < 3) { fprintf(stderr, "dung: %s tep so_o\n", argv[0]); return 1; }
size_t n_o = (size_t)strtoul(argv[2], NULL, 10);
/* Đọc từ điển, mỗi dòng một từ. Bỏ qua phần đọc tệp cho gọn. */
size_t n_tu;
char **tu = doc_tu_dien(argv[1], &n_tu);
if (tu == NULL) return 1;
printf("%zu tu, %zu o\n\n", n_tu, n_o);
Muc bang[] = {
{ "tong", hash_tong },
{ "djb2", hash_djb2 },
{ "djb2a", hash_djb2a },
{ "fnv1a", hash_fnv1a_ul },
};
for (size_t i = 0; i < sizeof bang / sizeof bang[0]; ++i)
do_mot_ham(&bang[i], tu, n_tu, n_o);
giai_phong_tu_dien(tu, n_tu);
return 0;
}terminal
./dem-va-cham tu-dien-anh.txt 16384
10000 tu, 16384 o tong : 16068 o trong (98.1%), o dong nhat 187 khoa, chat luong 24.812 djb2 : 8901 o trong (54.3%), o dong nhat 6 khoa, chat luong 1.008 djb2a : 8894 o trong (54.3%), o dong nhat 6 khoa, chat luong 1.003 fnv1a : 8907 o trong (54.4%), o dong nhat 5 khoa, chat luong 0.997
# Cùng dữ liệu, nhưng chỉ 32 ô, để thấy cái bẫy djb2
./dem-va-cham tu-dien-anh.txt 32
10000 tu, 32 o tong : 0 o trong ( 0.0%), o dong nhat 612 khoa, chat luong 1.148 djb2 : 0 o trong ( 0.0%), o dong nhat 612 khoa, chat luong 1.148 djb2a : 0 o trong ( 0.0%), o dong nhat 358 khoa, chat luong 1.004 fnv1a : 0 o trong ( 0.0%), o dong nhat 341 khoa, chat luong 0.998
Tự làm thử
- Cài cả bốn hàm băm trong bài, in giá trị của chúng cho
abc,bca,cabvà giải thích kết quả. - Kiểm bằng mã rằng
djb2(s) % 32luôn bằng(5381 + tổng mã ký tự) % 32, thử với một nghìn chuỗi ngẫu nhiên. - Tải một danh sách mười nghìn từ tiếng Anh, đo số ô trống và ô đông nhất của ba hàm với bảng 4096, 16384 và 32 ô.
- Cài hệ số chất lượng của Jenkins và dùng nó xếp hạng bốn hàm băm.
- Thêm bước trộn
splitmix64vào sau djb2, rồi đo lại với bảng 32 ô và xem cái bẫy có biến mất không.
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
- Hàm băm tốt cần ba tính chất: xác định, phân bố đều, và nhanh. Với khóa từ người dùng thì cần thêm chống đoán trước va chạm.
- Cộng mã ký tự là hàm băm tồi vì mọi hoán vị cho cùng kết quả và miền giá trị quá hẹp.
- Phải dùng kiểu không dấu cho biến tích lũy và ép ký tự về
unsigned char, nếu không sẽ gặp tràn số có dấu và ký tự âm. - Năm bit thấp của djb2 suy biến thành hàm băm ngây thơ, vì 33 chia 32 dư 1. Với bảng nhỏ hoặc lũy thừa của hai phải trộn thêm.
- Đừng tự thiết kế hàm băm. Dùng djb2, FNV-1a cho mã thường, hoặc xxHash và SipHash khi cần chất lượng hoặc chống tấn công.