之前刷leecode的时候最怕看到的就是动态规划类的问题,自己敲破脑袋也想不出来,看了答案之后总觉得如神来之笔。无法摸清思考的方向。上了卜老师的算法课之后感触还是挺深的,对于一些常见的动态规划问题也不像之前那样完全丈二摸不着头脑了,现对一些常见的算法和理论基础总结一下。
动态规划是一种适用于大部分优化问题的算法思想,与之前学过的分治法一样,都是希望将问题分解为更小的子问题进行优化求解,但是动态规划相较于分治法更加抽象、难以理解。
可以规约的问题一般用分治法就能解决,但是在包含最优子结构的情况时,需要使用动态规划进行求解。所谓最优子结构,就是子问题与原问题有一样的结构并且原问题的最优解包含子问题的最优解,这样才能利用动态规划的思想递归进行求解。
动态规划最核心的思想就是要定义好状态和状态转移函数。所谓状态就是将问题变成函数,比如0-1背包问题中 d ( i ) d(i) d(i)就是背包内物品重量为 i i i时,背包能产生的最大价值;而状态转移函数就是寻求当前状态与之前状态的关系,比如0-1背包问题中状态函数反映的是第 j j j个物品装与不装对状态的影响, d ( i ) = m a x { d ( i ) , d ( i − w ( j ) ) + v ( j ) } d(i)=max\{d(i),d(i-w(j))+v(j)\} d(i)=max{d(i),d(i−w(j))+v(j)}
下面列举一些经典的动态规划问题,完全理解对学习动态规划很有帮助。
0-1背包问题是最典型的动态规划问题,leecode里没有完全一样的题,但是可以用一个简单的问题描述来说清楚。输入物品的重量数组 w w w,价值数组 v v v,那么需要设一个数组 d p dp dp, d p [ i ] [ j ] dp[i][j] dp[i][j]表示以 j j j为容量放入前 i i i个物品产生的最大价值,边界条件为 d [ i ] [ 0 ] = 0 , d [ 0 ] [ j ] = 0 d[i][0]=0,d[0][j]=0 d[i][0]=0,d[0][j]=0。对于每个物品,有装与不装两种情况,若不装第 i i i个物品,那么对于可装重量为 j j j的背包产生的最大价值为 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i−1][j];若装入第 i i i个物品,则必须保证第 i i i个物品被装入,那么可装重量要变为 j − w [ i ] j-w[i] j−w[i],用于装前 i − 1 i-1 i−1件物品,这样就与之前的 d p dp dp数组产生了关联,这时最大价值为 d p [ i − 1 ] [ j − w [ i ] ] + v [ i ] dp[i-1][j-w[i]]+v[i] dp[i−1][j−w[i]]+v[i]。所以综合两种情况,有 d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − w [ i ] ] + v [ i ] ) dp[i][j]=max(dp[i-1][j], dp[i-1][j-w[i]]+v[i]) dp[i][j]=max(dp[i−1][j],dp[i−1][j−w[i]]+v[i])。
根据该递归表达式,可以看出 d p [ i ] [ j ] dp[i][j] dp[i][j]只与 d p [ i − 1 ] [ x ] ( x = 1... j ) dp[i-1][x](x=1...j) dp[i−1][x](x=1...j)有关,这些都是前面计算过的值,因此可以将动态规划的数组纬度降低为一维数组,化简后的递归表达式为 d p [ j ] = m a x ( d p [ j ] , d p [ j − w [ i ] ] + w [ i ] ) dp[j]=max(dp[j], dp[j-w[i]]+w[i]) dp[j]=max(dp[j],dp[j−w[i]]+w[i]),这时dp[j]的含义与上面 d p [ i ] [ j ] dp[i][j] dp[i][j]的含义相同,表示可装重量为 j j j的背包放入前 i i i个物品产生的最大价值。
核心的代码如下所示:
for(int i=0; i=w[i]; j--){ //背包的最大重量为W
dp[j]=max(dp[j], dp(j-w[i])+v[i]);
}
}
对于多维的约束问题(除了书包负重之外,还有别的约束如体积约束),那么只需对数组增加一个纬度,多一层循环即可。算法的时间复杂度为 O ( n N ) O(nN) O(nN),空间复杂度为 O ( N ) O(N) O(N)( N N N表示书包能装的最大重量, n n n表示物品的数目)。
0-1背包问题延伸之一是完全背包问题。
完全背包问题与0-1背包的不同点在于物品的个数可以是无限的,同样的对于输入的物品有重量数组 w w w,价值数组 v v v。对于二维的 d p dp dp数组,其递归表达式为 d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] + d p [ i ] [ j − w [ i ] ] + v [ i ] ) dp[i][j]=max(dp[i-1][j]+dp\red{[i]}[j-w[i]]+v[i]) dp[i][j]=max(dp[i−1][j]+dp[i][j−w[i]]+v[i]),与0-1背包唯一的不同就是存放完当前第 i i i种物品之后,还可以继续存放第 i i i种物品。
整理为一维的形式,存在的差别就是内层循环顺序不同,0-1背包是内层逆序循环,保证已经取到的物品不会再次被取到;而完全背包是顺序循环,顺序循环可以覆盖以前的状况,所以会存在多次选取的问题,完全符合题意。
核心的算法代码如下所示:
for(int i=0; i
与0-1背包问题一样,算法的时间复杂度为 O ( n N ) O(nN) O(nN),空间复杂度为 O ( n ) O(n) O(n)。
0-1背包问题延伸之二是多重背包问题
多重背包问题中每个物品的个数是有限个的,即给定的除了重量数组 w w w、价值数组 v v v,还要给定一个数量数组 n u m num num,表示每个物品的件数。借助之前的思想,可以把所有的相同物品拆分成一件件的不同的物品,它们的价值还是相同,这样就变成了一个加大版的0-1背包问题,所需时间复杂度为 O ( N ∑ i = 0 n n u m [ i ] ) O(N\sum_{i=0}^{n}num[i]) O(N∑i=0nnum[i]),算法时间复杂度随物品数量现线性增加。在网上看到一种巧妙的解法:利用二进制数的性质将某件物品分为若干件,每件的重量和价值系数分别为 1 , 2 , 4 , . . . , n u m s [ i ] − 2 k − 1 + 1 1,2,4,...,nums[i]-2^{k-1}+1 1,2,4,...,nums[i]−2k−1+1,拆分完后问题也变成了0-1背包问题。例如,当某件物品的数量为13时,可以将其拆分为4件不同的物品,每件物品的重量和价值系数分别为 1 , 2 , 4 , 6 1,2,4,6 1,2,4,6,那么利用二进制数的性质,0-13均可由这4个数组合而成。
核心代码如下:
int index = 0;
vector w_new;
vector v_new;
for(int i=0; i0){
num[i] -= k;
w_new.push_back(k*w[i]);
v_new.push_back(k*v[i]);
k<<1;
}
w_new.push_back(num[i]*w[i]);
v_new.push_back(num[i]*v[i]);
}
int n_new = w.size();
for(int i=0; i=w_new[i]; --j){
dp[j] = max(dp[j], dp[j-w_new[i]]+v_new[i]);
}
}
算法的时间复杂度为 O ( n ∑ i = 1 n l o g ( n u m [ i ] ) ) O(n\sum_{i=1}^{n}log(num[i])) O(n∑i=1nlog(num[i])),空间复杂度为 O ( N ) O(N) O(N)。
另外一个十分经典的动态规划例子是最长公共子序列问题。最长公共子序列两个序列的相同字符组成的字符串,且在这个子字符串中字符的顺序与在原序列中字符的顺序相同。
示例1:
输入: text1 = "abcde", text2 = "ace"
输出: 3 //最长子序列"ace"
示例2:
Input: text1 = "abc", text2 = "def"
Output: 0
这题同样可以用一个二维数组 d p dp dp用于动态规划的记录, d p [ i ] [ j ] dp[i][j] dp[i][j]表示两个序列的索引分别为 0 − i 0-i 0−i、 0 − j 0-j 0−j时所产生的相同子序列最大长度。那么对于输入的两个序列 s s s、 t t t,如果 s [ i ] = = t [ j ] s[i]==t[j] s[i]==t[j],那么此时最长子系列 d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + 1 dp[i][j]=dp[i-1][j-1]+1 dp[i][j]=dp[i−1][j−1]+1,若 s [ i ] ! = s [ j ] s[i]!=s[j] s[i]!=s[j],那么此时最长子序列 s [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) s[i][j]=max(dp[i-1][j],dp[i][j-1]) s[i][j]=max(dp[i−1][j],dp[i][j−1])。
具体的实现代码如下所示:
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int n1 = text1.size(), n2 = text2.size();
vector> dp(n1+1, vector(n2+1, 0));
for(int i=0;i
需要设置一个二维数组 d p dp dp,同时对两个序列的所有元素都要遍历一次,因此算法的时间复杂度和空间复杂度都是 O ( m n ) O(mn) O(mn)。
这是leetcode上整个买卖股票的系列,总共有六题,均可利用动态规划的思想进行求解,难度覆盖了easy、medium、hard三个等级。根据题目序号,从最简单的开始慢慢往上增加难度。
对于给定的数组,代表第 i i i天股票的价格,现在只允许一次买卖,求得到的最大利润。
示例 1:
输入: [7,1,5,3,6,4]
输出: 5 //第二天买第五天卖可以获得最大利润
示例2:
输入: [7,6,4,3,1]
输出: 0 //不存在买卖股票的行为可以获得利润
这是买卖股票最基础的版本,只允许买卖一次股票。很轻易可以想到动态规划的求解迭代表达式 d p [ i ] = m a x ( d p [ i − 1 ] , p r i c e s [ i ] − p r i c e s [ j ] ) , j ∈ ( 0 , i − 1 ) dp[i]=max(dp[i-1],prices[i]-prices[j]),j\in(0,i-1) dp[i]=max(dp[i−1],prices[i]−prices[j]),j∈(0,i−1)。由于当前最大值只与前一个相关,因此整个数组只需一个变量值保存即可,表达式可以化简为 d p = m a x ( d p , p r i c e s [ i ] − p r i c e s [ j ] ) , j ∈ ( 0 , i − 1 ) dp=max(dp,prices[i]-prices[j]),j\in(0,i-1) dp=max(dp,prices[i]−prices[j]),j∈(0,i−1)。目的是使得 p r i c e s [ i ] − p r i c e s [ j ] , j ∈ ( 0 , i − 1 ) prices[i]-prices[j],j\in(0,i-1) prices[i]−prices[j],j∈(0,i−1)取得最大值,也就是 p r i c e s [ j ] prices[j] prices[j]取得最小值,因此只要将当前为止的最小值存入一个变量,就不用再重复寻找,表达式再次化简为 d p = d p ( d p , p r i c e s [ i ] − m i n j = 0 i p r i c e s [ j ] ) dp=dp(dp,prices[i]-min^{i}_{j=0}prices[j]) dp=dp(dp,prices[i]−minj=0iprices[j])。
代码如下所示:
class Solution {
public:
int maxProfit(vector& prices) {
int profit = 0, minValue = INT_MAX;
int n = prices.size();
for(int i=0;i
算法只对整个数组遍历一次,因此时间复杂度为 O ( n ) O(n) O(n),只用了两个变量分别保存最小值和最大利润,因此空间复杂度为 O ( 1 ) O(1) O(1)。
同样对于给定的数组,代表第 i i i天股票的价格,可以买卖任意次数,求得到的最大利润。
示例1:
输入: [7,1,5,3,6,4]
输出: 7
示例2:
输入: [1,2,3,4,5]
输出: 4
这是买卖股票系列的第二题,也是一道简单题。与第一道题不同之处在于可以买卖多次,虽然说不允许当天同时买卖,但是解题时却可以这么考虑。给定如下情形 i < k < j , p r i c e s [ i ] < p r i c e s [ k ] < p r i c e s [ j ] i
代码如下所示:
class Solution {
public:
int maxProfit(vector& prices) {
int total = 0, n = prices.size();
for(int i=1;iprices[i-1]) total += prices[i]-prices[i-1];
return total;
}
};
遍历了整个数组,时间复杂度为 O ( n ) O(n) O(n),空间复杂度为 O ( 1 ) O(1) O(1)。
买卖股票系列之三和四,也是第一次碰到leecode里的困难题。不同之处在于买卖股票的数量被限制为了最多2和K次,由于K次也包含了第二次,因此现考虑买卖K次的求解情况。
这题可以用标准的动态规划求解思路进行求解,两次交易需要设置二维的 d p dp dp数组,第一维表示交易的次数,第二位表示交易的天数。对于第 i i i天,如果当天不买卖股票,那么最大利润即为第 i − 1 i-1 i−1天产生的利润,此时利润为 d p [ k ] [ i − 1 ] dp[k][i-1] dp[k][i−1],如果当天卖出股票,那多产生了一次交易,那么需要在第 j ( j ∈ ( 0 , i − 1 ) ) j(j\in(0,i-1)) j(j∈(0,i−1))天买入股票,最大利润为前 j − 1 j-1 j−1天的前 k − 1 k-1 k−1次交易产生的利润和当天交易产生的利润之和,即 d p [ k − 1 ] [ j − 1 ] + p r i c e s [ i ] − p r i c e s [ j ] dp[k-1][j-1]+prices[i]-prices[j] dp[k−1][j−1]+prices[i]−prices[j]。因此 d p [ k ] [ i ] = m a x ( d p [ k ] [ i − 1 ] , p r i c e s [ i ] − p r i c e s [ j ] + d p [ k − 1 ] [ j − 1 ] ) , j ∈ ( 0 , i − 1 ) dp[k][i]=max(dp[k][i-1],prices[i]-prices[j]+dp[k-1][j-1]),j\in(0,i-1) dp[k][i]=max(dp[k][i−1],prices[i]−prices[j]+dp[k−1][j−1]),j∈(0,i−1)。按照表达式写出的代码为:
for(int k=1;k<=K;++k){
for(int i=1;i
完整计算有三层循环,时间复杂度为 O ( k n 2 ) O(kn^2) O(kn2),因此需要进行优化。在这个循环中,实际上 m i n V a l u e minValue minValue被重复计算了,可以减少一重循环,优化后的代码为:
for(int k=1;k<=K;++k){
int minValue = prices[0];
for(int i=1;i
减少一层循环,时间复杂度变为 O ( k n ) O(kn) O(kn),空间复杂度也是O(kn),实际上空间复杂度还有优化的空间,根据代码可以看出第 i i i天产生的最大利润只与第 i − 1 i-1 i−1天相关,因此可以将 d p dp dp的第二个维度去掉,改进后的代码段为:
for(int i=1;i
最后完整的程序代码为
class Solution {
public:
int maxProfit(vector& prices) {
if(!prices.size()) return 0;
int K=2, n=prices.size();
vector dp(K+1, 0);
vector minValue(K+1, prices[0]);
for(int i=1;i
经过最后的优化,时间和空间复杂度都为 O ( k n ) O(kn) O(kn)。
对买卖股票加了限制条件,卖完股票必须隔一天才能买入新的股票。
那么每一天实际上有三种状态:买、卖、等,因此我们可以设置三个数组 b u y 、 s e l l 、 r e s t buy、sell、rest buy、sell、rest分别表示当天买、卖、休息所产生的最大利润,可以建立与之前天数的依赖关系。
b u y [ i ] = m a x ( b u y [ i − 1 ] , r e s t [ i − 1 ] − p r i c e s [ i ] ) buy[i]=max(buy[i-1],rest[i-1]-prices[i]) buy[i]=max(buy[i−1],rest[i−1]−prices[i]);不买或者之前休息当天买
s e l l [ i ] = m a x ( s e l l [ i − 1 ] , b u y [ i − 1 ] + p r i c e s [ i ] ) sell[i]=max(sell[i-1],buy[i-1]+prices[i]) sell[i]=max(sell[i−1],buy[i−1]+prices[i]);不卖或者之前买当天卖
r e s t [ i ] = m a x ( s e l l [ i − 1 ] , b u y [ i − 1 ] , r e s t [ i − 1 ] ) rest[i]=max(sell[i-1],buy[i-1],rest[i-1]) rest[i]=max(sell[i−1],buy[i−1],rest[i−1]);之前买卖的最大值
观察 r e s t rest rest的表达式,很明显 s e l l [ i − 1 ] > = r e s t [ i − 1 ] > b u y [ i − 1 ] sell[i-1]>=rest[i-1]>buy[i-1] sell[i−1]>=rest[i−1]>buy[i−1],因此有 r e s t [ i ] = s e l l [ i − 1 ] rest[i]=sell[i-1] rest[i]=sell[i−1],故而可将三个表达式变为两个表达式:
b u y [ i ] = m a x ( b u y [ i − 1 ] , s e l l [ i − 2 ] − p r i c e s [ i ] ) buy[i]=max(buy[i-1],sell[i-2]-prices[i]) buy[i]=max(buy[i−1],sell[i−2]−prices[i])
s e l l [ i ] = m a x ( s e l l [ i − 1 ] , b u y [ i − 1 ] + p r i c e s [ i ] ) sell[i]=max(sell[i-1],buy[i-1]+prices[i]) sell[i]=max(sell[i−1],buy[i−1]+prices[i])
观察这两个表达式,其实还有可以化简饿的地方,第 i i i天只与第 i − 1 i-1 i−1、 i − 2 i-2 i−2天会产生直接的联系,因此这两个数组是多余的时间开销,可以用几个变量表示即可,时间复杂度从 O ( n ) O(n) O(n)可降为 O ( 1 ) O(1) O(1)。
最后的代码如下:
class Solution {
public:
int maxProfit(vector& prices) {
int preSell = 0, sell = 0, preBuy = 0, buy = INT_MIN;
int n = prices.size();
for(int i=0;i
遍历一次整个数组,时间复杂度为 O ( n ) O(n) O(n),4个变量存储当前买卖的交易额,空间复杂度为 O ( 1 ) O(1) O(1)。
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
示例 1:
输入: coins = [1, 2, 5], amount = 11
输出: 3
示例 2:
输入: coins = [2], amount = 3
输出: -1
零钱交换问题类似于背包问题,不同之处在于零钱交换问题是将一个凑足一个给定大小的值。这是一个很明显的动态规划问题,设置一个数组 d p dp dp, d p [ i ] dp[i] dp[i]表示要凑足钱的大小为 i i i时的最小硬币数量,那么显然有 d p [ i ] = m i n j { d p [ i − c o i n s [ 0 ] ] , . . . . . , d p [ i − c o i n s [ j ] , . . . . , d p [ i − c o i n s [ n − 1 ] ] } + 1 dp[i]=min_j \{dp[i-coins[0]],.....,dp[i-coins[j],....,dp[i-coins[n-1]]\}+1 dp[i]=minj{dp[i−coins[0]],.....,dp[i−coins[j],....,dp[i−coins[n−1]]}+1。
具体的代码为:
class Solution {
public:
int coinChange(vector& coins, int amount) {
int n = coins.size();
vector dp(amount+1,INT_MAX-1);
dp[0] = 0;
for(int i=0; i<=amount; ++i){
for(int j=0;j=coins[j])
dp[i] = min(dp[i-coins[j]]+1, dp[i]);
}
}
return dp[amount]==INT_MAX-1? -1: dp[amount];
}
};
遍历了整个输入数组和动态规划大小的数组,时间复杂度为 O ( S n ) O(Sn) O(Sn),空间复杂度为 O ( S ) O(S) O(S)。
给定一个代表硬币面值的数组和一个固定的金额,求用这些硬币有多少种组成这个金额的方法。硬币的数量无限
示例1:
输入: amount = 5, coins = [1, 2, 5]
输出: 4
示例2:
输入: amount = 3, coins = [2]
输出: 0
这道题很容易想到利用动态规划进行求解。 d p [ i ] [ j ] dp[i][j] dp[i][j]表示用前 i i i枚硬币组成金额为 j j j的方法数,很明显有 d p [ i ] [ j ] = d p [ i − 1 ] [ j ] + d p [ i − 1 ] [ j − c o i n s [ i ] ] + . . . + d p [ i − 1 ] [ j − k ∗ c o i n s [ i ] ] dp[i][j]=dp[i-1][j]+dp[i-1][j-coins[i]]+...+dp[i-1][j-k*coins[i]] dp[i][j]=dp[i−1][j]+dp[i−1][j−coins[i]]+...+dp[i−1][j−k∗coins[i]],其中 < k ∗ c o i n s [ i ] j < ( k + 1 ) ∗ c o i n s [ i ]
具体的代码为
int change(int amount, vector& coins) {
int n = coins.size();
//vector> dp(n+1, vector(amount+1, 0));
vector dp(amount+1, 0);
dp[0]=1;
for(int i=1; i<=n; ++i){
for(int j=coins[i-1]; j<=amount; ++j)
dp[j] = dp[j] + dp[j-coins[i-1]];
}
return dp[amount];
}
算法的时间复杂度为 O ( m n ) O(mn) O(mn),空间复杂度为O(m)