【学习笔记】线段树

满二叉树的节点个数是 \(2n-1\)。如果叶子节点保证不会 pushdown 开两倍空间就够了。同时注意与线段树相同的分治树结构也是两倍,节约很多空间。

1.扫描线

不想贴图。

把这些矩形贴在一起,然后我们发现这些矩形的侧边把整个图割成了 \(2n\) 块(假装没有边合一起,有也没问题),每块都是矩形。

所以我们考虑线段树维护每一个侧边被覆盖的长度,移到下一个侧边就是这个覆盖长度乘上移动长度。

线段树维护这个线段的贡献,以及被完全覆盖的次数。碰见左侧边就让对应区间加一,碰见右侧边就让对应区间减一。

维护这个线段的贡献和这个线段被完全覆盖的次数。

在 pushup 的时候我们如果被完全覆盖过就直接不管儿子了。因为查询只有整体,而且增加和删除是对应的,根本不需要 pushdown。

和面积不一样的在于两个线段贴到一起中间那个是被消掉的。

所以多维护几个东西代表左右端点是否被覆盖以及这个线段有多少个不相交的线段。

2.线段树合并

P4556 雨天的尾巴

首先我们可以把链上操作转成树上差分。然后我们对每个节点开一棵权值线段树。朴素的树上差分都是开一个桶然后加和,但是这里开的是线段树。

所以就有了线段树合并。

在把 \(y\) 并到 \(x\) 的过程中,如果 \(x\) 本身没有一个 \(y\) 有的节点就可以直接把 \(y\) 的那个节点接上去节省时间。

就没了。

inline void merge(int x,int y){
	if(!sg[x].ls&&!sg[x].rs&&!sg[y].ls&&!sg[y].rs){//并到叶子节点了直接两个合起来
		sg[x].sm+=sg[y].sm;
		return;
	}
	if(sg[y].ls){//如果要并过来的没有左儿子就不用往左边合并了
		if(sg[x].ls)merge(sg[x].ls,sg[y].ls);
		else sg[x].ls=sg[y].ls;
	}
	if(sg[y].rs){
		if(sg[x].rs)merge(sg[x].rs,sg[y].rs);
		else sg[x].rs=sg[y].rs;
	}
	sg[x].sm=sg[sg[x].ls].sm+sg[sg[x].rs].sm;//更新
	return;
}

首先很容易看得出来单次合并的复杂度是 \(O(重合大小)\) 的。

那么对于 \(n\) 棵最开始只有一个元素的线段树,合并起来的复杂度是 \(O(n\log n)\)。这个很显然,因为就算所有元素都相等每次都跑满也只有 \(\log n\) 次。

注意线段树合并只能用于动态开点,因为普通线段树本来就是满的。

关于另一种带返回值的线段树合并,本质上就是返回更新后的节点编号,需要代码直接看 这份提交

空间如果卡得紧可以试着用 回收节点

至于 带转移的线段树 实际上也是一样的,把转移的东西放在合并里面,在某个节点为空的时候就代表转移转完了,打上一个 tag 之类的。注意合并的时候要 pushdown。

发现 \(x\)\(y\) 合并之后线段树的结构被破坏,比如如果我把 \(y\) 接到 \(x\) 的左儿子,再一次更新 \(x\) 的时候它更新左儿子那么 \(y\) 就会接受一个并不属于自己的更新。要求更新完之后 \(x\) 包含了 \(y\) 的信息,但是 \(y\) 仍然能够独立使用。当然我们可以把询问离线下来,这样 \(y\) 被破坏结构也跟我们没有关系(上面的板子题的做法)。如果强制在线我们可以考虑合并两个节点的时候新开一个节点储存这两个节点的信息。

Il int merge(int x,int y,int l,int r){
    if(!x||!y)return x|y;
    int p=++si,mid=(l+r)>>1;
    if(l==r){sm[p]=sm[x]+sm[y];return p;}
    ls[p]=merge(ls[x],ls[y],l,mid);
    rs[p]=merge(rs[x],rs[y],mid+1,r);
    pup(p);
    return p;
}

3.线段树分裂

P5494 【模板】线段树分裂

在分裂函数里面传当前节点,就是一边分裂一边建树。复杂度和区间查询一样。

inline void split(int ql,int qr,int &x,int &y,int l,int r){
	if(!x||qr<l||r<ql)return;
	if(ql<=l&&r<=qr){//就是要割的
		y=x;
		x=0;
		return;
	}
	if(!y)y=build();
	int mid=(l+r)>>1;
	if(ql<=mid)split(ql,qr,sg[x].ls,sg[y].ls,l,mid);
	if(qr>mid)split(ql,qr,sg[x].rs,sg[y].rs,mid+1,r);
	sg[x].sm=sg[sg[x].ls].sm+sg[sg[x].rs].sm;
	sg[y].sm=sg[sg[y].ls].sm+sg[sg[y].rs].sm;
	return;
}

4.线段树优化建图

CF786B Legacy

注意这个东西在最基本的情况下也有 \(8n\) 个点,边数可能达到 \(8n+q\log n\),注意复杂度。

我们考虑开线段树维护这个东西。令当前节点为 \(p\),它的左右儿子为 \(ls,rs\),它的父节点为 \(f\)

首先对于一个维护 \([l,r]\) 的节点,我们发现我们什么都不用维护。那么肯定它能够通往它的子节点。所以给 \(p\rightarrow ls,rs\)

然后因为子节点能够通往父节点,所以还要 \(p\leftarrow ls,rs\)

因为是包含关系并不是实际边所以边权设为 \(0\)

很明显这样会出问题。

这样子我们任何一个叶子节点到根节点的权值都是 \(0\),而且路径是双向的,所以任何一个节点到另一个节点的权值都是 \(0\)

问题出在路径是双向的地方。我们尝试开两个线段树解决这个问题。

第一棵线段树 \(a\) 维护点到区间,在这棵线段树上我们的子节点连向父节点;第二棵线段树 \(b\) 维护区间到点,父节点连向子节点。

因为它们的叶子节点是同样的点,所以可以直接当同一个。我们尝试使用动态开点线段树,这样叶子节点的编号就是对应在数组的编号,比较好操作。

那么对于普通连边我们直接连就好。对于连区间的,我们搞到对应区间的编号然后和对应点连一连就好。

把每个点当成普通点跑就好了。

但是注意点向区间 连边\(a\) 线段树的 父节点连向子节点。原因不清楚,但是如果你连反了在原本的线段树上路径就是 \(0\) 你再加这一条边就没有用了吗不是。

一开始连线段树的边数就有大致 \(8n\) 条边,然后每次连边最多可以达到 \(\log n\) 边,所以总边数达到了惊人的 \(8n+q\log n\)


当然我们还能再加强。

  • 区间向区间连边。

直接连边复杂度是 \(log^2\) 的。

但是我们可以开一个虚点然后这两个对应区间都向虚点连边。

点数最多加 \(q\) 个。边数最多加 \(q\log n\) 条。大概空间开十倍就好了。

5.线段树分治

注意它和线段树其实关系并不大,本质其实是分治思想,长得非常像线段树而且给了 \(\log\) 的贡献而已。

P5787 二分图

一个图是二分图代表每条边两边端点染色不同。

显然除了 dfs 我们还有一种扩展域并查集的做法,对于每个点开正反两个点代表不同的颜色,合并边的时候直接把正 \(x\) 和反 \(y\) 合并,正 \(y\) 和反 \(x\) 合并就好。

为什么用并查集?因为它支持插入,在使用可撤销并查集(带秩合并)可以做到 \(log\) 删除。

我们对于一个时间轴开线段树,每个点维护一个 vector 表示覆盖这个点的边集,那么每条边能够做到 \(\log k\) 插入。

我们考虑统一处理答案。

对于区间 \([l,r]\) 的询问,我们如果在加入当前这条边时发现已经破坏二分图结构就可以直接跳出。

注意我们开栈维护加入的边然后在分治退出的时候把这些边一一删掉就可以更好的维护(?)。

其实线段树分治是在分治的时候维护一个其它的数据结构并且能够通过 \(\log\) 来处理所有的询问,为什么叫线段树分治(?)。

6.李超树

用于维护直线,在斜率之类的题目里面算是超级有用的存在。

我们考虑维护斜率,对于线段树节点 \(p:[l,r]\),其维护当 \(x=mid\) 时使 \(y\) 最大的一次函数。

更新时因为不好覆盖,tag 需要一直传下去。假设更新 tag 时更新的值比原节点不优(如果更优可以把两个一次函数交换,因为原节点也没有下传)。然后我们根据其能否在左右端点造成更优的贡献决定是否继续下传。

因为数学不好所以给个原因。对于左端点而言,因为中点已经不优,如果左端点也不优,代表这个一次函数在 \([l,mid]\) 范围内处于原最优一次函数的下面,不会被当成答案。右端点同理。

这个东西的复杂度是 \(O(\log n)\) 的,因为在你中点被原最优盖住的情况下,最多只有一边能更优,也就是说你的 pushdown 是单点查询的复杂度的。

如果插入线段是 \(O(\log^2 n)\),插入直线不需要拆区间就是 \(O(\log n)\)

运用了标记永久化的思想,查询时记得边走边查。

李超树 支持合并,做法是无论是否是叶子节点都要把 y 对应的直线在 x 里进行 pushdown(这里假设目标是把 y 并到 x 上)。注意一定要先把左右儿子更新再传直线,否则可能出现左右儿子更改导致 pushdown 失效的情况。由于注意到一条直线如果下传那么最多下传 \(\log V\) 次就会到达叶子然后被删除,所以你 pushdown 不会超过 \(O(m\log V)\)。同时线段树合并的复杂度是 \(O(n\log n)\),所以李超树的合并理论上也是单 log 的,但是实际效果据说和 \(log^2\) 差不多。

李超树动态开点的空间只有 \(O(n)\),因为你相当于在所有节点进行了标记永久化没有必要递归到叶子节点,碰到一个非空的节点新建一个节点直接返回就好了,每次对空间的开销只有 \(O(1)\)

7.套矩阵

只是一种 trick。由于矩阵需要满足结合律,线段树刚好也可以满足结合律,所以我们可以把线段树和矩阵套起来维护。

转移时注意矩阵没有交换律是左儿子乘右儿子还是右儿子乘左儿子。

递归里面传矩阵作为变量常数过大,可以把矩阵在线段树外面更新然后更新的时候只需要传位置。

8.势能线段树

重点在于势能的分析,会把遇到的在能力范围内可以分析势能的东西堆在这里。有些分析是自己胡的,错了不管。

8.1 区间开根

定义势能函数 \(\phi(i)\) 表示 \(i\) 需要多少次开根操作变成 \(1\),因为 \(1\)\(0\) 不需要进行开根操作或者开根后无变化。

注意到一个数 \(V\) 最多开 \(\log\log V\) 次就会变成 \(1\),于是整棵线段树的势能就是 \(O(n\log\log V)\)

记录一个区间的最大值,若是 \(1\) 则跳过这段区间,否则递归到叶子节点修改值,每次花费 \(O(\log n)\) 使得势能减 \(1\)

势能为 \(0\) 时无法再进行任何花费时间的操作,所以总时间复杂度为 \(O(n\log n\log\log V)\)

8.2 区间对一个数取 gcd

如果对于 \(a\leftarrow\gcd(a,x)\) 中有 \(a\not\mid x\),则 \(a\) 的值至少减半。于是整棵线段树的势能是 \(O(n\log V)\) 的。

只需要支持快速判断一个区间内是否所有数都能被一个数整除。维护 lcm 即可。维护 lcm 需要给复杂度多乘上一个 \(\log\)。可能可以通过一些与 gcd 和 lcm 有关的科技做到单 \(\log\)

8.3 区间取 min(取 max 同理)

对于线段树节点维护最大值 \(mx\),最大值个数 \(cnt\) 和严格次大值 \(sec\)

对于一次取 min 操作:

  • \(mx\le x\) 发现不需要更改。

  • \(sec\le x<mx\),发现只需要更改 \(mx\) 的位置,在这个位置上打一个 tag 然后依据我们维护的最大值个数更新需要维护的比如区间和之类的东西。对于这里的 tag 可以在 pushdown 的时候让子节点根据父节点维护的东西修改,就没有必要记录一堆东西用于 pushdown 还容易出错。

  • \(x<sec\) 递归下去更改。

前面两种操作的复杂度与普通线段树是类似的。

注意到最后一种操作会使得这一段的颜色个数至少减一。定义势能函数 \(\phi([l,r])\) 表示线段树上维护 \([l,r]\) 这一段的颜色个数。容易看出这棵线段树的势能还是 \(O(n\log n)\) 的。一次修改花费 \(O(\log n)\) 使得势能减 \(O(\log n)\),因为会改变路径上所有区间的势能。

复杂度 \(O(n\log n)\)

8.4 区间推平

对的这个玩意势能是正确的。那为什么隔壁 ODT 要数据随机,不懂。

你考虑维护这段区间是否全为一个颜色,如果是是哪个颜色。然后你修改的时候只有在这一段都是一个颜色时才打上修改 tag。

一次修改最多使颜色段个数增加 \(1\) 个,对应到线段树上就是只有 \(O(\log n)\) 个区间的势能加一。于是整棵线段树的势能上限为 \(O(n\log n+m\log n)\)

如果你对于一个包含在查询区间的段里面递归下去推平,注意到这一段的颜色个数至少减一,对应到线段树上就是有 \(O(\log n)\) 个区间的势能减一。

那么复杂度就是 \(O(n\log n+m\log n)\)

8.5 区间推平,区间整除一个数

考虑没有区间推平操作,一个数在特判 \(1\) 的情况下最多被整除 \(\log\) 次,所以一棵线段树的势能初始是 \(O(n\log n)\) 级别的。

然而一次推平操作会把每个点的势能都加上 \(\log n\)

于是把区间内数相等的区间的势能看作 \(1\),即看作其中只有一个数。在这一段内整除可以转化为覆盖标记。

一次推平只会有 \(O(\log n)\) 个区间势能加一。所以复杂度就对了。

9.zkw 线段树

一种适配范围不广但常数优秀的做法。个人感觉其实写起来并不是那么快。

zkw 线段树会将长度为 \(n\) 的序列从 \(n+2\) 的大小扩到 \(2^k\),即寻找一个 \(m=2^k\ge n+2\),为什么是 \(n+2\) 缘于 zkw 特殊的区间操作方式。由于是 \(2\) 的次幂很容易推导出线段树的节点,叶子节点编号为 \([m,2m)\),表示 \([l,l]\) 的编号为 \(m+l\),于是我们很容易得到单点的编号。可以依此初始化叶子节点的值然后通过递推直接建树。

对于单点操作,对应到线段树上为一条链,简单地找到叶子节点再往上更新即可。

对于区间操作,将区间 \([l,r]\) 扩张成 \((l-1,r+1)\),这也是为什么一开始建树要补成 \(n+2\),防止扩张区间时越界。定位到叶子 \(L=l-1+m,R=r+1+m\),则需要的区间在 \(L\) 的左边和 \(R\) 的右边。跳 \(L\)\(R\) 的时候判断 \(L\) 是否此时是左儿子,\(R\) 是否是右儿子可以知道哪些区间需要被统计。

可以看出其特殊的操作不多,优点仅在于使用递推常数较小以及码量小于常规线段树。

10.递归 pushup

如果只需要递归一边的儿子,那么你总的复杂度仍然是对的,只是乘上了一个 \(O(\log n)\)。其实只要提一嘴就会了,只是一般 pushup 都是线性不会往这块想。

posted @ 2024-01-29 11:10  If_miao  阅读(73)  评论(0)    收藏  举报