CHAPTER_11 提高篇(5)——动态规划
11.1.1动态规划
动态规划(DP)是一种用来解决一类最优化问题的算法思想。简单来说,动态规划将一个复杂的问题分解成若干个子问题,通过综合子问题的最优解来得到原问题的最优解。需要注意的是,动态规划会把每个求解过的子问题的解记录下来,这样当下一次碰到同样的子问题时,就可以使用之前记录的结果,而不用重复计算。
一般可以使用递归或者递推的写法来实现动态规划,其中递归写法在此处又称作记忆化搜索。
11.1.2动态规划的递归写法
通过学习DP的递归写法,读者应该能够理解动态规划是如何记录子问题的解,来避免下次遇到相同子问题时候的重复计算。
以斐波拉契数列为例,斐波拉契数列中的第n个数按如下递归代码计算:
int F(int n) {
if(n==0||n==1)
return 1;
else
return F(n-1)+F(n-2);
}
事实上,这个递归会涉及很多重复的计算。如果没有及时保存中间计算的结果,实际的复杂度会高达O(2^n),这显然是无法承受的。
为了避免重复计算,可以开一个一维数组dp,其中dp[n]记录F[n]的结果,并用dp[n]=-1表示当前还没有计算过。代码优化如下:
int dp[MAXN];
int F(int n) {
if(n==0||n==1)
return 1;
is(dp[n]!=-1)
return dp[n];
else {
dp[n]=F(n-1)+F(n-2);
return dp[n];
}
}
通过这种方式直接使用记录的结果,就可以省去大半无效计算,也是记忆化搜索这个名字的由来。由此时间复杂度从O(2^n)优化到O(n)。
通过上面这个简单的子问题,我们引出了一个概念:如果一个问题可以被分解为若干个子问题,且这些子问题会重复出现,那么称这个问题拥有重叠子问题。动态规划通过囧路重叠子问题的解,来避免大量重复计算。因此,一个问题必须有重叠子问题,才能使用DP去解决。
11.1.3动态规划的递推写法
我们来看一道经典的数塔问题。
题目:
如下图所示,将一些数字排成数塔的形状,其中第一层有一个数字,第二层有两个数字,...,第n层有n个数字。现在要从第一层走到第n层,每次只能走向下一层连接的两个数字中的一个,问:最后将路径上所有数字相加后得到的和最大是多少?
输入样例:
5
5
8 3
12 7 16
4 10 11 6
9 5 3 9 4
输出样例:
44
思路:
按照题目表述,如果开一个数组二维 f ,其中f[i][j]存放第 i 层的第 j 个数字,例如f[1][1]=5和f[5][5]=4。此时很容易想到一种方案,用递归的方法穷举所有路径,然后记录其中的最大数字和。这种方式能够得到想要的结果,但是时间复杂度高达O(2^n),这显然无法接受。
那么产生这么高的复杂度原因在哪呢?我们可以分析其中的一个过程,一开始从5出发按照5-8-7的路线到达7,然后从7出发继续枚举到达底层。但是,之后按照5-3-7的陆先再次来到7时,又会从7出发去枚举到达底层。这就导致了多余的计算。事实上,我们可以在第一次从7出发到达底层的所有路径时,就把这些路径中的最大和记录,这样当再次访问7时就可以获得这个值,以避免重复计算。
通过上面的思考,我们可以令dp[i][j]表示从第 i 行第 j 个数字出发到达最底层的所有路径中能得到的最大和,例如dp[3][2]就是图中7到底层的路径最大和。在定义这个数组后,dp[1][1]就是最终答案。
注意到一个细节:如果要求出“从位置(1,1)到达最底层的最大和”dp[1][1],那么一定要先求出它的两个子问题dp[2][1]和dp[2][2],即进行了一次决策:走数字5的左下还是右下。在求出子问题后我们可以做出如下决策:
于是,对于任意一个位置,要求出dp[i][j]必须要求解它的两个子问题,然后做出如下决策1:
把dp[i][j]称为问题的状态,而把上面的式子称作状态转移方程,它把状态dp[i][j]转移为dp[i+1][j]和dp[i+1][j+1]。如此层数一直向下增大,什么时候到头呢?可以发现,数塔的最后一层的dp值总是等于元素本身,即dp[n][j]=f[n][j](1<=j<=n),把这种可以直接确定结果的部分称为边界,而动态规划的递推写法总是从这些边界出发,通过状态转移方踩扩散到整个dp数组1.
下面根据这种思想写出动态规划的代码:
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn=101;
int main() {
int f[maxn][maxn],dp[maxn][maxn];
int n; //数塔1的层数
cin>>n; //输入层数
for(int i=1;i<=n;i++) { //读入数塔
for(int j=0;j<=i;j++) {
cin>>f[i][j];
}
}
for(int j=1;j<=n;j++) { //边界
dp[n][j]=f[n][j];
}
for(int i=n-1;i>=1;i++) { //根据状态转移方程从n-1层往上计算
for(int j=1;j<=i;j++) {
dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+f[i][j];
}
}
cout<<dp[1][1]<<endl;
return 0;
}
显然,本题也可以使用递归的动态规划解决。两者的区别在于:递推写法是从边界开始自下而上的,而递归写法是从目标问题自顶向下的。