好用好理解的基础Splay模板教程
Powered by Vanadianfan.
写在前面
怀着异样的心情笔者写下了人生中的第一份博客,有点小激动呢。
网上关于 S p l a y Splay Splay 的讲解的博客已经足够多了~~(因为是Tarjan参与发明的)~~,尽管这些博客的水平参差不齐,但耐心东找找西找找后总能看到一些有用的部分,蒟蒻笔者也是挣扎在这些博客中学懂的。
如果凑巧翻到这篇博客的朋友还没有学会
S
p
l
a
y
Splay
Splay,我一定不会告诉你对于
S
p
l
a
y
Splay
Splay,你只需要清楚几件事:
- 左旋和右旋的图解原理(是什么)
- 为什么需要旋转?(为什么)
- 旋转真的会让树更加平衡吗?(是这样吗)(批判性思维非常重要)
- 怎样旋转会使整棵树更趋于平衡?(怎么办)
答案并不难,默认大家都知道了,本文的初衷是详讲模板细节。
也就是如何写好用的基础Splay模板。
这亚子 S p l a y Splay Splay 剩下的部分就和二叉查找树一摸一样了,码量略大,笔者写的也就4000B多一点(VSCode显示4081B)。因为数据结构的细节较多,希望下面代码的注释能对初学 S p l a y Splay Splay 的读者提供帮助。
笔者良心发现,加了注释之后代码长度变为了……6735B。
准备
在开始阅读基本模版代码和注释前,你需要阅读以下说明。
-
代码全部使用指针。它会比使用很多数组的写法快;和结构体数组的写法速度相当。
-
通过预先声明内存池回收无用的指针。避免耗时又低能的关键字 new 和 delete。
-
笔者的习惯是能用英文单词作为名称的一律不用中文拼音缩写或者日语罗马音缩写。
-
出于审美需要,笔者不喜欢在代码中间空一行来写注释,所以只写在一行代码的后面,敬请谅解。
-
如果运算符和变量名之间不加空格,笔者会窒息。
-
变量名说明:
- l s t lst lst = left subtree,左子树; r s t rst rst 同理。
- p r e pre pre,前一个节点,指 t h i s this this 的父亲节点。
- k e y key key,键值,理解为该节点的“关键”,在这里是指节点的权值。
- w e i g h t weight weight,实际记录的是该权值出现的次数,因为我们并不希望对重复的值开很多个点。
- s i z e size size,子树的大小(需要算上子树内节点的 w e i g h t weight weight ),用于排名定位和计算排名。
- 以上是关于节点的,其余详见代码注释。
-
预先加入 -INF 和 INF 可以防止一些玄学的边界问题。(别问我为什么)
-
把函数写进 class 是因为它可以提供 private 和 protected 关键字,这样假若读者和我一样使用VSCode,或者诸如Notepad++、~~DevC++~~的自动补全功能时,可以避免把有些我们不想看到的函数显示出来,例如在 m a i n main main 函数中你肯定不希望在补全时看见 R o t a t e Rotate Rotate 函数。
class 可以配合内存池。
这亚子代码看着很舒服。 -
如果你不喜欢 namespace 的封装,你可以手动拿掉。
-
大多数人对于子树习惯写 c h [ 2 ] ch[2] ch[2],主要是因为可以直接用判断函数的结果作为数组下标,但笔者好像并不是很喜欢“父亲”“儿子”“祖先”“兄弟”的表述方式???分开写作 l s t lst lst 和 r s t rst rst,我想这样大家会看得更清楚一些。
开始吧。
#include<cstdio>
#include<iostream>
using namespace std;
const int INF = 0x3f3f3f3f; // 或者你可以选择使用climits库里面的INT_MAX
inline void read(int &w){ // 模板题输入量还是蛮大的,推荐使用复制粘贴
w = 0; char c = 0; int f = 1;
while(!isdigit(c)){if(c == '-') f = -f; c = getchar();}
while(isdigit(c)) w = (w << 3) + (w << 1) + c - '0', c = getchar(); w *= f;
}
namespace Splay_Tree{
#define MAXN 100023
#define trial (val < cur->key ? cur->lst : cur->rst) // 可以看出这是根据键值向下的过程。写着太长了
struct Node{ // 补充说一句,上面那个宏定义完全是按照二叉查找树的定义来的,应该问题不大
Node *lst, *rst, *pre;
int key, weight, size;
bool isLsubT(){return this == this->pre->lst;} // 通过它获知当前节点是其pre的lst还是rst
void InfoUpdate(){this->size = lst->size + rst->size + this->weight;} // 一键更新节点数据
};
class SplayTree{
protected: // 以下很多东西是我在main函数里调用时不希望自动补全时显示出来的
Node MemoryPool[MAXN]; // 申请内存池
Node *root, *tail, *null; // 用一个什么值都是0、自己指向自己的名叫null的节点代替NULL,避免段错误
Node *bin[MAXN]; int bin_top; // “bin”是“容器”或“垃圾桶”的意思,相当于一个栈。tail是内存池的指针
Node *NewNode(int key){ // 这个函数执行的是类似Node(){…}的节点初始化
Node *x = bin_top ? bin[--bin_top] : tail++; // 如果垃圾桶里有东西就可以回收利用啦
x->key = key;
x->weight = x->size = 1; // 新建节点的时候这两个值肯定是1没有疑问
x->pre = x->lst = x->rst = null; // null节点派上用场
return x;
}
void Midorder(Node *cur){ // 这个中序输出是方便后期调试查看过程的,你知道指针很(bù)好调试
if(cur == null) return;
if(cur->key == -INF || cur->key == INF) return; // 我们并不是很想看到它们,过滤掉
Midorder(cur->lst); // 递归执行左子树
for(int i = 0; i < cur->weight; i++)
printf("%d ", cur->key); // cout << cur->key << endl;
Midorder(cur->rst); // 递归执行右子树
}
void Clear(Node *x){ // 这个函数用来删除以x节点为根的子树,一般没有用,也许可能大概某道题目能用上
if(x == null) return;
Clear(x->lst);
Clear(x->rst);
bin[bin_top++] = x; // 很重要的回收节点的操作——把它放进垃圾桶
}
void Rotate(Node *x){ // 为了缩(kàn)减(zhe)码(shū)量(fú)把左旋和右旋写到一起,当然用ch[2]更好写
Node *y = x->pre, *z = y->pre; // 左右旋都只与这3个节点有关
int k = x->isLsubT(); // 先记录好x节点的属性,因为下一步就会被修改,而后面还会用到
if(y->isLsubT()) z->lst = x; else z->rst = x;
x->pre = z; // 这两行使x替代了y的位置,原来y和z的关系就是现在x与z的关系
if(k) y->lst = x->rst, x->rst->pre = y; // 如果原先x在y的左边,那x的右子树挂在y的左边(右旋)
else y->rst = x->lst, x->lst->pre = y; // 如果原先x在y的右边,那x的左子树挂在y的右边(左旋)
if(k) x->rst = y; else x->lst = y; // 右旋y转到x的右边,左旋y转到x的左边
y->pre = x; y->InfoUpdate(); // x转到y的上面毫无疑问。转完了更新节点信息
}
void Splay(Node *x, Node *ter){ // Splay操作,函数定义是把x节点转到的ter(terminal)节点的下面
while(x->pre != ter){ // 还没到terminal的下面呢
Node *y = x->pre; // 下面4行要连着一起读!4行之后的3行也要一起读!
if(y->pre != ter) // 这里涉及到我一开始的问题的最后一个:如何旋转。假如y还可以转,那么如果
Rotate(x->isLsubT() == y->isLsubT() ? y : x); // x和y同在左边或者同在右边先转y,否则先转x,
Rotate(x); // 然后再转x,这样做的目的是防止转了之后还是一条链(相信大家很清楚);
} // 当然如果y转不了(y的头顶就是终点啦)的话,就只能转x。
x->InfoUpdate(); // 若刚刚Rotate里面更新x的信息就会更新x很多次,
if(ter == null) root = x; // 但因为x一直在往上走,所以只需要最后更新一次就够了,算是一点点常数优化。
} // 也许快不了多少,但这说明你对Splay操作的理解更深了
void Search(int val){ // 这是一个查找某个val(value,值)是否存在的函数
if(root == null) return;
Node *cur = root; // cur(current,“当前的”或“洋流”的意思)指的是当前节点
while(val != cur->key){ // 尽量不用递归
Node *Next = trial; // 先试探一下cur的下一步是不是悬崖
if(Next == null) break; // 是悬崖就算了,说明树里没有目标值
cur = Next; // 下一步还是土地就继续走下去,还有找到目标值的可能,于是循环
} // 按“走一步,再走一步”理解就好
Splay(cur, null); // 没事尽量多转一转。好处如下:1.有益身体健康 2.树形会更加匀称圆滑~~(平衡)
} // 由于这里有Splay,那就意味着,我们在找到之后顺带把那个节点转到了根节点的位置,这将有意想不到的妙处
public: // 我们只希望在外部看到下面这些函数
SplayTree(void){ // 当我们创建一棵Splay树的时候需要做的
tail = MemoryPool; // tail是内存池的指针,它指的位置就是下一次使用位置。当然初始时指向数组的脑袋处
null = tail++; // *null是指针,所以仅仅只是个指针。你得让它先指一块内存,否则身为野指针会导致段错误
null->lst = null->rst = null; // null所指的Node结构体里的所有指针全部指回它自己
null->weight = null->weight = null->key = 0; // null所指的Node结构体里的所有值为0
root = null, bin_top = 0; // 树的根节点是null,表明树为空;显然垃圾桶目前没装东西
} // 这就达成了我们想要的效果:访问NULL的某个值时不会引发段错误。因为我们访问的是一个实实在在的0!
void Print_Midorder(void){ // 调试时输出中序遍历的公有函数入口
Midorder(root); // 内部调用私有函数,以下原理皆同
printf("\n"); // cout << endl;
}// 完美解决了调试输出时换行符的麻烦
int Calculate_Rank(int val){ // 外部调用计算排名的公有函数的入口
Search(val); // 要查找的值被转到了根的位置,而根就是root,访问非常方便!
return root->lst->size; // 由于我们之前已经放了-INF进去了,这里就不+1了
}
Node *Rank_Locate(int k){ // 外部调用获知某排名上值的公有函数的入口
k++; // 因为-INF也占了一个位置,所以第K名实际上是树里的第(K+1)名
Node *cur = root; // 让我们从树根开始爬树吧
while(true){ // 前路漫漫
if(k <= cur->lst->size) cur = cur->lst; // 你看看左子树大小够不够K这么大,不够就往左跳
else if(k > cur->lst->size + cur->weight){
k -= cur->lst->size + cur->weight;
cur = cur->rst; // 这种情况是说:你看看左子树大小和当前节点weight的总和都不够K,就往右跳
}
else return cur; // 那么最后一种情况肯定就是:cur节点即为所求了
}
}
Node *Foreward(int val){ // 外部调用寻找权值为val的节点的前驱的公有函数的入口
Search(val); // 同理,快转到根节点来(莫名联想到“快到碗里来!”)
if(root->key < val) return root; // 好吧,显然
Node *cur = root->lst; // 前驱:小于val的最大的值,而这个想法可以反应在二叉树中:
while(cur->rst != null) cur = cur->rst; // 也就是先到左边(小于val),然后尽可能向右下走(最大)
return cur; // 抓到一只小可爱
}
Node *Backward(int val){ // 外部调用寻找权值为val的节点的后继的公有函数的入口
Search(val);
if(root->key > val) return root;
Node *cur = root->rst;
while(cur->lst != null) cur = cur->lst; // 不说了,你把Foreward里面的东西镜像一下就是了
return cur; // 又抓住了
}
void Insert(int val){ // 外部调用插入一个值的公有函数的入口
Node *cur = root, *f = null; // f用来记cur的头上是谁
while(cur != null && val != cur->key)
f = cur, cur = trial; // 又开始爬树了,不过这次要随时记录cur的头上是谁
if(cur != null) ++cur->weight; // 若这个值已经存在节点那么数量+1
else{
cur = NewNode(val); // 没有的话新开一个节点
if(f != null){ // 说明这个新的节点头上是有东西的
val > f->key ? f->rst : f->lst = cur; // 啊!有没有大佬告诉我为什么反过来写不取等就不行啊?
cur->pre = f; // 儿子认父亲现场
}
}
Splay(cur, null); // 某些玄学题目,你多写几个Splay说不定就可以拯救你的TLE了。
}
void Erase_via_Value(int val){ // 外部调用删除一个值的公有函数的入口
Node *lst = Foreward(val), *nxt = Backward(val); // 话说这个“lst”是“last”,“nxt”是“next”
Splay(lst, null), Splay(nxt, lst); // 请想想这个原理,把val的前驱lst转到根,
Node *cur = nxt->lst; // 把val的后继nxt转到根的右边,val是不是一定处在nxt的左边?(lst<val<nxt)
if(cur->weight > 1){
cur->weight--; // 如果这个要擦的值有好些个,数量-1即可
Splay(cur, null);
}
else{ // 否则就只有删除节点了
Clear(nxt->lst); // 因为只有一个节点,所以这里写成 "bin[bin_top++] = nxt->lst;" 也可以
nxt->lst = null;
}
}
#undef trial // JLY大佬说这是个好习惯
};
#undef MAXN
}
using namespace Splay_Tree;
SplayTree __ST; // 听说Windows操作系统的源代码充斥着大写和各种下划线……macOS用户瑟瑟发抖……
int main(){ // ios::sync_with_stdio(false); 曾经的我天真地以为学校OJ不卡scanf的速度。
__ST.Insert(-INF), __ST.Insert(INF); // 你看我没有骗你
int m; cin >> m;
for(; m; m--){
int op, x; read(op), read(x); // cin >> opt >> x;
if(op == 1) __ST.Insert(x);
if(op == 2) __ST.Erase_via_Value(x);
if(op == 3) printf("%d\n", __ST.Calculate_Rank(x));
if(op == 4) printf("%d\n", __ST.Rank_Locate(x)->key);
if(op == 5) printf("%d\n", __ST.Foreward(x)->key);
if(op == 6) printf("%d\n", __ST.Backward(x)->key);
__ST.Print_Midorder();
}
return 0;
}
会写Splay,LCT不是梦。
咳咳
万事万物的普遍规律:开端、发展、高潮、毁灭。
在奋力写下刚刚那些注释之后,这篇博客也该结束了。在注释的过程中,也加深了自己的理解,“教学相长也”。
感谢观看,作者蒟蒻,如有错误敬请指教。
结尾撒花!