《算法竞赛——从入门到进阶》读书笔记(7)
第7章 动态规划
动态规划(Dynamic Programming,DP)题是算法竞赛中的必出题型。DP是一种常用的算法思想。DP问题可难可易,非常灵活,重点在于对“状态”和“转移”的建模与分析。该算法时间效率高,代码量少。
基础DP
硬币问题
有
贪心策略:每次选择最大面值。但是这样不一定能得到最优解,甚至在有解的情况下无法算出答案。
-
不能得到最优解的情形。例如面值是:1,2,4,5,6,支付9元。如果用贪心法,答案是6+2+1。需要3个硬币。而最优的5+4只需要两个硬币。
-
算不出答案的情形。如果有面值1的硬币,能保证用贪心法得到一个解,如果没有1元硬币,往往得不到解。例如:2,3,5元的硬币,支付9元,用贪心法无法得到解,但解释存在的,即9= 5+2+2。
以上分析,用贪心法解决最少硬币问题要求硬币面值是特殊的。对于任意面值的硬币问题,需要用动态规划来解决。硬币问题是简单的递推问题。
下面以5种面值(1,5,10,25,30)的硬币为例讲解递推的过程。
- (1)只使用最小面值的1分硬币。初始值
Min[0]=0 ,其他的Min[i] 为无穷大。下面计算Min[1] 。同理,i=2 时,相当于在Min[1] 的基础上加一个硬币,得到Min[2] = Min[2-1]+ 1 =2。继续这个过程 。分析上述过程,得到递推关系Min[i] = min(Min[i],Min[i-1]+1)
- (2)在使用1元硬币的基础上增加使用第二大面值的5分硬币,此时,应该从
Min[5] 开始,因为比5分硬币小的金额不可能用5分硬币实现。i=5 时,相当于在i=0 的基础上加一个5分硬币,得到Min[5] = Min[5-5] +1=1 。上一步用1分硬币的方法有Min[5]=5 .取最小值,得Min[5]=1 。同理,i=6 时,有Min[6]=Min[6-5]+1=2 ,对比原来的Min[6]=6 ,取最小值。继续这个过程。分析上述过程,得到递推关系Min[i] = min(Min[i],Min[i-5]+1)
- (3)继续处理其他面值的硬币。
代码实现
#include<bits/stdc++.h>
using namespace std;
const int M=251; //支付最大金额
const int V=5; //5种硬币
int type[V]={1,5,10,25,50};
int Min[M]; // 每种金额对应的最优组合
void solve() {
for(int k=0; k<M;k++) Min[k] =INT_MAX;
Min[0]=0;
for(int j=0;j<V;j++) {
for(int i=type[j];i<M;i++) {
Min[i]=min(Min[i],Min[i-type[j]]+1); //递推式
}
}
}
int main() {
int s;
solve();
while(cin>>s) {
cout<<Min[s]<<endl;
}
return 0;
}
后续步骤:继续以上过程,最后得到结果。最后的答案是
输出0/1背包方案
现在回头看具体装了哪些物品,需要倒过来观察:
用实线箭头指出方案路径
代码参考
#include<bits/stdc++.h>
using namespace std;
const int M=1010;
int f[M][M]; //物品,容量
bool c[M]; //记录物品是否选择 ,默认没有选择
int n,m;
int v[M],w[M];
int main() {
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
for(int i=1;i<=n;i++) {
for(int j=0;j<=m;j++) {
f[i][j] = f[i-1][j]; //不选物品的总价值
if(j>=v[i]) {
f[i][j] = max(f[i][j],f[i-1][j-v[i]]+w[i]) ; //选物品的总价值 , 取最大值
}
}
}
// 输出方案
for(int i=n,j=m;i>0;i--) {
if(j-v[i]>=0 && f[i-1][j] < f[i-1][j-v[i]]+w[i]) {
c[i]=1; j-=v[i];
}
else c[i]=0;
}
cout<<f[n][m]<<endl; //输出选4个物品,限体积4 的最大值
for(int i=1;i<=n;i++) if(c[i]) cout<<i<<" ";
return 0;
}
滚动数组
在处理
代码参考
#include<bits/stdc++.h>
using namespace std;
const int M=1010;
int f[M]; //物品,容量
bool c[M]; //记录物品是否选择 ,默认没有选择
int n,m;
int v[M],w[M];
int main() {
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
for(int i=1;i<=n;i++) {
for(int j=m;j>=v[i];j--) { //注意:反过来循环
f[j] = max(f[j],f[j-v[i]]+w[i]);
}
}
cout<<f[m]<<endl; //输出限体积4 的最大值
return 0;
}
注意,
经过滚动数组的优化,空间复杂度从
动态规划题经常会给出很大的
滚动数组也有缺点,它覆盖了中间转移状态,只留下了最后的状态,所以损失了很多信息,导致无法输出背包的方案。
最长公共子序列
- 题目: 最长公共子序列
最长公共子序列(Longest Common Subsequence ,LCS)问题:给定两个序列
用暴力法找最长公共子序列需要先找出
例如,序列
用
代码参考
#include<bits/stdc++.h>
using namespace std;
const int M=1010;
char a[M],b[M];
int dp[M][M];
int n,m;
int main() {
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>a[i];
for(int i=1;i<=m;i++) cin>>b[i];
for(int i=1;i<=n;i++) {
for(int j=1;j<=m;j++) {
if(a[i] == b[j]) dp[i][j] = dp[i-1][j-1]+1;
else {
dp[i][j] = max(dp[i-1][j],dp[i][j-1]);
}
}
}
cout<<dp[n][m];
return 0;
}
如果要输出方案,和0/1背包的输出方案一样,LCS需要从后面倒推回去。
最长上升子序列
最长上升子序列(Longest Increasing Subsequence, LIS)问题:给定一个长度为
- P1020 导弹拦截
这个题目的思维转换有一些难度。题目的意思是统计一个序列中的单调递减子序列最少有多少个。这和最长递增子序列LIS有什么关系呢?假设已经有了求LIS的算法,可以这么转换:把序列反过来,就变成了了求反序列的递增子序列;先求反序列的第1个LIS,然后从原序列中去掉这个LIS,再对剩下的求第2个LIS,直到序列为空;这些LIS的数量就是题目的解。但是,其实并不用这么麻烦,这个题目实际上等价于求原序列的LIS,这是一道求LIS的裸题。
模拟计算过程:
- 从第一个数开始,找一个最长的递减子序列。第一拦截系统X:{389, 300, 299, 170, 158, 65}
- 去掉这些数,序列中还剩下{207, 155}。第二个拦截系统Y: {207,155}。
结论: 在Y中,至少有一个数a大于X中的某个数,否则a比X的所有数都小,应该在X中。所以 ,从每个拦截系统中拿出一个数能构成一个递增子序列,即拦截系统的数量等于这个递增子序列的长度。
方法1: 上一节讲解了最长公共子序列LCS,可以借助LCS思想,首先对序列排列,得到A’= {65, 158, 170, 299, 300, 389},那么A的LIS就是A和A'的LCS。其复杂度是
方法2:直接用DP求解LIS。复杂度也是
定义状态
代码参考
#include<bits/stdc++.h>
using namespace std;
const int M=100010;
int n,h[M];
//最长递增子序列
int LIS() {
int ans=1; //至少一个拦截系统
int dp[M];
dp[1]=1;
for(int i=2;i<=n;i++) {
int max=0;
for(int j=1;j<i;j++) {
if(dp[j]>max && h[i]>h[j]) max=dp[j];
}
dp[i]=max+1; //更新dp
if(dp[i]>ans) ans=dp[i]; //取最大值
}
return ans;
}
//最长递减子序列
int LDS() {
int ans=1; //至少一个拦截系统
int dp[M];
dp[1]=1;
for(int i=2;i<=n;i++) {
int max=0;
for(int j=1;j<i;j++) {
if(dp[j]>max && h[i]<h[j]) max=dp[j]; //主要修改该条件
}
dp[i]=max+1; //更新dp
if(dp[i]>ans) ans=dp[i]; //取最大值
}
return ans;
}
int main() {
cin>>n;
for(int i=1;i<=n;i++) cin>>h[i];
cout<<LDS()<<endl;
cout<<LIS();
return 0;
}
方法3: 有一种更快的、复杂度为
定义:数组d[ ]; len统计d[ ] 内数据的个数; high[ ] 为原始序列。
初始化:
操作步骤:逐个处理high[ ] 中的数字,例如处理到了high[k],①如果high[k]比d[]末尾的数字更大,就加到d[]的后面; ②如果high[k]比d[]末尾的数字小,就替换d[]中第1个大于它的数字。
以high ={4,8,9,5,6,7}为例。具体操作过程如下:
代码参考
#include<bits/stdc++.h>
using namespace std;
const int M=100010;
int n,h[M];
//最长递增子序列
int LIS() {
int len=1;
int d[M];
d[1]=h[1];
for(int i=2;i<=n;i++) {
if(h[i]> d[len]) d[++len] = h[i];
else {
int j=lower_bound(d+1,d+1+len,h[i]) -d;
d[j]=h[i];
}
}
return len;
}
int main() {
cin>>n;
for(int i=1;i<=n;i++) cin>>h[i];
cout<<LIS();
return 0;
}
区间DP
区间DP的主要思想是先在小区间进行DP得到最优解,然后再利用小区间的最优解合并求大区间的最优解。
区间DP,一般需要从小到大枚举所有可能的区间。在解题时,先解决小区间问题,然后合并小区间,得到更大的区间,直到解决最后的大区间问题。合并的操作一般是把左、右两个相邻的子区间合并。
区间DP的两个难点:枚举所有可能的区间、状态转移方程。
区间DP的复杂度:一个长度为
石子合并问题
题目链接 : 石子并问题
样例:3堆石子: 2,4,5, 最小花费17。
计算过程:① 第一次合并
DP的状态设计
设
- (1)合并之前,
dp[i][i]=0 ,1≤i≤n
-
(2)两堆合并,例如:
dp[1][2] = dp[1][1]+ dp[2][2]+sum[1][2] ;总结:dp[i][i+1] = dp[i][i]+dp[i+1][i+1]+sum[i][i+1] -
(3)三堆合并:合并第1堆到第3堆,有两种情况。
dp[1][3]= min(dp[1][1]+dp[2][3], dp[1][2]+dp[3][3])+sum[1][3] ;总结:dp[i][i+2] = min(dp[i][i]+dp[i+1][i+2],dp[i][i+1]+dp[i+2][i+2]) ; -
(4)推广:第
i 堆到第j 堆的合并。dp[i][j]=min(dp[i][k]+dp[k+1][j])+sum[i][j-i+1], i≤k≤j 。这就是状态转移方程式。
代码参考
#include<bits/stdc++.h>
using namespace std;
const int M=310;
const int INF=1<<30;
int a[M]; //石子颗数
int n; //堆数
int dp[M][M]; //花费
int sum[M]; //前n项和
int main() {
cin>>n;
for(int i=1;i<=n;i++) {
cin>>a[i];
sum[i]= sum[i-1]+a[i]; //计算前n项和
}
for(int len=1;len<n;len++) { //len为i 和 j 的距离
for(int i=1;i<=n-len;i++) { // 表示从第i堆开始
int j=i+len; //表示到第j堆结束
dp[i][j] = INF; //求最小值,默认值为无穷大
for(int k=i ; k<j; k++) { //i和j之间用k进行分割
dp[i][j] = min(dp[i][j], dp[i][k]+dp[k+1][j]+sum[j]-sum[i-1]); //sum[i][j] = sum[j] - sum[i-1]
}
}
}
cout<<dp[1][n];
return 0;
}
复杂度是
用
上述讨论符合“四边形不等式”优化原理,它是区间DP的常见优化方法。经过优化以后,复杂度接近
代码参考
// 四边形不等式优化
#include<bits/stdc++.h>
using namespace std;
const int M=310;
const int INF=1<<30;
int a[M]; //石子颗数
int n; //堆数
int dp[M][M]; //花费
int sum[M]; //前n项和
int s[M][M];
int main() {
cin>>n;
for(int i=1;i<=n;i++) {
cin>>a[i];
sum[i]= sum[i-1]+a[i]; //计算前n项和
s[i][i] = i; //初始最佳分割点
}
for(int len=1;len<n;len++) { //len为i 和 j 的距离
for(int i=1;i<=n-len;i++) { // 表示从第i堆开始
int j=i+len; //表示到第j堆结束
dp[i][j] = INF; //求最小值,默认值为无穷大
for(int k=s[i][j-1] ; k<= s[i+1][j]; k++) { //i和j之间用k进行分割, 缩小范围
if(dp[i][k] + dp[k+1][j] + sum[j]- sum[i-1] < dp[i][j]) {
dp[i][j] = dp[i][k] + dp[k+1][j] + sum[j]- sum[i-1];
s[i][j] = k; //记录[i,j]的最优分割点
}
}
}
}
cout<<dp[1][n];
return 0;
}
回文串
题目:P2890 回文串
题意: 给定字符串
样例解释:在结尾处插入"a",得到"abcba",花费1000; 如果在首端删除"a"得到"bcb",花费1100;如果在首端插入"bcb",花费350+200+350=900,这是最小值。
该题有多种解法,其中一种是把
下面用区间DP的方法求解。
定义状态
另外,在考虑删除和插入的花费时,由于这两种操作时等价的(这头加和那头减一样),所以只要取这两种操作的最小值就行了。用数组
有以下3种情况:
-
(1)如果
s[i] == s[j] ,那么dp[i][j] = dp[i+1][j-1] ; -
(2)如果
dp[i+1][j] 是回文串,那么dp[i][j] = dp[i+1][j]+w[i] ; -
(3)如果
dp[i][j-1] 是回文串,那么dp[i][j] = dp[i][j-1]+w[j] 。
代码参考
#include<bits/stdc++.h>
using namespace std;
const int M=2010;
int w[30],n,m,dp[M][M];
char s[M],ch;
int main() {
int x,y;
cin>>n>>m;
cin>>s;
for(int i=1;i<=n;i++) {
cin>>ch>>x>>y; w[ch - 'a'] = min(x,y); //取最小值
}
for(int i=m-1;i>=0;i--) { // i是子区间的起点
for(int j=i+1;j<m;j++) { // j是子区间的终点
if(s[i] == s[j]) dp[i][j] = dp[i+1][j-1];
else {
dp[i][j] = min(dp[i+1][j]+w[s[i]-'a'] , dp[i][j-1]+w[s[j]-'a']) ;
}
}
}
cout<<dp[0][m-1]<<endl;
return 0;
}