矩阵优化 DP 以及全局平衡二叉树实现的动态 DP 学习笔记
fydj
·
·
算法·理论
矩阵乘法
求斐波那契数列的第 n 项,其中 n\le 10^{18},对数 m 取模。
写出转移方程,f_i=f_{i-1}+f_{i-2},直接算是 O(n) 的,似乎没什么办法优化。
定义大小分别为 n\times p 和 p\times m 的矩阵(分别为 a 和 b)相乘的结果(矩阵 c)是一个大小为 n\times m 的矩阵,其中 c_{i,j}=\sum_{k=1}^p a_{i,k}\times b_{k,j}。
把转移方程写成矩阵形式,得到:
\begin{bmatrix}
f_i&f_{i-1}
\end{bmatrix}
=
\begin{bmatrix}
f_{i-1}&f_{i-2}
\end{bmatrix}
\times
\begin{bmatrix}
1&1\\1&0
\end{bmatrix}
常见优化矩阵乘法的方法:
预处理出转移矩阵的 $2$ 的若干次幂次方。
如果转移是 $1\times n$ 初始矩阵乘以 $n\times n$ 的转移矩阵的若干次方,可以用初始矩阵依次乘以转移矩阵的 $2$ 的若干次幂次方,这样一次乘法就由 $O(n^3)$ 变成 $O(n^2)$。
如果转移矩阵很多位置是空,可以把不是零的位置拎出来做乘法。
单位矩阵:只有从左上到右下的对角线的位置是 $1$ 其它都是 $0$ 的矩阵。
# 可以动态修改的矩阵优化 DP
[Ex - 01? Queries (atcoder.jp)](https://atcoder.jp/contests/abc246/tasks/abc246_h)
[[ABC246Ex\] 01? Queries - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/AT_abc246_h)
给定长度为 $N$ 的仅包含 `0`,`1`,`?` 的字符串 $S$,给定 $Q$ 组询问 $(x_1, c_1), (x_2, c_2), \cdots, (x_q, c_q)$,每次将原字符串中 $x_i$ 位置的字符改为 $c_i$,然后输出 $S$ 有多少种非空子序列,`?` 需任意替换为 `0` 或 `1`。对 $998244353$ 取模。
$1 \le N, Q \le 10^5, 1 \le x_i \le N$。
设 $f_{i,0/1}$ 表示处理到第 $i$ 位,最后一位是 $0$ 还是 $1$ 的方案,得到方程:
$$
f_{i,0}=f_{i-1,0}+[S_i\neq 1](f_{i-1,1}+1)\\
f_{i,1}=f_{i-1,1}+[S_i\neq 0](f_{i-1,0}+1)
$$
注意到有常数项 $1$,不是很好维护,于是再转移矩阵里加多一个常数项 $1$。
$$
\begin{bmatrix}
f_{i,0}&f_{i,1}&1
\end{bmatrix}
=
\begin{bmatrix}
f_{i-1,0}&f_{i-1,1}&1
\end{bmatrix}
\times
\begin{bmatrix}
1&[S_i\neq0]&0\\
[S_i\neq1]&1&0\\
[S_i\neq1]&[S_i\neq0]&1
\end{bmatrix}
$$
要求动态修改一个位置的值,相当于修改一个位置的转移矩阵,可以用线段树维护。
时间复杂度 $O(27(N+Q)\log N)$,前面的 $27$ 是矩阵乘法的常数。
# 动态 DP
[P4719 【模板】"动态 DP"&动态树分治 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/P4719)
[P4751 【模板】"动态DP"&动态树分治(加强版) - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/P4751)
给你一个 $n$ 个点的有点权(点 $i$ 的点权是 $a_i$)的树,$q$ 次操作,每次修正一个点的点权,每次修正后输出最大权独立集的权值大小。
设 $f_{i,0/1}$ 表示考虑 $i$ 的子树,选或不选点 $i$ 形成的最大权独立集的权值大小,列出 DP 式子:
$$
f_{i,0}=\sum _{j\in son_i} \max(f_{j,0},f_{j,1})\\
f_{i,1}=\sum _{j\in son_i} f_{j,0}+a_i
$$
考虑树链剖分,设 $mson_i$ 为点 $i$ 的重儿子,$lson_i$ 为点 $i$ 的轻儿子,又设:
$$
g_{i,0}=\sum _{j\in lson_i} \max(f_{j,0},f_{j,1})\\
g_{i,1}=\sum _{j\in lson_i} f_{j,0}+a_i
$$
那么可以列出转移方程:
$$
f_{i,0}=g_{i,0}+\max(f_{mson_i,0},f_{mson_i,1})\\
f_{i,1}=g_{i,1}+f_{mson_i,1}
$$
重新定义矩阵乘法:定义大小分别为 $n\times p$ 和 $p\times m$ 的矩阵(分别为 $a$ 和 $b$)相乘的结果(矩阵 $c$)是一个大小为 $n\times m$ 的矩阵,其中 $c_{i,j}=\max_{k=1}^p a_{i,k}+b_{k,j}$。就是把 $\sum$ 换成 $\max$,把 $\times$ 换成 $+$,称这种矩阵乘法为 $\max +$ 矩阵乘法。
那么化成矩阵就是:
$$
\begin{bmatrix}
f_{i,0}\\
f_{i,1}
\end{bmatrix}
=
\begin{bmatrix}
g_{i,0}&g_{i,0}\\
-\inf&g_{i,1}
\end{bmatrix}
\times
\begin{bmatrix}
f_{mson_i,0}\\
f_{mson_i,1}
\end{bmatrix}
$$
叶子(LeaF)节点没有儿子,所以后面乘的是 $\begin{bmatrix}0\\0\end{bmatrix}$。
树链剖分,用线段树维护一条重链上转移矩阵的值,跳轻边就把 $f$ 贡献到父亲的 $g$ 里面,时间复杂度是 $O(n\log^2 n)$。
但你会被卡树剖~~,而且树剖难写~~。
# 全局平衡二叉树
LCT 由于有 splay 的良好性质,所以高度是 $\log n$ 级别的,它可以很好地在 $O(\log n)$ 的时间内维护一条重链上转移矩阵的乘积。
但 LCT 常数又大有难写,注意到树的形态是固定的,用 LCT 会不会大材小用?
有一个叫全局平衡二叉树的一个东西,是一个静态的 LCT,给重链上的点附上一个权值(这个权值只被用来构建全局平衡二叉树),权值是当前点的子树大小减去重儿子的子树大小。一条重链的一段缩成一颗 splay 的时候,找到它的带权中点,以它为根。这样构建出的单棵 splay 可能不一定特别平衡,但从整体来看,整棵树是平衡的,深度不会超过 $\log n$ 级别。
修改的时候,跳重边可以直接更改父亲的转移矩阵乘积,跳轻边可以更改父亲的转移矩阵。
附上全局平衡二叉树实现上面例题的代码:
```cpp
const int N=1000099; const int inf=1047483646;
struct ju { int a[2][2]={}; friend ju operator * (const ju a,const ju b) { ju ans; int i,j; for(i=0;i<2;++i) for(j=0;j<2;++j) ans.a[i][j]=max(a.a[i][0]+b.a[0][j],a.a[i][1]+b.a[1][j]); return ans; } } val[N]={},sum[N]={};
int n,q,a[N]={},w[N]={},sz[N]={},lsz[N]={};
int mson[N]={},type[N]={},stype=0; vector<int> lian[N]={};
int fa[N]={},ch[N][2]={},f[N][2]={},g[N][2]={};
int em=0,e[N*2]={},ls[N]={},nx[N*2]={};
void insert(int x,int y) { e[++em]=y,nx[em]=ls[x],ls[x]=em; e[++em]=x,nx[em]=ls[y],ls[y]=em; return ; }
#define lson (ch[x][0])
#define rson (ch[x][1])
#define get(x) (ch[fa[x]][1]==(x))
#define isroot(x) (ch[fa[x]][0]!=(x)&&ch[fa[x]][1]!=(x))
int makebst(int tp,int l,int r) {
int i,tot=0,s=0,x; for(i=l;x=lian[tp][i],i<=r;++i) tot+=lsz[x];
for(i=l;x=lian[tp][i],s+=lsz[x];++i)
if(s*2>=tot) {
if(l<i) fa[lson=makebst(tp,l,i-1)]=x;
if(i<r) fa[rson=makebst(tp,i+1,r)]=x;
sum[x]=sum[lson]*val[x]*sum[rson];
return x;
}
}
int make(int rt) {
int i,tp=type[rt],y,oldf=fa[rt];
for(auto x:lian[tp]) {
for(i=ls[x];y=e[i],i;i=nx[i])
if(y!=fa[x]&&y!=mson[x])
y=make(y),g[x][0]+=max(f[y][0],f[y][1]),g[x][1]+=f[y][0];
g[x][1]+=a[x],val[x]=(ju){g[x][0],g[x][0],g[x][1],-inf};
}
fa[rt=makebst(tp,0,lian[tp].size()-1)]=oldf;
f[rt][0]=max(sum[rt].a[0][0],sum[rt].a[0][1]);
f[rt][1]=max(sum[rt].a[1][0],sum[rt].a[1][1]);
return rt;
}
void change(int x) {
sum[x]=sum[lson]*val[x]*sum[rson];
if(fa[x]&&isroot(x))
g[fa[x]][0]-=max(f[x][0],f[x][1]),g[fa[x]][1]-=f[x][0];
if(isroot(x))
f[x][0]=max(sum[x].a[0][0],sum[x].a[0][1]),f[x][1]=max(sum[x].a[1][0],sum[x].a[1][1]);
if(fa[x]&&isroot(x))
g[fa[x]][0]+=max(f[x][0],f[x][1]),g[fa[x]][1]+=f[x][0],
val[fa[x]]=(ju){g[fa[x]][0],g[fa[x]][0],g[fa[x]][1],-inf};
if(fa[x]) change(fa[x]);
return ;
}
int main()
{
// usefile("ddp");
int i,x,y,l,r,root,lstans=0; ju nxt;
read(n,q); for(i=1;i<=n;++i) read(a[i]); for(i=1;i<n;++i) read(x,y),insert(x,y);
w[l=r=1]=1; while(l<=r) { x=w[l]; for(i=ls[x];y=e[i],i;i=nx[i]) if(y!=fa[x]) fa[y]=x,w[++r]=y; ++l; }
for(i=n;x=w[i],y=fa[x],i;--i) { ++sz[x],++lsz[x],sz[y]+=sz[x],lsz[y]+=sz[x]; if(!mson[y]||sz[mson[y]]<sz[x]) lsz[y]+=sz[mson[y]]-sz[x],mson[y]=x; }
for(i=1;x=w[i],y=mson[x],i<=n;++i) { if(!type[x]) type[x]=++stype,lian[stype].push_back(x); if(y) type[y]=type[x],lian[type[x]].push_back(y); }
val[0]=sum[0]={0,-inf,-inf,0},root=make(1);
loop : --q; read(x,y),x^=lstans,val[x].a[1][0]+=y-a[x],g[x][1]+=y-a[x],a[x]=y,change(x),printf("%d\n",lstans=max(f[root][0],f[root][1])); if(q) goto loop; return 0;
}
```
# 其它技巧
[P6021 洪水 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/P6021)
容易写出转移方程:$f_i=\min(\sum_{j\in son_i} f_j + a_i)$。
设 $s_i=\sum_{j\in lson_i} f_j$,可以列成矩阵形式,这里的 $\times$ 是 $\min+$ 矩阵乘法:
$$
\begin{bmatrix}
f_i&0
\end{bmatrix}
=
\begin{bmatrix}
f_{mson_i}&0
\end{bmatrix}
\times
\begin{bmatrix}
s_i&\inf\\
a_i&0
\end{bmatrix}
$$
用全局平衡二叉树维护。
这里的转移和上一题不一样,上一题的初始矩阵是列向量,是竖着的,用根节点的转移矩阵乘以重儿子的矩阵,从上乘到下;而这里的初始矩阵是行向量,是横着的,用重儿子的矩阵乘以根节点的转移矩阵,从下乘到上。
但,题目要求询问子树,这相当于询问一条重链的后缀乘积,可以在全局平衡二叉树的 splay 中实现。
时间复杂度 $O(n\log n)$。
你还可以继续卡常,注意到:
$$
\begin{bmatrix}
a&\inf\\
b&0
\end{bmatrix}
\times
\begin{bmatrix}
c&\inf\\
d&0
\end{bmatrix}
=
\begin{bmatrix}
a+c&\inf\\
\min(b+c,d)&0
\end{bmatrix}
$$
所以只需要记录矩阵的左上角和左下角两个量即可。
```cpp
const int N=200099;
int n,q,fa[N]={},sz[N]={},mson[N]={},tag[N]={};
int type[N]={},stype=0,num[N]={},leng[N]={},root[N]={};
vector<int> lian[N]={};
int em=0,e[N*2]={},ls[N]={},nx[N*2]={};
ll a[N]={};
void insert(int x,int y) { e[++em]=y,nx[em]=ls[x],ls[x]=em ;e[++em]=x,nx[em]=ls[y],ls[y]=em; return ; }
struct ju {
ll a,b;
ju(ll a_=0,ll b_=0) { a=a_,b=b_; return ; }
friend ju operator * (const ju a,const ju b) {
return ju(a.a+b.a,min(a.b+b.a,b.b));
}
} v[N]={},sum[N]={}; int lson[N]={},rson[N]={};
void setup(int x) {
int i,y; sz[x]=1;
for(i=ls[x];y=e[i],i;i=nx[i])
if(y!=fa[x]) {
fa[y]=x,setup(y);
sz[x]+=sz[y];
if(sz[mson[x]]<sz[y])
mson[x]=y;
}
tag[x]=sz[x]-sz[mson[x]];
return ;
}
void getlian(int x) {
if(!type[x])
type[x]=++stype,
num[x]=0,leng[stype]=1,
lian[type[x]].push_back(x);
if(!mson[x]) return ;
type[mson[x]]=type[x];
num[mson[x]]=leng[type[x]]++;
lian[type[x]].push_back(mson[x]);
getlian(mson[x]);
int i,y;
for(i=ls[x];y=e[i],i;i=nx[i])
if(y!=fa[x]&&y!=mson[x])
getlian(y);
return ;
}
void update(int x) { sum[x]=sum[rson[x]]*v[x]*sum[lson[x]]; return ; }
int makebst(int tp,int l,int r) {
int i,s=0,tot=0;
for(i=l;i<=r;++i) tot+=tag[lian[tp][i]];
for(i=l;i<=r;++i) {
s+=tag[lian[tp][i]];
if(s*2>=tot) {
int x=lian[tp][i]; v[x].b=a[x];
if(l<i) fa[lson[x]=makebst(tp,l,i-1)]=x;
if(i<r) fa[rson[x]=makebst(tp,i+1,r)]=x;
update(x);
return x;
}
}
}
int make(int x) {
int i,y;
for(auto p:lian[type[x]])
for(i=ls[p];y=e[i],i;i=nx[i])
if(y!=fa[p]&&y!=mson[p])
y=make(y),fa[y]=p,v[p].a+=sum[y].b;
return root[type[x]]=makebst(type[x],0,leng[type[x]]-1);
}
int getopt() { static char chart; do chart=getchar(); while(chart!='Q'&&chart!='C'); return chart=='Q'; }
ju query(int x,int p) {
if(!x) return ju(0,1e15);
else if(num[x]==p)
return sum[rson[x]]*v[x];
else if(num[x]<p)
return query(rson[x],p);
else return sum[rson[x]]*v[x]*query(lson[x],p);
}
void change(int x) {
if(lson[fa[x]]==x||rson[fa[x]]==x)
update(x),change(fa[x]);
else if(!fa[x])
update(x);
else
v[fa[x]].a-=sum[x].b,
update(x),
v[fa[x]].a+=sum[x].b,
change(fa[x]);
return ;
}
int main()
{
// usefile("flood");
int i,x,y,opt;
read(n);
for(i=1;i<=n;++i)
read(a[i]);
for(i=1;i<n;++i)
read(x,y),insert(x,y);
setup(1),getlian(1);
v[0]=sum[0]=ju(0,1e15),fa[make(1)]=0;
read(q);
loop : --q;
opt=getopt();
if(opt) read(x),printf("%lld\n",query(root[type[x]],num[x]).b);
else read(x,y),a[x]+=y,v[x].b+=y,change(x);
if(q) goto loop;
return 0;
}
```
# 容易写错的地方
这里讲述的是个人经验,你可能没有容易写错的地方。
+ 注意构建全局平衡二叉树的时候对父亲的更改,有三个地方要注意:全局平衡二叉树内左右儿子的父亲重新设为根节点;splay 的父亲要设为对应的重链最顶上的节点的父亲;整颗全局平衡二叉树的根节点的父亲要设为 0。
+ 更改的时候要分三类:跳重边,直接更改 splay 子树内转移矩阵的乘积,并更新父亲;跳轻边,先把对父亲转移矩阵的贡献删去,再更改 splay 子树内转移矩阵的乘积以求出 DP 值,然后再把对父亲转移矩阵的贡献加回来,并更新父亲;根节点,直接更改乘积,不需要也不可以更新父亲。
+ $0$ 号节点的转移矩阵和转移矩阵乘积要设为单位矩阵,普通矩阵乘法是 $\begin{bmatrix}1&0\\0&1\end{bmatrix}$,$\min +$ 矩阵乘法是 $\begin{bmatrix}0&\inf\\\inf&0\end{bmatrix}$,若是 $\max +$ 矩阵乘法则把 $\inf$ 改为 $-\inf$。
# 练习
[Problem - 573D - Codeforces](https://codeforces.com/problemset/problem/573/D)
[P3781 [SDOI2017\] 切树游戏 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/P3781)