双数组DP:算法刷题必备技巧

简介

双数组 DP(也常叫「二维 DP / 两个序列 DP」)是算法刷题的高频重点,核心场景是处理两个数组 / 字符串之间的关联问题(比如匹配、最长公共子序列、编辑距离等)。这类问题的核心特征是:状态定义会同时依赖两个数组的下标,转移逻辑需要考虑两个数组当前元素的匹配 / 选择情况。

1. 问题场景(什么时候用?)

当题目同时涉及两个独立的数组 / 字符串,且需要求它们之间的「最优匹配、最长公共部分、最小操作数」等目标时,优先考虑双数组 DP:

  • 例 1:求两个字符串的最长公共子序列(LCS);
  • 例 2:将字符串 A 转换成字符串 B 的最小编辑距离;
  • 例 3:判断数组 A 是否是数组 B 的子序列;
  • 例 4:两个数组的最长公共子数组(连续)。

2. 状态定义的通用模板

双数组 DP 的状态定义几乎都是「二维 DP 表」,核心格式:

dp[i][j]:表示「数组 A 的前 i 个元素」和「数组 B 的前 j 个元素」满足题目要求的最优值(长度 / 次数 / 是否可行)。

  • 比如 dp[i][j] 可以是:
    • 数组 A 前 i 个、数组 B 前 j 个的最长公共子序列长度;
    • 把 A 前 i 个转换成 B 前 j 个的最小操作数;
    • A 前 i 个是否是 B 前 j 个的子序列(布尔值)。
  • 下标说明:通常「前 i 个元素」对应数组下标 0~i-1(方便处理空串 / 空数组的边界)。

3. 核心解题步骤

  1. 初始化 DP 表:处理「空数组 / 空字符串」的边界情况(如 dp[0][j]dp[i][0]);
  2. 状态转移:遍历两个数组的下标 i/j,根据「A [i-1] 和 B [j-1] 是否相等 / 匹配」推导 dp[i][j]
  3. 返回结果:通常是 dp[n][m](n 是 A 的长度,m 是 B 的长度)。

最长公共子序列

1143. 最长公共子序列 - 力扣(LeetCode)

根据经验+题目要求

有两个字符串,当然是有两个状态表示,比如dp[i]:表示[0][i]区间作为研究对象

那这样两个合并dp[i][j]:表示s1以[0][i],s2[0][j]

当s[i]!=s[j]的时候,不需要考虑dp[i-1][j-1]了,因为其他两种情况已经包含了这种情况了,所以可以优化不考虑,但是这里是求长度所以可以不考虑,如果是求个数呢???

对于字符串的dp问题,空串是有研究意义的,比如求个数的时候,空串也是一个

但是这里是求长度,空串就没有长度

dp[0][0]:已经表示一个字符了,那空串如何表示

我们初始化的时候多加一行多加一列,这一行一列就表示空串

0行表示第一个字符串的空串

0列表示第二个字符串的空串

然后下标映射成(0,0)->(1,1)

这样还不会越界,并且方便我们初始化,初始化为0即可

需要注意的是下标映射还可以进一步优化,可以通过添加一个不影响字符串的特殊字符来实现正确的下标映射

引入空串方便初始化,但是要注意下标映射关系

由于额外增加了一行和一列,最终结果应返回dp[m][n]

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        int m = text1.size();
        int n = text2.size();
        // 多加一行多加一列
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        // 处理下标映射
        text1 = ' ' + text1;
        text2 = ' ' + text2;
        // 从左往右从上往下
        for (int i = 1; i < m + 1; i++) {
            for (int j = 1; j < n + 1; j++) {
                if (text1[i] == text2[j]) {
                    // 如果相等
                    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];
    }
};

不相交的线

1035. 不相交的线 - 力扣(LeetCode)

本质来说和最长公共子序列是一样的,也是寻找最长公共子序列

你想一想a:[2,3,4,5]  b:[2,1,3,5,6]

那这两个数组的最长公共子序列[2,3,5]

是不是刚好三条线,当你是最长公共子序列的适合,上下上下是一一对应的,条数最多的,因为你最长公共子序列是不可能相交的,所以这道题的代码部分和最长公共子序列一样

class Solution {
public:
    int maxUncrossedLines(vector<int>& nums1, vector<int>& nums2) {
        int m = nums1.size();
        int n = nums2.size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        for (int i = 1; i < m + 1; i++) {
            for (int j = 1; j < n + 1; j++) {
                if (nums1[i - 1] == nums2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                } else {
                    dp[i][j] = max(dp[i][j - 1], dp[i - 1][j]);
                }
            }
        }
        return dp[m][n];
    }
};

不同的子序列

115. 不同的子序列 - 力扣(LeetCode)

和之前的分析一样

dp[i][j]:表示s串的区间[0][i]的子序列有多少个符合t[0][j]区间的子串

这里的意思就是s串中的某个区间的所有子序列,有多少个符合t的某个的整个区间,注意不是t的子序列

状态转移方程:这里我们需要分析是否包含s[i]即可

当包含s[i]的时候一定是s[i]==s[j],当不包含的时候仅仅+dp[i-1][j]

class Solution {
public:
    int numDistinct(string s, string t) {
        int m = s.size();
        int n = t.size();
        s = " " + s;
        t = " " + t;
        vector<vector<double>> dp(m + 1, vector<double>(n + 1));
        for (int i = 0; i < m + 1; i++) {
            dp[i][0] = 1;
        }
        for (int i = 1; i < m + 1; i++) {
            for (int j = 1; j < n + 1; j++) {
                dp[i][j] += dp[i - 1][j];
                if (s[i] == t[j]) {
                    dp[i][j] += dp[i - 1][j - 1];
                }
            }
        }
        return dp[m][n];
    }
};

通配符匹配

44. 通配符匹配 - 力扣(LeetCode)

本题和之前一样去设状态表示

dp[i][j]:表示p区间[0,i]能否匹配s区间[0][j];

状态转移方程:有四种情况

1:p[i]=='*'的时候,因为*可以匹配任意的序列,可以是空,可以是一个可以是两个

那dp[i][j]=dp[i-1][0~j]中寻找有一个true即可

即dp[i][j]=dp[i-1][j] || dp[i-1][j-1] || dp[i-1][j-2]……

2:当p[i]=='?'时,dp[i][j]=dp[i-1][j-1]

3:当p[i]==s[j]时,dp[i][j]=dp[i-1][j-1]

4:当p[i]==s[j]时,dp[i][j]=false即可

综上,我们把23情况合并,4不需要管,只需要一开始初始化为false

那第一种情况是否可以优化呢??

如果不优化就是O(n^3)

优化思路

一:数学表达式优化

dp[i][j]=dp[i-1][j] || dp[i-1][j-1] || dp[i-1][j-2]……

dp[i][j-1]=dp[i-1][j-1] || dp[i-1][j-2] || dp[i-1][j-3]……

用红色部分代替红色部分

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

二:根据实际意义优化

注意图中的i和j和前面的状态是反过来的,但是意思是一个意思

如果这个字符是'*'

我们可以这次干掉一个,但是不删掉*,让他去上一层

dp[i][j-1]这一层次看看还需要不需要删掉s当中的字符

换言之:就是我们是星号的时候匹配s的最后一个字符,但是我们不删掉星号,让这个星号去接着匹配前面的j-1,因为星号是一个特殊的,即可以匹配0个也可以匹配多个

初始化:

根据实际意义,我们添加一行一列代表空串,很明显dp[0][0]的位置是true

第一行表示p为空串,s为有字符的,那就需要初始化为false

第一列表示s为空串,p为有字符的,但是这里如果p的字符是'*',则为true,因为*可以匹配0个,当出现?或者某个字符时,则为false

填表顺序:从左到右,从上到下

返回值:返回dp[m][n]

class Solution {
public:
    bool isMatch(string s, string p) {
        int m = p.size();
        int n = s.size();
        vector<vector<bool>> dp(m + 1, vector<bool>(n + 1));
        dp[0][0] = true;
        p = " " + p;
        s = " " + s;
        // 预处理第一列
        for (int i = 1; i <= m; i++) {
            if (p[i] == '*') {
                dp[i][0] = dp[i - 1][0]; // *匹配0个字符,继承前i-1位的结果
            } else {
                break;
            }
        }
        for (int i = 1; i < m + 1; i++) {
            for (int j = 1; j < n + 1; j++) {
                if (p[i] == s[j] || p[i] == '?') {
                    dp[i][j] = dp[i - 1][j - 1];
                } else if (p[i] == '*') {
                    dp[i][j] = dp[i - 1][j] || dp[i][j - 1];
                }
            }
        }
        return dp[m][n];
    }
};

正则表达式匹配

10. 正则表达式匹配 - 力扣(LeetCode)

有时候可以根据实际意义去想会非常快(前提是你掌握了实际意义)

这样你分析* 的前面是普通字符的时候,

空串就是看dp[i][j-2]是否为true,其余的时候采用实际意义去想,也就是保留的思想

那就是p[j-1]==s[i]&&dp[i-1][j],干掉一个字符还要保留*

class Solution {
public:
    bool isMatch(string s, string p) {
        int m = s.size();
        int n = p.size();
        s = ' ' + s;
        p = ' ' + p;
        vector<vector<bool>> dp(m + 1, vector<bool>(n + 1));
        dp[0][0] = true;
        for (int j = 2; j < n + 1; j += 2)
            if (p[j] == '*')
                dp[0][j] = true;
            else
                break;
        for (int i = 1; i < m + 1; i++) {
            for (int j = 1; j < n + 1; j++) {
                if (p[j] == '*') {
                    dp[i][j] =
                        dp[i][j - 2] ||
                        (p[j - 1] == '.' || p[j - 1] == s[i]) && dp[i - 1][j];
                } else {
                    dp[i][j] =
                        dp[i - 1][j - 1] && (p[j] == '.' || s[i] == p[j]);
                }
            }
        }
        return dp[m][n];
    }
};

为什么要预处理,是为了方便我们计算s3的下标,状态表示的核心就是我们可以根据s1和s2的长度去确定s3的长度,所以仅仅需要两个状态表示即可

class Solution {
public:
    bool isInterleave(string s1, string s2, string s3) {
        int m = s1.size();
        int n = s2.size();
        if (m + n != s3.size()) {
            return false;
        }
        vector<vector<bool>> dp(m + 1, vector<bool>(n + 1));
        s1 = " " + s1;
        s2 = " " + s2;
        s3 = " " + s3;
        dp[0][0] = true;
        for (int j = 1; j < n + 1; j++) {
            if (s2[j] == s3[j]) {
                dp[0][j] = true;
            } else {
                break;
            }
        }
        for (int i = 1; i < m + 1; i++) {
            if (s1[i] == s3[i]) {
                dp[i][0] = true;
            } else {
                break;
            }
        }
        for (int i = 1; i < m + 1; i++) {
            for (int j = 1; j < n + 1; j++) {
                if (s1[i] == s3[i + j] && dp[i - 1][j]) {
                    dp[i][j] = true;
                } else if (s2[j] == s3[i + j] && dp[i][j - 1]) {
                    dp[i][j] = true;
                }
            }
        }
        return dp[m][n];
    }
};

两个字符串的最小ASCII删除和

712. 两个字符串的最小ASCII删除和 - 力扣(LeetCode)

前面做了这么多道题,这道题比起来就是简单

只要定义好状态,根据s1[i]和s2[j]是否相等去推状态转移方程即可

class Solution {
public:
    int minimumDeleteSum(string s1, string s2) {
        int m = s1.size();
        int n = s2.size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        s1 = " " + s1;
        s2 = " " + s2;
        // 初始化:s1为空,需删除s2前j个所有字符(累加ASCII)
        for (int j = 1; j <= n; j++) {
            dp[0][j] = dp[0][j - 1] + (int)s2[j];
        }

        // 初始化:s2为空,需删除s1前i个所有字符(累加ASCII)
        for (int i = 1; i <= m; i++) {
            dp[i][0] = dp[i - 1][0] + (int)s1[i];
        }
        for (int i = 1; i < m + 1; i++) {
            for (int j = 1; j < n + 1; j++) {
                if (s1[i] == s2[j]) {
                    dp[i][j] = dp[i - 1][j - 1];
                } else {
                    int ret1 = dp[i][j - 1] + (s2[j] - 0);
                    int ret2 = dp[i - 1][j] + (s1[i] - 0);
                    dp[i][j] = min(ret1, ret2);
                }
            }
        }
        return dp[m][n];
    }
};

最长重复子数组

718. 最长重复子数组 - 力扣(LeetCode)

本题是子数组问题,那就是连续的,注意和子序列做区分

这里不能以区间【0,i】研究问题,区间的意思就是你在这个区间当中所有的子数组当中找到一个最长的重复的子数组

但是你填写i+1位置的时候,由于连续,你必须接在i位置后面,但由于前面找的是区间,不一定是以i位置为结尾的

所以这里定义状态表示应该是以某个位置结尾

dp[i][j]:表示以s1以i位置结尾,s2以j位置结尾的所有子数组的最长重复子数组

那这样很明显返回值就是dp表当中最大的

所以想说的是:子序列为什么可以采用区间,子数组不可能,子序列说明当前位置可以跟在前面任意元素的后面,但是子数组不行,子数组只能跟在前面一个元素的后面

class Solution {
public:
    int findLength(vector<int>& nums1, vector<int>& nums2) {
        int m = nums1.size();
        int n = nums2.size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        int ret = 0;
        for (int i = 1; i < m + 1; i++) {
            for (int j = 1; j < n + 1; j++) {
                if (nums1[i - 1] == nums2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                }
                ret = max(ret, dp[i][j]);
            }
        }
        return ret;
    }
};

总结

所有双数组 DP 问题都遵循这 4 步,差异仅在「状态定义」和「转移逻辑」,这是解题的核心骨架:

1. 状态定义(最关键!)

统一模板:dp[i][j] 表示「数组 / 字符串 A 的前 i 个元素」和「数组 / 字符串 B 的前 j 个元素」满足题目要求的最优值 / 可行性 / 数量

  • 最优值:最小删除和、最小编辑距离、最长公共子序列长度;
  • 可行性:通配符匹配(true/false)、交错字符串(true/false);
  • 数量:公共子序列个数(扩展题型)。

💡 关键技巧:

  • 几乎所有场景都用「1-based 下标」(给字符串 / 数组前加空字符 / 空元素),避免处理「前 0 个元素」的边界判断;
  • 比如 s1 = " " + s1,则 dp[i][j] 对应 s1[1..i]s2[1..j]

2.总结

  • 双数组 DP 的核心是「状态定义」:明确dp[i][j]的含义,后续初始化和转移都是围绕这个定义展开;
  • 1-based 下标是通用技巧:给字符串 / 数组前加空占位符,简化边界处理;
  • 转移逻辑的本质是「选择」:匹配则继承前序结果,不匹配则选择最优操作(删 / 插 / 换 / 匹配);
  • 空间优化是加分项:二维 DP 表可压缩为一维,核心是保存「上一行前一列」的状态。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值