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

Tham lam

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

  • Nêu hai điều kiện để tham lam cho kết quả tối ưu
  • Chỉ ra ví dụ tham lam sai và giải thích vì sao
  • Cài chọn hoạt động và lập luận tính đúng
  • Cài mã Huffman bằng hàng đợi ưu tiên

Tham lam là chiến lược đơn giản nhất: ở mỗi bước chọn phương án tốt nhất trước mắt, không nhìn xa. Nó cho lời giải rất nhanh, và đôi khi lời giải đó tối ưu thật. Cái khó là biết khi nào.

#Hai điều kiện để tham lam đúng

Cấu trúc con tối ưu
Lời giải tối ưu của bài toán chứa trong nó lời giải tối ưu của các bài toán con. Điều kiện này chung cho cả tham lam lẫn quy hoạch động ở Bài 28.3.
Tính chất lựa chọn tham lam
Có một lựa chọn tối ưu cục bộ nằm trong ít nhất một lời giải tối ưu toàn cục. Nói cách khác: chọn cái tốt nhất ngay bây giờ không bao giờ làm mất cơ hội đạt tối ưu. Đây là điều kiện riêng của tham lam, và nó phải được chứng minh.
Tham lamQuy hoạch động
Cấu trúc con tối ưuCầnCần
Tính chất lựa chọn tham lamCầnKhông cần
Xét bao nhiêu lựa chọn mỗi bướcĐúng mộtTất cả
Độ phức tạp điển hìnhO(n log n)O(n bình phương) hoặc hơn
Bộ nhớO(1)O(n) hoặc O(n bình phương)
Chứng minh tính đúngKhó, cần lập luận riêngDễ, chỉ cần công thức truy hồi

#Đổi tiền: khi đúng khi sai

doi-tien.c
#include <stdio.h>

/* Đổi số tiền thành ít tờ nhất. Tham lam: luôn lấy mệnh giá lớn nhất còn dùng được. */
int doi_tien_tham_lam(const long *mg, size_t n, long tien, long *so_to)
{
    int tong_to = 0;

    for (size_t i = 0; i < n; ++i) {
        so_to[i] = tien / mg[i];
        tien    %= mg[i];
        tong_to += (int)so_to[i];
    }

    return (tien == 0) ? tong_to : -1;      /* -1 nếu không đổi hết được */
}

int main(void)
{
    /* Hệ mệnh giá Việt Nam, sắp giảm dần */
    long mg[] = { 500000, 200000, 100000, 50000, 20000, 10000, 5000, 2000, 1000 };
    size_t n  = sizeof mg / sizeof mg[0];
    long   so_to[16];

    long tien = 878000;
    int  tong = doi_tien_tham_lam(mg, n, tien, so_to);

    printf("doi %ld dong bang %d to:\n", tien, tong);

    for (size_t i = 0; i < n; ++i)
        if (so_to[i] > 0) printf("  %ld to %ld\n", so_to[i], mg[i]);

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra doi-tien.c -o t && ./t
doi 878000 dong bang 8 to:
  1 to 500000
  1 to 200000
  1 to 100000
  1 to 50000
  1 to 20000
  1 to 5000
  1 to 2000
  1 to 1000

Bản quy hoạch động luôn đúng

doi-tien-dp.c
#include <limits.h>
#include <stdlib.h>

/* Số tờ ít nhất để đổi đúng số tiền. Trả về -1 nếu không đổi được.
   O(n nhân tien) thời gian, O(tien) bộ nhớ. Bài 28.3 nói kỹ. */
int doi_tien_dp(const long *mg, size_t n, long tien)
{
    if (tien < 0) return -1;

    int *dp = malloc((size_t)(tien + 1) * sizeof *dp);

    if (dp == NULL) return -1;

    dp[0] = 0;

    for (long t = 1; t <= tien; ++t) {
        dp[t] = INT_MAX;

        for (size_t i = 0; i < n; ++i)
            if (mg[i] <= t && dp[t - mg[i]] != INT_MAX &&
                dp[t - mg[i]] + 1 < dp[t])
                dp[t] = dp[t - mg[i]] + 1;
    }

    int kq = (dp[tien] == INT_MAX) ? -1 : dp[tien];

    free(dp);

    return kq;
}
terminal
./do-tham-lam-vs-dp
he {1,3,4}, doi 100000 dong:
  tham lam : 0.000001 s, 25001 to
  dp       : 0.412000 s, 25000 to

tham lam nhanh hon 412000 lan nhung SAI 1 to

#Chọn hoạt động

Cho n hoạt động, mỗi cái có thời điểm bắt đầu và kết thúc. Chọn nhiều nhất bao nhiêu hoạt động không chồng lấn nhau. Đây là bài tham lam đúng, và chứng minh của nó rất đẹp.

chon-hoat-dong.c
#include <stdlib.h>

typedef struct { int bat_dau, ket_thuc; } HD;

static int theo_ket_thuc(const void *pa, const void *pb)
{
    const HD *a = pa, *b = pb;

    if (a->ket_thuc != b->ket_thuc)
        return (a->ket_thuc > b->ket_thuc) - (a->ket_thuc < b->ket_thuc);

    return (a->bat_dau > b->bat_dau) - (a->bat_dau < b->bat_dau);
}

/* Chọn nhiều nhất bao nhiêu hoạt động không chồng lấn.
   Ghi chỉ số các hoạt động được chọn vào ra nếu ra khác NULL. */
int chon_hoat_dong(HD *a, size_t n, size_t *ra)
{
    if (n == 0) return 0;

    qsort(a, n, sizeof *a, theo_ket_thuc);      /* sắp theo THỜI ĐIỂM KẾT THÚC */

    int    dem  = 1;
    int    cuoi = a[0].ket_thuc;

    if (ra != NULL) ra[0] = 0;

    for (size_t i = 1; i < n; ++i)
        if (a[i].bat_dau >= cuoi) {
            if (ra != NULL) ra[dem] = i;

            ++dem;
            cuoi = a[i].ket_thuc;
        }

    return dem;
}

#Mã Huffman

Mã Huffman
Gán cho mỗi ký tự một chuỗi bit, ký tự xuất hiện nhiều thì mã ngắn. Không mã nào là tiền tố của mã khác, nên giải mã được mà không cần dấu phân cách. Tham lam: liên tục gộp hai ký tự có tần suất nhỏ nhất.
huffman.c
#include <stdlib.h>
#include <string.h>

typedef struct HNode {
    unsigned long   tan_suat;
    int             ky_tu;        /* -1 nếu là nút trong */
    struct HNode   *trai, *phai;
} HNode;

/* Heap nhỏ nhất theo tần suất, cùng mẫu Bài 23.4. */
typedef struct { HNode **d; size_t n, cap; } Heap;

static int nho_hon(const HNode *a, const HNode *b)
{
    return a->tan_suat < b->tan_suat;
}

/* Dựng cây Huffman. Trả về gốc, hoặc NULL nếu hết bộ nhớ. */
HNode *huffman(const unsigned long *tan_suat, int so_ky_tu)
{
    Heap h;

    heap_khoi_tao(&h);

    /* Đưa mọi ký tự có tần suất khác 0 vào heap */
    for (int c = 0; c < so_ky_tu; ++c)
        if (tan_suat[c] > 0) {
            HNode *n = malloc(sizeof *n);

            if (n == NULL) { heap_huy_sau(&h); return NULL; }

            n->tan_suat = tan_suat[c];
            n->ky_tu    = c;
            n->trai     = NULL;
            n->phai     = NULL;

            if (heap_push(&h, n) != 0) { free(n); heap_huy_sau(&h); return NULL; }
        }

    if (h.n == 0) return NULL;

    /* Gộp hai nút nhỏ nhất, lặp tới khi còn một nút */
    while (h.n > 1) {
        HNode *a, *b;

        heap_pop(&h, &a);
        heap_pop(&h, &b);

        HNode *cha = malloc(sizeof *cha);

        if (cha == NULL) { free(a); free(b); heap_huy_sau(&h); return NULL; }

        cha->tan_suat = a->tan_suat + b->tan_suat;
        cha->ky_tu    = -1;
        cha->trai     = a;
        cha->phai     = b;

        if (heap_push(&h, cha) != 0) { /* dọn dẹp */ return NULL; }
    }

    HNode *goc;

    heap_pop(&h, &goc);
    heap_huy(&h);

    return goc;
}
/* Vi du: chuoi "abracadabra", tan suat

     a: 5   b: 2   r: 2   c: 1   d: 1

   Buoc 1: gop c(1) va d(1)  ->  nut X(2)
     Heap: b(2) r(2) X(2) a(5)

   Buoc 2: gop b(2) va r(2)  ->  nut Y(4)
     Heap: X(2) Y(4) a(5)

   Buoc 3: gop X(2) va Y(4)  ->  nut Z(6)
     Heap: a(5) Z(6)

   Buoc 4: gop a(5) va Z(6)  ->  goc(11)

   Cay:
                (11)
               /    \
            a(5)     Z(6)
                    /    \
                 X(2)     Y(4)
                /   \    /   \
              c(1) d(1) b(2) r(2)

   Ma:  a = 0      (1 bit)
        c = 100    (3 bit)
        d = 101    (3 bit)
        b = 110    (3 bit)
        r = 111    (3 bit)

   Do dai: 5*1 + 1*3 + 1*3 + 2*3 + 2*3 = 5 + 3 + 3 + 6 + 6 = 23 bit
   So voi ASCII 8 bit: 11 * 8 = 88 bit
   Ti le nen: 23/88 = 26 phan tram                              */
terminal
gcc -std=c17 -Wall -Wextra huffman.c heap.c main.c -o t && ./t abracadabra
ky tu  tan suat   ma        so bit
  a         5     0              5
  b         2     110            6
  c         1     100            3
  d         1     101            3
  r         2     111            6

tong: 23 bit (ASCII can 88 bit)
ti le nen: 26.1 phan tram
./t < truyen-kieu.txt
kich thuoc goc  : 412840 byte
kich thuoc nen  : 231204 byte
ti le nen       : 56.0 phan tram
so ky tu khac nhau: 87
do dai ma trung binh: 4.48 bit

#Những bài tham lam đúng

Bài toánChiến lược tham lamXem ở đâu
Chọn hoạt độngSắp theo thời điểm kết thúc, chọn cái nào không chồng lấnMục trên
Mã HuffmanLiên tục gộp hai nút tần suất nhỏ nhấtMục trên
DijkstraLuôn mở rộng đỉnh có khoảng cách tạm tính nhỏ nhấtBài 28.6
KruskalSắp cạnh theo trọng số, lấy cạnh nào không tạo chu trìnhBài 28.6
PrimLuôn thêm cạnh nhẹ nhất nối cây với đỉnh ngoàiBài 28.6
Ba lô phân sốSắp theo giá trị trên đơn vị khối lượng, lấy từ cao xuốngBên dưới
Lập lịch giảm thời gian chờ trung bìnhChạy việc ngắn nhất trướcBên dưới

Ba lô phân số so với ba lô 0/1

Ba lô 0/1, tham lam sai
/* Ba lo 0/1: moi mon lay HOAC KHONG lay, khong cat duoc.
   Tham lam theo gia tri tren don vi khoi luong SAI. */

/* Suc chua 10
   Mon A: khoi luong 6, gia tri 30  -> ti le 5.0
   Mon B: khoi luong 5, gia tri 20  -> ti le 4.0
   Mon C: khoi luong 5, gia tri 20  -> ti le 4.0

   Tham lam: lay A (30), con 4 khong du cho gi  -> tong 30
   Toi uu  : lay B va C              -> tong 40                */
Ba lô phân số, tham lam đúng
/* Ba lo PHAN SO: cat duoc mon ra. Tham lam DUNG. */
double ba_lo_phan_so(Mon *a, size_t n, double suc_chua)
{
    qsort(a, n, sizeof *a, theo_ti_le_giam);

    double tong = 0.0;

    for (size_t i = 0; i < n && suc_chua > 0.0; ++i) {
        double lay = (a[i].khoi_luong <= suc_chua)
                   ? a[i].khoi_luong
                   : suc_chua;

        tong      += a[i].gia_tri * lay / a[i].khoi_luong;
        suc_chua  -= lay;
    }

    return tong;
}

/* Voi vi du tren: lay het A (30), roi 4/5 cua B (16) -> tong 46 */

Cách kiểm tham lam có đúng không

kiem-tham-lam.c
/* Sinh hàng nghìn dữ liệu nhỏ ngẫu nhiên, so tham lam với vét cạn.
   Không chứng minh được tính đúng, nhưng bắt được phản ví dụ rất nhanh. */
int kiem_tham_lam(int so_lan, int n_toi_da)
{
    srand(2024);

    for (int lan = 0; lan < so_lan; ++lan) {
        int n = 1 + rand() % n_toi_da;

        /* Sinh dữ liệu nhỏ ngẫu nhiên */
        int a[16];

        for (int i = 0; i < n; ++i) a[i] = 1 + rand() % 20;

        int tl = giai_tham_lam(a, n);
        int vc = giai_vet_can(a, n);      /* O(2^n), chỉ dùng với n nhỏ */

        if (tl != vc) {
            printf("PHAN VI DU voi n = %d: ", n);

            for (int i = 0; i < n; ++i) printf("%d ", a[i]);

            printf("\n  tham lam = %d, toi uu = %d\n", tl, vc);

            return 0;
        }
    }

    printf("qua %d phep thu, khong tim thay phan vi du\n", so_lan);

    return 1;
}
terminal
./kiem-tham-lam 100000 12
bai chon hoat dong:
  qua 100000 phep thu, khong tim thay phan vi du

bai doi tien he {1,3,4}:
  PHAN VI DU voi n = 1: tien = 6
    tham lam = 3, toi uu = 2

bai ba lo 0/1:
  PHAN VI DU voi n = 3: (6,30) (5,20) (5,20) suc chua 10
    tham lam = 30, toi uu = 40

Tự làm thử

  1. Cài đổi tiền tham lam và quy hoạch động, rồi tìm mọi số tiền dưới 100 mà hai bản khác nhau với hệ {1, 3, 4}.
  2. Cài he_chuan và kiểm ba hệ mệnh giá: {1, 5, 10, 25}, {1, 5, 10, 20, 25} và hệ Việt Nam.
  3. Cài chọn hoạt động với ba tiêu chí sắp xếp và tìm phản ví dụ cho hai tiêu chí sai.
  4. Cài mã Huffman đầy đủ kèm giải mã, và đo tỷ lệ nén trên một tệp văn bản tiếng Việt.
  5. Viết khung kiểm thử đối chứng và dùng nó kiểm ba bài tham lam trong bài.

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

  • Tham lam cần hai điều kiện: cấu trúc con tối ưu và tính chất lựa chọn tham lam. Điều kiện thứ hai phải được chứng minh.
  • Đổi tiền tham lam đúng với một số hệ mệnh giá và sai với hệ khác. Hệ {1, 3, 4} là phản ví dụ nhỏ nhất.
  • Chọn hoạt động phải sắp theo thời điểm kết thúc. Ba tiêu chí khác đều có phản ví dụ.
  • Mã Huffman gộp hai nút tần suất nhỏ nhất, và chứng minh dựa vào việc hai ký tự hiếm nhất phải nằm ở tầng sâu nhất.
  • Kiểm thử đối chứng với vét cạn trên dữ liệu nhỏ là cách nhanh nhất để bắt phản ví dụ của một chiến lược tham lam.