这道子序列计数题,让 90% 的人栽在“相加“上

这道子序列计数题,让 90% 的人栽在"相加"上

“字符串 s = 'rabbbit' 里,藏着多少个 'rabbit'?”

如果你脱口而出"1 个",那这道题刚好能帮你打通 DP 的任督二脉。

这是 LeetCode 第 115 题——不同的子序列。它和编辑距离、最长公共子序列并称"字符串 DP 三剑客",但很多人在它身上翻车,不是因为代码难写,而是因为一个"相加"的逻辑想不明白


先搞清楚:子序列是什么?

子序列 = 从原字符串中删掉一些字符(可以一个都不删),但不改变剩下字符的相对顺序。

s = "rabbbit"

"rabbit" 是 s 的子序列吗?是的!删掉中间那个 'b' 就行。
"rabit"  是 s 的子序列吗?是的!删掉两个 'b' 就行。
"rat"    是 s 的子序列吗?是的!删掉 'b','b','i','t' 就行。

但这道题问的不是"是不是",而是**“有多少种不同的删法”**能得到目标字符串。

s = "rabbbit", t = "rabbit"

rab b bit   ← 删掉第3个 'b'(索引2)
rab b bit   ← 删掉第4个 'b'(索引3)
rab b bit   ← 删掉第5个 'b'(索引4)

答案:3 种

三个 ‘b’ 长得一样,但因为位置不同,所以是三种不同的方案。


这道题到底在考什么?

如果你之前做过编辑距离,会发现状态定义很像。但编辑距离问的是"最少几步",而这道题问的是"有几种方案"。

核心区别:

  • 编辑距离:每一步取 min(找最优)
  • 不同子序列:每一种方案都累加,取 sum(找总数)

这就是为什么很多人卡在这里——还在用"最优"的思维做"计数"的题


五步法:从零推导

第一步:定义状态

dp[i][j] = s 的前 i 个字符中,包含 t 的前 j 个字符作为子序列的方案数****

举个例子:

  • dp[0][0] = 1:空串里找空串,有 1 种方式(什么都不做)
  • dp[5][3]"babgb" 里包含 "bag" 的方案数?

第二步:状态转移方程(核心!)

假设现在处理 dp[i][j],最后一个字符分别是 s[i-1]t[j-1]

✅ 情况一:两字符相等 s[i-1] == t[j-1]

这是最精彩的部分。你有两种独立的选择,方案数相加

选择含义方案数
s[i-1] 去匹配当前字符配对成功,继续往前匹配剩余部分dp[i-1][j-1]
不用 s[i-1],继续找跳过当前字符,用 s 前面的字符匹配完整的 tdp[i-1][j]

dp[i][j] = dp[i-1][j-1] + dp[i-1][j]

为什么能相加? 因为这两种选择是互斥的——要么用这个 ‘b’,要么不用这个 ‘b’,没有第三种可能,所以方案数直接累加。


✅ 情况二:两字符不相等 s[i-1] != t[j-1]

当前字符不匹配,只能跳过:

dp[i][j] = dp[i-1][j]


第三步:初始化

边界含义
dp[0][0]空串里找空串1
dp[i][0]非空串里找空串1(全部删掉就行)
dp[0][j](j>0)空串里找非空串0(不可能)

第四步:遍历顺序

dp[i][j] 依赖左上方和上方,从左到右、从上到下遍历。


第五步:验证填表

s = "babgbag", t = "bag" 为例,手动追踪关键位置:

is[i-1]j=1 (b)j=2 (a)j=3 (g)
1b0+1=100
2a11+0=10
3b1+1=210
4g211+0=1
5b1+2=311
6a33+1=41
7g344+1=5

答案 dp[7][3] = 5,和示例完全一致!


完整代码

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

        // 初始化:任意长度的 s 都包含空串 t 一次
        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.charAt(i - 1) == t.charAt(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];
    }
}

复杂度:时间 O(mn)O(mn)O(mn),空间 O(mn)O(mn)O(mn)


进阶:空间优化

二维数组可以压缩成一维,但内层循环必须从右往左

class Solution {
    public int numDistinct(String s, String t) {
        int n = t.length();
        int[] dp = new int[n + 1];
        dp[0] = 1;  // 空串匹配空串

        for (int i = 1; i <= s.length(); i++) {
            // 必须从右往左!防止覆盖上一行数据
            for (int j = n; j >= 1; j--) {
                if (s.charAt(i - 1) == t.charAt(j - 1)) {
                    dp[j] = dp[j - 1] + dp[j];
                }
            }
        }

        return dp[n];
    }
}

为什么从右往左? 因为 dp[j] 依赖 dp[j-1](左上方),如果从左往右,dp[j-1] 会先被更新成当前行的值,导致错误。


一句话总结

相等:dp[i][j] = dp[i-1][j-1] + dp[i-1][j]  ← 两种选择,方案数相加
不等:dp[i][j] = dp[i-1][j]                  ← 一种选择,只能跳过

这道题和编辑距离的区别,本质上是计数 vs 最优的区别:

  • 计数问题:多种方案相加
  • 最优问题:多种方案取最值

搞懂了这一点,背包问题的"方案数"变种、路径计数等问题,都是同一个套路。


收藏这篇,下次遇到子序列计数的题,再也不用慌了。


欢迎在评论区交流你的 DP 学习心得,或者留下你想了解的算法题,下期安排!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值