算法讲解068 必备 见识更多二维动态规划题目

来源:算法讲解068【必备】见识更多二维动态规划题目 · 2026-11-03
依据:视频字幕 2000 字

一句话概括

用「两个前缀长度」作状态定义二维 DP:dp[i][j] = S 的前 i 个字符的子序列中,恰好等于 T 的前 j 个字符的个数;按「S 末尾字符选不选」分类转移。

它解决什么问题

给定字符串 S、T,统计 S 的子序列(不要求连续,保持相对顺序,可要可不要)中有多少个字面值严格等于 T。典型题:LeetCode 115 不同子序列(Distinct Subsequences)。

基础知识(前置概念,用大白话解释)

原理(为什么这么做是对的,尽量给直觉)

定义 dp[i][j]:只用 S 的前 i 个字符(下标 0..i-1)生成子序列,其中字面值等于 T 的前 j 个字符(下标 0..j-1)的个数。

按 S 的第 i 个字符(下标 i-1)选不选分类,这是不重不漏的划分:

  1. 不选 S[i-1]:那这个子序列完全来自 S 的前 i-1 个字符,方案数 = dp[i-1][j]。
  2. 选 S[i-1]:只有当 S[i-1] == T[j-1] 时才可能。此时它必须去匹配 T 的最后一个字符 T[j-1],前面部分就是「S 前 i-1 个字符凑出 T 前 j-1 个字符」,方案数 = dp[i-1][j-1]。

两类互斥(选/不选),所以相加。这就是全部转移。

边界直觉:

为什么答案在 dp[n][m]:n = |S|、m = |T|,正好是「S 全部字符」对「T 全部字符」,就是原问题。

步骤(编号,可照着写)

  1. 读入 S、T,n = |S|,m = |T|。
  2. 开 (n+1) × (m+1) 的表,全部初始化为 0。
  3. 对 i = 0..n:dp[i][0] = 1。
  4. 对 i = 1..n:

- 对 j = 1..m:

- dp[i][j] = dp[i-1][j](不选 S[i-1])

- 若 S[i-1] == T[j-1]:dp[i][j] += dp[i-1][j-1]

  1. 返回 dp[n][m]。

(可滚动数组优化到一维,见「竞赛模板」和「拓展」。)

样例(一个具体小例子,带每步变化)

S = "rabbbit",T = "rabbit",n = 7,m = 6。答案应为 3(三个 b 里选两个)。

只列关键行(行 = S 前缀长度 i,列 = T 前缀长度 j,j 从 0 到 6):

i=0 (空):   1 0 0 0 0 0 0
i=1 'r':    1 1 0 0 0 0 0
i=2 'a':    1 1 1 0 0 0 0
i=3 'b':    1 1 1 1 0 0 0
i=4 'b':    1 1 1 2 1 0 0
i=5 'b':    1 1 1 3 3 0 0
i=6 'i':    1 1 1 3 3 3 0
i=7 't':    1 1 1 3 3 3 3

看 i=4 行 j=3:dp[4][3] = dp[3][3] + dp[3][2](S[3]='b'==T[2]='b')= 1 + 1 = 2,即 "rab" 有两个 b 可选。i=5 行 j=3 同理累加到 3。最终 dp[7][6] = 3。

代码(Python 一份:好读、用于理解;C++ 一份:竞赛风,能直接抄进比赛,带注释,不用玄学宏)

Python(好读版):

def num_distinct(s: str, t: str) -> int:
    n, m = len(s), len(t)
    # dp[i][j]: s 前 i 个字符的子序列中等于 t 前 j 个字符的个数
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        dp[i][0] = 1
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            dp[i][j] = dp[i - 1][j]
            if s[i - 1] == t[j - 1]:
                dp[i][j] += dp[i - 1][j - 1]
    return dp[n][m]

C++(竞赛风,滚动数组,直接抄):

#include <bits/stdc++.h>
using namespace std;

// 统计 s 的子序列中等于 t 的个数(不取模,答案可能很大,用 long long)
long long numDistinct(const string& s, const string& t) {
    int n = (int)s.size(), m = (int)t.size();
    if (m > n) return 0;
    vector<long long> dp(m + 1, 0);
    dp[0] = 1;                       // 空 T 只有一种方案
    for (int i = 1; i <= n; ++i) {
        // 关键:j 必须从大到小,否则 dp[j-1] 已被本行更新,变成"可重复用 S[i-1]"
        for (int j = m; j >= 1; --j) {
            if (s[i - 1] == t[j - 1]) {
                dp[j] += dp[j - 1];
            }
            // 不相等时 dp[j] 保持不变,等价于 dp[i][j] = dp[i-1][j]
        }
    }
    return dp[m];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    string s, t;
    cin >> s >> t;
    cout << numDistinct(s, t) << '\n';
    return 0;
}

复杂度(时间/空间,最好/平均/最坏,顺带说常数大不大)

竞赛里什么时候用(看到什么题面特征就该想到它)

竞赛模板(C++,可直接背下来的写法;变量名清楚)

// 计数型二维 DP 通用骨架:dp[i][j] 依赖 dp[i-1][j] 和 dp[i-1][j-1]
// 滚动数组时 j 逆序;若依赖 dp[i][j-1](同行左侧)则 j 必须正序。
long long countSubseqEqual(const string& s, const string& t) {
    int n = (int)s.size(), m = (int)t.size();
    if (m > n) return 0;
    vector<long long> dp(m + 1, 0);
    dp[0] = 1;
    for (int i = 1; i <= n; ++i)
        for (int j = m; j >= 1; --j)
            if (s[i - 1] == t[j - 1])
                dp[j] += dp[j - 1];
    return dp[m];
}

边界与易错点(WA 的常见原因,越具体越好)

  1. 滚动数组方向写反:本转移依赖 dp[i-1][j-1],一维化后 j 必须从 m 到 1 逆序。正序会让 dp[j-1] 变成「本行已更新值」,等于允许同一个 S 字符被用多次,答案偏大。
  2. dp[i][0] 忘记置 1:T 为空串时答案是 1(全不选),漏了整列错。
  3. 下标混淆:dp 的 i、j 是长度(1..n),s[i-1]、t[j-1] 才是字符。写成 s[i] 会越界或错位。
  4. m > n 直接返回 0:S 比 T 短不可能凑出,提前剪枝也防越界。
  5. 溢出:不取模时答案可达 C(n, m) 级别,n=1000 时远超 int,必须 long long;若题目要求取模(如 1e9+7),每步加法后取模,别只在最后取。
  6. 多测不清空:滚动数组每轮重新 vector<long long> dp(m+1, 0) 或手动 fill,别复用脏数据。
  7. 空串输入:S 或 T 为空要能正常返回(T 空返回 1,S 空且 T 非空返回 0),读入用 cin >> 时注意空行。

同类题 / 变形(说清楚怎么改)

常见坑

拓展(变体、实际用途、面试常问)

自测 3 题(题目 + 折叠答案)

题 1:S = "babgbag",T = "bag",答案是多少?

答案

5。转移表最终 dp[7][3] = 5,对应视频里列出的五个子序列。

</details>

题 2:把本题改成「答案对 1e9+7 取模」,滚动数组内层怎么写?

答案
const long long MOD = 1000000007;
for (int j = m; j >= 1; --j)
    if (s[i-1] == t[j-1])
        dp[j] = (dp[j] + dp[j-1]) % MOD;

仍必须逆序;dp[0]=1 不参与取模。

</details>

题 3:若把「子序列」改成「子串」(必须连续),还能用这个 DP 吗?该怎么改?

答案

不能直接用这个转移。子串要求连续,标准做法是枚举 S 的每个起点做 KMP/哈希匹配,或用 dp[i][j] 表示「以 S[i-1]、T[j-1] 结尾的匹配长度」的另一种定义(本质是字符串匹配,不是本题的子序列计数)。本题 DP 的「选/不选」划分依赖子序列可跳字符,子串不成立。

</details>