Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học
Thi thử miễn phí
🏆 Thi thử HSG Tin học tuần này trên ClueOJ
Mở công khai cho mọi người · đề do thầy Nguyễn Việt Nam Sơn biên soạn
Lịch thi, đề cũ & lời giải →

Chuyên đề cơ bản 3: Quy hoạch động kinh điển trong C++ và Python: dãy con chung dài nhất, sửa xâu, QHĐ trên lưới, đếm cách đổi tiền, chia đoạn

Chuyên đề cơ bản 3: Quy hoạch động kinh điển trong C++ và Python: dãy con chung dài nhất, sửa xâu, QHĐ trên lưới, đếm cách đổi tiền, chia đoạn
Series: Chuyên đề cơ bản Độ khó: Trung bình Chuyên đề cơ bản 3 / Thuật toán cơ bản Quy hoạch động
// Chuyên đề cơ bản · Chuyên đề 3
Quy hoạch động kinh điển: LCS, sửa xâu, lưới, đổi tiền, chia đoạn
Phần lớn bài QHĐ trong đề HSG là biến thể của một nhúm dạng kinh điển. Nắm chắc cách đặt trạng thái và thứ tự vòng lặp của từng dạng, em sẽ thấy bài lạ hoá ra rất quen.

Lời mở đầu

Ở bài 19 em đã làm quen với quy hoạch động qua Fibonacci, đếm đường đi trên lưới, cái túi 0/1, dãy con tăng dài nhất và đổi tiền. Đó là phần nhập môn: mỗi bài một công thức. Nhưng khi vào phòng thi, đề không bao giờ ghi “bài này là cái túi”. Em phải tự nhận ra một bài về hai xâu chính là LCS trá hình, một bài robot đi trên bản đồ thật ra là QHĐ trên lưới, hay một bài đếm số cách leo cầu thang chính là bài đổi tiền với vòng lặp đảo ngược.

Chuyên đề này gom lại những dạng kinh điển hay gặp nhất sau bài 19: dãy con chung dài nhất, khoảng cách sửa xâu, lưới có ô cấm và đi 3 hướng, cái túi không giới hạn, đếm cách đổi tiền (và cái bẫy thứ tự vòng lặp nổi tiếng), chia dãy thành đoạn. Nếu em quên đệ quy có nhớ, hãy xem lại bài 15, vì mọi công thức dưới đây đều có thể viết thành hàm đệ quy trước rồi mới chuyển sang vòng lặp. Phần truy vết tổng quát, QHĐ trên đoạn, bitmask và chữ số sẽ học ở bài 27.

Trong bài này bạn sẽ học: dãy con chung dài nhất (LCS) với bảng hai chiều và cách đi ngược theo mũi tên để in ra xâu; khoảng cách sửa xâu với ba phép chèn, xoá, sửa; QHĐ trên lưới có ô cấm và đi 3 hướng (vì sao phải điền theo cột); cái túi không giới hạn và đếm số cách đổi tiền, nơi thứ tự hai vòng lặp quyết định em đếm tổ hợp hay chỉnh hợp; chia dãy thành đoạn với $dp[i] = \min_j$, thuật toán Kadane nhìn như QHĐ, và quy trình 5 bước nhận dạng bài QHĐ. Có 2 khung chạy tay, 3 mô phỏng bấm từng bước và 8 bài tập kèm gợi ý.

Lộ trình bài viết, bấm vào từng phần để nhảy tới:

Phần 1Dãy con chung dài nhất (LCS)

Bài toán. Cho hai xâu $s$ (dài $n$) và $t$ (dài $m$). Một xâu con (dãy con) là xâu nhận được khi xoá đi một số ký tự, giữ nguyên thứ tự các ký tự còn lại, không cần liên tiếp. Tìm xâu dài nhất vừa là xâu con của $s$ vừa là xâu con của $t$. Ví dụ $s$ = ABCBDAB, $t$ = BDCABA có LCS dài 4, chẳng hạn BCBA.

Hình dung LCS như việc nối dây giữa hai xâu: mỗi sợi dây nối hai ký tự giống nhau, và các sợi dây không được cắt nhau (vì phải giữ thứ tự). LCS là cách nối được nhiều dây nhất.

s =ABCBDABt =BDCABA

Hình 1. LCS = BCBA: bốn sợi dây xanh lá nối các ký tự giống nhau, không sợi nào cắt nhau. Các ký tự xám bị bỏ qua.

Trạng thái. Gọi $dp[i][j]$ là độ dài LCS của $i$ ký tự đầu của $s$ và $j$ ký tự đầu của $t$. Đáp án là $dp[n][m]$. Hàng 0 và cột 0 ứng với xâu rỗng nên bằng 0. Công thức: nhìn hai ký tự cuối $s_i$ và $t_j$ (trong code là s[i-1], t[j-1] vì đánh số từ 0):

  • Nếu $s_i = t_j$: nối luôn một sợi dây giữa hai ký tự này, phần còn lại là bài toán nhỏ hơn: $dp[i][j] = dp[i-1][j-1] + 1$.
  • Nếu $s_i \ne t_j$: hai ký tự này không thể cùng được nối (nếu cả hai đều có dây thì hai dây phải nối với nhau, vô lý). Vậy bỏ $s_i$ hoặc bỏ $t_j$: $dp[i][j] = \max(dp[i-1][j],\ dp[i][j-1])$.

Mỗi ô chỉ cần ô trên, ô trái và ô chéo trên trái, nên duyệt hàng tăng, cột tăng là đủ. Độ phức tạp $O(nm)$ cả thời gian lẫn bộ nhớ.

Khuôn mẫu QHĐ hai xâu
// dp[i][j]: đáp án cho s[0..i-1] và t[0..j-1]
for(int i = 0; i <= n; i++)
    for(int j = 0; j <= m; j++){
        if(i == 0 || j == 0){ dp[i][j] = cơ sở; continue; }
        if(s[i - 1] == t[j - 1]) dp[i][j] = ... dp[i - 1][j - 1] ...;
        else dp[i][j] = kết hợp(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
    }
Đánh số dp từ 1 (hàng 0, cột 0 dành cho xâu rỗng) giúp khỏi phải viết riêng trường hợp biên. LCS, sửa xâu, và rất nhiều bài “so khớp hai dãy” đều theo đúng khuôn này.

Truy vết. Muốn in ra chính xâu LCS, khi điền mỗi ô ta để ý nó lấy giá trị từ đâu: ↖ (hai ký tự khớp), ↑ (bỏ $s_i$) hay ← (bỏ $t_j$). Xong bảng, đứng ở góc dưới phải và đi ngược theo mũi tên; mỗi lần đi chéo ↖ là gom được một ký tự. Vì đi từ cuối về đầu nên phải đảo xâu gom được. Em bấm từng bước để xem cả hai pha, rồi thử đổi hai xâu khác:

MÔ PHỎNGĐiền bảng LCS rồi đi ngược theo mũi tênNhập hai xâu (mỗi xâu tối đa 8 ký tự) rồi bấm Tiếp / Tự chạy. Mỗi ô ghi mũi tên cho biết nó lấy giá trị từ đâu.
ô đang tínhô được dùng để tínhđường truy vếtchưa tính
∅BDCABA
∅0000000
A0↑0↑0↑0↖1←1↖1
B0↖1←1←1↑1↖2←2
C0↑1↑1↖2←2↑2↑2
B0↖1↑1↑2↑2↖3←3
D0↑1↖2↑2↑2↑3↑3
A0↑1↑2↑2↖3↑3↖4
B0↖1↑2↑2↑3↖4↑4

Bảng 1. Bảng LCS của s = ABCBDAB (hàng) và t = BDCABA (cột), mỗi ô ghi mũi tên và giá trị. Ô xanh lá là đường truy vết từ góc dưới phải; bốn lần đi chéo ↖ gom được A, B, C, B, đảo lại thành BCBA, dp = 4.

Xem code C++ / Python: xâu con chung dài nhất và in ra một xâu LCS (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int dp[3005][3005];

int main(){
    string s, t;
    cin >> s >> t;
    int n = s.size(), m = t.size();
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++){
            if(s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;   // khớp: đi chéo
            else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);            // bỏ một ký tự
        }
    string res = "";
    int i = n, j = m;
    while(i > 0 && j > 0){                         // truy vết từ góc dưới phải
        if(s[i - 1] == t[j - 1]){ res += s[i - 1]; i--; j--; }
        else if(dp[i - 1][j] >= dp[i][j - 1]) i--;   // mũi tên ↑
        else j--;                                    // mũi tên ←
    }
    reverse(res.begin(), res.end());               // gom ngược nên phải đảo lại
    cout << dp[n][m] << "\n" << res;
    return 0;
}
import sys

s, t = sys.stdin.read().split()
n, m = len(s), len(t)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
    for j in range(1, m + 1):
        if s[i - 1] == t[j - 1]:
            dp[i][j] = dp[i - 1][j - 1] + 1           # khớp: đi chéo
        else:
            dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])  # bỏ một ký tự
res = []
i, j = n, m
while i > 0 and j > 0:                               # truy vết từ góc dưới phải
    if s[i - 1] == t[j - 1]:
        res.append(s[i - 1]); i -= 1; j -= 1
    elif dp[i - 1][j] >= dp[i][j - 1]:
        i -= 1                                       # mũi tên ↑
    else:
        j -= 1                                       # mũi tên ←
print(dp[n][m])
print(''.join(reversed(res)))                        # gom ngược nên phải đảo lại
Xâu con khác đoạn con
Nhiều bạn đọc nhầm “xâu con chung” (được nhảy cóc) thành “đoạn con chung” (phải liên tiếp). Với đoạn con chung dài nhất, công thức đổi thành: $s_i = t_j$ thì $dp[i][j] = dp[i-1][j-1] + 1$, còn khác nhau thì $dp[i][j] = 0$ (đoạn bị đứt), và đáp án là ô lớn nhất trong bảng chứ không phải góc dưới phải. Đọc kỹ đề trước khi gõ code!

Một mẹo đáng nhớ: nếu $s$ và $t$ là hai hoán vị của $1..n$ thì LCS của chúng bằng dãy con tăng dài nhất (bài 19) của dãy “vị trí trong $t$ của từng phần tử $s$”. Nhờ đó bài $n = 10^5$ (bảng $O(n^2)$ không chạy nổi) vẫn giải được trong $O(n \log n)$.

Phần 2Khoảng cách sửa xâu (edit distance)

Bài toán. Cho xâu $s$ và $t$. Mỗi phép biến đổi là một trong ba thao tác trên $s$: chèn một ký tự, xoá một ký tự, sửa một ký tự thành ký tự khác. Hỏi cần ít nhất bao nhiêu phép để biến $s$ thành $t$? Con số này gọi là khoảng cách Levenshtein; trình soạn thảo dùng nó để gợi ý sửa lỗi chính tả, và nó cũng là “họ hàng” gần của LCS.

Trạng thái: $dp[i][j]$ = số phép ít nhất để biến $i$ ký tự đầu của $s$ thành $j$ ký tự đầu của $t$. Xét ký tự cuối:

  • $s_i = t_j$: không cần làm gì với hai ký tự này, $dp[i][j] = dp[i-1][j-1]$.
  • $s_i \ne t_j$: thao tác cuối cùng phải xử lý chúng bằng một trong ba cách, cộng 1 phép:
Thao tác cuốiÝ nghĩaĐi từ ôHướng trong bảng
Sửa $s_i$ thành $t_j$sau đó hai ký tự cuối khớp nhau$dp[i-1][j-1] + 1$↖ chéo
Xoá $s_i$còn phải biến $s_{1..i-1}$ thành $t_{1..j}$$dp[i-1][j] + 1$↑ trên
Chèn $t_j$ vào cuối $s$còn phải biến $s_{1..i}$ thành $t_{1..j-1}$$dp[i][j-1] + 1$← trái

Bảng 2. Ba lựa chọn khi hai ký tự cuối khác nhau: $dp[i][j] = 1 + \min$ của ba ô.

Cơ sở khác LCS: $dp[i][0] = i$ (xoá hết $i$ ký tự) và $dp[0][j] = j$ (chèn đủ $j$ ký tự). Quên cơ sở này, để hàng 0 và cột 0 bằng 0 như LCS, là lỗi rất hay gặp. Ví dụ biến SUNDAY thành SATURDAY cần 3 phép: chèn A, chèn T, sửa N thành R.

∅SATURDAY
∅012345678
S101234567
U211223456
N322233456
D433334345
A543444434
Y654455543

Bảng 3. Bảng sửa xâu SUNDAY (hàng) → SATURDAY (cột). Ô xanh lá là những chỗ hai ký tự khớp (chép ô chéo, không tốn phép). Đáp án ở góc dưới phải = 3.

Em chạy tay từng ô với hai xâu ngắn. Để ý ở mỗi ô khác nhau, khung chạy tay cho biết lựa chọn nào (sửa, xoá hay chèn) cho giá trị nhỏ nhất:

C++▶ Chạy tay từng dòng codeĐiền bảng sửa xâu theo từng hàng (ô vàng: ô đang tính, ô đỏ: các ô được dùng).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int dp[1005][1005];
5
6int main(){
7 string s, t;
8 cin >> s >> t;
9 int n = s.size(), m = t.size();
10 for(int i = 0; i <= n; i++) dp[i][0] = i;
11 for(int j = 0; j <= m; j++) dp[0][j] = j;
12 for(int i = 1; i <= n; i++){
13 for(int j = 1; j <= m; j++){
14 if(s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1];
15 else dp[i][j] = 1 + min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]});
16 }
17 }
18 cout << dp[n][m];
19 return 0;
20}
Xem code C++ / Python: khoảng cách sửa xâu (chèn, xoá, sửa) (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int dp[1005][1005];

int main(){
    string s, t;
    cin >> s >> t;
    int n = s.size(), m = t.size();
    for(int i = 0; i <= n; i++) dp[i][0] = i;
    for(int j = 0; j <= m; j++) dp[0][j] = j;
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++){
            if(s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1];
            else dp[i][j] = 1 + min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]});
        }
    }
    cout << dp[n][m];
    return 0;
}
import sys

s, t = sys.stdin.read().split()
n, m = len(s), len(t)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n + 1):
    dp[i][0] = i                    # xoá hết i ký tự
for j in range(m + 1):
    dp[0][j] = j                    # chèn đủ j ký tự
for i in range(1, n + 1):
    for j in range(1, m + 1):
        if s[i - 1] == t[j - 1]:
            dp[i][j] = dp[i - 1][j - 1]
        else:
            dp[i][j] = 1 + min(dp[i - 1][j - 1],   # sửa
                               dp[i - 1][j],       # xoá s[i-1]
                               dp[i][j - 1])       # chèn t[j-1]
print(dp[n][m])
Biến thể hay gặp
1. Chỉ được chèn và xoá (không sửa): đáp án là $n + m - 2 \cdot \text{LCS}$, vì giữ lại đúng LCS, xoá phần thừa của $s$ và chèn phần thiếu của $t$ (bài tập 2).
2. Mỗi thao tác có chi phí khác nhau: thay số 1 bằng chi phí tương ứng, kể cả ở hàng 0 và cột 0 (bài tập 6).
3. Xâu dài $5000$: bảng int $5001 \times 5001$ tốn khoảng 100 MB. Hàng $i$ chỉ dùng hàng $i - 1$, nên giữ hai hàng là đủ (code Python ở bài tập 2, 6 làm đúng như vậy).

Phần 3QHĐ trên lưới: ô cấm và đi 3 hướng

Bài 19 đã đếm số đường đi trên lưới khi robot chỉ đi sang phải hoặc xuống dưới. Công thức quen thuộc với bài tổng lớn nhất: $dp[i][j] = a[i][j] + \max(dp[i-1][j],\ dp[i][j-1])$. Đề thi thật hay thêm hai “gia vị”: ô cấm và hướng đi khác.

Ô cấm. Ô cấm không thể đứng, nên dp của nó phải mang nghĩa “không tới được”. Với bài đếm số đường: gán 0 là xong, vì 0 cách cộng vào đâu cũng không đổi. Với bài max: không được gán 0 (0 có thể lớn hơn một đường thật có tổng âm!), mà phải gán một số rất nhỏ $-\infty$ (ví dụ LLONG_MIN / 4, chia 4 để cộng thêm không bị tràn), và khi lấy max từ các ô trước cần kiểm tra “có ô nào tới được không”. Nếu ô đích vẫn là $-\infty$ thì in $-1$.

Đi 3 hướng. Bài toán: robot xuất phát ở ô bất kỳ của cột đầu, mỗi bước sang cột bên phải theo một trong ba hướng: chéo lên, ngang, chéo xuống. Kết thúc ở ô bất kỳ của cột cuối. Tìm tổng lớn nhất. Ô $(i, j)$ chỉ có thể tới từ $(i-1, j-1)$, $(i, j-1)$, $(i+1, j-1)$:

$dp[i][j] = a[i][j] + \max\big(dp[i-1][j-1],\ dp[i][j-1],\ dp[i+1][j-1]\big), \qquad dp[i][0] = a[i][0].$

(i−1, j−1)(i, j−1)(i, j)(i+1, j−1)dp[i][j] = a[i][j] + max của: dp[i−1][j−1], dp[i][j−1], dp[i+1][j−1]Cả 3 ô đều nằm ở CỘT TRƯỚC.→ vòng ngoài duyệt cột j, vòng trong duyệt hàng i.Duyệt hàng trước sẽ đọc ô (i+1, j−1)khi nó CHƯA được tính!

Hình 2. Ba ô có thể đi tới $(i, j)$ đều nằm ở cột $j - 1$. Thứ tự tính đúng là theo từng cột.

Đây là chỗ nhiều bạn mất điểm: theo thói quen viết for i ở ngoài, for j ở trong. Khi đó lúc tính $(i, j)$, ô $(i+1, j-1)$ thuộc hàng chưa duyệt tới nên vẫn bằng 0, chương trình chạy êm mà ra kết quả sai. Quy tắc chung: ô nào được dùng thì phải tính trước. Mọi mũi tên đều đi từ cột trái sang cột phải, nên vòng ngoài là cột. Em xem mô phỏng điền bảng theo từng cột, có ô cấm:

MÔ PHỎNGQHĐ trên lưới 3 hướng: điền theo từng cộtSửa lưới (mỗi hàng một dòng, # là ô cấm, tối đa 5 × 7) rồi bấm Tiếp. Bảng dp bên phải được điền từng cột.
ô đang tính3 ô có thể đi tới nóô cấmđường đi tốt nhất

Với lưới mặc định trong mô phỏng, đường tốt nhất là 5 → 6 → 4 → 9 → 5 với tổng 29: xuất phát ở hàng cuối, đi chéo lên hai lần, đi ngang, rồi chéo lên. Code dưới đây đọc lưới với $-1$ là ô cấm (các ô khác từ 0 trở lên) và in $-1$ nếu không có đường:

Xem code C++ / Python: đường đi tổng lớn nhất trên lưới 3 hướng có ô cấm (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

const long long NEG = LLONG_MIN / 4;      // "âm vô cùng": không tới được
long long a[1005][1005], dp[1005][1005];

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    for(int i = 0; i < n; i++)
        for(int j = 0; j < m; j++) cin >> a[i][j];     // -1 là ô cấm
    for(int i = 0; i < n; i++) dp[i][0] = (a[i][0] == -1) ? NEG : a[i][0];
    for(int j = 1; j < m; j++)                         // vòng ngoài: CỘT
        for(int i = 0; i < n; i++){                    // vòng trong: hàng
            dp[i][j] = NEG;
            if(a[i][j] == -1) continue;
            long long best = NEG;
            for(int d = -1; d <= 1; d++){              // 3 ô ở cột trước
                int p = i + d;
                if(p >= 0 && p < n) best = max(best, dp[p][j - 1]);
            }
            if(best != NEG) dp[i][j] = best + a[i][j];
        }
    long long ans = NEG;
    for(int i = 0; i < n; i++) ans = max(ans, dp[i][m - 1]);
    cout << (ans == NEG ? -1 : ans);
    return 0;
}
import sys

data = sys.stdin.buffer.read().split()
n, m = int(data[0]), int(data[1])
a = [list(map(int, data[2 + i * m: 2 + (i + 1) * m])) for i in range(n)]
NEG = float('-inf')                       # không tới được
dp = [[NEG] * m for _ in range(n)]
for i in range(n):
    if a[i][0] != -1:
        dp[i][0] = a[i][0]
for j in range(1, m):                     # vòng ngoài: CỘT
    for i in range(n):                    # vòng trong: hàng
        if a[i][j] == -1:
            continue
        best = dp[i][j - 1]
        if i > 0:
            best = max(best, dp[i - 1][j - 1])
        if i + 1 < n:
            best = max(best, dp[i + 1][j - 1])
        if best != NEG:
            dp[i][j] = best + a[i][j]
ans = max(dp[i][m - 1] for i in range(n))
print(-1 if ans == NEG else ans)
Ba câu hỏi trước khi viết QHĐ trên lưới
1. Từ đâu tới được ô $(i, j)$? Liệt kê đúng các ô trước, đó là công thức. 2. Thứ tự tính? Mọi ô trước phải xong trước: đi phải/xuống thì hàng tăng, cột tăng; đi 3 hướng sang phải thì theo cột; đi lên/phải thì hàng giảm, cột tăng. 3. Bắt đầu và kết thúc ở đâu? Một ô cố định, hay cả một hàng, một cột? Nếu kết thúc ở cả cột cuối thì đáp án là max cả cột, không phải một ô.

Phần 4Cái túi không giới hạn và đếm số cách đổi tiền

Cái túi không giới hạn. Giống cái túi 0/1 ở bài 19, nhưng mỗi loại vật có vô số cái, lấy bao nhiêu cũng được. Gọi $dp[c]$ là giá trị lớn nhất khi sức chứa là $c$. Chỉ cần đổi chiều của vòng lặp sức chứa:

Cái túi 0/1 (bài 19)Cái túi không giới hạn
Vòng lặpfor c = W down to wfor c = w to W
Khi đọc $dp[c - w]$vẫn là giá trị cũ (chưa có vật $i$)đã là giá trị mới (có thể đã chứa vật $i$)
Hệ quảmỗi vật lấy tối đa 1 lầnvật $i$ được lấy lại nhiều lần

Bảng 4. Chỉ khác nhau ở chiều duyệt $c$. Ví dụ sức chứa 10, ba vật (nặng 3, giá 5), (nặng 4, giá 7), (nặng 5, giá 9): túi 0/1 được 16 (vật 2 và 3), túi không giới hạn được 18 (hai vật nặng 5).

Xem code C++ / Python: cái túi không giới hạn (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

long long dp[100005];        // dp[c]: giá trị lớn nhất với sức chứa c

int main(){
    int n, W;
    cin >> n >> W;
    for(int i = 0; i < n; i++){
        int w; long long v;
        cin >> w >> v;
        for(int c = w; c <= W; c++)              // c TĂNG dần: vật i được lấy nhiều lần
            dp[c] = max(dp[c], dp[c - w] + v);
    }
    cout << dp[W];
    return 0;
}
import sys

data = sys.stdin.read().split()
n, W = int(data[0]), int(data[1])
dp = [0] * (W + 1)                  # dp[c]: giá trị lớn nhất với sức chứa c
for i in range(n):
    w, v = int(data[2 + 2 * i]), int(data[3 + 2 * i])
    for c in range(w, W + 1):       # c TĂNG dần: vật i được lấy nhiều lần
        if dp[c - w] + v > dp[c]:
            dp[c] = dp[c - w] + v
print(dp[W])

Đếm số cách đổi tiền. Có các loại xu mệnh giá $c_1, \ldots, c_k$, mỗi loại vô hạn. Hỏi có bao nhiêu cách tạo ra tổng $S$? Ở đây có hai câu hỏi khác nhau mà đề hay viết rất giống nhau:

  • Tổ hợp: $1 + 2 + 2$ và $2 + 1 + 2$ là một cách (chỉ quan tâm dùng bao nhiêu xu mỗi loại). Ví dụ: đổi tiền, mua hàng.
  • Chỉnh hợp: $1 + 2 + 2$ và $2 + 1 + 2$ là hai cách khác nhau (thứ tự có ý nghĩa). Ví dụ: leo cầu thang mỗi bước 1 hoặc 2 bậc, nhảy ô, ghép xâu.

Điều kỳ diệu: cả hai đều dùng đúng một dòng dp[s] += dp[s - c] với $dp[0] = 1$; chỉ khác vòng nào ở ngoài.

Xu ở ngoài, tổng ở trongTổng ở ngoài, xu ở trong
Codefor c in xu:
  for s = c..S:
    dp[s] += dp[s-c]
for s = 1..S:
  for c in xu:
    dp[s] += dp[s-c]
Ý nghĩa dp[s]số cách chỉ dùng các loại xu đã xétsố dãy xu (có thứ tự) có tổng s
Vì saocác xu luôn xuất hiện theo thứ tự loại 1, loại 2, … nên mỗi tập xu chỉ được đếm một lầnmọi loại xu đều được thử làm xu cuối cùng của dãy
Đếmtổ hợpchỉnh hợp
Xu {1, 2, 5}, S = 54 cách: 5; 2+2+1; 2+1+1+1; 1+1+1+1+19 cách: 5; 2+2+1 (3 thứ tự); 2+1+1+1 (4 thứ tự); 1+1+1+1+1

Bảng 5. Cùng một dòng cộng, đổi thứ tự hai vòng lặp là đổi hẳn bài toán.

Em chọn hai thứ tự khác nhau trong mô phỏng để thấy cùng mệnh giá 1, 2, 5 và $S = 6$ mà một bên ra 5, một bên ra 15. Ở thứ tự “xu ngoài”, các hàng mờ phía trên cho thấy bảng sau khi xét xong từng loại xu:

MÔ PHỎNGĐếm số cách đổi tiền: hai thứ tự vòng lặp, hai đáp ánNhập các mệnh giá, tổng S, chọn thứ tự vòng lặp rồi bấm Tiếp. Đổi thứ tự để thấy kết quả khác nhau.
dp[s] đang cộngdp[s − c] được cộng vàođáp ánbảng sau từng loại xu

Khung chạy tay dưới đây là bản tổ hợp. Để ý lúc xét xu 2: dp[4] += dp[2] mà $dp[2]$ vừa được cộng thêm cách “2” ở bước trước, nên “2 + 2” được đếm. Đó chính là lý do duyệt $s$ tăng dần cho phép dùng một loại xu nhiều lần, giống hệt cái túi không giới hạn.

C++▶ Chạy tay từng dòng codeĐếm số cách đổi tiền (tổ hợp): vòng ngoài là loại xu, vòng trong là tổng s tăng dần.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4long long dp[100005];
5
6int main(){
7 int n, S;
8 cin >> n >> S;
9 vector<int> c(n);
10 for(int i = 0; i < n; i++) cin >> c[i];
11 dp[0] = 1;
12 for(int i = 0; i < n; i++)
13 for(int s = c[i]; s <= S; s++)
14 dp[s] += dp[s - c[i]];
15 cout << dp[S];
16 return 0;
17}
Xem code C++ / Python: đếm số cách đổi tiền theo tổ hợp và theo chỉnh hợp (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

const long long MOD = 1000000007;
long long comb[100005], perm[100005];

int main(){
    int n, S;
    cin >> n >> S;
    vector<int> c(n);
    for(int i = 0; i < n; i++) cin >> c[i];
    comb[0] = perm[0] = 1;
    // Tổ hợp (không phân biệt thứ tự): XU ở ngoài, TỔNG ở trong
    for(int i = 0; i < n; i++)
        for(int s = c[i]; s <= S; s++)
            comb[s] = (comb[s] + comb[s - c[i]]) % MOD;
    // Chỉnh hợp (có phân biệt thứ tự): TỔNG ở ngoài, XU ở trong
    for(int s = 1; s <= S; s++)
        for(int i = 0; i < n; i++)
            if(s >= c[i]) perm[s] = (perm[s] + perm[s - c[i]]) % MOD;
    cout << comb[S] << " " << perm[S];
    return 0;
}
import sys

MOD = 10**9 + 7
data = sys.stdin.read().split()
n, S = int(data[0]), int(data[1])
c = list(map(int, data[2:2 + n]))
comb = [1] + [0] * S
perm = [1] + [0] * S
# Tổ hợp (không phân biệt thứ tự): XU ở ngoài, TỔNG ở trong
for x in c:
    for s in range(x, S + 1):
        comb[s] = (comb[s] + comb[s - x]) % MOD
# Chỉnh hợp (có phân biệt thứ tự): TỔNG ở ngoài, XU ở trong
for s in range(1, S + 1):
    tot = 0
    for x in c:
        if s >= x:
            tot += perm[s - x]
    perm[s] = tot % MOD
print(comb[S], perm[S])
Ba cái bẫy của bài đổi tiền
1. Nhầm thứ tự vòng lặp: luôn tự hỏi “1 + 2 và 2 + 1 có khác nhau không?” trước khi viết. Thử với xu {1, 2}, S = 3: tổ hợp ra 2, chỉnh hợp ra 3.
2. Quên $dp[0] = 1$: không có nó thì mọi ô đều bằng 0.
3. Tràn số: số cách tăng rất nhanh (chỉnh hợp với xu {1, 2} chính là dãy Fibonacci), nên đề thường bắt lấy dư cho $10^9 + 7$. Lấy dư ngay sau mỗi phép cộng.

Phần 5Chia dãy thành đoạn, Kadane và quy trình nhận dạng

Dạng chia đoạn. Đề: chia dãy $a_1, \ldots, a_n$ thành các đoạn liên tiếp, mỗi đoạn có một chi phí, tìm cách chia có tổng chi phí nhỏ nhất (hoặc lớn nhất). Khuôn trạng thái: $dp[i]$ = đáp án tốt nhất khi đã chia xong $i$ phần tử đầu. Đoạn cuối cùng là $a_{j+1..i}$ với một $j$ nào đó, phần trước nó đã chia tốt nhất bằng $dp[j]$:

$dp[i] = \min\limits_{0 \le j < i} \big(dp[j] + \text{chiphi}(j+1, i)\big), \qquad dp[0] = 0.$

Ví dụ. Chia dãy thành các nhóm liên tiếp, mỗi nhóm tốn một phí cố định $C$ cộng với độ chênh (lớn nhất − nhỏ nhất) trong nhóm. Nhóm ít thì chênh lệch lớn, nhóm nhiều thì tốn phí cố định. Khi duyệt $j$ giảm dần từ $i - 1$ về 0, đoạn cuối dài thêm một phần tử về bên trái, nên cập nhật max, min trong $O(1)$; tổng cộng $O(n^2)$. Với $a$ = [1, 2, 10, 11, 3, 4] và $C = 3$:

ia[i]Lựa chọn tốt nhấtTínhdp[i]
11đoạn cuối [1]dp[0] + 3 + (1 − 1) = 0 + 3 + 03
22đoạn cuối [1, 2]dp[0] + 3 + (2 − 1) = 0 + 3 + 14
310đoạn cuối [10]dp[2] + 3 + (10 − 10) = 4 + 3 + 07
411đoạn cuối [10, 11]dp[2] + 3 + (11 − 10) = 4 + 3 + 18
53đoạn cuối [3]dp[4] + 3 + (3 − 3) = 8 + 3 + 011
64đoạn cuối [3, 4]dp[4] + 3 + (4 − 3) = 8 + 3 + 112

Bảng 6. Đáp án $dp[6] = 12$: ba nhóm [1, 2], [10, 11], [3, 4], mỗi nhóm tốn $3 + 1 = 4$. Gom cả dãy vào một nhóm tốn $3 + 10 = 13$, còn mỗi phần tử một nhóm tốn $6 \cdot 3 = 18$.

Xem code C++ / Python: chia dãy thành các nhóm liên tiếp với chi phí nhỏ nhất (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

long long a[5005], dp[5005];

int main(){
    int n; long long C;
    cin >> n >> C;
    for(int i = 1; i <= n; i++) cin >> a[i];
    dp[0] = 0;                                   // chưa có phần tử nào: chi phí 0
    for(int i = 1; i <= n; i++){
        dp[i] = LLONG_MAX;
        long long mx = a[i], mn = a[i];
        for(int j = i - 1; j >= 0; j--){         // đoạn cuối là a[j+1..i]
            mx = max(mx, a[j + 1]); mn = min(mn, a[j + 1]);
            dp[i] = min(dp[i], dp[j] + C + (mx - mn));
        }
    }
    cout << dp[n];
    return 0;
}
import sys

data = sys.stdin.read().split()
n, C = int(data[0]), int(data[1])
a = [0] + list(map(int, data[2:2 + n]))
dp = [0] * (n + 1)                  # dp[0] = 0: chưa có phần tử nào
for i in range(1, n + 1):
    best = None
    mx = mn = a[i]
    for j in range(i - 1, -1, -1):  # đoạn cuối là a[j+1..i]
        x = a[j + 1]
        if x > mx: mx = x
        if x < mn: mn = x
        v = dp[j] + C + mx - mn
        if best is None or v < best:
            best = v
    dp[i] = best
print(dp[n])

Kadane cũng là QHĐ. Tổng đoạn con lớn nhất (bài tập 5 của bài 24 giải bằng chia để trị $O(n \log n)$) có lời giải $O(n)$ rất gọn nếu đặt đúng trạng thái: $dp[i]$ = tổng lớn nhất của một đoạn kết thúc đúng tại $i$. Đoạn đó hoặc chỉ gồm $a_i$, hoặc nối tiếp đoạn tốt nhất kết thúc tại $i - 1$:

$dp[i] = \max(a_i,\ dp[i-1] + a_i), \qquad \text{đáp án} = \max_i dp[i].$

Nói cách khác: nếu đoạn trước đang có tổng âm thì nó chỉ “kéo chân”, bỏ đi và bắt đầu lại từ $a_i$. Vì $dp[i]$ chỉ dùng $dp[i-1]$ nên giữ một biến là đủ. Chữ “kết thúc tại $i$” là mẹo đặt trạng thái cực kỳ hay dùng: LIS, Kadane, bài tập 7 và 8 đều dùng nó.

Xem code C++ / Python: tổng đoạn con lớn nhất bằng thuật toán Kadane (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int main(){
    int n;
    cin >> n;
    vector<long long> a(n);
    for(auto &x : a) cin >> x;
    long long cur = a[0], best = a[0];        // cur = dp[i]: đoạn tốt nhất KẾT THÚC tại i
    for(int i = 1; i < n; i++){
        cur = max(a[i], cur + a[i]);          // bắt đầu lại, hoặc nối tiếp đoạn trước
        best = max(best, cur);
    }
    cout << best;
    return 0;
}
import sys

data = sys.stdin.read().split()
n = int(data[0])
a = list(map(int, data[1:n + 1]))
cur = best = a[0]                 # cur = dp[i]: đoạn tốt nhất KẾT THÚC tại i
for x in a[1:]:
    cur = max(x, cur + x)         # bắt đầu lại, hoặc nối tiếp đoạn trước
    best = max(best, cur)
print(best)

Quy trình 5 bước nhận dạng. Gặp một bài lạ, em đi lần lượt qua năm câu hỏi dưới đây. Nếu trả lời được hết, bài đó gần như chắc chắn là QHĐ và em đã có luôn công thức.

BướcCâu hỏiVí dụ trả lời (đổi tiền tổ hợp)
1Đề hỏi lớn nhất / nhỏ nhất / đếm số cách / có tồn tại không? Thử mọi cách bằng quay lui có quá chậm không?đếm số cách tạo tổng S; quay lui là hàm mũ
2Lời giải có thể xây từng bước, mỗi bước một lựa chọn? Bước cuối cùng là gì?xu cuối cùng được thêm vào
3Sau khi bỏ bước cuối, phần còn lại có phải cùng dạng nhưng nhỏ hơn? Cần nhớ gì để mô tả nó? Đó là trạng thái.tổng còn lại $s$, và đã xét tới loại xu nào
4Viết công thức từ các trạng thái nhỏ hơn, chọn cơ sở và thứ tự tính sao cho ô được dùng luôn tính trước.$dp[s] \mathrel{+}= dp[s-c]$, $dp[0] = 1$, xu ở ngoài
5Đếm số trạng thái × số lựa chọn và so với giới hạn đề. Quá lớn thì tìm cách giảm chiều.$k \cdot S = 10^2 \cdot 10^4 = 10^6$, rất nhanh

Bảng 7. Năm câu hỏi giúp nhận ra và dựng một lời giải QHĐ.

Dấu hiệu trong đềDạngTrạng tháiĐộ phức tạp
hai xâu / hai dãy, so khớp, giữ thứ tự, chèn xoá sửaLCS, sửa xâu (phần 1, 2)$dp[i][j]$$O(nm)$
bảng, robot đi theo một số hướng cố định, không quay lạiQHĐ trên lưới (phần 3)$dp[i][j]$$O(nm)$
tạo tổng / sức chứa từ các loại vật, mỗi loại dùng nhiều lầntúi không giới hạn, đổi tiền (phần 4)$dp[s]$$O(kS)$
chia dãy thành các đoạn liên tiếp, mỗi đoạn có chi phíchia đoạn (phần 5)$dp[i]$ = chia xong $i$ phần tử$O(n^2)$ hoặc $O(nK)$
đoạn con / dãy con tốt nhất, điều kiện giữa hai phần tử kề nhauKadane, LIS$dp[i]$ = kết thúc tại $i$$O(n)$, $O(n^2)$

Bảng 8. Bảng nhận dạng các dạng kinh điển trong chuyên đề này. Các dạng khó hơn (trên đoạn, bitmask, chữ số) có ở bài 27.

Khi chưa chắc chắn
Viết đệ quy có nhớ theo đúng câu trả lời ở bước 3, 4: hàm f(trạng thái) thử mọi bước cuối và gọi lại chính nó (như bài 15), thêm mảng nhớ là xong. Cách này không phải lo thứ tự tính. Sau khi chắc công thức đúng, chuyển sang vòng lặp để chạy nhanh hơn, nhất là với Python.
Luyện tập trên web
Các bài dưới đây trên blog đều có lời giải chi tiết bằng C++ và Python, xếp từ dễ tới khó. Em hãy tự làm trước rồi mới xem lời giải nhé.Xem toàn bộ bài cùng chủ đề: nhãn Quy hoạch động.

Bài tậpLuyện quy hoạch động kinh điển

Bài 1Đường qua vườn có ô cấmDễ

Khu vườn là lưới $n \times m$ ($1 \le n, m \le 1000$), ô . đi được, ô # có bụi gai. Bạn Minh đứng ở góc trên trái và muốn tới góc dưới phải, mỗi bước chỉ đi sang phải hoặc xuống dưới, không bước vào ô có gai. Đếm số đường đi khác nhau, lấy dư cho $10^9 + 7$. Nếu ô xuất phát hoặc ô đích có gai thì đáp án là 0.

Input
3 4
....
.#..
....
Output
4

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Không có gai thì có $\binom{5}{2} = 10$ đường; 6 đường đi qua ô gai $(1, 1)$ bị loại, còn 4. Công thức như bài 19: $dp[i][j] = dp[i-1][j] + dp[i][j-1]$, riêng ô # thì $dp = 0$ (không ai đứng được ở đó, nên cũng không truyền cách nào đi tiếp). Ô xuất phát $dp = 1$ nếu nó không có gai. Nhớ lấy dư sau mỗi phép cộng. Bản Python chỉ giữ hai hàng để tiết kiệm bộ nhớ.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

const int MOD = 1000000007;
int dp[1005][1005];
string g[1005];

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    for(int i = 0; i < n; i++) cin >> g[i];
    for(int i = 0; i < n; i++)
        for(int j = 0; j < m; j++){
            if(g[i][j] == '#'){ dp[i][j] = 0; continue; }   // ô cấm: 0 cách
            if(i == 0 && j == 0){ dp[i][j] = 1; continue; }
            long long v = 0;
            if(i > 0) v += dp[i - 1][j];
            if(j > 0) v += dp[i][j - 1];
            dp[i][j] = v % MOD;
        }
    cout << dp[n - 1][m - 1];
    return 0;
}
import sys

MOD = 10**9 + 7
data = sys.stdin.read().split()
n, m = int(data[0]), int(data[1])
g = data[2:2 + n]
prev = [0] * m
for i in range(n):
    cur = [0] * m
    row = g[i]
    for j in range(m):
        if row[j] == '#':                 # ô cấm: 0 cách
            continue
        if i == 0 and j == 0:
            cur[j] = 1
        else:
            cur[j] = (prev[j] + (cur[j - 1] if j > 0 else 0)) % MOD
    prev = cur
print(prev[m - 1])
Bài 2Xoá để hai xâu bằng nhauDễ

Cho hai xâu $s$ và $t$ gồm chữ cái thường, độ dài không quá 1000. Mỗi thao tác được xoá một ký tự ở một trong hai xâu. Hỏi cần ít nhất bao nhiêu thao tác để hai xâu trở nên giống hệt nhau?

Input
sea eat
Output
2

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Hai xâu cuối cùng giống nhau là một xâu con chung, muốn xoá ít thì xâu còn lại phải dài nhất: chính là LCS. Đáp án $= n + m - 2 \cdot \text{LCS}$. Với sea và eat, LCS là ea dài 2, nên xoá $3 + 3 - 4 = 2$ ký tự (chữ s và chữ t).
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int dp[1005][1005];

int main(){
    string s, t;
    cin >> s >> t;
    int n = s.size(), m = t.size();
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++)
            dp[i][j] = (s[i - 1] == t[j - 1]) ? dp[i - 1][j - 1] + 1 : max(dp[i - 1][j], dp[i][j - 1]);
    cout << n + m - 2 * dp[n][m];      // giữ lại đúng LCS, xoá phần còn lại
    return 0;
}
import sys

s, t = sys.stdin.read().split()
n, m = len(s), len(t)
prev = [0] * (m + 1)
for i in range(1, n + 1):
    cur = [0] * (m + 1)
    a = s[i - 1]
    for j in range(1, m + 1):
        if a == t[j - 1]:
            cur[j] = prev[j - 1] + 1
        else:
            cur[j] = prev[j] if prev[j] > cur[j - 1] else cur[j - 1]
    prev = cur
print(n + m - 2 * prev[m])      # giữ lại đúng LCS, xoá phần còn lại
Bài 3Số cách trả tiềnDễ

Cửa hàng nhận $n$ loại tờ tiền với mệnh giá khác nhau $c_1, \ldots, c_n$ ($n \le 100$, $1 \le c_i \le 10^4$), mỗi loại có rất nhiều tờ. Hỏi có bao nhiêu cách trả đúng số tiền $S$ ($S \le 10^4$)? Hai cách chỉ khác nhau về thứ tự đưa tiền thì coi là một. In đáp án mod $10^9 + 7$.

Input
3 5
1 2 5
Output
4

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Thứ tự không quan trọng nên đây là bài đếm tổ hợp: vòng ngoài duyệt loại tiền, vòng trong duyệt tổng tăng dần, $dp[s] \mathrel{+}= dp[s - c]$, $dp[0] = 1$. Bốn cách của ví dụ: 5; 2 + 2 + 1; 2 + 1 + 1 + 1; 1 + 1 + 1 + 1 + 1.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

const long long MOD = 1000000007;
long long dp[100005];

int main(){
    int n, S;
    cin >> n >> S;
    dp[0] = 1;
    for(int i = 0; i < n; i++){            // XU ở ngoài -> đếm tổ hợp
        int c;
        cin >> c;
        for(int s = c; s <= S; s++) dp[s] = (dp[s] + dp[s - c]) % MOD;
    }
    cout << dp[S];
    return 0;
}
import sys

MOD = 10**9 + 7
data = sys.stdin.read().split()
n, S = int(data[0]), int(data[1])
dp = [1] + [0] * S
for c in map(int, data[2:2 + n]):          # XU ở ngoài -> đếm tổ hợp
    for s in range(c, S + 1):
        dp[s] = (dp[s] + dp[s - c]) % MOD
print(dp[S])
Bài 4Cắt thanh gỗTrung bình

Xưởng có một thanh gỗ dài $L$ ($L \le 10^4$). Có $n$ loại khúc gỗ khách cần ($n \le 100$): loại $i$ dài $l_i$ và bán được $p_i$ đồng ($p_i \le 10^9$). Xưởng được cắt thanh gỗ thành các khúc thuộc các loại trên, mỗi loại cắt bao nhiêu khúc cũng được, phần thừa bỏ đi. Hỏi tổng số tiền lớn nhất bán được?

Input
8 3
2 5
3 8
5 12
Output
21

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Đây là cái túi không giới hạn: sức chứa là độ dài $L$, vật là các loại khúc. Duyệt $c$ tăng dần để một loại được cắt nhiều lần. Ví dụ: cắt 3 + 5 được 8 + 12 = 20, nhưng 2 + 3 + 3 = 8 được 5 + 8 + 8 = 21 mới là tốt nhất. Tổng tiền có thể vượt int nên dùng long long.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

long long dp[10005];

int main(){
    int L, n;
    cin >> L >> n;
    for(int i = 0; i < n; i++){
        int l; long long p;
        cin >> l >> p;
        for(int c = l; c <= L; c++)             // tăng dần: cắt bao nhiêu khúc loại i cũng được
            dp[c] = max(dp[c], dp[c - l] + p);
    }
    cout << dp[L];
    return 0;
}
import sys

data = sys.stdin.read().split()
L, n = int(data[0]), int(data[1])
dp = [0] * (L + 1)
for i in range(n):
    l, p = int(data[2 + 2 * i]), int(data[3 + 2 * i])
    for c in range(l, L + 1):                   # tăng dần: cắt bao nhiêu khúc loại i cũng được
        v = dp[c - l] + p
        if v > dp[c]:
            dp[c] = v
print(dp[L])
Bài 5Leo cầu thang có bậc hỏngTrung bình

Cầu thang có $n$ bậc ($n \le 10^5$), Lan đứng ở mặt đất (bậc 0) và muốn lên bậc $n$. Mỗi bước, Lan nhảy lên đúng $d$ bậc với $d$ thuộc tập $k$ số khác nhau cho trước ($k \le 10$, $1 \le d \le n$). Có $m$ bậc bị hỏng, không được đặt chân lên (bậc $n$ không hỏng). Đếm số cách lên tới bậc $n$, hai cách khác nhau nếu dãy bước nhảy khác nhau. In đáp án mod $10^9 + 7$.

Dòng 1 gồm $n, k, m$; dòng 2 gồm $k$ độ dài bước; dòng 3 gồm $m$ bậc hỏng.

Input
5 2 1
1 2
3
Output
2

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Thứ tự bước nhảy có ý nghĩa (1 rồi 2 khác 2 rồi 1), nên đây là bài đếm chỉnh hợp: vòng ngoài duyệt bậc $s$, vòng trong thử mọi bước nhảy làm bước cuối. Bậc hỏng thì $dp = 0$. Ví dụ: bậc 3 hỏng, nên phải nhảy 1 → 2 rồi từ 2 nhảy lên 4, rồi 4 → 5; có hai cách đi từ 0 tới 2 (1 + 1 hoặc 2). Vậy đáp án là 2.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

const long long MOD = 1000000007;
long long dp[100005];
bool broken[100005];

int main(){
    int n, k, m;
    cin >> n >> k >> m;
    vector<int> d(k);
    for(auto &x : d) cin >> x;
    for(int i = 0; i < m; i++){ int b; cin >> b; broken[b] = true; }
    dp[0] = 1;
    for(int s = 1; s <= n; s++){                // TỔNG (bậc) ở ngoài -> đếm có thứ tự
        if(broken[s]) continue;                 // không được đặt chân: dp = 0
        for(int x : d)
            if(s >= x) dp[s] = (dp[s] + dp[s - x]) % MOD;
    }
    cout << dp[n];
    return 0;
}
import sys

MOD = 10**9 + 7
data = sys.stdin.read().split()
n, k, m = int(data[0]), int(data[1]), int(data[2])
d = list(map(int, data[3:3 + k]))
broken = set(map(int, data[3 + k:3 + k + m]))
dp = [0] * (n + 1)
dp[0] = 1
for s in range(1, n + 1):                   # TỔNG (bậc) ở ngoài -> đếm có thứ tự
    if s in broken:                         # không được đặt chân: dp = 0
        continue
    tot = 0
    for x in d:
        if s >= x:
            tot += dp[s - x]
    dp[s] = tot % MOD
print(dp[n])
Bài 6Sửa xâu có trọng sốTrung bình

Cho ba số $I, D, R$ ($1 \le I, D, R \le 10^6$) là chi phí của một phép chèn, xoá, sửa ký tự. Cho hai xâu $s$, $t$ (độ dài không quá 500). Tìm tổng chi phí nhỏ nhất để biến $s$ thành $t$. Dòng 1 là $I, D, R$; dòng 2 là $s$; dòng 3 là $t$.

Input
1 1 5
abc
adc
Output
2

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Giống phần 2, thay mỗi số 1 bằng chi phí của phép tương ứng: $dp[i][j] = \min(dp[i-1][j-1] + (s_i = t_j\ ?\ 0 : R),\ dp[i-1][j] + D,\ dp[i][j-1] + I)$, và cơ sở $dp[i][0] = i \cdot D$, $dp[0][j] = j \cdot I$. Ví dụ: sửa b thành d tốn 5, nhưng xoá b rồi chèn d chỉ tốn 2. Để ý không được rút gọn khi $s_i = t_j$ thành chép ô chéo một cách vội vàng nếu chi phí lạ; công thức min ở trên luôn đúng.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

long long dp[1005][1005];

int main(){
    long long I, D, Rp;
    string s, t;
    cin >> I >> D >> Rp >> s >> t;
    int n = s.size(), m = t.size();
    for(int i = 0; i <= n; i++) dp[i][0] = i * D;         // xoá hết
    for(int j = 0; j <= m; j++) dp[0][j] = j * I;         // chèn đủ
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++){
            long long same = dp[i - 1][j - 1] + (s[i - 1] == t[j - 1] ? 0 : Rp);
            dp[i][j] = min({same, dp[i - 1][j] + D, dp[i][j - 1] + I});
        }
    cout << dp[n][m];
    return 0;
}
import sys

data = sys.stdin.read().split()
I, D, Rp = int(data[0]), int(data[1]), int(data[2])
s, t = data[3], data[4]
n, m = len(s), len(t)
prev = [j * I for j in range(m + 1)]          # hàng 0: chèn đủ j ký tự
for i in range(1, n + 1):
    cur = [i * D] + [0] * m                   # cột 0: xoá hết i ký tự
    a = s[i - 1]
    for j in range(1, m + 1):
        v = prev[j - 1] + (0 if a == t[j - 1] else Rp)
        x = prev[j] + D
        if x < v: v = x
        x = cur[j - 1] + I
        if x < v: v = x
        cur[j] = v
    prev = cur
print(prev[m])
Bài 7Tổng đoạn con khi được bỏ một sốKhá

Cho dãy $n$ số nguyên ($n \le 10^5$, $|a_i| \le 10^9$). Chọn một đoạn con liên tiếp, rồi được phép bỏ đi tối đa một phần tử của đoạn (đoạn sau khi bỏ vẫn phải còn ít nhất một phần tử). Tìm tổng lớn nhất có thể.

Input
5
1 -2 0 3 -1
Output
4

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Mở rộng Kadane với hai trạng thái kết thúc tại $i$: $f_i$ = tổng lớn nhất chưa bỏ phần tử nào, $g_i$ = tổng lớn nhất đã bỏ đúng một. $f_i = \max(a_i, f_{i-1} + a_i)$; $g_i = \max(f_{i-1},\ g_{i-1} + a_i)$: hoặc bỏ chính $a_i$ (phần trước chưa bỏ gì), hoặc đã bỏ từ trước và nối thêm $a_i$. Đáp án là max mọi $f_i, g_i$. Ví dụ: đoạn 1 −2 0 3 bỏ −2 được 4.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    int n;
    cin >> n;
    vector<long long> a(n);
    for(auto &x : a) cin >> x;
    const long long NEG = LLONG_MIN / 4;
    long long f = a[0], g = NEG, best = a[0];   // f: chưa bỏ, g: đã bỏ đúng 1 phần tử
    for(int i = 1; i < n; i++){
        long long nf = max(a[i], f + a[i]);
        long long ng = max(f, g == NEG ? NEG : g + a[i]);   // bỏ a[i], hoặc đã bỏ trước đó
        f = nf; g = ng;
        best = max({best, f, g});
    }
    cout << best;
    return 0;
}
import sys

data = sys.stdin.read().split()
n = int(data[0])
a = list(map(int, data[1:n + 1]))
NEG = float('-inf')
f, g, best = a[0], NEG, a[0]     # f: chưa bỏ, g: đã bỏ đúng 1 phần tử
for x in a[1:]:
    f, g = max(x, f + x), max(f, g + x)   # bỏ x, hoặc đã bỏ trước đó
    best = max(best, f, g)
print(best)
Bài 8Chia đội thi đấuKhá

Có $n$ học sinh đứng thành hàng ($n \le 10^4$), học sinh thứ $i$ có điểm kỹ năng $a_i$ ($1 \le a_i \le 10^6$). Thầy chia hàng thành các đội gồm các bạn đứng liền nhau, mỗi đội có từ 1 đến $K$ bạn ($K \le 100$). Sau khi luyện tập, mọi bạn trong đội đều có điểm bằng điểm của bạn giỏi nhất đội. Hỏi tổng điểm cả hàng lớn nhất có thể là bao nhiêu?

Input
7 3
1 15 7 9 2 5 10
Output
84

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Dạng chia đoạn với $\max$: $dp[i] = \max_{1 \le len \le \min(K, i)} \big(dp[i - len] + len \cdot \max(a_{i-len+1..i})\big)$. Duyệt $len$ tăng dần để cập nhật max đoạn cuối trong $O(1)$, tổng $O(nK)$. Ví dụ: chia thành [1, 15, 7], [9], [2, 5, 10] được $45 + 9 + 30 = 84$. Kết quả có thể tới $10^{10}$, dùng long long.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

long long a[10005], dp[10005];

int main(){
    int n, K;
    cin >> n >> K;
    for(int i = 1; i <= n; i++) cin >> a[i];
    for(int i = 1; i <= n; i++){
        long long mx = 0;
        for(int len = 1; len <= K && len <= i; len++){   // đội cuối là a[i-len+1..i]
            mx = max(mx, a[i - len + 1]);
            dp[i] = max(dp[i], dp[i - len] + mx * len);
        }
    }
    cout << dp[n];
    return 0;
}
import sys

data = sys.stdin.read().split()
n, K = int(data[0]), int(data[1])
a = [0] + list(map(int, data[2:2 + n]))
dp = [0] * (n + 1)
for i in range(1, n + 1):
    mx = best = 0
    for ln in range(1, min(K, i) + 1):          # đội cuối là a[i-ln+1..i]
        x = a[i - ln + 1]
        if x > mx: mx = x
        v = dp[i - ln] + mx * ln
        if v > best: best = v
    dp[i] = best
print(dp[n])
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇
Bản quyền thuộc về Thầy Nguyễn Việt Nam Sơn. Bài viết, lời giải, hình minh hoạ và mã nguồn trên trang là công sức biên soạn của thầy qua nhiều năm giảng dạy. Vui lòng không sao chép, đăng lại hay sử dụng dưới mọi hình thức khi chưa được sự đồng ý của tác giả. Nếu thấy hữu ích, hãy chia sẻ bằng đường link bài gốc nhé. Cảm ơn bạn!
Tự học thuật toán: khó ở đâu?
Đọc xong một bài giải là bước đầu. Phần lớn học sinh tự học sẽ gặp những trở ngại sau:
  • Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
  • Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
  • Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
  • Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
  • Dễ nản giữa chừng khi không có ai theo sát.
Học cùng thầy Nam Sơn, các trở ngại đó được gỡ bỏ:
  • Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
  • Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
  • Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
  • Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.

Nhận xét