伸展树 Splay
上回书说到:平衡树。
伸展树简介
由
伸展树的基本操作
结构定义
与前两个数据结构都不同的是,
struct Splay { int s[2]; // 0 for left son, 1 for right son int p; // Father node index int dat; // Value int cnt; // Duplication count int size; // Node count in total} tree[N];再谈旋转
在平衡树 2.3节中,我们探讨了平衡树中左旋右旋的基本规则,并给出了三种简记技巧。在
可以发现,左旋和右旋是互为相反操作的。事实上,我们可以把整个旋转操作写成一个函数,通过传参来执行不同类型的旋转。我们只需要指定旋转的节点,至于旋转的取向则由程序自行决定(正确性是有保障的)。我们可以先分别写出左旋和右旋的函数,然后再把它们组合起来,最后别忘了更新父节点、以及上传子树大小(注意代码顺序):
void rotate(int x) { int y = tree[x].p, z = tree[y].p; // Father and grandfather int sn_pos = (tree[y].s[1] == x); // Where the son is, 0 left, 1 right int fa_pos = (tree[z].s[1] == y); // Where the father is, 0 left, 1 right tree[z].s[fa_pos] = x; // Son and father need a swap. If father was on the left previously, the son will be placed as a father on the left tree[x].p = z; // Update father 1 tree[y].s[sn_pos] = tree[x].s[sn_pos ^ 1]; // In the mid-traversal order, node B is between X and Y, so it will be linked to the opposite side tree[tree[x].s[sn_pos ^ 1]].p = y; // Update father 2 tree[x].s[sn_pos ^ 1] = y; // After a round of zigzagging, left branch that connects father and son will be switched to the right side tree[y].p = x; // Update father 3 update(y); update(x);}树的伸展
伸展树的灵魂与核心就在于它可以将任意节点转动到其他节点下面去,这一操作就叫做“伸展”。在 虽然听起来很玄学,这一操作的初衷是对可能接连而来的针对于该节点的访问进行快捷响应。好比你正在刷视频,你突然看了一个平常不常看的类型的视频,对于大数据来说,你的某次访问就预示着你接下来也可能会“沉迷”于此类视频,因而把这类视频的优先级提高,并作出快速响应。在存储了巨量数据的数据库里,把某类键值的优先级提高是常见加快查询速度的方式之一。
对于三个节点
- 线形,即三个点都伸向相同方向
- 折线形,伸展方向不相同
对于第一种情况,
我们定义
void splay(int x, int k) { while (tree[x].p != k) { int y = tree[x].p, z = tree[y].p; // Father and grandfather int sn_pos = (tree[y].s[1] == x); // Son direction int fa_pos = (tree[z].s[1] == y); // Father direction if (z != k) { // More than one rotation to go if (sn_pos == fa_pos) rotate(y); // Linear else rotate(x); // Broken-linear } rotate(x); // Anyhow X needs a rotation } if (!k) root = x; // Upgrade as the new root}前驱&后继
为了找前驱/后继,我们就先需要找到带有给定权值的点。将这个点提到根上,方便我们的求解。基本思路和
void findAndRaise(int idx, int dat) { splay(idx); if (tree[idx].dat > dat) findAndRaise(tree[idx].s[0], dat); else if (tree[idx].dat < dat) findAndRaise(tree[idx].s[1], dat);}
int precursor(int dat) { findAndRaise(root, dat); int tmp = root; if (tree[tmp].s[0]) { tmp = tree[tmp].s[0]; while (tree[tmp].s[1]) tmp = tree[tmp].s[1]; return tmp; } return tmp;}
int successor(int dat) { findAndRaise(root, dat); int tmp = root; if (tree[tmp].s[1]) { tmp = tree[tmp].s[1]; while (tree[tmp].s[0]) tmp = tree[tmp].s[0]; return tmp; } return tmp;}插入操作
单点插入
为了维护节点的父节点信息,这次的插入操作和以往都不太一样。使用类似于深搜的遍历方式一路向下更新,我们的 allocate 函数也加入了父节点的初始化:
int allocate(int dat, int p) { idx++; tree[idx].dat = dat; tree[idx].p = p; tree[idx].cnt = 1; tree[idx].size = 1; return idx;}
void insert(int dat) { int u = root, p = 0; while (u) { p = u; if (tree[p].dat > dat) u = tree[u].s[0]; else if (tree[p].dat < dat) u = tree[u].s[1]; else { tree[u].cnt++; splay(p); return; } } u = allocate(dat, p); if (p) { // Not first run if (tree[p].dat > dat) tree[p].s[0] = u; else tree[p].s[1] = u; } splay(u);}区间插入
假如我们要把一段序列插入到节点
void insertInterval(int pos, int tot) { int l = getIdxByRank(root, pos + 1); int r = getIdxByRank(root, pos + 2); splay(l); splay(r, l); int rt = build(0, tot - 1, r); tree[r].s[0] = rt; update(r); update(l);}删除操作
单点删除
基本思路和平衡树一样,依然是把要删除的节点转到叶子上去。只不过在
void removeNode(int idx, int dat) { int pre = precursor(dat); int suc = successor(dat); splay(pre); splay(suc, pre); int tar = tree[suc].s[0]; if (tree[tar].cnt > 1) { tree[tar].cnt--; splay(tar); } else tree[suc].s[0] = 0;}区间删除
单点删除是区间删除的一个特殊版本(
void removeInterval(int pos, int tot) { int l = getIdxByRank(root, pos); int r = getIdxByRank(root, pos + tot + 1); splay(l); splay(r, l); gc(tree[r].s[0]); tree[r].s[0] = 0; update(r); update(l);}有关排名查询和数值查询的内容与平衡树相同,故直接给出。注意
int getRankByData(int idx, int dat) { if (!idx) return 1; if (tree[idx].dat > dat) return getRankByData(tree[idx].s[0], dat) + 1; return getRankByData(tree[idx].s[1], dat) + (tree[idx].s[0] ? leftSubtree(idx).size : 0) + tree[idx].cnt;}
int getDataByRank(int idx, int rank) { if (!idx) return -INF; if (leftSubtree(idx).size >= rank) return getDataByRank(tree[idx].s[0], rank); if (leftSubtree(idx).size + tree[idx].cnt >= rank) return tree[idx].dat; return getDataByRank(tree[idx].s[1], rank - (tree[idx].s[0] ? leftSubtree(idx).size : 0) - tree[idx].cnt);}伸展树的高级操作
我们虽然将
区间翻转
在开始之前,我们首先要明白区间翻转在

可以发现,这个序列翻转了,在图中的表现就是交换树的左右儿子。我们类比线段树,可以引入一个懒标记来记录区间翻转操作。当然这里的标记打法和线段树略有不同:
给
打标记的时候 这个点的左右儿子还没换,这与线段树的 lazytag 不同。线段树在 上打标记,表示 已经修改过了,将要修改儿子的贡献。 ——樱雪喵 《Splay 详细图解 & 轻量级代码实现》
那如何找到区间
如果要进行如上的翻转操作,整个代码中就要加入下传标记的操作。在所有有关查找的地方加入标记下传即可。
void pushdown(int idx) { if (tree[idx].tag) { swap(tree[idx].s[0], tree[idx].s[1]); leftSubtree(idx).tag ^= 1; rightSubtree(idx).tag ^= 1; tree[idx].tag = 0; }}在查询排名和数值的时候下传标记即可。
启发式合并
在这期间,我们就需要一个并查集来维护每个点所在的子树,把维护根节点的整型换成一个数组(毕竟有很多子树需要维护根节点的值)。在伸展操作(旋转到根节点)时更新根信息即可。
典例演练
洛谷 P3391 [模板] 文艺平衡树
题目地址:P3391
题目难度:提高+/省选-
您需要写一种数据结构(可参考题目标题),来维护一个有序数列。
其中需要提供以下操作:翻转一个区间,例如原有序序列是
,翻转区间是 的话,结果是 。 输入格式:
第一行两个正整数
,表示序列长度与操作个数。序列中第 项初始为 。 接下来 行,每行两个正整数 ,表示翻转的区间。 输出格式:
输出一行
个正整数,表示原始序列经过 次变换后的结果。 数据范围:
对于
的数据, , 。
这道题额外涉及到一个知识点,输出树的中序遍历。我们知道中序遍历是按照“左-根-右”的顺序递归输出的,我们只需要判断是否存在左右子树,然后递归输出即可,注意特判首位的边界节点。
代码在云剪切板。
洛谷 P3224 [HNOI2012] 永无乡
题目地址:P3224
题目难度:省选/NOI-
题目来源:湖南 2012 各省省选
永无乡包含
座岛,编号从 到 ,每座岛都有自己的独一无二的重要度,按照重要度可以将这 座岛排名,名次用 到 来表示。某些岛之间由巨大的桥连接,通过桥可以从一个岛到达另一个岛。如果从岛 出发经过若干座(含 座)桥可以 到达岛 ,则称岛 和岛 是连通的。 现在有两种操作:
B x y表示在岛与岛 之间修建一座新桥。
Q x k表示询问当前与岛连通的所有岛中第 重要的是哪座岛,即所有与岛 连通的岛中重要度排名第 小的岛是哪座,请你输出那个岛的编号。 输入格式:
第一行是用空格隔开的两个整数,分别表示岛的个数
以及一开始存在的桥数 。 第二行有
个整数,第 个整数表示编号为 的岛屿的排名 。 接下来
行,每行两个整数 ,表示一开始存在一座连接编号为 的岛屿和编号为 的岛屿的桥。 接下来一行有一个整数,表示操作个数
。 接下来
行,每行描述一个操作。每行首先有一个字符 ,表示操作类型,然后有两个整数 。
- 若
为 Q,则表示询问所有与岛连通的岛中重要度排名第 小的岛是哪座,请你输出那个岛的编号。 - 若
为 B,则表示在岛与岛 之间修建一座新桥。 输出格式:
对于每个询问操作都要依次输出一行一个整数,表示所询问岛屿的编号。如果该岛屿不存在,则输出
。 数据范围:
- 对于
的数据,保证 , 。 - 对于
的数据,保证 , , 为一个 的排列, , 。
这道题就要运用到我们上边所讲的启发式合并。重定义我们的 root 数组包裹的地方,理解为重。
代码在云剪切板中。
洛谷 P2042 [NOI2005] 维护数列
题目地址:P2042
题目难度:省选/NOI
题目来源:NOI 2005
请写一个程序,要求维护一个数列,支持以下
种操作: | 编号 | 名称 | 格式 | 说明 | | :—: | :----------: | :------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------- | | 1 | 插入 |
| 在当前数列的第 个数字后插入 个数字: ;若在数列首插入,则 为 | | 2 | 删除 | | 从当前数列的第 个数字开始连续删除 个数字 | | 3 | 修改 | | 从当前数列的第 个数字开始的连续 个数字统一修改为 | | 4 | 翻转 | | 取出从当前数列的第 个数字开始的 个数字,翻转后放入原来的位置 | | 5 | 求和 | | 计算从当前数列的第 个数字开始的 个数字的和并输出 | | 6 | 求最大子列和 | | 求出当前数列中和最大的一段子列,并输出最大和 | 输入格式:
第一行包含两个整数
和 , 表示初始时数列中数的个数, 表示要进行的操作数目。 第二行包含
个数字,描述初始时的数列。以下 行,每行一条命令,格式参见问题描述中的表格。 输出格式:
对于输入数据中的
和 操作,向输出文件依次打印结果,每个答案(数字)占一行。 数据范围:
- 你可以认为在任何时刻,数列中至少有
个数。 - 输入数据一定是正确的,即指定位置的数在数列中一定存在。
- 对于
的数据,任何时刻数列中最多含有 个数。 - 对于
的数据,任何时刻数列中最多含有 个数,任何时刻数列中任何一个数字均在 内, ,插入的数字总数不超过 。 题面由 @syksykCCC 提供。
这道题需要我们复习一下之前线段树的知识,这篇博客介绍了线段树求解区间最大子列的方法。
代码在云剪贴板。
后记&鸣谢
整整一天都花在调题对拍上了,做P2042的时候头发都掉了不少,不过总算是结束伸展树了。再加上你谷爆发了一次逆天的DDoS事件、还有那么多人在抢R166666666,我连题目都进不去,旁边的某位孩子评测了10分钟才出结果……真应了大佬一句话——“暑假的猴子最多”……
参考资料:
[1] Brailliant11001.高贵的伸展树——Splay [EB/OL]. https://www.luogu.com/article/2zmvdtov, 2024-2-20/2024-7-16
[2] 樱雪喵.Splay 详细图解 & 轻量级代码实现 [EB/OL]. https://www.cnblogs.com/ying-xue/p/17122409.html, 2023-9-10/2024-7-16
[3] 小蒟蒻yyb.Splay入门解析【保证让你看不懂(滑稽)】 [EB/OL] https://www.cnblogs.com/cjyyb/p/7499020.html, 2017-9-9/2024-7-16
[4] Brailliant11001.从零开始掌握线段树大法 [EB/OL]. https://www.luogu.com/article/4qwcw951, 2023-12-30/2024-7-17
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!
部分内容可能已过时