动态规划进阶专题

这是一个长期的专题,因为内容太多而且很多都忘了,之前的入门完全是搬之前的笔记简单记录的
DP基础 数字三角形 LIS LIS的二分方案 LCS LCS转LIS 区间DP DAG上的DP 编辑距离 合并石子 滚动数组优化 前缀和优化 单调队列/单调栈优化 斜率优化 四边形不等式优化 状态压缩DP 数位DP 费用提前计算 DDP 树上DP:点覆盖 最大权独立集 背包DP;各种背包问题 01 完全 部分 多重 混合 二维费用 二进制分组优化 单调队列优化 有依赖 泛化物品
插头DP DP套DP

树上最大权独立集的性质

我们有
\(dp[u][0]=∑_{(v \in son(u))}〖max⁡(dp[v][0],dp[v][1]〗)\)
\(dp[u][1]=s_u+∑_{(v \in son(u))}〖dp[v][0]〗\)

设节点最大权值为\(k\)

那么有

\(dp[u][1]≤dp[u][0]+k\)

证明:

\(dp[u][1]−dp[u][0]=−(∑_{(v \in son(u))}max⁡(dp[v][0],dp[v][1]) )+(s_u+∑_{(v \in son(u))}dp[v][0] )=(∑_{(v \in son(u))}〖dp[v][0]−max⁡(dp[v][0],dp[v][1]) 〗)+s_u\)

显然左边和式\(<=0\),而\(s_u≤k\)

因此原式成立
这样,在做小\(n\)的独立集时就可以只设\(max⁡(dp[u][0],dp[u][1])\)
和 差值为状态 然后分类讨论按树形背包上下界优化转移即可

Ddp

我们同样以树上最大权独立集为例,现在我们要支持修改某个节点权值,那么可以发现产生的影响只是它的父亲及祖先节点,这样复杂度还是很大,考虑原始转移式
\(dp[u][0]=∑_{(v \in son(u))}〖max⁡(dp[v][0],dp[v][1]〗)\)
\(dp[u][1]=s_u+∑_{(v \in son(u))}〖dp[v][0]〗\)
对树进行树链剖分,对于每个点,预处理出除了重儿子之外的\(max(dp[v][0],dp[v][1])\)\(dp[v][0]\)的和,分别记为\(g[u][1],g[u][0]\)
然后可以重写转移式

\(dp[u][0]=g[u][1]+max⁡(dp[v][0],dp[v][1])\)
\(dp[u][1]=g[u][0]+dp[v][0]+s_u\)
\(v\)是重儿子
使用矩阵乘法改写
加法对max有分配律,max 加法自身都有交换结合律

把原来矩阵乘法看成加法,加法看成取max

把状态和转移用广义矩阵乘法描述

\(\begin{pmatrix}dp[u][0]&dp[u][1]\end{pmatrix} = \begin{pmatrix}dp[v][0] & dp[v][1] \end{pmatrix} * \begin{pmatrix}g[u][1]&g[u][0]+s_u\\g[u][1]&−inf\end{pmatrix}\)
那么,树链剖分后线段树维护矩阵连乘积
额外记录链底的dp值
修改一个点时,它对下面的影响是对向儿子的转移矩阵的
\(s_u\)
的值,如果这个点处于重链但不是链顶,那么这条重链上其它点的矩阵不变,而dp值直接乘上这一段重链的转移矩阵,得到新的dp值,然后跳到这一段重链的链顶,而这个链顶节点的是父亲的轻儿子,因此父亲的g值要改变,因此还要在往上跳过程中记录原来dp值,这样减掉这个值再加上新的dp值,然后单点修改矩阵,继续向上跳,直到跳到根为止

树形背包基础版

如果我们有这样一个问题,给定一个树,每个点有点权,现在如果选择一个节点且这个点不是根节点,那么这个点的父亲节点必须被选中,现在问选\(m\)个节点时最大点权和是多少

由于有限制关系,只能用dp

自然的,设\(f(i,j)\)
为以\(i\)为根选择了\(j\)个点的最大点权和

\(f( i, 0) =0\)

\(f (i, 1) =a[i]\)

现在考虑转移,由于当\(j>1\)时,是要求选中根节点,然后从它的子树中再选取\(j-1\)个点,而这个点有很多儿子,每个儿子的子树都可以分配一定数量的点,只要使得最后儿子选的总和为\(j-1\)即可

这样暴力肯定不行,尤其是多叉树,但是这是一个背包问题

我再设一个\(g (i ,j)\)
表示进行到第\(i\)个儿子,总共选了\(j\)

那么我枚举这个当前的儿子选几个就可以完成转移

可以提前预处理每一个点的儿子,然后对于每一个\(j\),直接算出f (i, j)

这样瓶颈在于计算\(g\)

对于每一个节点,计算时需要\(O(deg*m*m)\)

那么合起来就是\(O(n*m^2)\)

但是可以采取上下界优化

我们发现我们不用预处理\(g\)时候第二维开到\(m\)

开到\(siz_i −1\)
即可

然后\(f\)\(j\)开到\(siz_i\)
即可

这样还是不够

计算\(g\)的时候计算第\(i\)个的时候会枚举当前选了几个,当前选的肯定不能超过\(min⁡(j,siz_{now} )\)
但是也不能太小,因为如果太小那么前面要选的可能很大就不能满足,这样的话不能小于\(max⁡(0,j−∑_{(k=1)}^{(i−1)}〖siz_k 〗)\)
这样做之后,可以证明,时间复杂度是\(O(n*m)\)
的,达到了理论下界

然后如果要选的不是节点个数,而是点权和,那么这个就不那么适用了,当然也有常数优化

费用提前计算dp

以P5785任务安排为例

传统dp要设分\(j\)段,而这个\(j\)是没有受题目条件限制的,只是因为要算前面分段的启动时间对后面的影响而已
而又不能像csp2019划分那样直接省掉\(j\),记录\(i\)取最优的分的段数直接转移,因为前面总费用最小,如果前面段数多,那么对这次的贡献就大,这就存在博弈了,也就是说可能可以减小前面段数增大前面总费用但是减少这次的总费用从而使总费用更小

可以记录以\(i\)开头的费用和最小值,用后面的更新前面的,这样算后面的时前面的分段带来的额外代价还没有考虑,而算前面时直接加上对后面的影响再取最小值,这样算出的总费用是正确的,且没有后效性

如果仍然要以\(i\)为结尾且用前面的更新后面的,就要考虑费用提前计算

我们设\(f_i\)为以\(i\)为结尾使得分段总费用以及当前分段情况对后面造成的启动时间影响的费用的和 最小的 最小值

那么对于第\(n\)个,枚举最后一段的左端点,这样的话最后一段的贡献在确定左端点后是确定的,而前面的费用和对后面的贡献最小时答案就最小,因此答案就是\(f[n]\)
而对于其它的\(f[i]\),枚举分段左端点,左端点确定时它前面对它的额外费用前面已经算好了,而它本身的费用和它对后面的贡献是固定的,因此让前面的费用和加上对后面的贡献最小时\(f[i]\)取最小,因此具有最优子结构,也就是说费用提前计算是正确的

序列上DP

例题

Luogu1005矩阵取数游戏

题面
这个题看起来很复杂,实际上就是给定一个矩阵,每一次取数在每一行上各删掉一个数,进行\(m\)次取数,把所有数删掉,每删掉一个数时都要累加这个数*(2^第几次取数),求和的最大值
只不过有个要求就是每次删数时只能删除头和尾

直接按照题意dfs很复杂,要用一个函数枚举每一行删头还是尾,取完一整次后取数次数+=1然后再从第一行删数,删掉的数改为\(-1\),但最后要改回来,当取数次数到\(m\)时可以停止取数并更新最大值。
这个时间复杂度是\(O(m*(2^n))\),复杂度很高,况且不易改成记忆化,因为变量太多了,,而且也不易dp
(一次两种选择,\(n\)\(2^n\)种,执行\(m\)次取数)

接下来分析每一行取数是互不影响的,虽然一次取数是每一行取一个,但是总体还是每一行有自己的顺序,所以每一行分开,一行按顺序取\(m\)次,也是正确的。
这是个巨大的飞跃

接下来考虑贪心和dp

为什么不用贪心?
当没有只能取头尾限制时,贪心显然是正确的,先取小的数,乘上2^ 小的数,最后取大的数,乘上2^大的数
如:
1 2 3
先取小的数策略:
12+24+38=2+8+24=34
先取大的数策略:
3
2+24+18=6+8+8=22
所以先取小的数策略正确

但有头尾限制时就不行了,因为如果头比尾大,但是头的后面有特别小的数时,会先取尾,如果此时尾的前面的数还比头小时还取尾,所以头到最后才被取,但是头的后面太小了,应该早点取掉,而这样会让它后面才取掉,导致结果不是最大

dp:
应该一行一行的进行,最后累加结果
因为一次只能取头或尾,所以操作只能在两边进行,即中间是连续的数,所以可以采用区间dp
\(f(i,j)\)为从\(i\)\(j\)所取得的最大值,则原问题的解是\(f(0,m-1)\)

可以有转移,就是\(f(i+1,j)\)加上取\(i\)的值和\(f(i,j-1)+\)\(j\)的值的最大值

\(i\)\(j\)相等时,只有一个数要取,返回\(a[i]*(2^m)\)
然后考虑怎么根据\(i\)\(j\)推出当前是第几次取,由于\(m-\)还剩下多少数 \(+1=\)第几次取
\(j\)\(i\)是闭区间,所以\(j-i=\)还剩下的数\(-1\)
所以第几次取\(=m-(j-i)=m-j+i\)
因此dp就完整了

然后题目数据太大了,要高精或__int128,__int128必须自己写输出,自己写的输出必须在数>=10时再递归,否则会无限递归,导致mle或re

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
//#include<iostream
using namespace std;
__int128  dp[81][81];
int a[81][81];
int cur=0,n,m;
 __int128 dps(int i,int j){
	
	// if(i>m||j<0||i>j)return 0;没有用了,因为i和j只是更新1,所以相等时就停下来了,这个多余了
	if(i==j){
//		cout<<(a[cur][i]*(1ll<<m))<<endl;
		return a[cur][i]*((__int128)1<<m);
	}
	if(dp[i][j]!=-1)return dp[i][j];
	__int128 test1=dps(i+1,j)+((__int128)1<<(m-j+i))*(__int128)a[cur][i],test2=dps(i,j-1)+((__int128)1<<(m-j+i))*(__int128)a[cur][j];
//	cout<<test1<<" "<<test2<<endl;
	return dp[i][j]=max(test2,test1);
}
 void mywrite(__int128 num){
 	if(num>=10)//注意输出函数>=10时再递归
	mywrite(num/10);
	putchar(num%10+48);
}
int main(){
	cin>>n>>m;
	for(int i=0;i<n;++i){
		for(int j=0;j<m;++j){
			cin>>a[i][j];
		}
	}
	__int128 sum=0;
	for(;cur<n;++cur){
		memset(dp,-1,sizeof dp);//多行要清空
		sum+=dps(0,m-1);
	}
	mywrite(sum);
	cout<<endl;
	return 0;
}

来自 https://www.luogu.com.cn/record/44309807

一行共\(m^2\)个状态,每次转移花费\(O(1)\)

所以总复杂度为\(O(n*(m^2))\)

计数DP

例题

P2051 [AHOI2009] 中国象棋

来自 https://www.luogu.com.cn/problem/P2051

直接dp是不行的,因为要是想在第\(i\)行填,就要知道每一列的前\(i-1\)行的信息

需要消除顺序且采用组合数学

性质1

已经合法后互换任意行/列,仍合法

性质2

\(i\)行j列合法那么\(j\)\(i\)列也合法,就是可以转置

因此

可以设

\(i\)行,\(j\)个放了一个棋子,\(k\)个放了两个棋子 合法方案数

但是由于行列的对等性

可以设\(i\)\(j\)列且第\(i\)行选了\(k\)个合法方案数

\(K=0,1,2;\)
\(K=3\)时第\(i\)行和第\(i-1\)行各选一个

因此想要某一列有几个\(1\),其余任意合法,就是让最后一列有几个\(1\),其余任意合法

然后就是\(j\)\(i-1\)列最后一行有几个\(1\)(选几个)

背包问题

例题

Luogu5662纪念品

题面https://www.luogu.com.cn/problem/P5662
这道题没什么细节
是dp
考虑\(T=1\)
显然一天无论怎么买怎么卖最后的值还是\(m\),输出\(m\)
\(t=2\)很关键
两天时是完全背包
总体积是\(m\),因为第一天买花费钱数必须不能超过\(m\)
单体积为第一天的价格
单价值为第二天-第一天价格,显然当有物品的单价值\(<0\)时意味着买卖这个物品会赔钱,所以跳过这个物品,不参与dp
而每个物品可以买无限个
所以是完全背包而非01背包
进行完全背包
最后\(dp[m]\)为最大利润
输出\(m+dp[m]\)

推广到一般情况
当第\(i\)天买第\(k\)天卖
由于每天可以无限次买卖
所以相当于第\(i\)天买第\(i+1\)天卖第\(i+1\)天再买,第\(i+2\)天再卖
所以\(2\)\(2\)天的完全背包
把每次完全背包后的利润加在一起
\(m\)也要随之更新
每一次完全背包\(m\)都要加上这次的利润
最后输出和

注意:!!dp数组的大小不是纪念品的个数而是总体积大小,所以开到\(10^4\)而非\(100\),在这里wa了一次

posted @ 2026-01-26 14:05  sakrain  阅读(32)  评论(0)    收藏  举报