北京大学暑期课《ACM/ICPC竞赛训练》 ppt摘取
什么是动态规划?
●递归到动规的一般转化方法
递归函数有n个参数,就定义一个n维的数组,数组 的下标是递归函数参数的取值范围,数组元素的值 是递归函数的返回值,这样就可以从边界值开始, 逐步填充数组,相当于计算递归函数值的逆过程。
●动规解题的一般思路
1. 将原问题分解为子问题
把原问题分解为若干个子问题,子问题和原问题形式相同 或类似,只不过规模变小了。子问题都解决,原问题即解 决(数字三角形例)。
子问题的解一旦求出就会被保存,所以每个子问题只需求 解一次。
2. 确定状态
在用动态规划解题时,我们往往将和子问题相 关的各个变量的一组取值,称之为一个“状 态”。一个“状态”对应于一个或多个子问题, 所谓某个“状态”下的“值”,就是这个“状 态”所对应的子问题的解。
所有“状态”的集合,构成问题的“状态空间”。“状态 空间”的大小,与用动态规划解决问题的时间复杂度直接相关。 在数字三角形的例子里,一共有N×(N+1)/2个数字,所以这个 问题的状态空间里一共就有N×(N+1)/2个状态。
整个问题的时间复杂度是状态数目乘以计算每个状态所需 时间。
在数字三角形里每个“状态”只需要经过一次,且在每个 状态上作计算所花的时间都是和N无关的常数。
用动态规划解题,经常碰到的情况是,K个整型变量能 构成一个状态(如数字三角形中的行号和列号这两个变量 构成“状态”)。如果这K个整型变量的取值范围分别是 N1, N2, ……Nk,那么,我们就可以用一个K维的数组 array[N1] [N2]……[Nk]来存储各个状态的“值”。这个 “值”未必就是一个整数或浮点数,可能是需要一个结构 才能表示的,那么array就可以是一个结构数组。一个 “状态”下的“值”通常会是一个或多个子问题的解。
3. 确定一些初始状态(边界状态)的值
以“数字三角形”为例,初始状态就是底边数字,值 就是底边数字值。
4. 确定状态转移方程
定义出什么是“状态”,以及在该 “状态”下的“值”后,就要 找出不同的状态之间如何迁移――即如何从一个或多个“值”已知的 “状态”,求出另一个“状态”的“值”(“人人为我”递推型)。状 态的迁移可以用递推公式表示,此递推公式也可被称作“状态转移方 程”。
●能用动规解决的问题的特点
1) 问题具有最优子结构性质。如果问题的最优解所包含的 子问题的解也是最优的,我们就称该问题具有最优子结 构性质。
2) 无后效性。当前的若干个状态值一旦确定,则此后过程 的演变就只和这若干个状态的值有关,和之前是采取哪 种手段或经过哪条路径演变到当前的这若干个状态,没 有关系。
●动归的三种形式
1)记忆递归型
优点:只经过有用的状态,没有浪费。递推型会查看一些 没用的状态,有浪费
缺点:可能会因递归层数太深导致爆栈,函数调用带来额 外时间开销。无法使用滚动数组节省空间。总体来说,比递推 型慢。
2) “我为人人”递推型
没有什么明显的优势,有时比较符合思考的习惯。个别特 殊题目中会比“人人为我”型节省空间。
状态i的值Fi在被更新(不一定是 最终求出)的时候,依据Fi去更 新(不一定是最终求出)和状态i 相关的其他一些状态的值 Fk,Fm,..Fy
Fk
Fm
Fi -> Fx
Fy
…
3)“人人为我”递推型动归
状态i的值Fi由若干个值 已知的状态值Fk,Fm,..Fy 推出,如求和,取最大值 ……
在选取最优备选状态的值Fm,Fn,…Fy时, 有可能有好的算法或数据结构可以用来显 著降低时间复杂度。
Fk
Fm
Fx -> Fi
Fy
…
例题 神奇的口袋(百练2755)
有一个神奇的口袋,总的容积是40,用这个口袋可以变出一 些物品,这些物品的总体积必须是40。
John现在有n(1≤n ≤ 20)个想要得到的物品,每个物品 的体积分别是a1,a2……an。John可以从这些物品中选择一 些,如果选出的物体的总体积是40,那么利用这个神奇的口 袋,John就可以得到这些物品。现在的问题是,John有多少 种不同的选择物品的方式。
输入
输入的第一行是正整数n (1 <= n <= 20),表示不同的物品的 数目。接下来的n行,每行有一个1到40之间的正整数,分别 给出a1,a2……an的值。
输出
输出不同的选择物品的方式的数目。
输入样例 3 20 20 20
输出样例 3
1.枚举的解法:
枚举每个物品是选还是不选,共2^20种情况
2.递归解法
#include <iostream>
#include <cstring>
#include <cstdio>
#include <cmath>
using namespace std;
#define N 100
int a[N],n;
int ways(int n,int w) //从前n种物品中选择一些,凑成体积w的做法数目
{
if(w==0) return 1;
if(n<=0) return 0;
return ways(n-1,w)+ways(n-1,w-a[n]);
}
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
cin>>a[i];
cout<<ways(n,40)<<endl;
return 0;
}
动规解法
#include <iostream>
#include <cstring>
#include <cstdio>
#include <cmath>
using namespace std;
#define N 100
int dp[N][50],n,a[N]; //dp[i][j]表示从前i种物品里凑出体积j的方法数
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>a[i];
dp[i][0]=1;
}
dp[0][0]=1;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=40;j++)
{
dp[i][j]=dp[i-1][j];
if(j>=a[i])
dp[i][j]+=dp[i-1][j-a[i]];
}
}
cout<<dp[n][40]<<endl;
return 0;
}
“我为人人”型递推解法
此问题仅在询问容积40是否可达,40是个很小的 数,可以考虑对值域空间-即对容积的可达性进行动态 规划。
定义一维数组 int sum[41];依次放入物品,计算每次放入物品可达的容积, 并在相应空间设置记录,最后判断sum[40] 是否可达 ,到达了几次。
#include <iostream>
#include <cstring>
#include <cstdio>
#include <cmath>
using namespace std;
#define N 100
int dp[N],n,a;
int main()
{
cin>>n;
memset(dp,0,sizeof(dp));
for(int i=1;i<=n;i++)
{
cin>>a;
for(int j=40;j>=1;j--) //一定为逆序,否则会重复更新
if(dp[j]>0&&j+a<=40)
dp[j+a]+=dp[j];
//如果j有dp[j]种方式可达,则每种方式加上a就可达 j+a
dp[a]++;
}
cout<<dp[40]<<endl;
return 0;
}
最长上升子序列的“我为人人”做法:
#include <iostream>
#include <cstring>
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;
#define N 1000
int dp[N],n,a[N];
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>a[i];
dp[i]=1;
}
for(int i=1;i<=n;i++)
{
for(int j=i+1;j<=n;++j) //看看能更新哪些状态的值
{
if(a[j]>a[i])
dp[j]=max(dp[j],dp[i]+1);
}
}
cout<<*max_element(dp+1,dp+n+1)<<endl;
return 0;
}