Với hoạt động cần xếp vào một lịch, có tập con để thử; thì không máy nào duyệt nổi. Tham lam (greedy) bỏ qua gần hết các khả năng đó: ở mỗi bước nó chọn phương án có vẻ tốt nhất lúc ấy, chọn xong thì không đổi ý, và chuỗi lựa chọn như vậy vẫn đạt đáp án tối ưu. Code thường chỉ là một lần sắp xếp và một vòng lặp, nên độ phức tạp hay dừng ở .
Cái khó nằm ở chỗ khác: tốt nhất theo tiêu chí nào, và vì sao chuỗi lựa chọn ấy không bao giờ bỏ lỡ đáp án tốt hơn. Rất nhiều quy tắc nghe hợp lý hóa ra sai ở một trường hợp nào đó. Bài này dạy ba việc: đặt quy tắc và tìm phản ví dụ để loại quy tắc sai, chứng minh quy tắc còn lại bằng lập luận đổi chỗ, rồi cài các mẫu hay gặp.
Lấy tờ lớn nhất: khi nào đúng, khi nào sai
Có các mệnh giá với , mỗi mệnh giá dùng bao nhiêu tờ cũng được. Cần trả đúng đồng bằng ít tờ nhất. Quy tắc tham lam chỉ có một dòng: lấy tờ lớn nhất không vượt số tiền còn phải trả, rồi lặp lại. Với các mệnh giá và , ta lấy , hai tờ , một tờ , hai tờ : 6 tờ.
Quy tắc gọn, nhưng gọn chưa phải đúng. Với các mệnh giá và , tham lam lấy , tức 3 tờ, trong khi chỉ cần 2 tờ. Một phản ví dụ nhỏ bác bỏ cả quy tắc, nên tìm phản ví dụ là việc đầu tiên mỗi khi nghĩ ra một lời giải tham lam; chứng minh chỉ đáng viết cho quy tắc đã sống sót qua vài lần thử.
Một điều kiện đủ để quy tắc đúng: mỗi mệnh giá là bội của mệnh giá nhỏ hơn kề nó, . Chứng minh. Một cách trả tối ưu dùng ít hơn tờ với mỗi , vì tờ ấy đổi được lấy một tờ . Do đó các tờ nhỏ hơn gánh tổng không quá
nên số tờ phải đúng bằng thương nguyên S / c_k, chính là số tờ tham lam lấy. Phần còn lại S % c_k là bài toán cùng dạng với ít mệnh giá hơn.
Chứng minh này lộ ra hai mệnh đề đứng sau mọi lời giải tham lam đúng. (A) Có một nghiệm tối ưu chứa lựa chọn tham lam. (B) Sau khi chọn, phần còn lại là bài toán cùng dạng và nhỏ hơn. Công sức thường nằm ở (A). Điều kiện chia hết chỉ là điều kiện đủ: hệ cũng cho tham lam đúng dù không chia hết .
Hỏi nhanh
Máy rút tiền dùng quy tắc “lấy tờ lớn nhất còn vừa số tiền cần trả”. Với hệ mệnh giá nào dưới đây quy tắc này có thể cho kết quả không tối ưu?
Ba hệ còn lại có mỗi mệnh giá là bội của mệnh giá nhỏ hơn kề nó, nên tham lam đúng. Hệ sai ở : tham lam lấy (3 tờ) trong khi chỉ có 2 tờ.
Ví dụRút Tiền Gọn Nhất
Đề. Máy chỉ có các mệnh giá . Phát đúng đồng bằng ít tờ nhất.
Lời giải. Kiểm từng cặp mệnh giá kề nhau: , , , . Mỗi mệnh giá là bội của mệnh giá kề dưới, nên theo chứng minh trên tham lam đúng. Duyệt mệnh giá từ lớn xuống, lấy n / c tờ rồi giữ phần dư.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, c[] = {100, 20, 10, 5, 1};
signed main() {
cin >> n;
ll ans = 0;
for (ll i = 0; i < 5; i++) {
ans += n / c[i];
n %= c[i];
}
cout << ans << '\n';
}
286
8
Chọn nhiều hoạt động nhất: quy tắc nào đúng?
Có hoạt động, hoạt động diễn ra từ đến . Mỗi lúc chỉ tham dự được một hoạt động và phải tham dự trọn vẹn; hoạt động mới được bắt đầu đúng lúc hoạt động trước kết thúc. Tham dự được nhiều nhất bao nhiêu hoạt động?
Chưa biết quy tắc nào đúng, ta nêu vài quy tắc rồi tìm phản ví dụ cho từng cái. Ba ứng viên: chọn hoạt động bắt đầu sớm nhất, ngắn nhất, hay kết thúc sớm nhất; chọn xong thì loại những hoạt động chồng lên nó.
Hoạt động bắt đầu sớm nhất có thể kéo dài tới mức chặn mọi hoạt động khác. Hoạt động ngắn nhất có thể vắt ngang hai hoạt động vốn ghép được với nhau: chặn cả lẫn . Quy tắc kết thúc sớm nhất thì không tìm được phản ví dụ nào.
Chứng minh bằng đổi chỗ. Gọi là hoạt động kết thúc sớm nhất, là một cách chọn tối ưu bất kỳ và là hoạt động đầu tiên trong . Vì kết thúc không muộn hơn , mọi hoạt động khác của bắt đầu không sớm hơn lúc kết thúc. Thay bằng cho một cách chọn hợp lệ, cùng số hoạt động, nay chứa : đó là (A). Sau khi chọn , các hoạt động còn dùng được là những hoạt động bắt đầu không sớm hơn lúc kết thúc: bài toán cùng dạng, tức (B).
Chứng minh chặt chẽ tốn thời gian, mà trong phòng thi bạn không có thừa. Cách rẻ nhất để biết một quy tắc có sai không là viết thêm vét cạn cho dữ liệu rất nhỏ rồi chạy cả hai trên vài chục nghìn test ngẫu nhiên. Chương trình dưới kiểm lại cả ba quy tắc trên, gồm bốn phần:
key(i)là khóa sắp xếp của quy tắc đang thử: thời điểm kết thúc (rule = 0), bắt đầu (rule = 1) hay độ dài (rule = 2).cmpso hai chỉ số theo khóa đó.over(i, j)cho biết hai hoạt động có chồng nhau không: phần chung có độ dài dương khi mỗi hoạt động bắt đầu trước lúc hoạt động kia kết thúc. Dấu<chặt cho phép hoạt động sau bắt đầu đúng lúc hoạt động trước kết thúc.greedy()sắp chỉ số theo khóa rồi đi lần lượt, nhận hoạt động nào không chồng với mọi hoạt động đã nhận.brute()thử mọi tập con của tối đa 7 hoạt động và giữ tập lớn nhất không có cặp nào chồng nhau.mainsinh 20000 bộ test ngẫu nhiên với và thời điểm trong , đếm số test mà hai bên cho kết quả khác nhau.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, rule, s[10], e[10], ord[10], pick[10];
ll key(ll i) {
if (rule == 0) return e[i];
if (rule == 1) return s[i];
return e[i] - s[i];
}
bool cmp(ll i, ll j) {
return key(i) < key(j);
}
bool over(ll i, ll j) {
return s[i] < e[j] && s[j] < e[i];
}
ll greedy() {
for (ll i = 0; i < n; i++) ord[i] = i;
stable_sort(ord, ord + n, cmp);
ll m = 0;
for (ll k = 0; k < n; k++) {
bool ok = true;
for (ll t = 0; t < m; t++)
if (over(ord[k], pick[t])) ok = false;
if (ok) pick[m++] = ord[k];
}
return m;
}
ll brute() {
ll best = 0;
for (ll mask = 0; mask < (1 << n); mask++) {
bool ok = true;
for (ll i = 0; i < n; i++)
for (ll j = i + 1; j < n; j++)
if ((mask >> i & 1) && (mask >> j & 1) && over(i, j)) ok = false;
if (ok) best = max(best, (ll)__builtin_popcount(mask));
}
return best;
}
signed main() {
mt19937 rng(2024);
string name[] = {"kết thúc sớm nhất", "bắt đầu sớm nhất", "ngắn nhất"};
for (rule = 0; rule < 3; rule++) {
ll wrong = 0;
for (ll t = 1; t <= 20000; t++) {
n = rng() % 7 + 1;
for (ll i = 0; i < n; i++) {
s[i] = rng() % 9 + 1;
e[i] = s[i] + rng() % (10 - s[i]) + 1;
}
if (greedy() != brute()) wrong++;
}
cout << name[rule] << ": sai " << wrong << "/20000 test\n";
}
}
kết thúc sớm nhất: sai 0/20000 test
bắt đầu sớm nhất: sai 4709/20000 test
ngắn nhất: sai 102/20000 test
Quy tắc đúng không bị bắt lần nào, hai quy tắc sai bị bắt hàng trăm, hàng nghìn lần. Không bị bắt chưa phải là chứng minh, nhưng với test đủ nhỏ và đủ nhiều thì lỗi hiếm khi sống sót: phản ví dụ của quy tắc sai thường chỉ cần ba hoạt động.
Hỏi nhanh
Bây giờ mỗi hoạt động có thêm một điểm thưởng, và ta muốn tổng thưởng lớn nhất của các hoạt động không chồng nhau. Bạn vẫn sắp theo thời điểm kết thúc và nhận hoạt động nếu nó không chồng với hoạt động đã nhận. Điều nào đúng?
Lấy thưởng 1, thưởng 1 và thưởng 10: sắp theo kết thúc nhận hai hoạt động đầu, tổng 2, trong khi riêng được 10. Chứng minh đổi chỗ dùng ngầm rằng thay bằng không mất gì, chỉ đúng khi các hoạt động có giá trị như nhau. Sắp theo thưởng giảm dần cũng sai: thưởng 10 cùng và thưởng 6 mỗi cái. Bài có thưởng giải bằng quy hoạch động (bài 12).
Ví dụChọn Ca Tham Dự
Đề. hoạt động , được bắt đầu đúng lúc hoạt động trước kết thúc. Tham dự trọn vẹn được nhiều nhất bao nhiêu hoạt động?
Lời giải. Sắp theo thời điểm kết thúc tăng dần, giữ last là thời điểm kết thúc của hoạt động nhận gần nhất; nhận hoạt động nếu last. Dấu >= vì đề cho bắt đầu đúng lúc kết thúc; nếu chạm đầu mút cũng tính là chồng thì phải dùng >. Sắp các cặp để sort mặc định xếp theo kết thúc. Cả bài .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n;
pair<ll, ll> p[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) cin >> p[i].second >> p[i].first;
sort(p + 1, p + n + 1);
ll last = 0, ans = 0;
for (ll i = 1; i <= n; i++)
if (p[i].second >= last) {
ans++;
last = p[i].first;
}
cout << ans << '\n';
}
6
1 4
3 5
4 7
5 6
6 9
7 8
3
Sắp xếp theo khóa: đổi chỗ hai việc kề nhau
Nhiều bài tham lam không hỏi chọn gì mà hỏi làm theo thứ tự nào. Có thứ tự, nhưng lời giải thường là sắp theo một khóa. Muốn tìm khóa, xét đúng hai phần tử đứng kề nhau và tính xem đổi chỗ chúng làm kết quả thay đổi bao nhiêu.
Bài toán: việc làm lần lượt trên một máy từ thời điểm . Việc cần đơn vị thời gian, có trọng số và hoàn thành lúc . Chọn thứ tự để tổng nhỏ nhất.
Giả sử hai việc (cần , trọng số ) và (cần , trọng số ) đứng kề nhau, việc đứng trước bắt đầu lúc . Dù thứ tự nào, cặp này cũng xong cùng lúc , nên các việc khác giữ nguyên thời điểm. đứng trước thì cặp đóng góp ; đứng trước thì . Hiệu hai biểu thức, phần chứa triệt tiêu, còn
Hiệu không phụ thuộc , nên đúng ở mọi chỗ trong lịch: nên đứng trước khi . Khóa là , nhỏ đứng trước; khi mọi khóa là , việc ngắn làm trước. Nếu một thứ tự tối ưu chưa sắp theo khóa thì có hai việc kề nhau đứng ngược khóa; đổi chỗ chúng không làm tổng tăng, nên ta tới được thứ tự đã sắp mà vẫn tối ưu.
bool cmp(Job x, Job y) {
return x.p * y.w < y.p * x.w;
}
So sánh bằng nhân chéo để khỏi chia, và dùng < chứ không dùng <=: hai việc cùng khóa thì cả hai chiều đều phải trả false. Cùng cách nghĩ cho bài hạn chót: nếu một lịch hợp lệ có ngay trước mà hạn của muộn hơn, đổi chỗ thì xong sớm hơn, còn xong đúng lúc từng xong, vẫn kịp. Vậy sắp theo hạn chót tăng dần không làm hỏng lịch nào.
Hỏi nhanh
Hai việc đứng kề nhau: cần 1 đơn vị thời gian với trọng số 1, cần 10 đơn vị với trọng số 100. Mục tiêu là tổng nhỏ nhất. Nên cho việc nào đứng trước, và vì sao?
Mức tăng khi đứng trước là , không chứa . Khóa của là , của là , nên đứng trước. “Việc ngắn làm trước” chỉ đúng khi trọng số bằng nhau. Cặp việc xong cùng lúc, nhưng từng việc thì không.
Ví dụTổng Thưởng Hoàn Thành
Đề. việc làm lần lượt từ thời điểm , việc cần và có mốc . Hoàn thành lúc thì được thưởng (có thể âm). Phải làm tất cả; tìm tổng thưởng lớn nhất.
Lời giải. Tổng thưởng là tổng các trừ tổng các . Tổng các không phụ thuộc thứ tự, nên bài thành cực tiểu hóa tổng thời điểm hoàn thành: trường hợp mọi trọng số bằng 1 của mục này, làm việc ngắn trước. Thời điểm tới , tổng tới cỡ , cần long long.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n;
pair<ll, ll> job[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) cin >> job[i].first >> job[i].second;
sort(job + 1, job + n + 1);
ll now = 0, ans = 0;
for (ll i = 1; i <= n; i++) {
now += job[i].first;
ans += job[i].second - now;
}
cout << ans << '\n';
}
4
3 10
1 7
2 9
4 20
26
Cái túi chia nhỏ được, và cái túi nguyên khối
Có món, món nặng và giá trị ; túi chứa tối đa . Ở bản chia nhỏ được, ta được lấy một phần của món. Quy tắc: xếp các món theo giảm dần, lấy trọn món nếu còn chỗ, hết chỗ thì lấy phần vừa đủ.
Chứng minh đổi chỗ: gọi món là món có tỉ lệ cao nhất. Nếu một nghiệm tối ưu chưa lấy hết món thì túi đã đầy (còn chỗ thì thêm món chỉ có lợi), nên nó đang chứa một lượng món khác. Đổi một lượng đủ nhỏ sang món thì giá trị thay đổi
Giữ nguyên dữ liệu nhưng cấm cắt món, tức bản nguyên khối (0/1), thì quy tắc vỡ. Với túi và các món , lấy theo tỉ lệ được và , tổng , rồi không món nào vừa chỗ còn lại; trong khi cùng cho . Chứng minh vỡ đúng ở bước đổi một lượng , vì món không cắt được. Túi nguyên khối thuộc quy hoạch động (bài 12) và là ví dụ kinh điển của tham lam sai.
Tinh thần “rót vào chỗ đáng nhất trước” của túi chia nhỏ còn dùng được ở những bài phân phối lượng nguyên: trước hết đáp ứng mọi ràng buộc tối thiểu, rồi đổ phần dư vào nơi ít bị ràng buộc nhất.
Ví dụPhân Phối Dung Dịch
Đề. bình dung tích , phải rót đúng đơn vị. Lượng nguyên, (ký hiệu là làm tròn lên), và bình lớn hơn không được nhận ít hơn bình nhỏ hơn. In một phương án hoặc .
Lời giải. Trước hết cho mỗi bình đúng mức tối thiểu ; mức này đã thỏa điều kiện thứ tự vì kéo theo . Nếu tổng tối thiểu đã vượt thì không có phương án. Phần dư rót lần lượt vào bình lớn nhất trước, mỗi bình tới khi đầy: bình được rót thêm luôn lớn không kém mọi bình chưa được rót, nên điều kiện thứ tự không bị phá. Đề cho không vượt tổng dung tích nên luôn rót hết. Sắp chỉ số theo dung tích giảm dần, bằng nhau thì chỉ số nhỏ trước để ra đúng phương án như test mẫu.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, w, a[105], x[105], ord[105];
bool cmp(ll i, ll j) {
if (a[i] != a[j]) return a[i] > a[j];
return i < j;
}
signed main() {
cin >> n >> w;
ll used = 0;
for (ll i = 1; i <= n; i++) {
cin >> a[i];
x[i] = (a[i] + 1) / 2;
used += x[i];
ord[i] = i;
}
if (used > w) {
cout << -1 << '\n';
return 0;
}
sort(ord + 1, ord + n + 1, cmp);
ll rest = w - used;
for (ll k = 1; k <= n; k++) {
ll i = ord[k], add = min(rest, a[i] - x[i]);
x[i] += add;
rest -= add;
}
for (ll i = 1; i <= n; i++) cout << x[i] << " \n"[i == n];
}
4 19
5 8 6 9
3 4 3 9
Xây xâu từ trái sang phải
Nhiều bài hỏi xâu nhỏ nhất (hay lớn nhất) theo thứ tự từ điển. Thứ tự từ điển quyết định ở vị trí khác nhau đầu tiên, nên chữ ở vị trí quan trọng hơn mọi chữ phía sau cộng lại. Tham lam tự nhiên là dựng kết quả từ trái sang phải, mỗi vị trí đặt chữ nhỏ nhất còn đặt được mà không làm hỏng khả năng hoàn thành phần sau.
Ví dụ: xóa đúng chữ số của xâu để xâu còn lại nhỏ nhất. Nếu chữ số đứng ngay trước chữ số nhỏ hơn nó, xóa thì tiến lên đúng vị trí của và xâu nhỏ đi ngay ở đó. Một ngăn xếp (stack: như chồng đĩa, chỉ thêm vào và lấy ra ở trên cùng, gọi là đỉnh; bài 09) làm cả việc trong một lượt: với mỗi chữ số , chừng nào còn lượt xóa và đỉnh lớn hơn thì bỏ đỉnh, rồi đẩy vào; hết xâu mà còn lượt thì bỏ các chữ số cuối.
string st;
for (char c : s) {
while (k > 0 && !st.empty() && st.back() > c) {
st.pop_back();
k--;
}
st.push_back(c);
}
st.resize(st.size() - k);
Với và , ngăn xếp lần lượt là 3, 1, 14, 11, 115, 1159, 1152, 11526. Mỗi chữ số được đẩy vào và lấy ra tối đa một lần, cả bài .
Hỏi nhanh
Cho và (xóa đúng một chữ số để được số nhỏ nhất). Đáp án là gì?
Chữ số đứng ngay trước nhỏ hơn nó; xóa thì tiến lên đúng vị trí của , và số nhỏ đi ngay ở chữ số thứ hai. Xóa chữ số lớn nhất là quy tắc tự nhiên nhưng sai: nó chỉ cắt bớt đuôi, trong khi chữ số quan trọng hơn nằm bên trái.
Ví dụTrộn Hai Luồng Chữ Số
Đề. Xâu chữ số , tổng độ dài các test không quá . Được đổi chỗ hai chữ số kề nhau nếu một chẵn một lẻ, bao nhiêu lần tùy ý. Tìm xâu nhỏ nhất theo thứ tự từ điển.
Lời giải. Hai chữ số cùng chẵn lẻ không bao giờ vượt qua nhau, nên thứ tự của các chữ số chẵn và của các chữ số lẻ được giữ nguyên; ngược lại, mọi cách trộn hai dãy ấy đều đạt được. Bài thành: trộn dãy chữ số chẵn và dãy chữ số lẻ (mỗi dãy giữ thứ tự) để được xâu nhỏ nhất. Xây từ trái sang phải: ở mỗi bước lấy chữ nhỏ hơn trong hai chữ đứng đầu và . Hai chữ đầu khác chẵn lẻ nên không bao giờ bằng nhau, lựa chọn luôn rõ ràng; giống bước trộn của sắp xếp trộn.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll t;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> t;
while (t--) {
string s, ev, od, ans;
cin >> s;
for (char c : s) {
if ((c - '0') % 2 == 0) ev += c;
else od += c;
}
ll i = 0, j = 0;
while (i < (ll)ev.size() && j < (ll)od.size()) {
if (ev[i] < od[j]) ans += ev[i++];
else ans += od[j++];
}
ans += ev.substr(i) + od.substr(j);
cout << ans << '\n';
}
}
3
7092
86420
135024
0279
86420
012345
Chi phí ban đầu trừ các khoản tiết kiệm lớn nhất
Một dạng tham lam hay gặp: tính chi phí khi chưa làm gì, rồi xem mỗi lựa chọn cho phép tiết kiệm bao nhiêu. Nếu các khoản tiết kiệm độc lập với nhau (chọn khoản này không làm khoản kia đổi giá trị) và chỉ được chọn tối đa khoản, thì chọn khoản lớn nhất là tối ưu: một cách chọn bỏ sót khoản mà lấy khoản thì đổi lấy chỉ có lợi. Việc khó là biến đề về dạng “các khoản độc lập”.
Ví dụDải Phủ Ngắn Nhất
Đề. Hành lang ô, có ô cần che ở các vị trí . Dùng không quá tấm chắn, mỗi tấm phủ một đoạn ô liên tiếp. Tổng độ dài các tấm nhỏ nhất là bao nhiêu?
Lời giải. Một tấm duy nhất phải phủ từ đến , độ dài . Mỗi tấm thêm cho phép bỏ trống một khoảng giữa hai ô cần che kề nhau, tiết kiệm đúng ô. Các khoảng này rời nhau nên các khoản tiết kiệm độc lập: lấy khoảng lớn nhất. Với , các khoảng là ; một tấm tốn , bỏ hai khoảng và còn . Cả bài .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, m, k, b[N], g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m >> k;
for (ll i = 1; i <= n; i++) cin >> b[i];
for (ll i = 1; i < n; i++) g[i] = b[i + 1] - b[i] - 1;
sort(g + 1, g + n, greater<ll>());
ll ans = b[n] - b[1] + 1;
for (ll i = 1; i <= k - 1; i++) ans -= g[i];
cout << ans << '\n';
}
6 50 3
2 3 12 13 14 40
6
Hàng đợi ưu tiên: luôn lấy phần tử tốt nhất đang có
Có những bài mà lựa chọn tham lam ở mỗi bước làm thay đổi tập ứng viên: chọn xong thì có phần tử mới sinh ra, hoặc phần tử mới tới theo thời gian. Khi đó sắp một lần là không đủ; ta cần một cấu trúc luôn trả lại phần tử nhỏ nhất (hay lớn nhất) hiện có. Đó là hàng đợi ưu tiên (priority_queue): push, top, pop mỗi thao tác (bài 09 nói kỹ hơn). Mặc định nó nhả phần tử lớn nhất; khai báo thêm greater<ll> thì nhả phần tử nhỏ nhất.
Ví dụCắt Thanh Theo Danh Sách
Đề. Thanh dài cần cắt thành đúng đoạn dài . Mỗi lần cắt một đoạn thành hai, chi phí bằng độ dài đoạn trước khi cắt. Tìm tổng chi phí nhỏ nhất.
Lời giải. Chạy ngược thời gian: bắt đầu với đoạn , mỗi bước ghép hai đoạn thành một với chi phí bằng tổng của chúng, tới khi còn một thanh. Quy tắc: luôn ghép hai đoạn ngắn nhất đang có. Với : ghép (chi phí ), rồi (chi phí ), rồi (chi phí ), tổng .
Vì sao đúng: vẽ các lần ghép thành sơ đồ như hình, mỗi đoạn ban đầu là một nút ở dưới cùng, mỗi lần ghép nối hai nút thành một nút mới phía trên. Đoạn nằm trong lần ghép thì độ dài của nó bị tính đúng lần, nên tổng chi phí là . Trong một cách ghép tối ưu, đổi chỗ đoạn ngắn nhất với đoạn nằm trong nhiều lần ghép nhất không làm tổng tăng, nên có cách ghép tối ưu mà hai đoạn ngắn nhất được ghép với nhau ngay từ lần đầu: đó là (A). Coi hai đoạn đó là một đoạn bằng tổng của chúng thì được bài cùng dạng ít hơn một đoạn: (B). Đây chính là mã Huffman. Mỗi lần ghép tốn tới mà có tới lần, nên dùng long long.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll x, n;
priority_queue<ll, vector<ll>, greater<ll>> pq;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> x >> n;
for (ll i = 1; i <= n; i++) {
ll d;
cin >> d;
pq.push(d);
}
ll ans = 0;
while (pq.size() > 1) {
ll a = pq.top();
pq.pop();
ll b = pq.top();
pq.pop();
ans += a + b;
pq.push(a + b);
}
cout << ans << '\n';
}
10 4
1 2 3 4
19
Khi vòng lặp không chạy và ta in : thanh đã đúng độ dài cần. Quy tắc này khác với ghép dây chuyền từ ngắn tới dài: với dây chuyền tốn , còn ghép hai đoạn ngắn nhất cho .
Khi đi thi
- Nghĩ ra quy tắc thì thử ngay trên vài input 3 đến 5 phần tử, nhất là khi có giá trị bằng nhau hoặc một phần tử cực đoan; còn nghi ngờ thì viết vét cạn so sánh trên test nhỏ trước khi nộp.
- Bài xếp thứ tự: rút khóa từ hiệu hai thứ tự của hai phần tử kề nhau, sắp bằng so sánh nhân chéo trong
long long, dùng<chứ không<=; tổng thời điểm và tổng chi phí thường vượtint. - Ràng buộc nguyên khối (món không cắt được, hệ tiền lạ, hoạt động có trọng số) là dấu hiệu tham lam sắp sai: chuyển sang quy hoạch động (bài 12) thay vì sửa quy tắc mãi.
Tóm tắt
- Tham lam chọn phương án tốt nhất lúc này và không đổi ý; nó đúng khi có (A) một nghiệm tối ưu chứa lựa chọn đó và (B) phần còn lại là bài toán cùng dạng nhỏ hơn.
- Tìm quy tắc bằng cách nêu vài ứng viên ở các trường hợp cực đoan rồi tìm phản ví dụ nhỏ; chỉ chứng minh quy tắc còn sống, và kiểm bằng vét cạn khi chưa chắc.
- Chứng minh đổi chỗ: thay một phần tử của nghiệm tối ưu bằng lựa chọn tham lam mà không xấu đi; với bài thứ tự, đổi hai phần tử kề nhau để rút ra khóa.
- Các mẫu: kết thúc sớm nhất cho bài chọn đoạn, cho lập lịch, cho túi chia nhỏ, chi phí gốc trừ khoản tiết kiệm lớn nhất, ghép hai nhỏ nhất bằng hàng đợi ưu tiên, ngăn xếp cho xâu nhỏ nhất.
- Bài có ràng buộc nguyên khối hay trọng số thường không tham lam được.
Bài tập
Đọc thêm
- Tham lamVNOI Wiki
- Greedy with SortingUSACO Guide
- 贪心OI Wiki
- Competitive Programmer's Handbook, chương 6
- Johnson's rulecp-algorithms