2026.08 训练日记(上)
8.3
P10837、P9750、P1076
模拟。不是很难。注意细节。
P17089、P1800
有意思的动态规划。不是很好想。
8.4
P1016、P1250、P4144、P1668
贪心。看来还得多练。
P3382、P1883
三分求单峰(单谷)函数极值的模板。注意精度。
P5931
纯粹的数学题。食用前需保证对数学必修一较为熟悉。
应该算是比较有意思的。
P1404、P1542
二分答案。
P1717
DP。看来还得多练。
8.5
P4712
贪心好题。做着还算顺手。
P10389
简单推式子 + 二分答案。
P3029
离散化 + 双指针简单题。权当复习一下了。
P9025
三分求单谷函数极小值。注意三分区间初始左右端点。
P2652
离散化 + 二分查找。是道好题。
一定记住,正难则反。
考虑需要更换的牌数,不如考虑当前能够组成同花顺的最大牌数。
对于每张牌枚举其作为同花顺末尾牌的情况(有点贪心思想在里面,这样可以确保获得最大答案),二分查找同花色牌中大小符合条件的牌。
注意,题目中可能出现花色和数字都相同的牌,所以二分查找时需要对牌去重以保证正确性。
总之大致的流程就是先离散化花色、按花色存牌,枚举时先去重、二分查找、记答案。
最后输出原来的总牌数减去上述最大牌数的差即可。
P7149
离散化(当然这里因为保证了互不相同所以排个序求 rank 就可以了),二维前缀和维护,枚举计算。
答案记得 \(+1\)。
P5788、P2947、P2866、B3666
单调栈的模板题。
“本质上单调栈维护的是数列所有前缀的后缀最值。”by 一扶苏一 in B3666 题解
P3467
单调栈。
显然答案与建筑物的宽度无关。
考虑整个建筑物链等高的情况。显然用一张海报就能够覆盖。
但若整个建筑物链由两个高度不相同的等高建筑物子链拼接而成,那么显然需要两张海报。
如果是三个高度互不相等的,那么显然又需要三张。
但是!如果在一个较高的等高子链左右两侧的较矮的等高子链高度相同,这时这两个子链就可以用同一张海报覆盖,中间较高的子链高出来的部分再用一张即可。只需要两张。
此时我们就能想到用单调栈维护:初始海报数为建筑物数,枚举建筑物链的高度序列,当栈顶大于即将入栈元素时弹出,若最后栈顶元素与即将入栈元素等高,则将需要的海报数 \(-1\)。
最后得到的答案自然就是最少海报数了。
P1901
单调栈。
考虑当一个下标入栈时,弹栈操作中弹出的下标对应的发射站都相当于是以准备入栈的发射站作为右侧最近的更高发射站,所以对于每次弹栈,都将准备入栈的发射站接收的能量值加上弹出的发射站的能量值。
此时栈顶的发射站就是入栈的发射站左侧最近的更高发射站,所以入栈前给栈顶接收的能量值加上入栈的发射站的能量值。然后把这个发射站压入栈中。
这样对于每个发射站,都能够找到其左右两侧最近的更高发射站并向其发射能量。
P2032、P1886、B3667
单调队列模板。
P1714
令原数组前缀和为 \(\displaystyle s_i = \sum_{j = 1}^{i}{p_i}\),则题意即最大化 \(s_r - s_{l - 1}\)。
考虑枚举 \(s_r\),显然在 \(s_r\) 固定时,\(s_{l - 1}\) 应尽量小,并且应当满足 \(i - j \leq m\)。
这就转化成了求区间最值的问题,考虑用单调队列维护 \(s_{l - 1}\)。
另外,本题有两个需要注意的小细节:
- \(s_0 = 0\) 应当先加入单调队列中,否则无法覆盖到左端点为 \(1\) 的区间。
- 本题的子段长度应当大于 \(0\),为避免在维护中出现自己减自己的情况,应当先统计答案再入队。
P2239、P1061
水题。
8.6
P3367、P1536、P1551、P2814
并查集板子。
P1455
并查集 + 0/1 背包。
在合并过程中记得先特判两元素已经在同一集合内的情况直接返回,防止代价和贡献重复计算。
P1661
并查集 + 二分答案。
P1892
拓展域并查集。
传统并查集只能够维护集合间的一种关系,而拓展域并查集能够处理集合间的多种不同关系,例如如相互排斥或相互独立的关系等。
此处是二倍拓展域。
设有 \(n\) 个元素。因为要维护二倍拓展域所以扩充到 \(2n\) 个,元素 \(x\) 对应的反元素为 \(n + x\)。
若其中两个元素 \(a, b\) 是朋友,很简单,合并 \(a\) 与 \(b\) 即可。
若其中两个元素 \(a, b\) 是敌人,这时我们需要做的是合并 \(a\) 与 \(n + b\)、再合并 \(n + a\) 与 \(b\)。
可以理解为因为 \(b\) 是 \(a\) 的敌人,所以 \(a\) 与反的 \(b\) 是朋友。对于 \(b\) 同理。
这时如果我们再有 \(c\) 是 \(b\) 的敌人,那合并之后,会发现 \(a\) 与 \(c\) 在同一个集合里。
这样我们就满足了题目中“敌人的敌人是朋友”的条件,接下来的部分就是朴素的并查集合并、连通块计数的过程了。
P1955
离散化 + 并查集。
P1525
与 P1892 类似,也要用到二倍拓展域并查集。
P10091
考虑对分数值进行二分答案。
先对序列 \(a, b\) 进行升序排序。
此时显然有 \(\displaystyle \frac{a_j}{b_i} < \frac{a_{j + 1}}{b_i}\) 与 \(\displaystyle \frac{a_j}{b_i} > \frac{a_j}{b_{i + 1}}\) 成立。
于是在 \(\texttt{check}\) 函数中可以依此求二分到的分数值在所有分数中的大致排名。
最后对于二分出来的答案,可以对每个分母枚举是否能找到对应的分子,最后约分、输出即可。
数据极卡精度,需要 \(\texttt{long double}\)。对应地,四舍五入应用 \(\texttt{roundl}\) 而非 \(\texttt{round}\),求绝对值应用 \(\texttt{fabsl}\) 而非 \(\texttt{abs}\)。
P3865、P1816、P2880、P2251、P1890、P2412
ST 表板子题。
P14517
绝世好题!绝世好题!绝世好题!
考虑以下标 \(i\) 开始的数据如何合法。显然,这样的一组数据合法当且仅当:
发现上面这一坨区间最大值可以使用 ST 表维护。
接下来我们从后往前递推,令 \(f_i\)(取值为 \(0/1\))表示以下标 \(i\) 开始的数据是否合法。很容易得到递推式:
注意需要初始值 \(f_{n + 1} = 1\),其中 \(n\) 为题目所输入的数据行数。
因为题目中原题的第一组数据的 \(n, m\) 都尚未给出(其实也就是需要我们求方案数的核心部分),所以对于每个 \(f_i = 1\) 我们都钦定题目输入数据的 \(1 \sim i - 1\) 行为第一组数据给出的图边。
于是乎每当递推到一个 \(f_i = 1\),我们都有 \(\text{ans} \gets (\text{ans} + 2 \cdot 10^5 - \max_{j = 1}^{i - 1} \max \{ u_j, v_j \} + 1) \bmod 998244353\)。
另外,注意 \(i = 1\) 的情况应当单独处理,考虑到在此之中为访问 ST 表而计算的 \(\log_2{(i - 1)}\) 不应当出现真数为 \(0\) 的情况。
P7809
- 询问 \(l\) 到 \(r\) 区间的最长上升子序列的长度。
显然答案只可能为 \(1/2\),当且仅当 \([l, r]\) 中存在 \(\texttt{01}\) 子序列时答案为 \(2\)。
维护一个前缀 \(\texttt{01}\) 子序列计数数组 \(p\),当 \(p_l = p_r\) 时说明 \([l, r]\) 中并不存在 \(\texttt{01}\) 子序列,反之则存在。
- 询问 \(l\) 到 \(r\) 区间的最长不下降子序列的长度。
显然是一个形如 \(\texttt{00...011...1}\) 的子序列。
维护一个前缀 \(0\) 计数数组 \(s\) 和后缀 \(1\) 计数数组 \(t\),对于每个位置 \(i \in [l, r]\) 枚举其作为最长不下降子序列的转折点,故答案显然为:
前面这个最大值显然可以使用 ST 表维护。
8.7
P3378
优先队列板子。
P1177
堆排序板子。
懒得打字的话用 \(\texttt{std::sort}\) 得了。
P2085
显然给出的任意 \(F_i(x)\) 在 \((0, +\infty)\) 内都单调递增。
故考虑维护一个大根堆,对每个函数 \(F_i\) 枚举正整数 \(x \in [1, m]\),计算函数值,若其值小于当前堆顶则入队,若函数值大于当前堆顶,则由单调性得对任意 \(x_1 > x\) 均有 \(F_i(x_1) > F_i(x)\),对前 \(m\) 小值均无贡献,此时直接 \(\texttt{break}\) 掉。
最后倒序输出堆中的值即可。
P1878
小根堆维护合法舞伴序列。当然要重载运算符,技术值相差小的优先。
现在有一个问题,就是当序列中间的一对舞伴出列之后剩下的部分怎么处理。
即在序列 \(\texttt{\{..., A, B, C, D, ...\}}\) 中,如果 \(\texttt{(B, C)}\) 出列,剩下的 \(\texttt{\{..., A\}}\) 与 \(\texttt{\{D, ...\}}\) 如何合并。
考虑使用双向链表,令 \(l[i], r[i]\) 分别表示下标 \(i\) 元素的前驱和后继。
当序列中连续的下标 \((x, y)\) 出列时,更新如下:
初始值自然是 \(l[i] = i - 1\),\(r[i] = i + 1\)。
完整实现不难。
P1168
对顶堆。
建立一个大根堆、一个小根堆。
大根堆中的元素数 \(a\)、小根堆中的元素数 \(b\) 应当总满足:
同时,大根堆中的任意元素应当总不大于小根堆中的任意元素(等价于大根堆堆顶应当总不大于小根堆堆顶)。
说人话就是,对于前 \(n\) 个元素,小根堆维护这些数中前 \(\boldsymbol{\lceil n / 2 \rceil}\) 大的元素。
故当 \(2 \nmid n\) 时,小根堆堆顶是前 \(n\) 个数的中位数。
考虑如何维护这两个堆。
假设现在第 \(n\) 个数准备入队。
若 \(2 \nmid n\),即前面已经入队的元素个数为偶数,那么我们需要使小根堆中的元素数量更多。同时考虑到「大根堆堆顶必须不大于小根堆堆顶」的限制,我们将准备入队的数与大根堆堆顶比较。
如果它大于大根堆堆顶,那么直接把它加入小根堆中是满足条件的;否则,如果将它加入小根堆就会破坏上述限制。
解决方式就是,先将大根堆堆顶弹出并加入小根堆中,再把准备入队的数加入大根堆,这时就会发现原来的限制依然能够满足。
同样地,若 \(2 \mid n\),那么把准备入队的数与小根堆堆顶比较,若它小于小根堆堆顶则直接加入大根堆,否则把小根堆堆顶弹出加入大根堆,再把准备入队的数加入小根堆。
以上反映了对顶堆维护的基本思路模式。
P1801
也是对顶堆。
大根堆维护前 \(i\) 小值。
对于 \(\texttt{GET}\) 操作,因为是先 \(i \gets i + 1\) 再求第 \(i\) 小,所以相当于求当时的第 \(i + 1\) 小,故输出小根堆堆顶即可。
在此之后就把小根堆堆顶弹出,加入大根堆(因为 \(i\) 要 \(+1\))。
对于 \(\texttt{ADD}\) 操作,将准备入队的数与大根堆堆顶比较,若大于大根堆堆顶则加入小根堆,否则将大根堆堆顶弹出加入小根堆,而准备入队的数则加入大根堆。
这里总结一下判断加入数时应当如何操作的思路:
- 首先一定要确定对顶堆维护的是什么(一般而言都是与区间第 \(\boldsymbol k\) 大相关的)。
- 接下来,要明确当下的操作是需要大根堆或小根堆中的哪一个增加元素(记 \(A\) 为需要加入数的堆,\(B\) 为另一个,需要加入的数为 \(x\))。
- 明确之后,自然就是将 \(x\) 与 \(B\) 的堆顶相比较,如果直接把 \(x\) 加入 \(A\) 中并不会破坏对顶堆的限制,那么直接加入就行;否则需要把 \(B\) 的堆顶弹出加进 \(A\) 中,再把 \(x\) 加到 \(B\) 里。
做多了就能理解得更快了。
P1090
注意到一个贪心策略:每次选择最小的两堆合并。
这实际上的确是正确的,具体证明参见这篇题解。
拿小根堆维护即可,非常简单。
不过这题还有个更神的解法:先对原序列 \(a\) 排序,并另外维护一个序列 \(b\) 存放合并之后的果子堆,每次使用两序列中最小的两个数合并加入 \(b\) 中,同时上述贪心策略也使得 \(b\) 一定是升序的,故每次取数只需要在 \(a, b\) 的序列开头比较即可(也就是将 \(a, b\) 视作队列,队头取数相加添加到 \(b\) 队尾)。
排序只要用计数排序等算法就能够是整体的时间复杂度达到极低的 \(O(n)\),非常快。
8.9
P16938
题目要求的是有且仅有一次转弯的路径。
注意要特判 \(n = 1 \lor m = 1\) 的情况!
P16392
没什么好说的,贪心 + 二分答案。
在答案的取值范围内,状态只要是连续且单调的,那么就可以考虑进行二分答案。
这题赛时没做出来明显是对贪心和二分答案的极其生疏啊。
多练。
P16939
更没啥好说的了。极其明显的反悔贪心 + 堆优化。
多练吧。
P16937
大模拟。细节有点多。
8.10
B3644
拓扑排序板子。
P1960
也是板子。
当优先队列中同时存在多个可选节点则代表有多种可选拓扑排序序列。
P1113、P4017、P6145
拓扑排序 + 递推。注意细节注意细节注意细节。
P4089
拓扑排序,之后统计入度仍不为 \(0\) 的节点数量即为答案。
P3366、P2121、P2847、P1194、P1396、P1991、P1265
MST 板子以及各种小变式。做多一点就很熟练了。
P1340
增量 MST(Incremental MST)。
题意比较明显。
考虑在已经生成了 MST 的情况下加边。
假设把这条边加进来,这时 MST 上会形成一个环。遍历这条环,把环上边权最大的边飞出,就能构出新的 MST。
大致是这样的思路。
当然这题做法还有很多,例如每次加边都把新 MST 取边集合更新为原 MST + 加边,跑 \(W\) 遍 Kruskal,复杂度也能较暴力 Kruskal 有所优化;或者从最后一次加边开始倒着做,每次进行删边;等等。
P3073
瓶颈生成树(Bottleneck Spanning Tree, BST)的小变式。
注意实现细节。
另外有个性质:BST 等价于 MST。
8.11
P4779
重温堆优化 Dijkstra 板子。
P6175
考虑使用 Floyd 求全源最短路。
此后我们考虑如何求最小环。
-
在 Floyd 后再 \(O(n^3)\) 遍历一遍,如果 \(i, j, k\) 三个点互不相同就记录答案。
这种方法并不可行。考虑 \(k\) 恰好在 \(i\) 与 \(j\) 之间的最短路上的情况,此时这三个点无法形成环。 -
那在遍历的时候额外特判一个 \(\text{dis}_{i, k} + \text{dis}_{k, j} \neq \text{dis}_{i, j}\) 不就好了?
也不可行。因为有可能虽然 \(k\) 不在所求出的 \(i\) 与 \(j\) 之间的最短路上,但 \(\text{dis}_{i, k} + \text{dis}_{k, j}\) 与 \(\text{dis}_{i, j}\) 也相等。说人话就是可能存在另外一条 \(i\) 到 \(j\) 的路径长度与求出的最短路长度相同。
所以正确的解法应该是,在 Floyd 的过程中顺带进行计算。
在 Floyd 的过程中所转移得到的最短路是只途径节点 \(\boldsymbol{1 \sim k - 1}\) 的最短路,所以这种情况下我们就能保证 \(k\) 不在当前的最短路上,这时我们当前的环总长就是 \(\text{dis}_{i, j} + e(i, k) + e(j, k)\)。其中 \(e(u, v)\) 表示边 \((u, v)\) 的长度。
P5764
跑六遍 Dijkstra,全排列求最小值。不难,注意细节。
P2296
当然要找出所有符合题目中条件 \(1\) 的点了。
从每个点开始都遍历一遍判断无疑是愚蠢的,等着 T 飞吧。
我们需要用仅一次遍历来找出所有可用点。
考虑建反图,从 \(t\) 点开始 DFS,每遍历到一个点就把它在反图中的入度(即原图中的出度)\(-1\),若 DFS 后一个点的这入度被减到 \(0\) 了,就说明从 \(t\) 出发能够遍历到原图中该点的所有出边,也意味着原图中该点满足条件 \(1\)。
最后只使用原图中满足条件 \(1\) 的点来跑 BFS 求最短路就可以了。
P3371
SPFA 板子。
P3385
SPFA 求负环板子。
审题!审题!审题!
多测要清空!多测要清空!多测要清空!
P4667
考察图论建模能力。
考虑给有电线连接的两个端点连权值为 \(0\) 的边,给另外两个端点连权值为 \(1\) 的边(如果想通过这条边需要花费一次旋转)。
注意到可以特判 \(N\) 与
接下来跑最短路就好了。
另外,对于权值为 \(0/1\) 的图,其最短路可以使用 双端队列 BFS 求解:若边权为 \(0\) 则从队头入队,边权为 \(1\) 则从队尾入队,每次弹出仍然是从队头弹出。
8.12
P4822、P2939
分层图最短路板子。
P1948
分层图最小瓶颈路板子。
8.13
P2194、B3609、P2863
Tarjan 求 SCC 板子。
P3387
Tarjan 缩点板子。
P2341
显然一个 SCC 内的奶牛一定互相爱慕,不妨先 Tarjan 缩点。
接下来考虑一个 SCC 如何成为明星 SCC,显然这个 SCC 缩点后的出度应当为 \(0\)。
但是!我们又发现如果缩点后出度为 \(0\) 的 SCC 不止一个,那么此时这些 SCC 中的奶牛一定没有爱慕关系,所以这些奶牛都不可能成为明星。
P1262
如果一个间谍既不受贿也不能被其他间谍揭发,那么他就是不能被控制的间谍。遍历时标记,最后枚举输出编号最小的那一个即可。
否则,如果不存在 SCC,则所有入度为 \(0\) 的间谍金额总和即为答案。
如果存在 SCC,那么在这个 SCC 中最多只需要贿赂其中金额最小的那一个。这里进行 Tarjan 缩点,新的点权为原 SCC 中最小的点权。接下来再找入度为 \(0\) 的节点统计答案即可。
P2746
显然首先需要 Tarjan 缩点把图变成 DAG。第一问的答案即为 DAG 上入度为 \(0\) 的点。
接下来考虑第二问。
因为 DAG 中任意节点都必定能被某个源点到达,故将一个汇点连向源点后,从该源点出发的路径必定能构成一个环。这提醒我们若要让整张图强连通,需要通过回边把所有汇点连向所有源点(不一定要一一对应,全部连上就行)。于是让图变为 SCC 至少需要添加的有向边数量正是源点数与汇点数中的最大值。
P8435
Tarjan 求 v-DCC 点集板子。
首先明确割点的判定:
- 若该点不是 DFS 树的根节点,设该节点为 \(u\),则 \(u\) 为无向图的割点当且仅当其存在一个子节点 \(v\) 满足 \(\text{low}[v] \geq \text{dfn}[u]\)。
- 否则,若该点是根节点,那么只要其有两个以上的子节点,该节点即为割点。
要记录 v-DCC 的点集,则首先要像求 SCC 一样用栈记点(但不需要 \(\texttt{vis}\) 数组),在判定 \(u\) 为割点之后,把栈顶直到当前 \(v\) 全部弹出、记录到 v-DCC 中,最后再把 \(u\) 也记录进去。注意,节点 \(\boldsymbol u\) 不需要弹出。
B3610
也算是求 v-DCC 板子,只不过这题不把孤立点看作 v-DCC。关于孤立点是否为 v-DCC 这个问题倒是颇有争议(源自 v-DCC 不同定义之间的微妙差异)。
发现 \(\texttt{std::vector}\) 居然有比较运算符的重载,可以直接按字典序排序,省得自己再重载一回了。
Attention
- SCC 特指有向图;DCC(v-DCC / e-DCC)、割点、桥特指无向图。
- 无向图链式前向星要开两倍空间!
- v-DCC 中忽略重边不影响求点集的正确性,但 e-DCC 中不能忽略重边!
8.14
P3374、P1637、P1908、P5057、P3368、T677506
无论是用 BIT 还是线段树都是非常简单的板子题。
P1787
好题。
正难则反正难则反正难则反。
对非非众数子串(即众数子串)\([l, r]\) 应当满足的条件进行推导(设枚举到某个字符时, \(p_i\) 为该字符在原字符串前 \(i\) 位出现的次数):
令 \(S_i = i - 2p_i\),则上式可以写作
其中 \(l - 1 < r\)。
可以发现这个式子与逆序对的式子几乎相同。
用权值 BIT(或者权值线段树,如果乐意的话)维护计算答案即可。
别忘了这里算出的是众数子串的数量 \(m\),则最终答案应为 \(\displaystyle\frac{n(n + 1)}{2} - m\)。
另外,考虑到 \(S_i\) 边界情况下的值域在 \([-n, n]\),所以一定要加个偏移量。
P2163
直接离散化 + 二维 BIT 显然会 MLE。
这里使用二维前缀和 + 离散化 + 离线扫描线 + 一维 BIT 的神奇优化。
首先,我们考虑把每个查询拆分为四个前缀查询。
我们把 \(n\) 个点和 \(m\) 个查询封装成事件,每个事件结构体涵盖:
- 点坐标;
- 事件类型(为 \(0\) 表示是点,否则表示查询编号);
- 系数(二维前缀和容斥原理用,值为 \(\pm 1\))。
我们收集所有点及查询涉及的 \(y\) 坐标,并对其离散化。
把所有时间按 \(x\) 坐标升序排序,若 \(x\) 相同则使点事件优先于查询事件,确保先插入后查询。
这样就完成了离线扫描线的初始化。
接下来吧一维 BIT 和答案数组都初始化,依序遍历事件。
- 若为点事件,则在树状数组对应的 \(y\) 位置执行 \(\texttt{update}\) 操作。
- 若为查询事件,则在树状数组中 \(\texttt{query}\) 出来,乘上系数累加到答案中。
最后输出结果即可。
Attention
- BIT 的下标必须是 1-based。
8.16
P2343
权值线段树维护区间第 \(k\) 小。
P5076
二叉搜索树(Binary Search Tree, BST)板子题。
还算好理解的。
指针实现好看。
P3369
平衡树板子题。这里使用 AVL 树。
封装一个结构体 \(\texttt{Node}\) 为 AVL 树的节点。常规内容有:
- 数值 \(\texttt{val}\);
- 树高度 \(\texttt{h}\);
- 树节点数 \(\texttt{size}\);
- 左右子节点指针 \(\texttt{ls, rs}\)。
考虑到本题要维护可重集合,所以我们的节点中还需要多一个计数 \(\texttt{cnt}\) 表示当前数值出现的次数。
封装一个类 \(\texttt{AVL}\) 将上述结构体作为私有成员。另需一个根节点指针 \(\texttt{root}\)。目前有:
struct Node {
int val;
int h;
int cnt;
int size;
Node* ls;
Node* rs;
Node(int x) : val(x), h(1), cnt(1), size(1), ls(nullptr), rs(nullptr) {}
};
Node* root;
实现两个工具函数,用于安全取节点的高度和大小,若节点指针为 \(\texttt{nullptr}\) 则返回 \(0\):
int getHeight(Node* x) {
return x ? x->h : 0;
}
int getSize(Node* x) {
return x ? x->size : 0;
}
接下来实现一个(类似线段树的)\(\texttt{pushup}\) 函数,用于在删除或插入节点后更新节点的大小和高度值:
void pushup(Node* x) {
if (!x) {
return;
}
x->h = max(getHeight(x->ls), getHeight(x->rs)) + 1;
x->size = getSize(x->ls) + getSize(x->rs) + x->cnt;
}
接下来我们先考虑 AVL 的「灵魂」的实现。
AVL 要求左、右子树高度差不大于 \(1\),但如果插入或删除操作之后出现了违反该性质的情况该怎么办呢?我们分四类(LL,RR,LR,RL)讨论。这四类的分类定义即,从最小不平衡子树根节点到新节点的路径上,经过的前两条边是左分支(L)还是右分支(R),以此命名这四种不平衡的形态。每种形态与调整平衡的方法一一对应,不过当然互相对称的形态的调整方法也互相对称。
研究第一种形态,LL 型。调整的过程如下图所示。

这里有一个被称为「右旋」的操作,把红色节点旋转下来作为黄色节点的右子节点,这时红色节点的左子节点恰好空缺,原来作为黄色节点右子节点的蓝色节点也与父节点断开,于是蓝色节点就能成为红色节点的左子节点了。
右旋的实现如下:
Node* rotateRight(Node* x) {
Node* y = x->ls;
x->ls = y->rs;
y->rs = x;
pushup(x);
pushup(y);
return y;
}
对称地,RR 型的调整就需要一个「左旋」操作:
Node* rotateLeft(Node* x) {
Node* y = x->rs;
x->rs = y->ls;
y->ls = x;
pushup(x);
pushup(y);
return y;
}
接着考察 LR 型。如下图:

我们对黄色节点进行「左旋」就可以转化为 LL 型,此时再对红色节点应用「右旋」就可以把这个子树调平衡。
RL 型对称操作即可。
综上,我们得到了调整平衡的操作 \(\texttt{balance}\) 的完整流程,实现如下。
Node* balance(Node* x) {
if (!x) {
return nullptr;
}
pushup(x);
int lh = getHeight(x->ls);
int rh = getHeight(x->rs);
if (lh - rh > 1) {
if (getHeight(x->ls->ls) < getHeight(x->ls->rs)) {
x->ls = rotateLeft(x->ls); // LR 型额外处理
}
x = rotateRight(x); // LL 型(及转化后的 LR 型)处理
} else if (rh - lh > 1) {
if (getHeight(x->rs->ls) > getHeight(x->rs->rs)) {
x->rs = rotateRight(x->rs); // RL 型额外处理
}
x = rotateLeft(x); // RR 型(及转化后的 RL 型)处理
}
return x;
}
接着实现插入元素。这里运用二叉搜索树的性质就好了:
Node* insert(Node* x, int val) {
if (!x) {
return new Node(val);
}
if (val == x->val) {
++(x->cnt);
++(x->size);
return x;
}
if (val < x->val) {
x->ls = insert(x->ls, val);
} else {
x->rs = insert(x->rs, val);
}
return balance(x); // 别忘了调平衡!
}
然后到了删除元素的 \(\texttt{erase}\) 操作。
照二叉搜索树的性质找到要删除的元素节点之后,我们分四种情况讨论。
- 如果节点 \(\texttt{cnt}\) 大于 \(1\),那么直接给 \(\texttt{cnt}\) 和 \(\texttt{size}\) 减去 \(1\) 就好了;
- 如果节点是叶节点,直接删掉就行;
- 如果只有一个子节点,那么把这个子节点接到删除的节点原本所在的位置即可;
- 但如果有两个子节点呢?这时候我们就需要找到这个节点的后继(也就是数值比它大的最小的节点),把这个节点拉过来替代删除的节点。
具体实现如下:
Node* erase(Node* x, int val) {
if (!x) {
return nullptr;
}
if (val == x->val) {
if (x->cnt > 1) {
--(x->cnt);
--(x->size);
return x;
}
if (!(x->ls) || !(x->rs)) {
Node* tmp = x->ls ? x->ls : x->rs;
delete x;
return tmp;
}
Node* suc = x->rs;
while (suc->ls) {
suc = suc->ls;
}
x->val = suc->val;
x->cnt = suc->cnt;
suc->cnt = 1;
x->rs = erase(x->rs, suc->val);
} else if (val < x->val) {
x->ls = erase(x->ls, val);
} else {
x->rs = erase(x->rs, val);
}
return balance(x); // 还是那句话,别忘了调平衡!
}
剩下求指定元素的排名、求指定排名的元素、求前驱、求后继的四个操作就基本上只用对二叉搜索树较熟悉就不难写出了。
直接给出完整封装的 AVL 树类吧。码量并不小。
AVL 树类只需对外暴露构造函数、析构函数和六个操作接口就够了。
template<typename T>
T max(const T& a, const T& b) {
return a > b ? a : b;
}
class AVL {
private:
struct Node {
int val;
int h;
int cnt;
int size;
Node* ls;
Node* rs;
Node(int x) : val(x), h(1), cnt(1), size(1), ls(nullptr), rs(nullptr) {}
};
Node* root;
int getHeight(Node* x) {
return x ? x->h : 0;
}
int getSize(Node* x) {
return x ? x->size : 0;
}
void pushup(Node* x) {
if (!x) {
return;
}
x->h = max(getHeight(x->ls), getHeight(x->rs)) + 1;
x->size = getSize(x->ls) + getSize(x->rs) + x->cnt;
}
Node* rotateRight(Node* x) {
Node* y = x->ls;
x->ls = y->rs;
y->rs = x;
pushup(x);
pushup(y);
return y;
}
Node* rotateLeft(Node* x) {
Node* y = x->rs;
x->rs = y->ls;
y->ls = x;
pushup(x);
pushup(y);
return y;
}
Node* balance(Node* x) {
if (!x) {
return nullptr;
}
pushup(x);
int lh = getHeight(x->ls);
int rh = getHeight(x->rs);
if (lh - rh > 1) {
if (getHeight(x->ls->ls) < getHeight(x->ls->rs)) {
x->ls = rotateLeft(x->ls);
}
x = rotateRight(x);
} else if (rh - lh > 1) {
if (getHeight(x->rs->ls) > getHeight(x->rs->rs)) {
x->rs = rotateRight(x->rs);
}
x = rotateLeft(x);
}
return x;
}
Node* insert(Node* x, int val) {
if (!x) {
return new Node(val);
}
if (val == x->val) {
++(x->cnt);
++(x->size);
return x;
}
if (val < x->val) {
x->ls = insert(x->ls, val);
} else {
x->rs = insert(x->rs, val);
}
return balance(x);
}
Node* erase(Node* x, int val) {
if (!x) {
return nullptr;
}
if (val == x->val) {
if (x->cnt > 1) {
--(x->cnt);
--(x->size);
return x;
}
if (!(x->ls) || !(x->rs)) {
Node* tmp = x->ls ? x->ls : x->rs;
delete x;
return tmp;
}
Node* suc = x->rs;
while (suc->ls) {
suc = suc->ls;
}
x->val = suc->val;
x->cnt = suc->cnt;
suc->cnt = 1;
x->rs = erase(x->rs, suc->val);
} else if (val < x->val) {
x->ls = erase(x->ls, val);
} else {
x->rs = erase(x->rs, val);
}
return balance(x);
}
int getRank(Node* x, int val) {
if (!x) {
return 1;
}
if (val == x->val) {
return getSize(x->ls) + 1;
}
if (val < x->val) {
return getRank(x->ls, val);
}
return getSize(x->ls) + x->cnt + getRank(x->rs, val);
}
int getByRank(Node* x, int rk) {
if (!x) {
return 0;
}
int leftsize = getSize(x->ls);
if (rk <= leftsize) {
return getByRank(x->ls, rk);
}
if (rk <= leftsize + x->cnt) {
return x->val;
}
return getByRank(x->rs, rk - leftsize - x->cnt);
}
int getPredecessor(Node* x, int val) {
if (!x) {
return 0;
}
if (val <= x->val) {
return getPredecessor(x->ls, val);
}
int res = getPredecessor(x->rs, val);
return res ? res : x->val;
}
int getSuccessor(Node* x, int val) {
if (!x) {
return 0;
}
if (val >= x->val) {
return getSuccessor(x->rs, val);
}
int res = getSuccessor(x->ls, val);
return res ? res : x->val;
}
void destruct(Node* x) {
if (!x) {
return;
}
destruct(x->ls);
destruct(x->rs);
delete x;
}
public:
AVL() : root(nullptr) {}
~AVL() {
destruct(root);
}
void insert(int val) {
root = insert(root, val);
}
void erase(int val) {
root = erase(root, val);
}
int getRank(int val) {
return getRank(root, val);
}
int getByRank(int val) {
return getByRank(root, val);
}
int getPredecessor(int val) {
return getPredecessor(root, val);
}
int getSuccessor(int val) {
return getSuccessor(root, val);
}
};
另:右旋和左旋也可以分别称为 Zig 和 Zag。
所以,AVL 树的四种不平衡形式的调整也可以分别称为:Zig(LL);Zag(RR);Zag-Zig(LR);Zig-Zag(RL)。

浙公网安备 33010602011771号