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

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àiThêmLấy phần tử nhỏ nhấtXem phần tử nhỏ nhất
Mảng không sắp xếpO(1)O(n)O(n)
Mảng luôn giữ sắp xếpO(n)O(1)O(1)
Danh sách liên kết sắp xếpO(n)O(1)O(1)
Cây tìm kiếm cân bằngO(log n)O(log n)O(log n)
Heap nhị phânO(log n)O(log n)O(1)

#Cây nhị phân nằm trên mảng

Heap nhị phân nhỏ nhất
Một cây nhị phân hoàn chỉnh, trong đó mọi nút cha đều nhỏ hơn hoặc bằng cả hai con của nó. Hệ quả là gốc luôn là phần tử nhỏ nhất, nhưng giữa hai anh em thì không có quan hệ nào.
Cây hoàn chỉnh nên không có lỗ hổng nào, và ba công thức chỉ số nối cây với 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

heap.h
#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
heap.c
#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;
}
  1. Đặ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ỗ.

  2. 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.

  3. 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ỉ cao log2(n) tầng.

Vết của một lần push

BướcMảngichaViệc làm
Trước5 8 9 12 10 20 15--Heap hợp lệ, 7 phần tử
Đặt cuối5 8 9 12 10 20 15 373data[3] = 12 > 3, đổi chỗ
Sau đổi 15 8 9 3 10 20 15 1231data[1] = 8 > 3, đổi chỗ
Sau đổi 25 3 9 8 10 20 15 1210data[0] = 5 > 3, đổi chỗ
Sau đổi 33 5 9 8 10 20 15 120-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

heap.c (tiếp)
/* Đẩ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ướcMảngiCon nhỏ hơnViệc làm
Trước5 8 9 12 10 20 15--Lấy ra 5
Lá cuối lên gốc15 8 9 12 10 2001 (8)8 < 15, đổi chỗ
Sau đổi 18 15 9 12 10 2014 (10)10 < 15, đổi chỗ
Sau đổi 28 10 9 12 15 204không cóLà lá, dừng
main.c
#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;
}
terminal
gcc -std=c17 -Wall -Wextra -g heap.c main.c -o t && ./t
so phan tu: 7
nho nhat  : 1
lay het   : 1 3 4 7 8 9 20 
# 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

#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.

heap.c (tiếp)
/* 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;
}
terminal
./do-dung-heap 10000000
day tung phan tu : 0.812 s
dung tu duoi len : 0.094 s
nhanh hon        : 8.6 lan

Vết dựng heap

iMảng trước khi đẩy xuốngViệc làmMảng sau
29 4 7 1 8 20 3data[2]=7, con nhỏ hơn là 3, đổi9 4 3 1 8 20 7
19 4 3 1 8 20 7data[1]=4, con nhỏ hơn là 1, đổi9 1 3 4 8 20 7
09 1 3 4 8 20 7data[0]=9, con nhỏ hơn là 1, đổi1 9 3 4 8 20 7
0 tiếp1 9 3 4 8 20 7data[1]=9, con nhỏ hơn là 4, đổi1 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ụngCá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ã HuffmanHai cây con có tần suất nhỏ nhất, xem Bài 28.2
Bộ lập lịch tiến trìnhTiến trình có độ ưu tiên cao nhất
Mô phỏng theo sự kiện rời rạcSự kiện có thời điểm xảy ra sớm nhất
Tìm K phần tử lớn nhấtHeap nhỏ nhất kích thước K, xem bên dưới
Trộn K dãy đã sắpPhầ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ố

top-k.c
/* 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;
}
terminal
./top-k 100000000 10
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ử

  1. Cài MinHeap đầy đủ với day_len và day_xuong, rồi in mảng sau mỗi thao tác để kiểm bất biến bằng mắt.
  2. Viết hàm heap_kiem_tra duyệt mọi i và khẳng định data[(i-1)/2] <= data[i], gọi nó sau mỗi push và pop.
  3. Cài bản day_xuong chỉ 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.
  4. Cài heap_dung và đo thời gian so với đẩy từng phần tử, với n bằng mười triệu.
  5. Cài top_k rồ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).