高级数据结构学习笔记
一、线段树
0. 普适技巧
-
动态开点:节省空间。
-
标记永久化:分块的块标记本质就是这个。可以节省空间。
-
多
tag
线段树:一定要注意tag
的先后关系,还要注意相互转化关系。例:P4314 CPU 监控、P3373 【模板】线段树 2
1. 区间最值 & 历史区间最值
2. 二维线段树
简单地说,就是线段树套线段树。对于第一棵线段树的每一个节点,都要单独开一棵线段树,非常消耗空间。故此,二维线段树一般用于一下两种情形:
-
数据范围很小。往往此时维护的信息也很简单,可以不用写
struct
建树,而只是用数组存储。 -
操作很少。可以动态开点,此时的空间复杂度为 \(O(n\log^2n)\)。
标记永久化是二维线段树区间修改的一个重要技巧。
与其它二维数据结构的对比:
名称 | 信息类型 | 操作要求 | 时间复杂度 | 空间复杂度 |
---|---|---|---|---|
二维 ST 表 | 区间 | 静态 | \(O(n^2\log^2n+m)\) | \(O(n^2\log^2n)\) |
二维树状数组 | 前缀 | 动态 | \(O((n^2+m)\log^2n)\) | \(O(n^2)\) |
可持久化线段树 | 一维前缀 + 一维区间 | 静态 | \(O((n+m)\log n)\) | \(O(n\log n)\) |
树状数组套线段树 (动态可持久化线段树) |
一维前缀 + 一维区间 | 动态 | \(O((n+m)\log^2n)\) | \(O(n\log^2n)\) |
二维线段树 | 区间 | 动态 | \(O((n+m)\log^2n)\) | \(O(n+n\log^2n)\) |
例题:
-
Luck and Love:单修区查
-
P3567 [POI2014] KUR-Couriers:区修区查(由于操作的特性——推平操作非减,这个题可以配合标记永久化求解。)
-
P3157 [CQOI2011] 动态逆序对:这个题用动态开点可以搞过,但是建议用别的数据结构。
3. 可持久化线段树
可持久化的本质是对每一次操作的前缀和数组。而主席树则是一个数列上的权值前缀和数组,可用于解决区间权值问题。
可持久化线段树常常结合的一个技巧是线段树二分。比如,求 \([l, r]\) 区间第 \(k\) 小,我们只能做到直接查询数的排名而无法做到根据排名查询数。一种办法是二分数的大小,然后查询排名,复杂度 \(O(n \log^2 n)\);而线段树二分则直接利用线段树的分治结构在线段树查询时顺便二分,复杂度 \(O(n \log n)\)。
例题:
-
P2617 Dynamic Rankings & P3157 [CQOI2011] 动态逆序对 & P3759 [TJOI2017] 不勤劳的图书管理员:由于主席树的本质是前缀和,可以用树状数组实现动态修改。此时的代码已看不见可持久化的影子了。时间、空间复杂度 \(O(n \log^2 n)\)。
-
Sequence II:我们惯常的主席树思路为以原区间下标为不同树根,以值域为线段树下标;但以什么为线段树下标,得看我们根本想要维护什么。如此题,线段树下标所代表的即为第一次出现位置。
-
To the moon:使用标记永久化以节省空间。
-
P3605 [USACO17JAN] Promotion Counting P、P8844 [传智杯 #4 初赛] 小卡与落叶:在树上进行但不是树链剖分的问题。以 \(dfn\) 序为下标的可持久化线段树。
4. 线段树分裂 & 合并
二、分块
1. 树上莫队
使用 欧拉序而非树链剖分的 dfs 序 将树转化为了序列,进而求解。
至于为什么不用——
口胡的结论:dfs 序适用于维护的信息可以由区间合并而来的情况,而欧拉序适用于维护的信息可以由区间加减相邻元素而调整的情况。(欢迎打脸)
—— 引自 @VTloBong 的博客
个人认为,欧拉序具有遍历的连续性,适用于莫队的更新方法;而 dfs 序的本质是欧拉序的简化版,可以很方便地应用在其他无此诉求的数据结构上。
实现上需要注意的是添加 lca
。
例题:
-
P4689 [Ynoi2016] 这是我自己的发明:重在拆解询问进行转化。
2. 块状链表
由链表接起的一个个数组块,控制块长在 \(\sqrt{n} \sim 2\sqrt{n}\) 之间即可。
代码比较复杂,但常数较小。也许写熟练了会好一些。
三、平衡树
0. FHQ_Treap 及一些普适性内容
我之前已经写过一些内容了。这里想提一点。
平衡树实际有三个要素:键值、排名、下标。
其中下标是这个数插入时的一个不变量,有时固定的下标甚至是我们解题的突破口。
另外,在调试方面,对于基于随机的平衡树,换几个随机种子多试几次是很好的调试方法。
1. Splay
调试中常见问题:
-
0 节点
-
尽量使用循环不要使用递归
-
每次操作(不管是询问、插入还是删除)都要 splay(本质原因:势能分析法)
例题:P3369 【模板】普通平衡树、P4008 [NOI2003] 文本编辑器
2. 线段树套平衡树
与树状数组套主席树的做法对比:
时间 | 空间 | |
---|---|---|
树状数组套主席树 | \(O(n \log^2 n)\) | \(O(n \log^2 n)\) |
线段树套平衡树 | \(O(n \log^3 n)\) | \(O(n \log n)\) |
注意事项:
-
空间:初始节点 \(\text{inf}\) 和 \(\text{-inf}\) 个数 \(+\) 后面插入节点个数,即
MAXN*MAXLOG+MAXN*8
。 -
二分的写法:二分找到最后一个连最大排名都小于 k 的值
-
FHQ_Treap 会被卡常。应使用 Splay。
3. 可持久化平衡树
为什么一般不使用 Splay 进行可持久化?
-
Splay 既有向上操作,又有向下操作。见:link。
-
Splay 复杂度为均摊。某一个版本可能访问复杂度极高,但数据逼迫你反复调用。
具体实现:
-
一般平衡树中只需要在 Split 中新建节点。
-
带有懒标记的平衡树要注意在下传时新建节点。因为懒标记的下传本质是对当前版本该节点的一个操作,与以前版本而共用该节点的无关。
-
注意在新建节点时不要建到空节点了。
例题:P3835 【模板】可持久化平衡树、P5055 【模板】可持久化文艺平衡树
4. k-D 树
一般用来处理二维及以上空间中的数点问题。
只有查询矩形(超长方体)时复杂度是真的,为 \(O(n \times n^{\frac{1}{k}})\);其余操作复杂度都是假的。但是还是比较好用,在某些想不出来的计算几何中可以使用。
实现上需要注意:
-
KD 树是以“替罪羊树”为根基的平衡树。设置一个准值
Alpha
,若有子树大小与树大小比值超过Alpha
,则拍平重建这棵树。 -
询问时的“剪枝”:矩形完全不在该范围内的,返回;矩形完全在该范围内的,返回;矩形部分在该范围内的,判断当前点是否在范围内,然后向下递归。
-
节点要储存一个矩形的边框。故此,空节点要初始化为
INF
,以免Push_up
时出错。
例题:In case of failure、P4148 简单题、P4475 巧克力王国、P3769 [CH弱省胡策R2] TATT
5. Link-Cut Tree
LCT 是一种用于动态维护树的结构和树链信息的数据结构。
从树链剖分的思路出发。为了快速维护动态树,数据结构选用了可以分裂合并的平衡树。同时,splay 可进行区间翻转的性质刚好支持 Make_root 操作。
复杂度 \(O(n \log n)\),优于一般树剖的 \(O(n \log^2 n)\)。
大致过程:
-
Rotate(pt)
:左旋 / 右旋。 -
Splay(pt)
:先下放标记。然后重复如下内容:如果 fa(pt) 和 pt 是同种儿子(都是左 / 都是右),转 fa(pt);否则转 pt。再转一次 pt。 -
Access(x)
:当还不是原树的根时,不断Splay(x)
(提根),连接 x 与其虚父亲,然后对其虚父亲重复同样操作。 -
Make_root(x)
:Access(x)
后,将其所在区间整个翻转。
与一般树剖不同之处:
-
维护树的形态,而非值域。
-
链的不定性。
与一般 Splay 不同之处:
-
push_down 一定要预先从上到下进行。
-
“虚边”。这是一条单向边。可以这么认为:其指出是虚指,因为指出实际代表的是整条链的指出;而指到是实指,就是说被指到就是该节点本身被指到,而不是这条链被指到了。
实现上的易错点:
-
区分“原树”与“辅助树”上的操作。
-
Rotate 操作特判 father 是否为根。
-
push_down、push_up 处特判空节点。
-
当更新了某个节点的儿子时,一定要记得更新它儿子的父亲。
例题:
-
P4219 [BJOI2014] 大融合:如果要使用 LCT 维护子树信息,这个信息必须满足可减性。重点就是维护有关虚实转化的
Access
函数。 -
P5227 [AHOI2013] 连通图:LCT 维护生成树。(还没写。。。)
6. 全局平衡二叉树
全局平衡二叉树是一种用于维护静态树的数据结构。
准确地说,全局平衡二叉树应是一个二叉树森林。它在树剖的每一条重链上以 \(dfn\) 为关键字建立一棵二叉搜索树,并且通过每棵二叉树根节点的 fa 指针连接不同的重链。这里和 LCT 是一模一样的。
具体地,对于每一条重链上的二叉树,全局平衡二叉树按照每一个点的轻子树大小求出加权中点,当作树根。容易证明,这维护了整个森林(包括轻、重边)的总深度为 \(\log n\)。
对于 链 修改、查询,全局平衡二叉树能以优秀的常数做到 \(O(n \log n)\) 的复杂度过掉。
-
点到根的路径
可以发现,在点 \(x\) 到根的路径上的点 \(y\),满足在跳到过的链上且 \(dfn[y] < dfn[x]\)。如何在一棵二叉搜索树上查找关键字更小的所有点呢?如果在中间某一步,从 \(a\) 向 \(b\) 上跳时,满足 \(a\) 为 \(b\) 的右儿子,那么 \(b\) 的左子树中的所有节点 \(y\) 都满足 \(dfn[y] < dfn[x]\)。
-
点到根路径查询:上跳,每次如果是从 \(x\) 的右儿子跳上来,就将 \(x\) 左儿子的信息累加入答案。
-
点到根路径修改:上跳,每次如果是从 \(x\) 的右儿子跳上来,就对 \(x\) 左儿子打上标记。(一般来说,全局平衡二叉树使用标记永久化;当然也可以用
Push_down
,但这需要在预先知道上跳路径后再从上到下Push_down
一次。)
-
-
任意点到点之间的路径
先将二者的深度跳到一样,再维护两个指针同时向上直到跳到同一个点上就行了。过程和上面大部分是一样的,需要注意的是在同一条链上的情况,dfn 序偏左的节点需要在跳的过程中更新右子树而非左子树。
-
子树修改
维护轻儿子的信息与标记即可。此时的标记只能标记永久化。
所以全局平衡二叉树相比树剖劣在哪里呢?我一时真想不出来有什么树剖能做它做不了的。
子树信息的维护是全局平衡二叉树的短板。因为这时的标记只能使用标记永久化,有很多特殊的标记就维护不了。
例题:
四、堆
1. 左偏树
堆性质对左右节点的关系并无要求,致使堆的合并可以任意向左 / 右进行。因此,通过维护左偏,使得每个点向右到叶子节点的距离尽量小,可以得到一条尽量短的合并链。
注意事项:Merge、Delete 时注意维护并查集的变化。
例题:P3377 【模板】左偏树/可并堆、P1456 Monkey King
2. 启发式合并
将小的往大的合并。
显然,复杂度在两个堆的大小越接近时越劣。那么假设每次合并时大小都一样,可证明复杂度为 \(O(n \log^2 n)\)。
这是一个很有用的技巧,在很多其它数据结构上也可以使用。
五、并查集
0. 路径压缩 & 按秩合并
这么多年来终于看懂这两个的时间复杂度了。
路径压缩:时间复杂度是摊的 \(O(\log n)\)。
按秩合并:按照 深度 / 子树大小 合并时时间复杂度是严格的 \(O(\log_2 n)\)。单次的时间复杂度显然与树高(深度)挂钩。以深度为例:当加上按秩合并优化后,显然在树高相等时深度才会 \(+1\)。于是得证复杂度 \(O(n \log_2 n)\)。
1. 可持久化并查集
其实就是利用可持久化线段树实现的可持久化数组,来实现并查集的 father 数组。
但是注意这里不能使用路径压缩,因为路径压缩的复杂度是均摊的,而可持久化不能用在均摊复杂度的数据结构上。
直接的在线做法时间复杂度 \(O(n \log^2 n)\)。
另外还有一个离线做法,不使用可持久化线段树,而是根据可持久化的继承关系建成一棵树。在树上进行 dfs,进入某子树时对其操作,退出时撤销(不用路径压缩时,并查集撤销可做到 \(O(1)\))。如此,复杂度为 \(O(n \log n)\)。
六、离线分治算法
-
CDQ 分治
-
点分治
-
整体二分
七、扩展结构
1. 树链剖分
把树剖为链,满足性质:
-
dfn 在子树中连续
-
至多有 \(\log_2 n\) 条链。
在每条链上独立地建立数据结构。以多一个 \(\log n\) 为代价,我们将问题拓展到了树上。
树链剖分一般用于路径查询,它也可以进行子树查询。值得一提的是,当只维护子树信息时,我们往往可以通过以 dfn
为下标的数据结构解决。
-
P2486 [SDOI2011] 染色、P3976 [TJOI2015] 旅游:这种题将需要维护的信息封为一个结构体将简化程序。