动态规划
文章目录
一、背包问题
1、01背包问题
01背包问题就是通过遍历选择是否选第i个物品,以达到总价值最大:
#include<iostream>
using namespace std;
const int N = 1010;
int n,m;//物品总数为n个,背包的总容量为m
int dp[N];
int v[N],w[N];//物品的体积和价值
int main()
{
cin >> n >> m;
for(int i = 0;i < n;i++)cin >> v[i] >> w[i];
for(int i = 0;i < n;i++)
for(int j = m;j >= v[i];j--)
dp[j] = max(dp[j],dp[j - v[i]] + w[i]);//从不选和选中选中较大值
cout << dp[m] << endl;
return 0;
}
当遍历方向为从大到小时,每次更新时使用的是上一次的状态。更新的只与i-1时的状态有关,而逆序可以保证i-1的状态为上一次循环的状态,没有被更新。
2、完全背包问题
首先看结论,完全背包问题的代码就是将01背包中的从大到小遍历改成从小到大遍历
for(int i = 0;i < n;i++)
for(int j = v[i];j <= m;j++)
dp[j] = max(dp[j],dp[j - v[i]] + w[i]);
因为每个物品可以无限选择,所以选择的结果是多样的,此时:
dp[j] = max(dp[j],dp[j - v[i]] + w[i],dp[j - 2 * v[i]] + 2 * w[i]……)
同时,dp[j - v[i]] = max(dp[j - v[i]],dp[j - 2 * v[i]] + w[i]……)
所以,dp[j] = max(dp[j],dp[j - v[i]] + w[i])
由于这里的状态是需要及时更新的,所以这里的循环方式是正序。
3、多重背包
多重背包本质上还是利用01背包的思想处理的,每个物品可以选择多件,但是有数量限制,不同于完全背包的无限选择。
3.1、多重背包的朴素实现
for(int i = 0;i < n;i++)
{
scnaf("%d%d%d",&v,&w,&s);//输入体积价值和数量
for(int j = m;j >= v;j--)
for(int p = 1;p <= s && j >= p * v;p++)
f[j] = max(f[j],f[j - p * v] + p * w);
}
3.2、二进制优化多重背包
二进制优化多重背包是指利用2的次方将第i物品拆分为多个物品,并单独作为一个物品利用01背包计算结果。
比如,8拆分为1,2,4,1;10拆分为1,2,4,3。即20,21,22,23……2n,余数。
struct Good
{
int v,w;
};
vector<Good>good;
void dp()
{
for(int i = 0;i < n;i++)
{
int v,w,s;
cin >> v >> w >> s;
for(int j = 1;j <= s;s -= j,j *= 2)
good.push_back({j * v,j * w});
if(s)good.push_back({s * v,s * w});
}
for(auto g : good)
{
for(int i = m;i >= g.v;i--)
f[i] = max(f[i],f[i - g.v] + g.w);
}
}
4、分组背包
分组背包级有讲物品分为多组,每组有多个物品,但是每组物品最多只能选择一个。
for(int i = 0;i < n;i++)
{
int s;
cin >> s;
for(int j = 0;j < s;j++)cin >> v[j] >> w[j];
for(int j = m;j > 0;j--)
{
for(int p = 0;p < s;p++)
{
f[j] = max(f[j],f[j- v[p]] + w[p]);
}
}
}
二、线性DP
1、数字三角形
存在以下数字三角形,求和最大路径的值。
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
从最后一层开始dp即可,每次用较大的值加上上一个值,即可得到结果:
for(int i = n - 1;i > 0;i--)
{
fo(int j = 1;j <= i;j++)
{
num[i][j] += max(num[i][j],num[i][j + 1]);
}
}
最后的结果即为3num[1] [1]的值。
2、最长上升子序列
2.1、最长上升子序列的朴素实现
最长上升子序列的求法就是在每个点的位置存入以该点为结尾的最长子序列的长度,然后遍历是实现即可。
for(int i = 1;i <= n;i++)
for(int j = 1;j < i;j++)
f[i] = max(f[i],f[j] + 1);
2.2、优化
在储存子序列末尾数字的时候,取最小的存储,那么子序列末尾数字与子序列长度将呈正相关关系。
for(int i = 1;i <= n;i++)cin >> a[i];
int len = 0;
for(int i = 1;i <= n;i++)
{
int l = 0,r = len;
while(l < r)
{
int mid = l + r + 1 >> 1;
if(q[mid] < a[i])l = mid;
else r = mid - 1;
}
len = max(len ,r + 1);
q[r + 1] = a[i];
}
3、最长公共子序列
最长公共子序列不一定要求连续。如果序列末尾不相等,则从状态[i,j-1]和[i-1,j]中取较大的值,如果末尾相等,则为状态[i-1,j-1]+1。
for(int i = 1;i <= n;i++)
for(int j = 1;j <= m;j++)
{
f[i][j] = max(f[i-1][j],f[i][j-1]);
if(a[i] == b[j])f[i][j] = f[i-1][j-1] + 1;
}
例如,序列:
acbd abedc
状态表示:
j\i | 1 | 2 | 3 | 4 |
---|---|---|---|---|
1 | 1 | 1 | 1 | 1 |
2 | 1 | 1 | 2 | 2 |
3 | 1 | 1 | 2 | 2 |
4 | 1 | 1 | 2 | 3 |
5 | 1 | 2 | 2 | 3 |
4、最短编辑距离
存在两不同的字符串a和字符串b,每一步操作对a进行删除、插入或修改一个字符,最终将a变成b,即为操作距离。操作的次数最少的方式即为最短编辑距离。这个问题的解决方法类似于完全背包问题。
它的思想是将a从1位开始处理变成b的每一位的次数,即用数组f[i] [i]来记录将a[1 ~ i]变成b[1 ~ j]的步骤。
首先是预处理所有0位变化,假设a有n位,b有m位。
for(int i = 0;i <= n;i++)f[i][0] = i;
for(int i = 0;i <= m;i++)f[0][i] = i;
然后是状态更新部分,类似于完全背包,是正序遍历:
for(int i = 1;i <= n;i++)
for(int j = 1;j <= m;j++)
{
f[i][j] = min(f[i - 1][j],f[i][j - 1]) + 1;
if(a[i] == b[j])f[i][j] = min(f[i][j],f[i - 1][j - 1]);
else f[i][j] = min(f[i][j],f[i - 1][j - 1] + 1);
}
最后的答案即为f[n] [m],输出即可。
三、区间DP
首先利用前缀和预处理,方便求出每个区间的石子堆的重量和,输入的之后即可处理:
for(int i = 1;i <= n;i++)
{
cin >> s[i];
s[i] += s[i - 1];
}
然后利用三层循环来遍历每一个状态。第一层是区间长度,即需要合并的石子堆的数量。长度从2开始,因为长度为1时不需要合并;第二层是遍历区起点的下标;第三层是遍历区间里的每个点求出区间最小值。
const int null = 0x3f3f3f3f;
for(int l = 2;l <= n;l++)
{
for(int i = 1;i + l - 1 <= n;i++)
{
int j = i + l - 1;
f[i][j] = null;
for(int p = i;p <= j;p++)
f[i][j] = min(f[i][j],f[i][p] + f[p + 1][j] + s[j] - s[i - 1]);
}
}
最后得到的f[1] [n]就是答案。
四、计数DP
计数dp本质上是完全背包的变种,将数字n拆分为不同的数的和,数字可以重复,但是不计顺序。
例如:
5可以拆分为
1 1 1 1 1
1 1 1 2
1 1 3
1 2 2
2 3
有两种代码实现方法,第一种是利用01背包,不过属性由最大值改为数量
f[i] [j] = f[i - 1] [j] + f[i - 1] [j - i] + … + f[i - 1] [j - s * i];
f[i] [j - i] = f[i - 1] [j - i] + f[i - 1] [j - 2 * i] + … + f[i - 1] [j - s * i];
综上,f[i] [j] = f[i - 1] [j] + f[i - 1] [j - i],简化过后就是f[j] = f[j] + f[j - i]。
代码如下:
f[0] = 1;
for(int i = 1;i <= n;i++)
for(int j = i;j <= n;j++)
f[j] = f[j] + f[j - i];
还有一种代码为:
f[0][0] = 1;
for(int i = 1;i <= n;i++)
for(int j = 1;j <= i;j++)
f[i][j] = f[i -1][j - 1] + f[i - j][j];
五、数位统计DP
数位统计DP用于计算一定范围内的数字出现的次数,比如[0,10]这个区间里,0这个数字出现过两次,即0和10的个位上。这个可以通过暴力来实现,但是暴力的时间复杂度很高,计算量庞大。
数位统计DP的原理如下:
求某七位数abcdefg范围内数字1出现的次数,在其每一位上进行一次计算。
情况1、计算最高位数字的时候,只需要计算后边的次数即可,这里为000000~bcdefg的数字量,即bcdefg+1。
情况2、在中间时,这里以第四位为例
2.1前边的数小于abc,前边有abc中情况,后边有1000中情况,综合为abc*1000种。
2.2前边的数等于abc,存在三种状况
2.2.1 d < 1时,这个数比abcdefg大,情况为0
2.2.2 d = 1时,后边的数可以取000~efg,则数量为efg+1
2.2.3 d > 1时,后边可以取000~999,则数量为1000
ps:当d为0的时候,前边不能取000,要从001开始,所以要将数字-1
综上可以得到代码:
int get(vector<int> num,int l,int r)//求0~x中的数字数量,即x
{
int ans = 0;
for(int i = l;i >= r;i--)ans = ans * 10 + num[i];
return ans;
}
int pow10(int x)//求10的x次方
{
int ans = 1;
while(x--)ans *= 10;
return ans;
}
int count(int n,int x)
{
if(!n)return 0;
vector<int> num;
while(n)
{
num.push_back(n % 10);
n /= 10;
}
n = num.size();
int ans = 0;
for(int i = n - 1 - !x;~i;i--)//x为0时,最高位不需要计算,
{
if(i < n - 1)//最高位不需要这个步骤
{
ans += get(num,n - 1,i + 1) * pow10(i);
if(!x)ans -= pow10(i);//x为0时,减去一种情况
}
if(num[i] == x)ans += get(num,i - 1,0) + 1;//情况2,d < x时为0无需考虑
else if(num[i] > x)ans += pow10(i);
}
return ans;
}
六、状态压缩DP
1、蒙德里安的梦想
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-auHvkmdz-1647344306704)(E:\笔记&诗歌散文\笔记\博客\图片\QQ图片20220312171852.png)]
这一题是大多数人学习状态压缩是所做的第一题,状态压缩的定义也可以在这里得到阐述。所谓状态压缩,就是将当前状态用二进制01串表示。
-
在这里,我们通过先摆放横着的小方块,将所有的横着的小方块放完之后,剩下的部分只能有一种摆法供竖着的小方块摆,所以计算横摆小方块的情况即可。
-
然后是判断这一列的摆放是否合法。判断方法是,空着的连续小方格为偶数,这样才能用竖着的小方块摆满。
我们假设一共是两行,那么一列的横摆方式有(1表示摆放,0表示空)00,01,10,11四种,即22种。简单计算可知,如果存在n行,那么共有2n种摆放方式(包括不合法的摆放方式)。这些摆放方式恰好可以用0~2n的二进制来表示,由此,我们可以得到代码:
for(int i = 0;i < 1 << n;i++)
{
int cnt = 0;//用于储存连续空格的数量
vis[i] = 1;//表示是否合法
for(int i = 0;i < n;i++)//二进制位n位
{
if(i >> j & 1)
{
if(cnt & 1)
{
vis[i] = 0;
break;
}
cnt = 0;
}
else cnt++;
}
if(cnt & 1)vis[i] = 0;
}
在将所有的合法序列都筛选出来之后,再进行下一步筛选并储存。这次的筛选是选择合适的序列组,如果第i-1列的某处伸入了第i列,那么在个位置第i列就只能是空,例如:001001和011001,再第三位上均为1,不储存。
for(int i = 0;i < 1 << n;i++)
{
state[i].clear();//存储序列的容器,每次使用前清空。
for(int j = 0;j < n;j++)
{
if((i & j) == 1 && vis[i | j])//i & j == 1说明它们没有重复覆盖位置,vis[i | j]说明它们留下的空位合法
state[i].push_back(j);
}
}
最后就是dp环节了:
memset(f,0,sizeof f);
for(int i = 1;i <= m;i++)
for(int j = 0;j < 1 << n;j++)
for(auto k : state[i])
f[i][j] += f[i - 1][k];//三重循环遍历z
最后输出f[m] [0]即可,说明在m列上,伸出数量为0的方案数。
完整代码如下:
#include<iostream>
#include<vector>
#include<cstring>
using namespace std;
const int N = 12, M = 1 << N;
typedef long long LL;
int n, m;
LL f[N][M];
vector<int>state[M];
bool vis[M];
int main()
{
while (cin >> n >> m,n || m)
{
int cnt = 0;
for (int i = 0; i < 1 << n; i++)
{
vis[i] = 1;
for (int j = 0; j < n; j++)
{
if (i >> j & 1)
{
if (cnt & 1)
{
vis[i] = 0;
break;
}
cnt = 0;
}
else cnt++;
}
if (cnt & 1)vis[i] = 0;
}
for (int i = 0; i < 1 << n; i++)
{
state[i].clear();
for (int j = 0; j < 1 << n; j++)
{
if ((i & j) == 0 && vis[i | j])
state[i].push_back(j);
}
}
memset(f, 0, sizeof f);
f[0][0] = 1;
for (int i = 1; i <= m; i++)
for (int j = 0; j < 1 << n; j++)
for (auto k : state[j])
f[i][j] += f[i - 1][k];
cout << f[m][0] << endl;
}
return 0;
}
2、最短Hamilton路径
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KzVSwuGn-1647344306705)(E:\笔记&诗歌散文\笔记\博客\图片\最短Hamilton路径.png)]
这里我们需要维护两个量:一个是state,用来存储已经走过的点,另一个是j,即现在所处的点。
f[state] [j] = f[state_k] [k] + f[k] [j],state_k表示state除去j之后、并且包含k的集合。
这里的state利用二进制来表示,哪一位上为1,则表示其已经走过。例如:00100表示走过第三个点。
memset(f,0x3f,sizeof f);
f[1][0] = 0;
for(int i = 0;i < 1 << n;i++)
for(int j = 0;j < n;j++)
if(i >> j & 1)//走过第j位,比较上一状态
for(int k = 0;k < n;k++)
if(i - (1 << j) >> k & 1)//没走过第j位但是走过第k位
f[i][j] = min(f[i][j],f[i - (1 << j)][k] + d[k][j]);//数组d储存边权
完整代码如下:
#include<iostream>
#include<cstring>
using namespace std;
const int N = 20, M = 1 << 20;
int n;
int f[M][N], d[N][N];
int main()
{
cin >> n;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
cin >> d[i][j];
memset(f, 0x3f, sizeof f);
f[1][0] = 0;
for (int i = 0; i < 1 << n; i++)
for (int j = 0; j < n; j++)
if (i >> j & 1)
for (int k = 0; k < n; k++)
if (i - (1 << j) << k & 1)
f[i][j] = min(f[i][j], f[i - (1 << j)][k] + d[k][j]);
cout << f[(1 << n) - 1][n - 1] << endl;
return 0;
}
七、树形DP
根据题目描述,没有职员愿意和直接上司一起参会,意味着子结点和父结点不能同时选入。那么,每个结点就要更新两种状态,选和不选:该节点是选入状态时,利用子结点的不选状态进行更新:该节点是不选的时候,利用子结点选入和不选中较大的进行更新。
我们创建一个二维数组f[N] [2],两个下标分别表示位置和选择状态:
f[u] [1] += f[j] [0];
f[u] [0] += max(f[j] [0],f[j] [1]);
再利用dfs从父结点开始遍历每个结点就可以得到答案。
#include<iostream>
#include<cstring>
using namespace std;
const int N = 6010;
int e[N],ne[N],h[N],idx;
int f[N][2];//状态更新
int a[N];//存储读入数据
bool flag[N];//标记根节点
void add(int a,int b)
{
e[idx] = b,ne[idx] = h[a],h[a] = idx++;
}
void dfs(int u)
{
f[u][1] = a[u];
for(int i = h[u];~i;i = ne[i])
{
int j = e[i];
dfs(j);
f[u][1] += f[j][0];
f[u][0] += max(f[j][0],f[j][1]);
}
}
int main()
{
int n;
cin >> n;
for(int i = 1;i <= n;i++)cin >> a[i];
for(int i = 1;i < n;i++)
{
int a,b;
cin >> a >> b;
flag[a] = 1;//将子结点标记为true
add(b,a);
}
int root = 1;
while(flag[root])root++;//遍历寻找根节点,由于作为子结点的结点都被标记了,所以没有被标记的就是根结点。
dfs(root);
cout << max(f[root][0],f[root][1]) << endl;
return 0;
}
八、记忆化搜索
记忆化搜索再搜索过程中会将搜索过的状态都存下来,再次搜索到这个状态时就不必再次求解了。
首先要做的是将每个点的高度读入:
for(int i = 1;i <= n;i++)
for(int j = 1;j <= m;j++)
cin >> g[i][j];
然后将用于状态更新的数组初始化:
memset(f,-1,sizeof -1);
最后遍历每个点,计算以该点为起始点的路径即可:
int res = 0;
for(int i = 1;i <= n;i++)
for(int j = 1;j <= m;j++)
res = max(res,dp(i,j));
dp函数如下:
int mx[4]{1,0,-1,0},my[4]{0,1,0,-1};
int dp(int x,int y)
{
int& v = f[x][y];
if(v != -1)return v;//v!=-1说明这个点被搜索过,可以直接返回
v = 1;
for(int i = 0;i < 4;i++)
{
int a = x + mx[i],b = y + my[i];
if(a >= 1 && a <= n && b >= 1 && b <= m && g[a][b] < g[x][y])
v = max(v,dp(a,b) + 1);
}
return v;
}
完整代码如下:
#include<iostream>
#include<cstring>
using namespace std;
const int N = 310;
int g[N][N],f[N][N];
int n,m;
int mx[4]{1,0,-1,0},my[4]{0,1,0,-1};
int dp(int x,int y)
{
int& v = f[x][y];
if(v != -1)return v;//v!=-1说明这个点被搜索过,可以直接返回
v = 1;
for(int i = 0;i < 4;i++)
{
int a = x + mx[i],b = y + my[i];
if(a >= 1 && a <= n && b >= 1 && b <= m && g[a][b] < g[x][y])
v = max(v,dp(a,b) + 1);
}
return v;
}
int main()
{
cin >> n >> m;
for(int i = 1;i <= n;i++)
for(int j = 1;j <= m;j++)
cin >> g[i][j];
memset(f,-1,sizeof f);
int res = 0;
for(int i = 1;i <= n;i++)
for(int j = 1;j <= m;j++)
res = max(res,dp(i,j));
cout << res << endl;
return 0;
}