目录
动态规划的基本概念
动态规划的本质是通过对中间结果的记录实现用空间节省大量时间.有些问题可以被分解为类似的小问题,但是由于存在需要统筹全局的条件等原因没有办法简单地合并得到最终结果,或者会消耗大量的时间,此时就可以考虑动态规划.
可以用动态规划思路解决的问题往往存在一个状态转移方程,来描述从局部得到整体结果的过程.只要能得到正确的转移方程,动态规划的题目也就很简单了,但往往它并不容易得到.
动态规划的思路抽象,灵活度高,没有固定的解题套路,所以历来是学习者的难点和重点.在这里我们不做过多的概念描述,直接上题吧.
动态规划例题分析
爬楼梯
暴力解法:直接对每一步的走法进行遍历,并求和得到结果.可以想象这会是一个时间复杂度非常高的思路.
动态规划:第一个台阶与第二个台阶的情况很简单,都只有一种方式到达,那就是直接走上去.从第n(n>2)个台阶开始,每一个台阶都有两种方式到达:
1.从第n-1个台阶走上去
2.从第n-2个台阶走上去.
我们用一个数组sum[]来存储最后一个台阶的下标为n的时候,爬楼梯的可能性之和,那么我们得到状态转移方程:sum[n]=sum[n-1]+sum[n-2].是不是很熟悉?这个题目不过是换了一个马甲的斐波拉契数列罢了.从这里我们也可以看出,动态规划可以被理解为通过过程记忆化的方式优化的递归算法.下面给出代码:
int climbStairs(int n) {
if(n==0) return 0;
if(n==1) return 1;
if(n==2) return 2;
int before1=1;
int before2=2;
int now;
for(int i=3;i<=n;i++){
now=before1+before2;
before1=before2;
before2=now;
}
return now;
}
实际上就是一个计算斐波拉契数列的算法.
买卖股票的最佳时机
本题目当然也可以使用暴力解法,枚举在每一天买入,之后的每一天抛售的所有结果,并得到其中利润最大的结果.但这种方式的时间复杂度是难以接受的.
采用动态规划的思路解决.可以使用数组profit[n]表示第n天抛售股票的最大利润,那么最终的最大利润自然是profit[]中最大的数字了.那么如何计算profit[n]呢?
profit[n]表示第n天抛售的最大利润,那么利润最大时,显然是买入价格最低时.不妨提前记录到n天之前最低的买入价格minPrice,如果nowPrice表示第n天价格,那么profit[n]=nowPrice-minPrice自然是最大的利润.
下面给出代码:
int maxProfit(vector<int>& prices) {
int minprice = MAX_INT;
int maxprofit = 0;
for (int price: prices) {
maxprofit = max(maxprofit, price - minprice);
minprice = min(price, minprice);
}
return maxprofit;
}
可以发现,如果不使用动态规划算法,进行盲目的递归或者遍历的话,不会考虑到在买入价格取可能的最小值时,才有可能得到利润的最大值,因此会对买入价格偏高的情况进行大量盲目的遍历.而动态规划正是通过对"到目前位置的买入最小值"这一变量进行了存储,节约了大量的时间.这正是动态规划的思想精髓.
01背包问题
有n件物品,每件物品的重量为w[i],价值为c[i]。现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大
首先需要强调的是,01背包问题的前提是每件物品都只有两种状态:被选和没有被选.并不能将一件物品的一部分装入背包.这正是01背包中01的意义.如果可以将一件物品的一部分装入背包,那么完全可以采用比动态规划更简单的思路:通过每件物品的价值/重量求出每件物品的价值密度(我自己编的名词).然后从高到低取物品即可.这种思路就是一种简单的贪心算法思路.因为01背包题目中的物品只能取或者不取,所以在其容量与物品重量有较大冲突时,用简单的贪心算法并不能得到正确的结果.
用动态规划的方式解决01背包问题,我们首先要定义一个dp[][]数组,其中dp[i][j]表示将前i件物品装入容量为j的背包,可以得到的最大价值.通过某种算法将dp[][]数组填满后,很显然最后一个元素dp[n][v]就是题目的答案.那么如何去填满dp[][]数组呢?
我们通过一个例子来模拟这个动态规划的计算过程.
前i个物品\容量为j | 容量0 | 容量1 | 容量2 | 容量3 | 容量4 | 容量5 | 容量6 | 容量7 | 容量8 |
前0件物品 | |||||||||
前1件物品 | |||||||||
前2件物品 | |||||||||
前3件物品 | |||||||||
前4件物品 |
首先,在背包容量为0,以及背包只能装前0件物品时,显然背包内最大价值是0.也就是说,dp[i][0]和dp[0][j]一定是0.由此我们填入动态规划的初始条件.
前i个物品\容量为j | 容量0 | 容量1 | 容量2 | 容量3 | 容量4 | 容量5 | 容量6 | 容量7 | 容量8 |
前0件物品 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
前1件物品 | 0 | ||||||||
前2件物品 | 0 | ||||||||
前3件物品 | 0 | ||||||||
前4件物品 | 0 |
然后我们需要找到状态转移方程.我们先尝试着填满dp[][]表:
1.dp[1][1]:也就是将前1一件物品装入容量为1的背包.查阅得到第1件物品重量为2,所以不可能装入背包,此时背包最大价值仍然是0.因此dp[1][1]填入0.
2.dp[1][2]:也就是将前1一件物品装入容量为2的背包.因为第一件物品重量为2,此时可以装入背包,因此dp[1][2]填入第一件物品的价值3.
3.dp[1][2+]:也就是将前一件物品装入容量比2更大的背包时,因为只能装前1件物品,所以不管背包容量有多大,也只能装这一件.因此dp[1][2+]全部填入3.
4.dp[2][1]:也就是将前2一件物品装入容量为1的背包.因为前两件物品的重量都大于1,所以无法装入任何一件物品.背包价值仍然为0.
5.dp[2+][i]:因为所有物品的重量都大于1,所以没有可以装入背包的,因此第1列后面的元素全部为0.
6.dp[2][2]:也就是将前2一件物品装入容量为2的背包.我们看到这个元素上方的dp[1][2],是在只能取前一件物品时的背包价值为3,那么dp[2][2]如果可能更大,结果只能是新加入考虑范围的第2件物品同时也可能装入背包.但很可惜,背包已满,无法装入第二件物品,这里与dp[1][2]的结果相同,也是3.
但我们这时候就可以发现:dp[i][j]与其前面的元素的值,一定是有某种联系的.此时我们先不要继续填表,我们试着找一下规律吧.
前i个物品\容量为j | 容量0 | 容量1 | 容量2 | 容量3 | 容量4 | 容量5 | 容量6 | 容量7 | 容量8 |
前0件物品 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
前1件物品 | 0 | 0 | 3 | 3 | 3 | 3 | 3 | 3 | 3 |
前2件物品 | 0 | 0 | 3 | ||||||
前3件物品 | 0 | 0 | |||||||
前4件物品 | 0 | 0 |
假如我想填入dp[i][j],也就是偷取前i件物品,背包容量为j.与前一行也就是第一个下标为i-1时相比,此时出现的新情况有以下几种情况:
1.第i件物品完全装不下.那么dp[i][j]只能跟dp[i-1][j]相同.即dp[i][j]=dp[i-1][j].
2.第i件物品可以取到.那么我们可以取第i件物品,但也可以不取.那么我们就需要在取与不取中.选择能使价值更高的一个作为结果.如果不取,则与1相同,价值为dp[i-1][j].如果取,则价值为dp[i-1][j-wi]+vi.也就是在背包给第i件物品腾出空间的情况下的最大价值+第i件物品的价值.所以dp[i][j]就是上述两者的最大值max(dp[i-1][j],dp[i-1][j-w1]+vi).
由此我们得到了状态转移方程:
dp[i][j]= dp[i-1][j] (装不下第i件)
max(dp[i-1][j],dp[i-1][j-w1]+vi)(装得下第i件)
由此我们可以填满表格.
前i个物品\容量为j | 容量0 | 容量1 | 容量2 | 容量3 | 容量4 | 容量5 | 容量6 | 容量7 | 容量8 |
前0件物品 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
前1件物品 | 0 | 0 | 3 | 3 | 3 | 3 | 3 | 3 | 3 |
前2件物品 | 0 | 0 | 3 | 4 | 4 | 7 | 7 | 7 | 7 |
前3件物品 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 9 |
前4件物品 | 0 | 0 | 3 | 4 | 5 | 8 | 8 | 11 | 12 |
由此得到了最终答案dp[4][8]=12为最大的价值.上面的过程就是01背包问题的解决过程.
下面给出01背包问题的代码:
#include<iostream>
#include<algorithm>
using namespace std;
int dp[10][1000];
int weight[10];
int value[10];
int main()
{
int N,M;
cin>>N;//物品个数
cin>>M;//背包容量
for (int i=1;i<=N; i++){
cin>>weight[i]>>value[i];
}
for (int i=1; i<=N; i++)
for (int j=1; j<=M; j++){
if (weight[i]<=j){
dp[i][j]=max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i]); //状态转移方程
}
else
dp[i][j]=dp[i-1][j];
}
cout<<dp[N][M]<<endl;
}
在这里推荐一个b站讲解01背包的视频,上面的样例便是来自这个视频,笔者也是通过这个视频完全理解了01背包.在这里给出视频链接,如果读者没能理解这篇文章,可以移步:
当然,上面的01背包解法只是最基础的解法,如果读者感兴趣,可以自行搜索更加优秀的算法.例如,dp[][]数组可以被优化为一维数组等.这里不做赘述.
总结
可以看出,动态规划往往可以通过空间节省时间.当然,它也有很多缺陷.
首先是状态转移方程和存储使用的dp数组并不好确定.根据笔者的做题经验,这个dp数组往往具有过程性的特点,也就是它往往代表到某个中间过程为止时的最好解法.因此在确定dp数组时,可以假设dp[i]代表到i时间位置的最优解,这样的思路往往能逼近答案.例如股票题目中dp[i]代表第i天的最大利润,台阶问题中dp[i]代表第i个台阶时的情况总和...如果实在想不出,就考虑能不能采用二维数组,以此类推.确定了dp的意义,下一步就是考虑初始值和状态转移方程的设计.正如上面解决01背包的问题过程,可以试着先手动填写dp数组,逐渐掌握规律.
此外,dp算法还有一个问题,就是无法简单地得到具体的解决路径.例如我想知道在最优解的情况下,01背包内装了哪些物品?在能获利最大的情况下,我在第几天购买,第几天抛售的股票?这些问题或许可以从算法的中间步骤存储得到,但是无法反驳dp算法确实不重视这类问题的特征.