来源:算法讲解068【必备】见识更多二维动态规划题目 · 2026-11-03
依据:视频字幕 2000 字
用「两个前缀长度」作状态定义二维 DP:dp[i][j] = S 的前 i 个字符的子序列中,恰好等于 T 的前 j 个字符的个数;按「S 末尾字符选不选」分类转移。
给定字符串 S、T,统计 S 的子序列(不要求连续,保持相对顺序,可要可不要)中有多少个字面值严格等于 T。典型题:LeetCode 115 不同子序列(Distinct Subsequences)。
long long 或取模,见易错点)。定义 dp[i][j]:只用 S 的前 i 个字符(下标 0..i-1)生成子序列,其中字面值等于 T 的前 j 个字符(下标 0..j-1)的个数。
按 S 的第 i 个字符(下标 i-1)选不选分类,这是不重不漏的划分:
dp[i-1][j]。S[i-1] == T[j-1] 时才可能。此时它必须去匹配 T 的最后一个字符 T[j-1],前面部分就是「S 前 i-1 个字符凑出 T 前 j-1 个字符」,方案数 = dp[i-1][j-1]。两类互斥(选/不选),所以相加。这就是全部转移。
边界直觉:
dp[i][0] = 1:T 是空串,S 的任意前缀只有「全不选」这一个子序列等于空串。dp[0][j] = 0(j ≥ 1):S 是空串,凑不出非空 T。dp[0][0] = 1(空串等于空串,一种方案),它被 dp[i][0]=1 这一行覆盖。为什么答案在 dp[n][m]:n = |S|、m = |T|,正好是「S 全部字符」对「T 全部字符」,就是原问题。
dp[i][0] = 1。- 对 j = 1..m:
- dp[i][j] = dp[i-1][j](不选 S[i-1])
- 若 S[i-1] == T[j-1]:dp[i][j] += dp[i-1][j-1]
dp[n][m]。(可滚动数组优化到一维,见「竞赛模板」和「拓展」。)
S = "rabbbit",T = "rabbit",n = 7,m = 6。答案应为 3(三个 b 里选两个)。
只列关键行(行 = S 前缀长度 i,列 = T 前缀长度 j,j 从 0 到 6):
i=0 (空): 1 0 0 0 0 0 0
i=1 'r': 1 1 0 0 0 0 0
i=2 'a': 1 1 1 0 0 0 0
i=3 'b': 1 1 1 1 0 0 0
i=4 'b': 1 1 1 2 1 0 0
i=5 'b': 1 1 1 3 3 0 0
i=6 'i': 1 1 1 3 3 3 0
i=7 't': 1 1 1 3 3 3 3
看 i=4 行 j=3:dp[4][3] = dp[3][3] + dp[3][2](S[3]='b'==T[2]='b')= 1 + 1 = 2,即 "rab" 有两个 b 可选。i=5 行 j=3 同理累加到 3。最终 dp[7][6] = 3。
Python(好读版):
def num_distinct(s: str, t: str) -> int:
n, m = len(s), len(t)
# dp[i][j]: s 前 i 个字符的子序列中等于 t 前 j 个字符的个数
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n + 1):
dp[i][0] = 1
for i in range(1, n + 1):
for j in range(1, m + 1):
dp[i][j] = dp[i - 1][j]
if s[i - 1] == t[j - 1]:
dp[i][j] += dp[i - 1][j - 1]
return dp[n][m]
C++(竞赛风,滚动数组,直接抄):
#include <bits/stdc++.h>
using namespace std;
// 统计 s 的子序列中等于 t 的个数(不取模,答案可能很大,用 long long)
long long numDistinct(const string& s, const string& t) {
int n = (int)s.size(), m = (int)t.size();
if (m > n) return 0;
vector<long long> dp(m + 1, 0);
dp[0] = 1; // 空 T 只有一种方案
for (int i = 1; i <= n; ++i) {
// 关键:j 必须从大到小,否则 dp[j-1] 已被本行更新,变成"可重复用 S[i-1]"
for (int j = m; j >= 1; --j) {
if (s[i - 1] == t[j - 1]) {
dp[j] += dp[j - 1];
}
// 不相等时 dp[j] 保持不变,等价于 dp[i][j] = dp[i-1][j]
}
}
return dp[m];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s, t;
cin >> s >> t;
cout << numDistinct(s, t) << '\n';
return 0;
}
dp[i][j] 前缀定义。// 计数型二维 DP 通用骨架:dp[i][j] 依赖 dp[i-1][j] 和 dp[i-1][j-1]
// 滚动数组时 j 逆序;若依赖 dp[i][j-1](同行左侧)则 j 必须正序。
long long countSubseqEqual(const string& s, const string& t) {
int n = (int)s.size(), m = (int)t.size();
if (m > n) return 0;
vector<long long> dp(m + 1, 0);
dp[0] = 1;
for (int i = 1; i <= n; ++i)
for (int j = m; j >= 1; --j)
if (s[i - 1] == t[j - 1])
dp[j] += dp[j - 1];
return dp[m];
}
dp[i-1][j-1],一维化后 j 必须从 m 到 1 逆序。正序会让 dp[j-1] 变成「本行已更新值」,等于允许同一个 S 字符被用多次,答案偏大。dp[i][0] 忘记置 1:T 为空串时答案是 1(全不选),漏了整列错。dp 的 i、j 是长度(1..n),s[i-1]、t[j-1] 才是字符。写成 s[i] 会越界或错位。m > n 直接返回 0:S 比 T 短不可能凑出,提前剪枝也防越界。int,必须 long long;若题目要求取模(如 1e9+7),每步加法后取模,别只在最后取。vector<long long> dp(m+1, 0) 或手动 fill,别复用脏数据。cin >> 时注意空行。dp[i][j] 前缀定义,转移改成「删/插/换」三种取 min,相等时 dp[i][j]=dp[i-1][j-1]。dp[i-1][j-1]+1,否则 max(dp[i-1][j], dp[i][j-1])——注意这里依赖同行左侧,滚动数组要正序。*、? 分类。dp[j] = (dp[j] + dp[j-1]) % MOD。dp[n][m] 回溯,按「选/不选」分支走。dp[i-1][*] 还是 dp[i][j-1]。int 存答案不取模,n 稍大就爆。dp[0]=1 之外的元素。m>n 剪枝。f(i,j) 记忆化入手,再改写成迭代;两种写法等价,比赛里直接写迭代更快。题 1:S = "babgbag",T = "bag",答案是多少?
5。转移表最终 dp[7][3] = 5,对应视频里列出的五个子序列。
</details>
题 2:把本题改成「答案对 1e9+7 取模」,滚动数组内层怎么写?
const long long MOD = 1000000007;
for (int j = m; j >= 1; --j)
if (s[i-1] == t[j-1])
dp[j] = (dp[j] + dp[j-1]) % MOD;
仍必须逆序;dp[0]=1 不参与取模。
</details>
题 3:若把「子序列」改成「子串」(必须连续),还能用这个 DP 吗?该怎么改?
不能直接用这个转移。子串要求连续,标准做法是枚举 S 的每个起点做 KMP/哈希匹配,或用 dp[i][j] 表示「以 S[i-1]、T[j-1] 结尾的匹配长度」的另一种定义(本质是字符串匹配,不是本题的子序列计数)。本题 DP 的「选/不选」划分依赖子序列可跳字符,子串不成立。
</details>