Nhập môn

Thuật toán tham lam (Greedy Algorithm): ý tưởng, phản ví dụ, code C++, Python

Thuật toán tham lam (greedy algorithm) là phương pháp giải bài toán tối ưu bằng cách ở mỗi bước luôn đưa ra lựa chọn có lợi nhất ngay tại thời điểm đó và không bao giờ quay lại sửa quyết định cũ. Khi bài toán thỏa mãn tính chất lựa chọn tham lam, thuật toán vừa dễ cài đặt vừa chạy rất nhanh, thường chỉ mất O(n log n) nhờ bước sắp xếp ban đầu.

Ý chính
  • Tư duy tham lam: tại mỗi bước, chọn ngay phương án tốt nhất trước mắt (tối ưu cục bộ) với hy vọng chuỗi lựa chọn đó dẫn đến kết quả tốt nhất toàn cục (tối ưu toàn cục).
  • Hầu hết các bài tham lam đều bắt đầu bằng việc sắp xếp dữ liệu theo một tiêu chí hợp lý (ví dụ: thời điểm kết thúc sớm nhất, đơn giá cao nhất, trọng lượng nhẹ nhất).
  • Không phải bài toán tối ưu nào dùng tham lam cũng đúng! Trước khi viết code, hãy luôn thử tìm một phản ví dụ nhỏ xem lựa chọn trước mắt có làm hỏng bước sau hay không.

Thuật toán tham lam là gì?

Khi đứng trước một bài toán đòi hỏi phải đưa ra một chuỗi quyết định để đạt kết quả lớn nhất hoặc nhỏ nhất, có hai hướng tiếp cận rất khác nhau:

  • Hướng vét cạn / quay lui / quy hoạch động: Thử nhiều phương án, cân nhắc xem nếu bước này chọn A thì các bước sau được gì, nếu chọn B thì các bước sau được gì, rồi mới chốt phương án có tổng tốt nhất.
  • Hướng tham lam (Greedy): Không nhìn xa, không thử đi thử lại. Tại mỗi bước, cứ thấy lựa chọn nào hợp lệ và tốt nhất ngay lúc đó thì lấy luôn, rồi bước tiếp sang phần còn lại mà không bao giờ hối hận.

Chính vì không phải quay lui hay lưu bảng trạng thái lớn, thuật toán tham lam thường chạy cực nhanh — phần lớn thời gian chỉ nằm ở bước sắp xếp mảng O(n log n) ban đầu, sau đó duyệt một vòng O(n) là xong.

Ví dụ kinh điển: Bài toán chọn công việc không chồng lấp (Activity Selection)

Đề bài: Trong ngày có n = 6 sự kiện, sự kiện thứ i bắt đầu lúc bat_dau[i] và kết thúc lúc ket_thuc[i]: [(2, 5), (1, 3), (8, 10), (3, 6), (5, 7), (6, 8)]. Bạn chỉ có thể tham gia một sự kiện tại một thời điểm (nếu sự kiện trước kết thúc ở mốc t thì bạn có thể tham gia ngay sự kiện tiếp theo bắt đầu từ mốc ≥ t). Hỏi bạn có thể tham gia tối đa bao nhiêu sự kiện?

Khi mới đọc đề, người học thường nghĩ ra 3 kiểu "tham lam" khác nhau:

  1. Ý tưởng 1 — Chọn việc bắt đầu sớm nhất trước? Sai! Nếu có một việc bắt đầu rất sớm từ 0 nhưng kéo dài suốt đến 10 ([0, 10]), chọn nó xong là bạn mất sạch cả ngày.
  2. Ý tưởng 2 — Chọn việc diễn ra ngắn nhất trước? Cũng sai! Giả sử có 3 việc [1, 5], [4, 6] và [5, 9]. Việc [4, 6] ngắn nhất (chỉ dài 2 đơn vị), nhưng nếu chọn [4, 6] thì nó vướng vào cả hai việc kia nên bạn chỉ làm được 1 việc, trong khi chọn [1, 5] và [5, 9] thì làm được 2 việc.
  3. Ý tưởng 3 (Đúng) — Luôn chọn việc KẾT THÚC SỚM NHẤT trong các việc có thể làm: Việc kết thúc càng sớm thì quỹ thời gian trống còn lại phía sau cho các việc khác càng lớn. Không có lựa chọn nào khác có thể giải phóng bạn sớm hơn thế!

Hãy xem từng bước sau khi sắp xếp 6 công việc theo thời điểm kết thúc tăng dần:

0 1 2 3 4 5 6 7 8 9 10 Việc 1 [1, 3] Chọn [1, 3] Việc 2 [2, 5] Bỏ (bắt đầu 2 < 3) Việc 3 [3, 6] Chọn (bắt đầu 3 ≥ 3) Việc 4 [5, 7] Bỏ (5 < 6) Việc 5 [6, 8] Chọn [6, 8] Việc 6 [8, 10] Chọn [8, 10]
1. [1, 3] → Chọn (rảnh từ mốc 3) 2. [2, 5] → Bỏ vì bắt đầu 2 < 3 3. [3, 6] → Chọn (rảnh từ mốc 6) 4. [5, 7] → Bỏ vì bắt đầu 5 < 6 5. [6, 8] → Chọn (rảnh từ mốc 8) 6. [8, 10] → Chọn (tổng cộng 4 việc)
Hình 1. Sắp xếp theo thời điểm kết thúc tăng dần và lần lượt chọn các công việc không đè lên mốc kết thúc của việc trước đó.
Thứ tự sau khi xếp theo lúc kết thúc Khoảng thời gian Mốc rảnh hiện tại Quyết định Mốc rảnh mới
Việc 1 [1, 3] -1 (đầu ngày) 1 ≥ -1 → Chọn 3
Việc 2 [2, 5] 3 2 < 3 (bị trùng) → Bỏ 3
Việc 3 [3, 6] 3 3 ≥ 3 → Chọn 6
Việc 4 [5, 7] 6 5 < 6 (bị trùng) → Bỏ 6
Việc 5 [6, 8] 6 6 ≥ 6 → Chọn 8
Việc 6 [8, 10] 8 8 ≥ 8 → Chọn 10

Code chọn công việc bằng C++ và Python

#include <algorithm>
#include <iostream>
#include <utility>
#include <vector>
using namespace std;

// Chọn số lượng công việc không chồng lấp nhiều nhất
int chonCongViec(vector<pair<int, int>> ds) {
    // Sắp xếp theo thời điểm kết thúc (second) tăng dần
    sort(ds.begin(), ds.end(), [](const auto& a, const auto& b) {
        if (a.second != b.second) return a.second < b.second;
        return a.first < b.first;
    });

    int dem = 0, mocKetThuc = -1;
    for (const auto& [batDau, ketThuc] : ds) {
        if (batDau >= mocKetThuc) {  // không bị chồng lấp với việc vừa chọn
            dem++;
            mocKetThuc = ketThuc;
        }
    }
    return dem;
}

int main() {
    vector<pair<int, int>> ds = {{2, 5}, {1, 3}, {8, 10}, {3, 6}, {5, 7}, {6, 8}};
    cout << chonCongViec(ds) << "\n";  // 4
    return 0;
}

Độ phức tạp của thuật toán gồm O(n log n) cho bước sắp xếp cộng với O(n) cho vòng lặp duyệt qua danh sách, tổng cộng là O(n log n) thời gian và O(1) bộ nhớ phụ.

Học cùng giảng viên

Chủ đề này nằm trong Lecture 05: Greedy (Thuật toán Tham lam) của Khóa Orange. Xem đề cương

Khi nào tham lam BỊ SAI? Phản ví dụ bài toán đổi tiền

Sai lầm lớn nhất của người mới là áp dụng tham lam một cách cảm tính mà không kiểm tra. Hãy xét bài toán Đổi tiền ít tờ nhất:

  • Với hệ thống tiền tệ thông thường như {1, 2, 5, 10, 20, 50}, cách tham lam (luôn lấy tờ có mệnh giá lớn nhất không vượt quá số tiền còn lại) luôn cho kết quả đúng.
  • Nhưng giả sử một quốc gia phát hành 3 loại xu có mệnh giá {1, 3, 4} và bạn cần đổi số tiền S = 6:
    • Cách tham lam: Nhìn thấy tờ 4 lớn nhất ≤ 6, lấy ngay một tờ 4. Còn lại 6 - 4 = 2, buộc phải lấy thêm hai tờ 1. Tổng cộng tốn 3 tờ (4 + 1 + 1).
    • Cách tối ưu thật sự: Chỉ cần lấy hai tờ mệnh giá 3 (3 + 3 = 6), tốn 2 tờ!

Vì việc "tham" lấy tờ 4 ngay bước đầu đã đẩy phần dư vào thế bí phải dùng toàn tờ 1, bài toán đổi tiền với bộ mệnh giá bất kỳ không có tính chất lựa chọn tham lam mà bắt buộc phải giải bằng quy hoạch động (Dynamic Programming):

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

// Đổi tiền theo kiểu tham lam: luôn lấy tờ mệnh giá lớn nhất có thể
int doiTienThamLam(int S, const vector<int>& menhGia) {
    int soTo = 0;
    for (int i = (int)menhGia.size() - 1; i >= 0; --i) {
        soTo += S / menhGia[i];
        S %= menhGia[i];
    }
    return soTo;
}

// Đổi tiền tối ưu thật sự (quy hoạch động)
int doiTienToiUu(int S, const vector<int>& menhGia) {
    vector<int> dp(S + 1, 1e9);
    dp[0] = 0;
    for (int x = 1; x <= S; ++x) {
        for (int c : menhGia) {
            if (x >= c) dp[x] = min(dp[x], dp[x - c] + 1);
        }
    }
    return dp[S];
}

int main() {
    vector<int> menhGia = {1, 3, 4};
    cout << doiTienThamLam(6, menhGia) << " " << doiTienToiUu(6, menhGia) << "\n";  // 3 2
    return 0;
}

Làm sao phân biệt bài Tham lam và bài Quy hoạch động?

Đặc điểm Thuật toán tham lam (Greedy) Quy hoạch động (Dynamic Programming)
Cách chọn ở mỗi bước Chọn ngay 1 hướng tốt nhất cục bộ, bỏ qua mọi hướng còn lại Xét tất cả các hướng chuyển trạng thái khả dĩ rồi mới lấy min / max
Hướng giải quyết Đi thẳng từ trên xuống: chọn xong bước 1 mới bước sang bước 2 Giải từ các bài toán con nhỏ nhất trước để ghép thành bài toán lớn
Bài toán Cái túi phân số (Fractional Knapsack — được cắt nhỏ đồ vật) Dùng Tham lam đúng: cứ lấy đồ vật có đơn giá (giá trị / cân nặng) cao nhất trước Không cần tới quy hoạch động
Bài toán Cái túi 0-1 (0-1 Knapsack — chỉ được lấy nguyên vật hoặc bỏ) Tham lam bị sai (vật đơn giá cao có thể để thừa khoảng trống lãng phí trong túi) Bắt buộc dùng Quy hoạch động

Lỗi hay gặp khi giải bài tham lam

Cẩn thận
  • Chọn sai tiêu chí sắp xếp. Như ở bài chọn công việc, nếu sắp xếp theo thời điểm bắt đầu hoặc độ dài công việc thay vì thời điểm kết thúc, kết quả sẽ sai. Khi nghĩ ra một tiêu chí tham lam, hãy dành 2 phút vẽ thử 3–4 đoạn thẳng trên giấy nháp xem có trường hợp nào làm tiêu chí đó thất bại không.
  • Không dùng kiểu long long khi cộng dồn kết quả. Các bài tham lam về tính tổng chi phí, tổng thời gian chờ hoặc tổng lợi nhuận thường có kết quả vượt quá 2 × 10^9.
  • Cập nhật lại thứ tự sau mỗi bước bằng sort thay vì dùng Hàng đợi ưu tiên. Nếu sau mỗi bước tham lam, phần tử vừa xử lý lại sinh ra một giá trị mới cần chèn lại vào tập đang xét (như bài toán nối dây chi phí nhỏ nhất), gọi sort lại mỗi bước sẽ tốn O(n² log n). Trường hợp đó phải dùng Hàng đợi ưu tiên (priority_queue / heapq) để giảm xuống O(n log n).

Luyện ngay trên Big-O Coding

Áp dụng tư duy sắp xếp và lựa chọn tham lam vào các bài tập dưới đây trên hệ thống Big-O Coding:

Câu hỏi thường gặp

Làm thế nào để biết chắc một ý tưởng tham lam là đúng trong phòng thi?

Có hai cách kiểm tra nhanh rất hiệu quả:

  1. Lập luận đổi chỗ (Exchange Argument): Giả sử có một phương án tối ưu khác với lựa chọn tham lam của bạn; hãy thử đổi phần tử đầu tiên của phương án đó bằng phần tử tham lam xem kết quả có bị xấu đi không. Nếu không bao giờ xấu đi thì tham lam đúng.
  2. Chạy đối chiếu (Stress test): Viết một hàm vét cạn / quay lui cho n ≤ 10 và sinh ngẫu nhiên 100 test nhỏ so kết quả với hàm tham lam của bạn. Nếu trùng khớp cả 100 test thì bạn có thể tự tin nộp bài.
Có những thuật toán nổi tiếng nào trong khoa học máy tính dựa trên tư duy tham lam?

Rất nhiều thuật toán nền tảng bạn sẽ gặp khi học cấu trúc dữ liệu và giải thuật chính là thuật toán tham lam: thuật toán Dijkstra tìm đường đi ngắn nhất trên đồ thị trọng số không âm, thuật toán Kruskal và Prim tìm cây khung nhỏ nhất (Minimum Spanning Tree), và thuật toán mã hóa Huffman dùng để nén dữ liệu.

Bài toán xếp hàng chờ tính tiền sao cho tổng thời gian chờ của mọi người nhỏ nhất giải thế nào?

Đó là một bài tham lam kinh điển: luôn phục vụ người có thời gian xử lý ngắn nhất trước (Shortest Job First). Nếu người đầu tiên mất thêm t phút, thì tất cả n - 1 người đứng sau đều phải chờ thêm t phút; vì vậy xếp số nhỏ lên trước luôn làm tổng thời gian chờ nhỏ nhất.

Muốn học bài bản cùng giảng viên?

Khóa Orange: làm chủ thuật toán nâng cao như Graph, DP tối ưu, Segment Tree, HLD để giải bài Hard. 16 buổi online, C++/Java/Python, học thử buổi 1 miễn phí.

Xem Khóa Orange

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483