Bỏ qua điều hướng, tới nội dung chính
Học C
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íChainingOpen addressing
Độ khó cài đặtDễ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í mallocKhông thêm gì, nhưng cần ô trống dư
Thân thiện bộ nhớ đệmKém, mỗi bước một lần nhảyTốt, các ô cạnh nhau
Hệ số tải chịu đượcTrên 1 vẫn chạy đượcPhải giữ dưới 0,7
XóaDễ, gỡ khỏi danh sáchCần bia mộ, xem Bài 25.4
Trường hợp xấu nhấtO(n) nhưng suy giảm mượtO(n) và suy giảm rất gắt
Số lần cấp phátMộ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ùngGhi chú
Từ điển của PythonOpen addressingDò giả ngẫu nhiên, giữ thêm thứ tự chèn từ phiên bản 3.7
HashMap của JavaChainingChuyển ô sang cây đỏ đen khi chuỗi dài quá 8
unordered_map của C++ChainingChuẩ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 RustOpen addressingMẫu swiss table, dò theo nhóm 16 ô cùng lúc
Bảng băm của nhân LinuxChainingDù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 CuckooHai 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 HoodOpen 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ảoBiết trước toàn bộ tập khóa, xây hàm băm không va chạm nàoChỉ 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ử

  1. 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.
  2. Kiểm quy tắc căn bậc hai: với m bằng 100, 10 nghìn và một triệu, so số khóa thực tế với 1.18 * sqrt(m).
  3. 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 ô.
  4. Vẽ bảng số lần dò trung bình theo hệ số tải cho dò tuyến tính, với a từ 0,1 tới 0,99.
  5. 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 m khó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.