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 →

Bài 27: Quy hoạch động nâng cao trong C++ và Python: truy vết, QHĐ trên đoạn, QHĐ bitmask và QHĐ chữ số

Bài 27: Quy hoạch động nâng cao trong C++ và Python: truy vết, QHĐ trên đoạn, QHĐ bitmask và QHĐ chữ số
Series: Thuật toán nâng cao Độ khó: Khá Bài 27 / Thuật toán nâng cao Quy hoạch động
// Thuật toán nâng cao · Bài 27
Quy hoạch động nâng cao: truy vết, QHĐ trên đoạn, bitmask và chữ số
Biết đáp án tốt nhất là bao nhiêu mới là một nửa bài toán. Hôm nay ta học cách lấy ra chính lời giải, và ba dạng QHĐ hay gặp nhất trong đề HSG: trên đoạn, trên tập con và trên chữ số.

Lời mở đầu

Ở bài 19 em đã học quy hoạch động theo 5 bước: định nghĩa trạng thái, tìm công thức truy hồi, xác định cơ sở, chọn thứ tự tính và lấy đáp án. Với khung đó em đã giải được đường đi trên lưới, cái túi, dãy con tăng dài nhất, đổi tiền. Nhưng đề thi thật thường hỏi thêm: “in ra đường đi đó”, “chọn những vật nào”. Và nhiều bài có trạng thái không phải là “tiền tố i” quen thuộc mà là một đoạn $[l, r]$, một tập con hay các chữ số của một số.

Bài này đi qua bốn kỹ thuật đó. Phần bitmask dùng lại các phép toán bit ở bài 20, phần chữ số viết bằng đệ quy có nhớ. Cuối bài có một bảng giúp em nhận dạng nhanh một bài QHĐ thuộc dạng nào, đây là kỹ năng quan trọng nhất khi ngồi trong phòng thi.

Trong bài này bạn sẽ học: truy vết để in ra lời giải (đường đi trên lưới, các vật của cái túi, xâu con chung dài nhất); QHĐ trên đoạn $dp[l][r]$ và vì sao phải tính theo độ dài đoạn; QHĐ bitmask cho bài phân công và người du lịch $O(2^n n^2)$; QHĐ chữ số với cờ tight để đếm số trong đoạn $[A, B]$ tới $10^{18}$; các mẹo giảm bộ nhớ và bảng nhận dạng dạng QHĐ. Có khung chạy tay LCS và QHĐ trên đoạn, 3 mô phỏng tương tá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 1Truy vết: in ra chính lời giải

Bảng QHĐ chỉ lưu giá trị tốt nhất. Nhưng mỗi ô được tính ra từ một lựa chọn cụ thể (đi từ trên xuống hay từ trái sang, lấy vật hay không lấy…). Nếu biết mỗi ô được tính từ ô nào, ta đứng ở ô đáp án rồi đi ngược lại từng bước là dựng được lời giải. Có hai cách làm:

  • Lưu nguồn: khi tính dp[i][j], ghi luôn vào mảng tr[i][j] là ô nào (hay lựa chọn nào) cho ra giá trị đó. Truy vết chỉ việc đọc mảng tr.
  • Tính lại khi đi ngược: không cần mảng phụ. Đứng ở một ô, thử từng lựa chọn trong công thức xem lựa chọn nào cho đúng bằng giá trị đang có thì lùi theo lựa chọn đó.

Cách thứ hai tiết kiệm bộ nhớ và hay dùng hơn; ba ví dụ dưới đây đều làm theo cách này.

Ví dụ 1: đường đi có tổng lớn nhất trên lưới. Từ ô $(1, 1)$ đi tới ô $(n, m)$, mỗi bước sang phải (R) hoặc xuống dưới (D). Như bài 19: $dp[i][j] = \max(dp[i-1][j],\ dp[i][j-1]) + a[i][j]$. Muốn in đường đi, đứng ở $(n, m)$: nếu ô trên lớn hơn hoặc bằng ô trái thì ô hiện tại được tính từ ô trên, tức là bước cuối là D; ngược lại bước cuối là R. Lùi về ô đó và lặp lại tới khi về $(1, 1)$. Vì đi ngược nên các bước thu được theo thứ tự ngược, cuối cùng phải đảo lại.

hàng \ cột1234
11 (1)4 (3)5 (1)7 (2)
23 (2)3 (-1)10 (5)11 (1)
37 (4)9 (2)11 (1)14 (3)

Bảng 1. Mỗi ô ghi $dp[i][j]$ và (trong ngoặc) số trên lưới. Từ ô $(3, 4)$ = 14 đi ngược: ô trên 11 ≥ ô trái 11 → lên; ô trên 7 < ô trái 10 → sang trái; … Đường xanh lá là R R D R D, tổng 1 + 3 + 1 + 5 + 1 + 3 = 14.

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

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 = 1; i <= n; i++)
        for(int j = 1; j <= m; j++) cin >> a[i][j];
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++){
            if(i == 1 && j == 1) dp[i][j] = a[i][j];
            else if(i == 1) dp[i][j] = dp[i][j - 1] + a[i][j];
            else if(j == 1) dp[i][j] = dp[i - 1][j] + a[i][j];
            else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + a[i][j];
        }
    // TRUY VẾT: đứng ở (n, m), hỏi "ô này được tính từ ô nào?" rồi lùi về ô đó
    string path = "";
    int i = n, j = m;
    while(i > 1 || j > 1){
        if(j == 1 || (i > 1 && dp[i - 1][j] >= dp[i][j - 1])){
            path += 'D'; i--;          // đến từ ô trên: bước cuối là đi xuống
        } else {
            path += 'R'; j--;          // đến từ ô trái: bước cuối là sang phải
        }
    }
    reverse(path.begin(), path.end()); // đi ngược nên phải đảo lại
    cout << dp[n][m] << "\n" << path;
    return 0;
}
import sys

data = sys.stdin.buffer.read().split()
n, m = int(data[0]), int(data[1])
a = [[int(data[2 + i * m + j]) for j in range(m)] for i in range(n)]
dp = [[0] * m for _ in range(n)]
for i in range(n):
    for j in range(m):
        if i == 0 and j == 0:
            dp[i][j] = a[i][j]
        elif i == 0:
            dp[i][j] = dp[i][j - 1] + a[i][j]
        elif j == 0:
            dp[i][j] = dp[i - 1][j] + a[i][j]
        else:
            dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + a[i][j]
# TRUY VẾT: đứng ở ô cuối, lùi về ô đã cho ra giá trị của nó
path = []
i, j = n - 1, m - 1
while i > 0 or j > 0:
    if j == 0 or (i > 0 and dp[i - 1][j] >= dp[i][j - 1]):
        path.append('D'); i -= 1          # đến từ ô trên
    else:
        path.append('R'); j -= 1          # đến từ ô trái
print(dp[n - 1][m - 1])
print(''.join(reversed(path)))            # đi ngược nên phải đảo lại

Ví dụ 2: in các vật của bài cái túi. $dp[i][c]$ là giá trị lớn nhất khi chỉ dùng $i$ vật đầu với sức chứa $c$: $dp[i][c] = \max(dp[i-1][c],\ dp[i-1][c - w_i] + v_i)$. Đứng ở $(n, W)$ đi ngược: nếu $dp[i][c] = dp[i-1][c]$ thì có một cách tối ưu không cần vật $i$, ta bỏ qua nó; còn nếu $dp[i][c] \ne dp[i-1][c]$ thì chắc chắn vật $i$ đã được lấy, ghi nhận nó và giảm $c$ đi $w_i$.

vật i \ sức chứa c012345
0 (chưa có vật)000000
1 (w=2, v=3)003333
2 (w=3, v=4)003447
3 (w=4, v=5)003457
4 (w=5, v=6)003457

Bảng 2. Cái túi sức chứa 5 với 4 vật. Đi ngược từ ô $(4, 5)$ = 7: hàng 4, 3 giữ nguyên giá trị so với hàng trên (số in đậm, không lấy); ở hàng 2 giá trị 7 ≠ 3 nên lấy vật 2, c còn 2; ở hàng 1 giá trị 3 ≠ 0 nên lấy vật 1. Đáp án: vật 1 và 2, tổng giá trị 7.

Xem code C++ / Python: cái túi có in ra các vật được chọn (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int w[105], v[105];
long long dp[105][10005];

int main(){
    int n, W;
    cin >> n >> W;
    for(int i = 1; i <= n; i++) cin >> w[i] >> v[i];
    for(int i = 1; i <= n; i++)
        for(int c = 0; c <= W; c++){
            dp[i][c] = dp[i - 1][c];                                   // không lấy vật i
            if(c >= w[i]) dp[i][c] = max(dp[i][c], dp[i - 1][c - w[i]] + v[i]);   // lấy vật i
        }
    // TRUY VẾT: dp[i][c] khác dp[i - 1][c] nghĩa là bắt buộc phải lấy vật i
    vector<int> chosen;
    int c = W;
    for(int i = n; i >= 1; i--)
        if(dp[i][c] != dp[i - 1][c]){
            chosen.push_back(i);
            c -= w[i];
        }
    reverse(chosen.begin(), chosen.end());
    cout << dp[n][W] << "\n" << chosen.size() << "\n";
    for(int x : chosen) cout << x << " ";
    return 0;
}
import sys

data = sys.stdin.read().split()
n, W = int(data[0]), int(data[1])
w = [0] * (n + 1); v = [0] * (n + 1)
for i in range(1, n + 1):
    w[i], v[i] = int(data[2 * i]), int(data[2 * i + 1])
dp = [[0] * (W + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
    prev, cur, wi, vi = dp[i - 1], dp[i], w[i], v[i]
    for c in range(W + 1):
        cur[c] = prev[c]                               # không lấy vật i
        if c >= wi and prev[c - wi] + vi > cur[c]:
            cur[c] = prev[c - wi] + vi                 # lấy vật i
# TRUY VẾT: dp[i][c] khác dp[i - 1][c] nghĩa là bắt buộc phải lấy vật i
chosen, c = [], W
for i in range(n, 0, -1):
    if dp[i][c] != dp[i - 1][c]:
        chosen.append(i)
        c -= w[i]
chosen.reverse()
print(dp[n][W])
print(len(chosen))
print(*chosen)
Truy vết cần cả bảng
Ở bài 19 em đã biết mẹo nén cái túi về mảng 1 chiều. Nhưng khi cần truy vết thì phải giữ đủ các hàng, vì lúc đi ngược ta cần so $dp[i][c]$ với $dp[i-1][c]$. Nếu bộ nhớ không đủ, dùng thêm mảng bool lay[i][c] (1 byte mỗi ô) để ghi lựa chọn, còn giá trị vẫn nén 1 chiều.

Ví dụ 3: xâu con chung dài nhất (LCS). Cho hai xâu $s$ (dài $n$) và $t$ (dài $m$). Xâu con là xâu thu được khi xoá đi một số ký tự (giữ thứ tự các ký tự còn lại). Tìm xâu dài nhất là xâu con của cả hai. 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$. Xét hai ký tự cuối $s_i$ và $t_j$:

  • Nếu $s_i = t_j$: ghép ký tự này vào sau LCS của phần còn lại, $dp[i][j] = dp[i-1][j-1] + 1$.
  • Nếu $s_i \ne t_j$: một trong hai ký tự không được dùng, $dp[i][j] = \max(dp[i-1][j],\ dp[i][j-1])$.

Truy vết đi ngược từ $(n, m)$: hai ký tự khớp thì ký tự đó thuộc LCS, đi chéo; không khớp thì đi về phía ô lớn hơn (trên hoặc trái). Em xem kỹ cả hai pha trong mô phỏng:

MÔ PHỎNGĐiền bảng LCS rồi truy vết ngược từ góc dưới phảiNhập hai xâu (mỗi xâu tối đa 8 ký tự). Pha 1 điền bảng từng ô, pha 2 đi ngược để lấy ra chính xâu con chung.
ô đang tính / đang đứngô được dùng để tínhđường truy vếtchưa tính
s \ t∅BDCAB
∅000000
A000011
B011112
C011222
B011223
D012223
A012233

Bảng 3. Bảng LCS của s = ABCBDA và t = BDCAB. Ô xanh lá là những chỗ hai ký tự khớp và được lấy (đọc từ trên xuống: B, C, B), số in đậm không tô màu là những bước chỉ đi lên hoặc sang trái. LCS = BCB, độ dài 3.

Xem code C++ / Python: xâu con chung dài nhất có truy vết (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 = 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;
            else
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
    string res = "";
    int i = n, j = m;
    while(i > 0 && j > 0){
        if(s[i - 1] == t[j - 1]){
            res += s[i - 1]; i--; j--;
        }
        else if(dp[i - 1][j] >= dp[i][j - 1]) i--;
        else j--;
    }
    reverse(res.begin(), res.end());
    cout << dp[n][m] << "\n" << res;
    return 0;
}
import sys

s, t = sys.stdin.read().split()[:2]
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
        else:
            dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
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
    else:
        j -= 1
print(dp[n][m])
print(''.join(reversed(res)))
C++▶ Chạy tay từng dòng codeĐiền bảng LCS từng hàng rồi truy vết ngược từ góc dưới phải (ô vàng: ô đang tính, ô đỏ: vị trí truy vết, ô xanh lá: đường đã đi).
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 = 1; i <= n; i++)
11 for(int j = 1; j <= m; j++)
12 if(s[i - 1] == t[j - 1])
13 dp[i][j] = dp[i - 1][j - 1] + 1;
14 else
15 dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
16 string res = "";
17 int i = n, j = m;
18 while(i > 0 && j > 0){
19 if(s[i - 1] == t[j - 1]){
20 res += s[i - 1]; i--; j--;
21 }
22 else if(dp[i - 1][j] >= dp[i][j - 1]) i--;
23 else j--;
24 }
25 reverse(res.begin(), res.end());
26 cout << dp[n][m] << "\n" << res;
27 return 0;
28}
Khuôn mẫu truy vết
// 1. Tính xong toàn bộ bảng dp (giữ đủ các chiều)
// 2. Đứng ở trạng thái đáp án
while(chưa về trạng thái cơ sở){
    for(mỗi lựa chọn có thể dẫn tới trạng thái hiện tại)
        if(dp[hiện tại] == dp[trạng thái trước] + chi phí lựa chọn){
            ghi nhận lựa chọn;  hiện tại = trạng thái trước;  break;
        }
}
// 3. Đảo ngược danh sách lựa chọn đã ghi
Khi có nhiều lời giải cùng tối ưu, thứ tự thử các lựa chọn quyết định em in ra lời giải nào. Nếu đề bắt in lời giải nhỏ nhất theo thứ tự từ điển, thường phải QHĐ từ cuối về rồi đi xuôi từ đầu (xem bài tập 1, 2).

Phần 2QHĐ trên đoạn

Nhiều bài có cấu trúc: đáp án của cả đoạn $[l, r]$ được ghép từ đáp án của các đoạn con bên trong. Khi đó trạng thái là $dp[l][r]$, và công thức thường có một trong hai dáng:

  • Bóc hai đầu: $dp[l][r]$ tính từ $dp[l+1][r]$, $dp[l][r-1]$, $dp[l+1][r-1]$ (xâu đối xứng, trò chơi lấy hai đầu dãy). Độ phức tạp $O(n^2)$.
  • Chọn điểm chia $k$: $dp[l][r] = \min_{l \le k < r} \big(dp[l][k] + dp[k+1][r]\big) + \text{chi phí ghép}$ (ghép đá, nhân dãy ma trận, cắt que). Độ phức tạp $O(n^3)$.

Ví dụ: chèn ít ký tự nhất để thành xâu đối xứng. Cho xâu $s$, được chèn ký tự vào vị trí bất kỳ. Hỏi phải chèn ít nhất bao nhiêu ký tự để $s$ đọc xuôi ngược như nhau? Gọi $dp[l][r]$ là đáp án cho đoạn $s[l..r]$. Nhìn hai đầu:

  • $s_l = s_r$: hai đầu tự làm cặp với nhau, $dp[l][r] = dp[l+1][r-1]$.
  • $s_l \ne s_r$: phải chèn một ký tự để ghép cặp với một đầu. Chèn bản sao của $s_l$ vào cuối thì còn lại đoạn $[l+1, r]$; chèn bản sao của $s_r$ vào đầu thì còn lại $[l, r-1]$. Vậy $dp[l][r] = \min(dp[l+1][r],\ dp[l][r-1]) + 1$.

Cơ sở: $dp[i][i] = 0$ và đoạn rỗng bằng 0. Câu hỏi quan trọng là thứ tự tính. Nếu duyệt $l$ từ 0 lên như thói quen thì lúc tính $dp[0][4]$, ô $dp[1][4]$ chưa có! Mọi ô cần đến đều là đoạn ngắn hơn, nên ta duyệt theo độ dài đoạn tăng dần: điền đường chéo chính, rồi đường chéo kế tiếp, … tới ô góc trên phải $dp[0][n-1]$.

MÔ PHỎNGQHĐ trên đoạn: điền bảng dp[l][r] theo từng đường chéoNhập một xâu chữ thường (tối đa 8 ký tự). Mỗi bước tính một đoạn; các đoạn ngắn được tính trước đoạn dài.
ô (đoạn) đang tínhđoạn ngắn hơn được dùngđã xongchưa tính
C++▶ Chạy tay từng dòng codeQHĐ trên đoạn: duyệt độ dài len tăng dần, mỗi đoạn [l, r] dùng các đoạn ngắn hơn đã tính (ô vàng: đoạn đang tính, ô đỏ: đoạn con được dùng).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int dp[5005][5005];
5
6int main(){
7 string s;
8 cin >> s;
9 int n = s.size();
10 for(int len = 2; len <= n; len++)
11 for(int l = 0; l + len - 1 < n; l++){
12 int r = l + len - 1;
13 if(s[l] == s[r])
14 dp[l][r] = dp[l + 1][r - 1];
15 else
16 dp[l][r] = min(dp[l + 1][r], dp[l][r - 1]) + 1;
17 }
18 cout << dp[0][n - 1];
19 return 0;
20}
Xem code C++ / Python: chèn ít ký tự nhất để thành xâu đối xứng (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int dp[5005][5005];

int main(){
    string s;
    cin >> s;
    int n = s.size();
    for(int len = 2; len <= n; len++)
        for(int l = 0; l + len - 1 < n; l++){
            int r = l + len - 1;
            if(s[l] == s[r])
                dp[l][r] = dp[l + 1][r - 1];
            else
                dp[l][r] = min(dp[l + 1][r], dp[l][r - 1]) + 1;
        }
    cout << dp[0][n - 1];
    return 0;
}
s = input().strip()
n = len(s)
dp = [[0] * n for _ in range(n)]          # dp[i][i] = 0: một ký tự đã đối xứng
for length in range(2, n + 1):            # duyệt theo ĐỘ DÀI đoạn tăng dần
    for l in range(n - length + 1):
        r = l + length - 1
        if s[l] == s[r]:
            dp[l][r] = dp[l + 1][r - 1]   # hai đầu tự ghép cặp
        else:
            dp[l][r] = min(dp[l + 1][r], dp[l][r - 1]) + 1
print(dp[0][n - 1])

Dạng chọn điểm chia: ghép đá. Có $n$ đống đá xếp thành hàng, đống thứ $i$ nặng $a_i$. Mỗi lần được gộp hai đống kề nhau thành một, chi phí bằng tổng khối lượng hai đống. Gộp tới khi còn một đống, hỏi tổng chi phí nhỏ nhất. Mấu chốt: lần gộp cuối cùng của đoạn $[l, r]$ luôn ghép một khối $[l, k]$ với một khối $[k+1, r]$, và lần gộp đó tốn đúng $a_l + \cdots + a_r$ (tính nhanh bằng mảng cộng dồn) bất kể $k$ là bao nhiêu. Vậy:

$dp[l][r] = \min\limits_{l \le k < r} \big(dp[l][k] + dp[k+1][r]\big) + (P_r - P_{l-1}), \qquad dp[i][i] = 0.$

l \ r1234
1071528
20617
307
40

Bảng 4. Ghép 4 đống 3, 4, 2, 5. Ví dụ $dp[1][3] = \min(0 + dp[2][3],\ dp[1][2] + 0) + 9 = \min(6, 7) + 9 = 15$. Đáp án $dp[1][4] = 28$: gộp (3, 4) tốn 7, gộp (2, 5) tốn 7, gộp hai khối cuối tốn 14.

Ba cách viết thứ tự tính cho QHĐ trên đoạn
1. Vòng ngoài là độ dài len từ 2 tới $n$, vòng trong là l (như code trên).
2. Vòng ngoài l chạy giảm từ $n-1$ về 0, vòng trong r chạy tăng từ l + 1: khi tính $(l, r)$ thì hàng $l+1$ và các ô bên trái của hàng $l$ đã xong.
3. Đệ quy có nhớ solve(l, r): không phải nghĩ thứ tự, nhưng chậm hơn và dễ tràn ngăn xếp khi $n$ lớn.
Sai thứ tự là lỗi số một của QHĐ trên đoạn: chương trình vẫn chạy, chỉ là đọc phải những ô chưa tính (đang bằng 0).

Phần 3QHĐ bitmask: trạng thái là một tập con

Khi đề có $n$ nhỏ (thường $n \le 20$) và lời giải phụ thuộc vào việc những phần tử nào đã được dùng chứ không chỉ là bao nhiêu phần tử, ta mã hoá tập đã dùng thành một số nguyên mask (bit $j$ bằng 1 nghĩa là phần tử $j$ đã dùng), đúng như cách duyệt tập con ở bài 20. Có $2^n$ tập con, với $n = 20$ là khoảng $10^6$ trạng thái, vừa đủ để làm QHĐ. Các thao tác cần nhớ: kiểm tra mask >> j & 1, thêm phần tử mask | (1 << j), đếm số bit __builtin_popcount(mask).

Ví dụ 1: phân công. Có $n$ người và $n$ việc, người $i$ làm việc $j$ tốn $c_{ij}$. Mỗi người làm đúng một việc, mỗi việc do đúng một người làm. Tìm tổng chi phí nhỏ nhất. Thử mọi hoán vị tốn $n!$ (với $n = 15$ là $1{,}3 \cdot 10^{12}$). Nhận xét: nếu giao việc lần lượt cho người $0, 1, 2, \ldots$ thì khi đã giao xong tập việc mask, người tiếp theo chắc chắn là người thứ popcount(mask). Chi phí về sau chỉ phụ thuộc mask, không phụ thuộc ai làm việc nào trong mask. Vì vậy:

$dp[\text{mask} \cup \{j\}] = \min\big(dp[\text{mask} \cup \{j\}],\ dp[\text{mask}] + c[\text{popcount(mask)}][j]\big), \quad j \notin \text{mask}.$

Có $2^n$ trạng thái, mỗi trạng thái thử $n$ việc: $O(2^n \cdot n)$, với $n = 20$ là khoảng $2 \cdot 10^7$ phép tính. Mọi tập con của mask đều nhỏ hơn mask nên chỉ cần duyệt mask từ 0 lên.

Xem code C++ / Python: phân công $n$ người $n$ việc bằng QHĐ bitmask (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int n, c[20][20];
long long dp[1 << 20];

int main(){
    cin >> n;
    for(int i = 0; i < n; i++)
        for(int j = 0; j < n; j++) cin >> c[i][j];
    fill(dp, dp + (1 << n), LLONG_MAX);
    dp[0] = 0;                                  // chưa giao việc nào
    for(int mask = 0; mask < (1 << n); mask++){
        if(dp[mask] == LLONG_MAX) continue;
        int i = __builtin_popcount(mask);       // người tiếp theo cần giao việc
        if(i == n) continue;
        for(int j = 0; j < n; j++)
            if(!(mask >> j & 1)){               // việc j còn trống
                int nm = mask | (1 << j);
                dp[nm] = min(dp[nm], dp[mask] + c[i][j]);
            }
    }
    cout << dp[(1 << n) - 1];
    return 0;
}
import sys

data = sys.stdin.read().split()
n = int(data[0])
c = [[int(data[1 + i * n + j]) for j in range(n)] for i in range(n)]
INF = float('inf')
dp = [INF] * (1 << n)
dp[0] = 0                                   # chưa giao việc nào
for mask in range(1 << n):
    if dp[mask] == INF:
        continue
    i = bin(mask).count('1')                # người tiếp theo cần giao việc
    if i == n:
        continue
    for j in range(n):
        if not mask >> j & 1:               # việc j còn trống
            nm = mask | 1 << j
            if dp[mask] + c[i][j] < dp[nm]:
                dp[nm] = dp[mask] + c[i][j]
print(dp[(1 << n) - 1])

Ví dụ 2: người du lịch (TSP). Có $n$ thành phố, $d_{ij}$ là quãng đường từ $i$ tới $j$. Xuất phát từ thành phố 0, đi qua mỗi thành phố đúng một lần rồi quay về 0. Tìm tổng quãng đường ngắn nhất. Lần này chỉ biết tập đã thăm là chưa đủ: bước tiếp theo tốn bao nhiêu còn tuỳ ta đang đứng ở đâu. Trạng thái cần hai thành phần:

$dp[\text{mask}][v]$ = quãng đường ngắn nhất xuất phát từ 0, đã thăm đúng tập $\text{mask}$ và hiện đang ở $v \in \text{mask}$.

Cơ sở $dp[\{0\}][0] = 0$. Từ $(\text{mask}, v)$ đi tiếp tới $u \notin \text{mask}$: cập nhật $dp[\text{mask} \cup \{u\}][u]$ bằng $dp[\text{mask}][v] + d_{vu}$. Đáp án là $\min_v \big(dp[\text{tất cả}][v] + d_{v0}\big)$. Có $2^n \cdot n$ trạng thái, mỗi trạng thái thử $n$ bước đi: $O(2^n n^2)$. Với $n = 15$ là khoảng $7 \cdot 10^6$, rất nhanh.

1015203525300123Chu trình ngắn nhất:0 → 1 → 3 → 2 → 010 + 25 + 30 + 15 = 80Có (4 − 1)! = 6 thứ tự đi,với n = 15 là 14! ≈ 8,7 · 10^10:thử hết là không kịp.

Hình 1. Bốn thành phố. Chu trình xanh lá 0 → 1 → 3 → 2 → 0 dài 80 là ngắn nhất. Ví dụ $dp[\{0,1,3\}][3] = 10 + 25 = 35$, rồi $dp[\{0,1,2,3\}][2] = 35 + 30 = 65$, cộng $d_{20} = 15$ được 80.

Xem code C++ / Python: người du lịch (TSP) bằng QHĐ bitmask $O(2^n n^2)$ (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

const long long INF = 1e18;
int n;
long long d[16][16], dp[1 << 15][15];

int main(){
    cin >> n;
    for(int i = 0; i < n; i++)
        for(int j = 0; j < n; j++) cin >> d[i][j];
    for(int mask = 0; mask < (1 << n); mask++)
        for(int v = 0; v < n; v++) dp[mask][v] = INF;
    dp[1][0] = 0;                                   // mới ở thành phố 0, đã thăm {0}
    for(int mask = 1; mask < (1 << n); mask++)
        for(int v = 0; v < n; v++){
            if(dp[mask][v] == INF) continue;
            for(int u = 0; u < n; u++){
                if(mask >> u & 1) continue;         // u đã thăm rồi
                int nm = mask | (1 << u);
                dp[nm][u] = min(dp[nm][u], dp[mask][v] + d[v][u]);
            }
        }
    long long ans = INF;
    int full = (1 << n) - 1;
    for(int v = 0; v < n; v++) ans = min(ans, dp[full][v] + d[v][0]);   // quay về 0
    cout << ans;
    return 0;
}
import sys

data = sys.stdin.read().split()
n = int(data[0])
d = [[int(data[1 + i * n + j]) for j in range(n)] for i in range(n)]
INF = float('inf')
dp = [[INF] * n for _ in range(1 << n)]
dp[1][0] = 0                                    # mới ở thành phố 0, đã thăm {0}
for mask in range(1, 1 << n):
    row = dp[mask]
    for v in range(n):
        cur = row[v]
        if cur == INF:
            continue
        dv = d[v]
        for u in range(n):
            if mask >> u & 1:                   # u đã thăm rồi
                continue
            nm = mask | 1 << u
            if cur + dv[u] < dp[nm][u]:
                dp[nm][u] = cur + dv[u]
full = (1 << n) - 1
print(min(dp[full][v] + d[v][0] for v in range(n)))   # quay về 0
Bộ nhớ và giới hạn của bitmask
Mảng long long dp[1 << 20][20] tốn $2^{20} \cdot 20 \cdot 8 \approx 168$ MB, thường vượt giới hạn. Hãy dùng int nếu đáp án đủ nhỏ, hoặc giảm chiều. Dấu hiệu nhận dạng QHĐ bitmask: $n \le 15 \ldots 20$ (rất nhỏ một cách đáng ngờ) và bài hỏi về thứ tự hoặc cách ghép cặp các phần tử. Python chậm hơn C++ nhiều lần, nên khi luyện bằng Python em nên thử với $n \le 13$.

Phần 4QHĐ chữ số: đếm số trong đoạn [A, B]

Bài toán: đếm số nguyên trong đoạn $[A, B]$ ($0 \le A \le B \le 10^{18}$) có tổng các chữ số chia hết cho $K$ ($K \le 100$). Duyệt từng số là không thể. Ý tưởng: thay vì duyệt các số, ta xây số từng chữ số một, từ trái sang phải, và đếm bằng QHĐ.

Bước 1: đưa về đếm trên $[0, X]$. Gọi $\text{cnt}(X)$ là số lượng số trong $[0, X]$ thoả mãn. Đáp án là $\text{cnt}(B) - \text{cnt}(A - 1)$, giống hệt cách lấy tổng đoạn bằng mảng cộng dồn.

Bước 2: trạng thái. Viết $X$ thành xâu $n$ chữ số. Mọi số $y \le X$ được coi là có đúng $n$ chữ số (thêm số 0 ở đầu, tổng chữ số không đổi). Ta điền các chữ số của $y$ từ vị trí 0 tới $n-1$. Cần nhớ:

  • pos: đang điền vị trí thứ mấy;
  • sum: tổng các chữ số đã điền, lấy mod $K$ (chỉ cần biết số dư);
  • tight: các chữ số đã điền có trùng hệt tiền tố của $X$ không. Nếu có thì chữ số tiếp theo chỉ được chọn từ 0 tới $X[\text{pos}]$ (vượt là $y > X$). Chỉ cần một lần chọn chữ số nhỏ hơn, $y$ đã chắc chắn nhỏ hơn $X$ và các vị trí sau được chọn tự do 0…9.

Bước 3: công thức. $f(\text{pos}, \text{sum}, \text{tight}) = \sum_{d=0}^{\text{lim}} f\big(\text{pos}+1,\ (\text{sum}+d) \bmod K,\ \text{tight} \wedge d = \text{lim}\big)$, với $\text{lim} = X[\text{pos}]$ nếu tight, ngược lại là 9. Khi $\text{pos} = n$: trả về 1 nếu $\text{sum} = 0$. Số trạng thái chỉ $19 \cdot K \cdot 2$, mỗi trạng thái thử 10 chữ số: khoảng $4 \cdot 10^4$ phép tính cho $X$ tới $10^{18}$!

Vì sao chỉ ghi nhớ khi tight = false? Có đúng một nhánh tight duy nhất (bám theo $X$), nên trạng thái tight chỉ được gọi một lần ở mỗi vị trí, ghi nhớ cũng không lợi gì. Còn các trạng thái tự do được gọi lại rất nhiều lần. Mô phỏng dưới đây đi dọc nhánh tight và cho em thấy ở mỗi vị trí, các nhánh rẽ sang chữ số nhỏ hơn được đếm gọn bằng bảng nhớ:

MÔ PHỎNGQHĐ chữ số: đi dọc nhánh tight, tách các nhánh tự doNhập X và K. Xem vì sao chỉ cần đi qua mỗi chữ số của X một lần, các nhánh rẽ sớm hơn được đếm bằng bảng nhớ.
đã cố định bằng chữ số của Xvị trí đang điền / nhánh vẫn tightnhánh tự do (đã bé hơn X)chưa điền
Xem code C++ / Python: đếm số trong $[A, B]$ có tổng chữ số chia hết cho $K$ (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

string X;
int K;
long long memo[20][105];
bool seen[20][105];

// số cách điền các chữ số từ vị trí pos trở đi, biết tổng các chữ số đã điền mod K là sum
long long f(int pos, int sum, bool tight){
    if(pos == (int)X.size()) return sum == 0 ? 1 : 0;
    if(!tight && seen[pos][sum]) return memo[pos][sum];
    int lim = tight ? X[pos] - '0' : 9;          // còn bám sát X thì không được vượt chữ số của X
    long long res = 0;
    for(int d = 0; d <= lim; d++)
        res += f(pos + 1, (sum + d) % K, tight && d == lim);
    if(!tight){ seen[pos][sum] = true; memo[pos][sum] = res; }
    return res;
}

long long countUpTo(long long x){                // đếm số trong [0, x]
    if(x < 0) return 0;
    X = to_string(x);
    memset(seen, 0, sizeof(seen));               // pos tính từ trái nên mỗi X có bảng nhớ riêng
    return f(0, 0, true);
}

int main(){
    long long A, B;
    cin >> A >> B >> K;
    cout << countUpTo(B) - countUpTo(A - 1);
    return 0;
}
import sys
from functools import lru_cache

def count_up_to(x, K):                   # đếm số trong [0, x]
    if x < 0:
        return 0
    X = str(x)

    @lru_cache(maxsize=None)
    def f(pos, s, tight):
        if pos == len(X):
            return 1 if s == 0 else 0
        lim = int(X[pos]) if tight else 9    # còn bám sát X thì không vượt chữ số của X
        return sum(f(pos + 1, (s + d) % K, tight and d == lim) for d in range(lim + 1))

    return f(0, 0, True)

A, B, K = map(int, sys.stdin.read().split())
print(count_up_to(B, K) - count_up_to(A - 1, K))
Khuôn mẫu QHĐ chữ số
long long f(int pos, /* thông tin cần nhớ */, bool tight, bool started){
    if(pos == n) return (điều kiện cuối thoả) ? 1 : 0;
    if(!tight && started && đã nhớ) return memo[...];
    int lim = tight ? X[pos] - '0' : 9;
    long long res = 0;
    for(int d = 0; d <= lim; d++)
        res += f(pos + 1, /* cập nhật thông tin */, tight && d == lim, started || d != 0);
    ghi nhớ nếu !tight && started;
    return res;
}
Cờ started (đã gặp chữ số khác 0 chưa) chỉ cần khi số 0 ở đầu làm thay đổi điều kiện, ví dụ “hai chữ số kề nhau khác nhau” (bài tập 7): với số 7, các số 0 thêm vào đầu “007” không được coi là hai chữ số 0 kề nhau. Với tổng chữ số thì số 0 ở đầu vô hại nên bỏ cờ này.

Phần 5Mẹo: giảm bộ nhớ, thứ tự tính, nhận dạng

Giảm chiều bộ nhớ. Nếu hàng $i$ chỉ dùng hàng $i-1$ thì chỉ cần giữ hai hàng (mảng lăn). LCS hai xâu dài $10^4$ cần $10^8$ ô nếu giữ cả bảng, nhưng chỉ cần $2 \cdot 10^4$ ô với hai hàng. Cái túi 0/1 còn nén được về một hàng nếu duyệt $c$ giảm dần, để $dp[c - w]$ vẫn là giá trị của hàng cũ. Cái giá phải trả: mất khả năng truy vết.

LCS chỉ với hai hàng
vector<int> prv(m + 1, 0), cur(m + 1, 0);
for(int i = 1; i <= n; i++){
    for(int j = 1; j <= m; j++)
        cur[j] = (s[i - 1] == t[j - 1]) ? prv[j - 1] + 1 : max(prv[j], cur[j - 1]);
    swap(prv, cur);
}
// đáp án: prv[m]
Ô “trên” là prv[j], ô “trái” là cur[j - 1], ô “chéo” là prv[j - 1].

Chọn thứ tự tính. Quy tắc duy nhất: ô nào được dùng thì phải tính trước. Vẽ mũi tên từ mỗi ô tới các ô nó cần, rồi chọn thứ tự vòng lặp đi ngược chiều mũi tên. Lưới và LCS cần ô trên, ô trái: duyệt hàng tăng, cột tăng. QHĐ trên đoạn cần đoạn ngắn hơn: duyệt theo độ dài. Bitmask cần tập con: duyệt mask tăng dần. Khi khó xác định, viết đệ quy có nhớ là an toàn nhất (QHĐ chữ số hầu như luôn viết kiểu này).

DạngTrạng tháiDấu hiệu trong đềĐộ phức tạpVí dụ
Tiền tố (bài 19)$dp[i]$, $dp[i][c]$dãy, xét lần lượt từng phần tử; giới hạn $n \le 10^5$$O(n)$ … $O(nW)$cái túi, LIS, đổi tiền
Hai xâu / hai dãy$dp[i][j]$hai dãy, so khớp, xoá/chèn/sửa ký tự; $n, m \le 5000$$O(nm)$LCS, khoảng cách sửa xâu
Trên đoạn$dp[l][r]$gộp phần tử kề nhau, lấy ở hai đầu, đối xứng, đặt ngoặc; $n \le 500$$O(n^2)$, $O(n^3)$ghép đá, cắt que, xâu đối xứng
Bitmask$dp[\text{mask}]$, $dp[\text{mask}][v]$$n \le 20$, thứ tự / ghép cặp / phân công$O(2^n n)$, $O(2^n n^2)$TSP, phân công
Chữ số$f(\text{pos}, \ldots, \text{tight})$đếm số trong $[A, B]$ với $B$ tới $10^{18}$, điều kiện trên chữ số$O(\text{số chữ số} \cdot S \cdot 10)$tổng chữ số, số may mắn

Bảng 5. Nhận dạng nhanh các dạng QHĐ. Giới hạn của $n$ trong đề thường là gợi ý mạnh nhất: $n \le 20$ gần như chắc chắn là bitmask, $n \le 500$ hay là QHĐ trên đoạn $O(n^3)$.

Quy trình khi gặp một bài lạ
1. Viết thử lời giải vét cạn bằng đệ quy quay lui. 2. Hỏi: hàm đệ quy thật sự cần biết những thông tin gì về quá khứ? Đó chính là trạng thái. 3. Đếm số trạng thái × số lựa chọn mỗi trạng thái, so với giới hạn. 4. Thêm mảng nhớ vào đệ quy, hoặc viết lại bằng vòng lặp theo đúng thứ tự. 5. Kiểm tra bằng chính lời giải vét cạn ở bước 1 với dữ liệu nhỏ ngẫu nhiên.
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 nâng cao

Bài 1Đường đi lớn nhất, in đường điDễ

Cho lưới $n \times m$ ($1 \le n, m \le 500$) chứa số nguyên $|a_{ij}| \le 10^9$. Robot đi từ ô trái trên tới ô phải dưới, mỗi bước sang phải (R) hoặc xuống dưới (D). In ra tổng lớn nhất của các ô robot đi qua, và dòng thứ hai là đường đi gồm $n + m - 2$ ký tự. Nếu có nhiều đường cùng tổng lớn nhất, in đường có xâu nhỏ nhất theo thứ tự từ điển (D đứng trước R).

Input
3 3
1 2 3
4 5 6
1 1 9
Output
25
DRRD

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

💡 Gợi ý
Truy vết ngược từ $(n, m)$ như phần 1 không cho xâu nhỏ nhất theo từ điển, vì khi gặp hoà nó quyết định bước cuối trước. Mẹo: QHĐ từ cuối về, $g[i][j]$ = tổng lớn nhất đi từ $(i, j)$ tới đích, $g[i][j] = a_{ij} + \max(g[i+1][j], g[i][j+1])$. Sau đó đi xuôi từ $(1, 1)$: ưu tiên D nếu $g[i+1][j] \ge g[i][j+1]$, vì chọn ký tự nhỏ nhất có thể ở mỗi vị trí từ trái sang chính là xâu nhỏ nhất. Ở ví dụ, đường tốt nhất 1 → 4 → 5 → 6 → 9 có tổng 25, xâu là DRRD.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

long long a[505][505], g[505][505];

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++) cin >> a[i][j];
    for(int i = n; i >= 1; i--)                  // g[i][j]: tổng lớn nhất từ (i, j) tới đích
        for(int j = m; j >= 1; j--){
            if(i == n && j == m) g[i][j] = a[i][j];
            else if(i == n) g[i][j] = a[i][j] + g[i][j + 1];
            else if(j == m) g[i][j] = a[i][j] + g[i + 1][j];
            else g[i][j] = a[i][j] + max(g[i + 1][j], g[i][j + 1]);
        }
    string path = "";
    int i = 1, j = 1;
    while(i < n || j < m){                        // đi xuôi, ưu tiên D khi hoà
        if(j == m || (i < n && g[i + 1][j] >= g[i][j + 1])){ path += 'D'; i++; }
        else { path += 'R'; j++; }
    }
    cout << g[1][1] << "\n" << path;
    return 0;
}
import sys

data = sys.stdin.buffer.read().split()
n, m = int(data[0]), int(data[1])
a = [[int(data[2 + i * m + j]) for j in range(m)] for i in range(n)]
g = [[0] * m for _ in range(n)]                  # g[i][j]: tổng lớn nhất từ (i, j) tới đích
for i in range(n - 1, -1, -1):
    for j in range(m - 1, -1, -1):
        if i == n - 1 and j == m - 1:
            g[i][j] = a[i][j]
        elif i == n - 1:
            g[i][j] = a[i][j] + g[i][j + 1]
        elif j == m - 1:
            g[i][j] = a[i][j] + g[i + 1][j]
        else:
            g[i][j] = a[i][j] + max(g[i + 1][j], g[i][j + 1])
path = []
i = j = 0
while i < n - 1 or j < m - 1:                    # đi xuôi, ưu tiên D khi hoà
    if j == m - 1 or (i < n - 1 and g[i + 1][j] >= g[i][j + 1]):
        path.append('D'); i += 1
    else:
        path.append('R'); j += 1
print(g[0][0])
print(''.join(path))
Bài 2Xâu con chung nhỏ nhất theo từ điểnTrung bình

Cho hai xâu $s, t$ gồm chữ cái thường, độ dài không quá 1000. In ra độ dài xâu con chung dài nhất, và dòng thứ hai là một xâu con chung dài nhất. Nếu có nhiều xâu như vậy, in xâu nhỏ nhất theo thứ tự từ điển. (Nếu độ dài là 0 thì dòng thứ hai để trống.)

Input
abcbda
bdcab
Output
3
bca

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

💡 Gợi ý
Ví dụ có ba LCS dài 3 là bca, bcb, bda; nhỏ nhất là bca. Giống bài 1, ta làm QHĐ trên hậu tố: $suf[i][j]$ = độ dài LCS của $s[i..]$ và $t[j..]$. Rồi dựng đáp án từ trái sang: đang ở $(i, j)$, thử chữ cái $c$ từ a tới z; tìm lần xuất hiện sớm nhất $p \ge i$ của $c$ trong $s$ và $q \ge j$ trong $t$ (mảng nxt tính trước). Nếu $suf[p+1][q+1] = suf[i][j] - 1$ thì $c$ mở đầu được một LCS: lấy $c$, nhảy tới $(p+1, q+1)$. Lấy lần xuất hiện sớm nhất là tốt nhất vì phần còn lại dài nhất có thể. Mỗi ký tự của đáp án thử tối đa 26 chữ cái.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int suf[1005][1005], ns[1005][26], nt[1005][26];

int main(){
    string s, t;
    cin >> s >> t;
    int n = s.size(), m = t.size();
    for(int i = n - 1; i >= 0; i--)
        for(int j = m - 1; j >= 0; j--)
            suf[i][j] = (s[i] == t[j]) ? suf[i + 1][j + 1] + 1 : max(suf[i + 1][j], suf[i][j + 1]);
    for(int c = 0; c < 26; c++){ ns[n][c] = n; nt[m][c] = m; }
    for(int i = n - 1; i >= 0; i--){                 // ns[i][c]: vị trí đầu tiên >= i có chữ c
        for(int c = 0; c < 26; c++) ns[i][c] = ns[i + 1][c];
        ns[i][s[i] - 'a'] = i;
    }
    for(int j = m - 1; j >= 0; j--){
        for(int c = 0; c < 26; c++) nt[j][c] = nt[j + 1][c];
        nt[j][t[j] - 'a'] = j;
    }
    string res = "";
    int i = 0, j = 0;
    while(suf[i][j] > 0){
        for(int c = 0; c < 26; c++){
            int p = ns[i][c], q = nt[j][c];
            if(p < n && q < m && suf[p + 1][q + 1] == suf[i][j] - 1){
                res += char('a' + c);
                i = p + 1; j = q + 1;
                break;
            }
        }
    }
    cout << suf[0][0] << "\n" << res;
    return 0;
}
import sys

s, t = sys.stdin.read().split()[:2]
n, m = len(s), len(t)
suf = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n - 1, -1, -1):
    row, nxt, si = suf[i], suf[i + 1], s[i]
    for j in range(m - 1, -1, -1):
        if si == t[j]:
            row[j] = nxt[j + 1] + 1
        else:
            row[j] = nxt[j] if nxt[j] > row[j + 1] else row[j + 1]

def build_next(x):                               # nx[i][c]: vị trí đầu tiên >= i có chữ c
    L = len(x)
    nx = [[L] * 26 for _ in range(L + 1)]
    for i in range(L - 1, -1, -1):
        nx[i] = nx[i + 1][:]
        nx[i][ord(x[i]) - 97] = i
    return nx

ns, nt = build_next(s), build_next(t)
res = []
i = j = 0
while suf[i][j] > 0:
    for c in range(26):
        p, q = ns[i][c], nt[j][c]
        if p < n and q < m and suf[p + 1][q + 1] == suf[i][j] - 1:
            res.append(chr(97 + c))
            i, j = p + 1, q + 1
            break
print(suf[0][0])
print(''.join(res))
Bài 3Chèn ít nhất để thành xâu đối xứngTrung bình

Cho xâu $s$ gồm chữ cái thường, độ dài $n \le 1000$. Được chèn thêm ký tự vào vị trí bất kỳ. Hỏi cần chèn ít nhất bao nhiêu ký tự để $s$ trở thành xâu đối xứng?

Input
mbadm
Output
2

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

💡 Gợi ý
Đúng công thức phần 2: $dp[l][r] = dp[l+1][r-1]$ nếu $s_l = s_r$, ngược lại $\min(dp[l+1][r], dp[l][r-1]) + 1$. Để tiết kiệm bộ nhớ, duyệt $l$ giảm dần và chỉ giữ hai hàng: hàng $l$ đang tính và hàng $l + 1$. Ví dụ: chèn d sau chữ b và b sau chữ d, mbadm thành mbdadbm, chỉ 2 ký tự. Cách nhìn khác: đáp án bằng $n$ trừ độ dài xâu con đối xứng dài nhất, cũng bằng $n - \text{LCS}(s, \text{đảo ngược của } s)$.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    string s;
    cin >> s;
    int n = s.size();
    vector<int> nxt(n, 0), cur(n, 0);              // nxt = hàng l + 1, cur = hàng l
    for(int l = n - 1; l >= 0; l--){
        cur[l] = 0;
        for(int r = l + 1; r < n; r++){
            if(s[l] == s[r]) cur[r] = (r - l >= 2) ? nxt[r - 1] : 0;
            else cur[r] = min(nxt[r], cur[r - 1]) + 1;
        }
        swap(nxt, cur);
    }
    cout << nxt[n - 1];
    return 0;
}
s = input().strip()
n = len(s)
nxt = [0] * n                                  # hàng l + 1
for l in range(n - 1, -1, -1):
    cur = [0] * n                              # hàng l
    sl = s[l]
    for r in range(l + 1, n):
        if sl == s[r]:
            cur[r] = nxt[r - 1] if r - l >= 2 else 0
        else:
            a, b = nxt[r], cur[r - 1]
            cur[r] = (a if a < b else b) + 1
    nxt = cur
print(nxt[n - 1])
Bài 4Ghép đáTrung bình

Có $n$ đống đá xếp thành hàng ($1 \le n \le 200$), đống thứ $i$ nặng $a_i$ ($1 \le a_i \le 10^6$). Mỗi lần được gộp hai đống kề nhau thành một đống, chi phí bằng tổng khối lượng của hai đống. Tìm tổng chi phí nhỏ nhất để gộp tất cả thành một đống.

Input
4
3 4 2 5
Output
28

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

💡 Gợi ý
Đúng ví dụ ở Bảng 4: $dp[l][r] = \min_k (dp[l][k] + dp[k+1][r]) + (P_r - P_{l-1})$. Duyệt theo độ dài đoạn, $O(n^3)$. Cách tối ưu: gộp (3, 4) tốn 7, gộp (2, 5) tốn 7, gộp (7, 7) tốn 14, tổng 28. Chú ý: gộp lần lượt từ trái (3+4, rồi +2, rồi +5) tốn 7 + 9 + 14 = 30, kém hơn. Đáp án có thể tới cỡ $200 \cdot 2 \cdot 10^8$ nên dùng long long.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

long long P[205], dp[205][205];

int main(){
    int n;
    cin >> n;
    for(int i = 1; i <= n; i++){ long long x; cin >> x; P[i] = P[i - 1] + x; }
    for(int len = 2; len <= n; len++)
        for(int l = 1; l + len - 1 <= n; l++){
            int r = l + len - 1;
            dp[l][r] = LLONG_MAX;
            for(int k = l; k < r; k++)                     // lần gộp cuối: [l, k] với [k + 1, r]
                dp[l][r] = min(dp[l][r], dp[l][k] + dp[k + 1][r]);
            dp[l][r] += P[r] - P[l - 1];
        }
    cout << dp[1][n];
    return 0;
}
import sys

data = sys.stdin.read().split()
n = int(data[0])
P = [0]
for x in data[1:n + 1]:
    P.append(P[-1] + int(x))
dp = [[0] * (n + 1) for _ in range(n + 1)]
for length in range(2, n + 1):
    for l in range(1, n - length + 2):
        r = l + length - 1
        row = dp[l]
        best = min(row[k] + dp[k + 1][r] for k in range(l, r))   # lần gộp cuối
        row[r] = best + P[r] - P[l - 1]
print(dp[1][n])
Bài 5Cắt queKhá

Một que gỗ dài $L$ ($L \le 10^9$) cần cắt tại $m$ vị trí cho trước ($1 \le m \le 200$, các vị trí khác nhau, nằm trong khoảng $(0, L)$). Mỗi lần chỉ cắt một khúc tại một điểm, chi phí bằng độ dài khúc đang cắt. Thứ tự cắt tuỳ ý. Tìm tổng chi phí nhỏ nhất.

Dòng đầu là $L$ và $m$, dòng sau là $m$ vị trí cắt (không nhất thiết tăng dần).

Input
10 3
2 4 7
Output
20

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

💡 Gợi ý
Thêm hai đầu que: $p_0 = 0$, $p_{m+1} = L$, sắp xếp các vị trí. $dp[i][j]$ = chi phí nhỏ nhất để cắt hết các điểm nằm giữa $p_i$ và $p_j$ trên khúc $[p_i, p_j]$. Lần cắt đầu tiên trên khúc này tại điểm $k$ nào đó tốn $p_j - p_i$, rồi hai nửa độc lập: $dp[i][j] = \min_{i < k < j} (dp[i][k] + dp[k][j]) + (p_j - p_i)$, $dp[i][i+1] = 0$. Ví dụ: cắt ở 4 trước (tốn 10), khúc [0, 4] cắt ở 2 (tốn 4), khúc [4, 10] cắt ở 7 (tốn 6): tổng 20.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

long long p[205], dp[205][205];

int main(){
    long long L; int m;
    cin >> L >> m;
    for(int i = 1; i <= m; i++) cin >> p[i];
    p[0] = 0; p[m + 1] = L;
    sort(p, p + m + 2);
    for(int len = 2; len <= m + 1; len++)            // len = j - i
        for(int i = 0; i + len <= m + 1; i++){
            int j = i + len;
            dp[i][j] = LLONG_MAX;
            for(int k = i + 1; k < j; k++)           // cắt đầu tiên tại p[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];
    return 0;
}
import sys

data = sys.stdin.read().split()
L, m = int(data[0]), int(data[1])
p = sorted([0, L] + [int(x) for x in data[2:2 + m]])
N = m + 2
dp = [[0] * N for _ in range(N)]
for length in range(2, N):                      # length = j - i
    for i in range(N - length):
        j = i + length
        row = dp[i]
        row[j] = min(row[k] + dp[k][j] for k in range(i + 1, j)) + p[j] - p[i]   # cắt đầu tiên tại p[k]
print(dp[0][N - 1])
Bài 6Đếm số cách phân côngKhá

Có $n$ bạn ($1 \le n \le 16$) và $n$ nhiệm vụ. Bảng $n \times n$ gồm các số 0/1: số ở hàng $i$, cột $j$ bằng 1 nếu bạn $i$ làm được nhiệm vụ $j$. Đếm số cách giao cho mỗi bạn đúng một nhiệm vụ mình làm được, sao cho mỗi nhiệm vụ có đúng một bạn nhận.

Input
3
110
111
011
Output
3

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

💡 Gợi ý
Như bài phân công ở phần 3, nhưng thay $\min$ bằng tổng: $dp[\text{mask}]$ = số cách giao cho $\text{popcount(mask)}$ bạn đầu tiên đúng tập nhiệm vụ $\text{mask}$. $dp[0] = 1$; từ $\text{mask}$, bạn tiếp theo $i = \text{popcount(mask)}$ nhận nhiệm vụ $j \notin \text{mask}$ làm được thì cộng $dp[\text{mask}]$ vào $dp[\text{mask} \cup \{j\}]$. Ba cách của ví dụ (bạn 1, 2, 3 nhận): (1, 2, 3), (1, 3, 2), (2, 1, 3). Kết quả không vượt $16! \approx 2 \cdot 10^{13}$ nên dùng long long.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

long long dp[1 << 16];

int main(){
    int n;
    cin >> n;
    vector<string> g(n);
    for(auto &row : g) cin >> row;
    dp[0] = 1;
    for(int mask = 0; mask < (1 << n); mask++){
        if(!dp[mask]) continue;
        int i = __builtin_popcount(mask);             // bạn tiếp theo
        if(i == n) continue;
        for(int j = 0; j < n; j++)
            if(g[i][j] == '1' && !(mask >> j & 1))
                dp[mask | (1 << j)] += dp[mask];
    }
    cout << dp[(1 << n) - 1];
    return 0;
}
import sys

data = sys.stdin.read().split()
n = int(data[0])
g = data[1:1 + n]
can = [[j for j in range(n) if g[i][j] == '1'] for i in range(n)]
dp = [0] * (1 << n)
dp[0] = 1
for mask in range(1 << n):
    v = dp[mask]
    if not v:
        continue
    i = bin(mask).count('1')                    # bạn tiếp theo
    if i == n:
        continue
    for j in can[i]:
        if not mask >> j & 1:
            dp[mask | 1 << j] += v
print(dp[(1 << n) - 1])
Bài 7Không có hai chữ số kề nhau bằng nhauKhá

Cho $0 \le A \le B \le 10^{18}$. Đếm số nguyên trong $[A, B]$ mà trong cách viết thập phân (không có số 0 ở đầu) không có hai chữ số kề nhau nào bằng nhau. Ví dụ 1213 thoả, 1223 không thoả.

Input
1 100
Output
90

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

💡 Gợi ý
QHĐ chữ số với trạng thái $(\text{pos}, \text{last}, \text{tight}, \text{started})$: last là chữ số vừa điền, started cho biết đã gặp chữ số khác 0 chưa. Khi chưa started, các số 0 ở đầu chỉ là “đệm”, không được coi là chữ số kề nhau (nếu không, mọi số có ít chữ số hơn $X$ sẽ bị loại oan). Khi đã started, bỏ qua chữ số $d = \text{last}$. Ví dụ: trong 1…100 có 9 số một chữ số, 90 − 9 = 81 số hai chữ số thoả (bỏ 11, 22, …, 99), và 100 bị loại: tổng 90.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

string X;
long long memo[20][11];
bool seen[20][11];

long long f(int pos, int last, bool tight, bool started){
    if(pos == (int)X.size()) return 1;
    if(!tight && started && seen[pos][last]) return memo[pos][last];
    int lim = tight ? X[pos] - '0' : 9;
    long long res = 0;
    for(int d = 0; d <= lim; d++){
        if(started && d == last) continue;          // hai chữ số kề nhau bằng nhau
        bool ns = started || d != 0;
        res += f(pos + 1, ns ? d : 10, tight && d == lim, ns);
    }
    if(!tight && started){ seen[pos][last] = true; memo[pos][last] = res; }
    return res;
}

long long countUpTo(long long x){
    if(x < 0) return 0;
    X = to_string(x);
    memset(seen, 0, sizeof(seen));
    return f(0, 10, true, false);                   // last = 10: chưa có chữ số nào
}

int main(){
    long long A, B;
    cin >> A >> B;
    cout << countUpTo(B) - countUpTo(A - 1);
    return 0;
}
import sys
from functools import lru_cache

def count_up_to(x):
    if x < 0:
        return 0
    X = str(x)

    @lru_cache(maxsize=None)
    def f(pos, last, tight, started):
        if pos == len(X):
            return 1
        lim = int(X[pos]) if tight else 9
        res = 0
        for d in range(lim + 1):
            if started and d == last:           # hai chữ số kề nhau bằng nhau
                continue
            ns = started or d != 0
            res += f(pos + 1, d if ns else 10, tight and d == lim, ns)
        return res

    return f(0, 10, True, False)                # last = 10: chưa có chữ số nào

A, B = map(int, sys.stdin.read().split())
print(count_up_to(B) - count_up_to(A - 1))
Bài 8Hành trình qua mọi trạmKhó

Có $n$ trạm ($2 \le n \le 13$) đánh số từ 1. Đi thẳng từ trạm $i$ tới trạm $j$ mất $c_{ij}$ phút ($0 \le c_{ij} \le 10^6$, có thể $c_{ij} \ne c_{ji}$). Xe xuất phát ở trạm 1 và phải kết thúc ở trạm $n$, đi qua mỗi trạm đúng một lần. Tìm tổng thời gian nhỏ nhất.

Input
4
0 1 5 9
3 0 2 8
4 6 0 1
2 2 2 0
Output
4

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

💡 Gợi ý
Giống TSP ở phần 3 nhưng không quay về và điểm cuối cố định: $dp[\text{mask}][v]$ như cũ, cơ sở $dp[\{1\}][1] = 0$, đáp án là $dp[\text{tất cả}][n]$ (không cộng thêm gì). Mẹo nhỏ: không cho đi tiếp từ trạng thái đang ở trạm $n$ khi chưa thăm hết, vì trạm $n$ phải là điểm cuối (dù không chặn thì $dp[\text{tất cả}][n]$ vẫn đúng). Ví dụ: 1 → 2 → 3 → 4 mất 1 + 2 + 1 = 4. Độ phức tạp $O(2^n n^2)$.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

const long long INF = 1e18;
long long c[13][13], dp[1 << 13][13];

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; i++)
        for(int j = 0; j < n; j++) cin >> c[i][j];
    for(int mask = 0; mask < (1 << n); mask++)
        for(int v = 0; v < n; v++) dp[mask][v] = INF;
    dp[1][0] = 0;                                  // ở trạm 1 (chỉ số 0)
    for(int mask = 1; mask < (1 << n); mask++)
        for(int v = 0; v < n; v++){
            if(dp[mask][v] == INF || v == n - 1) continue;   // trạm n là điểm cuối
            for(int u = 0; u < n; u++){
                if(mask >> u & 1) continue;
                int nm = mask | (1 << u);
                dp[nm][u] = min(dp[nm][u], dp[mask][v] + c[v][u]);
            }
        }
    cout << dp[(1 << n) - 1][n - 1];
    return 0;
}
import sys

data = sys.stdin.read().split()
n = int(data[0])
c = [[int(data[1 + i * n + j]) for j in range(n)] for i in range(n)]
INF = float('inf')
dp = [[INF] * n for _ in range(1 << n)]
dp[1][0] = 0                                     # ở trạm 1 (chỉ số 0)
for mask in range(1, 1 << n):
    row = dp[mask]
    for v in range(n - 1):                       # v = n - 1 là điểm cuối, không đi tiếp
        cur = row[v]
        if cur == INF:
            continue
        cv = c[v]
        for u in range(n):
            if mask >> u & 1:
                continue
            nm = mask | 1 << u
            if cur + cv[u] < dp[nm][u]:
                dp[nm][u] = cur + cv[u]
print(dp[(1 << n) - 1][n - 1])
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