1. 引言:为什么关注两个数组的DP

在算法竞赛和面试中,涉及两个序列(字符串、数组)的动态规划问题是一类极其经典且高频的题型。它们的核心思想是将两个序列的匹配、对齐、转换等操作,通过一个二维的状态表进行递推。

这类问题的魅力在于:

  • 高度抽象:将现实中的“编辑文本”、“比较基因序列”、“匹配URL”等问题抽象为数学上的序列操作。

  • 结构清晰:通常状态定义 dp[i][j] 表示第一个数组的前 i 个元素和第二个数组的前 j 个元素之间的关系。

  • 难度梯度明显:从简单的 LCS(最长公共子序列)到复杂的正则匹配,覆盖了从入门到困难的各个层次。

掌握好这一类问题,不仅能在面试中应对自如,更能深刻理解动态规划中“状态空间”和“决策过程”的核心思想。


2. 动态规划基础回顾

在深入双数组DP之前,我们有必要回顾动态规划的核心思想。

2.1 DP三要素

  1. 状态 (State):定义子问题的解。对于双数组问题,标准状态是 dp[i][j],通常表示:

    • nums1[0..i-1] 和 nums2[0..j-1] 的某种性质。

    • 注意:我们通常使用 i 表示长度(前 i 个),而不是索引,这有助于处理空字符串的情况。

  2. 转移方程 (Transition):描述如何从较小的子问题推导出当前问题。通常基于 nums1[i-1] 和 nums2[j-1] 是否相等,或者进行某种操作。

  3. 边界条件 (Base Case):通常涉及一个数组为空的情况,即 dp[0][j] 和 dp[i][0]

2.2 二维DP表的构建哲学

对于两个数组的问题,我们往往将第一个数组放在行方向,第二个数组放在列方向,形成一个二维表格。

  • 行 (i):处理第一个数组的前缀。

  • 列 (j):处理第二个数组的前缀。

  • 目标:通常最终答案是 dp[m][n]

思考路径:当我们站在 (i, j) 这个格子时,我们只关心“当前这对元素”的匹配情况,以及“之前”已经计算好的前缀状态。


3. 经典模型一:最长公共子序列 (LCS)

最长公共子序列(Longest Common Subsequence)是双数组DP的“Hello World”。

3.1 问题定义与直觉

问题:给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。如果不存在公共子序列,返回 0。

  • 子序列:不要求连续,但相对顺序不变。

  • 与子串的区别:子串要求连续。

直觉:我们尝试逐个字符匹配。如果两个字符相等,那么它肯定可以成为公共子序列的一部分。如果不相等,我们有两种选择:要么跳过 text1 的当前字符,要么跳过 text2 的当前字符。

3.2 状态定义与转移方程

定义
dp[i][j] 表示 text1[0..i-1] 和 text2[0..j-1] 的最长公共子序列的长度。

转移方程

  1. 如果 text1[i-1] == text2[j-1]
    这两个字符可以配对。那么 dp[i][j] = dp[i-1][j-1] + 1
    解释:我们在两个字符串都去掉最后一个字符的最优解上,加上这个相等的字符。

  2. 如果 text1[i-1] != text2[j-1]
    我们无法同时使用这两个字符。我们需要看看去掉 text1 的最后一个字符,或者去掉 text2 的最后一个字符,哪个能获得更大的LCS。
    dp[i][j] = max(dp[i-1][j], dp[i][j-1])

边界条件
dp[0][j] = 0dp[i][0] = 0。因为只要有一个字符串为空,公共子序列长度就是 0。

3.3 代码实现与空间优化

基础实现(二维)

python

def longestCommonSubsequence(text1: str, text2: str) -> int:
    m, n = len(text1), len(text2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if text1[i-1] == text2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[m][n]

空间优化(滚动数组)
观察转移方程,dp[i][j] 只依赖于 dp[i-1][j-1]dp[i-1][j] 和 dp[i][j-1]。也就是说,计算当前行 i 只需要上一行 i-1 和当前行 i 的前一个元素。因此我们可以将空间复杂度从 O(m*n) 优化到 O(min(m, n))

python

def longestCommonSubsequence(text1: str, text2: str) -> int:
    if len(text1) < len(text2):
        text1, text2 = text2, text1  # 确保短的作为列,减少空间
    m, n = len(text1), len(text2)
    dp = [0] * (n + 1)
    
    for i in range(1, m + 1):
        prev = 0  # 相当于 dp[i-1][j-1]
        for j in range(1, n + 1):
            temp = dp[j]  # 保存旧的 dp[j] (即 dp[i-1][j]) 以便下一轮使用
            if text1[i-1] == text2[j-1]:
                dp[j] = prev + 1
            else:
                dp[j] = max(dp[j], dp[j-1])  # dp[j] 是旧的 (i-1, j), dp[j-1] 是新的 (i, j-1)
            prev = temp
    return dp[n]

3.4 打印所有LCS

要打印出具体的LCS字符串,我们需要回溯。通常我们需要一个 dp 表,从 (m, n) 反向走到 (0, 0)

  • 如果 text1[i-1] == text2[j-1],则该字符属于LCS,加入结果,向 (i-1, j-1) 移动。

  • 否则,比较 dp[i-1][j] 和 dp[i][j-1],向较大的方向移动。如果相等,说明存在多条路径,可以分别处理。

3.5 变体:最长公共子串

问题:最长公共子串要求连续。

状态定义dp[i][j] 表示以 text1[i-1] 和 text2[j-1] 结尾的最长公共子串的长度。

转移方程

  • 如果 text1[i-1] == text2[j-1],则 dp[i][j] = dp[i-1][j-1] + 1

  • 如果不相等,则 dp[i][j] = 0

  • 答案:遍历过程中的最大值。

注意:这里 dp[i][j] 的定义与LCS不同,它强制要求以当前字符结尾,因此状态转移更直接,但最终答案不是 dp[m][n],而是最大值。


4. 经典模型二:编辑距离 (Levenshtein Distance)

编辑距离是衡量两个字符串相似度的经典算法,广泛应用于拼写检查、DNA序列比对等场景。

4.1 问题定义与操作

问题:给你两个单词 word1 和 word2,请返回将 word1 转换成 word2 所使用的最少操作数。你可以对一个单词进行如下三种操作:

  1. 插入一个字符

  2. 删除一个字符

  3. 替换一个字符

4.2 状态设计与转移

状态定义
dp[i][j] 表示将 word1 的前 i 个字符转换成 word2 的前 j 个字符所需的最少操作次数。

边界条件

  • dp[0][j] = j:将空串转为 word2 前 j 个字符,需要插入 j 次。

  • dp[i][0] = i:将 word1 前 i 个字符转为空串,需要删除 i 次。

转移方程(考虑 word1[i-1] 和 word2[j-1]):

  1. 如果 word1[i-1] == word2[j-1]
    不需要任何操作,直接继承之前的距离。
    dp[i][j] = dp[i-1][j-1]

  2. 如果 word1[i-1] != word2[j-1]
    我们可以执行三种操作,取最小值:

    • 删除:删除 word1[i-1],然后让 word1[0..i-2] 去匹配 word2[0..j-1],代价是 dp[i-1][j] + 1

    • 插入:在 word1 中插入一个字符等于 word2[j-1],然后让 word1[0..i-1] 去匹配 word2[0..j-2],代价是 dp[i][j-1] + 1

    • 替换:将 word1[i-1] 替换为 word2[j-1],代价是 dp[i-1][j-1] + 1

    所以:
    dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1

4.3 代码实现与理解

python

def minDistance(word1: str, word2: str) -> int:
    m, n = len(word1), len(word2)
    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 word1[i-1] == word2[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
    return dp[m][n]

理解:编辑距离表是一个典型的“编辑图”。每一步决策都试图最小化操作次数。

4.4 变体:只有插入和删除、一次编辑距离

  • 只有插入和删除:实际上,如果只有插入和删除,那么替换操作等价于一次删除加一次插入,代价为2。但如果我们允许替换代价为1,则问题退化回编辑距离。如果操作只有插入和删除(比如在DNA比对中,替换视为删除+插入),我们修改转移方程即可。

  • 一次编辑距离:判断两个字符串是否只需要一次操作就能相等。可以利用双指针线性扫描,复杂度 O(n),不需要完整DP。


5. 经典模型三:交错字符串 (Interleaving String)

交错字符串问题考察的是对字符串顺序的严格匹配。

5.1 问题定义

给定三个字符串 s1s2s3,验证 s3 是否由 s1 和 s2 交错组成。

交错的定义s3 的字符顺序必须保持 s1 和 s2 各自的相对顺序,且 s3 恰好包含 s1 和 s2 的所有字符。

状态定义
dp[i][j] 表示 s1 的前 i 个字符和 s2 的前 j 个字符能否交错组成 s3 的前 i+j 个字符。

转移方程
dp[i][j] = (dp[i-1][j] and s1[i-1] == s3[i+j-1]) or (dp[i][j-1] and s2[j-1] == s3[i+j-1])

  • 边界dp[0][0] = True

  • dp[i][0]:只由 s1 匹配 s3,需检查 s1[0..i-1] == s3[0..i-1]

  • dp[0][j]:同理。

优化:可以使用一维DP,因为 dp[i][j] 只依赖于 dp[i-1][j] 和 dp[i][j-1]

python

def isInterleave(s1: str, s2: str, s3: str) -> bool:
    if len(s1) + len(s2) != len(s3):
        return False
    m, n = len(s1), len(s2)
    dp = [False] * (n + 1)
    dp[0] = True
    for j in range(1, n + 1):
        dp[j] = dp[j-1] and s2[j-1] == s3[j-1]
    for i in range(1, m + 1):
        dp[0] = dp[0] and s1[i-1] == s3[i-1]
        for j in range(1, n + 1):
            dp[j] = (dp[j] and s1[i-1] == s3[i+j-1]) or (dp[j-1] and s2[j-1] == s3[i+j-1])
    return dp[n]

6. 经典模型四:正则表达式与通配符匹配

这两道题是双数组DP中难度较高的代表,难点在于处理 * 的零次或多次匹配。

6.1 通配符匹配 (Wildcard Matching)

问题:实现支持 ? 和 * 的通配符匹配。

  • ? 匹配任意单个字符。

  • * 匹配任意字符序列(包括空序列)。

状态定义
dp[i][j] 表示 s 的前 i 个字符与 p 的前 j 个字符是否匹配。

转移方程

  1. 如果 p[j-1] == '?' 或 p[j-1] == s[i-1]
    dp[i][j] = dp[i-1][j-1]

  2. 如果 p[j-1] == '*'

    • * 匹配空序列:dp[i][j-1]

    • * 匹配至少一个字符:dp[i-1][j]
      所以 dp[i][j] = dp[i][j-1] or dp[i-1][j]

边界

  • dp[0][0] = True

  • dp[0][j]:只有当 p[0..j-1] 全是 * 时才为 True。

  • dp[i][0] = False for i > 0。

6.2 正则表达式匹配 (Regular Expression Matching)

问题:实现支持 . 和 * 的正则匹配。

  • . 匹配任意单个字符。

  • * 匹配前面那个字符的零次或多次出现。

区别:这里的 * 是依附于前一个字符的,复杂度更高。

状态定义
dp[i][j] 表示 s 的前 i 个字符与 p 的前 j 个字符是否匹配。

转移方程

  1. 如果 p[j-1] 是普通字符或 .,且 s[i-1] == p[j-1] 或 p[j-1] == '.'
    dp[i][j] = dp[i-1][j-1]

  2. 如果 p[j-1] == '*'
    我们需要看 p[j-2] 是什么。

    • 匹配零次:忽略 p[j-2] 和 *,即 dp[i][j-2]

    • 匹配一次或多次:前提是 s[i-1] 与 p[j-2] 匹配(p[j-2] == '.' 或 p[j-2] == s[i-1]),那么 dp[i-1][j](保持模式中的 *,消耗一个字符)。

    所以:
    dp[i][j] = dp[i][j-2] or ( (s[i-1] == p[j-2] or p[j-2] == '.') and dp[i-1][j] )

边界

  • dp[0][0] = True

  • dp[0][j]:需要处理 a*b*c* 这样的模式,即偶数位为 * 时可以匹配空。

6.3 难点:* 的零次或多次匹配

理解 * 的转移是正则匹配的关键。dp[i][j] 依赖 dp[i][j-2](零次)和 dp[i-1][j](多次)。这种依赖关系使得填表顺序至关重要:i 从小到大,j 从小到大,且 j 的循环中需要能访问到 dp[i][j-2]


7. 经典模型五:两个数组的最大路径和 / 最小路径和

虽然严格来说是一个二维网格问题,但可以看作是第一个数组(行索引)和第二个数组(列索引)的组合。

7.1 网格路径问题(二维)

问题:给定一个 m x n 的网格,每个格子有一个非负整数,找一条从左上角到右下角的路径,使得路径上的数字总和最小(或最大)。每次只能向下或向右移动。

状态dp[i][j] 表示从 (0,0) 到 (i,j) 的最小路径和。

转移dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1])

这本质上是将两个“数组”(行和列)组合起来。

7.2 带障碍物的路径

如果网格中有障碍物,dp[i][j] = 0 如果 grid[i][j] == 1,否则转移同上。

7.3 两个数组的独特路径转化为网格

有时候问题看似是两个数组,实际上是网格上的路径问题。例如,给定两个数组,每次可以从一个数组取头元素,问取完所有元素的顺序有多少种。这可以映射为网格路径计数问题。


8. 高级技巧与优化

8.1 空间优化:滚动数组

绝大多数双数组DP问题,状态转移只依赖于当前行和上一行(或者上一行和当前行的前一列)。因此我们可以将 dp[m+1][n+1] 优化为 dp[2][n+1] 或 dp[n+1]

  • LCS:依赖 i-1,j-1(左上),i-1,j(上),i,j-1(左)。使用一维数组时,需要用一个变量保存左上角的值。

  • 编辑距离:同样可以压缩为一维,注意覆盖顺序。

8.2 时间优化:四边形不等式、单调队列

对于某些特定形式的DP(如 dp[i][j] = min(dp[i-1][k] + cost(k, j))),可以利用四边形不等式进行决策单调性优化,将 O(n^3) 降到 O(n^2)。但在双数组标准模型中较少见。

8.3 位运算优化 (Bitset LCS)

对于字符集较小(如DNA序列,只有4种字符)且长度较长的LCS问题,可以使用位运算进行加速。通过将每一行状态表示为位集,利用位运算实现快速转移,复杂度可以降到 O(m * n / word_size),如使用 bitset

8.4 记忆化搜索 vs 迭代DP

对于复杂的状态转移(如正则匹配),记忆化搜索(递归+备忘录)通常更容易理解和实现,且不需要考虑填表顺序。缺点是递归栈开销。迭代DP性能更好,但需要谨慎处理边界和循环顺序。


9. 实战训练:LeetCode 经典题精讲

9.1 基础篇:718. 最长重复子数组

问题:给两个整数数组,求最长公共子数组(连续)的长度。

分析:与最长公共子串相同,但元素是整数。

状态dp[i][j] 表示以 A[i-1] 和 B[j-1] 结尾的最长公共子数组长度。

转移

  • 如果 A[i-1] == B[j-1]dp[i][j] = dp[i-1][j-1] + 1

  • 否则 dp[i][j] = 0

  • 答案:max(dp)

复杂度O(m*n),空间可优化为一维。

9.2 中等篇:1143. 最长公共子序列

见第3章,这是LCS的标准题。

9.3 进阶篇:72. 编辑距离

见第4章。

9.4 复杂篇:10. 正则表达式匹配

见第6.2节。

关键点:处理 * 时的零次和多次匹配。注意初始化 dp[0][j] 对于模式中 * 的处理。

python

def isMatch(s: str, p: str) -> bool:
    m, n = len(s), len(p)
    dp = [[False] * (n + 1) for _ in range(m + 1)]
    dp[0][0] = True
    # 初始化空串与模式匹配的情况
    for j in range(1, n + 1):
        if p[j-1] == '*':
            dp[0][j] = dp[0][j-2]
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if p[j-1] == '.' or p[j-1] == s[i-1]:
                dp[i][j] = dp[i-1][j-1]
            elif p[j-1] == '*':
                dp[i][j] = dp[i][j-2]  # 匹配零次
                if p[j-2] == '.' or p[j-2] == s[i-1]:
                    dp[i][j] = dp[i][j] or dp[i-1][j]  # 匹配一次或多次
    return dp[m][n]

9.5 其他:97. 交错字符串、44. 通配符匹配

交错字符串(第5章)和通配符匹配(第6.1章)都是常见考题。


10. 思维框架:如何攻克双数组DP

当你在面试或竞赛中遇到双数组DP问题时,可以按照以下步骤思考:

10.1 识别模型

  • 比较/匹配类:LCS、编辑距离、正则匹配。

  • 路径类:网格路径、交错字符串。

  • 子数组类:最长公共子串、最大子段和(但扩展到两个数组)。

10.2 状态定义五步法

  1. 前缀思想:几乎总是 dp[i][j] 表示第一个数组的前 i 个元素和第二个数组的前 j 个元素的结果。

  2. 明确含义:是“长度”、“是否可行”、“最小值”还是“最大值”?

  3. 结尾与否:是否强制以当前元素结尾?对于子数组问题需要,对于子序列不需要。

  4. 维度确认:二维通常足够。如果涉及更复杂的约束,可能需要三维(如同时考虑字符位置和匹配状态)。

  5. 目标位置:最终答案通常是 dp[m][n],也可能是遍历中的最大值。

10.3 边界条件与初始化

  • dp[0][j]:第一个数组为空时的处理。

  • dp[i][0]:第二个数组为空时的处理。

  • dp[0][0]:基础情况。

常见初始化:

  • LCS:全0。

  • 编辑距离:dp[i][0]=i, dp[0][j]=j

  • 正则匹配:需要处理 * 匹配空的情况。

10.4 转移的依赖关系

  • 相等情况:通常依赖 dp[i-1][j-1]

  • 不等情况:可能依赖 dp[i-1][j]dp[i][j-1],或进行替换、插入、删除操作。

  • * 情况:依赖 dp[i][j-2] 和 dp[i-1][j],依赖方向需要注意。

填表顺序

  • 通常 i 从 1 到 mj 从 1 到 n

  • 如果转移依赖 dp[i][j-2]j 必须从小到大,保证 j-2 已计算。

  • 如果依赖 dp[i-1][j]i 必须从小到大。

10.5 优化与实现

  • 先写出二维DP,确保正确性。

  • 再根据依赖关系尝试空间优化为滚动数组。

  • 如果时间复杂度过高,考虑是否有特殊性质(如字符集小、位运算优化)。


11. 总结与拓展

两个数组的动态规划问题是算法世界的瑰宝。从基础的 LCS 到复杂的正则匹配,它们体现了动态规划处理序列问题的通用框架。

掌握这些模型的意义

  1. 面试必备:几乎所有大厂的算法面试都会涉及至少一道双数组DP题。

  2. 思维训练:培养将复杂问题分解为子问题、定义状态、寻找递推关系的能力。

  3. 现实应用:文本差异比较(Git diff)、生物信息学(基因序列比对)、自然语言处理(句子相似度)等领域都基于这些算法。

拓展方向

  • 三维DP:当涉及两个数组外加一个状态(如匹配次数、剩余能量)时,需要三维状态。

  • KMP优化:对于某些特定模式匹配的DP,可以结合KMP自动机优化。

  • 分治优化:对于某些DP,可以用分治法(如Divide and Conquer DP Optimization)优化。

  • 非序列双数组:有时两个数组并不是字符串,而是具有数值或特殊结构,但思想相通。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐