文章目录
1 概念
动态规划(英语:Dynamic programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。 ——维基百科
刚开始看到“动态规划”这个名字有被惊到,觉得应该是一个神奇而魔幻的东西吧,但经过学习之后发现其实就是高中数学求数列递推公式题的魔改版好吗,不知道为什么取名为动态规划…
1.1 能用动态规划解决的问题
如果问题满足一下两点,那么它就可以用动态规划解决。
- 问题的答案依赖于问题的规模,也就是问题的所有答案构成了一个数列。就比如告诉{an}是等差数列0,2,4,6,8,10…,要求第n个数,那么an=2*n。n是问题的规模,答案取决于n的值。
- 大规模问题的答案可以由小规模问题的答案递推得到。比如刚才数列的题,显然满足an=an-1+2。
1.2 需要用动态规划解决的问题
很多问题都可以用动态规划解决,但不一定有必要。比如数列的问题,你完全可以用an=2*n来显式的表达,那么杀鸡就不必用牛刀了。但是,很多情况下,我们得不到这样显式的式子,这时候动态规划的魅力就体现出来了。
1.3 动态规划的魅力
那么动态规划有怎样的魅力呢?举个很简单的例子,老师在黑板上画了9条直线,问你有几条,你会一条一条数了之后告诉老师是9条。那么如果老师再画一条再问你一共多少条,你可以很快的回答出是10条,不会再一条一条的数,因为你已经把前面的9条作为记忆存储了。
这就是DP(动态规划)的魅力,将计算的结果缓存以便后续计算过程中的复用,以便省去重复的计算。
2 实现
2.1 步骤
动态规划可以拆分为以下两个步骤
- 建立状态转移方程(类似于数列中的递推公式)
这一步是动态规划的核心,当然也是最难的。没有规律可言,但是抓住一个思维:要求f(n),是否可以用已求得的f(1)~f(n-1)的值计算出。 - 缓存计算结果以复用
使用动态规划的目的就是降低求解问题的时间复杂度,途径就是计算当前问题时利用子问题的结果,所以缓存子问题的计算结果是动态规划的关键。
2.2 举个栗子
一般可以使用递归或者递推的写法来实现动态规划,这里以经典的求解斐波拉契数列为例子。如果没有学过动态规划,可能只会用递归的方式求解斐波拉契数列。代码如下:
long long fab(int n) { //简单递归法
if (n < 2) return 1;
else return fab(n - 1) + fab(n - 2);
}
事实上,这个递归会有很多重复的计算,以下递归树可以直观地表示。实际其时间复杂度为O(2n),指数级的算法在数字稍大的时候基本不能用,更何况当问题规模较大时递归调用系统栈也是非常耗时的。
2.3 动态规划的递归写法
这里将动态规划的递归写法用简单递归的方法对比,比较当n=50时,两者所消耗的时间。
#include<iostream>
#include<vector>
#include<time.h>
using namespace std;
vector<long long> a; //存储计算结果
long long fab1(int n) { //动态规划递归写法
if (n < 2) return 1; //递归边界
//以-1表示没有计算过
else if (a[n] == -1) a[n] = fab1(n - 1) + fab1(n - 2);
return a[n];
}
long long fab2(int n) { //简单递归调用法
if (n < 2) return 1;
else return fab2(n - 1) + fab2(n - 2);
}
int main() {
clock_t start, end;
int n; cin >> n;
a.resize(n+1,-1);
long long result;
start = clock();
result= fab1(n);
end = clock();
cout << "动态规划递归算法运行时间:" << (end - start)*1.0 / CLOCKS_PER_SEC << "s" << endl;
cout << "运行结果:" << result << endl;
start = clock();
result = fab2(n);
end = clock();
cout << "直接递归算法运行时间:" << (double)(end - start) / CLOCKS_PER_SEC << "s" << endl;
cout << "运行结果:" << result;
return 0;
}
程序运行结果表示,动态规划递归算法的时间消耗几乎为0,而简单递归却用了一分多钟。这是因为把已经计算的结果记录下来,每次遇到需要计算已经计算的子问题时,直接使用上次计算过的结果,这样就把时间复杂度从O(2n)降到了O(n)。
2.4 动态规划的递推写法
还是上面的例子,状态转移方程为 dp[i]=dp[i-1]+dp[i-2](i>=2)
#include<iostream>
#include<vector>
using namespace std;
vector<long long> a;
int main() {
int n; cin >> n;
a.resize(n+1);
for (int i = 0; i <= n; i++) {
if (i < 2) a[i] = 1;
else a[i] = a[i - 1] + a[i - 2];
}
cout << a[n];
return 0;
}
2.5 对比
显然,使用递推写法的计算方式是自底而上,即从边界开始不断向上解决问题,直到解决目标问题;而递归写法是自顶而下,即从目标问题出发,将其分解为子问题的组合,直到分解至边界为止。
3 例题
3.1 最大子列和问题
设置dp数组,其中dp[i]表示以a[i]结尾的最大子列和。
那么dp[i]有两种情况:
(1) 这个最大和的序列只有a[i]一个元素;
(2) 这个最大和的序列有多个元素,从它之前的某处开始。
于是,
状态转移方程为: dp[i]=max{a[i],dp[i-1]+a[i]},边界为dp[0]=a[0]。
#include<cstdio>
#include<algorithm>
using namespace std;
const int maxn=100000;
int a[maxn],dp[maxn];
int main() {
int n;scanf("%d",&n);
for(int i=0;i<n;i++){
scanf("%d",&a[i]);
}
dp[0]=a[0];
for(int i=1;i<n;i++){
dp[i]=max(dp[i-1]+a[i],a[i]);
}
int k=0;
for(int i=0;i<n;i++){
if(dp[i]>dp[k]) k=i;
}
printf("%d",dp[k]);
return 0;
}
3.2 击鼓传花问题
题目链接:https://www.luogu.com.cn/problem/P1057
题目大意就是:n个人围成一个圈,每次只能传给左右两个人,问有多少种传递方式,可以使传m次之后回到最初那个人的手上。
解析
对这个问题,使用回溯法当然也可以做,但是就如同斐波拉契数列问题,时间复杂度为O(2n),n等于30都要运行好久。相比之下,动态规划的算法效率会高很多。
#include<cstdio>
int dp[35][35];
//dp[i][j]表示第i次传到j号玩家手上的可能传递方式数目
//状态转移方程为dp[i][j]=dp[i-1][j-1]+dp[i-1][j+1]
//对于j==1和j==n的情况需要另外考虑
int main() {
int n,m;
scanf("%d%d",&n,&m);
dp[0][1]=1; //初始状态
for(int i=1;i<=m;i++){
for(int j=1;j<=n;j++){
if(j==1) dp[i][j]=dp[i-1][n]+dp[i-1][2];
else if(j==n) dp[i][j]=dp[i-1][n-1]+dp[i-1][1];
else dp[i][j]=dp[i-1][j-1]+dp[i-1][j+1];
}
}
printf("%d",dp[m][1]);
return 0;
}