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ách | Ngưỡng rehash thường dùng | Vì sao | Vượt ngưỡng thì |
|---|---|---|---|
| Chaining | 0,75 | Trên mức đó chuỗi bắt đầu dài đáng kể | Chậm dần đều, vẫn dùng được |
| Open addressing | 0,7 | Trên mức đó số lần dò tăng dựng đứng | Chậm rất nhanh, có thể tụt về O(n) |
| Bảng Swiss | 0,875 | Lọc bằng byte điều khiển làm dò rẻ hơn nhiều | Vẫ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ém | Có, phép dư trộn cả bit cao | Không, chỉ dùng bit thấp |
| Tăng gấp đôi | Phải tra bảng số nguyên tố dựng sẵn | Dịch trái một bit |
| Cần bước trộn thêm | Không | Có, 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ử
- Cài
hm_rehashcho bảng chaining và xác nhận mọi khóa vẫn tìm được sau khi rehash. - 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.
- 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.
- 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.
- 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.