本篇主要讨论二维动态规划,也就是由两个参数决定返回值的dp问题,本质上和第一篇并无区别,主要区别在于依赖关系往往更加复杂,对于dp表值的含义的定义也可能更多样,同样的我们希望从递归到记忆化搜索到dp到空间的优化。
(1)64.最小路径和
给定一个包含非负整数的 m x n
网格 grid
,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:一个机器人每次只能向下或者向右移动一步
本题很简单,递归的解法可以暴力尝试所有可能的路径和,但是递归其实可以极大的简化,就是在调用的时候加上一点贪心,因为总体的最小肯定是局部的最小组合而来,所以递归的时候直接加上对最小子结构的筛选,见代码。
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m=grid.size();
int n=grid[0].size();
return f(m-1,n-1,grid);
}
int f(int i,int j,vector<vector<int>>& grid){
if(i==0&&j==0){
return grid[0][0];
}
int up=5000000;
int left=5000000;
if(i>0){
up=f(i-1,j,grid);
}
if(j>0){
left=f(i,j-1,grid);
}
return grid[i][j]+min(up,left);
}
};
和上一篇一样,dp的递推就是基于递归的尝试策略,所以我们可以建立一个二维表,每个位置代表从起点(0,0)到当前位置的最小路径和,然后需要注意的就是初始化dp表和填表的顺序,我们需要的答案是dp(m,n),首先零行和零列的数据只有一种走法,所以可以直接填入,除此之外,我们通过递归不难发现,每个位置的dp值依赖于它的上方的值和左侧的值,所以每走到一个位置要保证他的左侧和上方已经填好,所以从上到下从左到右的填法可取,至此基础的解法已经结束,因为此题比较简单,所以一起研究空间优化的方法,我们发现他的依赖关系后,只用两个变量滚动更新显然不行,因为这两个位置没有直接的滚动的方法,所以我们的策略是用一个一维数组去滚动更新,每填完一个位置就用新的值覆盖这个代表他上方位置的值,这样就做到了一维数组当前位置的值代表此时位置上方的值,而一维数组当前位置的上一个位置代表当前位置左侧的值,直接看空间优化过后的code。
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m=grid.size();
int n=grid[0].size();
vector<int> dp(n+1);
dp[0]=grid[0][0];
for(int i=1;i<n;i++){
dp[i]=grid[0][i]+dp[i-1];
}
for(int i=1;i<m;i++){
dp[0]=dp[0]+grid[i][0];
for(int j=1;j<n;j++){
dp[j]=min(dp[j],dp[j-1])+grid[i][j];
}
}
return dp[n-1];
}
};
(2)单词搜索
给定一个 m x n
二维字符网格 board
和一个字符串单词 word
。如果 word
存在于网格中,返回 true
;否则,返回 false
。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
这道题用来解释什么样的题目不适合改成动态规划,我们在第一篇里提过,可以改成动态规划的题目的特点就是可以用少量的参数决定返回值(答案),而这个参数的数量就决定了这是几维的动态规划,比如上一篇就全部都是一维的动态规划,那么如果虽然可以用递归解决,但是由少量几个参数并不能决定最后的返回值,就不适合改成动态规划,比如这道题,除了当前的判断位置以外,已经走过的位置(因为不能走走过的位置,因此需要记录)也是决定答案的参数,这时候就难以用动态规划了,因为每个位置答案取决于不仅当前位置,甚至还要讨论表的每个不同状况,如此的复杂性就太高了,因此我们直接进行暴力搜索即可,思路就很简单,选择每个位置作为起点,走过的位置进行修改值(记录已经走过,当然返回之前要记得复原),然后向四个方向递归(如果有)有一个结果是true最后就是true,大体思路就是这样,细节看code。
class Solution {
public:
bool exist(vector<vector<char>>& board, string word) {
int m=board.size();
int n=board[0].size();
int w=word.size();
for(int i=0;i<m;i++){
for(int j=0;j<n;j++){
if(f(board,word,w,i,j,0,m,n)){
return true;
}
}
}
return false;
}
bool f(vector<vector<char>>& board, string word,int w,int i,int j,int k,int m,int n){
if(i==0||j==0||i==m||j==n||board[i][j]!=word[k]) return false;
if(board[i][j]==word[w-1]&&k==w-1) return true;
char cc=board[i][j];
board[i][j]='0';
board[i][j]=cc;
return f(board,word,w,i+1,j,k+1,m,n)||f(board,word,w,i-1,j,k+1,m,n)||f(board,word,w,i+1,j,k+1,m,n)||f(board,word,w,i+1,j,k+1,m,n);
}
};
(3)95.最长公共子序列
给定两个字符串 text1
和 text2
,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0
。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
- 例如,
"ace"
是"abcde"
的子序列,但"aec"
不是"abcde"
的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
这道题我们直接来看如何推出递推式子,首先动态规划的维数肯定取决于几个参数可以决定返回值,那么既然是两个字符串,很明显就是两个字符串的长度(只考虑的长度),可以去写一个二维动态规划,那么来到一个位置i,j 代表当考虑第一个字符串的前i个字符和第二个字符串的前j个字符的时候,怎么通过上一个子问题递推,如果当前字符相等,说明这个字符就可以作为最长公共子序列的一部分,在i-1,j-1的位置的答案+1即可,那么如果不相等呢,虽然不相等,但是不能说明该位置字符不是最长公共子序列的一部分,所以我们要考虑这两个字符分别作为有效答案一部分的可能,即是i,j-1以及i-1,j位置的答案的最大值,因为相等的时候我们已经考虑过,所以最大的正确答案肯定是他们两个中的最大值,那么这道题就是一个判断再进行递推的情况,
那么我们还要考虑一些填表的顺序,画图容易发现,本位置依赖于左上角,左侧,上方,三个位置,那么就按照常规的从左到右从上到下就可以保证可以填完,
最后再考虑空间的优化,因为这三个位置并不是滚动更新,所以我们可以用一个一维数组去优化,填过的值就是本行的,没填的值就是上一行的值,但是需要注意的是,这样去填的话左侧和左上角是无法同时出现的,所以我们可以单独维护一个变量,每更新一个位置之前用这个变量记录当前位置的值,对于下一个位置,刚刚记录的值恰好是左上角的值,详情见代码(空间优化的懒得写了,有时间补)。
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m=text1.size(),n=text2.size();
vector<vector<int>>dp(m+1,vector<int>(n+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]=dp[i-1][j-1]+1;
}else{
dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
}
}
}
return dp[m][n];
}
};
516.最长回文子序列
给你一个字符串 s
,找出其中最长的回文子序列,并返回该序列的长度。
子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。
首先仍然考虑用几个参数可以决定返回值,理论上一个参数确实可以决定返回值,前面长度为多少的最长回文子串,但对于更大的子问题,前面已经解决的问题对于后面没有帮助,因为需要的是整体的左右两侧的值是否相同来决定答案的值,所以就不难想到一个区间dp的思路,l,r代表当前区间的左右两侧,l,r对应的值如果相等,那么答案就是在l+1,r-1区间上的最长子串+2,如果不同,就和上一题思路一样,一定是l,r-1和l+1,r两个区间的最大值,因为可能有一个端点用于组成了最终答案,basecase自然就是l=r或者l=r-1的时候,此时的讨论略,先看递归代码(记忆化)
class Solution {
public://递归版本
int longestPalindromeSubseq(string s) {
int r=s.size()-1;
vector<vector<int>> re(r+1,vector<int>(r+1));
return f(re,s,0,r);
}
int f(vector<vector<int>>& re,string s,int l,int r){
if(re[l][r]!=0) return re[l][r];
if(l==r){
return 1;
}
if(r-1==l){
if(s[l]==s[r]){
return 2;
}else return 1;
}
if(s[l]==s[r]){
int a=f(re,s,l+1,r-1);
re[l+1][r-1];
return a+2;
}else{
int b=f(re,s,l+1,r);
int c=f(re,s,l,r-1);
re[l+1][r]=b;
re[l][r-1]=c;
return max(b,c);
}
}
};
dp的版本需要注意的点在于填表的顺序,注意填表顺序必须和位置依赖有关,也就是填这个位置的时候必须他所依赖的位置已经填完,我们最终需要的是最右上角的值(假设纵坐标是l)此外可以直接填好对角线上的值,还有r-1=l位置的值,而每个位置依赖于它左侧下方左下方的值,所以我们可以从右下到左上的去填表,最后返回dp[0][len-1]即可。看代码。
class Solution {
public:
int longestPalindromeSubseq(string s) {
int len=s.size();
vector<vector<int>> dp(len,vector<int>(len));
for(int i=0;i<len;i++){
dp[i][i]=1;
if(i<len-1){
if(s[i]==s[i+1]){
dp[i][i+1]=2;
}else dp[i][i+1]=1;
}
}
for(int k=0;k<=len-3;k++){
for(int r=len-1,l=len-k-3;l>=0;l--,r--){
if(s[l]==s[r]){
dp[l][r]=dp[l+1][r-1]+2;
}else dp[l][r]=max(dp[l+1][r],dp[l][r-1]);
}
}
return dp[0][len-1];
}
};
再研究如何进行空间优化,这个空间优化很有技术含量,首先我们要重新考虑一下更新的顺序,因为数组横着储存比较方便理解,斜着去填很明显不太好,所以我们尝试横着一排一排去填,需要注意的是,和上一题一样,当前位置的值的左侧和更新前本位置的值都被需要,也就是目标位置和左下角位置,那么我们需要一个额外的变量来滚动更新储存左下角的值,但是问题在于,每次更新这个位置的值在下一次更新中需要被用到,而我们需要记录的是未被更新的值,所以我们还需要一个额外的变量做过渡,也就是记录刚来到本位置的dp[r],随后dp[r]被填好,leftdown也用完了,再更新leftdown即可,细节看代码。
class Solution {
public://空间优化版本
int longestPalindromeSubseq(string s) {
int len=s.size();
vector<int> dp(len);
for(int l=len-1;l>=0;l--){
dp[l]=1;//l==r的情况
if(l<len-1){
dp[l+1]=s[l]==s[l+1]?2:1;
}
for(int r=l+2,leftdown=1,pre;r<len;r++){
pre=dp[r];//本次循环中还需要上一次更新的leftdown所以不能立马更新,用pre过渡一下
if(s[l]==s[r]){
dp[r]=leftdown+2;
}else{
dp[r]=max(dp[r],dp[r-1]);
}
leftdown=pre;
}//倒数第三行开始才有填的必要
}
return dp[len-1];
}
};
115.不同的子序列
给你两个字符串 s
和 t
,统计并返回在 s
的 子序列 中 t
出现的个数,结果需要对 109 + 7 取模。
这道题是一道常见的处理字符串的题目,有两个字符串所以很容易考虑到二维的动态规划,在考虑到两个参数是s,t的长度,所以我们可以根据s,t的前i,j个字符有多少满足题意,当我们确定好代表的状态的时候,为了确定方案的可行性,我们去看看如何写出递推,来到一个位置i,j我们如何根据已经判断过的其他临近位置来确定当前位置的方案数,或者说去考虑那些位置可以一步走到当前位置,那么很自然的思路就是对于目标串s当前的位置,我们是否可以把他作为答案的一部分(也就是可以和t配对),如果我们不把他作为答案的一部分,也就是他对最终答案没有任何贡献,那么其实也就是i-1,j位置的值,那么如果这个位置可以为答案带来变化,那么就是当且仅当当前位置可以和t的当前位置完成配对,那么此时可行的方案就又多了可以配对的情况,也就是这两个位置都没有配对的时候,就是i-1,j-1位置的值,所以综上,位置依赖的递推就是依赖i-1,j(不考虑s当前位置),i-1,j-1(考虑当前位置,并且必须可以配对的情况。看代码。
class Solution {
public:
int numDistinct(string s, string t) {
int n=s.size();
int m=t.size();
int mod=1000000007;
vector<int> dp(m+1);
for(int i=1;i<=n;i++){
dp[0]=1;
for(int j=m;j>0;j--){
if(s[i-1]==t[j-1]){
dp[j]+=dp[j-1];
}
dp[j]%=mod;
}
}
return dp[m];
}
};
这道题我直接使用了空间优化,画图可以发现我们依赖上方和左上方的值,所以如果开一个一维数组,从右往左更新,就可以保证实现滚动更新,且没有数据是污染数据。
72.编辑距离
给你两个单词 word1
和 word2
, 请返回将 word1
转换成 word2
所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
这道题是一道非常经典的字符串dp题目,首先这道题求的是最优的答案,所以我们要考虑所有可能“一步”来到i,j位置的情况,并求出最优的答案。还是老的分类方式,我们依据当前位置能否是最终答案,最简单的情况就是,根本不考虑当前字符,那么就是i-1,j位置的值加一即可,就是删除这个位置的值的代价,那么如果本位置可以作为最终答案的一部分,不要局限于最后的两个字符串末尾对应,因为有插入的方案,所以我们大可以让本位置的值和目标串前j-1个位置对应,再插入一个对应的值(word2[j-1]),那么如果这两个末尾位置的值必须要对应,显而易见要分为,本就配对,那么就直接等于i-1,j-1位置的值,如果不配对那么就进行替换即可,由此我们就讨论完毕了所有可能的“一步”走到当前位置的情况,求最小值得到最优子结构继续递推下去即可。
class Solution {
public:
int minDistance(string word1, string word2) {
int n=word1.size();
int m=word2.size();
vector<vector<int>> dp(n+1,vector<int>(m+1));
//初始化
for(int i=1;i<=n;i++) dp[i][0]=i;
for(int i=1;i<=m;i++) dp[0][i]=i;//此处如果插入删除等代价不是1,就i*对应价值
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
if(word1[i-1]==word2[j-1]){
dp[i][j]=dp[i-1][j-1];
}else{
dp[i][j]=min(dp[i-1][j]+1,dp[i][j-1]+1);//删除,插入
dp[i][j]=min(dp[i][j],dp[i-1][j-1]+1);//替换
}
}
}
return dp[n][m];
}
};
97.交错字符串
给定三个字符串 s1
、s2
、s3
,请你帮忙验证 s3
是否是由 s1
和 s2
交错 组成的。
两个字符串 s
和 t
交错 的定义与过程如下,其中每个字符串都会被分割成若干 非空
子字符串
:
s = s1 + s2 + ... + sn
t = t1 + t2 + ... + tm
|n - m| <= 1
- 交错 是
s1 + t1 + s2 + t2 + s3 + t3 + ...
或者t1 + s1 + t2 + s2 + t3 + s3 + ...
注意:a + b
意味着字符串 a
和 b
连接。
这道题递归方法很好想,就是暴力的一个个尝试,维护指针i,j代表来到了两个子字符串的哪个位置,然后有符合的就递归下去,再加上记忆化搜索其实就可以通过了,但是时间上比较差。
class Solution {
public:
bool isInterleave(string s1, string s2, string s3) {
int n=s1.size();
int m=s2.size();
int k=s3.size();
if(n+m!=k) return false;
vector<vector<int>> dp(n+1,vector<int>(m+1));
return f(s1,n,s2,m,s3,0,0,dp);
}
bool f(string s1,int n,string s2,int m,string s3,int i,int j,vector<vector<int>>& dp){
if(i+j>n+m){
return true;
}
if(dp[i][j]!=0){
return dp[i][j]==1?true:false;
}
bool a=false,b=false;
if(s1[i]==s3[i+j]){
a=f(s1,n,s2,m,s3,i+1,j,dp);
}
if(s2[j]==s3[i+j]){
b=f(s1,n,s2,m,s3,i,j+1,dp);
}
if(a||b){
dp[i][j]=1;
}else dp[i][j]=2;
return a||b;
}
};
根据递归去看,动态规划的思路无非去判断这两个子串是否有位置可以当作母串当前位置的值,那么我们用dp[i][j]代表s1前i个字符与s2前j个字符是否可以组成s3前i+j个字符,那么来到一个i,j我们就需要看是否有两个子串中的某个位置可以完成母串当前的任务,在与上这个字符前一个的状况,两个位置都进行判断即可。细节看代码。
class Solution {
public:
bool isInterleave(string s1, string s2, string s3) {
int n=s1.size();
int m=s2.size();
int k=s3.size();
if(n+m!=k) return false;
vector<vector<bool>> dp(n+1,vector<bool>(m+1));
dp[0][0]=true;
for(int i=1;i<=n;i++){
if(s1[i-1]==s3[i-1]){
dp[i][0]=true;
}else break;
}
for(int i=1;i<=m;i++){
if(s2[i-1]==s3[i-1]){
dp[0][i]=true;
}else break;
}
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
if(s1[i-1]==s3[i+j-1]){
dp[i][j]=dp[i][j]||dp[i-1][j];
}
if(s2[j-1]==s3[i+j-1]){
dp[i][j]=dp[i][j]||dp[i][j-1];
}
}
}
return dp[n][m];
}
};
至此,基础性的dp大致总结完毕,此后将分专题进行dp问题的总结。