文章目录
1 概念
- 动态规划(Dynamic Programming,DP):用来解决最优化问题的算法思想。
- 一般来说,动态规划将复杂的问题分解为若干子问题,通过综合子问题的最优解来得到原问题的最优解。
- 动态规划会将每个求解过的子问题记录下来,这样下次碰到相同的子问题,就可以直接使用之前记录的结果,而不重复计算。
- 一般用递归或递推的写法来实现动态规划,其中递归写法在此处又称为记忆化搜索。
2 递归写法
- 记录子问题的解,来避免下次遇到相同子问题时的重复计算。
以斐波那契数列为例子:
斐波那契数列定义为F0 =1,F1 = 1,Fn = Fn-1+Fn-2(n>=2)
递归写法为:
int F(int n){
if(n == 0 || n == 1) return 1;
else return F(n-1) + F(n-2);
}
这个递归会涉及到很多重复的计算,如当n==5时,可以得到F(5)= F(4)+F(3),接下来计算F(4)时又会有F(4)= F(3)+F(2),这时不采取措施,F(3)将会被计算两次。如果n很大,重复计算的次数将难以想象。
实际上由于没有保存中间计算的结果,实际复杂度将会高达O(2n),即每次都会计算F(n-1)和F(n-2)这两个分支,基本上不能承受n较大的情况。
开一个数组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];
}
}
通过记忆化搜素,把复杂度从O(2n)降到O(n)。
斐波那契数列递归图:
斐波那契数列记忆化搜索示意图:
如计算F(45),直接使用递归用时9.082809,用记忆化搜索仅仅0.000001
计算代码:
#include <cstdio>
#include <ctime>
#include <cstdlib>
#include <algorithm>
using std::fill;
const int MAXN = 1000;
int dp[MAXN];
int F2(int n){
if(n == 0 || n == 1) return 1;
else return F2(n-1) + F2(n-2);
}
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];
}
}
int main(){
int n = 45;
int ans;
fill(dp, dp + MAXN, -1);
clock_t start,end;
srand((unsigned)time(NULL));//随机数种子
start = clock();//开始记时
ans = F2(n);
end = clock();
printf("%d\n", ans);
printf("Time is %f\n", double(end - start) / CLOCKS_PER_SEC);//输出运行时间
srand((unsigned)time(NULL));//随机数种子
start = clock();//开始记时
ans = F(n);
end = clock();
printf("%d\n", ans);
printf("Time2 is %f\n", double(end - start) / CLOCKS_PER_SEC);//输出运行时间
return 0;
}
3 递推写法
以数塔问题为例:
将一些数字排成数塔形状,其中第一层有一个数字,第二层有两个数字,。。。第n层有n个数字。现在要从第一层走到第n层,每次只能走向下一层连接的两个数字中的一个。问:最后将路径上所有数字相加后得到的和最大是多少?
Sample Input:
5 //5层数塔,下面有5行
5
8 3
12 7 16
4 10 11 6
9 5 3 9 4
Sample Output:
44
- 思路:
从第一层出发,按5-8-7的线路来到7,并枚举从7出发到达最底层的所有路径,但是,之后按5-3-7的线路再次来到7时,又回去枚举从7出发到达最底层的所有路径。这导致从7出发到达最底层的所有路径被反复的访问,做了许多多余的计算。
其实可以把从7出发到达最低层的所有路径能产生的最大和记录下来,这样再次访问7就能直接获取这个最大值,避免重复计算。
如果要求出“从位置(1,1)到达最底层的最大和”dp[1][1],那么一定要求出它的两个子问题,“从位置(2,1)到达最底层的最大和”d[2][1]和"从位置(2,2)到达最底层的最大和"d[2][2],即进行一次决策,
走数字5左下,还是右下。于是dp[1][1]就是dp[2][1]和dp[2][2]的较大值加上5,写成式子就是:
dp[1][1] = max(dp[2][1], d[2][2])+f[1][1]
令dp[i][j]表示从第i行第j个数字出发的到达最低层的所有路径上所能得到的最大和
由此得到要求dp[i][j],
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]。
可以发现dp[i][j]只与i+1层的状态有关,与其他层无关。
边界(直接确定其结果):数组dp最后一层dp总是等于元素自身
动态规划的递推写法,总是从这些边界出发,通过状态转移方程扩散到整个dp数组。
- 参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int MAXN = 1000;
int f[MAXN][MAXN], dp[MAXN][MAXN];
int main(int argc, char const *argv[])
{
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 i = 1; i <= n; ++i)
{
dp[n][i] = f[n][i];
}
//从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]);
return 0;
}
4 总结
4.1 区别
- 递推写法:自底向上,即从边界开始,不断向上解决问题,直到解决了目标问题。
- 递归写法:自顶向下,从目标问题开始,将他们分解成子问题的组合,直到分解到边界为止。
4.2 应用条件
4.2.1 最优子结构
问题拥有最优子结构:一个问题的最优解可由子问题的最优解有效构造出来。
最优子结构保证了动态规划中原问题的最优解可以由子问题的最优解推导而来。
因此一个问题必须拥有最优子结构,才能用动态规划解决。
4.2.2 重叠子问题
一个问题必须拥有重叠子问题,才能使用动态规划求解。
重叠子问题:一个问题可以被分为若干个子问题,且这些子问题会重复出现。
4.2.3 条件
一个问题必须拥有重叠子问题和最优子结构,才能用动态规划来解决。
4.2.4 区别
4.2.4.1 分治和动态规划
共同点:都是将问题分解成子问题,然后合并子问题的解得到原问题。
- 区别:
- 分治分出来的子问题是不重叠的,如归并排序和快速排序(分别处理左右排序,然后将左右序列结果合并),分治解决的问题不一定是最优化问题。
- 动态规划解决的问题拥有重叠子问题,且一定是最优化问题。
4.2.4.2 贪心和动态规划
共同点:都要求问题拥有最优子结构。
- 区别
- 贪心:整个过程以单链的流水方式进行,显然这种“最优的选择”的正确性需要用归纳法证明。例如数塔问题,贪心从最上层开始,每次选择左下或右下较大的那个,一直到最底层得到结果,显然这不一定可以得到最优解。
- 动态规划:无论采用自底向上还是自顶向下的方法,都是从边界向上得到最优解,它总是会考虑所有子问题,并选择继承能得到最优结果那个,对于暂时没被继承的子问题,由于重叠子问题的存在,后期可能会再次考虑它,因此还有机会成为全局最优的一部分,不需要放弃。