Chương 0·Bài 04

Greedy

Với nn hoạt động cần xếp vào một lịch, có 2n2^n tập con để thử; n=2⋅105n = 2 \cdot 10^5 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 ở O(nlog⁡n)O(n \log n).

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á 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 nhiều hoạt động nhất: quy tắc nào đú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

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ó 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, và cái túi nguyên khối

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 thuộc quy hoạch động (bài 12) và là ví dụ kinh điển của tham lam sai.

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.

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

Đề. n≤100n \le 100 bình dung tích ai≤100a_i \le 100, phải rót đúng ww đơn vị. Lượng xix_i nguyên, ⌈ai/2⌉≤xi≤ai\lceil a_i/2 \rceil \le x_i \le a_i (ký hiệu ⌈x⌉\lceil x \rceil là xx 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 −1-1.

Lời giải. Trước hết cho mỗi bình đúng mức tối thiểu ⌈ai/2⌉\lceil a_i/2 \rceil; mức này đã thỏa điều kiện thứ tự vì ai>aja_i > a_j kéo theo ⌈ai/2⌉≥⌈aj/2⌉\lceil a_i/2 \rceil \ge \lceil a_j/2 \rceil. Nếu tổng tối thiểu đã vượt ww 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 ww 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.

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

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í 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í 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 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ụDải Phủ Ngắn Nhất

Đề. Hành lang m≤109m \le 10^9 ô, có n≤105n \le 10^5 ô cần che ở các vị trí b1<⋯<bnb_1 < \dots < b_n. Dùng không quá k≤nk \le n 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ừ b1b_1 đến bnb_n, độ dài bn−b1+1b_n - b_1 + 1. 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 gi=bi+1−bi−1g_i = b_{i+1} - b_i - 1 ô. Các khoảng này rời nhau nên các khoản tiết kiệm độc lập: lấy k−1k - 1 khoảng lớn nhất. Với b=2,3,12,13,14,40b = 2, 3, 12, 13, 14, 40, các khoảng là 0,8,0,0,250, 8, 0, 0, 25; một tấm tốn 3939, bỏ hai khoảng 2525 và 88 còn 66. 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 = 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

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 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.

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 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