这道子序列计数题,让 90% 的人栽在"相加"上
“字符串
s = 'rabbbit'里,藏着多少个'rabbit'?”如果你脱口而出"1 个",那这道题刚好能帮你打通 DP 的任督二脉。
这是 LeetCode 第 115 题——不同的子序列。它和编辑距离、最长公共子序列并称"字符串 DP 三剑客",但很多人在它身上翻车,不是因为代码难写,而是因为一个"相加"的逻辑想不明白。
先搞清楚:子序列是什么?
子序列 = 从原字符串中删掉一些字符(可以一个都不删),但不改变剩下字符的相对顺序。
s = "rabbbit"
"rabbit" 是 s 的子序列吗?是的!删掉中间那个 'b' 就行。
"rabit" 是 s 的子序列吗?是的!删掉两个 'b' 就行。
"rat" 是 s 的子序列吗?是的!删掉 'b','b','i','t' 就行。
但这道题问的不是"是不是",而是**“有多少种不同的删法”**能得到目标字符串。
s = "rabbbit", t = "rabbit"
rab b bit ← 删掉第3个 'b'(索引2)
rab b bit ← 删掉第4个 'b'(索引3)
rab b bit ← 删掉第5个 'b'(索引4)
答案:3 种
三个 ‘b’ 长得一样,但因为位置不同,所以是三种不同的方案。
这道题到底在考什么?
如果你之前做过编辑距离,会发现状态定义很像。但编辑距离问的是"最少几步",而这道题问的是"有几种方案"。
核心区别:
- 编辑距离:每一步取 min(找最优)
- 不同子序列:每一种方案都累加,取 sum(找总数)
这就是为什么很多人卡在这里——还在用"最优"的思维做"计数"的题。
五步法:从零推导
第一步:定义状态
dp[i][j]=s的前i个字符中,包含t的前j个字符作为子序列的方案数****
举个例子:
dp[0][0] = 1:空串里找空串,有 1 种方式(什么都不做)dp[5][3]:"babgb"里包含"bag"的方案数?
第二步:状态转移方程(核心!)
假设现在处理 dp[i][j],最后一个字符分别是 s[i-1] 和 t[j-1]。
✅ 情况一:两字符相等 s[i-1] == t[j-1]
这是最精彩的部分。你有两种独立的选择,方案数相加:
| 选择 | 含义 | 方案数 |
|---|---|---|
用 s[i-1] 去匹配 | 当前字符配对成功,继续往前匹配剩余部分 | dp[i-1][j-1] |
不用 s[i-1],继续找 | 跳过当前字符,用 s 前面的字符匹配完整的 t | dp[i-1][j] |
dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
为什么能相加? 因为这两种选择是互斥的——要么用这个 ‘b’,要么不用这个 ‘b’,没有第三种可能,所以方案数直接累加。
✅ 情况二:两字符不相等 s[i-1] != t[j-1]
当前字符不匹配,只能跳过:
dp[i][j] = dp[i-1][j]
第三步:初始化
| 边界 | 含义 | 值 |
|---|---|---|
dp[0][0] | 空串里找空串 | 1 |
dp[i][0] | 非空串里找空串 | 1(全部删掉就行) |
dp[0][j](j>0) | 空串里找非空串 | 0(不可能) |
第四步:遍历顺序
dp[i][j] 依赖左上方和上方,从左到右、从上到下遍历。
第五步:验证填表
以 s = "babgbag", t = "bag" 为例,手动追踪关键位置:
| i | s[i-1] | j=1 (b) | j=2 (a) | j=3 (g) |
|---|---|---|---|---|
| 1 | b | 0+1=1 | 0 | 0 |
| 2 | a | 1 | 1+0=1 | 0 |
| 3 | b | 1+1=2 | 1 | 0 |
| 4 | g | 2 | 1 | 1+0=1 |
| 5 | b | 1+2=3 | 1 | 1 |
| 6 | a | 3 | 3+1=4 | 1 |
| 7 | g | 3 | 4 | 4+1=5 ✅ |
答案 dp[7][3] = 5,和示例完全一致!
完整代码
class Solution {
public int numDistinct(String s, String t) {
int m = s.length(), n = t.length();
int[][] dp = new int[m + 1][n + 1];
// 初始化:任意长度的 s 都包含空串 t 一次
for (int i = 0; i <= m; i++) {
dp[i][0] = 1;
}
// 填表
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s.charAt(i - 1) == t.charAt(j - 1)) {
// 相等:用这个字符 + 不用这个字符
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
} else {
// 不相等:只能跳过
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[m][n];
}
}
复杂度:时间 O(mn)O(mn)O(mn),空间 O(mn)O(mn)O(mn)。
进阶:空间优化
二维数组可以压缩成一维,但内层循环必须从右往左:
class Solution {
public int numDistinct(String s, String t) {
int n = t.length();
int[] dp = new int[n + 1];
dp[0] = 1; // 空串匹配空串
for (int i = 1; i <= s.length(); i++) {
// 必须从右往左!防止覆盖上一行数据
for (int j = n; j >= 1; j--) {
if (s.charAt(i - 1) == t.charAt(j - 1)) {
dp[j] = dp[j - 1] + dp[j];
}
}
}
return dp[n];
}
}
为什么从右往左? 因为
dp[j]依赖dp[j-1](左上方),如果从左往右,dp[j-1]会先被更新成当前行的值,导致错误。
一句话总结
相等:dp[i][j] = dp[i-1][j-1] + dp[i-1][j] ← 两种选择,方案数相加
不等:dp[i][j] = dp[i-1][j] ← 一种选择,只能跳过
这道题和编辑距离的区别,本质上是计数 vs 最优的区别:
- 计数问题:多种方案相加
- 最优问题:多种方案取最值
搞懂了这一点,背包问题的"方案数"变种、路径计数等问题,都是同一个套路。
收藏这篇,下次遇到子序列计数的题,再也不用慌了。
欢迎在评论区交流你的 DP 学习心得,或者留下你想了解的算法题,下期安排!

3952

被折叠的 条评论
为什么被折叠?



