- 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:
- Ý 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ừ
0nhưng kéo dài suốt đến10([0, 10]), chọn nó xong là bạn mất sạch cả ngày. - Ý 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 được1việc, trong khi chọn[1, 5]và[5, 9]thì làm được2việc. - Ý 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:
| 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;
}
def chon_cong_viec(ds):
"""Chọn số lượng công việc không chồng lấp nhiều nhất."""
# Sắp xếp theo thời điểm kết thúc (phần tử thứ hai) tăng dần
ds_sap_xep = sorted(ds, key=lambda x: (x[1], x[0]))
dem = 0
moc_ket_thuc = -1
for bat_dau, ket_thuc in ds_sap_xep:
if bat_dau >= moc_ket_thuc:
dem += 1
moc_ket_thuc = ket_thuc
return dem
ds = [(2, 5), (1, 3), (8, 10), (3, 6), (5, 7), (6, 8)]
print(chon_cong_viec(ds)) # 4
Độ 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ụ.
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ềnS = 6:- Cách tham lam: Nhìn thấy tờ
4lớn nhất≤ 6, lấy ngay một tờ4. Còn lại6 - 4 = 2, buộc phải lấy thêm hai tờ1. Tổng cộng tốn3tờ (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ốn2tờ!
- Cách tham lam: Nhìn thấy 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;
}
def doi_tien_tham_lam(s, menh_gia):
so_to = 0
for c in reversed(menh_gia):
so_to += s // c
s %= c
return so_to
def doi_tien_toi_uu(s, menh_gia):
dp = [10**9] * (s + 1)
dp[0] = 0
for x in range(1, s + 1):
for c in menh_gia:
if x >= c and dp[x - c] + 1 < dp[x]:
dp[x] = dp[x - c] + 1
return dp[s]
menh_gia = [1, 3, 4]
print(doi_tien_tham_lam(6, menh_gia), doi_tien_toi_uu(6, menh_gia)) # 3 2
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
- 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 longkhi 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
sortthay 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ọisortlại mỗi bước sẽ tốnO(n² log n). Trường hợp đó phải dùng Hàng đợi ưu tiên (priority_queue/heapq) để giảm xuốngO(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ả:
- 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.
- Chạy đối chiếu (Stress test): Viết một hàm vét cạn / quay lui cho
n ≤ 10và 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.
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í.


