Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 25.524 phút đọc

Hệ số tải và rehash

Sau bài này bạn sẽ làm được

  • Tính hệ số tải và biết ngưỡng của từng cách
  • Cài rehash tính lại vị trí cho mọi phần tử
  • Giải thích chi phí khấu hao O(1) của put
  • Chọn giữa số bucket nguyên tố và lũy thừa của hai

Bảng băm chỉ nhanh khi còn đủ chỗ trống. Rehash là cơ chế giữ điều đó đúng: khi bảng đầy dần thì cấp một mảng lớn hơn và tính lại vị trí cho mọi phần tử. Nó tốn O(n), nhưng xảy ra đủ hiếm để không ảnh hưởng.

#Hệ số tải

Hệ số tải
Tỷ lệ giữa số phần tử và số ô. Với open addressing phải tính cả bia mộ, vì chúng cũng làm phép dò dài thêm.
/* Chaining */
he_so_tai = size / n_buckets

/* Open addressing */
he_so_tai = (size + so_bia_mo) / n_o
CáchNgưỡng rehash thường dùngVì saoVượt ngưỡng thì
Chaining0,75Trên mức đó chuỗi bắt đầu dài đáng kểChậm dần đều, vẫn dùng được
Open addressing0,7Trên mức đó số lần dò tăng dựng đứngChậm rất nhanh, có thể tụt về O(n)
Bảng Swiss0,875Lọc bằng byte điều khiển làm dò rẻ hơn nhiềuVẫn chấp nhận được ở mức cao hơn

#Cài đặt rehash

hashmap.c, bản chaining
/* Đổi số ô thành n_moi và tính lại vị trí cho mọi mục.
   Trả về 0 nếu ổn, -1 nếu hết bộ nhớ và bảng KHÔNG đổi. */
static int hm_rehash(HashMap *m, size_t n_moi)
{
    if (n_moi == 0) return -1;

    Entry **b_moi = calloc(n_moi, sizeof *b_moi);

    if (b_moi == NULL) return -1;      /* thất bại, bảng cũ còn nguyên */

    /* Chuyển từng mục sang mảng mới. KHÔNG cấp phát lại mục nào,
       chỉ nối lại con trỏ, nên hàm này không thể thất bại giữa chừng. */
    for (size_t i = 0; i < m->n_buckets; ++i) {
        Entry *e = m->buckets[i];

        while (e != NULL) {
            Entry *ke = e->next;                 /* lưu TRƯỚC khi đổi next */
            size_t j  = bam(e->key) % n_moi;     /* TÍNH LẠI vị trí */

            e->next   = b_moi[j];                /* thêm vào đầu ô mới */
            b_moi[j]  = e;
            e         = ke;
        }
    }

    free(m->buckets);
    m->buckets   = b_moi;
    m->n_buckets = n_moi;

    return 0;
}

/* Gọi ở đầu hm_put, trước khi thêm phần tử mới. */
int hm_put(HashMap *m, const char *key, int value)
{
    if (m == NULL || key == NULL) return -1;

    if ((m->size + 1) * 4 > m->n_buckets * 3)      /* tải sẽ vượt 0,75 */
        if (hm_rehash(m, m->n_buckets * 2) != 0)
            return -1;

    /* phần còn lại như Bài 25.3 */
    ...
}

Rehash cho open addressing

oahash.c
/* Với open addressing thì rehash phải CHÈN LẠI từng khóa,
   vì vị trí phụ thuộc cả vào những khóa khác đã có trong mảng mới. */
static int oa_rehash(OAMap *m, size_t n_moi)
{
    if (n_moi < 8 || (n_moi & (n_moi - 1)) != 0) return -1;

    O *o_moi = calloc(n_moi, sizeof *o_moi);

    if (o_moi == NULL) return -1;

    /* Chèn lại vào mảng mới. Không cấp phát chuỗi nào, chỉ chuyển con trỏ. */
    for (size_t i = 0; i < m->n_o; ++i) {
        if (m->o[i].trang_thai != O_DANG_DUNG) continue;      /* bỏ bia mộ */

        size_t h = (size_t)bam(m->o[i].key) & (n_moi - 1);

        for (size_t k = 0; k < n_moi; ++k) {
            size_t j = (h + k) & (n_moi - 1);

            if (o_moi[j].trang_thai == O_TRONG) {
                o_moi[j] = m->o[i];      /* chuyển cả struct, kể cả con trỏ key */

                break;
            }
        }
    }

    free(m->o);
    m->o         = o_moi;
    m->n_o       = n_moi;
    m->so_bia_mo = 0;      /* rehash quét sạch mọi bia mộ */

    return 0;
}

#Vì sao put vẫn là O(1)

Một lần put có thể tốn O(n) khi phải rehash. Nhưng rehash xảy ra đủ hiếm để chi phí trung bình vẫn là hằng số. Đây đúng là lập luận đã dùng cho mảng động ở Bài 14.8.

/* Bắt đầu với 8 ô, nhân đôi mỗi lần vượt ngưỡng.
   Rehash xảy ra ở các mốc kích thước 8, 16, 32, ..., n.

   Tổng chi phí của mọi lần rehash khi chèn n phần tử:

     8 + 16 + 32 + ... + n  <  2n

   Chia đều cho n lần put: mỗi lần gánh dưới 2 thao tác phụ.
   Đó là hằng số, nên put có chi phí KHẤU HAO O(1).

   So sánh với cách tăng thêm một lượng cố định, ví dụ mỗi lần thêm 100 ô:

     100 + 200 + 300 + ... + n  =  n(n+100)/200  =  O(n binh phuong)

   Chia cho n lần put: mỗi lần gánh O(n). Chèn n phần tử thành O(n binh phuong). */
terminal
./do-cach-lon 200000
chen 200000 khoa:
nhan doi        : 0.142 s, rehash 15 lan, tong 262136 lan chuyen o
them 1000 moi lan: 24.812 s, rehash 200 lan, tong 20000000 lan chuyen o
cham hon        : 174.7 lan

#Số nguyên tố hay lũy thừa của hai

Số nguyên tốLũy thừa của hai
Lấy chỉ sốh % p, một phép chia dư, khoảng 20 tới 40 chu kỳh & (n-1), một phép và bit, 1 chu kỳ
Chịu được hàm băm kémCó, phép dư trộn cả bit caoKhông, chỉ dùng bit thấp
Tăng gấp đôiPhải tra bảng số nguyên tố dựng sẵnDịch trái một bit
Cần bước trộn thêmKhôngCó, nếu hàm băm không đủ tốt
Bảng số nguyên tố dựng sẵn
/* Mỗi số xấp xỉ gấp đôi số trước, và đều là số nguyên tố. */
static const size_t NGUYEN_TO[] = {
    17, 37, 79, 163, 331, 673, 1361, 2729, 5471, 10949,
    21911, 43853, 87719, 175447, 350899, 701819, 1403641,
    2807303, 5614657, 11229331, 22458671, 44917381
};

static size_t nguyen_to_ke(size_t n)
{
    for (size_t i = 0; i < sizeof NGUYEN_TO / sizeof NGUYEN_TO[0]; ++i)
        if (NGUYEN_TO[i] > n) return NGUYEN_TO[i];

    return n * 2 + 1;      /* vượt bảng, chấp nhận không phải số nguyên tố */
}

#Có nên thu nhỏ bảng không

Xóa nhiều phần tử thì bảng còn rất ít phần tử trên rất nhiều ô. Có nên thu nhỏ lại không, và ở ngưỡng nào.

Ngưỡng thu nhỏ quá gần ngưỡng lớn lên
/* Thu nhỏ ngay khi tải xuống dưới một nửa ngưỡng lớn lên */
if (m->size * 4 < m->n_buckets)      /* tải dưới 0,25 */
    hm_rehash(m, m->n_buckets / 2);

/* Ở đúng mốc, một dãy put, remove, put, remove làm rehash mỗi lần,
   và mỗi lần tốn O(n). Chi phí khấu hao vỡ hoàn toàn. */
Khoảng đệm rộng
/* Để một khoảng đệm rộng giữa hai ngưỡng.
   Lớn lên ở 0,75, thu nhỏ ở 0,15, và không bao giờ nhỏ hơn 16 ô. */
if (m->n_buckets > 16 && m->size * 20 < m->n_buckets * 3)
    hm_rehash(m, m->n_buckets / 2);
terminal
./do-thu-nho
lap 100000 luot put roi remove tai dung moc:
nguong 0.375 : 100000 lan rehash, 18.412 s
nguong 0.15  :      0 lan rehash,  0.041 s

#Rehash dần cho hệ thời gian thực

Đỉnh nhọn 40 mili giây ở mục trước là không chấp nhận được với giao diện người dùng hay hệ điều khiển. Lời giải là chia công việc rehash ra nhiều lần.

rehash-dan.c
/* Giữ HAI mảng cùng lúc trong lúc chuyển. */
typedef struct {
    Entry **cu;
    Entry **moi;
    size_t  n_cu, n_moi;
    size_t  vi_tri;        /* ô tiếp theo cần chuyển từ cu sang moi */
    size_t  size;
    int     dang_chuyen;   /* khác 0 khi đang trong quá trình rehash */
} HashMapDan;

/* Chuyển một số ô mỗi lần được gọi. Gọi từ put, get và remove. */
static void chuyen_mot_it(HashMapDan *m, size_t so_o)
{
    if (!m->dang_chuyen) return;

    for (size_t k = 0; k < so_o && m->vi_tri < m->n_cu; ++k, ++m->vi_tri) {
        Entry *e = m->cu[m->vi_tri];

        while (e != NULL) {
            Entry *ke = e->next;
            size_t j  = e->h % m->n_moi;

            e->next     = m->moi[j];
            m->moi[j]   = e;
            e           = ke;
        }

        m->cu[m->vi_tri] = NULL;
    }

    if (m->vi_tri >= m->n_cu) {      /* xong */
        free(m->cu);
        m->cu          = NULL;
        m->dang_chuyen = 0;
    }
}

/* Trong lúc chuyển, phép tìm phải nhìn CẢ HAI mảng. */
int hmd_get(HashMapDan *m, const char *key, int *ra)
{
    unsigned long h = bam(key);

    if (m->dang_chuyen) {
        size_t i = h % m->n_cu;

        if (i >= m->vi_tri)                    /* ô này chưa được chuyển */
            for (Entry *e = m->cu[i]; e; e = e->next)
                if (e->h == h && strcmp(e->key, key) == 0) {
                    if (ra) *ra = e->value;

                    return 0;
                }
    }

    size_t j = h % m->n_moi;

    for (Entry *e = m->moi[j]; e; e = e->next)
        if (e->h == h && strcmp(e->key, key) == 0) {
            if (ra) *ra = e->value;

            return 0;
        }

    return -1;
}
terminal
./do-rehash-dan 1000000
thoi gian tung lan put, don vi micro giay:

rehash mot lan:
  trung binh :     0.31
  cao nhat   : 41820.00

rehash dan (32 o moi lan):
  trung binh :     0.38
  cao nhat   :     4.12

trung binh cham hon 1.2 lan, nhung dinh nhon giam 10150 lan

Danh sách kiểm cho một bảng băm hoàn chỉnh

Tự làm thử

  1. Cài hm_rehash cho bảng chaining và xác nhận mọi khóa vẫn tìm được sau khi rehash.
  2. Viết bản rehash chép nguyên chỉ số cũ, rồi đếm bao nhiêu phần trăm khóa không tìm lại được.
  3. Thêm trường lưu giá trị băm vào mỗi mục và đo lại thời gian rehash cùng thời gian tìm.
  4. So thời gian chèn hai trăm nghìn khóa với cách nhân đôi và cách thêm một nghìn ô mỗi lần.
  5. Cài rehash dần với ba mươi hai ô mỗi lần, rồi đo phân vị 99,9 của thời gian một lần put so với bản rehash một lần.

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ệ số tải quyết định tốc độ. Chaining rehash ở 0,75, open addressing ở 0,7 và phải tính cả bia mộ.
  • Rehash bắt buộc tính lại vị trí từ khóa với số ô mới. Chép nguyên chỉ số cũ là làm mất phần lớn dữ liệu.
  • Nhân đôi kích thước cho chi phí khấu hao O(1). Tăng thêm một lượng cố định biến n lần chèn thành O(n bình phương).
  • Lũy thừa của hai cộng một bước trộn tốt nhanh hơn số nguyên tố, vì phép và bit rẻ hơn phép chia dư nhiều lần.
  • Rehash một lần có đỉnh nhọn O(n). Rehash dần chia nó ra nhiều lần, chậm hơn khoảng hai mươi phần trăm ở trung bình nhưng độ trễ tối đa giảm hàng nghìn lần.