入门动态规划之前需要明确:
1、动态规划没有固定写法,极其灵活,常常需要具体问题具体分析;
2、多训练、多思考、多总结是学习动态规划的重点;
3、《算法笔记》上大多是使用递推来实现动态规划的,很少用递归,感觉是因为递推比递归好理解一些,可以先学会递推再写递归。
4、但是 需要明确很多的动态规划的题目都会结合DFS(即深度优先搜索)考察,所以递归还是非常有必要的!
1. 什么是动态规划
动态规划(Dynamic Programming,DP) 是一种用来解决一类 最优化问题 的算法思想。
简单来说:
最优子结构:动态规划将一个复杂的问题分解成若干个子问题,通过综合子问题的最优解来得到原问题的最优解。(“分”与“合”体现在 状态转移方程)
其实有时候用动态规划也不一定就是最优解那种意思。
比如斐波拉契数列,我们很难从中体会到最优解的意味。
我感觉这句话是在告诉我们:出现最优解的问题的时候,要第一时间想到动态规划,不是最优解的问题时,也要想到动态规划分解子问题的思想!
重叠子问题:动态规划会将每个求解过的子问题的解记录下来,这样当下一次碰到同样的子问题时,就可以直接使用之前记录的结果,而不是重复计算。(虽然动态规划使用这种方式来提高计算效率,但不能说这种做法就是动态规划的核心)
所谓 记录就是dp数组。
动态规划的写法:
递归
递推
其中递归写法在此处又称作 记忆化搜索,记忆化搜索在机试中出现频率不要太高!
附:
因为状态转移方程体现了动态规划重叠子问题和最优子结构这两个特性,因此书写状态转移方程是最困难的。下面是从网上学到的一个思维框架,用于辅助思考状态转移方程:
明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义。
按上面的套路走,最后的结果就可以套这个框架:
# 初始化 base case
dp[0][0][...] = base
# 进行状态转移
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 求最值(选择1,选择2...)
2. 动态规划的递归写法——重叠子问题
这一部分重点需要理解的是动态规划是如何 记录子问题的解,来避免下次遇到相同的子问题时的重复计算。
下面是一个不记录重复解的例子,复杂度为O ( ) ,爆炸~~~ :
为了避免重复计算,可以开一个一维数组dp,用来保存已经计算过的结果,其中dp[n]记录F(n)的结果,并用dp[n]=-1表示F(n)当前还没有被计算过。
int dp[MAXN];
然后就可以在递归当中判断dp[n]是否等于-1:如果不是-1,说明已经计算过F(n),直接返回dp[n]就是结果;否则,按照递归式进行递归。代码如下:
int F(int n)
{
if(n == 0||n == 1) return 1; //递归边界
if(dp[n] != -1) return dp[n];
else{
dp[n] = F(n-1) + F(n-2);
return dp[n];
}
}
通过上述的 记忆化搜索(即记忆化搜索等于DFS+dp数组),将复杂度从 O ( )降为O ( n )
上面的例子还引申了一个概念:如果一个问题可以被分解成若干个子问题,且这些子问题会重复出现,那么称这个问题拥有 重叠子问题(Overlapping Subproblems)。
一个问题必须拥有重叠子问题,才能使用动态规划去解决。
3. 动态规划的递推写法——最优子结构
最优子结构——数塔问题
下面以数塔问题为例:
出于上面的考虑,不妨令dp[i][j]表示从第i行第j个数字出发的到达最底层的所有路径中能得到的最大和。在定义这个数组之后,dp[1][1]就是最终想要的答案~
需要注意到的是:如果要求出“从位置(1,1)到达最底层的最大和”dp[1][1],那么一定要先求它的两个子问题“从位置(2,1)到达最底层的最大和”dp[2][1]和“从位置(2,2)到达最底层的最大和”dp[2][2],即进行一次决策:从数字5的左下还是右下。于是有:dp[1][1] = max(dp[2][1],dp[2][2])+f[1][1]
抽象出来是:
dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+f[i][j]
把dp[i][j]称为问题的状态,而把上面的式子称作状态转移方程,它把状态dp[i][j]转移为dp[i+1][j]和dp[i+1][j+1]。
可以发现第i层的状态只与第i+1层的状态有关。
那么,如果将层号增大,什么时候会到头呢?
可以发现,数塔的最后一层的dp值总是等于元素本身,即dp[n][j] = f[n][j](1<=j<=n),把这种直接确定其结果的部分称为边界,而 动态规划的递推写法总是从这些边界出发,通过状态转移方程扩散到整个dp数组。
代码:
#include<cstdio>
#include<algorithm>
using namespace std;
const int maxn = 1000;
int f[maxn][maxn],dp[maxn][maxn];
int main(){
int n;
scanf("%d",&n);
for(int i = 1;i <= n;++i){
for(int j = 1;j <= i;j++){
scanf("%d",&f[i][j]); //输入数塔
}
}
//边界
for(int j=1;j<=n;++j){
dp[n][j] = f[n][j];
}
//从第n-1层不断往上计算出dp[i][j]
for(int i = n-1;i>=1;i--){
for(int j=1;j<=i;++j){
//状态转移方程
dp[i][j] = max(dp[i+1][j],dp[i+1][j+1]) + f[i][j];
}
}
printf("%d\n",dp[1][1]); //dp[1][1]即为需要的答案
return 0;
}
显然,使用递归也可以实现上面的例子。递归与递推的区别在于:
递推写法:自底向上(Bottom-up Approach),即从边界开始,不断向上解决问题,直到解决了目标问题;
递归写法:自顶向下(Top-down Approach),即从目标问题开始,将它分解成子问题的组合,直到分解至边界为至。
通过上面的例子再引申一个概念:
如果一个问题的最优解可以由其子问题的最优解有效地构造出来,那么称这个问题拥有 最优子结构(Optimal Substructure)。最优子结构保证了动态规划中原问题的最优解可以由子问题的最优解推导而来。
最优子结构——凑零钱
一个问题必须拥有最优子结构,才能使用动态规划去解决。
要符合最优子结构,子问题间必须相互独立。啥叫相互独立?你肯定不想看数学证明,我用一个直观的例子来讲解。
比如说,你的原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高…… 为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高…… 当然,最终就是你每门课都是满分,这就是最高的总成绩。
得到了正确的结果:最高的总成绩就是总分。因为这个过程符合最优子结构,“每门科目考到最高”这些子问题是互相独立,互不干扰的。
但是,如果加一个条件:你的语文成绩和数学成绩会互相制约,此消彼长。这样的话,显然你能考到的最高总成绩就达不到总分了,按刚才那个思路就会得到错误的结果。因为子问题并不独立,语文数学成绩无法同时最优,所以最优子结构被破坏。
那么,既然知道了这是个动态规划问题,就要思考如何列出正确的状态转移方程?
先确定状态,也就是原问题和子问题中变化的变量。由于硬币数量无限,所以唯一的状态就是目标金额 amount。
然后确定 dp 函数的定义:当前的目标金额是 n,至少需要 dp(n) 个硬币凑出该金额。
然后确定「选择」并择优,也就是对于每个状态,可以做出什么选择改变当前状态。具体到这个问题,无论当的目标金额是多少,选择就是从面额列表 coins 中选择一个硬币,然后目标金额就会减少。
最后明确 base case,显然目标金额为 0 时,所需硬币数量为 0;当目标金额小于 0 时,无解,返回 -1。
4. 总结
(1) 结论
一个问题必须拥有重叠子问题和最优子结构,才能使用动态规划去解决。
下面指出这两个概念的区别:
(2) 分治与动态规划——重叠子问题
相同点:
将问题分解成子问题,然后合并子问题的解得到原问题的解。
不同点:
分治法解决的问题不拥有重叠子问题,解决的问题不一定是最优化问题;
动态规划解决的问题拥有重叠子问题,解决的问题一定是最优化问题。
例子:
分治法:快速排序、归并排序等。
(2) 贪心与动态规划——最优子结构
相同点
都要求原问题必须拥有最优子结构。
不同点
贪心的计算方式类似于”自顶向下“,但是并不等待所有子问题求解完毕后再选择使用哪一个,而是通过一种策略直接选择一个子问题去求解,没被选择的子问题就不会再去求解了。
动态规划的计算方式有”自顶向下“和”自底向上“两种,都是从边界开始向上得到目标问题的解。也就是说,它总是会考虑所有子问题,并选择继承能得到最优结果的那个,对暂时没有被继承的子问题,由于重叠子问题的存在,后期可能会再次考虑它们,因此还有机会成为全局最优的一部分,不需要放弃。
5. 题型训练
LeetCode 62. Unique Paths
LeetCode 63. Unique Paths II
LeetCode 64. Minimum Path Sum