【LeetCode】115. 不同的子序列
文章目录
- 115. 不同的子序列
- 题目描述
- 示例 1:
- 示例 2:
- 提示:
- 解题思路
- 问题深度分析
- 问题本质
- 核心思想
- 关键难点分析
- 典型情况分析
- 算法对比
- 算法流程图
- 主算法流程(二维DP)
- 状态转移流程
- 复杂度分析
- 时间复杂度详解
- 空间复杂度详解
- 关键优化技巧
- 技巧1:二维动态规划(最优解法)
- 技巧2:滚动数组优化
- 技巧3:递归 + 记忆化
- 技巧4:优化的动态规划
- 边界条件处理
- 边界情况1:s长度小于t长度
- 边界情况2:t为空字符串
- 边界情况3:s为空字符串
- 边界情况4:s和t完全相同
- 边界情况5:s和t没有公共字符
- 测试用例设计
- 基础测试用例
- 进阶测试用例
- 常见错误和陷阱
- 错误1:状态转移方程错误
- 错误2:边界条件处理错误
- 错误3:滚动数组更新顺序错误
- 错误4:索引越界
- 实用技巧
- 进阶扩展
- 扩展1:返回所有匹配的子序列
- 扩展2:限制匹配次数
- 扩展3:多个目标字符串
- 扩展4:带权重的匹配
- 应用场景
- 总结
- 完整题解代码
115. 不同的子序列
题目描述
给你两个字符串 s 和 t ,统计并返回在 s 的 子序列 中 t 出现的个数。
测试用例保证结果在 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 <= 1000
- s 和 t 由英文字母组成
解题思路
问题深度分析
这是经典的子序列匹配计数问题,核心在于理解子序列的定义和掌握动态规划方法。虽然题目看起来简单,但它是理解动态规划、字符串匹配和组合计数的重要题目。
问题本质
给定两个字符串s和t,需要统计s的子序列中t出现的个数。关键问题:
- 子序列定义:不需要连续,但顺序必须一致
- 匹配计数:统计所有可能的匹配方式
- 动态规划:使用DP记录状态,避免重复计算
关键点:
- 子序列:从s中选择字符,保持相对顺序,组成t
- 计数问题:需要统计所有可能的匹配方式
- 状态定义:dp[i][j]表示s的前i个字符中t的前j个字符出现的次数
核心思想
方法一:二维动态规划(最优解法)
- 状态定义:dp[i][j]表示s的前i个字符中t的前j个字符出现的次数
- 状态转移:
- 如果s[i-1] == t[j-1]:可以选择匹配或不匹配
- 匹配:dp[i-1][j-1](使用s[i-1]匹配t[j-1])
- 不匹配:dp[i-1][j](不使用s[i-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]
- 如果s[i-1] == t[j-1]:可以选择匹配或不匹配
- 边界条件:
- dp[i][0] = 1(空字符串是任何字符串的子序列)
- dp[0][j] = 0(j > 0,空字符串不能匹配非空字符串)
方法二:滚动数组优化
- 空间优化:使用一维数组代替二维数组
- 状态转移:从后往前更新,避免覆盖
- 空间复杂度:O(m),m为t的长度
方法三:递归 + 记忆化
- 递归定义:numDistinct(s, t, i, j)表示s从i开始,t从j开始的匹配数
- 记忆化:使用哈希表记录已计算的结果
- 时间复杂度:O(n*m),n为s的长度,m为t的长度
方法四:优化的动态规划
- 提前终止:如果s的长度小于t的长度,直接返回0
- 字符频率优化:如果t中某个字符在s中不存在,直接返回0
关键难点分析
难点1:理解子序列匹配
- 子序列不需要连续,但顺序必须一致
- 同一个字符可能被使用多次(如果s中有重复字符)
- 需要统计所有可能的匹配方式
难点2:状态转移方程
- 当s[i-1] == t[j-1]时,可以选择匹配或不匹配
- 匹配:使用s[i-1]匹配t[j-1],继续匹配剩余部分
- 不匹配:不使用s[i-1],继续在s的前i-1个字符中匹配t的前j个字符
- 两种情况都要考虑,所以是相加
难点3:边界条件处理
- dp[i][0] = 1:空字符串是任何字符串的子序列
- dp[0][j] = 0(j > 0):空字符串不能匹配非空字符串
- 需要正确处理这些边界情况
难点4:滚动数组优化
- 从后往前更新,避免覆盖未使用的值
- 需要理解为什么从后往前更新
典型情况分析
情况1:完全匹配
s = "rabbbit", t = "rabbit"
- s中有3个’b’,可以选择任意一个匹配t中的’b’
- 有3种匹配方式
情况2:部分匹配
s = "babgbag", t = "bag"
- s中有多个’b’、‘a’、‘g’,可以组合出5种匹配方式
情况3:无匹配
s = "abc", t = "def"
- s和t没有公共字符,返回0
情况4:完全包含
s = "abc", t = "abc"
- s完全包含t,返回1
情况5:重复字符
s = "aaa", t = "aa"
- s中有3个’a’,可以选择2个匹配t,有3种方式(C(3,2) = 3)
算法对比
| 算法 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| 二维DP | O(n*m) | O(n*m) | 最优解法,直观易懂 |
| 滚动数组优化 | O(n*m) | O(m) | 空间优化 |
| 递归+记忆化 | O(n*m) | O(n*m) | 递归思维 |
| 优化的DP | O(n*m) | O(m) | 提前终止优化 |
注:n为s的长度,m为t的长度
算法流程图
主算法流程(二维DP)
graph TDA[numDistinct s, t] --> B[初始化dp数组]B --> C[dp[i][0] = 1 for all i]C --> D[i = 1 to n]D --> E[j = 1 to m]E --> F{s[i-1] == t[j-1]?}F -->|是| G[dp[i][j] = dp[i-1][j-1] + dp[i-1][j]]F -->|否| H[dp[i][j] = dp[i-1][j]]G --> I{j < m?}H --> II -->|是| J[j++]J --> EI -->|否| K{i < n?}K -->|是| L[i++]L --> EK -->|否| M[return dp[n][m]]
状态转移流程
graph TDA[当前状态 dp[i][j]] --> B{s[i-1] == t[j-1]?}B -->|是| C[可以选择匹配或不匹配]C --> D[匹配: dp[i-1][j-1]]C --> E[不匹配: dp[i-1][j]]D --> F[dp[i][j] = dp[i-1][j-1] + dp[i-1][j]]E --> FB -->|否| G[只能不匹配]G --> H[dp[i][j] = dp[i-1][j]]
复杂度分析
时间复杂度详解
二维DP算法:O(n*m)
- 需要填充n*m的DP表
- 每个状态的计算是O(1)
- 总时间:O(n*m)
滚动数组优化算法:O(n*m)
- 需要遍历n*m个状态
- 每个状态的计算是O(1)
- 总时间:O(n*m)
递归+记忆化算法:O(n*m)
- 最多有n*m个不同的子问题
- 每个子问题的计算是O(1)
- 总时间:O(n*m)
空间复杂度详解
二维DP算法:O(n*m)
- 需要n*m的DP表
- 总空间:O(n*m)
滚动数组优化算法:O(m)
- 只需要一维数组,大小为m
- 总空间:O(m)
递归+记忆化算法:O(n*m)
- 递归调用栈:O(n+m)
- 记忆化表:O(n*m)
- 总空间:O(n*m)
关键优化技巧
技巧1:二维动态规划(最优解法)
func numDistinct(s string, t string) int {n, m := len(s), len(t)if n < m {return 0}// dp[i][j]表示s的前i个字符中t的前j个字符出现的次数dp := make([][]int, n+1)for i := range dp {dp[i] = make([]int, m+1)}// 边界条件:空字符串是任何字符串的子序列for i := 0; i <= n; i++ {dp[i][0] = 1}// 状态转移for i := 1; i <= n; i++ {for j := 1; j <= m; 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[n][m]
}
优势:
- 时间复杂度:O(n*m)
- 空间复杂度:O(n*m)
- 逻辑清晰,易于理解
- 正确处理所有边界情况
技巧2:滚动数组优化
func numDistinct(s string, t string) int {n, m := len(s), len(t)if n < m {return 0}// 使用一维数组,从后往前更新dp := make([]int, m+1)dp[0] = 1for i := 1; i <= n; i++ {// 从后往前更新,避免覆盖for j := m; j >= 1; j-- {if s[i-1] == t[j-1] {dp[j] += dp[j-1]}}}return dp[m]
}
特点:空间复杂度O(m),从后往前更新避免覆盖
技巧3:递归 + 记忆化
func numDistinct(s string, t string) int {memo := make(map[string]int)return helper(s, t, 0, 0, memo)
}func helper(s, t string, i, j int, memo map[string]int) int {// 如果t已经匹配完,返回1if j == len(t) {return 1}// 如果s已经用完但t还没匹配完,返回0if i == len(s) {return 0}key := fmt.Sprintf("%d,%d", i, j)if val, ok := memo[key]; ok {return val}result := 0// 如果当前字符匹配,可以选择匹配if s[i] == t[j] {result += helper(s, t, i+1, j+1, memo)}// 无论是否匹配,都可以选择不匹配result += helper(s, t, i+1, j, memo)memo[key] = resultreturn result
}
特点:递归思维,使用记忆化避免重复计算
技巧4:优化的动态规划
func numDistinct(s string, t string) int {n, m := len(s), len(t)if n < m {return 0}// 提前检查:如果t中某个字符在s中不存在,返回0tChars := make(map[byte]bool)for i := 0; i < m; i++ {tChars[t[i]] = true}sChars := make(map[byte]bool)for i := 0; i < n; i++ {sChars[s[i]] = true}for ch := range tChars {if !sChars[ch] {return 0}}// 使用滚动数组dp := make([]int, m+1)dp[0] = 1for i := 1; i <= n; i++ {for j := m; j >= 1; j-- {if s[i-1] == t[j-1] {dp[j] += dp[j-1]}}}return dp[m]
}
特点:提前终止优化,减少不必要的计算
边界条件处理
边界情况1:s长度小于t长度
- 处理:直接返回0
- 验证:如果n < m,不可能匹配
边界情况2:t为空字符串
- 处理:返回1(空字符串是任何字符串的子序列)
- 验证:dp[i][0] = 1
边界情况3:s为空字符串
- 处理:如果t也为空,返回1;否则返回0
- 验证:dp[0][j] = 0(j > 0)
边界情况4:s和t完全相同
- 处理:返回1
- 验证:只有一种匹配方式
边界情况5:s和t没有公共字符
- 处理:返回0
- 验证:无法匹配
测试用例设计
基础测试用例
- 示例1:
s = "rabbbit", t = "rabbit"→3 - 示例2:
s = "babgbag", t = "bag"→5 - 空字符串:
s = "", t = ""→1 - s为空:
s = "", t = "a"→0
进阶测试用例
- t为空:
s = "abc", t = ""→1 - 完全相同:
s = "abc", t = "abc"→1 - 无匹配:
s = "abc", t = "def"→0 - 重复字符:
s = "aaa", t = "aa"→3 - 单字符:
s = "abc", t = "a"→1 - 复杂情况:
s = "aabbcc", t = "abc"→8
常见错误和陷阱
错误1:状态转移方程错误
// 错误写法:只考虑匹配的情况
if s[i-1] == t[j-1] {dp[i][j] = dp[i-1][j-1]
} else {dp[i][j] = 0 // 错误!
}// 正确写法:考虑匹配和不匹配两种情况
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] // 只能不匹配
}
原因:即使字符匹配,也可以选择不匹配,两种情况都要考虑
错误2:边界条件处理错误
// 错误写法:没有初始化dp[i][0]
for i := 1; i <= n; i++ {for j := 1; j <= m; j++ {// ...}
}// 正确写法:初始化dp[i][0] = 1
for i := 0; i <= n; i++ {dp[i][0] = 1 // 空字符串是任何字符串的子序列
}
原因:空字符串是任何字符串的子序列,需要初始化为1
错误3:滚动数组更新顺序错误
// 错误写法:从前往后更新,会覆盖未使用的值
for j := 1; j <= m; j++ {if s[i-1] == t[j-1] {dp[j] += dp[j-1] // 错误!dp[j-1]已经被更新}
}// 正确写法:从后往前更新
for j := m; j >= 1; j-- {if s[i-1] == t[j-1] {dp[j] += dp[j-1] // 正确!dp[j-1]还是上一轮的值}
}
原因:从前往后更新会覆盖未使用的值,导致结果错误
错误4:索引越界
// 错误写法:直接使用s[i]和t[j]
if s[i] == t[j] {// ...
}// 正确写法:使用s[i-1]和t[j-1],因为dp[i][j]表示前i个和前j个
if s[i-1] == t[j-1] {// ...
}
原因:dp[i][j]表示s的前i个字符和t的前j个字符,所以使用s[i-1]和t[j-1]
实用技巧
- 优先使用二维DP:逻辑清晰,易于理解和实现
- 理解状态转移:匹配时可以选择匹配或不匹配,两种情况相加
- 边界条件:dp[i][0] = 1,空字符串是任何字符串的子序列
- 滚动数组优化:从后往前更新,避免覆盖未使用的值
- 提前终止:如果s长度小于t长度,直接返回0
- 字符检查:可以提前检查t中的字符是否都在s中
进阶扩展
扩展1:返回所有匹配的子序列
- 不仅统计个数,还返回所有匹配的子序列
扩展2:限制匹配次数
- 每个字符最多使用k次
扩展3:多个目标字符串
- 统计s中同时包含多个目标字符串的子序列个数
扩展4:带权重的匹配
- 不同的匹配方式有不同的权重
应用场景
- 字符串匹配:统计子序列匹配的个数
- 生物信息学:DNA序列匹配
- 文本处理:关键词匹配统计
- 算法设计:理解动态规划和组合计数
- 面试题目:经典的动态规划问题
总结
不同的子序列是一个经典的动态规划问题,核心在于:
- 理解子序列定义:不需要连续,但顺序必须一致
- 状态转移方程:匹配时可以选择匹配或不匹配,两种情况相加
- 边界条件:dp[i][0] = 1,空字符串是任何字符串的子序列
- 空间优化:使用滚动数组,从后往前更新
完整题解代码
package mainimport ("fmt"
)// =========================== 方法一:二维动态规划(最优解法) ===========================
func numDistinct1(s string, t string) int {n, m := len(s), len(t)if n < m {return 0}// dp[i][j]表示s的前i个字符中t的前j个字符出现的次数dp := make([][]int, n+1)for i := range dp {dp[i] = make([]int, m+1)}// 边界条件:空字符串是任何字符串的子序列for i := 0; i <= n; i++ {dp[i][0] = 1}// 状态转移for i := 1; i <= n; i++ {for j := 1; j <= m; 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[n][m]
}// =========================== 方法二:滚动数组优化 ===========================
func numDistinct2(s string, t string) int {n, m := len(s), len(t)if n < m {return 0}// 使用一维数组,从后往前更新dp := make([]int, m+1)dp[0] = 1for i := 1; i <= n; i++ {// 从后往前更新,避免覆盖for j := m; j >= 1; j-- {if s[i-1] == t[j-1] {dp[j] += dp[j-1]}}}return dp[m]
}// =========================== 方法三:递归 + 记忆化 ===========================
func numDistinct3(s string, t string) int {memo := make(map[string]int)return helper(s, t, 0, 0, memo)
}func helper(s, t string, i, j int, memo map[string]int) int {// 如果t已经匹配完,返回1if j == len(t) {return 1}// 如果s已经用完但t还没匹配完,返回0if i == len(s) {return 0}key := fmt.Sprintf("%d,%d", i, j)if val, ok := memo[key]; ok {return val}result := 0// 如果当前字符匹配,可以选择匹配if s[i] == t[j] {result += helper(s, t, i+1, j+1, memo)}// 无论是否匹配,都可以选择不匹配result += helper(s, t, i+1, j, memo)memo[key] = resultreturn result
}// =========================== 方法四:优化的动态规划 ===========================
func numDistinct4(s string, t string) int {n, m := len(s), len(t)if n < m {return 0}// 提前检查:如果t中某个字符在s中不存在,返回0tChars := make(map[byte]bool)for i := 0; i < m; i++ {tChars[t[i]] = true}sChars := make(map[byte]bool)for i := 0; i < n; i++ {sChars[s[i]] = true}for ch := range tChars {if !sChars[ch] {return 0}}// 使用滚动数组dp := make([]int, m+1)dp[0] = 1for i := 1; i <= n; i++ {for j := m; j >= 1; j-- {if s[i-1] == t[j-1] {dp[j] += dp[j-1]}}}return dp[m]
}// =========================== 测试 ===========================
func main() {fmt.Println("=== LeetCode 115: 不同的子序列 ===\n")testCases := []struct {name strings stringt stringexpected int}{{name: "例1: s=\"rabbbit\", t=\"rabbit\"",s: "rabbbit",t: "rabbit",expected: 3,},{name: "例2: s=\"babgbag\", t=\"bag\"",s: "babgbag",t: "bag",expected: 5,},{name: "空字符串: s=\"\", t=\"\"",s: "",t: "",expected: 1,},{name: "s为空: s=\"\", t=\"a\"",s: "",t: "a",expected: 0,},{name: "t为空: s=\"abc\", t=\"\"",s: "abc",t: "",expected: 1,},{name: "完全相同: s=\"abc\", t=\"abc\"",s: "abc",t: "abc",expected: 1,},{name: "无匹配: s=\"abc\", t=\"def\"",s: "abc",t: "def",expected: 0,},{name: "重复字符: s=\"aaa\", t=\"aa\"",s: "aaa",t: "aa",expected: 3,},{name: "单字符: s=\"abc\", t=\"a\"",s: "abc",t: "a",expected: 1,},{name: "复杂情况: s=\"aabbcc\", t=\"abc\"",s: "aabbcc",t: "abc",expected: 8,},}methods := map[string]func(string, string) int{"二维DP": numDistinct1,"滚动数组优化": numDistinct2,"递归+记忆化": numDistinct3,"优化的DP": numDistinct4,}for methodName, methodFunc := range methods {fmt.Printf("方法:%s\n", methodName)pass := 0for i, tc := range testCases {got := methodFunc(tc.s, tc.t)ok := got == tc.expectedstatus := "✅"if !ok {status = "❌"}fmt.Printf(" 测试%d(%s): %s\n", i+1, tc.name, status)if !ok {fmt.Printf(" 输出: %d\n 期望: %d\n", got, tc.expected)} else {pass++}}fmt.Printf(" 通过: %d/%d\n\n", pass, len(testCases))}
}
