← 返回题目列表

不同的子序列如何用动态规划计数?为什么匹配时要加上“不用当前字符”的方案?

困难 第 28 / 33 题 更新于 2026/08/01
动态规划字符串DP子序列计数DP

简化版

不同的子序列用 dp[i][j] 表示 s 的前 i 个字符中,有多少种子序列等于 t 的前 j 个字符。若 s[i-1] == t[j-1],当前字符可以用也可以不用,所以 dp[i][j] = dp[i-1][j-1] + dp[i-1][j];不相等时只能不用当前字符。

详细版

初始化时,任何字符串变成空串都有 1 种方式,也就是删除所有字符,所以 dp[i][0]=1;空串无法组成非空目标串,所以 dp[0][j]=0。转移时要分清“选当前 s[i-1] 去匹配 t[j-1]”和“不选当前字符”两类方案。

时间复杂度 O(mn),空间复杂度可以优化为 O(n)。如果计数可能很大,要关注语言中的整数范围,工程里可能需要 long 或取模;原题通常保证结果在范围内。

完整版教学

一、子序列计数和普通匹配有什么不同

子序列允许删除字符,但不能改变相对顺序。例如 s="rabbbit"t="rabbit",可以删除三个 b 里的任意一个,得到 3 种不同方案。这里问的不是能不能匹配,而是方案数量。

r a b b b i t
r a b   b i t
r a   b b i t
r a b b   i t

计数题的关键是把互不重叠的选择分类,避免漏算或重复算。

二、状态定义为什么用两个前缀

定义:

dp[i][j] = s[0..i-1] 的子序列中,等于 t[0..j-1] 的方案数

用前缀是因为子序列保持相对顺序。处理到 s[i-1] 时,只需要知道前面的字符能组成多少目标前缀。

状态含义
dp[i][j]用 s 的前 i 个字符匹配 t 的前 j 个字符
dp[i-1][j]不使用当前 s 字符
dp[i-1][j-1]使用当前 s 字符匹配当前 t 字符

这个定义自然包含“删字符”的动作:不用当前字符就是删除它。

三、初始化为什么 dp[i][0]=1

目标串为空时,任何 s 前缀都有 1 种方式匹配它:什么都不选。比如 s="abc" 要形成空串,唯一方案是删掉 a,b,c

dp[0][0] = 1
dp[1][0] = 1
dp[2][0] = 1
dp[3][0] = 1

dp[0][j]j>0 是 0,因为空的 s 不可能形成非空的 t

记忆钩子:计数 DP 里,空目标通常有 1 种“什么都不选”的方案。

四、字符相等时为什么是两个来源相加

s[i-1] == t[j-1],当前字符有两种互斥选择:

1. 用它匹配 t[j-1]:dp[i-1][j-1]
2. 不用它,继续让前 i-1 个字符匹配 t 前 j 个:dp[i-1][j]

所以:

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

这不是取最大值,因为题目要的是方案数;两个集合互不重叠,一个使用当前字符,一个不使用当前字符。

五、字符不相等时为什么只能继承

如果 s[i-1] != t[j-1],当前 s 字符无法匹配当前 t 字符,只能跳过它:

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

例如 s 当前是 xt 当前需要 b,你不能强行用 x 参与匹配。子序列允许删除,所以跳过当前字符是唯一合法选择。

这个地方不能写成 dp[i][j-1],因为那表示少匹配一个目标字符,语义已经变了。

六、代码和空间优化

二维写法最清晰:

int numDistinct(String s, String t) {
    int m = s.length(), n = t.length();
    long[][] dp = new long[m + 1][n + 1];
    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++) {
            dp[i][j] = dp[i - 1][j];
            if (s.charAt(i - 1) == t.charAt(j - 1)) {
                dp[i][j] += dp[i - 1][j - 1];
            }
        }
    }
    return (int) dp[m][n];
}

一维优化时,j 必须倒序遍历,避免 dp[j-1] 被本轮提前更新。

for c in s:
    for j from n down to 1:
        if c == t[j-1]: dp[j] += dp[j-1]

七、常见误区与追问

  • 误区:字符相等时只取 dp[i-1][j-1] 这样漏掉“不使用当前字符”的方案。
  • 误区:把计数题写成取最大值。 本题要统计方案数量,不是求最长或最优。
  • 误区:一维优化时正序遍历。 正序会让当前字符被同一轮重复使用。
  • 追问:为什么空目标是 1 种方案? 因为选择空子序列也是一种合法选择。
  • 追问:结果会不会溢出? 实际工程要用更大整数或取模,原题通常约束答案范围。
  • 追问:子串和子序列有什么区别? 子串必须连续,子序列只保持相对顺序。

八、加强记忆

不同的子序列要记住“当前字符用不用”这把刀。相等时分成使用和不使用两类,所以相加;不等时只能不使用,所以继承上方。初始化里空目标为 1 是计数 DP 的地基。一维优化必须倒序,因为每个 s 字符在一轮里只能贡献一次。