1. 问题解析与动态规划思路
这道题目要求我们计算字符串s的子序列中,字符串t出现的次数。子序列的定义是:在不改变字符相对顺序的情况下,通过删除某些字符得到的新序列。比如"abc"的子序列包括"a"、"b"、"c"、"ab"、"ac"、"bc"和"abc"。
1.1 为什么选择动态规划
动态规划特别适合解决这类"计数"问题,因为它可以:
- 将大问题分解为重叠子问题
- 通过记忆化存储中间结果避免重复计算
- 建立状态转移方程来描述问题演变规律
在本题中,我们需要比较两个字符串的所有可能子序列组合,这正是DP的用武之地。
1.2 DP数组定义的艺术
定义dp[i][j]为:s的前i个字符的子序列中,t的前j个字符出现的次数。这里使用i和j表示长度而非下标,是为了方便处理空串的情况。
注意:很多初学者会混淆下标和长度,使用长度表示可以简化边界条件处理
2. 初始化与边界条件处理
2.1 初始化逻辑详解
初始化是DP问题最容易出错的部分,我们需要考虑三种特殊情况:
- dp[i][0] = 1:t为空串时,任何字符串s都包含1个空串(通过删除所有字符得到)
- dp[0][j] = 0(j>0):s为空串时,无法包含非空的t
- dp[0][0] = 1:两个空串相互匹配
cpp复制// 初始化代码
vector<vector<int>> dp(s.size() + 1, vector<int>(t.size() + 1, 0));
for (int i = 0; i <= s.size(); i++)
dp[i][0] = 1; // 第一列初始化为1
2.2 边界条件的实际意义
理解这些初始值的实际含义非常重要:
- 当t为空时,我们总是有1种方法(删除所有字符)
- 当s为空而t非空时,不可能匹配
- 两个空串相互匹配
3. 状态转移方程推导
3.1 字符匹配时的决策
当s[i-1] == t[j-1]时(注意这里的下标转换),我们面临两种选择:
- 使用s[i-1]进行匹配:那么结果数等于dp[i-1][j-1]
- 不使用s[i-1]:结果数等于dp[i-1][j]
因此总的结果数是两者之和:
cpp复制if (s[i - 1] == t[j - 1])
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
3.2 字符不匹配时的处理
当字符不匹配时,我们只能选择不使用当前字符:
cpp复制else
dp[i][j] = dp[i - 1][j];
3.3 整数溢出防护
题目提示结果在32位有符号整数范围内,但中间计算可能溢出,因此需要防护:
cpp复制if (dp[i - 1][j - 1] <= INT_MAX - dp[i - 1][j])
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
4. 完整代码实现与优化
4.1 基础实现
cpp复制class Solution {
public:
int numDistinct(string s, string t) {
vector<vector<int>> dp(s.size() + 1, vector<int>(t.size() + 1, 0));
// 初始化
for (int i = 0; i <= s.size(); i++)
dp[i][0] = 1;
// 填充DP表
for (int i = 1; i <= s.size(); i++) {
for (int j = 1; j <= t.size(); j++) {
if (s[i - 1] == t[j - 1] &&
dp[i - 1][j - 1] <= INT_MAX - dp[i - 1][j]) {
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[s.size()][t.size()];
}
};
4.2 空间优化技巧
我们可以将二维DP优化为一维数组,节省空间:
cpp复制int numDistinct(string s, string t) {
vector<int> dp(t.size() + 1, 0);
dp[0] = 1; // 空串匹配
for (int i = 1; i <= s.size(); i++) {
// 需要从后往前遍历,避免覆盖
for (int j = t.size(); j >= 1; j--) {
if (s[i - 1] == t[j - 1]) {
dp[j] += dp[j - 1];
}
}
}
return dp[t.size()];
}
注意:空间优化版本需要反向遍历,否则会覆盖需要使用的上一轮结果
5. 复杂度分析与实际问题
5.1 时间复杂度
- 基础版本:O(m×n),m和n分别是s和t的长度
- 空间优化版本:O(n)
5.2 常见错误与调试技巧
- 下标混淆:容易把dp[i][j]对应的字符位置搞错,记住dp[i][j]对应s[i-1]和t[j-1]
- 初始化错误:忘记处理空串情况或处理不当
- 整数溢出:没有考虑中间结果的溢出问题
- 遍历顺序错误:在空间优化版本中错误地使用正向遍历
调试时可以打印整个DP表,观察填充过程是否符合预期。
6. 实际应用与变种问题
6.1 实际问题中的应用
这种子序列计数问题在生物信息学中很常见,比如:
- DNA序列匹配
- 蛋白质序列分析
- 自然语言处理中的字符串相似度计算
6.2 相关变种问题
- 编辑距离:计算将一个字符串转换为另一个字符串所需的最少操作次数
- 最长公共子序列:找出两个字符串共有的最长子序列
- 通配符匹配:支持通配符的字符串匹配问题
7. 个人实战经验分享
在解决这类DP问题时,我总结出以下经验:
- 先画表格:在纸上画出DP表的初始状态和几个填充步骤,直观理解状态转移
- 小规模测试:先用小例子手动计算,验证思路正确性
- 边界测试:特别测试空串、单字符、完全匹配等边界情况
- 打印中间结果:在复杂问题时,打印DP表帮助调试
对于本题,特别要注意的是当s和t都很长时,即使是O(n^2)的解法也可能超时或内存不足。在实际面试中,可以先提出基础解法,再讨论优化空间的可能性。