接下来的规划:
- 看:#2.7 + #2.9 看完;【子序列模板:最长回文子序列】 【进阶/最终篇:最小代价构造回文串】
- 敲:①【详解最长公共子序列问题】 ②【编辑距离问题】③【实践:回文子序列】
3.看:#2.8【答疑:状态压缩技巧】- 看+敲:一系列经典问题: 【背包问题】 【贪心类型问题】 【其它经典问题】
- #2.10 2.11—— 【进阶问题…】 #2.18 2.19——答疑【动态规划和回溯到底谁是王道】
要求:以上 1/2/4 必先完成。
当然这也不是动态规划问题,旨在说明,最优子结构并不是动态规划独有的一种性质,能求最值的问题大部分都具有这个性质;但反过来,最优子结构性质作为动态规划问题的必要条件,一定是让你求最值的,以后碰到那种恶心人的最值题,思路往动态规划想就对了,这就是套路。
动态规划不就是从最简单的 base case 往后推导吗,可以想象成一个链式反应,不断以小博大。但只有符合最优子结构的问题,才有发生这种链式反应的性质。
找最优子结构的过程,其实就是证明状态转移方程正确性的过程,方程符合最优子结构就可以写暴力解了,写出暴力解就可以看出有没有重叠子问题了,有则优化,无则 OK。这也是套路,经常刷题的朋友应该能体会。
即:暴力-》递归-》DP(剪枝/优化)
子序列问题是常见的算法问题,而且并不好解决。
首先,子序列问题本身就相对子串、子数组更困难一些,因为前者是不连续的序列,而后两者是连续的,就算穷举都不容易,更别说求解相关的算法问题了。
而且,子序列问题很可能涉及到两个字符串,比如让你求两个字符串的 最长公共子序列,如果没有一定的处理经验,真的不容易想出来。所以本文就来扒一扒子序列问题的套路,其实就有两种模板,相关问题只要往这两种思路上想,十拿九稳。
一般来说,这类问题都是让你求一个最长子序列,因为最短子序列就是一个字符嘛,没啥可问的。一旦涉及到子序列和最值,那几乎可以肯定,考察的是动态规划技巧,时间复杂度一般都是 O(n^2)。
原因很简单,你想想一个字符串,它的子序列有多少种可能?起码是指数级的吧,这种情况下,不用动态规划技巧,还想怎么着呢?
既然要用动态规划,那就要定义 dp 数组,找状态转移关系。我们说的两种思路模板,就是 dp 数组的定义思路。不同的问题可能需要不同的 dp 数组定义来解决。
1、第一种思路模板是一个一维的 dp 数组:
int n = array.length;
int[] dp = new int[n];
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
dp[i] = 最值(dp[i], dp[j] + ...)
}
}
举个我们写过的例子 最长递增子序列,在这个思路中 dp 数组的定义是:
在子数组array[0…i]中,以array[i]结尾的目标子序列(最长递增子序列)的长度是dp[i]。
为啥最长递增子序列需要这种思路呢?前文说得很清楚了,因为这样符合归纳法,可以找到状态转移的关系,这里就不具体展开了。
2、第二种思路模板是一个二维的 dp 数组:
int n = arr.length;
int[][] dp = new dp[n][n];
for (int i = 0; i < n; i++) {
for (int j = 1; j < n; j++) {
if (arr[i] == arr[j])
dp[i][j] = dp[i][j] + ...
else
dp[i][j] = 最值(...)
}
}
这种思路运用相对更多一些,尤其是涉及两个字符串/数组的子序列。本思路中 dp 数组含义又分为「只涉及一个字符串」和「涉及两个字符串」两种情况。
2.1 涉及两个字符串/数组时(比如最长公共子序列),dp 数组的含义如下:
在子数组arr1[0…i]和子数组arr2[0…j]中,我们要求的子序列(最长公共子序列)长度为dp[i][j]。
2.2 只涉及一个字符串/数组时(比如本文要讲的最长回文子序列),dp 数组的含义如下:
在子数组array[i…j]中,我们要求的子序列(最长回文子序列)的长度为dp[i][j]。
第一种情况可以参考这两篇旧文:详解编辑距离 和 最长公共子序列。
下面就借最长回文子序列这个问题,详解一下第二种情况下如何使用动态规划。
1143.最长公共子序列(Medium)
583. 两个字符串的删除操作(Medium)
712.两个字符串的最小ASCII删除和(Medium)
虽说是三LCS题目,但是只有第一道题目是经典。
不知道大家做算法题有什么感觉,我总结出来做算法题的技巧就是,把大的问题细化到一个点,先研究在这个小的点上如何解决问题,然后再通过递归/迭代的方式扩展到整个问题。
比如说我们前文 手把手带你刷二叉树第三期,解决二叉树的题目,我们就会把整个问题细化到某一个节点上,想象自己站在某个节点上,需要做什么,然后套二叉树递归框架就行了。
动态规划系列问题也是一样,尤其是子序列相关的问题。本文从「最长公共子序列问题」展开,总结三道子序列问题,解这道题仔细讲讲这种子序列问题的套路,你就能感受到这种思维方式了。
最长公共子序列
计算最长公共子序列(Longest Common Subsequence,简称 LCS)是一道经典的动态规划题目,大家应该都见过:
给你输入两个字符串s1和s2,请你找出他们俩的最长公共子序列,返回这个子序列的长度。
力扣第 1143 题就是这道题,函数签名如下:
int longestCommonSubsequence(String s1, String s2);
比如说输入s1 = “zabcde”, s2 = “acez”,它俩的最长公共子序列是lcs = “ace”,长度为 3,所以算法返回 3。
如果没有做过这道题,一个最简单的暴力算法就是,把s1和s2的所有子序列都穷举出来,然后看看有没有公共的,然后在所有公共子序列里面再寻找一个长度最大的。
显然,这种思路的复杂度非常高,你要穷举出所有子序列,这个复杂度就是指数级的,肯定不实际。
正确的思路是不要考虑整个字符串,而是细化到s1和s2的每个字符。前文 子序列解题模板 中总结的一个规律:
对于两个字符串求子序列的问题,都是用两个指针i和j分别在两个字符串上移动,大概率是动态规划思路。
最长公共子序列的问题也可以遵循这个规律,我们可以先写一个dp函数:
// 定义:计算 s1[i…] 和 s2[j…] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j)
这个dp函数的定义是:dp(s1, i, s2, j)计算s1[i…]和s2[j…]的最长公共子序列长度。
根据这个定义,那么我们想要的答案就是dp(s1, 0, s2, 0),且 base case 就是i == len(s1)或j == len(s2)时,因为这时候s1[i…]或s2[j…]就相当于空串了,最长公共子序列的长度显然是 0:
int longestCommonSubsequence(String s1, String s2) {
return dp(s1, 0, s2, 0);
}
/* 主函数 */
int dp(String s1, int i, String s2, int j) {
// base case
if (i == s1.length() || j == s2.length()) {
return 0;
}
// ...
接下来,咱不要看s1和s2两个字符串,而是要具体到每一个字符,思考每个字符该做什么。
我们只看s1[i]和s2[j],如果s1[i] == s2[j],说明这个字符一定在lcs中:
这样,就找到了一个lcs中的字符,根据dp函数的定义,我们可以完善一下代码:
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
if (s1.charAt(i) == s2.charAt(j)) {
// s1[i] 和 s2[j] 必然在 lcs 中,
// 加上 s1[i+1..] 和 s2[j+1..] 中的 lcs 长度,就是答案
return 1 + dp(s1, i + 1, s2, j + 1)
} else {
// ...
}
}
刚才说的s1[i] == s2[j]的情况,但如果s1[i] != s2[j],应该怎么办呢?
s1[i] != s2[j]意味着,s1[i]和s2[j]中至少有一个字符不在lcs中:
如上图,总共可能有三种情况,我怎么知道具体是那种情况呢?
其实我们也不知道,那就把这三种情况的答案都算出来,取其中结果最大的那个呗,因为题目让我们算「最长」公共子序列的长度嘛。
这三种情况的答案怎么算?回想一下我们的dp函数定义,不就是专门为了计算它们而设计的嘛!
代码可以再进一步:
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
if (s1.charAt(i) == s2.charAt(j)) {
return 1 + dp(s1, i + 1, s2, j + 1)
} else {
// s1[i] 和 s2[j] 中至少有一个字符不在 lcs 中,
// 穷举三种情况的结果,取其中的最大结果
return max(
// 情况一、s1[i] 不在 lcs 中
dp(s1, i + 1, s2, j),
// 情况二、s2[j] 不在 lcs 中
dp(s1, i, s2, j + 1),
// 情况三、都不在 lcs 中
dp(s1, i + 1, s2, j + 1)
);
}
}
这里就已经非常接近我们的最终答案了,还有一个小的优化,情况三「s1[i]和s2[j]都不在 lcs 中」其实可以直接忽略。
因为我们在求最大值嘛,情况三在计算s1[i+1…]和s2[j+1…]的lcs长度,这个长度肯定是小于等于情况二s1[i…]和s2[j+1…]中的lcs长度的,因为s1[i+1…]比s1[i…]短嘛,那从这里面算出的lcs当然也不可能更长嘛。
同理,情况三的结果肯定也小于等于情况一。说白了,情况三被情况一和情况二包含了,所以我们可以直接忽略掉情况三,完整代码如下:
// 备忘录,消除重叠子问题
int[][] memo;
/* 主函数 */
int longestCommonSubsequence(String s1, String s2) {
int m = s1.length(), n = s2.length();
// 备忘录值为 -1 代表未曾计算
memo = new int[m][n];
for (int[] row : memo)
Arrays.fill(row, -1);
// 计算 s1[0..] 和 s2[0..] 的 lcs 长度
return dp(s1, 0, s2, 0);
}
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
// base case
if (i == s1.length() || j == s2.length()) {
return 0;
}
// 如果之前计算过,则直接返回备忘录中的答案
if (memo[i][j] != -1) {
return memo[i][j];
}
// 根据 s1[i] 和 s2[j] 的情况做选择
if (s1.charAt(i) == s2.charAt(j)) {
// s1[i] 和 s2[j] 必然在 lcs 中
memo[i][j] = 1 + dp(s1, i + 1, s2, j + 1);
} else {
// s1[i] 和 s2[j] 至少有一个不在 lcs 中
memo[i][j] = Math.max(
dp(s1, i + 1, s2, j),
dp(s1, i, s2, j + 1)
);
}
return memo[i][j];
}
以上思路完全就是按照我们之前的爆文 动态规划套路框架 来的,应该是很容易理解的。至于为什么要加memo备忘录,我们之前写过很多次,为了照顾新来的读者,这里再简单重复一下,首先抽象出我们核心dp函数的递归框架:
int dp(int i, int j) {
dp(i + 1, j + 1); // #1
dp(i, j + 1); // #2
dp(i + 1, j); // #3
}
你看,假设我想从dp(i, j)转移到dp(i+1, j+1),有不止一种方式,可以直接走#1,也可以走#2 -> #3,也可以走#3 -> #2。
这就是重叠子问题,如果我们不用memo备忘录消除子问题,那么dp(i+1, j+1)就会被多次计算,这是没有必要的。
至此,最长公共子序列问题就完全解决了,用的是自顶向下带备忘录的动态规划思路,我们当然也可以使用自底向上的迭代的动态规划思路,和我们的递归思路一样,关键是如何定义dp数组,我这里也写一下自底向上的解法吧:
int longestCommonSubsequence(String s1, String s2) {
int m = s1.length(), n = s2.length();
int[][] dp = new int[m + 1][n + 1];
// 定义:s1[0..i-1] 和 s2[0..j-1] 的 lcs 长度为 dp[i][j]
// 目标:s1[0..m-1] 和 s2[0..n-1] 的 lcs 长度,即 dp[m][n]
// base case: dp[0][..] = dp[..][0] = 0
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// 现在 i 和 j 从 1 开始,所以要减一
if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
// s1[i-1] 和 s2[j-1] 必然在 lcs 中
dp[i][j] = 1 + dp[i - 1][j - 1];
} else {
// s1[i-1] 和 s2[j-1] 至少有一个不在 lcs 中
dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]);
}
}
}
return dp[m][n];
}
自底向上的解法中dp数组定义的方式和我们的递归解法有一点差异,而且由于数组索引从 0 开始,有索引偏移,不过思路和我们的递归解法完全相同,如果你看懂了递归解法,这个解法应该不难理解。
另外,自底向上的解法可以通过我们前文讲过的 动态规划状态压缩技巧 来进行优化,把空间复杂度压缩为 O(N),这里由于篇幅所限,就不展开了。
我的完整题解
/**
* @Description: 最大公共子序列——子序列问题。
* @param {string} text1
* @param {string} text2
* @return {返回 最大公共子序列的个数}
* @notes:
*/
int longestCommonSubsequence(string text1, string text2) {
// base
int m = text1.size(), n = text2.size();
if(m == 0 || n == 0){
return 0;
}
// base case 0 0 置为0
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
// 开始扫描,状态转移方程
// dp[i][j] 表示 text1[0- i-1] text2[0- j-1] 为止有多少公共子序列
for(int i=1;i<=m;i++){
for(int j=1;j<=n;j++){
if(text1[i-1] == text2[j-1]) dp[i][j] = 1+dp[i-1][j-1];
else{
// 不相等,说明至少有一个不在lcs中, 取最大数
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
}
// 最后返回 最大的
return dp[m][n];
}
下面,来看两道和最长公共子序列相似的两道题目。
这是力扣第 583 题「两个字符串的删除操作」,看下题目:
函数签名如下:
int minDistance(String s1, String s2);
题目让我们计算将两个字符串变得相同的最少删除次数,那我们可以思考一下,最后这两个字符串会被删成什么样子?
删除的结果不就是它俩的最长公共子序列嘛!
那么,要计算删除的次数,就可以通过最长公共子序列的长度推导出来:
int minDistance(String s1, String s2) {
int m = s1.length(), n = s2.length();
// 复用前文计算 lcs 长度的函数
int lcs = longestCommonSubsequence(s1, s2);
return m - lcs + n - lcs;
}
这道题就解决了!
这是力扣第 712 题,看下题目:
这道题,和上一道题非常类似,这回不问我们删除的字符个数了,问我们删除的字符的 ASCII 码加起来是多少。
那就不能直接复用计算最长公共子序列的函数了,但是可以依照之前的思路,稍微修改 base case 和状态转移部分即可直接写出解法代码:
我的题解:①自底向上:动态规划更高效 且 贯通这三道题目。 ②自顶向下: 递归方法
/**
* @Description: 两种解法——①前到后的 DP ②后到前的 递归+备忘录
* @param {string} s1
* @param {string} s2
* @return {*}
* @notes: dp[i][j] 表示s1[..--i-1] s2[..--j-1]构成ASCII码的最小值
* 关键:稍加修改 lcs的
*/
int minimumDeleteSum(string s1, string s2) {
int res = 0; //ascii mini value
int m=s1.size(),n=s2.size();
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
//base
for(int i=1;i<=m;i++){
res += (int)s1[i-1];
dp[i][0] = res;
}
res = 0;
for(int j=1;j<=n;j++){
res += (int)s2[j-1];
dp[0][j] = res;
}
// 开始扫描,状态转移方程
// dp[i][j] 表示 s1[0- i-1] s2[0- j-1] 为止有多少公共子序列
for(int i=1;i<=m;i++){
for(int j=1;j<=n;j++){
if(s1[i-1] == s2[j-1]) dp[i][j] = dp[i-1][j-1];
else{
// 不相等,说明至少有一个不在lcs中, 取最小的 ASCII码
dp[i][j] = min(dp[i-1][j]+(int)s1[i-1],
min(dp[i][j-1]+(int)s2[j-1], dp[i-1][j-1]+(int)s1[i-1]+s2[j-1]));
}
}
}
// 最后返回 最大的
return dp[m][n];
}
/**
* @Description: 方法二——使用自顶向下的方法 递归做法。
* @param {*}
* @return {*}
* @notes:
*/
// 备忘录
/* 主函数 */
int minimumDeleteSum2(string s1, string s2) {
int m = s1.length(), n = s2.length();
// 备忘录值为 -1 代表未曾计算
vector<vector<int>> memo(m, vector<int>(n, -1));
return dp(s1, 0, s2, 0, memo);
}
// 定义:将 s1[i..] 和 s2[j..] 删除成相同字符串,
// 最小的 ASCII 码之和为 dp(s1, i, s2, j)。
int dp(string &s1, int i, string &s2, int j, vector<vector<int>> &memo) {
int res = 0;
// base case
if (i == s1.length()) {
// 如果 s1 到头了,那么 s2 剩下的都得删除
for (; j < s2.length(); j++)
res += (int)s2[j];
return res;
}
if (j == s2.length()) {
// 如果 s2 到头了,那么 s1 剩下的都得删除
for (; i < s1.length(); i++)
res += (int)s1[i];
return res;
}
if (memo[i][j] != -1) {
return memo[i][j];
}
if (s1[i]==s2[j]) {
// s1[i] 和 s2[j] 都是在 lcs 中的,不用删除
memo[i][j] = dp(s1, i + 1, s2, j + 1, memo);
} else {
// s1[i] 和 s2[j] 至少有一个不在 lcs 中,删一个
memo[i][j] = min( (int)s1[i] + dp(s1, i + 1, s2, j, memo),
(int)s2[j] + dp(s1, i, s2, j + 1, memo));
}
return memo[i][j];
}
base case 有一定区别,计算lcs长度时,如果一个字符串为空,那么lcs长度必然是 0;但是这道题如果一个字符串为空,另一个字符串必然要被全部删除,所以需要计算另一个字符串所有字符的 ASCII 码之和。
关于状态转移,当s1[i]和s2[j]相同时不需要删除,不同时需要删除,所以可以利用dp函数计算两种情况,得出最优的结果。其他的大同小异,就不具体展开了。
至此,三道子序列问题就解决完了,关键在于将问题细化到字符,根据每两个字符是否相同来判断他们是否在结果子序列中,从而避免了对所有子序列进行穷举。
这也算是在两个字符串中求子序列的常用思路吧,建议好好体会,多多联系~
前几天在网上看到一份鹅场的面试题,算法部分大半是动态规划,最后一题就是写一个计算编辑距离的函数,今天就专门写一篇文章来探讨一下这个经典问题。
我个人很喜欢编辑距离这个问题,因为它看起来十分困难,解法却出奇得简单漂亮,而且它是少有的比较实用的算法(是的,我承认很多算法问题都不太实用)。下面先来看下题目:
为什么说这个问题难呢,因为显而易见,它就是难,让人手足无措,望而生畏。
为什么说它实用呢,因为前几天我就在日常生活中用到了这个算法。之前有一篇公众号文章由于疏忽,写错位了一段内容,我决定修改这部分内容让逻辑通顺。但是公众号文章最多只能修改 20 个字,且只支持增、删、替换操作(跟编辑距离问题一模一样),于是我就用算法求出了一个最优方案,只用了 16 步就完成了修改。
再比如高大上一点的应用,DNA 序列是由 A,G,C,T 组成的序列,可以类比成字符串。编辑距离可以衡量两个 DNA 序列的相似度,编辑距离越小,说明这两段 DNA 越相似,说不定这俩 DNA 的主人是远古近亲啥的。
下面言归正传,详细讲解一下编辑距离该怎么算,相信本文会让你有收获。
一、思路
编辑距离问题就是给我们两个字符串s1和s2,只能用三种操作,让我们把s1变成s2,求最少的操作数。需要明确的是,不管是把s1变成s2还是反过来,结果都是一样的,所以后文就以s1变成s2举例。
前文 最长公共子序列 说过,解决两个字符串的动态规划问题,一般都是用两个指针i,j分别指向两个字符串的最后,然后一步步往前走,缩小问题的规模。
设两个字符串分别为 “rad” 和 “apple”,为了把s1变成s2,算法会这样进行:
请记住这个 GIF 过程,这样就能算出编辑距离。关键在于如何做出正确的操作,稍后会讲。
根据上面的 GIF,可以发现操作不只有三个,其实还有第四个操作,就是什么都不要做(skip)。比如这个情况:
因为这两个字符本来就相同,为了使编辑距离最小,显然不应该对它们有任何操作,直接往前移动i,j即可。
还有一个很容易处理的情况,就是j走完s2时,如果i还没走完s1,那么只能用删除操作把s1缩短为s2。比如这个情况:
类似的,如果i走完s1时j还没走完了s2,那就只能用插入操作把s2剩下的字符全部插入s1。等会会看到,这两种情况就是算法的 base case。
下面详解一下如何将这个思路转化成代码,坐稳,准备发车了。
二、代码详解
先梳理一下之前的思路:
base case 是i走完s1或j走完s2,可以直接返回另一个字符串剩下的长度。
对于每对儿字符s1[i]和s2[j],可以有四种操作:
if s1[i] == s2[j]:
啥都别做(skip)
i, j 同时向前移动
else:
三选一:
插入(insert)
删除(delete)
替换(replace)
有这个框架,问题就已经解决了。读者也许会问,这个「三选一」到底该怎么选择呢?很简单,全试一遍,哪个操作最后得到的编辑距离最小,就选谁。这里需要递归技巧,理解需要点技巧,先看下代码:
下面来详细解释一下这段递归代码,base case 应该不用解释了,主要解释一下递归部分。
都说递归代码的可解释性很好,这是有道理的,只要理解函数的定义,就能很清楚地理解算法的逻辑。我们这里 dp(i, j) 函数的定义是这样的:
def dp(i, j) -> int
# 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
记住这个定义之后,先来看这段代码:
if s1[i] == s2[j]:
return dp(i - 1, j - 1) # 啥都不做
# 解释:
# 本来就相等,不需要任何操作
# s1[0..i] 和 s2[0..j] 的最小编辑距离等于
# s1[0..i-1] 和 s2[0..j-1] 的最小编辑距离
# 也就是说 dp(i, j) 等于 dp(i-1, j-1)
如果s1[i]!=s2[j],就要对三个操作递归了,稍微需要点思考:
dp(i, j - 1) + 1, # 插入
# 解释:
# 我直接在 s1[i] 插入一个和 s2[j] 一样的字符
# 那么 s2[j] 就被匹配了,前移 j,继续跟 i 对比
# 别忘了操作数加一
图片
dp(i - 1, j) + 1, # 删除
# 解释:
# 我直接把 s[i] 这个字符删掉
# 前移 i,继续跟 j 对比
# 操作数加一
图片
dp(i - 1, j - 1) + 1 # 替换
# 解释:
# 我直接把 s1[i] 替换成 s2[j],这样它俩就匹配了
# 同时前移 i,j 继续对比
# 操作数加一
图片
现在,你应该完全理解这段短小精悍的代码了。还有点小问题就是,这个解法是暴力解法,存在重叠子问题,需要用动态规划技巧来优化。
怎么能一眼看出存在重叠子问题呢?前文 动态规划之正则表达式 有提过,这里再简单提一下,需要抽象出本文算法的递归框架:
def dp(i, j):
dp(i - 1, j - 1) #1
dp(i, j - 1) #2
dp(i - 1, j) #3
对于子问题dp(i-1,j-1),如何通过原问题dp(i,j)得到呢?有不止一条路径,比如dp(i,j)->#1和dp(i,j)->#2->#3。一旦发现一条重复路径,就说明存在巨量重复路径,也就是重叠子问题。
三、动态规划优化
对于重叠子问题呢,前文 动态规划详解 介绍过,优化方法无非是备忘录或者 DP table。
备忘录很好加,原来的代码稍加修改即可:
def minDistance(s1, s2) -> int:
memo = dict() # 备忘录
def dp(i, j):
if (i, j) in memo:
return memo[(i, j)]
...
if s1[i] == s2[j]:
memo[(i, j)] = ...
else:
memo[(i, j)] = ...
return memo[(i, j)]
return dp(len(s1) - 1, len(s2) - 1)
主要说下 DP table 的解法:
首先明确 dp 数组的含义,dp 数组是一个二维数组,长这样:
dp[i][j]的含义和之前的 dp 函数类似:
def dp(i, j) -> int
# 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
dp[i-1][j-1]
# 存储 s1[0..i] 和 s2[0..j] 的最小编辑距离
有了之前递归解法的铺垫,应该很容易理解。dp 函数的 base case 是i,j等于 -1,而数组索引至少是 0,所以 dp 数组会偏移一位,dp[…][0]和dp[0][…]对应 base case。。
既然 dp 数组和递归 dp 函数含义一样,也就可以直接套用之前的思路写代码,唯一不同的是,DP table 是自底向上求解,递归解法是自顶向下求解:
我的题解:
class Solution {
public:
/**
* @Description: 最近的编辑距离
* @param {string} word1
* @param {string} word2
* @return {int} dp[i][j] 表示,word1[0-- i-1] word2[0-- j-1]的最近编辑距离。
* @notes: 首先basecase等初始化好, 然后状态 选择 转移到目标位置。最后 要最后位置的dp。
*/
int minDistance(string word1, string word2) {
int m = word1.size(), n = word2.size();
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
// base case
for(int i=1;i<=m;i++) dp[i][0] = i;
for(int j=1;j<=n;j++) dp[0][j] = j;
if(m == 0 || n == 0){
return dp[m][n];
}
// 状态转移 开始
for(int i=1;i<=m;i++){
for(int j=1;j<=n;j++){
// 顺序遍历
if(word1[i-1] == word2[j-1]) dp[i][j] = dp[i-1][j-1]; // skip
else{
dp[i][j] = min(dp[i-1][j]+1,
min(dp[i][j-1]+1, dp[i-1][j-1]+1));// 删除 插入 替换
}
}
}
return dp[m][n];
}
};`
我们说这个问题对 dp 数组的定义是:在子串s[i…j]中,最长回文子序列的长度为dp[i][j]。一定要记住这个定义才能理解算法。
为啥这个问题要这样定义二维的 dp 数组呢?我们前文多次提到,找状态转移需要归纳思维,说白了就是如何从已知的结果推出未知的部分,这样定义容易归纳,容易发现状态转移关系。
具体来说,如果我们想求dp[i][j],假设你知道了子问题dp[i+1][j-1]的结果(s[i+1…j-1]中最长回文子序列的长度),你是否能想办法算出dp[i][j]的值(s[i…j]中,最长回文子序列的长度)呢?
可以!这取决于s[i]和s[j]的字符:
如果它俩相等,那么它俩加上s[i+1…j-1]中的最长回文子序列就是s[i…j]的最长回文子序列:
如果它俩不相等,说明它俩不可能同时出现在s[i…j]的最长回文子序列中,那么把它俩分别加入s[i+1…j-1]中,看看哪个子串产生的回文子序列更长即可:
以上两种情况写成代码就是这样:
if (s[i] == s[j])
// 它俩一定在最长回文子序列中
dp[i][j] = dp[i + 1][j - 1] + 2;
else
// s[i+1..j] 和 s[i..j-1] 谁的回文子序列更长?
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
至此,状态转移方程就写出来了,根据 dp 数组的定义,我们要求的就是dp[0][n - 1],也就是整个s的最长回文子序列的长度。
首先明确一下 base case,如果只有一个字符,显然最长回文子序列长度是 1,也就是dp[i][j] = 1,(i == j)。
因为i肯定小于等于j,所以对于那些i > j的位置,根本不存在什么子序列,应该初始化为 0。
另外,看看刚才写的状态转移方程,想求dp[i][j]需要知道dp[i+1][j-1],dp[i+1][j],dp[i][j-1]这三个位置;再看看我们确定的 base case,填入 dp 数组之后是这样:
为了保证每次计算dp[i][j],左、下、左下三个方向的位置已经被计算出来,只能斜着遍历或者反着遍历:
我选择反着遍历,代码如下:
我的题解:
class Solution {
public:
/**
* @Description:
* @param {string} s
* @return {*}返回 dp[0][n-1]
* @notes: 首先basecase 下三角0/1; 然后 目标为右上角;最后 状态转移方程为 == +2; else: 找 max(i,j-1 i+1,j )
* dp[n][n] 表示由dp[i+1][j-1] 退出dp[i][j] 回文长度。
*/
int longestPalindromeSubseq(string s) {
int n = s.size();
if( n==0 || n==1) return n==0?0:1;
// base case + init
vector<vector<int>> dp(n, vector<int>(n,0));
// dui jiao
for(int i=0;i<n;i++) dp[i][i] = 1;
// start
for(int i = n-2;i>=0;i--){
for(int j = i+1;j<n;j++){
//状态转移方程
if(s[i] == s[j]) dp[i][j] = dp[i+1][j-1]+2;
else{
dp[i][j] = max(dp[i+1][j], dp[i][j-1]);
}
}
}
return dp[0][n-1];
}
/**
* @Description: 遍历方向不一致。倾斜遍历。
* @param {string} s
* @return {*}
* @notes:
*/
int longestPalindromeSubseq2(string s) {
int n = s.size();
if( n==0 || n==1) return n==0?0:1;
// base case + init
vector<vector<int>> dp(n, vector<int>(n,0));
// dui jiao
for(int i=0;i<n;i++) dp[i][i] = 1;
// start
for(int num = (n+n)/2-2;num>=0;num--){
for(int i = 0;i<n;i++){
int j = n - num + i -1;
if(j<0 || j>=n) continue;
//状态转移方程
if(s[i] == s[j]) dp[i][j] = dp[i+1][j-1]+2;
else{
dp[i][j] = max(dp[i+1][j], dp[i][j-1]);
}
}
}
return dp[0][n-1];
}
/**
* @Description: 状态压缩到一维 数组。
* @param {string} s
* @return {*}
* @notes:
*/
int longestPalindromeSubseq3(string s) {
int n = s.size();
if( n==0 || n==1) return n==0?0:1;
// base case + init
vector<int> dp(n, 1);
// start
for(int i = n-2;i>=0;i--){
int pre = 0;
for(int j = i+1;j<n;j++){
int temp = dp[j];
//状态转移方程
if(s[i] == s[j]) dp[j] = pre+2;
else{
dp[j] = max(dp[j], dp[j-1]);
}
pre = temp;
}
}
return dp[n-1];
}
};
至此,最长回文子序列的问题就解决了。
主要还是正确定义 dp 数组的含义,遇到子序列问题,首先想到两种动态规划思路,然后根据实际问题看看哪种思路容易找到状态转移关系。
另外,找到状态转移和 base case 之后,一定要观察 DP table,看看怎么遍历才能保证通过已计算出来的结果解决新的问题
有了以上思路方向,子序列问题也不过如此嘛。
我的题解:
class Solution {
public:
int minInsertions(string s) {
int n = s.size();
if( n==0 || n==1) return 0;
// base case + init
vector<int> dp(n, 0);
// start
for(int i = n-2;i>=0;i--){
int pre = 0;
for(int j = i+1;j<n;j++){
int temp = dp[j];
//状态转移方程
if(s[i] == s[j]) dp[j] = pre;
else{
dp[j] = min(dp[j], dp[j-1])+1;
}
pre = temp;
}
}
return dp[n-1];
}
};
可以说动态规划技巧对于算法效率的提升非常可观,一般来说都能把指数级和阶乘级时间复杂度的算法优化成 O(N^2),堪称算法界的二向箔,把各路魑魅魍魉统统打成二次元。
但是,动态规划本身也是可以进行阶段性优化的,比如说我们常听说的「状态压缩」技巧,就能够把很多动态规划解法的空间复杂度进一步降低,由 O(N^2) 降低到 O(N),
能够使用状态压缩技巧的动态规划都是二维dp问题,你看它的状态转移方程,如果计算状态dp[i][j]需要的都是dp[i][j]相邻的状态,那么就可以使用状态压缩技巧,将二维的dp数组转化成一维,将空间复杂度从 O(N^2) 降低到 O(N)。
你看我们对dp[i][j]的更新,其实只依赖于dp[i+1][j-1], dp[i][j-1], dp[i+1][j]这三个状态:
这就叫和dp[i][j]相邻,反正你计算dp[i][j]只需要这三个相邻状态,其实根本不需要那么大一个二维的 dp table 对不对?
状态压缩的核心思路就是,将二维数组「投影」到一维数组:
思路很直观,但是也有一个明显的问题,图中dp[i][j-1]和dp[i+1][j-1]这两个状态处在同一列,而一维数组中只能容下一个,那么当我计算dp[i][j]时,他俩必然有一个会被另一个覆盖掉,怎么办?
这就是状态压缩的难点,下面就来分析解决这个问题,还是拿「最长回文子序列」问题举例,它的状态转移方程主要逻辑就是如下这段代码:
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
// 状态转移方程
if (s[i] == s[j])
dp[i][j] = dp[i + 1][j - 1] + 2;
else
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
想把二维dp数组压缩成一维,一般来说是把第一个维度,也就是i这个维度去掉,只剩下j这个维度。压缩后的一维dp数组就是之前二维dp数组的dp[i][…]那一行。
我们先将上述代码进行改造,直接无脑去掉i这个维度,把dp数组变成一维:
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
// 在这里,一维 dp 数组中的数是什么?
if (s[i] == s[j])
dp[j] = dp[j - 1] + 2;
else
dp[j] = max(dp[j], dp[j - 1]);
}
}
上述代码的一维dp数组只能表示二维dp数组的一行dp[i][…],那我怎么才能得到dp[i+1][j-1], dp[i][j-1], dp[i+1][j]这几个必要的的值,进行状态转移呢?
在代码中注释的位置,将要进行状态转移,更新dp[j],那么我们要来思考两个问题:
1、在对dp[j]赋新值之前,dp[j]对应着二维dp数组中的什么位置?
2、dp[j-1]对应着二维dp数组中的什么位置?
对于问题 1,在对dp[j]赋新值之前,dp[j]的值就是外层 for 循环上一次迭代算出来的值,也就是对应二维dp数组中dp[i+1][j]的位置。
对于问题 2,dp[j-1]的值就是内层 for 循环上一次迭代算出来的值,也就是对应二维dp数组中dp[i][j-1]的位置。
那么问题已经解决了一大半了,只剩下二维dp数组中的dp[i+1][j-1]这个状态我们不能直接从一维dp数组中得到:
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
if (s[i] == s[j])
// dp[i][j] = dp[i+1][j-1] + 2;
dp[j] = ?? + 2;
else
// dp[i][j] = max(dp[i+1][j], dp[i][j-1]);
dp[j] = max(dp[j], dp[j - 1]);
}
}
因为 for 循环遍历i和j的顺序为从左向右,从下向上,所以可以发现,在更新一维dp数组的时候,dp[i+1][j-1]会被dp[i][j-1]覆盖掉,图中标出了这四个位置被遍历到的次序:
那么如果我们想得到dp[i+1][j-1],就必须在它被覆盖之前用一个临时变量temp把它存起来,并把这个变量的值保留到计算dp[i][j]的时候。为了达到这个目的,结合上图,我们可以这样写代码:
for (int i = n - 2; i >= 0; i--) {
// 存储 dp[i+1][j-1] 的变量
int pre = 0;
for (int j = i + 1; j < n; j++) {
int temp = dp[j];
if (s[i] == s[j])
// dp[i][j] = dp[i+1][j-1] + 2;
dp[j] = pre + 2;
else
dp[j] = max(dp[j], dp[j - 1]);
// 到下一轮循环,pre 就是 dp[i+1][j-1] 了
pre = temp;
}
}
别小看这段代码,这是一维dp最精妙的地方,会者不难,难者不会。为了清晰起见,我用具体的数值来拆解这个逻辑:
假设现在i = 5, j = 7且s[5] == s[7],那么现在会进入下面这个逻辑对吧:
if (s[5] == s[7])
// dp[5][7] = dp[i+1][j-1] + 2;
dp[7] = pre + 2;
我问你这个pre变量是什么?是内层 for 循环上一次迭代的temp值。
那我再问你内层 for 循环上一次迭代的temp值是什么?是dp[j-1]也就是dp[6],但这是外层 for 循环上一次迭代对应的dp[6],也就是二维dp数组中的dp[i+1][6] = dp[6][6]。
也就是说,pre变量就是dp[i+1][j-1] = dp[6][6],也就是我们想要的结果。
那么现在我们成功对状态转移方程进行了降维打击,算是最硬的的骨头啃掉了,但注意到我们还有 base case 要处理呀:
// 二维 dp 数组全部初始化为 0
vector<vector<int>> dp(n, vector<int>(n, 0));
// base case
for (int i = 0; i < n; i++)
dp[i][i] = 1;
如何把 base case 也打成一维呢?很简单,记住,状态压缩就是投影,我们把 base case 投影到一维看看:
二维dp数组中的 base case 全都落入了一维dp数组,不存在冲突和覆盖,所以说我们直接这样写代码就行了:
// 一维 dp 数组全部初始化为 1
vector<int> dp(n, 1);
至此,我们把 base case 和状态转移方程都进行了降维,实际上已经写出完整代码了:
int longestPalindromeSubseq(string s) {
int n = s.size();
// base case:一维 dp 数组全部初始化为 1
vector<int> dp(n, 1);
for (int i = n - 2; i >= 0; i--) {
int pre = 0;
for (int j = i + 1; j < n; j++) {
int temp = dp[j];
// 状态转移方程
if (s[i] == s[j])
dp[j] = pre + 2;
else
dp[j] = max(dp[j], dp[j - 1]);
pre = temp;
}
}
return dp[n - 1];
}
本文就结束了,不过状态压缩技巧再牛逼,也是基于常规动态规划思路之上的。
你也看到了,使用状态压缩技巧对二维dp数组进行降维打击之后,解法代码的可读性变得非常差了,如果直接看这种解法,任何人都是一脸懵逼的。
算法的优化就是这么一个过程,先写出可读性很好的暴力递归算法,然后尝试运用动态规划技巧优化重叠子问题,最后尝试用状态压缩技巧优化空间复杂度。
也就是说,你最起码能够熟练运用我们前文 动态规划框架套路详解 的套路找出状态转移方程,写出一个正确的动态规划解法,然后才有可能观察状态转移的情况,分析是否可能使用状态压缩技巧来优化。
希望读者能够稳扎稳打,层层递进,对于这种比较极限的优化,不做也罢。毕竟套路存于心,走遍天下都不怕!
对应3.1 最长回文子序列
我的题解:
/**
* @Description: 状态压缩到一维 数组。
* @param {string} s
* @return {*}
* @notes:
*/
int longestPalindromeSubseq3(string s) {
int n = s.size();
if( n==0 || n==1) return n==0?0:1;
// base case + init
vector<int> dp(n, 1);
// start
for(int i = n-2;i>=0;i--){
int pre = 0;
for(int j = i+1;j<n;j++){
int temp = dp[j];
//状态转移方程
if(s[i] == s[j]) dp[j] = pre+2;
else{
dp[j] = max(dp[j], dp[j-1]);
}
pre = temp;
}
}
return dp[n-1];
}