【算法】 动态规划基础
背包问题
1. 01背包
求恰好装满,设为负无穷
只求最大值,设为0
for(int i=1;i<=n;i++){
for(int j=0;j<=m;j++){
f[i][j]=f[i-1][j];//若j<v[i],则最大价值为在前j个物品中选总体积为j的最大价值
if(j>=v[i]) f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]);
}
}
一维01背包优化
for(int i=1;i<=n;i++){
for(int j=m;j>=v[i];j--){//只枚举到v[i]可以节省时间
f[j]=max(f[j],f[j-v[i]]+w[i]);
}
}
2. 完全背包
for(int i=1;i<=n;i++) // 枚举物品
for(int j=0;j<=v;j++) // 枚举体积
for(int k=0;k*c[i]<=j;k++) // 三重循环 枚举每种取用件数*c[i]不大于当前总体积j
dp[i][j]=max(dp[i][j],dp[i-1][j-c[i]*k]+w[i]*k);
完全背包一级优化
for(int i=1;i<=n;i++) // 枚举物品
for(int j=0;j<=v;j++){//参照01背包朴素 优化为二重循环 正序枚举体积
dp[i][j]=dp[i-1][j];
if(j>=c[i]) dp[i][j]=max(dp[i][j],dp[i][j-c[i]]+w[i]);
}
二级
for(int i=1;i<=n;i++) // 枚举物品
for(int j=c[i];j<=v;j++) // 正序枚举体积
dp[j]=max(dp[j],dp[j-c[i]]+w[i]);
3. 多重背包
for(int i=1;i<=n;i++) // 枚举物品
for(int j=0;j<=v;j++) // 枚举体积
for(int k=0;k<=s[i]&&k*c[i]<=j;k++) // 枚举决策
dp[i][j]=max(dp[i][j],dp[i-1][j-k*c[i]]+k*w[i]);
二进制优化
int k=1; //相当于base(每组件数):1 2 4 8 16 32 64 128 256...据此打包
while(k<=s){
cnt++;
c[cnt]=k*a;
w[cnt]=k*b;
s-=k;
k*=2;
}
if(s>0){ //若拆完之后还有零头
cnt++; //再分一个包
c[cnt]=a*s;
w[cnt]=b*s;
}
}
//相当于将多重背包转化为01背包
n=cnt;//01物品总个数
for(int i=1;i<=n;i++)
for(int j=v;j>=c[i];j--)//注意倒序遍历
dp[j]=max(dp[j],dp[j-c[i]]+w[i]);
单调队列优化
memcpy(g, f, sizeof f);
for (int j = 0; j < v; j ++ )
{
int hh = 0, tt = -1;
for (int k = j; k <= m; k += v)
{
if (hh <= tt && q[hh] < k - s * v) hh ++ ;
while (hh <= tt && g[q[tt]] - (q[tt] - j) / v * w <= g[k] - (k - j) / v * w) tt -- ;
q[ ++ tt] = k;
f[k] = g[q[hh]] + (k - q[hh]) / v * w;
}
}
4. 分组背包
for(int i=1;i<=n;i++)
for(int j=v;j>=0;j--) //倒序遍历
for(int k=1;k<=s[i];k++) //每组s[i]个
if(c[i][k]<=j) //注意判断条件!!!!!!!!!!
dp[j]=max(dp[j],dp[j-c[i][k]]+w[i][k]); //选或不选
5. 混合背包
if(s==0) s=v/tc; // 完全背包
if(s==-1) s=1; // 01背包
// 二进制优化
int k=1;
while(k<=s){
cnt++;
c[cnt]=k*tc;
w[cnt]=k*tw;
s-=k;
k*=2;
}
if(s>0){
cnt++;
c[cnt]=s*tc;
w[cnt]=s*tw;
}
}
// 将01背包 完全背包 多重背包全部打包成cnt件
n=cnt;// 接下来就是普通的01背包啦
for(int i=1;i<=n;i++){
for(int j=v;j>=c[i];j--){
dp[j]=max(dp[j],dp[j-c[i]]+w[i]);
}
}
6. 二维费用的背包
for(int i=1;i<=n;i++){
scanf("%d%d%d",&tv,&tm,&w);
for(int j=v;j>=tv;j--){
for(int k=m;k>=tm;k--){// 无非就是再加一维
dp[j][k]=max(dp[j][k],dp[j-tv][k-tm]+w);
}
}
}
7. 有依赖的背包问题
其实很简单 就是把线性的01背包简单变形为一棵树
链式前向星+dfs
struct node{
int to,next;
}e[maxm];
// 链式前向星 或者叫 邻接表
//加边操作
void add(int x,int y){
cnt++;
e[cnt].to=y;
e[cnt].next=head[x];
head[x]=cnt;
}
void dfs(int k){ //当前节点k
for(int i=head[k];i;i=e[i].next){// 枚举物品
int son=e[i].to; // 记录子节点
dfs(son);// 向下递归到最末子树 在回溯的过程中从最末更新dp值 直到回到root
// 由于当前节点k必选 因此体积j需要将c[k]空出来 01背包倒序枚举体积
for(int j=v-c[k];j>=0;j--){
for(int l=0;l<=j;l++){// 枚举决策
dp[k][j]=max(dp[k][j],dp[k][j-l]+dp[son][l]);
}// 不选son子树 选son子树
}
}
for(int i=v;i>=c[k];i--) dp[k][i]=dp[k][i-c[k]]+w[k];
for(int i=0;i<c[k];i++) dp[k][i]=0;
}
int main(){
scanf("%d%d",&n,&v);
for(int i=1;i<=n;i++){
int p;
scanf("%d%d%d",&c[i],&w[i],&p);
if(p==-1) root=i; // 根节点
add(p,i); // 加边加边 由父节点指向子节点
}
dfs(root); // 从根节点开始搜
printf("%d",dp[root][v]);
}
8. 背包问题求方案数
求方案数类问题,我们需要调整一下dp数组的含义。以下以01背包为例:
- \(dp[i][j]\) 表示的是从前 i 个物品中选,体积不超过 j 的选法的集合。而此处为了便于方案数的计算,令 \(dp[i][j]\) 表示为从前 i 个物品中选,体积恰好为 j 的选法的集合。注意dp数组含义改变后需要初始化,只有当体积恰好为零时,总价值才恰好为零,即 \(dp[0]=0\),其他情况均出于未更新的状态,因此需要全部初始化为 \(-inf\)。
- dp数组的值等于此状态下的最大价值,另外我们还需要一个数组 \(g[i][j]\),表示此种状态下取最大值(即取 \(dp[i][j]\) )的方案数。 最后我们只需要遍历一下 \(dp[n][j]\) 得到最大价值(最优方案并不一定会把背包装满 因此需要遍历),再将价值=最大价值的所有对应 \(g[i][j]\) 加起来,即为最优方案总数。
9. 背包问题求具体方案数
背包问题求具体方案的思路基本相同,重点在于判断每件物品到底选了还是没选,好像是废话,类似于最短路求最短路径。且由于要输出方案,所以我们不能使用空间优化后的转移方程。另外,要求输出字典序最小的方案时还须考虑选择顺序。
最长公共子序列
#include<bits/stdc++.h>
using namespace std;
int n;
int a1[100010],a2[100010];
int belong[100010];
int f[100010],b[100010],len;
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++)
{
scanf("%d",&a1[i]);
belong[a1[i]]=i;
}
for(int i=1;i<=n;i++)
scanf("%d",&a2[i]);
for(int i=1;i<=n;i++)
{
if(belong[a2[i]]>b[len])
{
b[++len]=belong[a2[i]];
f[i]=len;
continue;
}
int k=lower_bound(b+1,b+len+1,belong[a2[i]])-b;
b[k]=belong[a2[i]];
f[i]=k;
}
printf("%d\n",len);
return 0;
}
二分+子序列进阶
# include <iostream>
# include <cstdio>
# include <cstring>
# include <algorithm>
using namespace std;
int a[100005],t[100005],A[100005],B[100005],f[100005];
bool cmp(int a,int b)
{
return a<b;
}
int solve(int l,int r,int x)
{
int mid=(l+r)/2;;
if (l==r) return l;
if (a[mid]>x) return solve(l,mid,x);
if (a[mid]<=x) return solve(mid+1,r,x);
}
int main()
{
int n;
scanf("%d",&n);
for (int i=1;i<=n;i++) scanf("%d",&A[i]);
for (int i=1;i<=n;i++) scanf("%d",&B[i]);
for (int i=1;i<=n;i++) f[A[i]]=i;
for (int i=1;i<=n;i++) t[i]=f[B[i]];
memset(a,0,sizeof(a));
for (int i=1;i<=n;i++) {
if ((i==0)||(t[i]>a[a[0]])) a[++a[0]]=t[i];
else if (t[i]<a[a[0]]) a[solve(1,a[0],t[i])]=t[i];
//a[lower_bound(a+1,a+a[0]+1,t[i])-a]=t[i];(替换a[solve(1,a[0],t[i])]=t[i];也行但是会稍微慢一点。。)
}
printf("%d\n",a[0]);
return 0;
}
最长公共子序列(LCS)
#include<bits/stdc++.h>
using namespace std;
int n;
int a1[100010],a2[100010];
int belong[100010];
int f[100010],b[100010],len;
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++)
{
scanf("%d",&a1[i]);
belong[a1[i]]=i;
}
for(int i=1;i<=n;i++)
scanf("%d",&a2[i]);
for(int i=1;i<=n;i++)
{
if(belong[a2[i]]>b[len])
{
b[++len]=belong[a2[i]];
f[i]=len;
continue;
}
int k=lower_bound(b+1,b+len+1,belong[a2[i]])-b;
b[k]=belong[a2[i]];
f[i]=k;
}
printf("%d\n",len);
return 0;
}
状压dp
状态压缩动态规划,就是我们俗称的状压DP,是利用计算机二进制的性质来描述状态的一种DP方式。
例题:luogu P1896 [SCOI2005] 互不侵犯
简述:给一个 \(n \times n\) 的矩阵放置棋子,每个棋子周围的八个空间不能有棋子,询问有多少种放置方法
状态定义:\(dp_{i,j,k}\) 表示在第 \(i\) 行,棋子状态是 \(j\) 时,放置了 \(k\) 个棋子的最多情况
状态转移:\(dp[i][sit][j] += dp[i - 1][sit2][j - c(sit)];\)
时间复杂度:\(O(n^4)\)
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 15;
int n, k;
int dp[N][1 << 9][N * N];
inline int c(int sit) {
return __builtin_popcount(sit);
}
// 检查是否符合放置要求
bool check(int sit, int sit2) {
for(int i = 0; i < n - 1; ++i) {
if((sit & (1 << i)) && (sit & (1 << (i + 1)))) return false;
}
if((sit & (sit2 << 1)) || (sit & (sit2 >> 1)) || (sit & sit2)) return false;
return true;
}
inline void solve() {
std::cin >> n >> k;
for(int i = 0; i < n; ++i) {
for(int sit = 0; sit < (1 << n); ++sit) {
// sit表示每一行的状态,状压dp核心
if(!check(sit, 0)) continue;
if(i == 0) dp[i][sit][c(sit)] = 1;
else {
for(int j = c(sit); j <= k; ++j) {
for(int sit2 = 0; sit2 < (1 << n); ++sit2) {
if(!check(sit2, 0) || !check(sit, sit2)) continue;
dp[i][sit][j] += dp[i - 1][sit2][j - c(sit)];
}
}
}
}
}
int ans = 0;
for(int sit = 0; sit < (1 << n); ++sit) {
ans += dp[n - 1][sit][k];
}
std::cout << ans << endl;
return;
}
signed main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
int T;
T = 1;
while(T --) {
solve();
}
}
区间DP
例题:luogu P1880 [NOI1995] 石子合并
简述:N堆石子摆成一条线。现要将石子有次序地合并成一堆。规定每次只能选相邻的2堆石子合并成新的一堆,并将新的一堆石子数记为该次合并的代价。计算将N堆石子合并成一堆的最大/小代价。
状态定义:\(dp_{i,j}\) 表示区间 \([i, j]\) 合并所能得到的最大/小代价
状态转移:\(dp[j][ends] = min(dp[j][ends],dp[j][i]+dp[i+1][ends]+weigth[i][ends]);\)
dp[j][ends] = min(dp[j][ends],dp[j][i]+dp[i+1][ends]+weigth[i][ends]);
时间复杂度:\(O(n^3)\)
#include<bits/stdc++.h>
#define int long long
#define inf 0x3f3f3f3f
using namespace std;
const int N = 3e2 + 10;
const int M = 1e6 + 10;
int n, m;
int a[N];
int dpmax[N][N], dpmin[N][N];
int sum[N];
inline void solve() {
scanf("%lld", &n);
for(int i = 1; i <= n; ++i) {
scanf("%lld", &a[i]);
a[i + n] = a[i];
}
for(int i = 1; i <= 2 * n; ++i) {
sum[i] = sum[i - 1] + a[i];
}
for(int len = 2; len <= n; ++len) {
for(int i = 1; i <= 2 * n - len + 1; ++i) {
int j = len + i - 1;
dpmax[i][j] = -1, dpmin[i][j] = inf;
for(int k = i; k < j; ++k) {
dpmax[i][j] = max(dpmax[i][j], dpmax[i][k] + dpmax[k + 1][j] + sum[j] - sum[i - 1]);
dpmin[i][j] = min(dpmin[i][j], dpmin[i][k] + dpmin[k + 1][j] + sum[j] - sum[i - 1]);
}
}
}
int maxx = -1;
int minn = inf;
for(int i = 1; i < n; ++i) {
maxx = max(maxx, dpmax[i][i + n - 1]);
minn = min(minn, dpmin[i][i + n - 1]);
}
printf("%lld\n%lld\n", minn, maxx);
}
signed main() {
// ios::sync_with_stdio(0);
// cin.tie(0), cout.tie(0);
int T;
T = 1;
while(T--) {
solve();
}
return 0;
}
数位DP
#include<iostream>
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define maxn 15
int num;
int* a = new int[maxn];
int f[15];
//int a[maxn];
int b[maxn];//b保存第p为存的是那个数
int ten[maxn];
int L, R;
int t;
int dfs(int p, bool limit) {//p表示在第p位,limite表示此时是否处于限制位
if (p < 0) {
//for (int i = 2; i >= 0; i--)cout << b[i];//无限递归,记得加结束return
//cout << endl;
return 0;//搜索结束,返回
}
if (!limit && f[p] != -1) {//记忆化搜索,不处于限制位,并且f[p]被算过了
return f[p];
}
int up = limit ? a[p] : 9;//判断是否处于限制位,如果是就只能取到a[p]为,否则最高位能取到9
int ans = 0;
for (int i = 0; i <= up; i++) {
//b[p] = i;
if (i == 3) {
if (limit && i == up) {
ans += 1;
for (int j = p - 1; j >= 0; j--)//处于限制条件就把限制数下面全算上
ans += a[j] * ten[j];
}
else//如果不处于限制条件直接加上10的p次方
ans += ten[p];
}
else ans += dfs(p - 1, limit && i == up);//这里填a[p]可以填up也行,在处于限制的时候up等于a[p]
}
if (!limit)//记忆化搜索,如果没有处于限制条件就可以直接那算过一次的数直接用,能节省很多时间
f[p] = ans;
return ans;
}
int handle(int num) {
int p = 0;
while (num) {//把num中的每一位放入数组
a[p++] = num % 10;
num /= 10;
}
//说明a数组写进去了,但是读取无效数据是什么意思勒,之前好像不是这样的,解决办法,动态创建数组
/*for (int i = 0; i < p; i++) {
cout << a[i];
}*/
return dfs(p - 1, true);//对应的最高位为p-1位,为True表示没有处于限制位
}
void init() {
ten[0] = 1;
for (int i = 1; i < 15; i++) {
ten[i] = ten[i - 1] * 10;
}
memset(f, -1, sizeof(f));
}
int32_t main() {
cin>>t;
while(t--){
cin>>L>>R;
//handle(23);
init();//一定要记得初始化,TM的我在这卡了半个月
cout << handle(R)-handle(L) << endl;
delete[]a;
}
return 0;
}
概率DP
顾名思义,概率DP就是动态规划求概率的问题。一般来说,我们将dp数组存放的数据定义为到达此状态的概率,那么我们初值设置就是所有初始状态概率为1,最终答案就是终末状态dp值了。
我们在进行状态转移时,是从初始状态向终末状态顺推,转移方程中大致思路是按照当前状态去往不同状态的位置概率转移更新DP,且大部分是加法。