Chương 1·Bài 12

Dynamic Programming

Tính số Fibonacci thứ 50 đúng theo định nghĩa Fn=Fn−1+Fn−2F_n = F_{n-1} + F_{n-2} tốn hơn 4⋅10104 \cdot 10^{10} lần gọi hàm, dù chỉ có 51 số khác nhau cần biết: cùng một số bị tính đi tính lại vô số lần. Ghi mỗi số lại khi tính xong thì chỉ còn 51 bước.

Quy hoạch động (dynamic programming, viết tắt DP hay QHĐ) là đúng ý đó: đáp án của bài lớn ghép từ đáp án của vài bài nhỏ cùng loại, và mỗi bài nhỏ chỉ giải một lần. Cái khó nằm ở thiết kế: bài nhỏ là gì, ghép ra sao, tính theo thứ tự nào. Bài này luyện ba câu hỏi đó trên các khuôn hay gặp nhất: dãy con, đổi tiền, ba lô, bảng hai chiều và đoạn; phần lớn bài quy hoạch động ở các chương sau là biến thể của chúng.

Từ đệ quy đến bảng

Dãy Fibonacci có F0=0F_0 = 0, F1=1F_1 = 1 và Fn=Fn−1+Fn−2F_n = F_{n-1} + F_{n-2}. Viết đúng như định nghĩa:

C++
ll fib(ll n) {
    if (n < 2) return n;
    return fib(n - 1) + fib(n - 2);
}

Gọi CnC_n là số lệnh gọi hàm (kể cả lệnh gọi đầu tiên) khi tính fib(n). Ta có C0=C1=1C_0 = C_1 = 1 và Cn=Cn−1+Cn−2+1C_n = C_{n-1} + C_{n-2} + 1, từ đó chứng minh được Cn=2Fn+1−1C_n = 2F_{n+1} - 1 (thử với vài nn nhỏ để thấy). Vì cứ mỗi bước FnF_n gấp lên khoảng 1,618 lần nên C40≈3,3⋅108C_{40} \approx 3{,}3 \cdot 10^8 và C50≈4,1⋅1010C_{50} \approx 4{,}1 \cdot 10^{10}, trong khi chỉ có n+1n + 1 giá trị F0,…,FnF_0, \dots, F_n khác nhau.

Không nhớ thì fib(3) (xanh) bị tính lại nhiều lần; có nhớ thì nút xám chỉ là một lần đọc bảng.

Nguyên nhân thấy rõ trên hình: fib(3) luôn trả về cùng một giá trị dù được gọi từ đâu, vì kết quả của một bài toán con chỉ phụ thuộc vào đối số. Vậy lần đầu tính xong ta cất kết quả đi, các lần sau lấy ra. Có hai cách cài.

Đệ quy có nhớ (memoization) giữ nguyên hàm trên, thêm một bảng memo ghi giá trị đã biết; trước khi gọi, mọi ô được gán −1-1, nghĩa là chưa tính:

C++
ll memo[100];

ll fib(ll n) {
    if (n < 2) return n;
    if (memo[n] != -1) return memo[n];
    return memo[n] = fib(n - 1) + fib(n - 2);
}

Thân hàm của mỗi fib(k) chạy nhiều nhất một lần và tốn O(1)O(1) ngoài các lệnh gọi con, nên cả bài là O(n)O(n). Đây là quy tắc đếm dùng suốt bài: thời gian xấp xỉ số trạng thái nhân chi phí chuyển của mỗi trạng thái.

Lập bảng (bottom-up) bỏ hẳn đệ quy: tính F0,F1,F2,…F_0, F_1, F_2, \dots theo thứ tự tăng, mỗi ô chỉ dùng các ô đã có.

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long

ll n, f[100];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    f[1] = 1;
    for (ll i = 2; i <= n; i++) f[i] = f[i - 1] + f[i - 2];
    cout << f[n] << '\n';
}
50

F92F_{92} là số Fibonacci lớn nhất còn vừa long long; F93F_{93} đã vượt 9⋅10189 \cdot 10^{18}. Vòng lặp chỉ nhìn hai ô liền trước nên bộ nhớ có thể rút xuống hai biến.

Đệ quy có nhớ viết thẳng từ công thức và chỉ chạm tới những trạng thái thật sự cần, nhưng tốn chi phí gọi hàm và độ sâu đệ quy (hàng triệu tầng dễ tràn ngăn xếp). Lập bảng nhanh hơn và dễ nén bộ nhớ, đổi lại ta phải tự xác định thứ tự tính. Các mục sau dùng bảng; đệ quy có nhớ là phương án dự phòng khi thứ tự tính khó nhìn ra.

Ví dụBậc Đá Tối Ưu

Đề. Có N≤105N \le 10^5 bậc đá, bậc ii cao hih_i. Robot đứng ở bậc 1, mỗi lần nhảy sang bậc i+1i + 1 hoặc i+2i + 2, tốn ∣hi−hj∣|h_i - h_j|. In tổng chi phí nhỏ nhất để tới bậc NN.

Lời giải. Gọi dpi\mathrm{dp}_i là chi phí nhỏ nhất để tới bậc ii. Bước nhảy cuối cùng tới ii đến từ bậc i−1i - 1 hoặc i−2i - 2, nên dpi=min⁡(dpi−1+∣hi−hi−1∣, dpi−2+∣hi−hi−2∣)\mathrm{dp}_i = \min(\mathrm{dp}_{i-1} + |h_i - h_{i-1}|,\ \mathrm{dp}_{i-2} + |h_i - h_{i-2}|), với dp1=0\mathrm{dp}_1 = 0 và dp2=∣h2−h1∣\mathrm{dp}_2 = |h_2 - h_1|. Giống hệt Fibonacci, chỉ thay cộng bằng lấy min. Vét cạn mọi đường nhảy là FNF_N đường; bảng chỉ có NN ô, mỗi ô O(1)O(1). Với ví dụ, dp=(0,5,4,7,7)\mathrm{dp} = (0, 5, 4, 7, 7).

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e5 + 5;
ll n, h[N], dp[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> h[i];
    dp[2] = abs(h[2] - h[1]);
    for (ll i = 3; i <= n; i++)
        dp[i] = min(dp[i - 1] + abs(h[i] - h[i - 1]), dp[i - 2] + abs(h[i] - h[i - 2]));
    cout << dp[n] << '\n';
}
5
8 13 4 10 7

Trạng thái và công thức

Fibonacci cho sẵn công thức. Ở đa số bài, phần khó nhất là tự tìm ra nó. Một lời giải quy hoạch động luôn trả lời đủ bốn câu:

  1. Trạng thái. Bài toán con được mô tả bởi những tham số nào, và dp[...] nghĩa chính xác là gì (đếm gì, tối ưu gì, với điều kiện gì)?
  2. Công thức chuyển. dp của một trạng thái tính từ dp của những trạng thái nào? Cách tìm: xét lựa chọn cuối cùng (phần tử cuối, bước cuối, vật cuối) rồi hỏi phần còn lại là bài toán con nào.
  3. Giá trị cơ sở. Trạng thái nhỏ nhất nào có đáp án biết sẵn?
  4. Thứ tự tính và đáp án. Thứ tự nào bảo đảm khi cần một ô thì mọi ô nó phụ thuộc đã có, và đáp án nằm ở ô nào?

Công thức chuyển chỉ đúng khi có hai tính chất. Thứ nhất là tối ưu con: phần còn lại của một phương án tối ưu cũng phải tối ưu cho bài con của nó, vì nếu không ta thay bằng phương án tốt hơn thì cả phương án lớn cũng tốt hơn. Với bài đếm, tính chất này đổi thành: các trường hợp (ví dụ “phần tử cuối là gì”) phải rời nhau và phủ hết mọi cấu hình, để mỗi cấu hình được đếm đúng một lần. Thứ hai là trạng thái đủ thông tin: khi đã biết dp của trạng thái, các quyết định về sau không cần biết ta tới đó bằng đường nào. Tính chất thứ hai dễ bị bỏ quên nhất.

Dãy con tăng dài nhất (longest increasing subsequence, LIS). Cho dãy a1,…,ana_1, \dots, a_n. Dãy con là dãy thu được bằng cách xóa một số phần tử và giữ nguyên thứ tự những phần tử còn lại. Tìm độ dài lớn nhất của một dãy con tăng nghiêm ngặt. Có 2n2^n dãy con nên vét cạn không đi xa được.

Trạng thái: dpi\mathrm{dp}_i là độ dài dãy con tăng dài nhất kết thúc đúng tại aia_i. Phần tử cuối nằm trong định nghĩa vì chỉ biết nó ta mới biết có nối thêm được aia_i hay không. Công thức chuyển: xét phần tử đứng ngay trước aia_i trong dãy tối ưu. Nếu không có thì dãy chỉ gồm aia_i, độ dài 1. Nếu đó là aja_j với j<ij < i và aj<aia_j < a_i thì phần còn lại là một dãy tăng kết thúc tại aja_j, và dài nhất có thể là dpj\mathrm{dp}_j:

dpi=1+max⁡(0, max⁡j<i, aj<aidpj).\mathrm{dp}_i = 1 + \max\Bigl(0,\ \max_{j < i,\ a_j < a_i} \mathrm{dp}_j\Bigr).

Ký hiệu max⁡\max có điều kiện viết bên dưới nghĩa là lấy số lớn nhất trong các giá trị thỏa điều kiện đó: ở đây là dpj\mathrm{dp}_j lớn nhất trong các j<ij < i có aj<aia_j < a_i, và số 0 lo trường hợp không có jj nào. min⁡\min viết kiểu này cũng đọc tương tự.

Vì sao chỉ cần dpj\mathrm{dp}_j? Nếu phần đứng trước aia_i kết thúc ở aja_j mà chưa phải dãy dài nhất kết thúc ở aja_j, thay nó bằng dãy dài nhất ấy vẫn cho một dãy tăng hợp lệ (điều kiện nối aj<aia_j < a_i không đổi) mà dài hơn: mâu thuẫn. Thứ tự tính là ii tăng dần, đáp án là số lớn nhất trong các dpi\mathrm{dp}_i. Với a=(5,2,8,6,3,6,9,7)a = (5, 2, 8, 6, 3, 6, 9, 7) ta được dp=(1,1,2,2,2,3,4,4)\mathrm{dp} = (1, 1, 2, 2, 2, 3, 4, 4), đáp án 4.

Ô cam là vị trí đang tính, ô xanh là vị trí trước tốt nhất; cuối cùng truy vết theo par.

Mảng par[i] ghi phần tử đứng trước aia_i trong dãy tốt nhất. Đi ngược từ ô tốt nhất theo par rồi đảo lại ta in được chính dãy con. Đó là truy vết (reconstruction), dùng lại nhiều lần ở các mục sau.

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 5005;
ll n, a[N], dp[N], par[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    ll best = 1;
    for (ll i = 1; i <= n; i++) {
        dp[i] = 1;
        for (ll j = 1; j < i; j++)
            if (a[j] < a[i] && dp[j] + 1 > dp[i]) {
                dp[i] = dp[j] + 1;
                par[i] = j;
            }
        if (dp[i] > dp[best]) best = i;
    }
    vector<ll> seq;
    for (ll i = best; i; i = par[i]) seq.push_back(a[i]);
    reverse(seq.begin(), seq.end());
    cout << dp[best] << '\n';
    for (ll x : seq) cout << x << ' ';
    cout << '\n';
}
8
5 2 8 6 3 6 9 7

Thời gian là O(n2)O(n^2): với n=5000n = 5000 có khoảng 1,25⋅1071{,}25 \cdot 10^7 cặp (j,i)(j, i), thoải mái; với n=2⋅105n = 2 \cdot 10^5 thì không, và khi đó ta tính nhanh hơn bằng chặt nhị phân (bài 05) hoặc cây Fenwick (bài 16). Code trên theo kiểu kéo: mỗi ii nhìn lại các jj đứng trước. Có thể viết kiểu đẩy: khi dpj\mathrm{dp}_j đã xong thì cập nhật cho mọi i>ji > j nối được. Hai kiểu tính cùng một công thức, chọn kiểu nào cho vòng lặp gọn hơn.

Hỏi nhanh

Một học sinh đặt gig_i là độ dài dãy con tăng dài nhất nằm trong ii phần tử đầu (không bắt buộc kết thúc tại aia_i), rồi thử viết gig_i qua các giá trị gg nhỏ hơn. Vì sao cách đặt này không đi tiếp được?

Ví dụLịch Hoạt Động

Đề. Kế hoạch N≤105N \le 10^5 ngày; mỗi ngày chọn đúng một trong ba hoạt động A,B,CA, B, C, nhận ai,bi,cia_i, b_i, c_i điểm, và không được chọn cùng một hoạt động hai ngày liền. In tổng điểm lớn nhất.

Lời giải. Thử đặt dpi\mathrm{dp}_i là tổng điểm lớn nhất sau ii ngày thì hỏng đúng như câu hỏi trên: muốn biết hôm nay chọn được hoạt động nào, phải biết hôm qua đã chọn gì. Đưa thông tin đó vào trạng thái: dpi,j\mathrm{dp}_{i,j} là tổng điểm lớn nhất sau ii ngày với ngày ii chọn hoạt động jj. Hôm qua là một trong hai hoạt động khác jj, nên dpi,j=si,j+max⁡k≠jdpi−1,k\mathrm{dp}_{i,j} = s_{i,j} + \max_{k \ne j} \mathrm{dp}_{i-1,k}, với dp0,⋅=0\mathrm{dp}_{0,\cdot} = 0. Đáp án là số lớn nhất trong ba ô dpN,j\mathrm{dp}_{N,j}. Trạng thái chỉ thêm đúng phần thiếu: một trong ba giá trị, nên bảng 3N3N ô.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e5 + 5;
ll n, a[N][3], dp[N][3];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 1; i <= n; i++)
        for (ll j = 0; j < 3; j++) cin >> a[i][j];
    for (ll i = 1; i <= n; i++)
        for (ll j = 0; j < 3; j++)
            for (ll k = 0; k < 3; k++)
                if (k != j) dp[i][j] = max(dp[i][j], dp[i - 1][k] + a[i][j]);
    cout << max({dp[n][0], dp[n][1], dp[n][2]}) << '\n';
}
4
8 3 5
6 9 4
7 2 10
5 11 6

Đổi tiền

Có các mệnh giá c1,…,ckc_1, \dots, c_k, mỗi mệnh giá dùng bao nhiêu đồng cũng được. Tạo tổng đúng bằng xx bằng ít đồng nhất.

Cách nghĩ đầu tiên là tham lam (bài 04): luôn lấy đồng lớn nhất không vượt phần còn lại. Với các mệnh giá {1,3,4}\{1, 3, 4\} và x=6x = 6, tham lam lấy 4+1+14 + 1 + 1 (3 đồng), trong khi 3+33 + 3 chỉ cần 2 đồng. Lấy đồng lớn trước có thể phá hỏng phần còn lại, nên ta phải xét mọi khả năng một cách có hệ thống.

Trạng thái: dps\mathrm{dp}_s là số đồng ít nhất để tạo tổng đúng ss, bằng +∞+\infty nếu không tạo được. Công thức chuyển: trong một phương án tối ưu cho ss, lấy ra một đồng bất kỳ, mệnh giá cc. Phần còn lại tạo tổng s−cs - c và (tối ưu con) phải dùng ít đồng nhất có thể. Ta chưa biết cc là mệnh giá nào nên thử tất cả:

dp0=0,dps=1+min⁡ci≤sdps−ci.\mathrm{dp}_0 = 0, \qquad \mathrm{dp}_s = 1 + \min_{c_i \le s} \mathrm{dp}_{s - c_i}.

Số hạng nào bằng +∞+\infty thì bỏ qua. Duyệt ss tăng dần vì dps\mathrm{dp}_s phụ thuộc các ô nhỏ hơn. Với {1,3,4}\{1, 3, 4\} ta có dp0..6=(0,1,2,1,1,2,2)\mathrm{dp}_{0..6} = (0, 1, 2, 1, 1, 2, 2); riêng dp6=1+min⁡(dp5,dp3,dp2)=2\mathrm{dp}_6 = 1 + \min(\mathrm{dp}_5, \mathrm{dp}_3, \mathrm{dp}_2) = 2, đạt khi lấy đồng 3 ở bước cuối.

Đổi câu hỏi thành đếm số cách thì trước hết phải nói rõ hai cách khác nhau khi nào. Hai cách hiểu cho hai bài toán, và hai đoạn code chỉ khác nhau ở thứ tự của hai vòng for.

Nếu 1+21 + 2 và 2+12 + 1 là hai cách (dãy có thứ tự), mỗi dãy có đúng một phần tử cuối cc, bỏ nó đi còn một dãy tạo tổng s−cs - c. Vậy dps=∑ci≤sdps−ci\mathrm{dp}_s = \sum_{c_i \le s} \mathrm{dp}_{s - c_i}, tức tổng các dps−ci\mathrm{dp}_{s - c_i} qua mọi mệnh giá ci≤sc_i \le s (ký hiệu ∑\sum là phép cộng, điều kiện viết bên dưới cho biết cộng những số nào), với dp0=1\mathrm{dp}_0 = 1 (dãy rỗng). Tổng ss nằm ở vòng ngoài, mệnh giá ở vòng trong.

Nếu chỉ quan tâm mỗi mệnh giá dùng bao nhiêu đồng (tập mệnh giá), ta xử lý lần lượt từng mệnh giá để mỗi tập chỉ được sinh một lần. Gọi dpk,s\mathrm{dp}_{k,s} là số cách tạo ss chỉ dùng kk mệnh giá đầu. Hoặc không dùng mệnh giá ckc_k, còn dpk−1,s\mathrm{dp}_{k-1,s} cách; hoặc dùng ít nhất một đồng ckc_k, bỏ một đồng ấy đi thì còn dpk,s−ck\mathrm{dp}_{k,s-c_k} cách, và ở đó ckc_k vẫn được dùng tiếp:

dpk,s=dpk−1,s+dpk,s−ck.\mathrm{dp}_{k,s} = \mathrm{dp}_{k-1,s} + \mathrm{dp}_{k,s-c_k}.

Nén thành một mảng, mệnh giá ở vòng ngoài và ss tăng dần ở vòng trong: dp[s] += dp[s - c]. Duyệt tăng chính là để dp[s - c] đã chứa các cách dùng cc. Chương trình dưới in số dãy có thứ tự (f) rồi số tập mệnh giá (g).

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll MOD = 1e9 + 7, N = 1e6 + 5;
ll n, x, c[105], f[N], g[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> x;
    for (ll i = 0; i < n; i++) cin >> c[i];
    f[0] = g[0] = 1;
    for (ll s = 1; s <= x; s++)
        for (ll i = 0; i < n; i++)
            if (c[i] <= s) f[s] = (f[s] + f[s - c[i]]) % MOD;
    for (ll i = 0; i < n; i++)
        for (ll s = c[i]; s <= x; s++) g[s] = (g[s] + g[s - c[i]]) % MOD;
    cout << f[x] << '\n' << g[x] << '\n';
}
2 5
1 2

Với x=5x = 5 và hai mệnh giá 1, 2: có 3 tập (2+2+12+2+1, 2+1+1+12+1+1+1, năm đồng 1) nhưng 8 dãy có thứ tự, vì ba tập đó cho lần lượt 3, 4 và 1 cách xếp. Đổi chỗ hai vòng for là đổi sang bài toán khác, nên phải đọc kỹ đề xem “cách” có phân biệt thứ tự không.

Hỏi nhanh

Mệnh giá {1,2}\{1, 2\}, x=3x = 3, dp[0] = 1. Chương trình có vòng ngoài duyệt ss từ 1 tới xx, vòng trong duyệt từng mệnh giá c≤sc \le s và cộng dp[s] += dp[s - c]. Cuối cùng dp[3] bằng bao nhiêu và nó đếm gì?

Ba lô 0/1

Khi mỗi vật chỉ lấy được một lần, ta có bài toán ba lô 0/1 (knapsack). Có nn vật, vật ii nặng wiw_i và có giá trị viv_i. Chọn một tập vật có tổng trọng lượng không quá sức chứa CC sao cho tổng giá trị lớn nhất. Vét cạn 2n2^n tập thì với n=100n = 100 là vô vọng.

Mỗi vật có hai khả năng, lấy hoặc không lấy, và có lấy được hay không còn tùy sức chứa còn lại. Vậy trạng thái cần hai tham số: dpi,c\mathrm{dp}_{i,c} là tổng giá trị lớn nhất khi chỉ xét ii vật đầu và tổng trọng lượng không quá cc. Xét vật ii: không lấy thì còn dpi−1,c\mathrm{dp}_{i-1,c}; lấy (nếu wi≤cw_i \le c) thì phần còn lại là i−1i - 1 vật đầu với sức chứa c−wic - w_i.

dpi,c=max⁡(dpi−1,c, dpi−1,c−wi+vi),dp0,c=0.\mathrm{dp}_{i,c} = \max\bigl(\mathrm{dp}_{i-1,c},\ \mathrm{dp}_{i-1,c-w_i} + v_i\bigr), \qquad \mathrm{dp}_{0,c} = 0.

Đáp án là dpn,C\mathrm{dp}_{n,C}. Bảng có (n+1)(C+1)(n + 1)(C + 1) ô, mỗi ô O(1)O(1), tổng thời gian O(nC)O(nC).

Mỗi ô lấy từ ô ngay trên (bỏ vật) hoặc ô lùi w cột ở hàng trên (lấy vật); bước cuối là truy ngược.

Hình dùng bốn vật (w,v)=(3,4),(2,3),(4,6),(1,1)(w, v) = (3, 4), (2, 3), (4, 6), (1, 1) và C=6C = 6. Ô cuối dp4,6=dp3,6\mathrm{dp}_{4,6} = \mathrm{dp}_{3,6} vì lấy thêm vật 4 chỉ được dp3,5+1=8\mathrm{dp}_{3,5} + 1 = 8, kém 9. Ô dp3,6=dp2,2+6=9\mathrm{dp}_{3,6} = \mathrm{dp}_{2,2} + 6 = 9 (lấy vật 3) và dp2,2=dp1,0+3\mathrm{dp}_{2,2} = \mathrm{dp}_{1,0} + 3 (lấy vật 2). Đường truy ngược cho tập {2,3}\{2, 3\}, nặng 6 và giá trị 9.

Điều kiện “không quá cc” ứng với khởi tạo dp0,c=0\mathrm{dp}_{0,c} = 0 cho mọi cc. Nếu đề đòi trọng lượng đúng bằng cc, chỉ dp0,0=0\mathrm{dp}_{0,0} = 0 còn các ô khác khởi tạo −∞-\infty (hoặc +∞+\infty khi tối thiểu hóa).

Hàng ii chỉ dùng hàng i−1i - 1 nên ta giữ một mảng dp[c] và sửa tại chỗ: dp[c] = max(dp[c], dp[c - w] + v). Công thức cần dp[c - w] của hàng cũ, tức khi chưa xét vật ii. Vì c−w<cc - w < c, nếu duyệt cc giảm dần thì ô c−wc - w chưa bị sửa trong lượt này. Duyệt tăng dần thì dp[c - w] đã chứa vật ii và vật bị lấy lại nhiều lần; đó chính là ba lô không giới hạn số lần, tức bài đổi tiền ở mục trước. Hướng duyệt quyết định bài toán. Với một vật w=2w = 2, v=3v = 3 và C=4C = 4: duyệt giảm cho dp=(0,0,3,3,3)\mathrm{dp} = (0, 0, 3, 3, 3), duyệt tăng cho (0,0,3,3,6)(0, 0, 3, 3, 6) vì vật được lấy hai lần.

C++
for (ll i = 1; i <= n; i++)
    for (ll c = C; c >= w[i]; c--) dp[c] = max(dp[c], dp[c - w[i]] + v[i]);

Bộ nhớ giảm từ O(nC)O(nC) xuống O(C)O(C). Với n=1000n = 1000 và C=105C = 10^5, bảng đầy đủ có 10810^8 ô, tức 400 MB với int, còn mảng một chiều chỉ 400 KB. Mảng nhỏ còn nằm gọn trong bộ nhớ đệm của CPU, nên chạy nhanh hơn hẳn:

Ba lô 0/1 với 500 vật, sức chứa 20000, trung vị 5 lần chạy, i7-10850H, g++ -O2.đệ quy có nhớ117 ms · gấp 21 lầnbảng hai chiều38 ms · gấp 6,8 lầnmột mảng, duyệt giảm5,6 ms
Ba lô 0/1 với 500 vật và sức chứa 20000, bằng đệ quy có nhớ, bảng hai chiều và một mảng duyệt giảm, đo trên i7-10850H, g++ -O2.

Cùng 10710^7 trạng thái, một mảng duyệt giảm nhanh gấp khoảng 7 lần bảng hai chiều và hơn 20 lần đệ quy có nhớ. Không cần truy vết thì hãy nén.

Hỏi nhanh

Ta sửa vòng lặp ba lô thành for (ll c = w; c <= C; c++) (duyệt tăng dần). Chạy trên đúng một vật có w=3w = 3, v=5v = 5 và sức chứa C=7C = 7. Chương trình in ra bao nhiêu?

Bảng hai chiều, truy vết

Khi trạng thái là một ô (i,j)(i, j) của bảng, công thức chuyển thường lấy từ các ô kề đã tính. Xét bảng R×CR \times C số nguyên (có thể âm). Đi từ ô (1,1)(1, 1) tới ô (R,C)(R, C), mỗi bước sang phải hoặc xuống dưới. Tìm đường có tổng các ô đi qua lớn nhất và in đường đó. Số đường là (R+C−2R−1)\binom{R + C - 2}{R - 1}, quá nhiều để duyệt.

Trạng thái: dpi,j\mathrm{dp}_{i,j} là tổng lớn nhất của một đường từ (1,1)(1, 1) tới (i,j)(i, j). Bước cuối tới (i,j)(i, j) đến từ ô trên hoặc ô trái:

dpi,j=ai,j+max⁡(dpi−1,j, dpi,j−1),dp1,1=a1,1.\mathrm{dp}_{i,j} = a_{i,j} + \max\bigl(\mathrm{dp}_{i-1,j},\ \mathrm{dp}_{i,j-1}\bigr), \qquad \mathrm{dp}_{1,1} = a_{1,1}.

Duyệt hàng từ trên xuống, mỗi hàng từ trái sang phải: khi tới (i,j)(i, j) cả hai ô nguồn đã có. Các ô ngoài bảng (hàng 0, cột 0) phải là −∞-\infty, không phải 0. Nếu đặt 0 thì một đường “xuất phát từ ngoài bảng” sẽ thắng mỗi khi mọi đường thật có tổng âm.

Muốn in đường, ta đi ngược từ (R,C)(R, C). Tại ô (i,j)(i, j) chọn ô nguồn có dp\mathrm{dp} lớn hơn, vì đó chính là ô đã cho ra dpi,j\mathrm{dp}_{i,j}; ghi lại bước vừa lùi và dừng khi tới (1,1)(1, 1), rồi đảo chuỗi bước. Không cần mảng mũi tên riêng, vì bảng dp đã chứa đủ thông tin để suy ra mọi lựa chọn.

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long

ll R, C, a[105][105], dp[105][105];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> R >> C;
    for (ll i = 1; i <= R; i++)
        for (ll j = 1; j <= C; j++) cin >> a[i][j];
    for (ll i = 0; i <= R; i++)
        for (ll j = 0; j <= C; j++) dp[i][j] = -1e18;
    for (ll i = 1; i <= R; i++)
        for (ll j = 1; j <= C; j++) {
            if (i == 1 && j == 1) dp[i][j] = a[i][j];
            else dp[i][j] = a[i][j] + max(dp[i - 1][j], dp[i][j - 1]);
        }
    string path;
    ll i = R, j = C;
    while (i > 1 || j > 1) {
        if (dp[i - 1][j] >= dp[i][j - 1]) {
            path += 'D';
            i--;
        } else {
            path += 'R';
            j--;
        }
    }
    reverse(path.begin(), path.end());
    cout << dp[R][C] << '\n' << path << '\n';
}
3 4
2 -3 4 1
-1 5 -2 3
3 -4 6 -1

Đường tối ưu đi xuống, phải, phải, xuống, phải: 2−1+5−2+6−1=92 - 1 + 5 - 2 + 6 - 1 = 9. Cái giá của truy vết là phải giữ cả bảng; nén thành một hàng như ở ba lô thì mất đường đi. Cùng khuôn này, thay max bằng phép cộng và ô cấm bằng 0 ta đếm được số đường; thay hai chiều của bảng bằng hai chuỗi ta được ví dụ dưới.

Hỏi nhanh

Bảng một hàng ba ô (−4,5,2)(-4, 5, 2). Một chương trình dùng công thức dpi,j=ai,j+max⁡(dpi−1,j,dpi,j−1)\mathrm{dp}_{i,j} = a_{i,j} + \max(\mathrm{dp}_{i-1,j}, \mathrm{dp}_{i,j-1}) cho mọi ô, kể cả (1,1)(1, 1), nhưng khởi tạo các ô viền (hàng 0, cột 0) bằng 0. Nó in ra gì cho ô (1,3)(1, 3)?

Ví dụChuỗi Chung Dài Nhất

Đề. Cho hai chuỗi chữ thường ss, tt, mỗi chuỗi dài tối đa 3000. In một dãy con chung dài nhất (bất kỳ nếu có nhiều); độ dài 0 thì in dòng rỗng.

Lời giải. Trạng thái là một cặp tiền tố: dpi,j\mathrm{dp}_{i,j} là độ dài dãy con chung dài nhất của ii ký tự đầu của ss và jj ký tự đầu của tt. Ô (i,j)(i, j) chỉ cần xét hai ký tự cuối:

dpi,j={dpi−1,j−1+1,si=tj,max⁡(dpi−1,j, dpi,j−1),si≠tj,\mathrm{dp}_{i,j} = \begin{cases} \mathrm{dp}_{i-1,j-1} + 1, & s_i = t_j, \\ \max\bigl(\mathrm{dp}_{i-1,j},\ \mathrm{dp}_{i,j-1}\bigr), & s_i \ne t_j, \end{cases}

với hàng 0 và cột 0 bằng 0. Khi si≠tjs_i \ne t_j, một dãy con chung không thể dùng cả hai ký tự ấy (nếu dùng thì cả hai là ký tự cuối của dãy, nên bằng nhau), nên phải bỏ một trong hai. Khi si=tjs_i = t_j, ghép hai ký tự ấy với nhau không bao giờ thiệt: nếu dãy tối ưu chỉ dùng một trong hai, đổi cách ghép ký tự cuối sang đúng cặp (i,j)(i, j) vẫn được dãy cùng độ dài.

Gõ hai chuỗi khác để thử; ô xanh lá trên đường truy vết là ký tự được lấy.

Truy vết từ ô (n,m)(n, m): ký tự bằng nhau thì ghi lại và đi chéo, khác thì đi tới ô kề có giá trị lớn hơn. Mỗi lần ghi, dp\mathrm{dp} giảm đúng 1, nên ghi được đúng dpn,m\mathrm{dp}_{n,m} ký tự; đảo lại là xong. Bảng có 9⋅1069 \cdot 10^6 ô; dùng int thì khoảng 36 MB, và phải giữ cả bảng để truy vết. Chuỗi đánh số từ 0 (s[i - 1]) nhưng bảng đánh số từ 1, vì ii là độ dài tiền tố.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

string s, t;
int dp[3005][3005];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> s >> t;
    ll n = s.size(), m = t.size();
    for (ll i = 1; i <= n; i++)
        for (ll j = 1; j <= m; j++) {
            if (s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
            else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
        }
    string ans;
    ll i = n, j = m;
    while (i > 0 && j > 0) {
        if (s[i - 1] == t[j - 1]) {
            ans += s[i - 1];
            i--;
            j--;
        } else if (dp[i - 1][j] >= dp[i][j - 1]) i--;
        else j--;
    }
    reverse(ans.begin(), ans.end());
    cout << ans << '\n';
}
abcde
ace

Quy hoạch động trên đoạn

Có bài mà bài con tự nhiên là một đoạn liên tục [l,r][l, r] của dãy: lời giải của đoạn dài ghép từ lời giải của hai đoạn ngắn hơn, nối nhau ở một điểm tách kk.

Ví dụ: một thanh gỗ dài LL đã đánh dấu mm vị trí cần cắt, 0<p1<p2<⋯<pm<L0 < p_1 < p_2 < \dots < p_m < L. Mỗi nhát cắt một mảnh gỗ dài ℓ\ell tốn ℓ\ell. Phải cắt tại mọi vị trí đánh dấu theo thứ tự tùy ý; tìm tổng chi phí nhỏ nhất. Thứ tự có ảnh hưởng: thanh dài 10 đánh dấu ở 2, 4, 7. Cắt ở 2 trước thì tốn 10+8+6=2410 + 8 + 6 = 24, cắt ở 7 trước tốn 10+7+4=2110 + 7 + 4 = 21, cắt ở 4 trước tốn 10+4+6=2010 + 4 + 6 = 20. Có m!m! thứ tự nên không thể thử hết.

Ta nhìn vào nhát cắt đầu tiên trên một mảnh. Nó cắt tại một vị trí pkp_k nào đó và tốn đúng chiều dài mảnh; sau nhát này hai nửa tách rời hẳn và được cắt xong một cách độc lập. Đặt p0=0p_0 = 0, pm+1=Lp_{m+1} = L và gọi dpi,j\mathrm{dp}_{i,j} là chi phí nhỏ nhất để cắt xong mảnh từ pip_i tới pjp_j:

dpi,i+1=0,dpi,j=(pj−pi)+min⁡i<k<j(dpi,k+dpk,j)(j≥i+2).\mathrm{dp}_{i,i+1} = 0, \qquad \mathrm{dp}_{i,j} = (p_j - p_i) + \min_{i < k < j}\bigl(\mathrm{dp}_{i,k} + \mathrm{dp}_{k,j}\bigr) \quad (j \ge i + 2).

Đáp án là dp0,m+1\mathrm{dp}_{0,m+1}. Hai bài con ở vế phải đều là đoạn ngắn hơn, nên thứ tự tính phải theo độ dài j−ij - i tăng dần. Với p=(0,2,4,7,10)p = (0, 2, 4, 7, 10): dp0,2=4\mathrm{dp}_{0,2} = 4, dp1,3=5\mathrm{dp}_{1,3} = 5, dp2,4=6\mathrm{dp}_{2,4} = 6; rồi dp0,3=7+min⁡(5,4)=11\mathrm{dp}_{0,3} = 7 + \min(5, 4) = 11, dp1,4=8+min⁡(6,5)=13\mathrm{dp}_{1,4} = 8 + \min(6, 5) = 13; cuối cùng dp0,4=10+min⁡(13,10,11)=20\mathrm{dp}_{0,4} = 10 + \min(13, 10, 11) = 20.

Bảng điền theo độ dài đoạn tăng dần; hai ô xanh là hai mảnh con của nhát cắt đầu tốt nhất.
C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long

ll L, m, p[505], dp[505][505];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> L >> m;
    for (ll i = 1; i <= m; i++) cin >> p[i];
    p[m + 1] = L;
    for (ll len = 2; len <= m + 1; len++)
        for (ll i = 0; i + len <= m + 1; i++) {
            ll j = i + len;
            dp[i][j] = 1e18;
            for (ll k = i + 1; k < j; k++) dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j]);
            dp[i][j] += p[j] - p[i];
        }
    cout << dp[0][m + 1] << '\n';
}
10 3
2 4 7

Có O(m2)O(m^2) trạng thái, mỗi trạng thái thử O(m)O(m) điểm tách, tổng O(m3)O(m^3). Số phép tính thật là khoảng m3/6m^3 / 6 (số bộ ba i<k<ji < k < j), nên m=500m = 500 tốn cỡ 2⋅1072 \cdot 10^7 còn m=5000m = 5000 thì không chạy nổi. Bài 41 sẽ cho thấy khi nào hạ được O(m3)O(m^3) xuống O(m2)O(m^2).

Hỏi nhanh

Trong quy hoạch động trên đoạn, dpi,j\mathrm{dp}_{i,j} phụ thuộc dpi,k\mathrm{dp}_{i,k} và dpk,j\mathrm{dp}_{k,j} với i<k<ji < k < j. Cách duyệt nào đảm bảo mọi ô cần dùng đã được tính?

Ví dụGộp Các Khối Kề Nhau

Đề. Có N≤400N \le 400 khối thành hàng, khối ii cỡ Ai≤109A_i \le 10^9. Mỗi thao tác gộp hai khối kề nhau cỡ xx, yy thành một khối cỡ x+yx + y, tốn x+yx + y. In tổng chi phí nhỏ nhất để còn một khối.

Lời giải. Lần ghép cuối cùng gộp một khối tạo từ đoạn [l,k][l, k] với một khối tạo từ đoạn [k+1,r][k + 1, r], và tốn đúng tổng cả đoạn, bất kể trước đó gộp ra sao. Hai nửa được gộp độc lập. Vậy dpl,r\mathrm{dp}_{l,r}, chi phí nhỏ nhất để gộp đoạn khối [l,r][l, r] thành một khối, bằng min⁡l≤k<r(dpl,k+dpk+1,r)+(Sr−Sl−1)\min_{l \le k < r}(\mathrm{dp}_{l,k} + \mathrm{dp}_{k+1,r}) + (S_r - S_{l-1}), với SS là tổng tiền tố và dpl,l=0\mathrm{dp}_{l,l} = 0. Ở đây đoạn là đoạn khối đóng [l,r][l, r] chứ không phải đoạn giữa hai vị trí cắt như thanh gỗ, nên điểm tách chạy từ ll tới r−1r - 1. Với ví dụ (2,2,1,2)(2, 2, 1, 2): tách giữa [1,2][1, 2] và [3,4][3, 4] cho 4+3+7=144 + 3 + 7 = 14. Thời gian cỡ 4003/6≈107400^3/6 \approx 10^7; chi phí tới cỡ 101410^{14} nên dùng ll.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

ll n, s[405], dp[405][405];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 1; i <= n; i++) {
        cin >> s[i];
        s[i] += s[i - 1];
    }
    for (ll len = 2; len <= n; len++)
        for (ll l = 1; l + len - 1 <= n; l++) {
            ll r = l + len - 1;
            dp[l][r] = 1e18;
            for (ll k = l; k < r; k++) dp[l][r] = min(dp[l][r], dp[l][k] + dp[k + 1][r]);
            dp[l][r] += s[r] - s[l - 1];
        }
    cout << dp[1][n] << '\n';
}
4
2 2 1 2

Chọn trạng thái

Phần lớn thời gian của một bài quy hoạch động nằm ở việc chọn trạng thái. Vài nguyên tắc giúp chọn nhanh hơn.

Đếm trước khi code. Thời gian xấp xỉ số trạng thái nhân chi phí chuyển. Giới hạn của đề cho biết cỡ nào còn chạy được: nn từ 10510^5 đến 10610^6 cho phép trạng thái một chiều với chuyển O(1)O(1) hoặc O(log⁡n)O(\log n); n≤5000n \le 5000 cho bảng n2n^2 với chuyển O(1)O(1); n≤500n \le 500 cho n3n^3 như quy hoạch động trên đoạn; hai tham số nhỏ, ví dụ n≤100n \le 100 và tổng giá trị ≤105\le 10^5, cho ba lô. Một trạng thái đúng nhưng cần 101010^{10} phép thì phải đổi trạng thái, tối ưu code không cứu được.

Thiếu thông tin thì thêm vào trạng thái, và chỉ thêm đúng phần thiếu. Đếm xâu nhị phân độ dài nn không có hai số 1 liền nhau: muốn biết được thêm bit 1 hay không phải biết bit cuối, nên trạng thái là (i,b)(i, b) với bb là bit cuối. Thêm 0 thì sau bit nào cũng được, thêm 1 chỉ sau bit 0: dpi,0=dpi−1,0+dpi−1,1\mathrm{dp}_{i,0} = \mathrm{dp}_{i-1,0} + \mathrm{dp}_{i-1,1} và dpi,1=dpi−1,0\mathrm{dp}_{i,1} = \mathrm{dp}_{i-1,0}. Với n=3n = 3 có 3+2=53 + 2 = 5 xâu: 000, 001, 010, 100, 101.

Giới hạn lớn thì đổi vai. Nếu một chiều của bảng quá lớn mà một đại lượng khác lại nhỏ, thử đưa đại lượng nhỏ vào trạng thái và dùng đại lượng lớn làm giá trị cần tối ưu. Ví dụ dưới làm đúng như vậy với ba lô.

Chuyển nhiều thì dùng tiền tố hoặc cấu trúc dữ liệu. Nếu dps\mathrm{dp}_s là tổng (hoặc min, max) của một dãy ô liên tiếp ở hàng trước, ta tính nhanh bằng tổng tiền tố (bài 02) hay cấu trúc dữ liệu (bài 16, 17), hạ chi phí chuyển từ O(n)O(n) xuống O(1)O(1) hoặc O(log⁡n)O(\log n). Chẳng hạn truy vấn “max⁡dpj\max \mathrm{dp}_j trên mọi jj có aj<aia_j < a_i” của LIS được cây Fenwick trả lời trong O(log⁡n)O(\log n), nên cả bài còn O(nlog⁡n)O(n \log n).

Modulo và kiểu dữ liệu. Bài đếm thường đòi kết quả theo modulo 109+710^9 + 7: lấy dư sau mỗi phép cộng, với phép trừ thì cộng thêm modulo trước khi lấy dư để khỏi âm. Bài tối ưu tràn int rất nhanh (tổng 10510^5 số cỡ 10910^9 đã tới 101410^{14}): chọn long long từ đầu, và chọn giá trị vô cực đủ lớn nhưng cộng thêm không tràn.

Kiểm tra. Hầu hết lời giải sai nằm ở định nghĩa trạng thái hoặc công thức, hiếm khi ở cú pháp. Viết bản vét cạn cho nn nhỏ (bài 00) và so với bản quy hoạch động trên nhiều test ngẫu nhiên; khi lệch, in bảng dp của một test cỡ 5 rồi tính tay vài ô.

Ví dụBa Lô Theo Tổng Giá Trị

Đề. Có N≤100N \le 100 vật, vật ii nặng WiW_i và có giá trị Vi≤1000V_i \le 1000; ba lô chịu tổng trọng lượng C≤109C \le 10^9. Chọn một tập vật, mỗi vật tối đa một lần, có tổng trọng lượng không quá CC để tổng giá trị lớn nhất.

Lời giải. Bảng ba lô theo trọng lượng cần 10910^9 ô, không khả thi. Nhưng tổng mọi giá trị S=V1+⋯+VN≤105S = V_1 + \dots + V_N \le 10^5 lại nhỏ, nên đổi vai: dpx\mathrm{dp}_x là tổng trọng lượng nhỏ nhất của một tập có tổng giá trị đúng xx, với dp0=0\mathrm{dp}_0 = 0 và các ô khác +∞+\infty. Mỗi vật (w,v)(w, v) sửa mảng với xx giảm dần: dpx←min⁡(dpx,dpx−v+w)\mathrm{dp}_x \leftarrow \min(\mathrm{dp}_x, \mathrm{dp}_{x-v} + w). Đáp án là xx lớn nhất có dpx≤C\mathrm{dp}_x \le C, và x=0x = 0 luôn thỏa. Giữ trọng lượng nhỏ nhất cho mỗi giá trị là đủ, vì ràng buộc chỉ là tổng trọng lượng không quá CC: tập nhẹ hơn cùng giá trị luôn thay được tập nặng hơn.

Thời gian O(N⋅S)=107O(N \cdot S) = 10^7, bộ nhớ O(S)O(S). Trọng lượng cộng lại tới 101110^{11} nên dùng ll, vô cực đặt 101810^{18} để cộng thêm một trọng lượng vẫn không tràn. Với ví dụ, vật 1 và 2 cho giá trị 13, nặng đúng 10; mọi giá trị lớn hơn đều cần từ 11 trở lên.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

ll n, C, w[105], v[105], dp[100005];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> C;
    ll S = 0;
    for (ll i = 0; i < n; i++) {
        cin >> w[i] >> v[i];
        S += v[i];
    }
    for (ll x = 1; x <= S; x++) dp[x] = 1e18;
    for (ll i = 0; i < n; i++)
        for (ll x = S; x >= v[i]; x--) dp[x] = min(dp[x], dp[x - v[i]] + w[i]);
    ll ans = 0;
    for (ll x = 0; x <= S; x++)
        if (dp[x] <= C) ans = x;
    cout << ans << '\n';
}
4 10
6 8
4 5
5 7
3 4

Khi đi thi

  • Trước khi gõ code, viết ra ba dòng: dp[...] nghĩa là gì, công thức chuyển, thứ tự tính; rồi lấy số trạng thái nhân chi phí chuyển so với giới hạn. Thử công thức bằng tay trên một test cỡ 5 trước khi cài.
  • Chọn chiều duyệt theo phụ thuộc: ba lô 0/1 duyệt sức chứa giảm dần, đổi tiền và ba lô không giới hạn duyệt tăng dần, đếm tập mệnh giá thì mệnh giá ở vòng ngoài, quy hoạch động đoạn theo độ dài tăng dần.
  • Trạng thái không tồn tại phải là ±∞\pm\infty chứ không phải 0 khi có số âm; kiểm tra trước khi cộng vào vô cực, và dùng long long hay modulo ngay khi đề nhắc tới tổng lớn hoặc “số cách”.

Tóm tắt

  • Quy hoạch động là giải mỗi bài con đúng một lần và lưu lại; thời gian xấp xỉ số trạng thái nhân chi phí chuyển.
  • Bốn việc phải làm: định nghĩa trạng thái bằng lời, tìm công thức chuyển bằng cách xét lựa chọn cuối, đặt giá trị cơ sở, chọn thứ tự tính. Trạng thái phải đủ thông tin cho các quyết định sau; thiếu thì thêm đúng phần thiếu.
  • Đổi tiền: tham lam có thể sai. Đếm dãy có thứ tự thì tổng ở vòng ngoài, đếm tập mệnh giá thì mệnh giá ở vòng ngoài.
  • Ba lô 0/1 nén một mảng và duyệt sức chứa giảm dần; duyệt tăng dần thành ba lô không giới hạn. Khi một chiều quá lớn, đổi vai trọng lượng và giá trị.
  • Truy vết dựa trên bảng đầy đủ: đi ngược từ ô đáp án qua ô đã cho ra giá trị; nén mảng làm mất truy vết.
  • Bảng hai chiều dùng viền −∞-\infty; quy hoạch động đoạn tính theo độ dài tăng dần với thời gian O(n3)O(n^3).

Bài tập

Đọc thêm