这两周的时间,自己继续学习动态规划的相关知识,重点是背包问题和区间DP的学习以及一些典型例题的总结和分析。
01背包问题
题意是说有n件物品,每件物品的重量为w[i],价值为c[i],有一个容量为V的背包,问如何选取物品,可以使得背包内物品的总价值最大,每件物品只有一个。然后考虑这个题呢,没接触DP的话,会首先想到枚举,但是,肯定会超时的。所以用动态规划解决,可以令dp[i][v]表示前i件物品恰好装入容量为v的背包中所能获得最大价值。考虑到有两种策略:
第一种是不放第i件物品,问题就可以转化为前i-1件物品恰好装入容量为v的背包中所能获得的最大价值,就是dp[i-1][v]。
第二种就是如果放第i件物品,问题就转化为前i-1件物品恰好装入容量为v-w[i]的背包中能够获得的最大价值,也就是dp[i-1][v-w[i]]+c[i]。
然后就可以列出状态转移方程dp[i][v]=max{dp[i-1][v],dp[i-1][v-w[i]]+c[i]}。
因为dp[i][v]只与之前的状态有关,就可以从1到n枚举i,v是从0到V,边界条件为dp[0][v],然后逐渐把整个数组递推出来。dp[i][v]就是为v的情况,枚举并且取最大值就可以了。
然后这个题目还可以用滚动数组的技巧来进行优化。计算dp[i][v]的时候只需要dp[i-1][v]左侧部分的数据,所以就直接写成dp[v],然后枚举的方向为逆序:从1到n枚举i,从v到0枚举V,状态转移方程就可以写成dp[v]=max(dp[v],dp[v-w[i]]+c[i])。
可以考虑到,第i件物品放还是不放产生的最大值由前面的i-1件物品的最大值决定,每个物品都可以看作一个阶段,由上一个阶段的状态得到。
优化代码:
for (int i = 1; i <= n; i++) {
for (int v = V; v >= w[i]; v--) { //逆序枚举
dp[v] = max(dp[v], dp[v - w[i]] + c[i]);
}
}
完全背包问题
完全背包问题和01背包问题的唯一区别就是,完全背包的物品数量每种都有无穷件,只要选的时候不超过容量就可以,01背包的物品数量每种只有1件。也是有两种策略:
第一种和01背包一样,不放第i件物品,就是dp[i][v]=dp[i-1][v]。
第二种就开始有区别了,01背包每件物品只有一个,完全背包每件物品有无穷个,所以选择放完第i件物品之后状态不是转移到dp[i-1][v-w[i]],而是转移到dp[i][v-w[i]],放了第i件物品后还能继续放第i件物品,一直到v-w[i]不再大于等于零。
然后就可以列出状态转移方程dp[i][v]=max(dp[i-1][v],dp[i][v-w[i]]+c[i]),边界为dp[0][v]=0。
唯一的区别就是第二个参数是dp[i],不是dp[i-1]。
优化之后的形式也为dp[v]=max(dp[v],dp[v-w[i]]+c[i]),边界为dp[v]=0。唯一不同的地方就是,v的枚举顺序是正向枚举,01背包是逆向枚举,完全背包中每一步的选择都会对接下来的各种情况产生影响,所以只能正序。
优化代码:
for (int i = 1; i <= n; i++) {
for (int v = w[i]; v <=V; v++) { //正向枚举
dp[v] = max(dp[v], dp[v - w[i]] + c[i]);
}
}
多重背包问题
多重背包就是01背包和完全背包的混合体,其中既有有限量的物品又有无限量的物品,这里可以使用二进制的思想进行优化。就是把01背包和完全背包组合在一起就可以,下面是基础模板:
for(int i=1; i<=n; i++)//每个物品
for(int k=0; k<num[i]; k++)//调用num[i]次01背包代码
for(int j=m; j>=weight[i]; j--)//01背包
dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);
区间DP
区间DP就是对区间的动态规划,一般通过记忆化搜索和递推实现。一般思路就是将大区间化成小区间来处理,然后对小区间处理,再回溯求出区间的最大值,通过保证小区间的最优达到大区间的最优。
典型例题就是石子合并问题,P1880 [NOI1995] 石子合并,题意是说有N堆石子,现要将石子有次序地合并成一堆,规定每次只能选相邻的两堆合并成新的一堆,并将新的一堆的石子数,记为该次合并的得分,计算出将N堆石子合并成1堆的最小得分和最大得分。如果一开始的最左端的石子和最右端的石子可以合并成一堆,说明它们之间的石子也已经被合并,可以记作l到r之间的石子,只有这样l和r才可能相邻被合并,在任意时刻,任意一堆石子都可以用一个闭区间 [ l,r ]来描述,表示这堆石子是由最初的第l到r堆石子合并而成的,然后一定会有一个数k,在l到k堆石子已经合成了一堆,第k+1到r堆石子被合并成一堆,然后这两堆石子再合并成 [ l,r ]。就是两个长度较小的区间上的信息向一个更长区间发生了状态转移,划分点 k 就是转移的决策。应该把区间长度length作为DP的阶段,区间长度可以由左端点和右端点表示,就是lenth=r-l+1。
状态转移方程:
典型例题
P4170 [CQOI2007]涂色,题意是说给定一个长度为n的木板,每次可以选择一个连续长度的木板将它染成同一种颜色,后来的颜色可以覆盖之前的颜色,给定一个目标颜色的木板,问最少需要花多少次才能染成目标木板。要求如何涂到目标长度,可以反过来想,把整块木板涂成相同颜色,最后对答案加1。因为在一个相同颜色块上面涂别的颜色相当于把这个别的颜色再涂回去。用f[i][j][c]表示将区间[i][j]全部涂成颜色c所需要的最少步数,枚举中转点k和颜色c,就可以得出状态转移方程:
值得注意的是,对于区间[i,j],可能由区间[i,k]+[k+1,j]转化而来,还有可能是由区间[i,j]直接转化而来,就是把整个区间涂好某种颜色之后,再把整个区间改成另一种颜色。所以在状态转移的时候可以维护一个最小的f[i][j][c],后面再用这个最小值再去更新f[i][j][c],最后可以求出整段木板涂成某一种颜色的最小代价,最小代价再加1就是从空木板涂成目标木板所需要的最小步数。
void solve()
{
cin>>s+1;
int n=strlen(s+1);
memset(f,0x3f,sizeof f);
for(int i=1; i<=n; i++)
for(char c='A'; c<='Z'; c++)
f[i][i][(int)(c-'A')]=(s[i]==c?0:1);
for(int len=2; len<=n; len++)
{
for(int i=1; i+len-1<=n; i++)
{
int j=i+len-1;
for(int k=i; k<=j-1; k++)
{
int mi=1e18;
for(char c='A'; c<='Z'; c++)
{
int p=(int)(c-'A');
f[i][j][p]=min(f[i][j][p],f[i][k][p]+f[k+1][j][p]);
mi=min(mi,f[i][j][p]);
}
for(char c='A'; c<='Z'; c++)
{
int p=(int)(c-'A');
f[i][j][p]=min(f[i][j][p],mi+1);
}
}
}
}
int res=inf;
for(char c='A'; c<='Z'; c++)
res=min(res,f[1][n][(int)(c-'A')]);
cout<<res+1<<endl;
}
P1896 [SCOI2005] 互不侵犯,题意就是说在N×N的棋盘里面放K个国王,使他们互不攻击,共有多少种摆放方案,国王能攻击到它上下左右,以及左上左下右上右下八个方向上附近的各一个格子,共8个格子。可以用一个二进制数字来表示某一行的国王放置情况,如果第i位是1,就表示位置i处放了国王,如果为0就表示没有放置国王。对于某个数字,其代表的放置状态可能不合法,如果一个数的二进制有两个相邻的1,说明不合法,所以可以右移一位与原数按位与就可以判断是否合法。
确定状态转移方程:可以定义dp[i][s][k]为当前放置到第i行,当前行的放置状态为s,当前已经放了k个国王的方案数。确定最终的状态转移方程为dp[i][s][k]+=dp[i-1][ss][k-get(s)],ss代表第i-1行的放置状态,get(s)代表在s对应放置状态下该行放置国王的个数。
边界处理:在第一行中,放置状态为s且刚好放置了get(s)个国王的方案数是1种,就是dp[1][s][get(s)]=1。
#include<iostream>
using namespace std;
const int n = 12;
typedef long long LL;
LL dp[n][1 << n][n * n];
int get(int x) { //返回x对应的二进制中1的个数
int cnt = 0;
while (x) {
x -= x & (-x); //减去最低位的1
cnt++;
}
return cnt;
}
//判断数x是否是合法的放置状态
int check1(int x) {
return (x & (x << 1)) == 0;
}
//判断x和y代表的放置状态是否冲突
int check2(int x, int y) {
if (x & y) return 0;
if ((x << 1) & y) return 0;
if (x & (y << 1)) return 0;
return 1;
}
int main() {
int N, K;
cin >> N >> K;
for (int i = 1; i <= N; i++) { //逐行放置
for (int s = 0; s < (1 << N); s++) { //枚举所有可能的放置状态
if (check1(s)) { //若s为合法的放置状态
if (i == 1) { dp[i][s][get(s)] = 1; } //边界处理
else {
for (int ss = 0; ss < (1 << N); ss++) { //枚举上一行的放置状态
if (check2(s, ss) && check1(ss)) { //检查当前行与上一行是否冲突
for (int k = get(s); k <= K; k++) {
dp[i][s][k] += dp[i - 1][ss][k - get(s)];
}
}
}
}
}
}
}
//统计答案
LL res = 0;
for (int s = 0; s < (1 << N); s++) res += dp[N][s][K];
cout << res << endl;
return 0;
}
P2015 二叉苹果树,题意就是说有一棵苹果树,如果树枝有分叉,一定是分2叉,这棵树共有N个结点,编号为1-N,树根编号一定是1,用一根树枝两端连接的结点的编号来描述一根树枝的位置,现在这颗树枝条太多,需要剪枝,但是一些树枝上长有苹果,给定需要保留的树枝数量,求出最多能留住多少苹果。可以用f[i][j]表示以i为根节点的子树,保留j条树枝时,保留的最大苹果数,还能得出的一个条件是当某条边被保留下来时,从根节点到这条边的路径上的所有边也都必须保留下来。
状态转移方程:
保留一条边必须保留从根节点到这条边路径上的所有边,如果想从u的子节点v的子树上留边,也要留下u,v之间的连边,连接方式用邻接矩阵、邻接表实现。
int n,q,x,y,v,tot,h[101],e[101],f[101][101],s[101][101];
struct node
{
int to,next;
}a[220];
void add(int x,int y)
{
a[++tot]=(node){y,h[x]};
h[x]=tot;
}
void dp(int k)
{
e[k]=1;//记录当前节点已经被访问
for(int i=h[k];i>0;i=a[i].next)//,枚举边
{
if(e[a[i].to]==1) continue;//如果它的子节点已经被访问过,那就不用做了。
dp(a[i].to);//递归
for(int j=q;j>0;j--)
{
for(int g=j-1;g>=0;g--)
{
f[k][j]=max(f[k][j],f[a[i].to][g]+f[k][j-g-1]+s[k][a[i].to]);
}
}
}
}
int main()
{
cin>>n>>q;
for(int i=1;i<=n-1;i++)
{
cin>>x>>y>>v;
add(x,y);
add(y,x);
s[x][y]=s[y][x]=v;//记录权值
}
dp(1);
cout<<f[1][q];//q是要保留的树枝数量
return 0;
}
小结:
这两周动态规划的学习算是结束了,但接下来还有更加艰难的挑战再等着自己。坚定信心,一鼓作气,全力冲刺,不留遗憾!!!下周要继续努力呀!!!