Chương 1·Bài 1.3.2

DP on Sequences

Cho một dãy gồm n≤2⋅105n \le 2 \cdot 10^5 số. Tìm độ dài lớn nhất của một dãy con tăng, tức là dãy thu được bằng cách xóa một số phần tử (có thể không xóa) và giữ nguyên thứ tự các phần tử còn lại, sao cho các giá trị tăng nghiêm ngặt. Bài toán này gọi là dãy con tăng dài nhất (longest increasing subsequence, viết tắt LIS), và ta cần giải trong O(nlog⁡n)O(n \log n).

Cách quy hoạch động thẳng gọi dpidp_i là độ dài dãy con tăng dài nhất kết thúc tại phần tử thứ ii, mỗi dpidp_i nhìn lại mọi phần tử đứng trước, mất O(n2)O(n^2). Nếu giữ thêm, với mỗi độ dài kk, giá trị cuối nhỏ nhất của một dãy con tăng dài kk, thì mỗi phần tử mới chỉ cần một lần chặt nhị phân trên mảng này. Trang này đi qua các bài quy hoạch động mà trạng thái là một vị trí hay một cặp vị trí trên một hoặc hai dãy: đoạn con tổng lớn nhất, LIS, xâu con chung dài nhất, khoảng cách sửa xâu và đường đi trên lưới.

IĐoạn con tổng lớn nhất

Cho dãy a1,…,ana_1, \dots, a_n gồm các số nguyên có thể âm. Tìm đoạn con (các phần tử liên tiếp, khác dãy con ở chỗ không được bỏ phần tử giữa chừng) không rỗng có tổng lớn nhất. Thử mọi cặp đầu cuối rồi tính tổng bằng tổng tiền tố (bài 0.3) mất O(n2)O(n^2). Cũng đã biết cách làm O(n)O(n): với mỗi vị trí cuối rr, lấy tổng tiền tố PrP_r trừ đi tổng tiền tố nhỏ nhất trước đó.

Quy hoạch động nhìn bài toán theo cách khác. Gọi dpidp_i là tổng lớn nhất của một đoạn con kết thúc đúng tại ii. Đoạn này hoặc chỉ gồm aia_i, hoặc là một đoạn kết thúc tại i−1i - 1 được nối thêm aia_i; đoạn kết thúc tại i−1i-1 tốt nhất có tổng dpi−1dp_{i-1}, nên

dpi=ai+max⁡(0,dpi−1),đaˊp aˊn=max⁡idpi.dp_i = a_i + \max(0, dp_{i-1}), \qquad \text{đáp án} = \max_i dp_i.

Ta chỉ cần giá trị của bước trước, nên một biến là đủ. Cách làm này thường gọi là thuật toán Kadane.

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 3e5 + 5;
ll n, a[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    ll cur = 0, ans = -1e18;
    for (ll i = 1; i <= n; i++) {
        cur = max(cur, 0LL) + a[i];
        ans = max(ans, cur);
    }
    cout << ans << '\n';
}
8
-2 1 -3 4 -1 2 1 -5

Độ phức tạp: O(n)O(n), với n=3⋅105n = 3 \cdot 10^5 cần khoảng 6⋅1056 \cdot 10^5 phép tính.

Hỏi nhanh

Một bạn viết chương trình tìm tổng lớn nhất của một đoạn con không rỗng, nhưng khởi tạo đáp án bằng 00. Bấm Chạy để xem chương trình in gì với dãy ba số −3,−1,−2-3, -1, -2:

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, a[100];

signed main() {
    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    ll cur = 0, ans = 0;
    for (ll i = 1; i <= n; i++) {
        cur = max(cur, 0LL) + a[i];
        ans = max(ans, cur);
    }
    cout << ans << '\n';
}
3
-3 -1 -2

Đáp án đúng là −1-1 (đoạn chỉ có số −1-1), nhưng chương trình in 00. Vì sao?

Thêm trạng thái khi có thêm thao tác

Đề có thể cho thêm một thao tác, và khi đó trạng thái cần nhớ thêm thao tác đã làm tới đâu. Xét ví dụ sau.

Ví dụĐoạn Nhân Một Lần

Đề. Cho dãy a1,…,ana_1, \dots, a_n (n≤3⋅105n \le 3 \cdot 10^5, ∣ai∣≤109|a_i| \le 10^9) và số nguyên xx (∣x∣≤100|x| \le 100). Được chọn nhiều nhất một đoạn liên tiếp rồi nhân mọi phần tử trong đó với xx (cũng có thể không chọn). Sau đó lấy tổng lớn nhất của một đoạn con liên tiếp, đoạn con được phép rỗng (tổng 00).

Lời giải. Với đoạn được lấy tổng, mỗi phần tử của nó nằm ở một trong ba vùng liên tiếp: trước vùng nhân, trong vùng nhân, hoặc sau vùng nhân; có vùng có thể rỗng. Ta gọi f0f_0, f1f_1, f2f_2 là tổng lớn nhất của một đoạn kết thúc tại vị trí hiện tại, mà phần tử cuối cùng đang ở vùng trước, vùng trong, vùng sau tương ứng.

Đoạn được chọn gồm ba vùng liên tiếp: trước vùng nhân, trong vùng nhân (số -5 thành 15) và sau vùng nhân.41-5223-1435-46x = -3trướctrongsau
Đoạn được chọn gồm ba vùng liên tiếp: trước vùng nhân, trong vùng nhân (số -5 thành 15) và sau vùng nhân.

Khi thêm phần tử aa, mỗi vùng chỉ có thể tiếp nối từ vùng của nó hoặc vùng ngay trước:

  • f0←max⁡(f0,0)+af_0 \leftarrow \max(f_0, 0) + a: vẫn ở vùng trước, hoặc bắt đầu đoạn mới tại aa.
  • f1←max⁡(f0,f1,0)+x⋅af_1 \leftarrow \max(f_0, f_1, 0) + x \cdot a: aa bị nhân; đoạn đến từ vùng trước, từ chính vùng trong, hoặc bắt đầu tại aa.
  • f2←max⁡(f1,f2)+af_2 \leftarrow \max(f_1, f_2) + a: sang vùng sau, từ vùng trong hoặc từ vùng sau.

Đáp án là 00 (đoạn rỗng) và lớn nhất của các giá trị f0f_0, f1f_1, f2f_2 sau mỗi phần tử. Cả ba cập nhật dùng giá trị cũ của bước trước, nên trong code tính xong rồi mới gán.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 3e5 + 5;
ll n, x, a[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> x;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    ll f0 = 0, f1 = -1e18, f2 = -1e18, ans = 0;
    for (ll i = 1; i <= n; i++) {
        ll g0 = max(f0, 0LL) + a[i];
        ll g1 = max(max(f0, f1), 0LL) + x * a[i];
        ll g2 = max(f1, f2) + a[i];
        f0 = g0, f1 = g1, f2 = g2;
        ans = max(ans, max(f0, max(f1, f2)));
    }
    cout << ans << '\n';
}
6 -3
4 -5 2 -1 3 -4

Độ phức tạp: O(n)O(n), với n=3⋅105n = 3 \cdot 10^5 cần khoảng 2⋅1062 \cdot 10^6 phép tính.

IIDãy con tăng dài nhất: O(n²)

Ta bắt đầu với cách quy hoạch động thẳng. Gọi dpidp_i là độ dài dãy con tăng dài nhất kết thúc đúng tại aia_i. Cần ghi nhớ phần tử cuối 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. 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 11; 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, dài nhất có thể là dpjdp_j. Vậy

dpi=1+max⁡(0, max⁡j<i, aj<aidpj),dp_i = 1 + \max\Bigl(0,\ \max_{j < i,\ a_j < a_i} dp_j\Bigr),

trong đó max⁡\max thứ hai lấy giá trị lớn nhất trong các dpjdp_j thỏa điều kiện viết bên dưới, và số 00 dành cho trường hợp không có jj nào. Đáp án là dpidp_i lớn nhất. Với dãy 5,2,8,6,3,6,9,75, 2, 8, 6, 3, 6, 9, 7 ta có dp=(1,1,2,2,2,3,4,4)dp = (1, 1, 2, 2, 2, 3, 4, 4), đáp án 44.

Ô 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 paripar_i ghi phần tử đứng trước aia_i trong dãy tốt nhất. Đi ngược từ phần tử có dpdp lớn nhất theo parpar rồi đảo lại, ta in được chính dãy con; đó là truy vết (reconstruction).

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 5005;
ll n, a[N], dp[N], par[N], seq[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;
    }
    ll len = 0;
    for (ll i = best; i; i = par[i]) seq[len++] = a[i];
    cout << dp[best] << '\n';
    for (ll i = len - 1; i >= 0; i--) cout << seq[i] << ' ';
    cout << '\n';
}
8
5 2 8 6 3 6 9 7

Độ phức tạp: O(n2)O(n^2), với n=5000n = 5000 cần khoảng 1,25⋅1071{,}25 \cdot 10^7 phép tính.

Với n=2⋅105n = 2 \cdot 10^5 cách này cần tới 2⋅10102 \cdot 10^{10} phép tính, quá chậm.

IIIDãy con tăng dài nhất: O(n log n)

Ta có thể thấy được điểm lãng phí của cách trên: mỗi aia_i xét lại mọi aja_j đứng trước, trong khi nhiều dãy cùng độ dài chỉ khác nhau ở phần tử cuối. Hai dãy con tăng cùng dài kk, dãy có phần tử cuối nhỏ hơn dễ được nối thêm hơn: mọi phần tử nối được vào dãy kia thì cũng nối được vào dãy này. Vì vậy, với mỗi độ dài kk ta chỉ cần nhớ phần tử cuối nhỏ nhất của một dãy con tăng dài kk, gọi là tailktail_k (mảng tailtail đánh số từ 00 trong code, nên tail[k−1]tail[k - 1] ứng với độ dài kk).

Mảng tailtail tăng nghiêm ngặt: một dãy con tăng dài k+1k + 1 kết thúc tại tt chứa một dãy dài kk kết thúc tại giá trị nhỏ hơn tt. Khi đọc phần tử mới xx, ta tìm vị trí đầu tiên trong tailtail có giá trị không nhỏ hơn xx:

  • Nếu không có vị trí nào, xx lớn hơn mọi phần tử cuối, ta nối xx vào dãy dài nhất và tailtail dài thêm 11.
  • Nếu có vị trí pp, thì xx là phần tử cuối nhỏ hơn (hoặc bằng) cho dãy dài p+1p + 1, nên ghi đè tail[p]=xtail[p] = x.
Mỗi phần tử mới tìm ô đầu tiên của tail không nhỏ hơn nó: ghi đè (cam) hoặc nối thêm (xanh lá).

Vì tailtail tăng nên vị trí đó tìm được bằng chặt nhị phân. Độ dài cuối cùng của tailtail là đáp án. Cần lưu ý tailtail không phải một dãy con thật của đầu vào: các giá trị trong nó có thể đến từ những dãy khác nhau. Muốn in một dãy tối ưu, ta ghi thêm cho mỗi phần tử vị trí của phần tử đứng trước nó ngay lúc chèn, rồi đi ngược như ở mục trước.

Ví dụNhững Mốc Tăng Dần

Đề. Cho dãy n≤2⋅105n \le 2 \cdot 10^5 số nguyên. In độ dài lớn nhất của một dãy con tăng nghiêm ngặt.

Lời giải. Dùng mảng tailtail ở trên. Hàm lower_bound trả về vị trí đầu tiên có giá trị không nhỏ hơn xx, đúng như bước tìm của ta. Với dãy tăng nghiêm ngặt, hai giá trị bằng nhau không được cùng nằm trong một dãy, nên cần lower_bound: khi xx bằng một phần tử cuối đã có, ta ghi đè lên chính nó thay vì nối thêm.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, a[N], tail[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    ll len = 0;
    for (ll i = 1; i <= n; i++) {
        cin >> a[i];
        ll p = lower_bound(tail, tail + len, a[i]) - tail;
        tail[p] = a[i];
        if (p == len) len++;
    }
    cout << len << '\n';
}
8
5 1 4 2 3 9 6 7

Độ phức tạp: O(nlog⁡n)O(n \log n), với n=2⋅105n = 2 \cdot 10^5 cần khoảng 3,5⋅1063{,}5 \cdot 10^6 phép tính.

Hỏi nhanh

Ta thử thay lower_bound bằng upper_bound trong chương trình trên, rồi chạy với ba số bằng nhau 2,2,22, 2, 2. Bấm Chạy để xem chương trình in gì:

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, a[100], tail[100];

signed main() {
    cin >> n;
    ll len = 0;
    for (ll i = 1; i <= n; i++) {
        cin >> a[i];
        ll p = upper_bound(tail, tail + len, a[i]) - tail;
        tail[p] = a[i];
        if (p == len) len++;
    }
    cout << len << '\n';
}
3
2 2 2

Với bài dãy con tăng nghiêm ngặt, đáp án đúng cho dãy này là 11. Chương trình in 33 vì sao?

Cùng ý này còn giải được nhiều bài khác. Ví dụ, cho nn khung hình chữ nhật, chọn nhiều khung nhất sao cho chúng lồng được vào nhau: sắp các khung theo chiều rộng tăng dần (với chiều rộng bằng nhau thì xếp chiều cao giảm dần), rồi tìm dãy con tăng dài nhất theo chiều cao.

IVXâu con chung dài nhất

Cho hai xâu ss và tt. Tìm một dãy con chung dài nhất, tức là xâu vừa là dãy con của ss vừa là dãy con của tt (xóa bớt ký tự nhưng giữ thứ tự), gọi là bài xâu con chung dài nhất (longest common subsequence, LCS). Với ∣s∣=∣t∣=n|s| = |t| = n có 2n2^n dãy con của mỗi xâu, nên không thể liệt kê.

Trạng thái là một cặp tiền tố: dpi,jdp_{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. Xét hai ký tự cuối sis_i và tjt_j:

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

với hàng 00 và cột 00 bằng 00. Khi hai ký tự khác nhau, một dãy con chung không thể dùng cả hai (nếu dùng thì cả hai là ký tự cuối của dãy, nên phải bằng nhau), nên phải bỏ một trong hai: bỏ sis_i cho dpi−1,jdp_{i-1,j}, bỏ tjt_j cho dpi,j−1dp_{i,j-1}. Khi hai ký tự bằng nhau, ghép chúng làm ký tự cuối của dãy không bao giờ thiệt hơn: nếu dãy tối ưu dùng sis_i nhưng ghép với ký tự khác của tt, ta đổi cách ghép sang tjt_j mà dãy vẫn dài như cũ.

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): hai ký tự bằng nhau thì ghi lại ký tự đó và đi chéo; khác nhau thì đi sang ô kề có giá trị lớn hơn (ô trên hoặc ô trái). Mỗi lần ghi, dpdp giảm đúng 11 nên ta ghi đúng dpn,mdp_{n,m} ký tự; đảo ngược xâu ghi được là xong. Cần giữ cả bảng để truy vết.

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

Đề. Cho hai chuỗi ss và tt, mỗi chuỗi dài tối đa 30003000. In một dãy con chung dài nhất (bất kỳ nếu có nhiều); nếu độ dài lớn nhất bằng 00 thì in dòng rỗng.

Lời giải. Dùng đúng công thức và cách truy vết ở trên. Bảng có 30012≈9⋅1063001^2 \approx 9 \cdot 10^6 ô; với kiểu int khoảng 3636 MB. Chuỗi đánh số từ 00 (s[i - 1]) nhưng bảng đánh số từ 11, vì ii là độ dài của 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

Độ phức tạp: O(nm)O(nm), với n=m=3000n = m = 3000 cần khoảng 9⋅1069 \cdot 10^6 phép tính.

VKhoảng cách sửa xâu

Cho hai xâu AA và BB. Mỗi bước được chèn một ký tự vào vị trí bất kỳ, xóa một ký tự, hoặc thay một ký tự bằng ký tự khác, mỗi bước tốn 11. Tìm số bước ít nhất để biến AA thành BB, gọi là khoảng cách sửa xâu (edit distance). Với ∣A∣,∣B∣≤5000|A|, |B| \le 5000.

Cũng dùng cặp tiền tố: dpi,jdp_{i,j} là số bước ít nhất để biến ii ký tự đầu của AA thành jj ký tự đầu của BB. Biến xâu rỗng thành jj ký tự cần jj lần chèn, và ngược lại cần ii lần xóa, nên dpi,0=idp_{i,0} = i và dp0,j=jdp_{0,j} = j. Xét hai ký tự cuối AiA_i và BjB_j:

  • Nếu Ai=BjA_i = B_j, ta giữ chúng nguyên: dpi,j=dpi−1,j−1dp_{i,j} = dp_{i-1,j-1}.
  • Nếu khác nhau, bước cuối cùng của một phương án tối ưu là một trong ba loại: xóa AiA_i (còn biến i−1i-1 ký tự đầu thành jj ký tự đầu, dpi−1,jdp_{i-1,j}), chèn BjB_j vào cuối (còn biến ii ký tự đầu thành j−1j-1 ký tự đầu, dpi,j−1dp_{i,j-1}), hoặc thay AiA_i thành BjB_j (dpi−1,j−1dp_{i-1,j-1}). Tốn thêm 11 bước:
dpi,j=1+min⁡(dpi−1,j, dpi,j−1, dpi−1,j−1).dp_{i,j} = 1 + \min(dp_{i-1,j},\ dp_{i,j-1},\ dp_{i-1,j-1}).

Mỗi hàng chỉ dùng hàng ngay trước nó, và bài này chỉ hỏi số bước chứ không cần in phương án, nên ta giữ hai hàng thay cho cả bảng 5000×50005000 \times 5000 ô.

Ví dụBiến Đổi Hai Xâu

Đề. Cho hai xâu AA và BB (1≤∣A∣,∣B∣≤50001 \le |A|, |B| \le 5000, gồm chữ in hoa). Mỗi thao tác chèn, xóa hoặc thay một chữ cái tốn 11. In số thao tác ít nhất để biến AA thành BB.

Lời giải. Dùng công thức ở trên. Hàng của ii lưu ở d[i & 1], hàng trước ở d[(i - 1) & 1]; trước khi tính hàng ii, đặt ô đầu hàng bằng ii.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
string a, b;
ll d[2][5005];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> a >> b;
    ll n = a.size(), m = b.size();
    for (ll j = 0; j <= m; j++) d[0][j] = j;
    for (ll i = 1; i <= n; i++) {
        ll p = i & 1, q = p ^ 1;
        d[p][0] = i;
        for (ll j = 1; j <= m; j++) {
            if (a[i - 1] == b[j - 1]) d[p][j] = d[q][j - 1];
            else d[p][j] = 1 + min(min(d[q][j], d[p][j - 1]), d[q][j - 1]);
        }
    }
    cout << d[n & 1][m] << '\n';
}
STONE
MONEY

Độ phức tạp: O(nm)O(nm) thời gian, O(m)O(m) bộ nhớ, với n=m=5000n = m = 5000 cần khoảng 2,5⋅1072{,}5 \cdot 10^7 phép tính.

VIĐường đi trên lưới

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.

Gọi dpi,jdp_{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.dp_{i,j} = a_{i,j} + \max(dp_{i-1,j},\ dp_{i,j-1}), \qquad dp_{1,1} = a_{1,1}.

Duyệt từng hàng từ trên xuống, mỗi hàng từ trái sang phải, thì khi tới (i,j)(i, j) cả hai ô nguồn đã có. Các ô ngoài bảng (hàng 00, cột 00) phải là −∞-\infty, không phải 00: nếu đặt 00, 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 mỗi ô chọn ô nguồn có dpdp lớn hơn (đó chính là ô đã cho ra dpi,jdp_{i,j}), ghi lại bước vừa lùi, dừng khi tới (1,1)(1, 1) rồi đảo chuỗi bước.

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

Độ phức tạp: O(RC)O(RC), với R=C=100R = C = 100 cần khoảng 2⋅1042 \cdot 10^4 phép tính.

Đườ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.

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)dp_{i,j} = a_{i,j} + \max(dp_{i-1,j}, 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 00, cột 00) bằng 00. Nó in ra gì cho ô (1,3)(1, 3)?

Cùng khuôn này, thay max bằng phép cộng và đặt ô cấm bằng 00 ta đếm được số đường đi.

Ví dụĐường Đi Qua Lưới

Đề. Lưới n×nn \times n (n≤1000n \le 1000), mỗi ô là ô trống . hoặc ô cấm *. Từ ô góc trên bên trái, mỗi bước đi sang phải hoặc xuống dưới, không bước vào ô cấm. Đếm số đường đi tới ô góc dưới bên phải, modulo 109+710^9 + 7.

Lời giải. Gọi dpi,jdp_{i,j} là số đường tới (i,j)(i, j). Ô cấm có dp=0dp = 0; ô trống khác ô xuất phát có dpi,j=dpi−1,j+dpi,j−1dp_{i,j} = dp_{i-1,j} + dp_{i,j-1} (lấy dư), vì mọi đường tới (i,j)(i, j) có đúng một bước cuối, từ ô trên hoặc ô trái. Ô xuất phát có dp1,1=1dp_{1,1} = 1 nếu nó là ô trống. Viền ngoài bảng bằng 00, vì không có đường nào đi vào từ ngoài; đây là bài đếm nên viền 00 mới đúng.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll MOD = 1e9 + 7;
ll n, dp[1005][1005];
char g[1005][1005];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    for (ll i = 1; i <= n; i++)
        for (ll j = 1; j <= n; j++) cin >> g[i][j];
    if (g[1][1] == '.') dp[1][1] = 1;
    for (ll i = 1; i <= n; i++)
        for (ll j = 1; j <= n; j++) {
            if (g[i][j] == '*' || (i == 1 && j == 1)) continue;
            dp[i][j] = (dp[i - 1][j] + dp[i][j - 1]) % MOD;
        }
    cout << dp[n][n] << '\n';
}
5
.....
.**..
...*.
*....
.....

Độ phức tạp: O(n2)O(n^2), với n=1000n = 1000 cần khoảng 10610^6 phép tính.

Khi hai người cùng đi từ hai đầu và phải cùng đến một ô, ta có thể xét hai người đi đồng thời từng bước: sau dd bước, mỗi người nằm trên đường chéo dd, nên trạng thái chỉ cần hai tọa độ hàng thay vì bốn tọa độ.

Bài nâng cao

Xem 2 bài nâng cao

Ví dụHai Hành Trình Và Một Điểm Hẹn

Đề. Bảng n×mn \times m (3≤n,m≤10003 \le n, m \le 1000), ô (i,j)(i, j) có giá trị ai,j≥0a_{i,j} \ge 0. Người thứ nhất đi từ (1,1)(1, 1) tới (n,m)(n, m), mỗi bước xuống hoặc sang phải. Người thứ hai đi từ (n,1)(n, 1) tới (1,m)(1, m), mỗi bước lên hoặc sang phải. Hai người phải gặp nhau tại đúng một ô chung, ô này không được tính điểm; mọi ô khác trên hai hành trình được cộng vào tổng điểm. Tìm tổng điểm lớn nhất.

Lời giải. Ta cần giá trị tốt nhất của đường đi từ mỗi góc tới mỗi ô, nên tính bốn bảng bằng đúng công thức đường đi lớn nhất ở mục trên, mỗi bảng từ một góc: d1d_1 từ (1,1)(1, 1), d2d_2 từ (n,m)(n, m), d3d_3 từ (n,1)(n, 1) và d4d_4 từ (1,m)(1, m), trong đó d2d_2 và d4d_4 chạy theo chiều ngược lại của người đi. Ô gặp (i,j)(i, j) nằm bên trong bảng (không nằm ở hàng hoặc cột biên). Người thứ nhất vào ô gặp theo hướng sang phải hoặc xuống, người thứ hai theo hướng sang phải hoặc lên; vì mỗi người chỉ đi qua ô đó một lần và hai người không dùng chung ô nào khác, chỉ có hai cách ghép không giao nhau:

  • Người thứ nhất đi vào từ ô trên (i−1,j)(i-1, j) và ra ở ô dưới (i+1,j)(i+1, j); người thứ hai vào từ ô trái (i,j−1)(i, j-1) và ra ở ô phải (i,j+1)(i, j+1).
  • Người thứ nhất vào từ ô trái (i,j−1)(i, j-1) và ra ở ô phải (i,j+1)(i, j+1); người thứ hai vào từ ô dưới (i+1,j)(i+1, j) và ra ở ô trên (i−1,j)(i-1, j).

Tổng điểm của cấu hình thứ nhất là d1[i−1][j]+d2[i+1][j]+d3[i][j−1]+d4[i][j+1]d_1[i-1][j] + d_2[i+1][j] + d_3[i][j-1] + d_4[i][j+1], cấu hình thứ hai là d1[i][j−1]+d2[i][j+1]+d3[i+1][j]+d4[i−1][j]d_1[i][j-1] + d_2[i][j+1] + d_3[i+1][j] + d_4[i-1][j]. Đáp án là giá trị lớn nhất trên mọi ô trong và cả hai cấu hình.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1005;
ll n, m, a[N][N], d1[N][N], d2[N][N], d3[N][N], d4[N][N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    for (ll i = 1; i <= n; i++)
        for (ll j = 1; j <= m; j++) cin >> a[i][j];
    for (ll i = 1; i <= n; i++)
        for (ll j = 1; j <= m; j++) d1[i][j] = a[i][j] + max(d1[i - 1][j], d1[i][j - 1]);
    for (ll i = n; i >= 1; i--)
        for (ll j = m; j >= 1; j--) d2[i][j] = a[i][j] + max(d2[i + 1][j], d2[i][j + 1]);
    for (ll i = n; i >= 1; i--)
        for (ll j = 1; j <= m; j++) d3[i][j] = a[i][j] + max(d3[i + 1][j], d3[i][j - 1]);
    for (ll i = 1; i <= n; i++)
        for (ll j = m; j >= 1; j--) d4[i][j] = a[i][j] + max(d4[i - 1][j], d4[i][j + 1]);
    ll ans = 0;
    for (ll i = 2; i < n; i++)
        for (ll j = 2; j < m; j++) {
            ll x = d1[i - 1][j] + d2[i + 1][j] + d3[i][j - 1] + d4[i][j + 1];
            ll y = d1[i][j - 1] + d2[i][j + 1] + d3[i + 1][j] + d4[i - 1][j];
            ans = max(ans, max(x, y));
        }
    cout << ans << '\n';
}
3 3
2 4 6
8 0 10
12 14 16

Độ phức tạp: O(nm)O(nm), với n=m=1000n = m = 1000 cần khoảng 5⋅1065 \cdot 10^6 phép tính.

Ví dụGhép Đúng K Khối Chung

Đề. Cho hai xâu SS và TT (N,M≤1000N, M \le 1000) và số K≤10K \le 10. Chọn đúng KK xâu không rỗng P1,…,PKP_1, \dots, P_K sao cho trong cả SS lẫn TT, chúng xuất hiện dưới dạng các đoạn con liên tiếp, đôi một không giao nhau và theo đúng thứ tự (đoạn của PiP_i nằm trước đoạn của Pi+1P_{i+1} trong từng xâu). Tối đa hóa tổng độ dài ∣P1∣+⋯+∣PK∣|P_1| + \cdots + |P_K|. Input bảo đảm có cách chọn.

Lời giải. Đây là LCS thêm một chiều đếm số khối. Với mỗi cặp tiền tố (i,j)(i, j) và số khối kk ta nhớ hai giá trị. gi,j,kg_{i,j,k} là tổng độ dài lớn nhất khi dùng đúng kk khối trong ii ký tự đầu của SS và jj ký tự đầu của TT. fi,j,kf_{i,j,k} giống gg nhưng ép ký tự SiS_i và TjT_j ghép với nhau thành ký tự cuối của khối thứ kk (chỉ xảy ra khi Si=TjS_i = T_j). Khi Si=TjS_i = T_j, ký tự này hoặc nối thêm vào khối đang dở, hoặc mở khối mới:

fi,j,k=1+max⁡(fi−1,j−1,k, gi−1,j−1,k−1),f_{i,j,k} = 1 + \max(f_{i-1,j-1,k},\ g_{i-1,j-1,k-1}),

và fi,j,kf_{i,j,k} không có giá trị (đặt −∞-\infty) nếu Si≠TjS_i \ne T_j. Còn gi,j,k=max⁡(gi−1,j,k, gi,j−1,k, fi,j,k)g_{i,j,k} = \max(g_{i-1,j,k},\ g_{i,j-1,k},\ f_{i,j,k}) và gi,j,0=0g_{i,j,0} = 0. Đáp án là gN,M,Kg_{N,M,K}. Hàng ii chỉ dùng hàng i−1i - 1, nên ta giữ hai hàng theo ii để bảng không quá lớn.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll NEG = -1e9;
ll n, m, K, f[2][1005][11], g[2][1005][11];
string s, t;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m >> K >> s >> t;
    for (ll j = 0; j <= m; j++)
        for (ll k = 0; k <= K; k++) {
            g[0][j][k] = k == 0 ? 0 : NEG;
            f[0][j][k] = NEG;
        }
    for (ll i = 1; i <= n; i++) {
        ll p = i & 1, q = p ^ 1;
        for (ll k = 0; k <= K; k++) {
            g[p][0][k] = k == 0 ? 0 : NEG;
            f[p][0][k] = NEG;
        }
        for (ll j = 1; j <= m; j++) {
            g[p][j][0] = 0;
            f[p][j][0] = NEG;
            for (ll k = 1; k <= K; k++) {
                if (s[i - 1] == t[j - 1]) f[p][j][k] = 1 + max(f[q][j - 1][k], g[q][j - 1][k - 1]);
                else f[p][j][k] = NEG;
                g[p][j][k] = max(max(g[q][j][k], g[p][j - 1][k]), f[p][j][k]);
            }
        }
    }
    cout << g[n & 1][m][K] << '\n';
}
6 6 2
abcxyz
abqxyz

Độ phức tạp: O(NMK)O(NMK), với N=M=1000N = M = 1000 và K=10K = 10 cần khoảng 10710^7 phép tính.

Khi đi thi

  • Dãy con (có thể bỏ phần tử) nghĩ tới LIS và LCS; đoạn con (liên tiếp) nghĩ tới Kadane và tổng tiền tố.
  • LIS với nn lớn thì viết bản O(nlog⁡n)O(n \log n): nghiêm ngặt dùng lower_bound, không giảm dùng upper_bound.
  • Bảng n×mn \times m vượt bộ nhớ thì giữ hai hàng; muốn truy vết thì phải giữ cả bảng (hoặc lưu lựa chọn gọn hơn).

Tóm tắt

  • Đoạn con tổng lớn nhất: dpi=ai+max⁡(0,dpi−1)dp_i = a_i + \max(0, dp_{i-1}); có thao tác thêm thì thêm trạng thái cho từng giai đoạn của thao tác.
  • LIS: dpidp_i là độ dài dài nhất kết thúc tại ii cho O(n2)O(n^2); mảng phần tử cuối nhỏ nhất theo độ dài và chặt nhị phân cho O(nlog⁡n)O(n \log n).
  • LCS và khoảng cách sửa xâu dùng bảng dpi,jdp_{i,j} trên hai tiền tố; truy vết bằng cách đi ngược bảng từ ô đáp án.
  • Đường đi trên lưới: bước cuối đến từ ô trên hoặc ô trái; bài tối ưu có viền −∞-\infty, bài đếm có viền 00.
  • Nhiều đường đi xuất phát từ nhiều góc thì tính một bảng cho mỗi góc rồi ghép tại ô gặp.

Bài tập

Đọc thêm