动态动态规划 & 全局平衡二叉树 小记
DDP 简介
是这样一类技巧,利用广义的矩阵乘法实现 单点修改权值,动态查询某个点的 DP 值
其核心思想是利用矩阵乘法具有结合律(可以使用数据结构维护)的优势
序列上的 Ddp
先看一个例子:最大子段和,显然我们有 \(f_{i}=\max(f_{i-1},0)+a_i\)
现在我们要支持修改点权,还是要求 \(\max f_i\)。
那么我们可以维护这样几个量:\(f_i,g_i=\max_{j\in [1,i]}f_j,a_i\)
也就是有:
那么写成 \((\max,+)\) 运算的矩阵形式也就是有:
我们使用线段树维护矩阵乘法,每次单点修改只需要修改这个矩阵,最后求出所有矩阵的积之后就可以求出答案了。
树上的 Ddp
来看一个模板题。
给定一个带点权的树,求其最大带权独立集权值之和
\(m\) 次询问,每次修改一个点的点权,修改永久生效
容易有如下暴力 dp:
设 \(f_{i,0/1}\) 为当前点是否取的最大权独立集大小(子树内)。
则显然有:
我们能否将其转化为一个序列上的问题呢?
树链剖分,我们可以利用重剖的优秀性质:一条链至多划分为 \(\log n\) 个区间。
那么我们只需要解决:
- 维护重链的矩阵运算结果
- 维护轻儿子(链顶)向上更新的辅助信息。
考虑将树进行轻重链剖分,同时维护 \(tf\) 表示仅考虑当前点合并轻儿子信息之后的答案,我们定义 \(u\) 的重儿子是 \(son_u\),容易有:
这样做的目的是:保证每个点有唯一前驱,以转化为序列,且容易修改辅助信息的和(至多有 \(\log n\) 次)
首先我们预处理出 \(tf,f\),然后对于每个节点 \(u\) 维护转移矩阵 \(mat_u\),将 \(tf\) 与 \(f_{son}\) 纳入考虑范围,容易有:
于是就可以知道 \(mat_u\) 的值了。
这样利用线段树维护区间积就解决了第一个问题。注意转移顺序,是深度大的乘上深度小的转移矩阵,所以线段树上是右区间乘左区间,并且还需要记录链底的编号。
然后我们考虑第二个问题 \(upd(x,k)\),考虑这样解决:
-
首先更改 \(mat_x\) 的值
-
跳到链顶 \(x\leftarrow top_x\)
-
使用旧 链顶 \(dp\) 值,撤销掉对 \(fa_x\) 的 \(tf\) 影响
-
取出新的链顶的 \(dp\) 值
-
使用 新 链顶 \(dp\) 值,加入对 \(fa_x\) 的 \(tf\) 影响
-
更新 \(fa_x\) 的转移矩阵
-
-
更新 \(x=fa_x\) 回到 \(2\)
当当前链顶是树根时候,不需要进行 \(2,3\) 操作
这个题目有一个有趣的性质是叶子节点的转移矩阵第一行可以等效为 \([f_0,f_1]\)。
一般情况下不具备这个性质时需要单独处理
复杂度是 \(O(n\log^2 n)\),带八倍矩阵乘法常数。
全局平衡二叉树
这时,你在做完模板题之后发现:你遇到了模板题的加强版:P4751
在数据规模扩大到 \(10^6\) 级别后,似乎树剖有些捉襟见肘,此时我们是否存在更加适合于 Ddp 的结构呢?它就是 全局平衡二叉树
如果您认为在下拙作较为晦涩,欢迎左转 Oi-wiki
算法介绍
我们注意到,树链剖分方法里,有一个地方开销是比较大的,就是利用线段树查询一段区间的矩阵积,考虑优化掉它。
有没有什么办法可以 \(O(1)\) 实现这一步呢?
可以的,我们考虑将每一条重链单独拿出,按照深度建立一颗二叉搜索树(这保证了左右子树加树根是一段连续区间),同时树根维护整个子树的矩阵积,这样的话,我们可以 \(O(1)\) 查询一个子树的矩阵积,就只需要实现如下操作:
- 跳到二叉树的父亲(假设存在,同一条重链),
pushup - 跳出重链,类似树剖进行撤销和更新,同时继续往上跳。这一步显然是 \(O(\log n)\) 的总共,这一步我们可以对每个二叉树根的父亲设置为链顶在原树上的父亲
发现我们只需要保证跳跃总步数是 \(O(\log n)\) 级别即可。
考虑这样一种剖分策略:定义 \(lsz_u=sz_u-sz_{son_u}\),我们拿出一条重链上的点,做 \(lsz\) 的前缀和,取出它的带权中点(也就是 \(sum\le 2s[1,x]\) 的最小 \(x\)),将它设置为根,剩下两半递归处理。
考虑你从节点 \(u\) 开始向上跳,令一个 \(k\) 为二叉树子树内 \(lsz\) 的和,每跳一个二叉树上的点,这个 \(k\) 会翻倍,与此同时,你跳到另一棵树上时,\(k\) 是不会减少的,综上你每在二叉树上跳一次,那么会使得 \(k\) 翻倍,所以跳二叉树边总次数也是 \(\log n\) 级别的。
因此我们保证了跳跃总步数是 \(\log n\) 级别

建树后是:

我们称 二叉树边为重边,非二叉树边为轻边
建树是容易的,我们递归每一个重链的链顶,提取出这一条重链,对其递归建树,最后解决父亲的关系即可。
int divide(int l,int r){
if(l>r)return 0;
int sum=0;
for(int i=l;i<=r;++i)sum+=lsz[sta[i]];
for(int i=l,t=lsz[sta[l]];i<=r;++i,t+=lsz[sta[i]])if(sum<=t*2){
lc[sta[i]]=divide(l,i-1);
rc[sta[i]]=divide(i+1,r);
if(lc[sta[i]])fa[lc[sta[i]]]=sta[i];
if(rc[sta[i]])fa[rc[sta[i]]]=sta[i];
pushup(sta[i]);
return sta[i];
}
}
int build(int u,int f){
for(int x=u,l=f;x;l=x,x=son[x])for(auto v:e[x])if(v!=l&&v!=son[x])fa[build(v,x)]=x;
top=0;for(int x=u;x;x=son[x])sta[++top]=x;
return divide(1,top);
}
维护 Ddp
我们可以类似于平衡树的方法维护,只不过这个是静态树。
每个点建立两个矩阵 \(mat1,mat2\),其中 \(mat1\) 是自身原本的转移矩阵,而 \(mat2\) 是所在二叉树的子树内 \(mat1\) 合并的结果。
注意合并顺序与矩阵乘法顺序有关系,这一步可以通过 pushup 实现。
void pushup(int x){
mat2[x]=mat1[x];
if(lc[x])mat2[x]=mat2[lc[x]]*mat2[x];
if(rc[x])mat2[x]=mat2[x]*mat2[rc[x]];
}
往上跳的一步,如果没有跳出重链,那么直接 pushup 即可,否则类似树链剖分,撤销并更新 \(tf\)
void upd(int x,int v){
mat1[x].a[1][0]+=v-w[x];w[x]=v;
for(int i=x;i;i=fa[i]){
if(lc[fa[i]]!=i&&rc[fa[i]]!=i&&fa[i]){
int t=fa[i];
mat1[t].a[0][0]=mat1[t].a[0][1]=mat1[t].a[0][0]-get2(i);
mat1[t].a[1][0]-=get(i);//撤销
pushup(i);
mat1[t].a[0][0]=mat1[t].a[0][1]=mat1[t].a[0][0]+get2(i);
mat1[t].a[1][0]+=get(i);//重更
}
else pushup(i);
}
}
题目选练
首先模板题已经没了。
保卫王国
比较水的题目啊。
首先考虑这玩意是最小点覆盖,如果用全集减去最大独立集来做的话就是模板题了。不过为了练习我们还是选择打一遍朴素 dp。
首先这个修改可以看作是将点权修改为 \(0\) 或者 \(\infty\),就是动态问题了。
同样考虑设 \(f,tf\),有:
改写为矩阵形式是:
改改模板就能过了吧。而且这玩意也不需要特判叶子。
洪水
这个题的主要目的是说明在全局平衡二叉树上如何查找一个点的 dp 值。
显然我们可以设 \(f_u,tf_u\),显然有转移 \(f_u=\min(w_u,\sum f_v)\)
那么也有:
由于转移涉及 \(w\),可以设计转移:
这里可能需要特判叶子。
这里我们考虑全局平衡二叉树如何查找一个节点的 dp 值,整个修改过程是不变的。
譬如我们需要找 \(u\) 的 \(dp\) 值,显然重链上在 \(u\) 后面的那些矩阵其实是 \(u\) 向上跳,当它走左边的时候右边的儿子,一个个组成的,也就是若干的子树操作,这里乘算即可。
int gans(int u){
mat now=mat1[u];if(rc[u])now=mat2[rc[u]]*now;
while(f[u]&&(lc[f[u]]==u||rc[f[u]]==u)){
if(lc[f[u]]==u){
if(rc[f[u]])now=mat2[rc[f[u]]]*mat1[f[u]]*now;
else now=mat1[f[u]]*now;
}
u=f[u];
}
return now.a[0][0];
}
海报
可以设一个 dp 状态为 \(dp_{i,0/1/2/3}\) 表示填完了 \([1,i]\),其中距离 \(i\) 最远的没有举手的人的距离。
转移是轻松的:\(dp_{i,j}\to dp_{i+1,j+1}\) \(i+1\) 举了手。还有 \(dp_{i,j}\to dp_{i+1,0}\) 没举手。
可以写出 \(4\times 4\) 的转移矩阵,利用线段树维护。
如何求解环上答案。考虑只对 \(4\sim n\) 做 dp,暴力枚举 \(1,2,3\) 的状态。
对 \([4,n]\) 建立线段树维护 DDP,则取出树根的转移矩阵后,暴力枚举 \(1,2,3\) 的状态对标上去算答案。
猫或狗
可以设 \(f_{i,0/1/2}\) 为当前 \(i\) 所在连通块颜色是 \(0/1/2\)(注:其实可以只记录 \(1,2\),但笔者唐了)。
转移是容易的:
对轻儿子的计算结果合并为 \(tf_{u,0/1/2}\) 写出转移矩阵,做 \((\min,+)\) 矩阵乘法即可。
修改同理。
ABC351G
先不考虑乘0的撤销,如何计算?
设 \(tf_u=\prod_{\text {v is light son}}f_v\)。
则有:
用 DDP 做矩阵乘法即可。
对于零,考虑将 \(tf\) 维护为 \(x·0^y,x\neq 0\) 的形式,传入矩阵时传入真实值,而撤销时根据原本是否为零改动 \(y\) 即可。
P10626 JOI 2024 test2
唯一难点:建立表达式树。
建立出表达式树后离线扫描线,相当于不断更改叶子的取值(从零到一),然后在合适的时刻计算答案。
可以设 \(f_{u,0/1}\) 表示当前子树运算结果是否可以为 \(0\),是否可以为 \(1\)。
显然,\(f_{u,0}\oplus f_{u,1}=1\) 在任意时刻成立。
这棵树显然是二叉树,对于取反运算符缺少轻儿子但自身运算规则完备。
所以根据轻儿子值以及运算符,可以求出其转移矩阵,\((and,or)\) 矩阵乘法。
node get(int op,int k){// k is the lighted son value
if(op=='!')return node(0,1,1,0);//a[0][0],a[0][1],a[1][0],a[1][1]
else if(op=='^')return node(k==0,k==1,k==1,k==0);
else if(op=='&')return node(1,0,k==0,k==1);
else if(op=='|')return node(k==0,k==1,0,1);
else node(1,0,0,0);//leave,the value is zero now
}
查询答案,维护当前重链链底元素是谁,根据其取值推知答案,显然答案唯一。
由此可以往上跳重链更新和求解答案。
void chg(int x){
mat1[x]=node(0,0,0,1);dp[x]=1;int val=1;
for(int i=x;i;i=fa[i]){
if(lc[fa[i]]!=i&&rc[fa[i]]!=i&&fa[i]){
pushup(i);
mat1[fa[i]]=get(col[fa[i]],mat2[i].a[dp[down[i]]][0]?0:1);
}
else pushup(i);
}
}
来说说表达式树怎么建立?
考虑开一个栈,栈内元素有左右括号以及 \((val,op)\) 二元组(\(val\) 是 \(op\) 左边的元素编号(即已经在树上分配了一个编号,这个也可以代表一个表达式的运算结果代表编号),进行 \(op\) 这个运算)。
首先利用栈运算消去括号运算。
其次,对于取反运算,加入 \((newnode,!)\) 这样一个二元组,在后面加入一个二元组 \((val,op)\) 时(此刻应当还没有碰到 \(op\),只有 \(val\),可以来源于一个 \([num]\),可以来源于一个括号内运算结果),就可以将 \(val\) 直接做取反操作(不断弹出取反操作,对应编号连边,直到弹完,将最后一个取反代表编号插回去,运算未知)。
最后,对于括号运算,根据前两步处理,这个运算只有 and xor or 三种。
设计递归函数 \(sol(id,array,op)\) 表示当前递归层,按 \(op\) 分段,每一段递归后合并答案。当前操作元素二元组是 \(array\),且赋予的代表这个式子结果的编号是 \(id\)。
void build(int ed,vector<pr >a,int op){
if(a.size()<=1)return ;
vector<pr >b;int now=ed;
int nxtop=(op=='|'?'^':'&');
for(int i=a.size()-1;~i;--i){
if(a[i].second==op){
col[now]=op;
reverse(b.begin(),b.end());
b.back().second='?';//免得误算
int nxt=(b.size()==1?b.back().first:++num);
e[now].push_back(nxt);
build(nxt,b,nxtop);
b.clear();
nxt=(i==0?a[0].first:++num);
e[now].push_back(nxt);
now=nxt;
}
b.push_back(a[i]);
}
reverse(b.begin(),b.end());
b.back().second='?';
build(now,b,nxtop);
}
void init(){
string s;cin>>s;
vector<pr >sta;
int val=0;
for(int i=0;i<s.size();++i){
if(s[i]=='(')sta.push_back(mk(0,'('));
else if(s[i]==')'){
vector<pr>a;
while(!sta.empty()&&sta.back().second!='('){
a.push_back(sta.back());sta.pop_back();
}
reverse(a.begin(),a.end());
int id=(a.size()==1?a.back().first:++num);
build(id,a,'|');
sta.pop_back();
while(!sta.empty()&&sta.back().second=='!'){
e[sta.back().first].push_back(id);
id=sta.back().first;
sta.pop_back();
}
sta.push_back(mk(id,'?'));
}
else if(s[i]=='['){
val=0;++num;
}
else if(s[i]>='0'&&s[i]<='9')val=val*10+s[i]-'0';
else if(s[i]==']'){
int id=num;col[id]=-val;op.push_back(mk(val,id));
while(!sta.empty()&&sta.back().second=='!'){
e[sta.back().first].push_back(id);
id=sta.back().first;
sta.pop_back();
}
sta.push_back(mk(id,'?'));
}
else if(s[i]=='!')sta.push_back(mk(++num,'!')),col[num]='!';
else sta.back().second=s[i];
}
if(sta.size()>1)build(rt=++num,sta,'|');
else rt=sta.back().first;
}
Min-Max搜索 ZJOI2019
注意题目是 \(\max\),且每个叶子点权不一样。
那么答案的贡献路径是一条链
那么这条链的任何一个点它断掉之后都可以导致答案变化。
由于代价是 \(\max\),我们自然想到枚举 \(k\) 并且求解 \(\le k\) 的问题答案。
这时候我们可以用一个 \(dp\),设 \(f_u\) 为:
-
如果 \(u\) 不在答案链上
\(f_u\) 为其当前值仍然符合答案不变的要求的方案数,例如 \(dep\) 是奇数就是 \(<w_{rt}\),否则是 \(>w_{rt}\)
-
如果 \(u\) 在答案链上,\(f_u\) 为当前值不变的方案数。
那么不妨设 \(cnt\) 为叶子节点个数,则显然最终的变化方案数是 \(2^{cnt}-f_1\)。
因为我们保证了所有值不变,它们并未互相影响。
考虑转移
-
非叶子节点
显然你可以容斥转移,只要所有儿子非法,那么自己就可以取到一个合法解(例如自身取 \(\min\),这时候所有的儿子是取 \(\max\),它们要求 \(<w_{rt}\),只要它们全部 \(>w_{rt}\) 自身就满足 \(>w_{rt}\) 的要求了)。
也就是
\(f_u=\prod_{v\in Son(u)}(2^{c_{v}}-f_v)\),\(c\) 是子树内叶子节点个数
-
叶子节点
根据当前节点自身是否可以让其非法且其大小关系是否合适决定是否可以选择这个点让其合法找方案数即可。注意有情况是选了一定会导致非法。这里稍微分讨就好了。
-
答案链上点
根据上述非叶子节点的分析
- 对于不是答案链上的儿子,乘上 \(2^{c_v}-f_v\)
- 对于是答案链上的儿子,乘上 \(f_v\)
这样每次暴力更改可以获得 70 高分。
void dp(int u,int fa,int lim){
if(lef[u]){
if(abs(u-wrt)<K&&(lim&1)==(u<wrt))f[u]=1;
else f[u]=((dep[u]&1)==(u<wrt))<<1;
return ;
}
f[u]=1;
for(int v:e[u])if(v!=fa){
dp(v,u,lim);
f[u]=(pw[sz[v]]+p-f[v])%p*f[u]%p;
}
}
void dfs(int u,int fa){
f[u]=1;
for(auto v:e[u])if(v!=fa){
if(wrt==val[v])dfs(v,u),f[u]=f[u]*f[v]%p;
else dp(v,u,dep[u]),f[u]=(pw[sz[v]]+p-f[v])%p*f[u]%p;
}
}
注意到答案实际上是删掉答案链后,所留下的森林里每棵树的 \(2^{c_v}-f_v\) 的积,并且每次 \(k\) 增大 \(1\) 所改变的点的点权个数是 \(O(1)\) 的,可以考虑对于每个树都使用全局平衡二叉树进行维护。
那么我们就可以设 \(M_u=2^{c_u},tf_u=\prod_{v\in Son(u),v\neq son}(M_v-f_v),C_u=\prod_{v\in Son(u),v\neq son}M_v\)。
就有:
注意到乘法可能有零而导致撤销出错,可以维护一个 \(rl_u\) 表示 \(tf_u\) 中去掉所有零之后的数字,同时维护 \(cnt0_{u}\) 表示计算过程中的零的个数来得到真实值。
切树游戏 SDOI2017
考虑异或卷积,设 \(F_u(x)\) 为子树 \(u\) 的连通子图(也就是 \(u\) 是深度最小的点)其权值为 \(k\) 的异或生成函数,定义 \(F(x)·G(x)=\sum\sum f_ig_jx^{i\oplus j}\)
注意 \(m\le 128,q\le 30000,change \le 10000\)
感觉有点像 \((n+q)m\log n\) 的味道
考虑暴力DP,可以维护 \(FWT(F_u(x))\),这样有 \(FWT(F_u(x))=FWT(x^{w_u})·\prod_{v\in Son(u)}(FWT(F_v(x))+FWT(0))\)
答案就是 \([x^k]\sum_u IFWT(FWT(F_u(x)))=[x^k]IFWT(\sum FWT(F_u(x)))\)
可以考虑再维护一个 \(FWT(G_u(x))\) 所有子树的 \(FWT(G)\) 加上 \(FWT(u)\)
这样做是 \(nm\log m\) 单次。
这有个弊端就是你每次都必须要把这个 \(IFWT\) 的整个数组弄出来。
直接维护这个向量就好了吧,这个向量对应位置 \(+,*\) 的运算也满足矩阵乘法的要求。矩阵乘法的内部元素可以是一个向量。
不妨设 \(f_u,g_u\),令 \(·\) 为全一那就有:
那么按常理,维护 \(tf_u,tg_u\) 有:
而 \(f,g,tf,tg\) 满足乘法分配律,结合律,加法结合律,交换律,所以我们可以把它当作矩阵来弄。
有:
注意到在 \(son\) 是叶子时,左侧矩阵与底部行相同,所以可以不用特判
但是修改时怎么撤销呢?预处理所有逆元即可?0 怎么办?。
事实上我们可以像上一个题一样考虑 \(tf\) 的撤销,可以将 \(tf\) 的每一项再维护一下非零值的乘积以及当前零的个数就可以方便的做撤销了,然后再提取值即可。
现在复杂度变成了 \(O(qm\log n)\),带矩阵乘法 27 倍常数,果不其然 TLE。循环展开同样无果。
不必气馁,我们探求一下这个矩阵有什么性质:
它仍然满足这种形式,可以被 \(a,b,c,d\) 表达,所以我们只需要维护 \(a,b,c,d\) 四个量即可计算矩阵乘法。
这样就降低到了 4 倍常数,可以通过。
ZJOI2022深搜
显然具体的最小值很难求出,考虑拆贡献计算对于每个 \(i=0\sim n-1\),有多少个 \(f(x,y)>i\)
这样可以转化为动态将点从1修改为0,\(f(x,y,i)\) 就变成了从 \(x\to y\) 不经过0的概率
设 \(f_x\) 为 \(\sum_{y\in subtree(x)}f(x,y,i)\),设 \(s_x\) 为 \(\sum_{y\in subtree(x)} f_y\)。
-
\(col_x =0\)
这应该很容易了,\(f_x=0,s_x=\sum_{y\in Son(x)}s_y\)。
-
\(col_x=1\)
\(f_x=1+\sum_{y\in Son(x)}f_y·mul_y\),其中 \(mul_y\) 是不经过 \(0\) 走到 \(y\) 的概率
- \(col_y\neq 0,mul_y=\frac{1}{c_0+1-ok_y}\),其中 \(c_0\) 是非法儿子个数(存在 \(0\) 的子树),\(ok_y\) 是这个子树是否存在 \(0\)。
\(s_x=f_x+\sum_{y\in Son(x)}s_y\)
写出了朴素的dp,考虑ddp。
维护一个 \(cnt_x\) 表示 \(x\) 的子树里有几个子树存在 \(0\),维护 \(ok_x\) 表示 \(x\) 的子树是否存在 \(0\)。
有:
尝试写一下转移矩阵:
设 \(ts_x=\sum_{y\in Son(x),y\neq son_x}s_y,tf_x=1+\sum_{y\in Son(x),y\neq son_x}\frac{f_y}{1+cnt_x-ok_y}\)
有:
那么问题来了,修改的时候怎么办。注意到 \(ok\) 和 \(col,cnt\) 都是有关变量影响 dp,那么直接用树状数组维护子树内0点个数,暴力修改。反正都是逐个纠正。这些点的总变化次数都是 \(O(n)\) 的。
使用 DDP 更新即可。
需要注意的是,\(tf\) 与 \(ok\) 和 \(cnt\) 有关,因此需要对 \(ok=1\) 与 \(ok=0\) 分类维护 \(\sum f_v\) 系数就可以计算了,否则当 \(u\) 发生变化时 \(tf\) 也会发生变化。也就是维护的轻儿子贡献需要与 \(u\) 的可变系数脱钩,避免贡献整体变化的情况。
以及撤销时用的不是全局平衡二叉树的那个重链BST的根而是最浅层点的 \(ok\) 值。
另外,用的时候可能会用到这个正在修改的点,需要判掉,用以前的 \(ok\) 值撤销。
当更改一个点时,自身 \(col\) 发生变化,带动往上一部分祖先的 \(ok,cnt\) 变化,这边从下往上逐个更改并更新即可,变化总次数是 \(O(n)\) 的。
密码箱
可以发现可以单独维护分子分母,也就是有:
因此会有假设后面若干步得到答案是 \(\frac{x}{y}\),那么有:
可以写作矩阵:
因此答案可以表达为关于序列 \(a\) 相关的矩阵乘积,注意是倒着由 \(k\) 乘到 \(0\) 的,并且最开始 \(x=0,y=1\)。
考虑操作 W,其实显然有:
注意这里我们需要右乘,其道理是如果将E也表示为这样的形式,那么就不需要考虑 \(a\) 的末尾具体是啥了
考虑操作 E
其后一种情况可以写为:
同时前一种情况可以描述为
同时我们发现对于上面的那种情况,带入 \(a_k=1\),有:
得到了同样的结果
那么这告诉我们,其实有:
剩下的维护属于平衡树基础操作:维护正反的左右向的矩阵积一共四个即可。

浙公网安备 33010602011771号