动态规划算法总结
动态规划(Dynamic Programming)
是一种分阶段求解决策的数学思想,通过把原问题分成若干个子问题,并归纳子问题在解空间的状态转移方程,利用计算机进行迭代求解的算法。动态规划是目前算法思想中常见的基本思想之一,常常能够很巧妙地解决其他算法难以有效解决的复杂问题,也因此衍生了很多典型的题目和结构。本文将简单举例分析典型的动态规划题目和思想,并给初学算法的同学提供借鉴。当然,刷题不是本质的,题目只是载体,算法的思想,才是真正值得学习和借鉴的东西。
例题1:01背包问题
对于01背包典型问题,网络上题解已经很多了,这里就不再重复典型的01背包问题,而引入一个新的01背包问题,进行分析。虽然这个题目花里胡哨,但本质上其实是和最初版本的01背包问题是相同的。
分析如下:
现在只有0~11号共12个背包,Alice的射箭结果已经确定,Bob需要根据Alice的结果来设计最佳的得分方法。其实,Alice的射箭结果就相当于背包的容量,但是对于本题来说,必须至少付出比容量+1的代价才能“拿走”背包,因为Bob必须>Alice时才能得到k分。这个问题,自然就可以和动态规划01背包问题类比了,不过最后还要回溯一下:
首先,设计数组 d p [ i ] [ j ] dp[i][j] dp[i][j],表示Bob看到了0~i个箭靶时,同时应用了j支箭时,可能的最大的分。那么,状态转移方程如下:
(1)如果不选第i个箭靶进行操作,则得分和看0~(i-1)个箭靶相同,即 d p [ i ] [ j ] = d p [ i − 1 ] [ j ] dp[i][j]=dp[i-1][j] dp[i][j]=dp[i−1][j]
(2)如果可用的总箭数 j > a l i c e A r r o w s [ i ] j>aliceArrows[i] j>aliceArrows[i],可以决定选择第i个箭靶进行操作,那么需要比较选不选的区别,并且取出那个最大的作为真正的 d p [ i ] [ j ] dp[i][j] dp[i][j](定义如此),因此 d p [ i ] [ j ] = m a x { d p [ i − 1 ] [ j ] , d p [ i ] [ j − a l i c e A r r o w s [ i ] ] + k } dp[i][j]=max\{dp[i-1][j],dp[i][j-aliceArrows[i]]+k\} dp[i][j]=max{dp[i−1][j],dp[i][j−aliceArrows[i]]+k}其中,k在这里是i箭靶的得分,本题中k=i。
这样,只需要遍历i = 0:11;j=1:numArrows+1,就可以得到最大的dp值了,即 d p [ 11 ] [ n u m A r r o w s ] dp[11][numArrows] dp[11][numArrows]
但是题目到这里还没有结束,因为还要回溯,怎么回溯呢?可能好多同学又要感觉很复杂了,其实也并不复杂,我们回溯的目的是找到0,1,2,3……11箭靶的应该射箭数,而且还是回溯的到 d p [ 11 ] [ n u m A r r o w s ] dp[11][numArrows] dp[11][numArrows]路径的每一步对应射箭数。其实也很简单,只需要每个i进行回溯即可,具体如下:
首先开辟一个0~11的数组ans[],用来存结果。
然后,i从11开始回溯,如果 d p [ 11 ] [ n u m A r r o w s ] = d p [ 10 ] [ n u m A r r o w s ] dp[11][numArrows] = dp[10][numArrows] dp[11][numArrows]=dp[10][numArrows],那么必然没有选11箭靶,则ans[11]=0;反之,若不想等,则必然选了11箭靶,选了11箭靶,如果是没得分是没有用的,那么肯定是至少选了aliceArrows[11]+1支箭,所以numsArrows减去这些,然后从 d p [ 10 ] [ n u m A r r o w s ] dp[10][numArrows] dp[10][numArrows]继续回溯,直到 d p [ 1 ] [ n u m A r r o w s ] dp[1][numArrows] dp[1][numArrows],到了之后为什么不管dp[0]了呢?因为得分是0,不会对结果有贡献,如果箭是多余的,那么其他所有的箭都给dp[0]就好了,这就是dp[0]的作用,可以承载多余的箭,即负担多余的背包裕量。
以上就是整体思路,C++代码如下:
class Solution {
public:
vector<int> maximumBobPoints(int numArrows, vector<int>& aliceArrows) {
for(int i =0;i<12;i++)
aliceArrows[i] += 1; //这里提前做了+1处理,是为了之后不用写+1了
int dp[12][numArrows+1];
memset(dp,0,sizeof(dp));
// vector<vector<int>> dp(12,vector<int>(numArrows+1,0));
for(int i =1;i<12;i++)
for(int j=1;j<numArrows+1;j++)
{
//不选第i个
dp[i][j] = dp[i-1][j];
if(j>=aliceArrows[i])
dp[i][j] = max(dp[i][j],dp[i-1][j-aliceArrows[i]]+i);
}
vector<int>best(12,0);
int m = 11;
int bag_weight = numArrows;
while(m>=1){
int i = m;
if(dp[i][bag_weight]==dp[i-1][bag_weight])
best[i] = 0;
else{
best[i] = aliceArrows[i];
bag_weight -= aliceArrows[i];
}
m -= 1;
}
int sum = 0;
for(int i =0;i<12;i++)
sum += best[i];
if(sum < numArrows)
best[0] += numArrows - sum;
return best;
}
};
例题2:最大连续子数组和
最大连续子数组和,就是动态规划典型的“序列”问题,包括最大连续回文子串、最长上升子序列、最大连续序列乘积等等,这些可能初学的同学看了就觉得麻烦和恐惧,但是其实都不难,而且都是很经典的题目,只要搞懂了状态转移方程和基本思想,其实都是差不多的解决方法。而且搞懂了这几个之后,对解决动态规划大部分的题目都会很有帮助。
首先来看最大连续子数组和:
思路很简单,只需要定义 d p [ i ] dp[i] dp[i]为以第i个元素结尾的最大和连续子数组的和,状态转移方程即如下所示:
d p [ i ] = m a x { d p [ i − 1 ] + n u m s [ i ] , n u m s [ i ] } dp[i] = max\{dp[i-1]+nums[i],nums[i]\} dp[i]=max{dp[i−1]+nums[i],nums[i]}
之后就很简单了,代码如下:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
//定义dp[i]为以第i个元素结尾的最大和连续子数组的和
/*
dp[i] = max{dp[i-1]+nums[i],nums[i]};
*/
int dp[nums.size()];
memset(dp,0,sizeof(dp));
dp[0] = nums[0];
if(nums.size()==1)
return dp[0];
int maxans = nums[0];
for(int i=1;i<nums.size();i++){
dp[i] = max(dp[i-1]+nums[i],nums[i]);
maxans = max(maxans,dp[i]);
}
return maxans;
}
};
例题3:最长上升子序列:
有了最大连续子数组和的铺垫,最长上升子序列的思路就能简单很多了,题目是要求出最长上升子序列的长度,而子序列是指在序列中顺序是一致的,但未必是连续的。
同样地,我们还是设计一个长度和序列等长的状态数组 d p [ i ] dp[i] dp[i],这一次,我们规定 d p [ i ] dp[i] dp[i]表示的是以 n u m s [ i ] nums[i] nums[i]结尾的最长上升子序列的长度,那么状态转移方程如下:
d p [ i ] = m a x { d p [ j ] + 1 , d p [ i ] } ( 0 = < j < i 且 n u m s [ i ] > n u m s [ j ] ) dp[i] = max\{dp[j]+1,dp[i]\}(0=<j<i且nums[i]>nums[j]) dp[i]=max{dp[j]+1,dp[i]}(0=<j<i且nums[i]>nums[j])
这样似乎就可以了,代码实现一下,也是正确的,如下所示:
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int size = nums.size();
int dp[size],ans = 0;
memset(dp,0,sizeof(dp));
for(int i=0;i<size;i++){
dp[i] = 1;
for(int j=0;j<=i;j++)
if(nums[i]>nums[j])
dp[i] = max(dp[i],dp[j]+1);
ans = max(ans,dp[i]);
}
return ans;
}
};
例题4:最长回文子串
最长回文子串问题要复杂一点,不过也没有特别复杂,要取出字符串中最长的回文子串(连续才是子串)
状态转移方程设计如下:
设 d p [ i ] [ j ] dp[i][j] dp[i][j]代表 s [ i . . . j ] s[i...j] s[i...j]是否是回文串,初始化时,所有长度为1的子串都是回文子串,即对角线元素为true。
之后,遍历可能的子串长度(从2到n),遍历子串中每个元素作为左边界,判断右边界是否等于左边界,如果等于,且当前长度小于等于3,则肯定是回文子串,如果大于3,那就取决于这两个左右边界之内的子串是否是回文子串,即 d p [ i ] [ j ] = d p [ i + 1 ] d p [ j − 1 ] dp[i][j] = dp[i+1]dp[j-1] dp[i][j]=dp[i+1]dp[j−1],同时每次记录一下回文子串的最长长度,并记录左边界,最后无需回溯,用string的substr(begin,maxlen)即可返回要求的最长回文子串,代码实现如下:
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
if (n < 2)
return s;
int maxLen = 1;
int begin = 0;
// dp[i][j] 表示 s[i..j] 是否是回文串
vector<vector<int>> dp(n, vector<int>(n));
// 初始化:所有长度为 1 的子串都是回文串
for (int i = 0; i < n; i++) {
dp[i][i] = true;
}
// 递推开始
// 先枚举子串长度
for (int L = 2; L <= n; L++) {
// 枚举左边界,左边界的上限设置可以宽松一些
for (int i = 0; i < n; i++) {
// 由 L 和 i 可以确定右边界,即 j - i + 1 = L 得
int j = L + i - 1;
// 如果右边界越界,就可以退出当前循环
if (j >= n)
break;
if (s[i] != s[j]) {
dp[i][j] = false;
} else {
if (j - i < 3) {
dp[i][j] = true;
} else {
dp[i][j] = dp[i + 1][j - 1];
}
}
// 只要 dp[i][L] == true 成立,就表示子串 s[i..L] 是回文,此时记录回文长度和起始位置
if (dp[i][j] && j - i + 1 > maxLen) {
maxLen = j - i + 1;
begin = i;
}
}
}
return s.substr(begin, maxLen);
}
};
例题5:乘积最大子数组
本题是最后一道总结的关于序列的动态规划题,也是基本上面试的时候能问的或者是考试的时候能出的最难的一道关于序列的动态规划题了。
题目是这样的:
看起来十分令人头大, 因为有正数也有负数,负数相乘又会变成正数,所以想起来比较复杂。
其实并不是这样的,只要多想一步即可,这还是一个经典的动态规划问题。
怎么多想一步呢?那就是维护两个长度为N的dp[]数组。
具体来说,一方面,我们要维护一个乘积为负的数组 d p 1 [ ] dp1[] dp1[],另一方面,我们要维护一个乘积为正的数组 d p 2 [ ] dp2[] dp2[],其中 d p 1 [ ] dp1[] dp1[]为以nums[i]结尾,乘积最小的负数组; d p 2 [ ] dp2[] dp2[]为以nums[i]结尾,乘积最大的正数组
状态转移方程怎么写呢?其实是这样的:
如果当前nums[i]=0,则必然 d p 1 [ i ] = d p 2 [ i ] = 0 dp1[i]=dp2[i]=0 dp1[i]=dp2[i]=0;如果nums[i]>0,则 d p 1 [ i ] = m a x ( d p 1 [ i − 1 ] ∗ n u m s [ i ] , n u m i [ i ] ) , d p 2 [ i ] = d p 2 [ i − 1 ] ∗ n u m s [ i ] dp1[i] = max(dp1[i-1]*nums[i],numi[i]),dp2[i]=dp2[i-1]*nums[i] dp1[i]=max(dp1[i−1]∗nums[i],numi[i]),dp2[i]=dp2[i−1]∗nums[i];如果nums[i]<0,则 d p 1 [ i ] = d p 2 [ i − 1 ] ∗ n u m s [ i ] , d p 2 [ i ] = m i n ( d p 1 [ i − 1 ] ∗ n u m s [ i ] , n u m s [ i ] ) dp1[i] = dp2[i-1]*nums[i],dp2[i]=min(dp1[i-1]*nums[i],nums[i]) dp1[i]=dp2[i−1]∗nums[i],dp2[i]=min(dp1[i−1]∗nums[i],nums[i])。
状态转移方程确定了,那边界条件如何确定呢?这要看第一个数nums[i]了,首先把dp1[0]、dp2[0]分别置为-1,1,之后第一个数按正负赋给dp1[0]或dp2[0]即可,总的代码如下:
class Solution {
public:
int maxProduct(vector<int>& nums) {
if(nums.size()==1)
return nums[0];
int dp1[nums.size()],dp2[nums.size()];
memset(dp1,0,sizeof(dp1));
memset(dp2,0,sizeof(dp2));
//dp1[]以nums[i]结尾,乘积最小的负数组;dp2[]以nums[i]结尾,乘积最大的正数组
int ans = nums[0];
dp1[0] = 1;
dp2[0] = -1;
if(nums[0]>=0)
dp2[0] = nums[0];
else
dp1[0] = nums[0];
for(int i=1;i<nums.size();i++){
if(nums[i]==0){
dp1[i] = 0;
dp2[i] = 0;
}
if(nums[i]>0){
dp2[i] = max(dp2[i-1]*nums[i],nums[i]);
dp1[i] = dp1[i-1]*nums[i];
}
if(nums[i]<0){
dp2[i] = dp1[i-1]*nums[i];
dp1[i] = min(dp2[i-1]*nums[i],nums[i]);
}
ans = max(ans,dp2[i]);
}
return ans;
}
};
例题6:路径个数(组合数)
路径个数说的是组合数问题的动态规划求解,题目如下:
实际上这就是个组合数问题,即从m+n-2步中选出m-1步是向下走的,n-1步是向右走的,但是,如果直接用组合数公式,则计算量过大,会超时,如何用动态规划求解呢?其实是很简单的,因为每个点只有两条可达路径,从左或从上,那么思路就很简单了,直接放代码如下:
class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> f(m, vector<int>(n));
for (int i = 0; i < m; ++i)
f[i][0] = 1;
for (int j = 0; j < n; ++j)
f[0][j] = 1;
for (int i = 1; i < m; ++i)
for (int j = 1; j < n; ++j)
f[i][j] = f[i - 1][j] + f[i][j - 1];
return f[m - 1][n - 1];
}
};
这道题的价值不仅在于应用了动态规划的思想,而且这也是一种高效的组合数计算算法,将原来的乘法运算转化为加法运算,大大提高了算法效率。
例题7:括号生成
括号生成问题如下:
这也可以用动态规划+递归的算法思想解决,具体是这样的:
我们用 d p [ i ] dp[i] dp[i]来表示 i i i对括号时,所有可能的排列,即 d p [ i ] dp[i] dp[i]是一个列表,包含了所有的 i i i对括号可能的排列。
那么,对于 d p [ n ] dp[n] dp[n]来说,所有的可能就是 d p [ n ] = " ( d p [ p ] ) " + d p [ q ] , p + q = n − 1 dp[n] = "(dp[p])"+dp[q],p+q = n-1 dp[n]="(dp[p])"+dp[q],p+q=n−1,可以很容易地证明,这样是没有重复的,因为对不同的 ( p , q ) (p,q) (p,q)的组合来说,是不同的,这样一来,代码如下:
C++版本:
class Solution {
public:
vector<string> generateParenthesis(int n) {
//注意此处{}用法是C++11特性
if(n==0) return {};
if(n==1) return {"()"};
vector<vector<string>> dp(n+1);
dp[0] = {""};
dp[1] = {"()"};
for(int i=2;i<=n;i++)
for(int j=0;j<i;j++){
for(string p:dp[j])
for(string q:dp[i-j-1]){
string str = "("+p+")"+q;
dp[i].push_back(str);
}
}
return dp[n];
}
};
Python版本:
class Solution:
def generateParenthesis(self, n: int) -> List[str]:
dp = [0]*(n+1)
dp[0] = [""]
dp[1] = ["()"]
for i in range(2,n+1):
dp[i] = []
for j in range(i):
for p in dp[j]:
for q in dp[i-j-1]:
tmp = "("+p+")"+q
dp[i].append(tmp)
return dp[n]
本文就到这里了,因为作者要睡觉了。