Hàng đợi ưu tiên và heap
Sau bài này bạn sẽ làm được
- Hiểu quan hệ cha con qua chỉ số trong mảng
- Cài hai thao tác đẩy lên và đẩy xuống cho đúng
- Cài push và pop trong O(log n)
- Giải thích vì sao dựng heap từ dưới lên là O(n) chứ không phải O(n log n)
Hàng đợi ưu tiên trả về phần tử quan trọng nhất chứ không phải phần tử đến sớm nhất. Heap nhị phân cài nó trong O(log n) mà không cần con trỏ nào: cả cây nằm gọn trên một mảng liên tiếp.
#Ưu tiên thay cho thứ tự đến
| Cách cài | Thêm | Lấy phần tử nhỏ nhất | Xem phần tử nhỏ nhất |
|---|---|---|---|
| Mảng không sắp xếp | O(1) | O(n) | O(n) |
| Mảng luôn giữ sắp xếp | O(n) | O(1) | O(1) |
| Danh sách liên kết sắp xếp | O(n) | O(1) | O(1) |
| Cây tìm kiếm cân bằng | O(log n) | O(log n) | O(log n) |
| Heap nhị phân | O(log n) | O(log n) | O(1) |
#Cây nhị phân nằm trên mảng
/* Ba công thức, đúng cho mọi cây nhị phân hoàn chỉnh đánh số từ 0 */
cha(i) = (i - 1) / 2
trai(i) = 2 * i + 1
phai(i) = 2 * i + 2
/* Kiểm với mảng 5 8 9 12 10 20 15:
i = 0 (5) -> trai = 1 (8), phai = 2 (9)
i = 1 (8) -> trai = 3 (12), phai = 4 (10)
i = 2 (9) -> trai = 5 (20), phai = 6 (15)
i = 4 (10) -> cha = 1 (8) */#Đẩy lên khi thêm
#ifndef HEAP_H
#define HEAP_H
#include <stddef.h>
/* Heap nhỏ nhất trên mảng động.
Bất biến: với mọi i từ 1 tới n-1, data[(i-1)/2] <= data[i] */
typedef struct {
int *data;
size_t n;
size_t suc_chua;
} MinHeap;
void heap_khoi_tao(MinHeap *h);
void heap_huy(MinHeap *h);
int heap_rong(const MinHeap *h);
size_t heap_so_phan_tu(const MinHeap *h);
int heap_push(MinHeap *h, int v);
int heap_pop(MinHeap *h, int *out);
int heap_xem(const MinHeap *h, int *out);
int heap_dung(MinHeap *h, const int *a, size_t n);
#endif#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include "heap.h"
void heap_khoi_tao(MinHeap *h)
{
h->data = NULL;
h->n = 0;
h->suc_chua = 0;
}
void heap_huy(MinHeap *h)
{
free(h->data);
heap_khoi_tao(h);
}
int heap_rong(const MinHeap *h) { return h->n == 0; }
size_t heap_so_phan_tu(const MinHeap *h) { return h->n; }
static void doi_cho(int *a, int *b) { int t = *a; *a = *b; *b = t; }
/* Đẩy phần tử ở vị trí i lên trên cho tới khi nó không nhỏ hơn cha.
Chạy nhiều nhất log2(n) bước, vì mỗi bước lên một tầng. */
static void day_len(MinHeap *h, size_t i)
{
while (i > 0) {
size_t cha = (i - 1) / 2;
if (h->data[cha] <= h->data[i]) break; /* đã đúng chỗ */
doi_cho(&h->data[cha], &h->data[i]);
i = cha;
}
}
static int bao_dam(MinHeap *h)
{
if (h->n < h->suc_chua) return 0;
size_t moi = h->suc_chua ? h->suc_chua * 2 : 8;
if (moi > SIZE_MAX / sizeof *h->data) return -1;
int *tam = realloc(h->data, moi * sizeof *tam);
if (tam == NULL) return -1;
h->data = tam;
h->suc_chua = moi;
return 0;
}
/* Thêm một phần tử. O(log n). */
int heap_push(MinHeap *h, int v)
{
if (bao_dam(h) != 0) return -1;
h->data[h->n] = v; /* đặt vào cuối, tức lá cuối cùng của tầng cuối */
++h->n;
day_len(h, h->n - 1);
return 0;
}
int heap_xem(const MinHeap *h, int *out)
{
if (h->n == 0) return -1;
if (out != NULL) *out = h->data[0]; /* gốc luôn là nhỏ nhất, O(1) */
return 0;
}Đặt phần tử mới vào cuối mảng
Đó chính là ô trống tiếp theo của tầng cuối, nên cây vẫn hoàn chỉnh. Chỉ có bất biến cha nhỏ hơn con có thể bị vi phạm, và chỉ ở đúng một chỗ.
So với cha, nhỏ hơn thì đổi chỗ
Sau khi đổi, chỗ vi phạm duy nhất dịch lên một tầng. Mọi quan hệ cha con khác vẫn nguyên.
Lặp cho tới khi không nhỏ hơn cha, hoặc chạm gốc
Nhiều nhất
log2(n)bước, vì mỗi bước lên đúng một tầng và cây chỉ caolog2(n)tầng.
Vết của một lần push
| Bước | Mảng | i | cha | Việc làm |
|---|---|---|---|---|
| Trước | 5 8 9 12 10 20 15 | - | - | Heap hợp lệ, 7 phần tử |
| Đặt cuối | 5 8 9 12 10 20 15 3 | 7 | 3 | data[3] = 12 > 3, đổi chỗ |
| Sau đổi 1 | 5 8 9 3 10 20 15 12 | 3 | 1 | data[1] = 8 > 3, đổi chỗ |
| Sau đổi 2 | 5 3 9 8 10 20 15 12 | 1 | 0 | data[0] = 5 > 3, đổi chỗ |
| Sau đổi 3 | 3 5 9 8 10 20 15 12 | 0 | - | Chạm gốc, dừng |
Ba lần đổi chỗ cho tám phần tử, đúng bằng log2(8). Đây là trường hợp xấu nhất: phần tử mới nhỏ hơn tất cả và phải leo từ lá lên gốc.
#Đẩy xuống khi lấy
/* Đẩy phần tử ở vị trí i xuống dưới cho tới khi nó không lớn hơn cả hai con.
Chạy nhiều nhất log2(n) bước. */
static void day_xuong(MinHeap *h, size_t i)
{
for (;;) {
size_t trai = 2 * i + 1;
size_t phai = 2 * i + 2;
size_t nho = i;
if (trai < h->n && h->data[trai] < h->data[nho]) nho = trai;
if (phai < h->n && h->data[phai] < h->data[nho]) nho = phai;
if (nho == i) break; /* đã nhỏ hơn hoặc bằng cả hai con */
doi_cho(&h->data[i], &h->data[nho]);
i = nho;
}
}
/* Lấy phần tử nhỏ nhất ra. O(log n). */
int heap_pop(MinHeap *h, int *out)
{
if (h->n == 0) return -1;
if (out != NULL) *out = h->data[0];
h->data[0] = h->data[h->n - 1]; /* đưa lá cuối lên gốc */
--h->n;
if (h->n > 0) day_xuong(h, 0);
return 0;
}Vết của một lần pop
| Bước | Mảng | i | Con nhỏ hơn | Việc làm |
|---|---|---|---|---|
| Trước | 5 8 9 12 10 20 15 | - | - | Lấy ra 5 |
| Lá cuối lên gốc | 15 8 9 12 10 20 | 0 | 1 (8) | 8 < 15, đổi chỗ |
| Sau đổi 1 | 8 15 9 12 10 20 | 1 | 4 (10) | 10 < 15, đổi chỗ |
| Sau đổi 2 | 8 10 9 12 15 20 | 4 | không có | Là lá, dừng |
#include <stdio.h>
#include "heap.h"
int main(void)
{
MinHeap h;
int v;
heap_khoi_tao(&h);
int nguon[] = { 9, 4, 7, 1, 8, 20, 3 };
for (size_t i = 0; i < sizeof nguon / sizeof nguon[0]; ++i)
if (heap_push(&h, nguon[i]) != 0) { heap_huy(&h); return 1; }
printf("so phan tu: %zu\n", heap_so_phan_tu(&h));
heap_xem(&h, &v);
printf("nho nhat : %d\n", v);
printf("lay het : ");
while (heap_pop(&h, &v) == 0) printf("%d ", v);
printf("\n");
heap_huy(&h);
return 0;
}so phan tu: 7 nho nhat : 1 lay het : 1 3 4 7 8 9 20
All heap blocks were freed -- no leaks are possible ERROR SUMMARY: 0 errors from 0 contexts
#Dựng heap từ mảng có sẵn là O(n)
Đẩy từng phần tử vào một heap rỗng tốn O(n log n). Nhưng nếu đã có sẵn cả mảng thì có cách nhanh hơn: gọi day_xuong cho từng nút không phải lá, đi từ dưới lên.
/* Dựng heap từ mảng a có n phần tử. O(n), không phải O(n log n). */
int heap_dung(MinHeap *h, const int *a, size_t n)
{
if (n > SIZE_MAX / sizeof *h->data) return -1;
int *tam = malloc(n ? n * sizeof *tam : 1);
if (tam == NULL) return -1;
memcpy(tam, a, n * sizeof *tam);
free(h->data);
h->data = tam;
h->n = n;
h->suc_chua = n;
/* Nút cuối cùng không phải lá là cha của nút cuối, tức (n-2)/2.
Viết dưới dạng i = n/2 rồi đếm lùi để tránh trừ với size_t. */
for (size_t i = n / 2; i-- > 0; )
day_xuong(h, i);
return 0;
}day tung phan tu : 0.812 s dung tu duoi len : 0.094 s nhanh hon : 8.6 lan
Vết dựng heap
| i | Mảng trước khi đẩy xuống | Việc làm | Mảng sau |
|---|---|---|---|
| 2 | 9 4 7 1 8 20 3 | data[2]=7, con nhỏ hơn là 3, đổi | 9 4 3 1 8 20 7 |
| 1 | 9 4 3 1 8 20 7 | data[1]=4, con nhỏ hơn là 1, đổi | 9 1 3 4 8 20 7 |
| 0 | 9 1 3 4 8 20 7 | data[0]=9, con nhỏ hơn là 1, đổi | 1 9 3 4 8 20 7 |
| 0 tiếp | 1 9 3 4 8 20 7 | data[1]=9, con nhỏ hơn là 4, đổi | 1 4 3 9 8 20 7 |
Kết quả 1 4 3 9 8 20 7 là heap hợp lệ: 1 nhỏ hơn 4 và 3, 4 nhỏ hơn 9 và 8, 3 nhỏ hơn 20 và 7. Nó không phải dãy sắp xếp, và không cần phải vậy.
#Ứng dụng
| Ứng dụng | Cái gì được xếp theo ưu tiên |
|---|---|
| Thuật toán Dijkstra | Đỉnh có khoảng cách tạm tính nhỏ nhất, xem Bài 28.6 |
| Mã Huffman | Hai cây con có tần suất nhỏ nhất, xem Bài 28.2 |
| Bộ lập lịch tiến trình | Tiến trình có độ ưu tiên cao nhất |
| Mô phỏng theo sự kiện rời rạc | Sự kiện có thời điểm xảy ra sớm nhất |
| Tìm K phần tử lớn nhất | Heap nhỏ nhất kích thước K, xem bên dưới |
| Trộn K dãy đã sắp | Phần tử nhỏ nhất trong số K phần tử đầu của các dãy |
Tìm K phần tử lớn nhất trong n số
/* Giữ một heap NHỎ NHẤT kích thước đúng K.
Gốc của nó là phần tử nhỏ nhất trong K phần tử lớn nhất đã gặp.
Gặp số lớn hơn gốc thì thay gốc rồi đẩy xuống.
O(n log K) thời gian, O(K) bộ nhớ.
So với sắp xếp cả mảng: O(n log n) và O(n). */
int top_k(const int *a, size_t n, size_t k, int *ra)
{
if (k == 0 || k > n) return -1;
MinHeap h;
heap_khoi_tao(&h);
if (heap_dung(&h, a, k) != 0) return -1; /* K phần tử đầu */
for (size_t i = k; i < n; ++i) {
if (a[i] > h.data[0]) {
h.data[0] = a[i];
day_xuong(&h, 0);
}
}
for (size_t i = 0; i < k; ++i) ra[i] = h.data[i];
heap_huy(&h);
return 0;
}sap xep ca mang : 8.412 s, bo nho 400 MB heap kich thuoc K: 0.291 s, bo nho 40 byte ket qua giong nhau: co
Tự làm thử
- Cài
MinHeapđầy đủ vớiday_lenvàday_xuong, rồi in mảng sau mỗi thao tác để kiểm bất biến bằng mắt. - Viết hàm
heap_kiem_traduyệt mọiivà khẳng địnhdata[(i-1)/2] <= data[i], gọi nó sau mỗi push và pop. - Cài bản
day_xuongchỉ so với con trái, rồi tìm dãy đầu vào ngắn nhất làm nó cho kết quả sai. - Cài
heap_dungvà đo thời gian so với đẩy từng phần tử, với n bằng mười triệu. - Cài
top_krồi so thời gian và bộ nhớ với cách sắp xếp cả mảng, n bằng mười triệu và k bằng 10.
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
- Heap nhị phân là cây hoàn chỉnh nằm trên mảng, nối bằng ba công thức
(i-1)/2,2i+1,2i+2. - Push đặt vào cuối rồi đẩy lên. Pop đưa lá cuối lên gốc rồi đẩy xuống. Cả hai đều O(log n).
- Khi đẩy xuống phải so với con nhỏ hơn trong hai con, không được chỉ so với con trái.
- Dựng heap từ mảng có sẵn bằng cách đẩy xuống từ dưới lên chỉ tốn O(n), nhanh hơn hẳn cách đẩy từng phần tử.
- Heap chỉ hứa gốc là nhỏ nhất. Nó không phải cây tìm kiếm, và tìm một giá trị bất kỳ vẫn là O(n).