题目描述

✅ 115. 不同的子序列

image-20260928203514018

image-20260928203514019

题意分析

从源字符串 s 中删除任意个字符,保留其余字符的相对顺序,使得到的字符串恰好等于目标 t,统计一共有多少种选择方法。保留的字符不要求在 s 中连续,但不能改变顺序,也不能重复使用同一个位置。

计数区分的是从 s 中选了哪些下标。即使得到的字符内容完全相同,只要下标选择不同,仍然是不同方案。目标串比源串长时不可能组成,答案为零;最终答案是方案数量,不是最长匹配长度。

解法:一维前缀计数动态规划

核心思路

[!blue]

先从二维状态理解:ways[i][j] 表示从 s 的前 i 个字符中,选出 t 的前 j 个字符的方案数。考虑最新加入的源字符 s[i - 1],所有方案可以按是否使用这个具体位置分成互不重叠的两类。

不使用它时,仍从前 i - 1 个源字符组成前 j 个目标字符,贡献 ways[i - 1][j]。使用它时,由于它是目前最靠后的源位置,只能匹配目标前缀的最后一个字符 t[j - 1];两字符相等才有这一类,其余部分贡献 ways[i - 1][j - 1]。因此匹配时将两类相加,不匹配时只保留第一类,不会重复或漏计方案。

边界是 ways[i][0] = 1:无论源前缀多长,组成空目标都只有“全部不选”这一种方法。没有源字符却要组成非空目标时,方案数为零。这是后续计数能够从空选择逐步扩展的起点。

每一行只依赖上一行,可以压缩成 dp[j]。处理当前源字符前,dp[j] 保存旧行;相等时执行 dp[j] += dp[j - 1],不等时不改动。为了让右边的 dp[j - 1] 仍属于上一行,j 必须从大到小更新。若反过来更新,刚写入的状态又会被读取,相当于同一个源字符在本轮被使用多次。

处理了 i + 1 个源字符后,最多只能组成同样长度的目标前缀,所以代码从 min(i + 1, t.length()) 开始倒序遍历。扫描结束时,dp[t.length()] 就统计了整个源串组成完整目标串的所有方案。

解题步骤

  1. 创建长度为 t.length() + 1 的计数数组,令 dp[0] = 1,其余状态为零。
  2. 依次枚举源字符 s[i],本轮可处理的目标长度上限为 min(i + 1, t.length())。
  3. 从上限倒序枚举 j 到一,比较 s[i] 与 t[j - 1]。
  4. 字符相等时,把旧的 dp[j - 1] 加到 dp[j];不相等时保留 dp[j],表示跳过当前源字符。
  5. 始终保持 dp[0] = 1,最后返回 dp[t.length()]。

代码实现

class Solution {
    public int numDistinct(String s, String t) {
        long[] dp = new long[t.length() + 1];

        dp[0] = 1;

        for (int i = 0; i < s.length(); i++) {
            int upper = Math.min(i + 1, t.length());

            // 倒序更新让左边仍是旧前缀状态,避免同一源字符重复使用。
            for (int j = upper; j >= 1; j--) {
                if (s.charAt(i) == t.charAt(j - 1)) {
                    // 匹配时增加选当前字符的方案,原值保留不选它的方案。
                    dp[j] += dp[j - 1];
                }
            }
        }

        return (int) dp[t.length()];
    }
}
func numDistinct(s string, t string) int {
    dp := make([]int64, len(t)+1)
    dp[0] = 1

    for i := 0; i < len(s); i++ {
        upper := len(t)
        if i+1 < upper {
            upper = i + 1
        }
        // 倒序更新让左边仍是旧前缀状态,避免同一源字符重复使用。
        for j := upper; j >= 1; j-- {
            if s[i] == t[j-1] {
                // 匹配时增加选当前字符的方案,原值保留不选它的方案。
                dp[j] += dp[j-1]
            }
        }
    }
    return int(dp[len(t)])
}

复杂度分析

  • 时间复杂度:O(mn),其中 m、n 分别为源串和目标串长度。每个源字符至多更新 n 个目标前缀状态。
  • 空间复杂度:O(n)。只保留目标前缀长度这一维,二维状态用于推导,并不实际创建。

关键点总结

[!green]

  • 计数对象是源下标的选择方法,重复字符仍可能带来不同方案。
  • 按是否使用当前源位置分类,两类互斥且覆盖所有选择,匹配时应当相加。
  • 空目标的一种空选择提供起始计数;倒序更新保证每个源位置最多使用一次。
  • 先说明二维状态,再压缩为一维,才能明确每次读取属于哪一轮。

易错点总结

[!yellow]

  • 按子串处理:跳过源串中的字符完全合法,不能要求选择的位置连续。
  • 把 dp[0] 初始化为零:空选择是后续匹配的来源,缺少它会使所有计数都保持为零。
  • 从小到大更新:会读取本轮已经累加的左侧状态,重复使用当前源字符,应从大到小更新。
  • 字符相等时直接赋值为 dp[j - 1]:会丢掉不使用当前字符的已有方案,正确操作是累加。
  • 字符不等时将 dp[j] 清零:仍然可以跳过当前源字符,旧方案应全部保留。
  • 把目标长度当成末尾下标:状态长度为 j,需要比较的是 t[j - 1]。

相似题目

题目 难度 关联与区别
1143. 最长公共子序列 中等 同样处理两个前缀的匹配关系,原题取最大长度,本题在匹配时累加选与不选的方案数。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/13573294
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!