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

Open addressing

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

  • Cài dò tuyến tính đầy đủ
  • Giải thích hiện tượng gom cụm và cách giảm
  • Cài xóa bằng bia mộ và biết vì sao bắt buộc cần
  • So sánh open addressing với chaining trên năm tiêu chí

Open addressing bỏ hẳn con trỏ: mọi phần tử nằm ngay trong mảng, và va chạm thì đi tìm ô trống khác. Nó nhanh hơn chaining nhờ bộ nhớ đệm, nhưng phép xóa lại khó tới mức cần một khái niệm mới.

#Dò tìm ô trống trong chính mảng

Ô 3 đã bị xóa nhưng phải giữ dấu bia mộ, nếu không phép dò tìm cuc sẽ dừng ở đó.
/* Dò tuyến tính: thử ô i, rồi i+1, i+2, ... quay vòng */
size_t i = bam(key) % n;

while (o[i].trang_thai == DANG_DUNG && strcmp(o[i].key, key) != 0)
    i = (i + 1) % n;

/* Kết thúc vòng lặp thì hoặc o[i] trống, hoặc o[i].key bằng key. */
ChainingOpen addressing
Phần tử nằm ởNút cấp phát riêng ngoài mảngNgay trong mảng
Số lần mallocMột lần cho mỗi phần tửChỉ khi rehash
Bộ nhớ phụ mỗi phần tử8 byte con trỏ cộng đầu khối0
Số phần tử tối đaKhông giới hạnĐúng bằng số ô
Đọc một ôNhảy tới địa chỉ ngẫu nhiênĐọc tuần tự, nằm cùng dòng đệm

#Ba cách dò

Ba công thức
/* 1. Dò tuyến tính: i, i+1, i+2, i+3, ... */
size_t vi_tri(size_t h, size_t k, size_t n) { return (h + k) % n; }

/* 2. Dò bậc hai: i, i+1, i+4, i+9, i+16, ... */
size_t vi_tri(size_t h, size_t k, size_t n) { return (h + k * k) % n; }

/* 3. Băm kép: i, i+h2, i+2*h2, i+3*h2, ... với h2 là hàm băm thứ hai */
size_t vi_tri(size_t h, size_t k, size_t n, size_t h2)
{
    return (h + k * h2) % n;
}

/* h2 PHẢI nguyên tố cùng nhau với n, nếu không phép dò
   chỉ chạm được một phần các ô và có thể lặp vô hạn.
   Cách thường dùng khi n là lũy thừa của 2:  h2 = 2 * (h2_tho % (n/2)) + 1
   tức luôn là số lẻ, mà số lẻ thì nguyên tố cùng nhau với 2 mũ k. */
Tuyến tínhBậc haiBăm kép
Gom cụm sơ cấpNặngKhôngKhông
Gom cụm thứ cấpCóCóKhông
Thân thiện bộ nhớ đệmRất tốtKémKém nhất
Chi phí tính vị tríMột phép cộngMột phép nhânMột hàm băm nữa
Chạm được mọi ô khôngCóChỉ khi n là số nguyên tố và tải dưới nửaCó nếu h2 chọn đúng
Dùng trong thực tếPhổ biến nhấtÍtPython dùng biến thể

#Hiện tượng gom cụm

Gom cụm sơ cấp
Với dò tuyến tính, các ô đã dùng dính thành cụm liền nhau. Cụm càng dài thì xác suất một khóa mới rơi vào nó càng cao, và rơi vào thì cụm lại dài thêm. Hiện tượng tự khuếch đại này làm số lần dò tăng rất nhanh khi bảng đầy dần.
mo-phong-cum.c
#include <stdio.h>
#include <stdlib.h>

#define N 64

int main(void)
{
    int o[N] = { 0 };

    srand(42);

    for (int da_dien = 0; da_dien < 56; ++da_dien) {
        size_t i = (size_t)(rand() % N);

        while (o[i]) i = (i + 1) % N;      /* dò tuyến tính */

        o[i] = 1;

        if ((da_dien + 1) % 14 == 0) {
            printf("sau %2d khoa (tai %.2f): ", da_dien + 1,
                   (double)(da_dien + 1) / N);

            int cum_dai_nhat = 0, cum = 0;

            for (int k = 0; k < N; ++k) {
                putchar(o[k] ? '#' : '.');

                if (o[k]) { ++cum; if (cum > cum_dai_nhat) cum_dai_nhat = cum; }
                else      cum = 0;
            }

            printf("  cum dai nhat %d\n", cum_dai_nhat);
        }
    }

    return 0;
}
terminal
gcc -std=c17 mo-phong-cum.c -o t && ./t
sau 14 khoa (tai 0.22): ..#.##...#..#..#....#.#...#...#....#..#.....#..#.......#.......  cum dai nhat 2
sau 28 khoa (tai 0.44): .###.###.##.##.#...##.##..###.#...###.##....##.###..#.#.#......  cum dai nhat 3
sau 42 khoa (tai 0.66): .#############.#####.####.####..######.###..#######.####.#....  cum dai nhat 13
sau 56 khoa (tai 0.88): ##############################.##############################.  cum dai nhat 30

#Bia mộ khi xóa

Đây là chỗ open addressing khó hơn hẳn chaining, và là lý do nhiều người tránh nó.

Đánh dấu là trống
/* Xóa bằng cách đánh dấu ô là TRỐNG */
int hm_remove(HashMap *m, const char *key)
{
    size_t i = tim_vi_tri(m, key);

    if (i == KHONG_THAY) return -1;

    free(m->o[i].key);
    m->o[i].trang_thai = TRONG;      /* SAI: phá chuỗi dò */

    return 0;
}
Đánh dấu là bia mộ
/* Xóa bằng cách đánh dấu ô là BIA MỘ */
int hm_remove(HashMap *m, const char *key)
{
    size_t i = tim_vi_tri(m, key);

    if (i == KHONG_THAY) return -1;

    free(m->o[i].key);
    m->o[i].key        = NULL;
    m->o[i].trang_thai = BIA_MO;     /* đã xóa, nhưng phép dò vẫn đi qua */
    --m->size;
    ++m->so_bia_mo;

    return 0;
}
Trạng thái ôPhép tìm gặp thìPhép chèn gặp thì
TRỐNGDừng, kết luận không cóDừng, đặt khóa mới vào đây
BIA MỘĐi tiếp, ô này từng có khóaNhớ vị trí này làm ứng viên, nhưng vẫn đi tiếp để kiểm khóa trùng
ĐANG DÙNGSo khóa, khớp thì trả về, không khớp thì đi tiếpSo khóa, khớp thì cập nhật, không khớp thì đi tiếp

#Cài đặt đầy đủ

oahash.h
#ifndef OAHASH_H
#define OAHASH_H

#include <stddef.h>

typedef enum { O_TRONG = 0, O_DANG_DUNG, O_BIA_MO } TrangThaiO;

typedef struct {
    char       *key;          /* NULL khi trống hoặc bia mộ */
    int         value;
    TrangThaiO  trang_thai;
} O;

/* Bất biến:
     1. n_o là lũy thừa của 2, ít nhất bằng 8
     2. size + so_bia_mo <= n_o * 7 / 10
     3. Mọi khóa xuất hiện nhiều nhất một lần
     4. trang_thai == O_DANG_DUNG  khi và chỉ khi  key != NULL      */
typedef struct {
    O     *o;
    size_t n_o;
    size_t size;
    size_t so_bia_mo;
} OAMap;

OAMap *oa_tao(size_t n_o);
void   oa_huy(OAMap *m);
int    oa_put(OAMap *m, const char *key, int value);
int    oa_get(const OAMap *m, const char *key, int *ra);
int    oa_remove(OAMap *m, const char *key);

#endif
oahash.c
#include <stdlib.h>
#include <string.h>

#include "oahash.h"

#define KHONG_CO ((size_t)-1)

static unsigned long bam(const char *s)
{
    unsigned long h = 2166136261u;      /* FNV-1a, tron bit tot hon djb2 */

    for (; *s != '\0'; ++s) {
        h ^= (unsigned char)*s;
        h *= 16777619u;
    }

    return h;
}

OAMap *oa_tao(size_t n_o)
{
    if (n_o < 8 || (n_o & (n_o - 1)) != 0) return NULL;   /* phải là lũy thừa của 2 */

    OAMap *m = malloc(sizeof *m);

    if (m == NULL) return NULL;

    m->o = calloc(n_o, sizeof *m->o);      /* O_TRONG là 0, key là NULL */

    if (m->o == NULL) { free(m); return NULL; }

    m->n_o       = n_o;
    m->size      = 0;
    m->so_bia_mo = 0;

    return m;
}

/* Tìm chỉ số ô chứa key, hoặc KHONG_CO. */
static size_t tim_o(const OAMap *m, const char *key)
{
    size_t h = (size_t)bam(key) & (m->n_o - 1);

    for (size_t k = 0; k < m->n_o; ++k) {
        size_t i = (h + k) & (m->n_o - 1);

        if (m->o[i].trang_thai == O_TRONG) return KHONG_CO;   /* hết chuỗi dò */

        if (m->o[i].trang_thai == O_DANG_DUNG &&
            strcmp(m->o[i].key, key) == 0)
            return i;

        /* O_BIA_MO thì đi tiếp */
    }

    return KHONG_CO;      /* đã quét hết bảng */
}

int oa_get(const OAMap *m, const char *key, int *ra)
{
    if (m == NULL || key == NULL) return -1;

    size_t i = tim_o(m, key);

    if (i == KHONG_CO) return -1;

    if (ra != NULL) *ra = m->o[i].value;

    return 0;
}

int oa_put(OAMap *m, const char *key, int value)
{
    if (m == NULL || key == NULL) return -1;

    if ((m->size + m->so_bia_mo + 1) * 10 > m->n_o * 7)
        if (oa_rehash(m, m->n_o * 2) != 0) return -1;

    size_t h        = (size_t)bam(key) & (m->n_o - 1);
    size_t ung_vien = KHONG_CO;

    for (size_t k = 0; k < m->n_o; ++k) {
        size_t i = (h + k) & (m->n_o - 1);

        if (m->o[i].trang_thai == O_TRONG) {
            size_t dich = (ung_vien != KHONG_CO) ? ung_vien : i;
            size_t n    = strlen(key) + 1;
            char  *ban  = malloc(n);

            if (ban == NULL) return -1;

            memcpy(ban, key, n);

            if (m->o[dich].trang_thai == O_BIA_MO) --m->so_bia_mo;

            m->o[dich].key        = ban;
            m->o[dich].value      = value;
            m->o[dich].trang_thai = O_DANG_DUNG;
            ++m->size;

            return 0;
        }

        if (m->o[i].trang_thai == O_BIA_MO) {
            if (ung_vien == KHONG_CO) ung_vien = i;

            continue;
        }

        if (strcmp(m->o[i].key, key) == 0) {
            m->o[i].value = value;

            return 1;
        }
    }

    return -1;      /* bảng đầy, không xảy ra nếu bất biến 2 được giữ */
}

int oa_remove(OAMap *m, const char *key)
{
    if (m == NULL || key == NULL) return -1;

    size_t i = tim_o(m, key);

    if (i == KHONG_CO) return -1;

    free(m->o[i].key);
    m->o[i].key        = NULL;
    m->o[i].trang_thai = O_BIA_MO;
    --m->size;
    ++m->so_bia_mo;

    return 0;
}

void oa_huy(OAMap *m)
{
    if (m == NULL) return;

    for (size_t i = 0; i < m->n_o; ++i)
        free(m->o[i].key);      /* free(NULL) an toàn, không cần kiểm */

    free(m->o);
    free(m);
}
terminal
gcc -std=c17 -Wall -Wextra -g oahash.c main.c -o t && ./t
put an=1, ba=2, cuc=3
get an   -> 1
get ba   -> 2
get cuc  -> 3
remove ba
get ba   -> khong thay
get cuc  -> 3   <- van tim duoc nho bia mo
size = 2, bia mo = 1
# Valgrind chạy trên bản không bật sanitizer
valgrind --leak-check=full ./t
All heap blocks were freed -- no leaks are possible
ERROR SUMMARY: 0 errors from 0 contexts

#So sánh với chaining

terminal
./do-hai-cach 1000000
1000000 khoa chuoi, he so tai 0.7

           chaining   open addressing
chen       0.842 s          0.514 s
tim        0.612 s          0.287 s
xoa        0.701 s          0.331 s
bo nho      88.0 MB          46.1 MB

open addressing nhanh hon 1.6 den 2.1 lan, ton it hon 1.9 lan bo nho
# Nhưng với hệ số tải cao thì đảo chiều
./do-hai-cach-tai-cao 1000000
he so tai 0.95

           chaining   open addressing
tim        0.634 s          4.812 s

chaining nhanh hon 7.6 lan
Tiêu chíChainingOpen addressing
Số dòng cài đặtKhoảng 120Khoảng 180
Chỗ dễ saiSở hữu chuỗi khóaBia mộ, và ngưỡng rehash
Tốc độ ở tải 0,7ChuẩnNhanh hơn 1,6 tới 2 lần
Tốc độ ở tải 0,95Gần như không đổiChậm hơn 7 lần
Bộ nhớNhiều hơn khoảng hai lầnÍt hơn
Con trỏ tới phần tử có bền khôngCó, tới khi xóaKhông, rehash làm dịch chỗ
Chịu được hàm băm kémCóKhông

Biến thể hiện đại: bảng Swiss

Tự làm thử

  1. Cài OAMap với dò tuyến tính và bia mộ, chạy dưới valgrind cho tới khi sạch.
  2. Viết bản xóa đánh dấu ô là trống, rồi tái hiện lỗi tìm không thấy khóa vẫn nằm trong bảng.
  3. Chạy mô phỏng gom cụm với bảng 64 ô, in trạng thái sau mỗi 14 khóa và ghi lại cụm dài nhất.
  4. Viết bản chèn dừng ở bia mộ đầu tiên, rồi tìm dãy thao tác làm bảng có hai mục cùng khóa.
  5. Đo thời gian tìm của chaining và open addressing ở hệ số tải 0,5, 0,7, 0,9 và 0,95, rồi vẽ bả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

  • Open addressing để mọi phần tử trong chính mảng, nên không cần con trỏ và rất thân thiện với bộ nhớ đệm.
  • Dò tuyến tính gom cụm nặng nhất về lý thuyết nhưng thường nhanh nhất trong thực tế, nhờ đọc tuần tự.
  • Xóa bắt buộc dùng bia mộ. Đánh dấu ô là trống sẽ cắt đứt chuỗi dò và làm mất khóa nằm phía sau.
  • Phép chèn phải nhớ bia mộ đầu tiên nhưng vẫn đi tiếp tới ô trống, để không tạo ra hai mục cùng khóa.
  • Hệ số tải phải tính cả bia mộ, và bắt buộc giữ dưới 0,7. Vượt qua đó thì số lần dò tăng dựng đứng.