动态规划(Dynamic Programming,DP)是一种非常精妙的算法思想,它没有固定的写法、及其灵活,常常需要具体问题具体分析。动态规划将一个复杂的问题分解成若干个子问题,通过综合子问题的最优解来得到原问题的最优解。需要注意的是,动态规划会将每个求解过的子问题的解记录下来,这样当下一次碰到同样的子问题时,就可以直接使用之前记录的结果,而不是重复计算。这就是重叠子问题(Overlap Subprogram)。
大部分是从书上找的,仅供参考。
目录
1.1 从斐波那契数列说起
1.2 理解dp模型
1.3 序列问题
- 1.3.1 最大连续子序列和
- 1.3.2 最长不下降子序列(LIS)
- 1.3.3 最长公共子序列(LCS)[正在建设中...]
1.4 最大矩阵和[正在建设中...]
<还在建设中>
1.1 从斐波那契数列说起
我们已斐波那契(Fibonacci)数列为例子,斐波那契的定义为F0=1,F1=1,Fn=F(n-1)+F(n-2) (n>=2).具体的代码如下:
int fib(int n)
{
if(n==0||n==1)
{
return 1;
}
else
{
return fib(n-1)+fib(n-2);
}
}
这个递归会涉及到很多重复的计算,如图1-1,当n=5时,可以得到Fib(5)=Fib(4)+Fib(3),接下来在计算Fib(4)时又会有Fib(4)= Fib(3)+Fib(2).这个时候要是不采取措施,F(3)将会被计算两次。可以推知,如果n很大,重复计算的次数将难以想象。
如何优化呢?
为了避免重复计算,可以开一个一维数组dp,用以保存已经计算过的结果,其中dp[n]记录F(n)的结果,并用dp[n]=-1来表示F(n)当前没有被计算过。
在递归当中判断dp[n]是否是-1,如果不是-1,说明已经计算过Fib(n),直接返回dp[n]就是结果;否则,按照递归式进行递归。
int dp[101];
int fib(int n)
{
if(n==0||n==1)
{
return 1;
}
if(dp[n]!=-1)//如果已经计算过,直接返回结果
{
return dp[n];
}
else//如果不是
{
dp[n]=fib(n-1)+fib(n-2);//计算fib,保存到dp[n]
return dp[n];//返回dp[n]即可
}
}
这样就把已经计算过的内容记录下来了,于是当下次再碰到需要计算系统的内容时,就能直接使用上次计算的结果,这可以省去大半无效计算,而这也是记忆化搜索的由来。把复查度从O(2^n)降到了O(n)。
如果一个问题可以被分解为若干个子问题,且这些子问题会重复出现,那么就称这个问题拥有重叠子问题(Overlapping Subproblems).动态规划通过记录重叠子问题的解,来使下次碰到相同的子问题时直接使用之前记录的结果,以此避免大量重复计算。因此,一个问题必须拥有重叠子问题,才能使用动态规划去解决。
1.2 理解dp模型
问题引入
数字三角形(POJ1163)
在上面的数字三角形中寻找一条从顶部到底边的路径,使得路径上所经过的数字之和最大。路径上的每一步都只能往左下或 右下走。只需要求出这个最大和即可,不必给出具体路径。 三角形的行数大于1小于等于100,数字为 0 - 99
输入格式:
5 //表示三角形的行数 接下来输入三角形
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
要求输出最大和
首先肯定能用dfs水过去,先给出一个dfs的写法。我们把问题转化为从最高点按照规则走到最低点的路径最大的权值和,也可以看成是一个无向图,数据给出的数字代表两点之间的边的权值。
输入:
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
输出:
30
下面给出dfs代码。我相信很多人都能看懂。看不懂请出门左转到A.pro读算法の6:快速搞定dfs算法
#include <stdio.h>
#include <iostream>
using namespace std;
int a[101][101],dp[101][101],n,s;
void dfs(int x,int y,int dis)
{
if(x==n)
{
s=max(s,dis);
return;
}
dfs(x+1,y,dis+a[x+1][y]);
dfs(x+1,y+1,dis+a[x+1][y+1]);
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int i,j;
cin>>n;
for(i=1;i<=n;i++)
{
for(j=1;j<=i;j++)
{
cin>>a[i][j];
}
}
dfs(1,1,a[1][1]);
cout<<s<<endl;
return 0;
}
这个算法最大的问题是耗时太严重。它把所有的路径都搜了一遍,每条路径都是由N-1步组成,每一步可以选择走左边或右边,因此路径总数为,算法时间复杂度为O(),严重超时。
我们不妨开一个新的数组dp[i][j],表示从第i行第j个数字出发的到达最底层的所有路径中能得到的最大和。如果要求出“从位置(1,1)到达最底层的最大和”dp[1][1],那么一定要先求出它的两个子问题“从位置(2,1)到达最底层的最大和dp[2][1]”和“从位置(2,2)到达最底层的最大和dp[2][2]”,即进行了一次决策;走数字5的左下还是右下。于是dp[1][1]就是d[2][1]和dp[2][2]的较大值加上5.
写出式子就是:dp[1][1]=max(dp[2][1],dp[2][2])+a[1][1]。
我要么选dp[2][1],要么选dp[2][2],我要从中间选个最好的,然后加上我当前的值。
因此我们很容易就推导出:
如果要求出dp[i][j],那么一定要先求出它的两个子问题从位置(i+1,j)到达最底层的最大和dp[i+1][j]和从位置(i+1,j+1)到达最底层的最大和dp[i+1][j+1],即进行了一次决策:走位置(i,j)的左下还是右下。于是dp[i][j]就是dp[i+1][j]和dp[i+1][j+1]的较大值加上f[i][j]。写成式子就是:
dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+a[i][j]
我们把dp[i][j]称为问题的状态,而把上面的式子称作状态转移方程式,它把状态dp[i][j]转移为dp[i+1][j]和dp[i+1][j+1].可以发现,状态dp[i][j]只与第i+1层的状态有关,而与其他层的状态无关,这样层号为i的状态就总是可以有层号为i+1的两个子状态得到。那么,如果总是将层号增大,什么时候会到头呢?可以发现,数塔的最后一层的dp值总是等于元素本身,
即dp[n][j]=a[n][j](1<=j<=n),把这种可以直接确定其结果的部分称为边界,而动态规划的递推写法总是从这些边界出发,通过状态转移方程扩散到这个dp数组。
这样就可以从最底层各位置的dp值开始,不断往上求出每一层各位置的dp值,最后就会得到dp[1][1],即为想要的答案。
#include <stdio.h>
#include <iostream>
using namespace std;
int a[101][101],dp[101][101],n,s;
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int i,j;
cin>>n;
for(i=1;i<=n;i++)
{
for(j=1;j<=i;j++)
{
cin>>a[i][j];
}
}
for(i=1;i<=n;i++)
{
dp[n][i]=a[n][i];//将数字三角形的值给dp数组
}
for(i=n-1;i>=1;i--)//倒着搜
{
for(j=1;j<=i;j++)
{
dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+a[i][j];//根据状态转移方程可得
}
}
cout<<dp[1][1]<<endl;
return 0;
}
接下来,我们就进行一下总结:
递归到动规的一般转化方法
递归函数有n个参数,就定义一个n维的数组,数组的下标是递归函数参数的取值范围,数组元素的值是递归函数的返回值,这样就可以从边界值开始, 逐步填充数组,相当于计算递归函数值的逆过程。
动规解题的一般思路
1. 将原问题分解为子问题
- 把原问题分解为若干个子问题,子问题和原问题形式相同或类似,只不过规模变小了。子问题都解决,原问题即解决(数字三角形例)。
- 子问题的解一旦求出就会被保存,所以每个子问题只需求 解一次。
2.确定状态
- 在用动态规划解题时,我们往往将和子问题相关的各个变量的一组取值,称之为一个“状 态”。一个“状态”对应于一个或多个子问题, 所谓某个“状态”下的“值”,就是这个“状 态”所对应的子问题的解。
- 所有“状态”的集合,构成问题的“状态空间”。“状态空间”的大小,与用动态规划解决问题的时间复杂度直接相关。 在数字三角形的例子里,一共有N×(N+1)/2个数字,所以这个问题的状态空间里一共就有N×(N+1)/2个状态。
整个问题的时间复杂度是状态数目乘以计算每个状态所需时间。在数字三角形里每个“状态”只需要经过一次,且在每个状态上作计算所花的时间都是和N无关的常数。
3.确定一些初始状态(边界状态)的值
以“数字三角形”为例,初始状态就是底边数字,值就是底边数字值。
4. 确定状态转移方程
定义出什么是“状态”,以及在该“状态”下的“值”后,就要找出不同的状态之间如何迁移――即如何从一个或多个“值”已知的 “状态”,求出另一个“状态”的“值”(递推型)。状态的迁移可以用递推公式表示,此递推公式也可被称作“状态转移方程”。
能用动规解决的问题的特点
1) 问题具有最优子结构性质。如果问题的最优解所包含的 子问题的解也是最优的,我们就称该问题具有最优子结 构性质。
2) 无后效性(这和贪心成对比)。当前的若干个状态值一旦确定,则此后过程的演变就只和这若干个状态的值有关,和之前是采取哪种手段或经过哪条路径演变到当前的这若干个状态,没有关系。
1.3 序列问题
前面罗里吧嗦了那么多,我们来解决一些问题吧。
1.3.1 最大连续子序列和
给定一个数字序列A1,A2,......An,求i,j(1<=i<=j<=n),使得Ai+...+Ai最大,输出这个最大和。
样例数据:
-2 11 -4 13 -5 -2
显然11+(-4)+13=20为和最大的选取情况,因此最大和为
20
令状态dp[i]表示a[i]作为末尾的连续序列的最大和。
以样例为例:序列 -2 11 -4 13 -5 -2,下标分别记作0,1,2,3,4,5,那么
dp[0]=-2,
dp[1]=11,
dp[2]=7 (11+(-4)=7)
dp[3]=20 (7+13=20)
dp[4]=15 (20+(-5)=15)
dp[5]=13 (15+(-2)=13)
因为dp[i]要求是必须以A[i]结尾的连续序列,那么只有两种情况:
1.这个最大和的连续序列只有个一个元素,即以a[i]开始,以a[i]结尾。
2.这个最大和的连续序列有多个元素,即从前面某处a[p]开始(p<i),一直到a[i]结尾。
考虑两种情况:
第一种情况,最大和就是A[i]本身。
第二种情况,最大和是dp[i-1]+A[i],
由于只有这两种情况,于是得到状态转移方程:
dp[i]=max{A[i],dp[i-1]+A[i]}
这个式子只和i与i之前的元素有关,且边界为dp[0]=A[0],由此从小到大枚举i,即可得到整个dp数组。接着输出dp[0],dp[1],.......dp[n-1]中的最大值即为最大连续子序列的和。
#include <stdio.h>
#include <iostream>
using namespace std;
int a[20001],dp[20001],n,maxn;
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int i;
cin>>n;
for(i=1;i<=n;i++)
{
cin>>a[i];
}
for(i=1;i<=n;i++)
{
dp[i]=max(dp[i-1]+a[i],a[i]);//根据转移方程
if(dp[i]>maxn)//不断更新找到的最大值
{
maxn=dp[i];
}
}
//for(i=1;i<=n;i++)
//{
// cout<<dp[i]<<' ';
//}
//cout<<endl;
cout<<maxn<<endl;
return 0;
}
此处顺便介绍无后效性的概念。状态的无后效性是指:当前状态记录了历史信息,一旦当前状态确定,就不会再改变,且未来的决策只能在已有的一个或若干个状态的基础上进行,历史信息只能通过已有的状态去影响未来的决策。针对本节问题来说,每次计算状态 dp[i],都只会涉及 dp[i-1],而不直接用到 dp[i-1] 蕴含的历史消息。
对动态规划可解的问题来说,总会有多设计状态的方式,但并不是所有状态都具有无后效性,因此必须设计一个拥有无后效性的状态以及相应的状态转移方程,否则动态规划就没有办法得到正确结果。事实上,如何设计状态和状态转移方程,才是动态规划的核心,而它们也是动态规划最难的地方。
1.3.2 最长不下降子序列(LIS)
最长不下降子序列(Longest Increasing Sequence,LIS)是这样一个问题:
在一个数字列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降(非递减)的。
例如,现有序列A={1,2,3,-1,-2,7,9}(下标从1开始),它的最长不下降子序列是{1,2,3,7,9},长度为5。另外,还有一些子序列是不下降子序列,比如{1,2,3}、{-2,7,9}等,但都不是最长的。
令dp[i]表示以a[i]结尾的最长不下降子序列长度。这样对A[i]来说就会有两种可能:
1、如果存在a[i]之前的元素a[j](j<i),使得a[j]<=a[i]且dp[j]+1>dp[i],那么就把a[i]跟在以a[j]结尾的LIS后面,形成一条更长的不下降子序列(令dp[i]=dp[j]+1)。
2、如果a[i]之前的元素都比a[i]大,那么A[i]就只好自己形成一条LIS,但是长度为1,即这个子序列里面只有一个a[i]。
最后以a[i]结尾的LIS长度就是1、2中能形成的最大长度。
输入:
8
1 2 3 -2 -1 7 8 9
输出:
6
由此可以写出状态转移方程:
1、dp[i]=max{1,dp[j]+1}
2、其实这个里面的1就是边界 ,令dp[1]=1,因为每次要记录的是第i个位置的长度,所以令dp[i]=1;
#include <stdio.h>
#include <iostream>
using namespace std;
int dp[20001],a[20001],n,maxn;
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int i,j;
cin>>n;
for(i=1;i<=n;i++)
{
cin>>a[i];
}
for(i=1;i<=n;i++)
{
dp[i]=1;
for(j=1;j<i;j++)
{
if(a[i]>=a[j] && (dp[j]+1>dp[i]))
{
dp[i]=dp[j]+1;
}
}
maxn=max(maxn,dp[i]);
}
cout<<maxn<<endl;
return 0;
}