当前位置: 首页 > news >正文

【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个字符出现的次数
核心思想

方法一:二维动态规划(最优解法)

  1. 状态定义:dp[i][j]表示s的前i个字符中t的前j个字符出现的次数
  2. 状态转移
    • 如果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]
  3. 边界条件
    • dp[i][0] = 1(空字符串是任何字符串的子序列)
    • dp[0][j] = 0(j > 0,空字符串不能匹配非空字符串)

方法二:滚动数组优化

  1. 空间优化:使用一维数组代替二维数组
  2. 状态转移:从后往前更新,避免覆盖
  3. 空间复杂度:O(m),m为t的长度

方法三:递归 + 记忆化

  1. 递归定义:numDistinct(s, t, i, j)表示s从i开始,t从j开始的匹配数
  2. 记忆化:使用哈希表记录已计算的结果
  3. 时间复杂度:O(n*m),n为s的长度,m为t的长度

方法四:优化的动态规划

  1. 提前终止:如果s的长度小于t的长度,直接返回0
  2. 字符频率优化:如果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)
算法对比
算法时间复杂度空间复杂度特点
二维DPO(n*m)O(n*m)最优解法,直观易懂
滚动数组优化O(n*m)O(m)空间优化
递归+记忆化O(n*m)O(n*m)递归思维
优化的DPO(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. 示例1s = "rabbbit", t = "rabbit"3
  2. 示例2s = "babgbag", t = "bag"5
  3. 空字符串s = "", t = ""1
  4. s为空s = "", t = "a"0
进阶测试用例
  1. t为空s = "abc", t = ""1
  2. 完全相同s = "abc", t = "abc"1
  3. 无匹配s = "abc", t = "def"0
  4. 重复字符s = "aaa", t = "aa"3
  5. 单字符s = "abc", t = "a"1
  6. 复杂情况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]

实用技巧

  1. 优先使用二维DP:逻辑清晰,易于理解和实现
  2. 理解状态转移:匹配时可以选择匹配或不匹配,两种情况相加
  3. 边界条件:dp[i][0] = 1,空字符串是任何字符串的子序列
  4. 滚动数组优化:从后往前更新,避免覆盖未使用的值
  5. 提前终止:如果s长度小于t长度,直接返回0
  6. 字符检查:可以提前检查t中的字符是否都在s中

进阶扩展

扩展1:返回所有匹配的子序列
  • 不仅统计个数,还返回所有匹配的子序列
扩展2:限制匹配次数
  • 每个字符最多使用k次
扩展3:多个目标字符串
  • 统计s中同时包含多个目标字符串的子序列个数
扩展4:带权重的匹配
  • 不同的匹配方式有不同的权重

应用场景

  1. 字符串匹配:统计子序列匹配的个数
  2. 生物信息学:DNA序列匹配
  3. 文本处理:关键词匹配统计
  4. 算法设计:理解动态规划和组合计数
  5. 面试题目:经典的动态规划问题

总结

不同的子序列是一个经典的动态规划问题,核心在于:

  1. 理解子序列定义:不需要连续,但顺序必须一致
  2. 状态转移方程:匹配时可以选择匹配或不匹配,两种情况相加
  3. 边界条件:dp[i][0] = 1,空字符串是任何字符串的子序列
  4. 空间优化:使用滚动数组,从后往前更新

完整题解代码

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))}
}
http://www.dtcms.com/a/614566.html

相关文章:

  • JavaScript实现一个复制函数,兼容旧浏览器
  • 网站开发人员岗位要求wordpress主题安装报错
  • 第38节:WebGL 2.0与Three.js新特性
  • 前端性能监控新方案
  • 网站建设岗位能力评估表深圳网警
  • LlamaIndex PromptTemplate 全面解析
  • 邯郸网站建设优化排名无锡网站推广¥做下拉去118cr
  • 高级语言编译程序 | 深入探讨编译原理及应用领域
  • 网站建设公司杭州18年咸鱼app引导页面设计模板
  • 2025年开源项目
  • 工控人如何做自己的网站怎么利用网站开发app
  • 温振传感器振动信号采集器 机泵状态实时监测 报警数据自动采集模块
  • 襄阳营销网站建设做一个公司网站
  • Vue3计算属性如何兼顾模板简化、性能优化与响应式自动更新?
  • 换友情链接的网站门户网站开发建设成本明细
  • 已解决:jupyter lab启动时警告与报错的解决方法
  • 【Android】布局优化:include、merge、ViewStub以及Inflate()源码浅析
  • 部署Spring Boot项目到Linux服务器数据盘
  • 网站的建设模式是指什么时候个人公众号做电影网站
  • Spring aop 五种通知类型
  • 千博企业网站管理系统完整版 2014ios认证 东莞网站建设
  • 国外的一些网站精美网站设计欣赏
  • 深度学习:动量梯度下降实战(Momentum Gradient Descent)
  • 电脑做服务器建网站app试玩网站制作
  • 【Janet】数据结构
  • Tensor与NumPy转换
  • 06-文件操作-教程
  • 【ros2】ROS2 C++服务端与客户端开发指南
  • 网站开发成本主要有哪些网络广告发布
  • 【035】Dubbo3从0到1系列之dubbo-remoting核心接口Endpoint