LeetCode 115:不同的子序列

问题本质与核心挑战
给定两个字符串 s 和 t,需计算 s 的子序列中 t 出现的个数。子序列的定义是:通过删除 s 中的某些字符(可删除0个),保持字符相对顺序不变得到的序列。
核心挑战:
- 直接枚举所有子序列会导致 组合爆炸(时间复杂度指数级);
- 需通过 动态规划 高效推导状态转移,避免重复计算。
核心思路:二维动态规划
1. 状态定义
设 dp[i][j] 表示:
s的前i个字符(s[0..i-1]);t的前j个字符(t[0..j-1]);
的匹配方案数(即s的前i个字符中,子序列等于t的前j个字符的个数)。
2. 状态转移方程
分两种情况:
情况 1:
s[i-1] == t[j-1](当前字符匹配):- 用
s[i-1]匹配t[j-1]:方案数继承自dp[i-1][j-1](前面的子序列已匹配,当前字符参与匹配); - 不用
s[i-1]匹配t[j-1]:方案数继承自dp[i-1][j](前面的子序列已匹配,当前字符不参与)。
因此:
dp[i][j] = dp[i-1][j-1] + dp[i-1][j];- 用
情况 2:
s[i-1] != t[j-1](当前字符不匹配):
只能忽略s[i-1],方案数继承自dp[i-1][j](前面的子序列已匹配,当前字符不参与)。
因此:dp[i][j] = dp[i-1][j];
3. 初始化逻辑
- 空字符串匹配:
dp[0][0] = 1(空字符串是自身的子序列)。 s非空,t为空:任何s的子序列都包含空字符串,故dp[i][0] = 1(i ≥ 0)。s为空,t非空:无法匹配,故dp[0][j] = 0(j > 0)。
算法步骤详解(以示例 1 为例:s = "rabbbit", t = "rabbit")
步骤 1:初始化 DP 表
n = 7(s长度),m = 6(t长度)。- DP 表维度
8×7(i从 0 到 7,j从 0 到 6)。 - 初始化:
dp[0][0] = 1;- 第一列(
j=0):dp[i][0] = 1(i=0~7); - 第一行(
i=0,j>0):dp[0][j] = 0(j=1~6)。
步骤 2:逐行填充 DP 表
遍历 i(1~7)和 j(1~6),根据字符是否匹配应用转移方程:
i(s 前 i 字符) |
j(t 前 j 字符) |
s[i-1] |
t[j-1] |
转移逻辑 | dp[i][j] 计算 |
|---|---|---|---|---|---|
| 1 | 1 | r |
r |
相等,dp[0][0]+dp[0][1] |
1 + 0 = 1 |
| 2 | 1 | a |
r |
不等,dp[1][1] |
1 |
| 3 | 1 | b |
r |
不等,dp[2][1] |
1 |
| … | … | … | … | … | … |
| 7 | 6 | t |
t |
相等,dp[6][5]+dp[6][6] |
3 + 0 = 3(最终结果,符合示例) |
完整代码(Java)
class Solution {
public int numDistinct(String s, String t) {
int n = s.length();
int m = t.length();
// 若t比s长,直接返回0(不可能匹配)
if (m > n) return 0;
// dp[i][j]:s前i个字符与t前j个字符的匹配方案数
int[][] dp = new int[n + 1][m + 1];
// 初始化:空字符串匹配空字符串
dp[0][0] = 1;
// s的前i个字符匹配空字符串(只有空序列一种可能)
for (int i = 1; i <= n; i++) {
dp[i][0] = 1;
}
// 空s匹配非空t(不可能,方案数为0)
for (int j = 1; j <= m; j++) {
dp[0][j] = 0;
}
// 填充DP表
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; 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[n][m];
}
}
代码逐行解释
- 边界处理:若
t长度大于s,直接返回0(无法匹配)。 - DP 表初始化:
dp[0][0] = 1:空字符串互相匹配。- 第一列(
j=0):任何s的前缀都包含空序列,故全为1。 - 第一行(
i=0,j>0):空s无法匹配非空t,故全为0。
- 状态转移:
- 遍历
s和t的每个前缀,根据字符是否匹配,选择累加两种情况或继承前一状态。
- 遍历
- 结果返回:
dp[n][m]表示s全串与t全串的匹配方案数。
复杂度分析
- 时间复杂度:
O(n×m)(双重遍历,n是s长度,m是t长度)。 - 空间复杂度:
O(n×m)(存储 DP 表)。
优化思路:若需降低空间复杂度,可使用滚动数组将空间优化为 O(m)(仅保留前一行状态),但会牺牲部分可读性。
该方法通过 动态规划清晰建模状态转移,将指数级复杂度的问题降为多项式级,是字符串匹配类问题的经典解法。核心在于理解“匹配时的两种选择(用或不用当前字符)”,并通过 DP 表高效记录中间结果。