Bỏ qua điều hướng, tới nội dung chính
Học C
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ầuNghĩa là gìVi phạm thì sao
Xác địnhCùng khóa luôn cho cùng giá trị băm trong một lần chạyKhông tìm lại được thứ vừa lưu vào
Phân bố đềuCá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)
NhanhChi 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_tongdjb2FNV-1a
Số dòng334
Phép tính mỗi byte1 cộng1 nhân, 1 cộng1 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ìnhTốt
Băm dữ liệu nhị phânTệĐượcTốt
Chống tấn công cố ýKhôngKhôngKhô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ử

  1. Cài cả bốn hàm băm trong bài, in giá trị của chúng cho abc, bca, cab và giải thích kết quả.
  2. Kiểm bằng mã rằng djb2(s) % 32 luôn bằng (5381 + tổng mã ký tự) % 32, thử với một nghìn chuỗi ngẫu nhiên.
  3. 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 ô.
  4. 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.
  5. Thêm bước trộn splitmix64 và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.