1. 问题描述与理解
LeetCode 115题"不同的子序列"是一道经典的动态规划问题,题目给定两个字符串s和t,要求计算在s的子序列中等于t的个数。这里的子序列指的是通过删除s中的某些字符(可以不删除)而不改变剩余字符的相对位置得到的新字符串。
举个例子:
- s = "rabbbit", t = "rabbit"
- 输出应为3,因为在"rabbbit"中有三种方式可以得到"rabbit":
- rabb_bit(删除第二个b)
- rab_bbit(删除第三个b)
- rab_bbit(删除第四个b)
这个问题看似简单,但隐藏着几个关键点需要特别注意:
- 子序列的定义:必须保持原始顺序,不能重新排列字符
- 空字符串是任何字符串的子序列
- 当t为空字符串时,结果应为1(因为只有删除所有字符这一种方式)
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解题思路分析
2.1 暴力递归法(基础思路)
最直观的方法是使用递归枚举所有可能的子序列。我们可以这样思考:
- 如果s和t的第一个字符匹配,我们有两种选择:
- 匹配这对字符,然后继续比较s和t的下一个字符
- 跳过s的这个字符,继续比较s的下一个字符和t的当前字符
- 如果不匹配,则只能跳过s的当前字符
这种方法的时间复杂度是指数级的O(2^n),在LeetCode上会超时,但它是理解问题的基础。
2.2 动态规划优化
为了优化效率,我们引入动态规划(DP)来解决这个问题。DP的核心思想是存储中间结果以避免重复计算。
定义dp[i][j]表示s的前i个字符和t的前j个字符的匹配数。状态转移方程如下:
-
初始化:
- dp[i][0] = 1(空字符串是任何字符串的子序列)
- dp[0][j] = 0(j>0,非空字符串不可能是空字符串的子序列)
-
状态转移:
- 如果s[i-1] == t[j-1]:
dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
(匹配当前字符的方案数 + 不匹配当前字符的方案数) - 否则:
dp[i][j] = dp[i-1][j]
(只能跳过当前字符)
- 如果s[i-1] == t[j-1]:
这种方法将时间复杂度降低到了O(mn),空间复杂度也是O(mn),其中m和n分别是s和t的长度。
3. 代码实现与解析
3.1 C++实现
cpp复制int numDistinct(string s, string t) {
int m = s.length(), n = t.length();
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;
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];
}
3.2 代码优化(空间压缩)
观察状态转移方程,我们发现dp[i][j]只依赖于上一行的数据,因此可以将空间复杂度优化到O(n):
cpp复制int numDistinct(string s, string t) {
int m = s.length(), n = t.length();
vector<unsigned long long> dp(n+1, 0);
dp[0] = 1;
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];
}
}
}
return dp[n];
}
注意这里内层循环是从后往前遍历,以避免覆盖还未使用的dp[j-1]值。
4. 边界条件与注意事项
4.1 整数溢出问题
由于结果可能非常大,使用int类型会导致溢出。在C++中我们使用unsigned long long,在Java中使用long,在Python中则不需要特别处理。
4.2 空字符串处理
- 当t为空字符串时,应返回1
- 当s为空字符串且t不为空时,应返回0
4.3 测试用例设计
好的测试用例应该包括:
- 常规情况(如示例)
- s和t完全相同
- t比s长
- 多个匹配的情况
- 没有匹配的情况
- 包含重复字符的情况
5. 算法复杂度分析
- 时间复杂度:O(m*n),其中m是s的长度,n是t的长度
- 空间复杂度:
- 未优化版本:O(m*n)
- 优化版本:O(n)
6. 类似题目推荐
- LeetCode 72. 编辑距离
- LeetCode 1143. 最长公共子序列
- LeetCode 97. 交错字符串
- LeetCode 392. 判断子序列
- LeetCode 1092. 最短公共超序列
这些题目都涉及到字符串匹配和动态规划,可以帮助巩固这类问题的解法。
7. 常见错误与调试技巧
7.1 常见错误
- 初始化错误:忘记处理空字符串的情况
- 索引错误:混淆s和t的索引,导致数组越界
- 状态转移方程错误:特别是当字符不匹配时的处理
- 整数溢出:没有使用足够大的数据类型存储结果
7.2 调试技巧
- 打印DP表格:对于小例子,打印整个DP表格有助于理解算法执行过程
- 逐步执行:使用调试器一步步跟踪程序执行
- 小测试用例:先用小的、手工可以验证的例子测试
- 边界测试:专门测试空字符串、单字符等边界情况
8. 实际应用场景
虽然这个问题看起来是纯算法题,但它的思想在实际中有重要应用:
- 生物信息学:DNA序列比对
- 自然语言处理:文本相似度计算
- 版本控制系统:文件差异比较
- 拼写检查:单词相似度计算
理解这类问题的解法有助于解决更复杂的实际字符串处理问题。
9. 个人解题心得
在解决这个问题时,我有几点深刻体会:
-
动态规划问题的关键是定义好状态和状态转移方程。有时候需要画图辅助理解。
-
对于字符串问题,索引处理要格外小心。我经常因为索引问题调试很久,后来养成了在纸上先写出索引对应关系的习惯。
-
空间优化虽然重要,但应该先写出清晰的原始版本,确保正确性后再考虑优化。
-
测试用例的设计和手动计算预期结果非常重要,这能帮助快速发现逻辑错误。
-
这类问题需要大量练习才能熟练掌握,建议做完这道题后继续尝试我推荐的类似题目。
