重链剖分学习笔记

前言

树链剖分(简称树剖)是一种将树剖分成若干链维护信息解决问题的思想。本文讲的是其中的重链剖分,着重介绍较为基础的内容,旨在帮助初学者更好地理解并掌握。

附题单

求 LCA

定义(斜杠表示本文中对其可能有多种表示方法):

  • du/depu 为点 u 的深度(到根的数)。

  • szu/sizu 为以 u 为根的子树大小。

  • fu/fau 为点 u 的父亲。

  • hu/hvyu/heau/hsonu 为点 u 的一个儿子 v 满足 szv>szu2,称为重儿子,一个点除重儿子外的儿子称为轻儿子。显然,一个点至多只有一个重儿子

  • 重边为点 uhu 之间的连边(如图中的边)。轻边为树中除重边以外的边(边):

  • 重链为由连续的重边组成的极长链。特别地,若一个点没有重边与之相连,则也称这个点为重链

  • tu/topuu 所在重链中,深度最小的点,称之为链头

upd:可以定义为子树大小最大的儿子。核心思想都是使得走一条连向父亲的轻边子树大小至少乘 2

可以通过 DFS 求出上述量:

void dfs1(int u,int fa){//求 sz[u],d[u],f[u]。
    sz[u]=1;
    for(int v:g[u]){
        if(v^fa){
            d[v]=d[u]+1;
            dfs1(v,f[v]=u);
            sz[u]+=sz[v];
        }
    }
}
void dfs2(int u,int fa){//求 h[u],t[u]。
    for(int v:g[u]){
        if(v^fa){
            if((sz[v]<<1)>sz[u]){
                t[h[u]=v]=t[u];
            }else{
                t[v]=v;
            }
            dfs2(v,u);
        }
    }
}

应用:洛谷 P3379 【模板】最近公共祖先(LCA)

  • 给出 n 个点的有根树,根为 sm 次询问,每次询问点 a,b 的最近公共祖先。

  • n,m5×105

考虑使用树链剖分。先看求 LCA 的代码:

int lca(int x,int y){
	while(t[x]^t[y]){
		if(d[t[x]]<d[t[y]]){
			swap(x,y);
		}
		x=f[t[x]];
	}
	if(d[x]>d[y]){
		swap(x,y);
	}
	return x;
}

该代码求解 LCA 的过程用两句话来讲就是:

  • x,y不同重链中,选取链头深度较大的点,让其跳过链头到达链头的父亲

  • x,y同一重链中,深度小的点即为 LCA

证明

定义:

  • xftx跳链操作,显然一个点能进行跳链操作必须满足其链头深度较大特殊地,维护同一重链上的信息也称为跳链操作(方便后文表达)

  • LCA(x,y)x,y 的 LCA,简称 LCA。

首先,最后跳到的节点一定是公共祖先,只需要证明它深度最大,即不是 LCA 的祖先

考虑反证,假设某次跳链操作 x 越过了 LCA(设到达 LCA 的某个祖先 z),如图:

说明 LCA 到 x 之间的边全部是重边边)。则 LCA 向自己儿子中 y 所在子树的根的连边为轻边边):

image

dtxdLCA(x,y)dty>dLCA(x,y)dty>dtx,这与 x 能进行跳链操作相矛盾。因此跳到的一定是 LCA,原命题得证。

接下来分析复杂度。因为 txftx 的那条边一定是轻边(不然 ftx 也在重链上,tx 就不是重链上深度最小的点了)。根据轻边的性质,跳一条轻边所在子树大小至少乘 2(同一重链上的情况作为常数忽略)。所以跳链操作的次数是 O(logn) 级别的,因此单组询问也是 O(logn) 级别的。

所以,我们得到了一个时间复杂度为 O(mlogn),空间复杂度为 O(n) 的做法。

评测记录 代码

树上问题转序列问题

洛谷 P3384 【模板】重链剖分/树链剖分

  • 给出 N 个点的有根树,根为 R,点有点权。有 M 次操作,支持:

    • x y z,将 x,y 路径上的点的点权加 z

    • x y,求 x,y 路径上的点的权值和,模 P

    • x z,把以 x 为根的子树内的点权值加 z

    • x,求以 x 为根的子树内的点的权值和,模 P

  • N,M105

定义:

  • numu/idu/dfnu 为点 u 的 DFS 序,简称编号/时间戳。

  • stu 为 DFS 序中以 u 为根的子树的起始(时间戳最小)位置,edu 为结束(时间戳最大)位置。显然 stu=dfnu

首先,你是会用线段树求解区间加区间求和的。其次,若只有 34 操作,利用同一子树内点的 DFS 序连续,可以从 R 开始 DFS 求出 DFS 序,然后转化为区间加区间求和。这启示着我们对于 12 操作,同样将其转化为序列问题解决。回顾求解 LCA 的过程,在跳链时,我们需要维护当前链上的信息。因此,我们需要得到一种使得重链上的点编号连续的 DFS 序。可以通过如下的代码求出:

void dfs3(int x, int fa) {
    sg[st[x] = num[x] = ++sg[0]] = val[x] % p;
    if (h[x]) {
        dfs3(h[x], x);
    }
    for (int i : g[x]) {
        if ((i ^ fa) && (i ^ h[x])) {
            dfs3(i, x);
        }
    }
    ed[x] = sg[0];
}

在这个代码中,若出现连续的重边,就会优先 DFS 重儿子,使当前重链的下一个点的编号为当前点编号加 1,从而达到编号连续的目的

为什么这样能够不重不漏地维护信息呢(笔者想硬胡一个理性理解,您要是觉得混淆还是跳过这部分,毕竟是个常识)

  • 对于最终跳到同重链上的两个点,会维护它们之间的信息(代码里可以实现)。

  • 对于在重链上的点,跳到其重链之前会先维护它以下的信息,跳过它后会维护它以上的信息,因此不会重复。跳到当前重链时,这个点被包含在重链内,在线段树中维护的区间包括了它,因此不会漏

之后就是区间问题了。由于跳链操作总数为 O(MlogN) 级别,每次跳链要在其对应的 DFS 序区间内进行线段树操作(一次为 O(logN)),因此时间复杂度为 O(Mlog2N),空间复杂度为 O(N)

评测记录 代码

边转点技巧

洛谷 P4114 Qtree1

  • 给出 n 个点的树,边有边权,支持三种操作:

    • CHANGE i t,将输入的第 i 条边的边权改为 t

    • QUERY a b,查询 a,b 两点路径上最大的边权。

    • DONE,结束程序。

  • n105,操作数不超过 3×105

对于一棵树而言,儿子向父亲的连边一定是唯一的。因此,边转点的技巧就在于将点的权值设置为与父亲连边的边权。如图,字表示边权,绿字表示点权。根暂时不去管,后面会解释。

image

仍旧正常跳链查询,但是当 x,y 两点跳到同一重链上的时候:

image

这一段链维护的信息实际上是 x 的重儿子到 y 这一段区间。因此跳链的代码要写成这样:

inline int answer(int x, int y) {
    if (x == y) {//没有边。
        return 0;
    }
    int ans = -1e9;
    while (t[x] ^ t[y]) {
        if (d[t[x]] < d[t[y]]) {
            swap(x, y);
        }
        ans = max(ans, query(1, 1, n, dfn[t[x]], dfn[x]));//仍然正常维护链上信息,相当于维护 x 到 f[t[x]] 的边。
        x = f[t[x]];
    }
    if (x == y) {//相等时,说明 x 到 LCA、y 到 LCA 的链都已经维护好(可以结合上图理解)。
        return ans;
    }
    if (d[x] > d[y]) {//先令 y 为那个相同重链上不是 LCA 的点了。
        swap(x, y);
    }
    return max(ans, query(1, 1, n, dfn[x] + 1, dfn[y]));//重儿子到 y。
}

回过头来解释一下为什么不用管根,因为若根作为 LCA,并且 x,y 两点同时跳到,会直接返回。若根和 y 之间还有边,会从根的重儿子开始维护,不会涉及到根。因此不用管根。

修改就是单点修改那条边两端深度较大的节点,因为那条边的信息存在儿子上了。

m 为操作数。时间复杂度为 O(mlog2n),空间复杂度为 O(n)

评测记录 代码

习题:

Ⅰ - ABC294G Distance Queries on a Tree

洛谷链接

  • n 个点的树,Q 次操作:

    • i w,将第 i 条边边权改为 w

    • x y,查询 x,y 之间路径的权值和。

  • n,Q2×105

唯一赛时会的 G。

运用边转点技巧,由于只有单点修改,并且是可差分信息,套上树状数组维护即可。

时间复杂度为 O(Qlog2n),空间复杂度为 O(n)

评测记录(含代码)

维护“存在性信息”小优化

洛谷 P3398 仓鼠找 sugar

  • 给出 n 个点的树,q 次询问 a,b 之间的路径和 c,d 之间的路径有没有公共点。

  • n,q105

这是一种极其无脑的做法,就是码量比较大。我们直接将 a,b 路径上的点全推平成 1,再查询 c,d 路径上有没有 1 就行了,具体方法是维护区间或和。注意到只要有一个 1 即可,因此当查到 1 的时候,直接返回即可。

维护这种存在性的信息,都可以采用类似的技巧优化。

时间复杂度 O(qlog2n),空间复杂度 O(n)。但是经过剪枝,甚至跑得比部分 O(qlogn) 的代码还要快。

评测记录 代码

习题

Ⅰ - 洛谷 P3950 部落冲突

  • 给出 n 个点的树,m 次操作,有以下几种:

    • p q,查询 p 能否到达 q

    • p q,断掉 p,q 之间的边,保证 p,q 相邻。

    • x,对于第 xC 操作,恢复那条边。

  • n,m3×105

先边转点,然后用 1 表示边是好的,0 表示断开。显然 p 能到 q 必须路径上的边全是 1。维护区间与和,支持单点修改,同样可以类似剪枝。

时间复杂度 O(mlog2n),空间复杂度 O(n)

评测记录 代码

结合“颜色限制”

洛谷 P3313 [SDOI2014]旅行

  • 给出一棵 N 个点的树,点 i 有权值 Wi 和颜色 Ci,有 Q 次操作,分为以下类型:

    • CC x c,执行 Cxc

    • CW x w,执行 Wxw

    • QS x y,查询点 x,y 路径上,Ci=Cx 的点 i 的权值和,保证 Cx=Cy

    • QM x y,查询点 x,y 路径上,Ci=Cx 的点 i 权值的最大值,保证 Cx=Cy

  • N,Q1051Ci105

如果没有颜色的限制就是树剖板子。加上颜色限制后,考虑对每一种颜色在树剖后的序列上维护线段树,然后查询就是跳链时在 Cx 那种颜色的线段树上查询。发现这样空间无法接受,于是考虑动态开点,记录每种颜色的根节点

CW 操作就是在对应的线段树上直接改权值,CC 操作相当于从当前颜色的线段树中删掉并在新颜色的线段树上修改。删掉可以直接置 0,因为这样不会产生贡献,也可以用垃圾桶技巧,但不建议用指针中的 delete,会有玄学错误。

时间复杂度为 O(Qlog2N),空间复杂度为 O(QlogN)

评测记录 代码

习题

Ⅰ - 洛谷 P5838 [USACO19DEC]Milk Visits G

双倍经验:SP11985 GOT - Gao on a tree

  • 有一棵 N 个节点的树,第 i 个节点有颜色 TiM 次询问,每次询问点 A,B 路径上是否存在颜色为 C 的节点。

  • N,M1051Ti,Cn

每一种颜色分别在树链剖分后的序列上维护线动态开点线段树,将每个点在自己颜色的动态开点线段树上置 1,维护区间或和。跳链时在那种颜色对应的线段树上查询,可以使用“维护‘存在性信息’小优化”的技巧剪枝,结果为 1 说明存在,为 0 不存在。

时间复杂度为 O(Mlog2N),空间复杂度为 O(NlogN)

评测记录 代码

Ⅱ - ABC133F Colorful Tree

洛谷链接

  • 给出 N 个节点的树,每条边有颜色、边权。处理 Q 个询问,第 i 次询问给出 xi,yi,ui,vi,表示先将所有颜色为 xi 的边边权变成 yi,再求出 uivi 的路径边权和。询问之间相互独立,即不会真的修改

  • N,Q105

首先,路径上的边修改后才会影响答案。考虑树剖,先边转点。信息又和颜色有关,同样使用动态开点技巧在树剖后的序列上对每种颜色维护线段树,记录当前区间内有效边的个数 tot 以及有效边的边权和 sum。同时维护前缀和。

跳链时,先用前缀和查询原边权和,再在线段树上查询该区间的信息,记查询到的和为 a,个数为 b。则要a 那一部分的贡献换成 b×yi,对应的答案加上 b×yia。特判颜色不存在的情况。

时间复杂度为 O(Qlog2N),空间复杂度为 O(N)

评测记录(含代码)

Ⅲ - UVA12424 Answering Queries on a Tree

洛谷题目链接

PDF 题面

  • n 个节点的树。第 i 个节点颜色为 Ci。有 m 次操作:

    • u c:把 Cu 改为 c

    • u v:询问 u,v 两点路径之间出现最多的颜色次数。

  • T 组数据,1T,Ci,c101n,m1051u,vn

注:相关变量名称和原题略有不同

这题也是关于颜色限制的题,但是只有 10 种颜色,而且只有单点修改,并且是可差分信息,因此不需要动态开点线段树,开 10 棵树状数组就可以了。

先边转点,再对树剖后的序列用树状数组维护每种颜色的前缀个数。修改时,先将原颜色中该位置上 1,再在新颜色中该位置上 +1。然后把颜色(C 数组)改为新颜色,因为下一次修改是针对这个新颜色的。

查询时,用 ansi 表示第 i 种颜色出现的次数。在跳链时,枚举每种颜色并用树状数组差分查询,再加到对应的 ans 上。最后输出 ans 数组的最大值即可。

时间复杂度为 O(Tmlog2n),空间复杂度为 O(n)

评测记录 代码

Ⅳ - CF960H Santa's Gift

CF

注:为了避免混淆,修改了部分变量的名称

  • 给出 n 个点的有根树,m 种颜色,点 i 有颜色 ai,颜色 i 有权值 bi。维护两种操作,共 q 次:
    • x y,将 axy

    • x,从树中等概率选取一个点 i,得到 (Si×bxC)2 的价值。求期望价值。其中 Si 表示以 i 为根的子树中颜色为 x 的节点数。C 是给定的常数。

  • n,m5×104

先写出答案的表达式:

i=1n(Si×bxC)2n=i=1n(bx2Si22bxCSi+C2)n=bx2i=1nSi22bxCi=1nSi+nC2n

考虑维护每种颜色的 Si,套路树剖再动态开点线段树。操作 1 相当于对颜色 ax 将根到 x 路径上的 Si1,对于颜色 y 将这些 Si1。这个修改区间含根节点很友好,不需要正常跳链,直接从 x 跳到根就好了。

线段树的节点要维护平方和以及和,简单讲一下平方和的维护:

i=lr(xi+v)2=i=lr(xi2+2vxi+v2)=i=lrxi2+2vi=lrxi+(rl+1)v2

就是在原来平方和的基础上加上 2v 倍的原来的和,再加上 (rl+1)v2。由于是动态开点线段树不方便 pushdown,所以写了标记永久化。

对于 2 操作,查询区间为 [1,n] 也很友好,直接用那种颜色线段树的根的信息就可以了。

时空复杂度均为 O(mlog2n),可以接受。

评测记录(含代码)

Ⅴ - CF463E Caisa and Tree

  • 给出 n 个点以 1 为根的树,点 u 有点权 aum 次操作,两种类型:

    • 1 u,查询点 u 的一个最近祖先 v,满足 gcd(au,av)>1,没有输出 1

    • 2 u d,将 aud

  • n,m105au2×106

「保证 2 操作的数量不超过 50」的限制和 10 秒的时限太不牛了。让我们去掉这个限制并将时限改为 2 秒,再考虑怎么做。

看到单点修改和路径查询,容易想到树链剖分。考虑到 gcd(au,av)>1 本质上是 au,av 有着共同的质因数,所以要先用欧拉筛筛出 2×106 内的质数。然后可以枚举这个质因数,分别查询有该质因数的最近祖先,再从其中选出最近的即可。

由于根到某个点路径的 dfn 序是递增的,考虑通过求最大的 dfn 序来找到最近祖先。具体地,对每种质数维护一个 set,里面有序存放点权有该质因数的点的 dfn 序。查询某种质因数时,在跳链的过程中通过 lower_boundupper_bound 二分找到不超过当前点 u 的最大 dfn 序(如果 u 为初始点则是严格小于,因为自己不能选),若该值 dfntopu,说明该值对应的点在当前重链上,直接返回即可。

对于修改操作,相当于在 au 的质因数的 set 中删除 dfnu,在 y 的质因数的 set 中插入 dfnu

乍一看分解质因数很暴力,但是我们可以在欧拉筛的时候处理每个数 i 的最小质因数 prei,分解质因数的时候,设当前数为 x,则分解一个 prex 出来,再令 xxprex。由于质数 2,因此除以 prex 的操作至多进行 logx,意味着我们完成了在 O(logx) 的时间复杂度内分解质因数。而且,2×106 内的数本质不同的质因数个数不超过 7,考虑 2×3×5×7×11×13×17×19=9699690,而我们对于同一个质因数只需要进行一次跳链查询/修改 set,因此单次操作跳链查询/修改 set 次数是常数级别。

设值域为 V,最终的时间复杂度为 O(mlog2n),空间复杂度为 O(|V|)

评测记录(含代码)

Ⅵ - 洛谷 P6088 [JSOI2015] 字符串树

  • 给出 n 个点的树,边上有字符串 sq 次询问 u,v 两点路径上有多少边的字符串以字符串 t 为前缀。

  • n,q105|si|10

注意到 |si|10,即本质不同的前缀不会超过 106 种。于是先边转点,然后把前缀看成颜色,运用本节的套路,有两种方法:

  • 【方法一】

    对每种字符串建立动态开点线段树,线段树上的区间维护对应的 dfn 序区间内该前缀的出现次数。跳链时在对应前缀的线段树上查询。

    时间复杂度为 O(qlog2n),空间复杂度为 O((i=1nsi)×logn)

  • 【方法二】

    对每种前缀开一个 vector,按从小到大的顺序存放具有该前缀节点的 dfn 序。跳链时,设当前跳到点 u,二分出 l 表示第一个大于等于 dfntopu 的值的位置,r 表示最后一个不超过 dfnu 的位置,则该重链的贡献为 rl+1

    时间复杂度为 O(qlog2n),空间复杂度为 O(i=1nsi)

相比之下,【方法一】空间更劣,常数更大,但是可以支持更多的修改操作。【方法二】空间较优,常数较小,但是在修改方面有一定的局限性。

给的是【方法二】的代码。

评测记录 代码

Ⅶ - 洛谷 P4947 PION后缀自动机

  • 给出一棵 n 个点的树,每个点上有若干字符串,有 m 次操作,分为三种:

    • query /p u v,查询 u,v 简单路径上的边数。

    • query /e u v *.s,查询 u,v 简单路径上字符串 s 出现了几次。

    • del /e u v *.s,查询 u,v 简单路径上字符串 s 出现了几次,并删除这些 s

  • n,m105,字符串总数 k 不超过 5×105,字符串长度不超过 8

query /p 操作比较简单,答案为 depu+depv2×depLCA(u,v)。考虑解决剩下两个操作。

字符串的种数很少,考虑使用 P5838 的套路,有两种方法:

  • 【方法一】

    对每种字符串维护一棵动态开点线段树,线段树的节点维护对应 dfn 序区间内该字符串的出现次数。

    • 对于 query /e 操作,跳链时在对应的线段树上查询当前重链的贡献。

    • 对于 del /e 操作,则先做一遍 query /e 操作,然后再进行删除。删除的时候,在跳链时将当前重链对应的 dfn 区间推平成 0

    时间复杂度为 O(mlog2n),空间复杂度为 O(klogn)

  • 【方法二】

    对每种字符串维护一棵平衡树(这里使用 __gnu_pbds::tree),有序存放有该字符的点的 dfn 序,注意可能会重复,所以元素类型为 pairsecond 的作用是区分同一个点的两个相同字符串。

    • 对于 query /e 操作,设当前在点 u,跳链时使用 order_of_key 找到在 [1,dfntopu)[1,dfnu] 中的值个数,相减即为当前重链上该字符串的出现次数。

    • 对于 del /e 操作,同样先做一遍 query /e 操作,然后再进行删除。删除的时候,使用 lower_bound 找到第一个大于等于 dfntopu 的迭代器 l 和第一个大于 dfnu 的迭代器 r暴力删除 [l,r) 之间的所有迭代器。

    看上去很暴力,但是一开始插入了 k 个元素,一个元素只能被删除一次,因此时间复杂度为 O(mlog2n),空间复杂度为 O(k+n)

给的是【方法二】的代码。笔者因为看到 P7735 的一种新写法,即求出 LCA(u,v) 后分别算 uLCA(u,v)vLCA(u,v) 的信息,以为会更好写,结果那种写法会算重 LCA(u,v) 的信息。在 P7735 中单点是没有贡献的,但是这题有,于是调了很久才发现要单独减去 LCA(u,v) 的贡献。因此部分代码不建议读者参考,还是建议读者去参考本文“树上问题转序列问题”中提到的跳链写法。

评测记录 代码

维护树上子段信息

SP6779 GSS7 - Can you answer these queries VII

  • 给出 N 个点的树,点 i 有点权 xi。给出 Q 次操作:

    • a b,查询 a,b 路径上的最大子段和(就是路径上的点构成的连续序列的最大子段和,可以为 0,即空序列)。

    • a b c,将 a,b 路径上的点权值赋值成 c

  • N,Q105

首先你是会区间最大子段和的,区间推平就是打懒标记,如果为正就取整个区间,否则取空区间。

考虑在树上怎么做,回顾区间最大子段和的过程,线段树非叶子节点的信息是由左右儿子的信息合并起来的。这里为了后文方便阐述,给一下合并区间信息的代码,稍微讲一下:

//ans 为合并的区间;l,r 为被合并的区间;sum 表示区间和;ret 表示区间最大子段和;lmax 表示区间最大前缀和;rmax 表示区间最大后缀和。
node merge(node l,node r){
    node ans;
    ans.sum=l.sum+r.sum;
    ans.lmax=max(l.lmax,l.sum+r.lmax);//考虑信息是否横跨两个区间。
    ans.rmax=max(r.rmax,r.sum+l.rmax);
    ans.ret=max({l.ret,r.ret,l.rmax+r.lmax});
    return ans;
}

那么对于一棵树,我们可以以 LCA 为界,将两边链的信息合并,如图中色和色部分:

image

在跳到同一重链之前,类似合并重链信息即可,跳到同一重链之后,分两种情况(图中涂上/色的链为最终跳到的同一重链):

image

image

还有一个小细节。合并完得到的两条链是这样的:

image

由于合并的是连续的序列,因此方向应该相同。所以对于 x 一路跳链合并成的那一段信息,要交换以下 lmaxrmax,即原来的 rmax 从右边到了左边变成了 lmax,原来的 lmax 从左边到了右边变成了 rmax,如图中的 lmax,rmax

image

剩下几种情况同理即可。

时间复杂度为 O(Qlog2N),空间复杂度为 O(N)

评测记录 代码

习题

Ⅰ - 洛谷 P7735 [NOI2021] 轻重边

注:为了避免混淆,修改了原题中某些定义的名称。

  • 给出 n 个点的树,树上可能会出现两种边:短边和长边。一开始所有边都是短边。有 m 次操作,两种类型:

    • a b,对于 a,b 路径上的任意一点,将所有与其相连的边变成短边,再将路径上的所有边变成长边。

    • a b,查询 a,b 路径上有多少条长边。

  • T 组数据,T3n,m105

注:需要和“结合‘颜色限制’”区分,这里的颜色属于一类维护的信息,而非一种限制。

挺诈骗,和边转点没有一点关系。

首先要对问题进行一定的转化。有结论:钦定一开始树上所有点的颜色都是 0,对于第 i1 操作,将这条路径的点染上颜色 i,则两个端点颜色相同且不为 0 的边就是长边,反之为短边

考虑归纳证明,结论对于初始状态成立。由于没有端点在路径上的边在操作后是不会变的,因此只需要考虑以下几类情况:

  • 与路径重合的边,操作后应该是长边。由于其两端点都在路径上,被染成了相同的颜色,所以是长边,结论成立。

  • 一个端点在路径上的边,操作后应该是短边。若其之前是长边,说明之前两端点颜色都为 j(0<j<i),则此时一个端点被染成了 i,两端点颜色不同,是短边,结论成立;若其之前是短边,则两个端点颜色分别为 j,k(0j,k<i),此时无论替换哪个点的颜色为 i ,两端点的颜色都是不同的,是短边,结论成立。

证毕。

看到路径操作首先想树剖,考虑如何对树剖后的序列维护区间内相邻的、颜色相同且不为 0 的无序对数量。在线段树节点内维护区间最左/右边的位置的颜色 lc/rc,区间答案 tot。合并区间信息就是这样:

il node merge(co node&l,co node&r){//合并左右两个区间。
    node ret;
    ret.lc=l.lc;//左端肯定是左区间的左端。右端同理。
    ret.rc=r.rc;
    ret.tot=l.tot+r.tot;//不跨区间的情况,子区间的答案都要算。
    if(l.rc&&r.lc&&l.rc==r.lc){//跨区间,如果符合条件就算上。
        ++ret.tot;
    }
    return ret;
}

查询就是跳链的时候合并重链信息。注意仍要想例题那样注意最终以 LCA 为界的两条链合并时的方向

时间复杂度为 O(Tmlog2n),空间复杂度为 O(n),不知为何我的代码要卡常才能过。

评测记录 代码

Ⅱ - 洛谷 P9555 「CROI · R1」浣熊的阴阳鱼

  • 给出 n 个点的树,点有点权 ai(ai{0,1})。支持 q 次操作:

    • uau¬au

    • u v,你带着一个最大大小为 2 的可重集 Su 走到 v,初始 S=,遇到一个点 x,若 ¬axS,则删除 ¬ax,得分 +1,否则将 ax 插入 S。求最终的得分。

  • n,q105

看到树上修改和路径查询,首先想到树剖。我们发现 |S|2,且 S 的顺序不影响答案,因此我们可以用一个二元组 (i,j)(0ij2) 记录 S 的状态(0,1 表示放了什么元素,2 表示没有元素)。为了方便表述,将 2 也认为是 S 内的元素,即强制 |S|=2

分析一下询问,相当于已经给定了初始状态 S={au,2}。再根据树剖的思想,我们要快速查询一条重链上的信息,即快速查询那条链对应的区间的信息。考虑使用线段树维护。具体地,对于线段树上的一个节点 x(设对应的区间为 [l,r]),记 fx,i,j 表示从 l 位置开始走,经过 l 位置后 S={i,j},走到 r 时的得分,类似地 gx,i,j 表示从 r 走到 l 的得分。还需要记录 LtoRx,i,j 表示从 l 位置开始走,经过 l 位置后 S={i,j},到达 rS 的状态,同理还有 RtoLx,i,j 表示从 r 到达 lS 的状态。注意,我们强调了初始 S 是经过起点后的状态,意味着上述的 S已经受起点的影响,即统计答案的时候不再受到起点的影响(因为已经受过了)。类似地,强调 LtoRRtoL 是到达端点后的状态,说明信息已经受到终点的影响,因为状态的定义里保证了它受到起点的影响,所以同时保证了统计了完整的信息。

考虑如何合并区间信息,我们发现需要快速计算出已经到了一个区间末尾,下一步走到另一半区间开头时,S 的状态,因此还需要记录 lcx,rcx 来存储 alar。所以线段树的节点是张这样的:

#define pii pair<int,int>
struct node{//变量名有部分不同。
    int cnt_l[3][3],cnt_r[3][3],lc,rc;//f,g,lc,rc。
    pii status_l[3][3],status_r[3][3];//LtoR,RtoL。
}seg[N<<2];

然后计算状态可以通过以下的函数实现(我写的比较暴力,直接枚举 12 种情况分类讨论):

#define ppi pair<pii,int>
#define mp make_pair
ppi get_status(pii p,int x){//第二维表示得分增量。
    if(p==mp(0,0)&&!x){
        return mp(p,0);
    }
    if(p==mp(0,0)&&x){
        return mp(mp(0,2),1);
    }
    if(p==mp(0,1)&&!x){
        return mp(mp(0,2),1);
    }
    if(p==mp(0,1)&&x){
        return mp(mp(1,2),1);
    }
    if(p==mp(0,2)&&!x){
        return mp(mp(0,0),0);
    }
    if(p==mp(0,2)&&x){
        return mp(mp(2,2),1);
    }
    if(p==mp(1,1)&&!x){
        return mp(mp(1,2),1);
    }
    if(p==mp(1,1)&&x){
        return mp(mp(1,1),0);
    }
    if(p==mp(1,2)&&!x){
        return mp(mp(2,2),1);
    }
    if(p==mp(1,2)&&x){
        return mp(mp(1,1),0);
    }
    return mp(mp(x,2),0);//空集就直接放。
}

区间信息具体合并方法为,先从计算当前状态走到区间末尾的信息,然后计算跨过区间后的状态,以及计算跨区间这一步对得分的贡献。然后再从另一半区间,以跨区间后的状态开始走,计算得分。从当前起点走到另一个端点的状态,就是从另一半区间,以跨区间后的状态开始走,走到那个端点时的状态。代码如下:

#define fi first 
#define se second
node merge(node l,node r){
    node ret;
    ret.lc=l.lc;
    ret.rc=r.rc;
    for(int i=0;i<=2;++i){
        for(int j=i;j<=2;++j){
            ppi l_start=get_status(r.status_r[i][j],l.rc),r_start=get_status(l.status_l[i][j],r.lc);
            ret.cnt_l[i][j]=l.cnt_l[i][j]+r_start.se+r.cnt_l[r_start.fi.fi][r_start.fi.se];
            ret.status_l[i][j]=r.status_l[r_start.fi.fi][r_start.fi.se];
            ret.cnt_r[i][j]=r.cnt_r[i][j]+l_start.se+l.cnt_r[l_start.fi.fi][l_start.fi.se];
            ret.status_r[i][j]=l.status_r[l_start.fi.fi][l_start.fi.se];
        }
    }
    return ret;
}

对于长度为 1 的区间,有初始化:

seg[x].lc=seg[x].rc=b[l];//b[l] 是那个点的元素值。
for(int i=0;i<=2;++i){
    for(int j=i;j<=2;++j){
        seg[x].cnt_l[i][j]=seg[x].cnt_r[i][j]=0;//端点已经考虑过且没有遇到新元素,对得分无贡献。
        seg[x].status_l[i][j]=seg[x].status_r[i][j]=mp(i,j);//起点终点相同,受到起点的影响即受到了终点的影响。
    }
}

那么单点修改也很好维护:

seg[x].lc^=1;
seg[x].rc^=1;

查询就是跳链查询。注意合并信息的顺序

时间复杂度为 O(qlog2n),空间复杂度为 O(n)

评测记录 代码

Ⅲ - P4679 [ZJOI2011] 道馆之战

  • 给出一棵 n 个点的树。每个点有 A,B 两个区域,若不是障碍物,每次可以走到相邻点的相同区域,或当前点的另一个区域。

  • m 次操作,有两种类型:

    • u v,查询从 u 开始,向 v 的方向走,最多能不重复地访问多少区域。

    • u s,将 u 的区域状态改成 s

  • n5×104m105

下文用 0 区域代表 A 区域,用 1 区域代表 B 区域。

考虑重链剖分 + 线段树。

在线段树的一个节点内,维护:

  • fi,j(i,j{0,1}) 表示从左端点 i 区域走到右端点 j 区域可以踩的最多格子数。若不能走到,则状态为 0

  • gi,j(i,j{0,1}) 表示从右端点 i 区域走到左端点 j 区域可以踩的最多格子数。若不能走到,则状态为 0

  • ai(i{0,1}) 表示从左端点 i 区域开始最多能走多远。

  • bi(i{0,1}) 表示从右端点 i 区域开始最多能走多远。

  • l 表示左端点的地图形状。

  • r 表示右端点的地图形状。

信息可以这样合并,大概就是考虑是否走过区间中点,以及从哪个区域走过区间中点:

#define str string
#define co const
#define mst memset
struct node { 
    int f[2][2], g[2][2], a[2], b[2]; str l, r; bool e; 
    node(str l_ = "", str r_ = "", bool e_ = 1) {
        l = l_; r = r_; e = e_; 
        mst(f, 0, sof f); mst(g, 0, sof g); mst(a, 0, sof a); mst(b, 0, sof b);
    }
    node operator+(co node o) const {
        if (e) return o; if (o.e) return *this; node ret(l, o.r, 0);
        for (int i = 0; i < 2; ++i) {
            ret.a[i] = a[i]; ret.b[i] = o.b[i];
            for (int j = 0; j < 2; ++j) {
                for (int k = 0; k < 2; ++k) {
                    bool op = (r[k] == '.' && o.l[k] == '.');
                    if (op && f[i][k] && o.f[k][j]) 
                        ret.f[i][j] = max(ret.f[i][j], f[i][k] + o.f[k][j]);
                    if (op && o.g[i][k] && g[k][j])
                        ret.g[i][j] = max(ret.g[i][j], o.g[i][k] + g[k][j]);
                }
                bool op = (r[j] == '.' && o.l[j] == '.');
                if (op && f[i][j]) ret.a[i] = max(ret.a[i], f[i][j] + o.a[j]);
                if (op && o.g[i][j]) ret.b[i] = max(ret.b[i], o.g[i][j] + b[j]);
            }   
        }
        return ret;
    }
};

对于单点,有初始化:

void upd(node &o, co str s) { // 将叶子 o 的地图改成 s。
    o = node(s, s, 0);
    for (int i = 0; i < 2; ++i) 
        if (s[i] == '.') o.f[i][i] = o.g[i][i] = o.a[i] = o.b[i] = 1;
    if (s[0] =='.' && s[1] == '.') 
        o.f[0][1] = o.f[1][0] = o.g[0][1] = o.g[1][0] = 
        o.a[0] = o.a[1] = o.b[0] = o.b[1] = 2;
}

只需要支持跳链查询、单点修改即可。注意跳链查询要翻转一条链。

时间复杂度为 O(mlog2n),空间复杂度为 O(n)

提交记录 代码

结合斐波那契数列(矩阵快速幂)

洛谷 P5138 fibonacci

  • 定义 fi 为斐波那契数列第 i 项,满足 f0=0,f1=1,fi=fi2+fi1(i>2)(同时定义 i<0fi=fi+2fi+1)。

  • 给出以 1 为根的 N 个点的树,每个点初始权值为 0M 次操作,两种类型:

    • x k,将以 x 为根子树内的所有点 u,将其权值加上 fdudx+k

    • x y,查询 x,y 路径上点的权值和,模 109+7

  • N,M105k1018

需要用到的前置知识:

Ⅰ - i<0 时,fi=(1)i1×fi

证明

考虑归纳。

  • 易证当 i=1i=2 时成立。

  • 假设在 ki2 时成立,则当 i=k1 时:

    • k 为奇数,则 k+1k1 均为偶数,k2 为奇数。有:

      fk1=fk+1fk=fk1fk=(fk1+fk)=fk+1=(1)k2×f(k1)

      成立。

    • k 为偶数,则 k+1k1 均为奇数,k2 为偶数。有:

      fk1=fk+1fk=fk1(fk)=fk1+fk=fk+1=(1)k2×f(k1)

      成立。

证毕。

Ⅱ - fm+n=fm1×fn+fm×fn+1

证明

首先 fm1×fn+fm×fn+1=fn1×fm+fn×fm+1

考虑做差:

fm1×fn+fm×fn+1(fn1×fm+fn×fm+1)=fm×(fn+1fn1)fn×(fm+1fm1)=fm×fnfn×fm=0

所以上面那个式子是成立的。

先钦定 m,n 为自然数。同样考虑归纳。

  • 易证 m=0n=0m,n[0,1] 时均成立。

  • 假设在 m,n[1,k] 时成立,则当 m,n[1,k+1] 时,在原值域上新增了三种情况:

    • m=k+1,n[1,k] 时:

      fm+n=fm+n2+fm+n1=fm1+n1+fm1+n=fm2×fn1+fm1×fn+fm2×fn+fm1×fn+1=fm2×(fn+1fn)+fm1×fn+fm2×fn+fm1×fn+1=fm2×fn+1fm2×fn+fm1×fn+fm2×fn+fm1×fn+1=fm1×fn+(fm2+fm1)×fn+1=fm1×fn+fm×fn+1

      成立。

    • m[1,k],n=k+1 时(其实本质相同,但是为了完整还是写了):

      fm+n=fm+n2+fm+n1=fm1+n1+fm+n1=fm2×fn1+fm1×fn+fm1×fn1+fm×fn=fm2×fn1+fm1×fn1+fm1×fn+fm×fn=fm×fn1+fm+1×fn=fm1×fn+fm×fn+1

      成立。

    • m=n=k+1 时:

      fm+n=fm+n2+fm+n1=fm+n2+fm+n3+fm+n2=fm1+n1+fm1+n2+fm1+n1=fm2×fn1+fm1×fn+fm2×fn2+fm1×fn1+fm2×fn1+fm1×fn=fm1×fn+fm2×(fn1+fn2+fn1)+fm1×(fn+fn1)=fm1×fn+fm2×fn+1+fm1×fn+1=fm1×fn+fm×fn+1

      成立。

因此当 m,n[0,) 时成立。

接下来考虑负数的情况:

假设 m,n[k,) 时成立,则当 m,n[k1,) 时,同样新增三种情况:

  • m=k1,n[k,) 时:

    fm+n=fm+n+2fm+n+1=fm+1+n+1fm+1+n=fm×fn+1+fm+1×fn+2fm×fnfm+1×fn+1=fm×(fn+1fn)+fm+1×(fn+2fn+1)=fm×fn1+fm+1×fn=fm1×fn+fm×fn+1

    成立。

  • m[k,),n=k1 时:

    fm+n=fm+n+2fm+n+1=fm+1+n+1fm+n+1=fm×fn+1+fm+1×fn+2fm1×fn+1fm×fn+2=fn+1×(fmfm1)+fn+2×(fm+1fm)=fm2×fn+1+fm1×fn+2=fm2×fn+1+fm1×(fn+fn+1)=fm2×fn+1+fm1×fn+fm1×fn+1=fm1×fn+(fm2+fm1)×fn+1=fm1×fn+fm×fn+1

    成立。

  • m=n=k1 时:

    fm+n=fm+n+2fm+n+1=fm+n+2(fm+n+3fm+n+2)=fm+n+2fm+n+3+fm+n+2=fm+1+n+1fm+2+n+1+fm+1+n+1=fm×fn+1+fm+1×fn+2fm+1×fn+1fm+2×fn+2+fm×fn+1+fm+1×fn+2=fm×fn+1+fm×fn+1+fm+1×(fn+2fn+1)(fm+2fm+1)×fn+2=fm×fn+1+fm×fn+1+fm+1×fnfm×fn+2=fm×fn+1fm×(fn+2fn+1)+fm+1×fn=fm×fn+1fm×fn+fm+1×fn=(fm+1fm)×fn+fm×fn+1=fm1×fn+fm×fn+1

    成立。

因此当 m,n(,) 时成立,证毕。

有了这两个结论以后,就可以开始做这道题了。

求斐波那契数列想到矩阵快速幂。路径查询问题先想树剖。将以 x 为根子树内的点 u 权值加上 fdudx+k,相当于加上 fdu1×fkdx+fdu×fkdx+1。将 fdu1fdu 分开维护(分别记为 a,b),发现系数是常数,可做。对树剖后的序列建线段树,修改操作就是将子树在序列上对应的区间的两个系数区间加,维护区间权值和 suma 值的和 vab 值的和 vba 值的系数懒标记 tgab 值的系数懒标记 tgb

ls 为左子,rs 为右子,x 为当前节点(和输入的不同)。下传就是:

void down(int x){
	if(tga[x]){//需要下传。
		sum[ls]=(sum[ls]+va[ls]*tga[x]%M)%M;//答案加上 va[ls]*tga[x],注意这里要拿 a 值的和来乘,因为 tga[x] 是当前区间的系数,区间内每一项都要乘上这么多,根据乘法分配律就是给和乘上这么多。下面同理。
		tga[ls]=(tga[ls]+tga[x])%M;
		sum[rs]=(sum[rs]+va[rs]*tga[x]%M)%M;
		tga[rs]=(tga[rs]+tga[x])%M;
		tga[x]=0;
	}
	if(tgb[x]){
		sum[ls]=(sum[ls]+vb[ls]*tgb[x]%M)%M;
		tgb[ls]=(tgb[ls]+tgb[x])%M;
		sum[rs]=(sum[rs]+vb[rs]*tgb[x]%M)%M;
		tgb[rs]=(tgb[rs]+tgb[x])%M;
		tgb[x]=0;
	}
}

查询就是跳链查询。

时间复杂度为 O(Mlog2N),空间复杂度为 O(N)

评测记录 代码

离线技巧

洛谷 P4312 [COI2009] OTOCI / SP4155 OTOCI - OTOCI

  • 给出一个 n 个点的图,一开始所有点都是孤点,点 i 有权值 wi。有三种操作,共 q 次:

    • bridge u v,查询点 u,v 是否连通,若不连通,则在两点之间连边。

    • penguins u x,将 wux

    • excursion u v,查询点 u,v 路径上的点权和,若不连通,输出 impossible

  • n3×104q3×105

由于每次在不连通的点之间连边,因此最终图的形态肯定是一个森林,且每次询问的路径,若两点连通,他们的路径一定在森林上。因此考虑处理出 impossible 的答案,然后先建出森林并树链剖分,将询问离线,用树状数组维护信息并差分计算答案。

时间复杂度为 O(qlog2n),空间复杂度为 O(n+q),可以接受。

评测记录 代码

习题

Ⅰ - 洛谷 P2542 航线规划

  • 给出一个 n 个点 m 条边的无向图,支持两种操作:

    • u v,询问 u,v 两点间的割边数。

    • u v,断掉 u,v 之间的边。

  • 记操作总数为 qn3×104m105q4×104

删边不好维护,考虑离线倒序操作,变成加边操作和查询操作。回忆一下静态的两点间割边数怎么做,先建出边双树,然后答案就是两点边双连通分量之间的树上距离。故先离线建出边双树,树上边权置 1,并树剖、边转点。然后每加一条边,相当与两点边双间的割边全部失效,区间推平成 0,然后查询就是两点边双路径上的边权和。

来理解一些细节:

  • 每次加边后,边双树的形态应当发生改边,但是两点树上路径可能包含失效的割边。其实是没有影响的,两点间存在失效的割边相当于已经存在了 0 边,再次推平成 0 并不影响,并且也可以把真正的割边置 0

  • 查询会受到非割边的影响吗?其实是不会的,考虑那些 0 边,应当将这些边构成的极长链全部缩成一个点,然后统计剩下 1 边的贡献。但是在树上查询的时候,1 边的贡献已经存在,非割边权为 0 对答案没有贡献。故这样做是对的。

时间复杂度为 O(qlog2n),空间复杂度为 O(n+m+q)

评测记录 代码

posted @   lzyqwq  阅读(226)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效
点击右上角即可分享
微信分享提示