#笔记 线段树
线段树与树状数组 3
一、简单复习
线段树
线段树是一个二叉树结构。其核心函数为 FindRange 函数,基本写法如下:
function FindRange(L, R):
if (InRange(L, R)):
do sth.
return
else if (OutofRange(L, R)):
return
else:
ls->FindRange(L, R)
rs->FindRange(L, R)
若 do sth
的时间复杂度为 \(O(1)\),则上述函数的时间复杂度为 \(O(\log n)\),其中 \(n\) 是序列长度。在初等阶段,线段树的所有操作的本质都是 FindRange 函数,操作的复杂度正确性也都来源于上述结论。
线段树的总结点数不超过 \(2n - 1\)。即如果单点信息量为 \(O(1)\),则其空间复杂度为 \(O(n)\)。
树状数组
树状数组是一个用来维护序列前缀和,并支持进行单点加的数据结构。其主要写法为:
function lowbit(x):
return x & -x
function upd(p, w):
while p <= n:
a[p] += w
p += lowbit(p)
function qry(p):
ret = 0
while p:
ret += a[p]
p -= lowbit(p)
return ret
对于高维度的前缀和,树状数组只需要在外面多套几层循环即可。
动态开点线段树
动态开点线段树的思想在于,当序列长度过长,但是有效操作过少时,线段树上实际被用到/修改到的结点个数是比较少的,如果把树完整的建出来,会消耗大量的空间,并且产生大量无用的空结点。为了避免这个问题,可以初始时只建立树根。在 FindRange 运行过程中,如果需要用到子节点,并且子节点不存在,再新建子节点。这样的空间复杂度为 \(O(\min(n, m \log n))\),其中 \(m\) 是操作个数,\(n\) 是序列长度,\(\min\) 的第一项带有二倍常数。可以发现,动态开点线段树的时空复杂度在任何情况下必定不劣于普通线段树。但是因为判定较多并且内存不连续,在 \(m \log n \geq n\) 时,其时间常数可能略大于普通线段树。
如果有 pushdown 函数的话,开子节点的操作可以在 pushdown 中完成,否则需要在递归孩子时进行判断。
可持久化线段树
对于一个数列,有若干次操作,每次操作会对数列进行单点修改,称为新建一个版本。可以用可持久化线段树来维护数列的每个版本。具体来说,注意到每个版本相对于上一个版本都只有一个位置不同,对应到线段树上,则一个版本的线段树只会有一个叶节点到根的链上的信息与上个版本不一致,其他版本完全相同,因此每次新建一个版本时,对于需要被修改的子树,递归新建,对于另一侧的子树则直接用指针指到上一个版本的对应孩子。因为任何一个叶节点到根的路径长度都是 \(O(\log n)\),所以每次只会新建 \(O(\log n)\) 个结点。因此其时空复杂度均为 \(O(m \log n + n)\),其中 \(m\) 是操作个数,\(n\) 是序列长度。注意第二项有两倍常数,实际内存池大小应该开 \(m \log n + 2n\)。
二、有关 FindRange 时间复杂度的证明
注意到在线段树上的任何一层的所有结点运行 FindRange 函数,最多仅有两个节点同时不满足 InRange 和 OutofRange,即两个包括了操作左右端点 \(L, R\) 的结点。剩下的结点显然要么满足 InRange,要么满足 OutofRange,
因此对于线段树上的每一层,最多有两个结点向下新建递归函数,而线段树上的总层数为 \(O(\log n)\),因此只会新建 \(O(\log n)\) 个函数。因此 FindRange 函数的时间复杂度为 \(O(\log n)\)。通过上述分析可以发现,因为每个节点向下新建的是两个递归函数,所以 FindRange 操作实际上带有两倍常数。
三、树状数组与线段树的对比
树状数组的功能被线段树完全包含。在我所知道的范围内,树状数组能够完成的操作,线段树一定可以以不劣于树状数组的时空复杂度完成。
简单起见,我们这里分析的线段树是不进行动态开点的一般线段树。
时间常数
显然树状数组的单次查询时间复杂度为 \(O(p_1)\),修改的时间复杂度为 \(O(p_0)\) ,其中 \(p\) 是修改/查询的位置,\(p_0, p_1\) 分别表示二进制下 \(0,1\) 的个数。当 \(p\) 在 \([1, n]\) 范围内均匀随机时,可以认为 \(p_0 = p_1 = \frac 1 2 \log n\)。因此虽然树状数组的时间复杂度仍然为 \(O(\log n)\),不过随机意义下其常数为 \(\frac 1 2\)。当 \(p\) 不是随机值而是构造值时,可以给 \(p\) 加上一个恒定偏移量。即自行取一个与 \(n\) 同阶的常数 \(x\),将所有操作的 \(p\) 都改为 \(p + x\)。可以证明,此时期望 \(p_0 = p_1 = \frac 1 2 \log n\)。需要指出的是,如果对于所有的操作位置,所加的偏移量相同,虽然其期望常数为 \(\frac 1 2\),但是显然极差极大(因为如果 \(x\) 选崩了可能毫无效果),因此可以对于每个操作位置,独立选择一个偏移量 \(x\)。只需要保证操作位置加上偏移量后大小关系不变即可。
需要指出的是,上述分析都是理论情况,一般情况下,树状数组不需要实现上述优化。
因此,树状数组总存在 \(\frac 1 2\) 的常数。与之相比,线段树无论是建树还是 FindRange 函数,都带有两倍常数。因此在不考虑其他操作的情况下,线段树的常数是树状数组的四倍。事实上在实现时,由于线段树存在大量的递归调用,并且内存访问不连续,其时间常数会更大。而树状数组因为内存访问连续,对 cache 比较友好,其时间常数会更小。
当树状数组进行区间查询时,由于需要进行一次前缀相减操作,也即一次查询进行两次操作,看起来二分之一常数会被抵消,但是事实上有一些奇技淫巧可以降低该操作的常数。具体可以看这篇日报。
综上分析,树状数组的时间常数优于线段树,且优势比较明显。
空间常数
线段树的节点数为 \(2n - 1\),而树状数组的节点数为 \(n\)。在节点数上,树状数组优于线段树。
由于线段树每个节点还要存储当前节点的左右端点,左右孩子等信息,其实际空间常数更大。而树状数组对于每个节点只需要维护结点对应的权值和,空间常数小于线段树。
综上分析,树状数组的空间常数优于线段树。
题外话:如果线段树被卡空间了,可以考虑不在节点里面维护区间左右端点,而是递归时作为参数进行递归。这样虽然会增大时间常数,但是可以节约 \(4n\) 个信息。
代码复杂度
肉眼可见,树状数组的码量远小于线段树。
局限性
在一般情况下,只进行前缀查询与单点修改的树状数组必须满足信息具有可减性。例如,前缀最大值无法使用树状数组维护。因为把一个结点的值改小以后,无法撤销原值对后面结点造成的贡献。特殊情况是,如果对每个位置的修改都保证新值不小于原值,则可以使用树状数组维护最大值,因为原值不会再对后面节点产生贡献。
进行区间查询与单点加(这里的加是广义加,指无需撤销原信息对后面节点造成的贡献的情况,例如在保证新值不小于原值时用树状数组维护最大值)的树状数组必须满足信息的前缀可减性。例如,即使每个位置的修改都越改越大,也无法使用树状数组维护区间最大值。因为树状数组维护的是前缀和(这里和指 \(\max\)),但是 \([l, r]\) 的最大值无法通过 \([1, r]\) 最大值与 \([1, l)\) 的最大值得到。树状数组能处理的比如进行单点修改和区间求和。因为 \(sum_{l, r} = sum_{1, r} - sum_{1, l - 1}\)。
而线段树不受上述限制。只要被维护的信息满足结合律(即可以通过两个孩子的信息合并成父节点的信息),就可以进行区间查询。如果只进行单点查询的话,甚至不需要信息满足结合律(因为单点查询一定会在叶结点处返回有效信息,而不需要把信息合并到非叶节点,一个典型的例子是可持久化线段树的模板题只有叶子上维护了信息,不需要写 pushup)。
树状数组只能处理单点修改和前缀查询的操作,在特殊的情况下(即其前缀和有可减性时),才能支持单点修改与区间查询。在另一种特殊的情况下(即序列可差分)在能进行区间加和单调查询。在更加特殊的情况下,才能进行区间修改与区间查询(这种情况本质上是讨论一种修改对一个查询的贡献,没有了解的必要。如果想要了解可以看这个链接)。
而线段树不受上述限制。只要可以区间打标记就可以区间修改,只要信息可合并就可以进行区间查询,且二者独立互不影响,在二者都成立时,可以同时进行区间修改和区间查询。
综上分析,线段树的普适性高于树状数组。树状数组在使用时存在各种局限。
动态开点
这里的动态开点是指对于操作数 \(m\) 和序列长度 \(n\),\(m \log n \lt n\),即动态开点有意义时的讨论。
线段树可以轻松做到动态开点,并且时间常数虽然略有增加,但是不会太大。
树状数组的动态开点需要借助 hash 进行。具体的方式将在下文讨论。经过笔者亲身测验,使用 hash/std::unordered_map 维护的树状数组,其常数远大于动态开点线段树。并且因为需要实现 hash,并且要对查询修改进行更改,所以其码量并不会比线段树小多少。
但是在实现树套树时,线段树需要实现两颗线段树(一般是外层普通线段树,内层动态开点线段树,外层序列树,内层权值树)。此时其码量极其惊人(因为要实现两棵树之间相互影响的接口,其码量远大于分写两棵独立线段树),而树状数组只需要多加一个 for 循环(即二维树状数组),此时的码量是远小于线段树套线段树的。如果比赛时时间仍然不够,可以直接用 std::map 代替数组,这样时间上又多了一个 \(\log\),但是码量和一个普通树状数组无异。
需要说明的是,在实现树套树时,动态开点二维树状数组的常数表现奇差。通常只能跑 \(n = 3 \times 10^4\) 左右的数据范围。而线段树套线段树可以轻松跑过 \(10^5\)。
综上分析,在一维情况下,动态开点线段树优于树状数组,但是在更高维度时,动态开点树状数组的码量占有明显优势,但是得分偏低。
可持久化
因为线段树的优秀结构,可以轻松进行可持久化。
树状数组的本质还是一个数组,因此对树状数组进行可持久化,相当于对树状数组维护信息的数组进行可持久化,这样还是要通过可持久化线段树来维护可持久化数组。并且因为一次操作新建了 \(O(\log n)\) 个版本,所以其时空复杂度都比直接使用可持久化线段树维护序列多一个 \(\log n\)。
综上分析,线段树的可持久化完全优越于树状数组的可持久化。从某种意义上,树状数组无法可持久化。
总结
对比二者,线段树的普适性更高,但是对于树状数组擅长维护的信息,树状数组在各方面的表现都优于线段树。因此在信息可以使用树状数组维护时,应该优先选择树状数组。
四、树状数组高维离散化(动态开点)
一个结论
一个显然的结论是,如果我们保证只在需要对某个内存进行修改时,再动态开辟这块内存,那么一个程序的时间复杂度显然不会低于空间复杂度,因为至少有开辟了所有空间的时间复杂度,光这部分复杂度就不低于空间复杂度。
反过来说,如果我们保证每次只开辟被修改的内存,那么程序的空间复杂度就不会高于时间复杂度,在时间复杂度符合要求时,一般如果空间复杂度与之相等则也能符合要求。因此这样的动态开点都是在避免开辟不被修改的内存。
一个复杂度比较劣的做法
考虑一个长度为 \(n\) 的序列,初始时全为 \(0\),要求进行 \(m\) 次单点加和前缀求和操作。其中 \(m \log n \lt n\),空间上不允许开出大小为 \(n\) 的数组。
注意到对于一次修改操作,我们只修改了 \(O(\log n)\) 个位置。因此实际上,即使 \(m\) 次操作全是查询,也只有 \(O(m \log n)\) 个位置的值被修改了,其余位置都是 \(0\)。
我们可以先空跑一边树状数组,即只进行 for 循环不进行修改,然后记录下所有被访问到的下标,把这些下标拿出来,进行离散化。设 \(T = m \log n\),则离散化的复杂度为 \(O(T \log T)\),确定一个下标离散化后的值可以通过 std::lower_bound 做到 \(O(\log T)\)。在树状数组运行过程中,一共需要确定 \(T\) 个结点的离散化值。因此这样做的时间复杂度为 \(O(T \log T)\),即 \((m \log n \log(m \log n))\)。因为保证了 \(m \log n \lt n\),因此时间复杂度为 \(O(m \log^2n)\)。其空间复杂度为 \(O(m \log n)\)。
显然如果不保证 \(m \log n \lt n\),不考虑离散化时,其空间复杂度为 \(O(\min(m \log n, n))\)。
优化复杂度
考虑时间瓶颈在于离散化。我们进行离散化的本质是把一个数映射成了另一个数。考虑我们需要一个时间复杂度更优秀的能够维护映射的算法,这让我们想到了 hash。对所有被访问到的位置进行 hash,其期望复杂度即可降为 \(O(m \log n)\)。如果不会 hash 的话,可以让 CYC 讲一讲。
需要指出的是,经过笔者实测,在为了保证效率而选择较大的 hash 模数时,这个算法的空间开销极大。而如果为了节约空间而缩小模数规模,则其实际运行效率非常缓慢,且码量与线段树没有明显差别,使用需谨慎。
升维
我们发现,上面的算法可以轻松扩展到高维树状数组的情况。具体的,对于 \(k\) 维树状数组,实际调用的空间只有 \(O(m \log^k n)\) 个位置,其中 \(n\) 为每一维的大小。同样的把这些位置拿出来进行离散化,可以做到 \(O(m \log^k n)\) 的空间和 \(O(m \log^{k + 1} n)\) 的时间,而用 hash 代替离散化可以做到 \(O(m \log ^k n)\) 的时空。
需要指出的是,在算法竞赛的范围内,当 \(k \geq 2\) 时,因为常数较大,\(O(m \log^{k + 1} n)\) 的实际效率可能不如直接暴力,因此期望得分甚至可能不如暴力。而 \(O(m \log^k n)\) 的算法的实际运行时空效率也非常差,只适用于在比赛时间不够用时 rush 一个好写的树套树作为骗分,不适合作为正解。
说明
还需要指出的是,对于这一部分内容,结合目前各位的水平以及所接触到的比赛,只需要各位大概了解思想(这里包括只开辟有用空间的思想与用高速映射(hash)代替一般映射(离散化)的思想),不需要扎实掌握,更不需要做相关题目。
五、关于结构体构造函数的一些补充说明
所有结构体都有至少一个构造函数,当不显式的实现任何构造函数时,编译器会自动补全一个没有任何参数的空函数作为其构造函数。对于直接声明的结构体变量,都会调用这个没有参数也没有内容的空函数。
当你实现了任何一个构造函数时(比如 Node(const int L, const int R)
),则编译器不会再自动补全无参数的构造函数。在此时如果直接声明一个结构体变量,会 CE 报错,因为找不到对应的构造函数。此时需要再手动声明一个参数为空的构造函数。
例如,下述代码会 CE:
struct Node {
int OvO;
Node(const int a) : Ovo(a) {}
};
Node Fusu[maxn];
下述两个代码不会 CE:
struct Node {
int OvO;
};
Node Fusu[maxn];
struct Node {
int OvO;
Node() {}
Node(const int a) : Ovo(a) {}
};
Node Fusu[maxn];
如果对于第五部分还有疑问,可以去询问 jzk。