LeetCode 第115题:不同的子序列
题目描述
给定一个字符串 s 和一个字符串 t ,计算在 s 的子序列中 t 出现的个数。
字符串的一个 子序列 是指,通过删除一些(也可以不删除)字符且不干扰剩余字符相对位置所组成的新字符串。(例如,"ACE" 是 "ABCDE" 的一个子序列,而 "AEC" 不是)
题目数据保证答案符合 32 位带符号整数范围。
难度
困难
题目链接
示例
示例 1:
输入:s = "rabbbit", t = "rabbit"
输出:3
解释:
如下图所示, 有 3 种可以从 s 中得到 "rabbit" 的方案。
rabbbit
^^^^ ^^
rabbbit
^^ ^^^^
rabbbit
^^^ ^^^
示例 2:
输入:s = "babgbag", t = "bag"
输出:5
解释:
如下图所示, 有 5 种可以从 s 中得到 "bag" 的方案。
babgbag
^^ ^
babgbag
^^ ^
babgbag
^ ^^
babgbag
^ ^^
babgbag
^^^
提示
1 <= s.length, t.length <= 1000s和t由英文字母组成
解题思路
方法一:动态规划
这道题可以使用动态规划来解决。我们定义 dp[i][j] 表示字符串 s 的前 i 个字符中,子序列 t 的前 j 个字符出现的次数。
关键点:
- 状态定义:
dp[i][j]表示s[0...i-1]中子序列t[0...j-1]出现的次数 - 边界条件:
dp[i][0] = 1,表示空字符串是任何字符串的子序列,且只有1种方案 - 状态转移:
- 如果
s[i-1] == t[j-1],则dp[i][j] = dp[i-1][j-1] + dp[i-1][j] - 如果
s[i-1] != t[j-1],则dp[i][j] = dp[i-1][j]
- 如果
具体步骤:
- 初始化
dp数组,大小为(m+1) × (n+1),其中m是s的长度,n是t的长度 - 初始化边界条件:
dp[i][0] = 1,对于所有0 <= i <= m - 遍历
s和t,填充dp数组:- 如果
s[i-1] == t[j-1],则dp[i][j] = dp[i-1][j-1] + dp[i-1][j] - 如果
s[i-1] != t[j-1],则dp[i][j] = dp[i-1][j]
- 如果
- 返回
dp[m][n]
时间复杂度:O(m×n),其中 m 是 s 的长度,n 是 t 的长度 空间复杂度:O(m×n),需要一个二维数组存储状态
方法二:动态规划(空间优化)
我们可以观察到,dp[i][j] 只与 dp[i-1][j-1] 和 dp[i-1][j] 有关,因此可以使用滚动数组优化空间复杂度。
关键点:
- 使用一维数组
dp[j]表示当前s的前i个字符中,子序列t的前j个字符出现的次数 - 从右向左更新
dp数组,避免覆盖还未使用的值
具体步骤:
- 初始化一维
dp数组,大小为n+1,其中n是t的长度 - 初始化边界条件:
dp[0] = 1 - 遍历
s和t,更新dp数组:- 从右向左遍历
j,从n到1 - 如果
s[i-1] == t[j-1],则dp[j] = dp[j-1] + dp[j] - 如果
s[i-1] != t[j-1],则dp[j]保持不变
- 从右向左遍历
- 返回
dp[n]
时间复杂度:O(m×n),其中 m 是 s 的长度,n 是 t 的长度 空间复杂度:O(n),只需要一个一维数组存储状态
图解思路
方法一:动态规划过程分析表
以示例1为例,s = "rabbbit", t = "rabbit"
| dp[i][j] | j=0 ("") | j=1 ("r") | j=2 ("ra") | j=3 ("rab") | j=4 ("rabb") | j=5 ("rabbi") | j=6 ("rabbit") |
|---|---|---|---|---|---|---|---|
| i=0 ("") | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
| i=1 ("r") | 1 | 1 | 0 | 0 | 0 | 0 | 0 |
| i=2 ("ra") | 1 | 1 | 1 | 0 | 0 | 0 | 0 |
| i=3 ("rab") | 1 | 1 | 1 | 1 | 0 | 0 | 0 |
| i=4 ("rabb") | 1 | 1 | 1 | 1 | 1 | 0 | 0 |
| i=5 ("rabbb") | 1 | 1 | 1 | 1 | 2 | 0 | 0 |
| i=6 ("rabbbi") | 1 | 1 | 1 | 1 | 2 | 3 | 0 |
| i=7 ("rabbbit") | 1 | 1 | 1 | 1 | 2 | 3 | 3 |
方法二:空间优化过程表
以示例1为例,s = "rabbbit", t = "rabbit"
| 迭代 | dp[0] | dp[1] | dp[2] | dp[3] | dp[4] | dp[5] | dp[6] | 当前字符 |
|---|---|---|---|---|---|---|---|---|
| 初始化 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | - |
| i=1 (s[0]='r') | 1 | 1 | 0 | 0 | 0 | 0 | 0 | r |
| i=2 (s[1]='a') | 1 | 1 | 1 | 0 | 0 | 0 | 0 | a |
| i=3 (s[2]='b') | 1 | 1 | 1 | 1 | 0 | 0 | 0 | b |
| i=4 (s[3]='b') | 1 | 1 | 1 | 1 | 1 | 0 | 0 | b |
| i=5 (s[4]='b') | 1 | 1 | 1 | 1 | 2 | 0 | 0 | b |
| i=6 (s[5]='i') | 1 | 1 | 1 | 1 | 2 | 3 | 0 | i |
| i=7 (s[6]='t') | 1 | 1 | 1 | 1 | 2 | 3 | 3 | t |
代码实现
C# 实现
public class Solution {
// 方法一:动态规划
public int NumDistinct(string s, string t) {
int m = s.Length;
int n = t.Length;
// 创建dp数组
long[,] dp = new long[m + 1, n + 1];
// 初始化边界条件
for (int i = 0; i <= m; i++) {
dp[i, 0] = 1;
}
// 填充dp数组
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s[i - 1] == t[j - 1]) {
dp[i, j] = dp[i - 1, j - 1] + dp[i - 1, j];
} else {
dp[i, j] = dp[i - 1, j];
}
}
}
return (int)dp[m, n];
}
// 方法二:动态规划(空间优化)
public int NumDistinctOptimized(string s, string t) {
int m = s.Length;
int n = t.Length;
// 创建一维dp数组
long[] dp = new long[n + 1];
// 初始化边界条件
dp[0] = 1;
// 填充dp数组
for (int i = 1; i <= m; i++) {
for (int j = n; j >= 1; j--) {
if (s[i - 1] == t[j - 1]) {
dp[j] = dp[j - 1] + dp[j];
}
// 如果不相等,dp[j]保持不变
}
}
return (int)dp[n];
}
}
Python 实现
class Solution:
# 方法一:动态规划
def numDistinct(self, s: str, t: str) -> int:
m, n = len(s), len(t)
# 创建dp数组
dp = [[0] * (n + 1) for _ in range(m + 1)]
# 初始化边界条件
for i in range(m + 1):
dp[i][0] = 1
# 填充dp数组
for i in range(1, m + 1):
for j in range(1, n + 1):
if s[i - 1] == t[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]
# 方法二:动态规划(空间优化)
def numDistinctOptimized(self, s: str, t: str) -> int:
m, n = len(s), len(t)
# 创建一维dp数组
dp = [0] * (n + 1)
# 初始化边界条件
dp[0] = 1
# 填充dp数组
for i in range(1, m + 1):
for j in range(n, 0, -1):
if s[i - 1] == t[j - 1]:
dp[j] = dp[j - 1] + dp[j]
# 如果不相等,dp[j]保持不变
return dp[n]
C++ 实现
class Solution {
public:
// 方法一:动态规划
int numDistinct(string s, string t) {
int m = s.length();
int n = t.length();
// 创建dp数组,使用unsigned long long避免溢出
vector<vector<unsigned long long>> dp(m + 1, vector<unsigned long long>(n + 1, 0));
// 初始化边界条件
for (int i = 0; i <= m; i++) {
dp[i][0] = 1;
}
// 填充dp数组
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s[i - 1] == t[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];
}
// 方法二:动态规划(空间优化)
int numDistinctOptimized(string s, string t) {
int m = s.length();
int n = t.length();
// 创建一维dp数组
vector<unsigned long long> dp(n + 1, 0);
// 初始化边界条件
dp[0] = 1;
// 填充dp数组
for (int i = 1; i <= m; i++) {
for (int j = n; j >= 1; j--) {
if (s[i - 1] == t[j - 1]) {
dp[j] = dp[j - 1] + dp[j];
}
// 如果不相等,dp[j]保持不变
}
}
return dp[n];
}
};
执行结果
C# 实现
- 执行用时:76 ms
- 内存消耗:38.9 MB
Python 实现
- 执行用时:44 ms
- 内存消耗:17.2 MB
C++ 实现
- 执行用时:0 ms
- 内存消耗:6.5 MB
性能对比
| 语言 | 执行用时 | 内存消耗 | 特点 |
|---|---|---|---|
| C# | 76 ms | 38.9 MB | 代码结构清晰,但性能较慢 |
| Python | 44 ms | 17.2 MB | 代码简洁,性能适中 |
| C++ | 0 ms | 6.5 MB | 执行速度最快,内存占用最少 |
代码亮点
- 🎯 使用动态规划解决子序列计数问题,思路清晰
- 💡 空间优化方法将空间复杂度从O(m×n)降低到O(n)
- 🔍 正确处理了数据范围可能导致的溢出问题,使用更大的整数类型
- 🎨 代码结构清晰,变量命名规范,易于理解和维护
常见错误分析
- 🚫 没有考虑到结果可能超出32位整数范围,导致溢出
- 🚫 空间优化时从左到右更新dp数组,导致使用了已经被更新的值
- 🚫 初始化边界条件错误,例如忘记设置dp[0]=1
- 🚫 状态转移方程理解错误,导致计算结果不正确
解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 动态规划 | O(m×n) | O(m×n) | 思路直观,易于理解 | 空间消耗较大 |
| 动态规划(空间优化) | O(m×n) | O(n) | 空间效率高 | 代码稍复杂,需要注意更新顺序 |
相关题目
- LeetCode 72. 编辑距离 - 困难
- LeetCode 583. 两个字符串的删除操作 - 中等
- LeetCode 1143. 最长公共子序列 - 中等 </rewritten_file>