浅谈线段树

1 前言

线段树一直是高频考点,可以直接出也可以作为数据结构优化其他算法。这里我只想说说线段树的基本理解以及如何构造,也就是如何写出信息和标记,信息之间的合并,标记之间的复合,信息和标记之间的复合。以及矩阵的辅助理解,区间最值、历史版本相关问题。

2 线段树

线段树运用了分治的思想,将每个大区间分成两个小的区间。每个区间都有自己的信息,为儿子两个区间信息的合并。

2.1 单点修改#

如果我们有这样一个问题:

  1. 单点加
  2. 区间和

在线的情况下,如果是暴力,单点修改是 O(1),但区间和是 O(n)。而用了线段树就将复杂度平均了,均为 O(logn)

这是单点修改的情况。但出现区间修改,定位到每个点的话,单次复杂度就是 O(nlogn),不如暴力。

2.2 区间修改#

我们先需要找到线段树上所有完全包含于目标区间的极大区间,修改这些即可。此时要是能用线段树,信息要能够直接计算出来。至于上面的节点回溯时合并信息即可。

如果所有询问都与这些极大区间以下的节点无关,那么答案还是正确的。

但是下面的节点怎么办?它们没有立马被修改操作所影响。此时可以在极大区间上打一个标记,表示该区间以下的所有节点都没有被标记所影响。

如果询问或修改下面的节点,就把标记下传,同时更新出应有的信息,此时的答案和修改就是正确的。

这样就将修改均摊给了所有询问。但此时也面临着问题。我们需要找到一种标记的描述,使得任何时刻它都等价于还未下传的修改造成的影响,下传后能够直接计算出子节点当前的信息。同时,如果下传时遇到了另一个标记(此时一定有先后顺序,由标记的下传的时刻可以得出,即下传的一定在后面),那么两个标记要能够复合成一个,这个新的标记等价于两个标记先后对区间的影响。

如果能够找到这样的标记,可以与信息复合、与标记复合,那么就可以用普通线段树实现。

2.3 标记与信息#

抽象的说,信息与标记都是一个半群,设信息为 D,标记为 M,而我们要思考的就是 D+DDD×MDM×MM

有些题目难在信息的合并,有些难在标记的复合。而标记的复合一般来说会更难,因为并不是简单的相加,而是将两个标记融为一体,如果你的标记内容过少,就会难以复合成新的标记。

信息一般至少要有询问的答案,而复合一般是增量。举个不算难的例子:

  1. 区间加
  2. 区间乘
  3. 区间覆盖
  4. 区间询问和

先考虑加乘。显然信息就是区间和。那么标记可以只维护单单一个增量吗?难以复合。因为加乘会相互影响,具体的:((a×b+c)×d+e=bda+cd+e。所以我们至少要维护两个量,并规定两者的先后顺序。我们规定标记为 (a,b) 表示所有数先整体乘 a 再加 b。那么上面可以写成 (b,c)×(d,e)=(bd,cd+e)。看出来了吗?系数就是两个乘法标记相乘,而加法标记也可以直接写出。

为什么是先乘后加?(a+b)×c+d 会被认为 (a+b+d)×c,如果要等价就要写成 (a+b+dc)×c。涉及除法影响精度。

再考虑覆盖操作,只用一个标记 c 表示覆盖的数即可。一般认为先加乘再覆盖,所以最终的标记就是 (a,b,c) 表示先乘再加最后覆盖。写成代码:

tag merge(tag x, tag y) { //b->a
 	if(x.c) { //如果前面有覆盖操作,那么整个操作就可以看成复合
 		x.c = x.c * y.a + y.b;	
	} else {
		x.a *= y.a;
		x.b = x.b * y.a + y.b;
	}
	if(y.c) x.c = y.c; //如果后面有覆盖,最后就覆盖了
}

2.4 矩阵#

如果能够将信息看作向量,每一个修改看作一个矩阵,那么用线段树直接维护矩阵是第一选择。为什么可以这样做?因为矩阵天然满足结合律,所以所有标记的复合就是矩阵乘法,不需要思考。

普通的矩阵是 (×,+) 矩阵,广义矩阵可以是 (+,max) 同样有结合律。而广义矩阵通常用在维护最值问题中。矩阵直接用一般来说常数会很大,是因为暴力矩乘的原因,但它本质上就是维护一堆标记。所以找到矩乘中冗余的转移,手动做矩乘不会超时了。

所以说矩阵是方便我们推式子和理解,写的时候不一定要直接写。

例题:P4314 CPU 监控[NOIP2022] 比赛

3 区间最值相关

一个例题:

  1. 区间取最小值
  2. 区间加
  3. 求区间和

这类问题的特点就是修改操作有区间取最值,定位到的区间无法快速统计信息。考虑维护区间最大值 mx 与严格次小值 se。然后对于 1 操作 x 分类讨论:

  1. 如果 mxx,那么没有任何影响,退出。
  2. 如果 se<mx<x,那么唯一修改的就是所有最大值,此时维护一下区间最大值数量 ct 就可以快速维护总和。
  3. 否则继续向下递归。

可以证明这样的复杂度是 O(nlog2n) 的。

这样做,标记怎么构造?我们将数分为最大值和非最大值,每一部分有不同的增量,分别维护即可。具体的,维护 (a,b) 表示最大值的增量与非最大值的增量,下传时注意最大值的增量只能下传给两个儿子的最大值(相同则一起下传),如果不是最大值就下传非最大值的增量。

void pd(int u) {
	int mx = std::max(t[u << 1].mx, t[u << 1 | 1].mx);
	if(mx == t[u << 1].mx) {
		mdf(u << 1, add1[u], add2[u]);
	} else mdf(u << 1, add1[u], add2[u]);
	if(mx == t[u << 1].mx) {
		mdf(u << 1 | 1, add1[u], add2[u]);
	} else mdf(u << 1 | 1, add1[u], add2[u]);
}

如果询问多一个区间最大值,因为操作 1 定位到的区间最值一定会修改成 x,子节点也是 x,并且区间加并不影响该区间最值,所以也容易快速维护。

这里包含最值操作的主要思想就是将最值操作转变为了加减操作,是我们熟悉的。

4 历史版本问题

每次询问不止问当前的信息,还需要询问历史信息相关。可以看作多了一个数组 B 需要维护。

通常维护的历史版本信息有历史最大值、历史最小值、历史版本和。

而历史最值的维护方法一般要维护最大/小版本增量标记。

4.1 不含最值操作#

询问是历史最大值、最小值,可以用普通线段树维护,如 P4314 CPU 监控

对于修改是区间加减,求历史最大值的和、历史最小值的和、历史版本和。这里有三种转化方法。

4.1.1 历史最大值#

定义数组 Ci=AiBi,此时 Ci 显然是非正的,一次区间加减操作对 C 的影响便是 min(Ai+x,0)

那么维护 C 数组就是上文提到的问题了,吉司机线段树实现,复杂度 O(nlog2n)

4.1.2 历史最小值#

同理,定义数组 Ci=AiBi,此时 Ci 显然是非负的,一次区间加减操作对 C 的影响便是 max(Ai+x,0)

复杂度 O(nlog2n)

4.1.2 历史版本和#

这个根本不涉及最值操作,直接打标记就可以做了。复杂度 O(nlogn)

4.2 含有区间最值操作#

如果含有区间最值操作,就用吉司机线段树将最值操作化为区间加减问题,就可以打 tag 维护了。

posted @   Fire_Raku  阅读(63)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示