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
/* 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. */| Chaining | Open addressing | |
|---|---|---|
| Phần tử nằm ở | Nút cấp phát riêng ngoài mảng | Ngay trong mảng |
| Số lần malloc | Mộ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ối | 0 |
| Số phần tử tối đa | Khô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ính | Bậc hai | Băm kép | |
|---|---|---|---|
| Gom cụm sơ cấp | Nặng | Không | Không |
| Gom cụm thứ cấp | Có | Có | Không |
| Thân thiện bộ nhớ đệm | Rất tốt | Kém | Kém nhất |
| Chi phí tính vị trí | Một phép cộng | Một phép nhân | Một hàm băm nữa |
| Chạm được mọi ô không | Có | Chỉ khi n là số nguyên tố và tải dưới nửa | Có nếu h2 chọn đúng |
| Dùng trong thực tế | Phổ biến nhất | Ít | Python 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ỐNG | Dừ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óa | Nhớ vị trí này làm ứng viên, nhưng vẫn đi tiếp để kiểm khóa trùng |
| ĐANG DÙNG | So khóa, khớp thì trả về, không khớp thì đi tiếp | So 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);
#endifoahash.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í | Chaining | Open addressing |
|---|---|---|
| Số dòng cài đặt | Khoảng 120 | Khoảng 180 |
| Chỗ dễ sai | Sở hữu chuỗi khóa | Bia mộ, và ngưỡng rehash |
| Tốc độ ở tải 0,7 | Chuẩn | Nhanh hơn 1,6 tới 2 lần |
| Tốc độ ở tải 0,95 | Gần như không đổi | Chậ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ông | Có, tới khi xóa | Không, rehash làm dịch chỗ |
| Chịu được hàm băm kém | Có | Không |
Biến thể hiện đại: bảng Swiss
Tự làm thử
- Cài
OAMapvới dò tuyến tính và bia mộ, chạy dưới valgrind cho tới khi sạch. - 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.
- 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.
- 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.
- Đ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.