目录
文章内容来自对牛客网左神课程的整理。
很多同学(包括今天之前的我)都认为动态规划很难,其实很大程度上是因为不知道动态规划是怎么从其他算法一步步优化演变来的,上来就介绍动态规划的方法论,难免接受度比较低。
“输出是最好的学习方法”,因此本文旨在整理左神课程中对动态规划的讲解,增加图示内容,一是对个人学习效果的检验,二是希望大家能真正理解并熟练运用动态规划算法。
为什么要从一道题目开始引入?
算法重在应用,相较于理论说教,在应用场景里去体会算法的优劣和演化是非常重要的。
在此文后会接连推出对经典动态规划问题的题解。
ps:要先自己理解透彻算法的计算路径再去看代码,看不懂代码是因为算法还没有理解,而不是因为其他。
首先,大部分暴力搜索问题都能优化到动态规划问题。
暴力搜索->记忆化搜索->动态规划->动态规划的进一步优化 是常见的算法优化路径。
找零钱问题:
给定数组penny,数组元素值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数aim代表要找的钱数,求换钱有多少种方法。
假设penny[]为{5,8,10,15,20},aim 为120
最简单的:暴力搜索
暴力搜索就是把所有的情况都列出,如果能得到一种方法凑成aim,就在计数器上加1.
res1、res2 等需要继续分解计算,因此可通过递归算法进行代码实现。
暴力搜索代码实现:
首先找出递归算法需要哪些数值:数组penny,数组中可以选择货币面值的起始位置index,还需要兑换多少钱aim。
即每次递归是在penny[index],penny[index+1],..penny[N-1]中寻找可兑换成aim的方法种类数。
class Exchange {
public:
int process(vector<int>penny,int index,int n,int aim){//penny为面值数组,index为数组中表示可使用货币的子数组的起始位置,
//n 为数组大小,aim为当前要兑换的面值。
int ress = 0;
if(index == n)//递归边界,当前没有零钱货币可以选择
{
ress = aim == 0?1:0;//如果aim为0,也就是在没有零钱货币可以选择并且当前需要兑换面值为0时,这种方案是可行的,则res=1;
//否则当前没有零钱可以选择去兑换aim,且aim不为0,这种情况是不可行的,因此可行的方案数res为0;
return ress;
}
else{//index<=n;
for(int i = 0;penny[index]*i<=aim;i++){
ress+=process(penny,index+1,n,aim-penny[index]*i);
}
return ress;
}
}
int countWays(vector<int> penny, int n, int aim) {
// write code here
if(aim<0 || n == 0 || penny.size()==0)
return 0;
int rest = process(penny,0,n,aim);
return rest;
}
};
递归计算res1时,把在{8,10,15,20}中换120元的基础上,选0张8元货币,在{10,15,20}中换120元这种情况称为res11;选择5张8元货币,在{10,15,20}中换80元这种情况成称为res16;
递归计算res11,即在{10,15,20}中换120元。我们可以选择5张10元,在{15,20}中换70元。
递归计算res16,即在{10,15,20}中换80元。我们可以选择1张10元,在{15,20}中换70元。
有些同学可能已经注意到,在两个递归过程中,取{15,20}中任意数量的货币来兑换70元的情况都出现了。也就是说,暴力搜索是有冗余计算的。
针对冗余计算的进一步优化:记忆化搜索
记忆化搜索的本质是把每次递归过程中已经计算出的情况记下来,再出现这种情况时,直接取值即可而不必再计算。
在暴力搜索递归函数process(vector<int>penny,int index,int n,int aim)中,数组penny和n是不变的,始终变化的是index和aim,因此可以用p(index,aim),我们现在把递归过程记录下来,以(index,aim)为键建立全局哈希表,记录每次递归过程计算结果,计算前先判断这个位置是不是已经计算过了。
报错:
In file included from a.cc:2:
./solution.h:3:65: error: array has incomplete element type 'int []'
int process(vectorpenny,int index,int n,int aim,int map[][]){//penny为面值数组,index为数组中表示可使用货币的子数组的起始位置,
^
1 error generated.
在c++ 中二维数组名作为函数形参时,一定要给定第二个维度的大小,不然编译不通过。
一维数组作为函数参数时,要给定数组大小,不然编译通过,但是调用时会出错。
class Exchange {
public:
int process(vector<int>penny,int index,int n,int aim,int map[][1002]){//penny为面值数组,index为数组中表示可使用货币的子数组的起始位置,
//n 为数组大小,aim为当前要兑换的面值。
int ress = 0;
if(map[index][aim]!=0)//之前计算过
{
if(map[index][aim] != -1)//index,aim的组合下有可行方案
ress += map[index][aim];
}
else{
if(index == n)//递归边界,当前没有零钱货币可以选择
{
ress = aim == 0?1:0;
}
else{//index<n;
for(int i = 0;penny[index]*i<=aim;i++){
ress+=process(penny,index+1,n,aim-penny[index]*i,map);
}
}
}
map[index][aim] = ress == 0?-1:ress;
return ress;
}
int countWays(vector<int> penny, int n, int aim) {
// write code here
if(aim<0 || n == 0 || penny.size()==0)
return 0;
int map[51][1002] = {0};//初始化
return process(penny,0,n,aim,map);
}
};
进一步优化:动态规划
生成行数为N,列数为aim+1的矩阵dp,dp[i][j]表示用penny[0,...i]来兑换j元钱的方法数。
第一列中aim为0,dp[i][0]指的是用penny[0,...i]来兑换0元钱的方法数,显然为1,因为方法就是每种零钱的数量都是0.
第一行中指的是仅仅用penny[0],兑换aim元钱的方法数,因此当aim是penny[i]的整数倍时,该值为1,其他情况该值为0.
每次遍历求dp[i][j]的时间复杂度为O(aim),一共有N*aim个dp值,因此时间复杂度为O(N*aim^2).
记忆化搜索和动态规划的联系
1、记忆化搜索时间复杂度也为O(N*aim^2).,就是某种形态的动态规划
2、记忆化搜索不关心到达某个递归过程的路径,而只是简单的对计算过的递归过程进行记录
3、动态规划则是规定好每一个递归过程的计算顺序,依次进行计算,后面的计算过程严格依赖前面的计算过程。
代码实现:
class Exchange {
public:
int countWays(vector<int> penny, int n, int aim) {
// write code here
int dp[52][1002]={0};//动态规划矩阵
for(int j = 0;j<=aim;j++){//第一行
dp[0][j] = j%penny[0]?0:1;
}
for(int i = 1;i<n;i++){
for(int j = 0;j<=aim;j++){
int k = 0;
while(j-k*penny[i]>=0){
dp[i][j] += dp[i-1][j-k*penny[i]];
k++;
}
}
}
return dp[n-1][aim];
}
};
动态规划的简化: 去掉枚举过程,简化动态规划方程
dp[i][j] = dp[i-1][j] + dp[i-1][j-penny[i]]+dp[i-1][j-penny[i]*2]...(1)
又有dp[i][j-penny[i]] = dp[i-1][j-penny[i]]+dp[i-1][j-penny[i]*2]...
因此对(1)进一步简化: dp[i][j] = dp[i-1][j] + dp[i][j-penny[i]];
这样省去了枚举过程,时间复杂度为O(N*aim)。在这里看到了规定计算顺序对动态规划的重要性。
优化后的动态规划代码:
class Exchange {
public:
int countWays(vector<int> penny, int n, int aim) {
// write code here
int dp[52][1002]={0};//动态规划矩阵
for(int j = 0;j<=aim;j++){//第一行
dp[0][j] = j%penny[0]?0:1;
}
for(int i = 1;i<n;i++){
for(int j = 0;j<=aim;j++){
if(j>=penny[i])
dp[i][j] = dp[i][j-penny[i]]+dp[i-1][j];
else
dp[i][j] = dp[i-1][j];
}
}
return dp[n-1][aim];
}
};
总结
类似问题面试优化路径:
暴力搜索->记忆化搜索->动态规划->动态规划的优化
1、写出暴力搜索方法
2、看哪些参数能代表一个递归过程,找到那些参数
3、将参数整体当做key,把递归结果作为value放入map中,实现记忆化搜索
4、整理各个状态的依赖关系,分析记忆化搜索的依赖路径,简单的、可以直接计算的状态计算,依赖简单计算结果的状态后计算,实现动态规划
5、看动态规划中状态的计算能否得到简化,得到更加简单的动态规划方程。
几乎所有暴力搜索问题都可以套用以上路径进行优化,但是经典的动态规划问题要直接记住动态规划方法的过程,因为经典已经非常经典了,面试要求会更高。