Chương 0·Bài 04

Greedy

Trả tiền ở chợ, ai cũng làm theo phản xạ: lấy tờ lớn nhất còn vừa số tiền, rồi lặp lại. Mỗi bước chọn cái có vẻ tốt nhất lúc ấy, chọn xong không đổi ý. Lối giải đó gọi là tham lam (greedy). Khi đúng, nó gọn tới bất ngờ: thường chỉ là một lần sắp xếp và một vòng lặp, thay cho việc thử 2n2^n khả năng.

Cái khó là biết khi nào nó đúng, vì nhiều quy tắc nghe rất hợp lý lại sai ở một trường hợp nhỏ. Bài này dạy ba việc: 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ỗ, và các mẫu tham lam hay gặp.

Lấy tờ lớn nhất

Có các mệnh giá c1<c2<⋯<ckc_1 < c_2 < \dots < c_k với c1=1c_1 = 1, mỗi mệnh giá dùng bao nhiêu tờ cũng được. Cần trả đúng SS đồ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á 1,3,9,271, 3, 9, 27 và S=50S = 50, ta lấy 2727, hai tờ 99, một tờ 33, hai tờ 11: 6 tờ.

Quy tắc gọn, nhưng gọn chưa phải đúng. Với các mệnh giá 1,3,41, 3, 4 và S=6S = 6, tham lam lấy 4+1+14 + 1 + 1, tức 3 tờ, trong khi 3+33 + 3 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ử.

Đổi hệ mệnh giá: hình so tham lam với cách ít tờ nhất và tự tìm phản ví dụ nhỏ nhất.

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ó, cj+1=mjcjc_{j+1} = m_j c_j. Chứng minh. Một cách trả tối ưu dùng ít hơn mjm_j tờ cjc_j với mỗi j<kj < k, vì mjm_j tờ ấy đổi được lấy một tờ cj+1c_{j+1}. Do đó các tờ nhỏ hơn ckc_k gánh tổng không quá

(m1−1) c1+⋯+(mk−1−1) ck−1=(c2−c1)+(c3−c2)+⋯+(ck−ck−1)=ck−1,(m_1 - 1)\,c_1 + \dots + (m_{k-1} - 1)\,c_{k-1} = (c_2 - c_1) + (c_3 - c_2) + \dots + (c_k - c_{k-1}) = c_k - 1,

nên số tờ ckc_k 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ệ 1,2,5,10,20,50,…1, 2, 5, 10, 20, 50, \dots cũng cho tham lam đúng dù 22 không chia hết 55.

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?

Ví dụRút Tiền Gọn Nhất

Đề. Máy chỉ có các mệnh giá 1,5,10,20,1001, 5, 10, 20, 100. Phát đúng n≤109n \le 10^9 đồng bằng ít tờ nhất.

Lời giải. Kiểm từng cặp mệnh giá kề nhau: 5=5⋅15 = 5 \cdot 1, 10=2⋅510 = 2 \cdot 5, 20=2⋅1020 = 2 \cdot 10, 100=5⋅20100 = 5 \cdot 20. 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ư.

Code đầy đủ
#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

Chọn hoạt động

Có nn hoạt động, hoạt động ii diễn ra từ sis_i đến eie_i. 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ó.

Chọn một quy tắc rồi chạy: xanh là hoạt động được nhận, đỏ là hoạt động bị bỏ vì chồng.

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: [4,7][4, 7] chặn cả [1,5][1, 5] lẫn [6,10][6, 10]. 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 GG là hoạt động kết thúc sớm nhất, OO là một cách chọn tối ưu bất kỳ và FF là hoạt động đầu tiên trong OO. Vì GG kết thúc không muộn hơn FF, mọi hoạt động khác của OO bắt đầu không sớm hơn lúc GG kết thúc. Thay FF bằng GG cho một cách chọn hợp lệ, cùng số hoạt động, nay chứa GG: đó là (A). Sau khi chọn GG, 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 GG 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). cmp so 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.
  • main sinh 20000 bộ test ngẫu nhiên với n≤7n \le 7 và thời điểm trong [1,10][1, 10], đếm số test mà hai bên cho kết quả khác nhau.
C++
#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";
    }
}

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?

Ví dụChọn Ca Tham Dự

Đề. n≤2⋅105n \le 2 \cdot 10^5 hoạt động [ai,bi][a_i, b_i], đượ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 ai≥a_i \ge 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 (bi,ai)(b_i, a_i) để sort mặc định xếp theo kết thúc. Cả bài O(nlog⁡n)O(n \log n).

Code đầy đủ
#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

Đổi chỗ hai việc kề

Nhiều bài tham lam không hỏi chọn gì mà hỏi làm theo thứ tự nào. Có n!n! 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: nn việc làm lần lượt trên một máy từ thời điểm 00. Việc ii cần pip_i đơn vị thời gian, có trọng số wiw_i và hoàn thành lúc CiC_i. Chọn thứ tự để tổng w1C1+w2C2+⋯+wnCnw_1 C_1 + w_2 C_2 + \dots + w_n C_n nhỏ nhất.

Kéo các thanh trượt: đổi chỗ chỉ đổi thời điểm xong của hai việc, và hiệu không phụ thuộc T.

Giả sử hai việc XX (cần aa, trọng số wXw_X) và YY (cần bb, trọng số wYw_Y) đứng kề nhau, việc đứng trước bắt đầu lúc TT. Dù thứ tự nào, cặp này cũng xong cùng lúc T+a+bT + a + b, nên các việc khác giữ nguyên thời điểm. XX đứng trước thì cặp đóng góp wX(T+a)+wY(T+a+b)w_X (T + a) + w_Y (T + a + b); YY đứng trước thì wY(T+b)+wX(T+a+b)w_Y (T + b) + w_X (T + a + b). Hiệu hai biểu thức, phần chứa TT triệt tiêu, còn

a wY−b wX.a\,w_Y - b\,w_X .

Hiệu không phụ thuộc TT, nên đúng ở mọi chỗ trong lịch: XX nên đứng trước YY khi awX≤bwY\frac{a}{w_X} \le \frac{b}{w_Y}. Khóa là pi/wip_i / w_i, nhỏ đứng trước; khi mọi wi=1w_i = 1 khóa là pip_i, 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.

C++
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ó XX ngay trước YY mà hạn của XX muộn hơn, đổi chỗ thì YY xong sớm hơn, còn XX xong đúng lúc YY 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: AA cần 1 đơn vị thời gian với trọng số 1, BB cần 10 đơn vị với trọng số 100. Mục tiêu là tổng wiCiw_i C_i nhỏ nhất. Nên cho việc nào đứng trước, và vì sao?

Ví dụTổng Thưởng Hoàn Thành

Đề. n≤2⋅105n \le 2 \cdot 10^5 việc làm lần lượt từ thời điểm 00, việc ii cần aia_i và có mốc did_i. Hoàn thành lúc fif_i thì được thưởng di−fid_i - f_i (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 did_i trừ tổng các fif_i. Tổng các did_i 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 2⋅10112 \cdot 10^{11}, tổng tới cỡ 101710^{17}, cần long long.

Code đầy đủ
#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

Cái túi chia nhỏ

Có nn món, món ii nặng wiw_i và giá trị vi>0v_i > 0; túi chứa tối đa WW. Ở 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 vi/wiv_i / w_i 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 11 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 11 thì túi đã đầy (còn chỗ thì thêm món 11 chỉ có lợi), nên nó đang chứa một lượng món jj khác. Đổi một lượng δ>0\delta > 0 đủ nhỏ sang món 11 thì giá trị thay đổi

δ(v1w1−vjwj)≥0.\delta \left( \frac{v_1}{w_1} - \frac{v_j}{w_j} \right) \ge 0 .

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 99 và các món (w,v)=(4,12),(6,15),(2,9),(5,10)(w, v) = (4, 12), (6, 15), (2, 9), (5, 10), lấy theo tỉ lệ được (2,9)(2, 9) và (4,12)(4, 12), tổng 2121, rồi không món nào vừa 33 chỗ còn lại; trong khi (2,9)(2, 9) cùng (6,15)(6, 15) cho 2424. Chứng minh vỡ đúng ở bước đổi một lượng δ\delta, vì món không cắt được. Túi nguyên khối giải bằng quy hoạch động (bài 12).

Cùng một túi, ba cách lấy: chia nhỏ theo v/w, nguyên khối theo v/w, và nguyên khối tốt nhất.

Xâu nhỏ nhất

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í 11 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 kk chữ số của xâu ss để xâu còn lại nhỏ nhất. Nếu chữ số tt đứng ngay trước chữ số cc nhỏ hơn nó, xóa tt thì cc tiến lên đúng vị trí của tt 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ố cc, chừng nào còn lượt xóa và đỉnh lớn hơn cc thì bỏ đỉnh, rồi đẩy cc vào; hết xâu mà còn lượt thì bỏ các chữ số cuối.

C++
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 s=31415926s = 31415926 và k=3k = 3, 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 O(n)O(n).

Ngăn xếp luôn tăng dần từ đáy lên đỉnh chừng nào còn lượt xóa.

Hỏi nhanh

Cho s=1329s = 1329 và k=1k = 1 (xóa đúng một chữ số để được số nhỏ nhất). Đáp án là gì?

Ví dụTrộn Hai Luồng Chữ Số

Đề. Xâu chữ số ss, tổng độ dài các test không quá 3⋅1053 \cdot 10^5. Đượ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 EE và dãy chữ số lẻ OO (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 EE và OO. 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.

Code đầy đủ
#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

Chi phí trừ tiết kiệm

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 kk khoản, thì chọn kk khoản lớn nhất là tối ưu: một cách chọn bỏ sót khoản yy mà lấy khoản x<yx < y thì đổi xx lấy yy chỉ có lợi. Việc khó là biến đề về dạng “các khoản độc lập”.

Ví dụ: hành lang có các ô cần che ở vị trí b1<⋯<bnb_1 < \dots < b_n, dùng không quá kk tấm chắn, mỗi tấm phủ một đoạn ô liên tiếp; tổng độ dài nhỏ nhất là bao nhiêu? Một tấm duy nhất phủ từ b1b_1 đến bnb_n. Mỗi tấm thêm cho phép bỏ trống một khoảng giữa hai ô kề nhau, tiết kiệm bi+1−bi−1b_{i+1} - b_i - 1 ô. Các khoảng rời nhau nên các khoản độc lập: bỏ k−1k - 1 khoảng lớn nhất. Với b=2,3,12,13,14,40b = 2, 3, 12, 13, 14, 40 và k=3k = 3, một tấm tốn 3939, bỏ hai khoảng 2525 và 88 còn 66.

Hàng đợi ưu tiên

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 O(log⁡n)O(\log n) (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 x≤109x \le 10^9 cần cắt thành đúng n≤2⋅105n \le 2 \cdot 10^5 đoạn dài d1,…,dnd_1, \dots, d_n. 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 nn đoạn did_i, 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 1,2,3,41, 2, 3, 4: ghép 1+21 + 2 (chi phí 33), rồi 3+33 + 3 (chi phí 66), rồi 4+64 + 6 (chi phí 1010), tổng 1919.

Mỗi lần ghép hai đoạn ngắn nhất đang có; nút xanh là các đoạn còn trong hàng đợi.

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 did_i nằm trong hih_i lần ghép thì độ dài của nó bị tính đúng hih_i lần, nên tổng chi phí là d1h1+d2h2+⋯+dnhnd_1 h_1 + d_2 h_2 + \dots + d_n h_n. 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 10910^9 mà có tới 2⋅1052 \cdot 10^5 lần, nên dùng long long.

Code đầy đủ
#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

Khi n=1n = 1 vòng lặp không chạy và ta in 00: 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 1,1,1,11, 1, 1, 1 dây chuyền tốn 2+3+4=92 + 3 + 4 = 9, còn ghép hai đoạn ngắn nhất cho 2+2+4=82 + 2 + 4 = 8.

multiset cũng lấy được phần tử nhỏ nhất trong O(log⁡n)O(\log n), nhưng mỗi phần tử là một nút cấp phát riêng trong cây, còn priority_queue chỉ là một mảng liền:

Ghép một triệu đoạn theo kiểu Huffman, trung vị 5 lần chạy, i7-10850H, g++ -O2.multiset1177 ms · gấp 4 lầnpriority_queue292 ms
Ghép một triệu đoạn bằng multiset và bằng priority_queue, đo trên i7-10850H, g++ -O2.

Cùng độ phức tạp, priority_queue nhanh hơn khoảng 4 lần. Chỉ cần lấy phần tử nhỏ nhất hay lớn nhất thì dùng nó; multiset để dành cho lúc cần xóa một phần tử bất kỳ hay tìm phần tử kế tiếp.

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ượt int.
  • 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, p/wp / w cho lập lịch, v/wv / w cho túi chia nhỏ, chi phí gốc trừ kk khoản tiết kiệm lớn nhất, ghép hai nhỏ nhất bằng priority_queue, 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