Loading

浅谈线段树

1 前言

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

2 线段树

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

2.1 单点修改

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

  1. 单点加
  2. 区间和

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

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

2.2 区间修改

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

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

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

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

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

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

2.3 标记与信息

抽象的说,信息与标记都是一个半群,设信息为 \(D\),标记为 \(M\),而我们要思考的就是 \(D+D\rightarrow D\)\(D\times M\rightarrow D\)\(M\times M\rightarrow M\)

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

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

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

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

为什么是先乘后加?\((a+b)\times c+d\) 会被认为 \((a+b+d)\times c\),如果要等价就要写成 \((a+b+\frac{d}{c})\times 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 矩阵

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

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

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

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

3 区间最值相关

一个例题:

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

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

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

可以证明这样的复杂度是 \(O(n\log^2 n)\) 的。

这样做,标记怎么构造?我们将数分为最大值和非最大值,每一部分有不同的增量,分别维护即可。具体的,维护 \((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 历史最大值

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

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

4.1.2 历史最小值

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

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

4.1.2 历史版本和

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

4.2 含有区间最值操作

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

posted @ 2024-11-21 16:28  Fire_Raku  阅读(3)  评论(0编辑  收藏  举报