伸展树(上)
-
-
算法训练营5.2(没有将值相同的节点合并,但是代码易懂)
-
简介:
-
名称:伸展树(分裂树)
-
本质:一种二叉搜索树,可以在O(logn)的时间内完成插入、查找和删除操作,是基于数据的时间局部性和空间局部性原理产生的。
-
一些abstract:
-
时间局部性和空间局部性的原理:
时间局部性:刚刚被访问的元素,极有可能在不久后被再次访问
空间局部性:刚刚被访问的元素,它的相邻节点也很有可能被访问
因为局部性原理,伸展树在每次操作后都将刚被访问的节点旋转至树根,用来加速后续的操作。
-
和别的树的区别:平衡二叉树(AVL树)通过动态调整平衡,使树的高度保持在O(logn),所以单词搜索的时间复杂度为O(logn),但是为了严格保持平衡,在调整时会做过多旋转,影响了插入和删除的性能。
而伸展树的实现更为简捷,无须时刻保持全树平衡,任意节点的左右子树高无限制。伸展树的单次搜索也可能需要n次操作,但是均摊之后复杂度也为O(logn)的,也就是说,连续m次操作的复杂度可以保持在O(mlogn)。其优势在于不用记录平衡因子、树高、子树size等额外信息,效率更高(尤其是对于连续的搜索操作),适用范围更广。
-
核心是不断将节点旋转到根节点,使得树不会退化成链表。每个节点至少保存以下信息:
-
parent:父节点的指针
-
child[0/1]:左右儿子指针
-
value:这个节点存的值
-
count:出现了多少个值相同的点
-
size:子树中有多少个节点,算自己
-
-
-
-
节点类:
struct Node {
int fa; // 父亲的id,就是数组的下标值
int child[2]; // 两个儿子的id
int val, count, size;
}node[maxn];
int cnt;
// 当没有node的val为val时,创建一个节点
// 不更新size的原因是:后面插入的时候已经将路径中点的size都加了1,所以root的size已经加过了
void createNode(int root, int val) {
node[++cnt].val = val;
node[cnt].fa = root;
node[root].child[node[root].val < val] = cnt;
node[cnt].child[0] = node[cnt].child[1] = 0;
node[cnt].count = node[cnt].size = 1;
} -
操作:
-
旋转:包括右旋和左旋
Splay(x, goal)是在保持伸展树有序性的前提下,通过一系列旋转将伸展树的元素x调整到goal的子节点,若goal=0,则是调整到根部。
具体旋转策略和Treap一样,这里略过,可以看Treap篇。
板子:这里右旋左旋写在了一起,比较巧妙,因为左旋还是右旋取决于x到底是父亲的哪个儿子,所以可以根据这个父子关系知道是该左旋还是右旋,因为旋转的对称性,所以肯定可以写成一个函数。
void Rotate(int x) { // x旋转上去
int y = node[x].fa, z = node[y].fa;
int c = (node[y].child[0]==x); // 判断是不是左儿子,c=1左儿子,c=0是右儿子
node[y].child[!c] = node[x].child[c];
node[node[x].child[c]].fa = y;
node[x].fa = z;
if (z) { // y不是原来的树根,就需要改变z的儿子,变为x
node[z].child[node[z].child[1]==y]=x;
}
node[x].son[c] = y;
node[y].fa = x;
} -
伸展:旋转是其基本操作。伸展操作分为逐层伸展和双层伸展。
-
逐层伸展:
将x旋转到目标goal之下,若x的父节点不是goal,就一直往上旋转。如果goal为0,则旋转到根的位置。
板子:
void Splay(int x, int goal) {
while(node[x].fa != goal) {
Rotate(x);
}
if (!goal) {
root = x; // x到根了 根更新为x
}
}但是这样有可能每次调用在最坏情况下都是O(n)的,一种方法是使用双层伸展,即每次都向上两层。
-
双层伸展:分为三种情况:
-
Zig/Zag:当x的父亲y已经是根的时候,只需要旋转一次即可,根据x和y的父子关系进行旋转。
-
Zig-Zig / Zag-Zag:x和x的父亲y,以及y和y的父亲z的父子关系相同时,显然两次旋转方向相同时。
-
Zig-Zag / Zag-Zig:x和x的父亲y,以及y和y的父亲z的父子关系相同时,显然两次旋转方向不同时。
情况1和情况3的情况和逐层伸展完全一致。对于情况2,逐层伸展两次都是x在旋转,而双层伸展时先进行x的父亲y的旋转,再进行x的旋转。如下图所示:
可以发现,双层伸展比逐层伸展得到的树高度更小,又基本操作的时间复杂度和树高成正比,所以效率更高。所以代码的思路为:对于情况1,旋转x;对于情况2,旋转y再旋转x;对于情况3,旋转两次x。
板子:
void Splay(int x, int goal) {
while(node[x].fa != goal) {
int y = node[x].fa, z = node[y].fa;
if (z != goal) { // 情况 2 & 3
((node[z].child[0]==y)^(node[y].child[0]==x)) ? Rotate(x):Rotate(y); // 为真则父子关系不同,为情况3,两次都是旋转x;不然情况2需要旋转y
}
Rotate(x); // 无论哪种情况,最后都要转一下x
}
if (!goal) {
root = x; // x到根了 根更新为x
}
}Tarjan等人已经证明,双层伸展均摊时间为O(logn)。
-
-
查找:根据val查找节点,和二叉搜索树的查找一样,如果查找成功,则将val的点旋转到根。
板子:
bool findval(int val) {
int x = root;
while(true) {
if (node[x].val == val) { // 找到
Splay(x, 0); // 将x旋转到根
return true;
}
if (node[x].child[node[x].val < val]) { // 根据x的值看往哪边找,如果还有点,则还可以继续找
x = node[x].child[node[x].val < val];
} else {
return false;
}
}
} -
插入:和二叉搜索树的插入一样,插入之后旋转到根节点。
板子:
void Insert(int val) {
int x, nxt;
for(x = root; node[x].child[node[x].val < val]; x = node[x].child[node[x].val < val]) { // 不然非小即大
node[x].size++;
if (node[x].val == val) { // 相等特判
node[x].count++;
return;
}
}
node[x].child[node[x].val < val] = createNode(x, val); // 以x为根创建一个值为val的新节点
Splay(node[x].child[node[x].val < val], 0);
} -
分裂:
以val为界,将伸展树分裂为两棵小伸展树,即val节点的左右子树。即先找到val节点,调整为根节点,得到左右子树,删除根节点,分裂为两棵伸展树。
板子:
bool Split(int val, int &t1, int &t2) {
if (findval(val)) {
// find如果找到了会Splay,之后根节点就已经改过了
t1 = node[root].child[0];
t2 = node[root].child[1];
node[t1].fa = node[t2].fa = 0;
return true;
}
return false;
} -
合并:
将两棵伸展树t1和t2合并为一个伸展树,必须保证t1的元素最大值小于t2的元素最小值。首先找到t1中的最大元素x,这个操作同时将x调整到伸展树的根,因为x是t1中最大的,所以显然没有右子树,然后将t2作为x的右子树,就得到了合并的伸展树root。
板子:
void Findmax(int t) {
while (t) { // 一直往右下找,直到没有点,前一个点就是最大节点
pre = t;
t = node[t].child[1];
}
Splay(pre, 0); // Splay后root已经为pre了
}
void Join(int t1, int t2) {
if (t1) { // 特判是不是空
Findmax(t1);
node[root].child[1] = t2; // 新的根节点的右儿子
node[t2].fa = root; // t2认爹
}
else {
root = t2;
}
} -
删除:如果count只为1,则将元素val从伸展树中删除,首先在伸展树中查找val,然后分裂为两棵子树,再合并。不然数量少一即可。
void Delete(int val, int type) { // type为0则数量少1,type为1则完全删掉所有值为这个值
int t1 = 0, t2 = 0;
if (findval(val)) {
if (node[root].count==1 || type == 1) {
Split(val, t1, t2);
Join(t1, t2);
} else {
node[root].count--;
}
}
} -
前驱&后继:
-
前驱板子:
int GetPre(int val) { // 找val在这棵树中的前驱
int p = root;
int res = -inf;
while(p) {
if (node[p].val < val) {
res = node[p].val;
p = node[p].child[1];
} else {
p = node[p].child[0];
}
}
return res;
} -
后继板子:
int GetNxt(int val) { // 找val在这棵树中的后继
int p = root;
int res = inf;
while(p) {
if (node[p].val > val) {
res = node[p].val;
p = node[p].child[0];
} else {
p = node[p].child[1];
}
}
return res;
}
-
-
查找x子树中第k小元素:
板子:
int Findk(int x, int k) {
if (k > node[x].size || k<=0) return inf;
while(1) {
int down = node[x].child[0]?node[node[x].child[0]].size:0;
int up = node[x].child[0]?node[node[x].child[0]].size + node[x].count:node[x].count;
if (k > down && k <= up) {
return x;
}
if (k <= down) {
x = node[x].child[0];
} else {
k -= up;
x = node[x].child[1];
}
}
} -
-
-
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 25岁的心里话