cswork / 模板打开 500 路题单

第 26 站 · 动态规划

字符串匹配与编辑

双串 DP:dp[i][j] 看两个前缀 O(m × n)

周赛难度分 难度分 1736–难度分 1977· 中位 难度分 1806(3/10 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 26 站 · 10 题
先学
学完去
面试频次
2 家公司考过 · 面经出现 2 次Amazon 1、TikTok / ByteDance 1

先做这道

LC 1143最长公共子序列中等
卡住再看

掌握双前缀状态及匹配、跳过两类转移,并能恢复一条公共子序列。

要点

  1. dp[i][j] 定义在前缀上:a 的前 i 个字符和 b 的前 j 个字符;多开一行一列表示空串。
  2. 末尾字符相等就继承 dp[i-1][j-1];不相等时,替换、删除、插入分别对应左上、上、左三个格子。
  3. 最长公共子序列、不同的子序列、通配符匹配都是这张表,只是转移不同。

易错点

  • dp 的下标是前缀长度,对应的字符是 a[i-1];写成 a[i] 会错一位。
  • 忘了初始化第 0 行和第 0 列(变成空串需要删 i 次,从空串变来需要插 j 次)。
  • 压成一行时,左上角的值会被本行覆盖,需要额外一个变量保存。

看动画

一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。

交互演示 · 逐步观察

二维 DP · 填表求最长公共子序列

「ACBD」和「ABCD」的最长公共子序列

换个输入
∅
A
B
C
D
∅
00,0
00,1
00,2
00,3
00,4
A
01,0
·1,1
·1,2
·1,3
·1,4
C
02,0
·2,1
·2,2
·2,3
·2,4
B
03,0
·3,1
·3,2
·3,3
·3,4
D
04,0
·4,1
·4,2
·4,3
·4,4
当前处理已发现 / 已计算答案
LCS 长度
—

dp[i][j] 是「ACBD」前 i 个字符与「ABCD」前 j 个字符的最长公共子序列长度。第 0 行和第 0 列是空串,都为 0。

始终成立按行从左到右填:算 dp[i][j] 时,它依赖的上方、左方、左上方都已算好。

模板

选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 1143。

Python
def edit_distance(a: str, b: str) -> int:
    m, n = len(a), len(b)
    # dp[i][j]: edits to turn a[:i] into b[:j]; row 0 and column 0 are the empty prefixes
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(m + 1):
        dp[i][0] = i
    for j in range(n + 1):
        dp[0][j] = j
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if a[i - 1] == b[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]
            else:  # replace, delete from a, insert into a
                dp[i][j] = 1 + min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1])
    return dp[m][n]
C++
int editDistance(const string& a, const string& b) {
    int m = a.size(), n = b.size();
    // dp[i][j]: edits to turn a[0, i) into b[0, j); row 0 and column 0 are the empty prefixes
    vector<vector<int>> dp(m + 1, vector<int>(n + 1));
    for (int i = 0; i <= m; i++) dp[i][0] = i;
    for (int j = 0; j <= n; j++) dp[0][j] = j;
    for (int i = 1; i <= m; i++)
        for (int j = 1; j <= n; j++)
            dp[i][j] = a[i - 1] == b[j - 1]
                           ? dp[i - 1][j - 1]
                           : 1 + min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]});  // replace, delete, insert
    return dp[m][n];
}
Java
class Solution {
    int editDistance(String a, String b) {
        int m = a.length(), n = b.length();
        // dp[i][j]: edits to turn a[0, i) into b[0, j); row 0 and column 0 are the empty prefixes
        int[][] dp = new int[m + 1][n + 1];
        for (int i = 0; i <= m; i++) dp[i][0] = i;
        for (int j = 0; j <= n; j++) dp[0][j] = j;
        for (int i = 1; i <= m; i++)
            for (int j = 1; j <= n; j++)
                if (a.charAt(i - 1) == b.charAt(j - 1)) dp[i][j] = dp[i - 1][j - 1];
                else // replace, delete, insert
                    dp[i][j] = 1 + Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i][j - 1]));
        return dp[m][n];
    }
}
Go
func editDistance(a, b string) int {
	m, n := len(a), len(b)
	// dp[i][j]: edits to turn a[:i] into b[:j]; row 0 and column 0 are the empty prefixes
	dp := make([][]int, m+1)
	for i := range dp {
		dp[i] = make([]int, n+1)
		dp[i][0] = i
	}
	for j := range dp[0] {
		dp[0][j] = j
	}
	for i := 1; i <= m; i++ {
		for j := 1; j <= n; j++ {
			if a[i-1] == b[j-1] {
				dp[i][j] = dp[i-1][j-1]
			} else { // replace, delete, insert
				dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])
			}
		}
	}
	return dp[m][n]
}

常见变体

这一站的题还会用到下面 2 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。

单串切分:最后一个词是 s[j:i]时间 O(n × L × L),L 为最长单词 · 空间 O(n)

一个字符串能不能切成字典里的词、有几种切法:ok[i] 表示前缀 s[:i] 能切,枚举最后一个词的起点 j;只需看最长单词那么远,词典放进哈希集合。LC 139 单词拆分;LC 2707 字符串中的额外字符把 true/false 换成最少剩余字符。

Python
def word_break(s: str, words: list[str]) -> bool:
    """Can s be cut into dictionary words, each usable any number of times? (LC 139)"""
    vocab = set(words)
    longest = max(map(len, words), default=0)
    ok = [True] + [False] * len(s)  # ok[i]: the prefix s[:i] can be cut
    for i in range(1, len(s) + 1):
        for j in range(max(0, i - longest), i):  # the last word is s[j:i]
            if ok[j] and s[j:i] in vocab:
                ok[i] = True
                break
    return ok[len(s)]
C++
// Can s be cut into dictionary words, each usable any number of times? (LC 139)
bool wordBreak(const string& s, const vector<string>& words) {
    unordered_set<string> vocab(words.begin(), words.end());
    size_t longest = 0;
    for (auto& w : words) longest = max(longest, w.size());
    int n = s.size();
    vector<bool> ok(n + 1, false);  // ok[i]: the prefix s[0, i) can be cut
    ok[0] = true;
    for (int i = 1; i <= n; i++)
        for (int j = max(0, i - (int)longest); j < i && !ok[i]; j++)  // the last word is s[j, i)
            ok[i] = ok[j] && vocab.count(s.substr(j, i - j));
    return ok[n];
}
Java
class Solution {
    // Can s be cut into dictionary words, each usable any number of times? (LC 139)
    boolean wordBreak(String s, List<String> words) {
        Set<String> vocab = new HashSet<>(words);
        int longest = 0;
        for (String w : words) longest = Math.max(longest, w.length());
        int n = s.length();
        boolean[] ok = new boolean[n + 1]; // ok[i]: the prefix s[0, i) can be cut
        ok[0] = true;
        for (int i = 1; i <= n; i++)
            for (int j = Math.max(0, i - longest); j < i && !ok[i]; j++) // the last word is s[j, i)
                ok[i] = ok[j] && vocab.contains(s.substring(j, i));
        return ok[n];
    }
}
Go
// wordBreak reports whether s can be cut into dictionary words, each usable any number of times.
func wordBreak(s string, words []string) bool {
	vocab := map[string]bool{}
	longest := 0
	for _, w := range words {
		vocab[w] = true
		longest = max(longest, len(w))
	}
	ok := make([]bool, len(s)+1) // ok[i]: the prefix s[:i] can be cut
	ok[0] = true
	for i := 1; i <= len(s); i++ {
		for j := max(0, i-longest); j < i && !ok[i]; j++ { // the last word is s[j:i]
			ok[i] = ok[j] && vocab[s[j:i]]
		}
	}
	return ok[len(s)]
}
数子序列出现次数:倒序滚动一维时间 O(|s| × |t|) · 空间 O(|t|)

问 t 作为子序列在 s 里出现几次:dp[j] 是用目前读过的 s 凑出 t[:j] 的方法数,读到字符 c 时,所有 t[j-1] == c 的位置加上 dp[j-1]。j 要倒着走,否则同一个 c 会被用两次,和 0/1 背包倒序是一个道理。LC 115 不同的子序列。

Python
def num_distinct(s: str, t: str) -> int:
    """How many times t occurs in s as a subsequence (LC 115)."""
    dp = [1] + [0] * len(t)  # dp[j]: ways the part of s read so far forms t[:j]
    for c in s:
        for j in range(len(t), 0, -1):  # backwards: this c is used at most once per match
            if t[j - 1] == c:
                dp[j] += dp[j - 1]
    return dp[len(t)]
C++
// How many times t occurs in s as a subsequence (LC 115).
unsigned long long numDistinct(const string& s, const string& t) {
    vector<unsigned long long> dp(t.size() + 1, 0);  // dp[j]: ways the part of s read so far forms t[0, j)
    dp[0] = 1;
    for (char c : s)
        for (size_t j = t.size(); j >= 1; j--)  // backwards: this c is used at most once per match
            if (t[j - 1] == c) dp[j] += dp[j - 1];
    return dp[t.size()];
}
Java
class Solution {
    // How many times t occurs in s as a subsequence (LC 115).
    long numDistinct(String s, String t) {
        long[] dp = new long[t.length() + 1]; // dp[j]: ways the part of s read so far forms t[0, j)
        dp[0] = 1;
        for (char c : s.toCharArray())
            for (int j = t.length(); j >= 1; j--) // backwards: this c is used at most once per match
                if (t.charAt(j - 1) == c) dp[j] += dp[j - 1];
        return dp[t.length()];
    }
}
Go
// numDistinct returns how many times t occurs in s as a subsequence (LC 115).
func numDistinct(s, t string) int {
	dp := make([]int, len(t)+1) // dp[j]: ways the part of s read so far forms t[:j]
	dp[0] = 1
	for i := 0; i < len(s); i++ {
		for j := len(t); j >= 1; j-- { // backwards: this character is used at most once per match
			if t[j-1] == s[i] {
				dp[j] += dp[j-1]
			}
		}
	}
	return dp[len(t)]
}

为什么对

  1. 状态定义在前缀上:dp[i][j] 只关心 a 的前 i 个字符和 b 的前 j 个字符。任何编辑序列都能按「最后一个字符怎么处理」归入匹配、替换、删除、插入之一,所以转移覆盖了全部情况。
  2. 末尾字符相同时直接继承 dp[i−1][j−1] 是安全的:总存在一个最优方案让这两个字符对齐,对齐它们不会更差。
  3. 多出的一行一列代表空串,dp[i][0] = i、dp[0][j] = j 分别是全删和全插,边界因此统一。

30 秒口述稿

面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。

中文
两个字符串的 DP,我定义 dp[i][j] 为 a 的前 i 个字符和 b 的前 j 个字符上的答案,多开一行一列代表空串。以编辑距离为例:末尾字符相同就继承左上角,不同就在替换、删除、插入对应的左上、上、左三个格子里取最小再加一。任何编辑序列都能按最后一个字符怎么处理归到这几类,所以不会漏。最长公共子序列、通配符匹配也是这张表,只是转移不同,复杂度 O(m × n)。
English
For two-string DP I let dp[i][j] be the answer for the first i characters of a and the first j of b, with an extra row and column for the empty string. For edit distance, matching last characters inherit the diagonal, and otherwise I take one plus the minimum of the diagonal, upper and left cells for replace, delete and insert. Any sequence of edits falls into one of those cases by what it does with the last characters, so nothing is missed. Longest common subsequence and wildcard matching use the same table with other transitions, in O(m × n).

自评一下

对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。

对照要点(0/3)
这道题你会了吗?

小测

4 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习

4 道题
  1. 把 "abc" 变成 "yabd" 的编辑距离(插入、删除、替换各算一步)是?
  2. 求两个长度为 m、n 的字符串的最长公共子序列长度,二维 DP 需要 O(m·n) 空间。只要长度、不要具体子序列时,空间最少能压到多少?
  3. m = len(a),n = len(b)。时间和额外空间分别是?
    def lcs(a, b):
        m, n = len(a), len(b)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        for i in range(m):
            for j in range(n):
                if a[i] == b[j]:
                    dp[i + 1][j + 1] = dp[i][j] + 1
                else:
                    dp[i + 1][j + 1] = max(dp[i][j + 1], dp[i + 1][j])
        return dp[m][n]
  4. n = len(s),把整数加法看作 O(1)。时间复杂度是?
    from functools import lru_cache
    
    def count_decodings(s):
        @lru_cache(maxsize=None)
        def go(t):
            if not t:
                return 1
            ways = go(t[1:]) if t[0] != '0' else 0
            if len(t) >= 2 and '10' <= t[:2] <= '26':
                ways += go(t[2:])
            return ways
    
        return go(s)

作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。

模板练习

用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。

下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。

def edit_distance(a: str, b: str) -> int:
    return dp[m][n]

分档练习

本站在 500 路里的 10 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。

核心 7

#题目一句话思路难度分
1143最长公共子序列掌握双前缀状态及匹配、跳过两类转移,并能恢复一条公共子序列。
1035不相交的线把不相交连线解释为公共子序列,训练把图形叙述转换为序列约束。难度分 1806
72编辑距离分别建模插入、删除和替换,清楚说明两个下标的含义。
583两个字符串的删除操作限制为删除操作后推导转移,并与LCS长度公式交叉验证。
97交错字符串由两个已消费前缀确定第三个位置,避免遇到相同字符时错误贪心。
139单词拆分将字典分词建模为前缀可达性,说明词可复用以及无法分割的情况。
2707字符串中的额外字符在分词模型中允许付费跳过字符,从布尔可达升级为最小损失。难度分 1736

进阶 3

#题目一句话思路难度分
712两个字符串的最小ASCII删除和把操作次数推广为字符权重,处理空前缀的累计删除费用。
115不同的子序列对相同字符区分使用和跳过,统计下标选择而不是去重后的字符串。
1092最短公共超序列在最优长度之外重建实际字符串,处理分支选择及剩余前缀。难度分 1977