伸展树的基本操作——以【NOI2004】郁闷的出纳员为例
前两天老师讲了伸展树……虽然一个月以前自己就一直在看平衡树这一部分的书籍,也仔细地研读过伸展树地操作代码,但是就是没写过程序……(大概也是在平衡树的复杂操作和长代码面前望而生畏了)但是今天借着老师布置作业这个机会,加上hockey之前不厌其烦地手把手带着我写过四五遍Splay的神代码,是时候把它用程序实现出来了。
第一部分 伸展树基本概念和操作
伸展树是一种平衡二叉查找树,但是和其它平衡树(比如红黑树、AVL树)不同,它的节点上没有记录任何用于保持平衡的其他信息(AVL树上记录了节点的高度,而红黑树上记录了节点的颜色)。而更奇葩的地方在于,作为一棵平衡树,它在实际中可能并不平衡。当树中的节点数为N的时候,一次插入/查找操作的最坏复杂度是O(N),AVL树和红黑树试图通过修整树的结构(具体来说是高度)来避免这种最会情况的发生。而伸展树的思想并非如此——假如最坏情况的时间开销是O(N),那么我们不用尝试彻底避免它,而是只要它不要经常发生就好了。为了达到这一点,我们在每次访问一个结点之后将它通过一系列平衡树的旋转操作转到树根的位置。至于这么做的好处,我们可以通过概率和摊还分析中的势能方法证明进行M次操作的摊还时间界为O(MlogN),这个复杂度的证明过程十分地复杂,在这里不再赘述(我总是用这种方式跳过我不会的东西……学懂了之后一定补上)
在正式介绍各个操作之前先明确一下我们的节点定义形式(这些看似奇怪的定义方法在简化代码提高效率方面特别有用)
struct node
{
node *p, *s[];
int key, size;
node(){p = s[] = s[] = ; size = ; key = ;}
node(int key) :key(key) {p = s[] = s[] = ; size = ;}
bool getlr(){return p->s[] == this;}
node *link(int w, node *p){s[w] = p; if(p)p->p = this; return this;}
void update(){size = + (s[] ? s[]->size : ) + (s[] ? s[]->size : );}
} ;
node为我们的节点类,p和s[0]、s[1]均为指向node的指针类型,其中p表示当前节点的父节点,s[0]表示左儿子,s[1]表示右儿子
key表示当前节点的键值,size表示当前节点为根的子树的节点个数
接下来是两个构造函数,分别是无参数初始化和使用键值初始化,想必不用过多解释
getlr()返回一个布尔值,表示当前节点是它的父亲的哪个儿子(0为左,1为右)
link(int w, node *p)表示将节点p连接到当前节点的w孩子位置上(照例,0为左,1为右,下文中不再说明),并返回当前节点(hockey:一会你就能知道这个东西多么神)
update()表示更新当前节点的size,BTW,不熟悉C++ 中“?:”运算符的同学需要复习一下,因为接下来会多次使用
首先先复习一下平衡树的最基本操作——旋转
旋转操作这种东西最直观最容易理解的就是看图啦,接下来我们要用自然语言描述这个过程以便准确的写出程序
1.左旋:当p是它父亲的右儿子时执行左旋,将它的左儿子变为p的父亲的新的右儿子,而p原来的父亲变为p新的左儿子
2.右旋:当p是它父亲的左儿子时执行右旋,将它的右儿子变为p的父亲的新的左儿子,而p原来的父亲变为p新的右儿子
通俗一点来讲,以上图中的右旋为例,操作实际上就是将左图中的BE断开,在AE间链接一条边,然后用手捏着B让A自由下落,就变成了右图的情况,反之即是左旋。
另外很重要的一点,既然p的旋转方式完全取决于p->getlr()的值,那么我们完全没有必要把左旋和右旋分开成两个程序,下面上代码~
void rot(node *p)
{
node *q = p->p->p;
p->getlr() ? p->link(, p->p->link(, p->s[])) : p->link(, p->p->link(, p->s[]));
p->p->update();
if(q)q->link(q->s[] == p->p, p); else{p->p = ; root = p;}
}
接下来开始解释代码——
首先先找到p的爷爷保存起来。
接下来一行就是重点!请牢记旋转操作的定义兵仔细读这行代码:如果p是右儿子,就将p的左儿子连接到p的父亲上,再将连接好的p的父亲连接到p的左儿子位置上,反之则是右旋。这一行充分体现了link()函数的优越性。
然后更新p的父亲(这时的p的父亲还是原来那个父亲,想一想为什么)
最后把p连到它父亲原来的位置上
至于为什么不需要更新p,答案是我们会在splay操作中一直调用rot把p转到树根,那时候再更新它也不迟
3.伸展:
顾名思义,伸展树的核心操作自然应该是伸展(splay)。splay(p)的定义很简单,为将p转到根的位置上,我们需要细致的考虑一下它的实现方式。
最简单的,也是最容易想到的方式就是不停地对p做旋转直至p被转到根上,但是这样会有一个问题——在某种情况下均摊时间界会被破坏,而得到这样的一组输入是轻而易举的[1]
下图很好地说明了这个例子(Ubuntu下画图不好用大家见谅):如果我们不停地旋转做左图中的1把它旋转到根的话,结果就会变成右图那样(在黑板上画一画)
显然如果我们继续访问2号节点,整棵树仍旧会像一条链,每次访问的复杂度都是O(N),这样我们的平衡树就没有意义了。
但是我们有没有什么方法来避免它?答案是肯定的。接下来我们将引入双旋的概念(将前文中的左旋右旋统称为单旋)
(1)一字形旋转:
这种旋转方式就是为了避免上文中那种情况的出现而创造的。它的自然语言说明如下:如果p和p的父亲同是它们的父亲的左儿子或者右儿子时,先对p的父亲进行一次单旋,再对p进行单旋,如下图所示
我们现在想要把7号节点转到根上,首先对6进行一次左旋
然后我们再对7做一次左旋,就变成了下图那样
从直观角度看来,这种旋转方式并没有任何优化效果——因为它把一条链变成了反过来的另外一条链,但是实则不然。在图中我们用一个等腰三角形来代表一棵子树,但是子树的尺寸并不一致——大家可以通过手推一下上文中8个节点的例子使用一字形双旋转的出色效果,这里不再赘述(图太难画了……)
(2)之字形旋转:
这是另外一种双旋转,当p和p的父亲不同为左儿子或者右儿子的时候,直接对p进行两次单旋就好了
综上我们可以发现双旋转的特殊情况仅有一字形那一种,接下来Splay核心操作代码奉上~
void splay(node *p)
{
while(p->p && p->p->p)
p->getlr() == p->p->getlr() ? (rot(p->p), rot(p)) : (rot(p), rot(p));
if(p->p)rot(p);
p->update();
}
这里定义splay(node *p)意为把p旋转到根。while循环中是两种双旋转,如果没有通过双旋转转到根,则最后进行一次单旋转。由于在rot操作中我们没有更新p,在最后我们把它更新一下
接下来的操作都很基础很简单啦:大部分和一般的二叉查找树没有区别
4.插入:
直接像二叉查找树一样插入就好啦,记得在最后把插入的节点splay到根上
void insert(int x)
{
if(!root){root = new node(x); return;}
node *p = root, *p1;
while(p){p1 = p; p = p->s[p->key < x];}
p = new node(x);
p1->link(p1->key < x, p);
splay(p);
}
5.查找:
还是像二叉查找树一样查找,最后不要忘记splay到根上,然后返回找到的那个节点的指针,找不到则返回空指针
node *find(int x)
{
node *p = root;
while(p && p->key != x)p = p->s[p->key < x];
if(p)splay(p);
return p;
}
6.寻找第k小:
这个难度不大,稍微需要一点分析——如果p的左子树的size+1恰好等于k,则p为所求,若左子树的size大于等于k,则在左子树中继续查找。若左子树的size+1小于k,则在右子树中继续查找,同时别忘了把k减去左子树size+1。最后返回第k小的指针,找不到则返回空指针。特别注意左子树不存在的情况
node *findKth(int k)
{
if(root->size < k)return ;
node *p = root;
while(!(((p->s[] ? p->s[]->size : ) < k) && ((p->s[] ? p->s[]->size : ) + >= k)))
if(!p->s[]){k -= ; p = p->s[];}
else {if(p->s[]->size >= k)p = p->s[];else{k = k - p->s[]->size - ;p = p->s[];}}
if(p)splay(p);
return p;
}
7.前驱:
prev()操作求的是比当前根小的最大的数的指针,没什么难度,主要在删除操作时会用到,别忘记splay到根
node *prev()
{
node *p = root->s[];
if(!p)return ;
while(p->s[])p = p->s[];
splay(p);
return p;
}
8.后继:
和前驱操作对应的,求比当前根大的最小的数,也是在删除操作会用到
node *succ()
{
node *p = root->s[];
if(!p)return ;
while(p->s[])p = p->s[];
splay(p);
return p;
}
9.splay2:
同样是为删除操作做的准备工作,和前面的splay唯一的区别在于将p旋转到某一个顶点的儿子位置而非根节点的位置上
void splay(node *p, node *tar)
{
while(p->p != tar && p->p->p != tar)
p->getlr() == p->p->getlr() ? (rot(p->p), rot(p)) : (rot(p), rot(p));
if(p->p != tar)rot(p);
p->update();
}
我们在调用的时候会保证tar是p的某一个祖先(一般情况下tar是根节点)。特别地,我们可以用splay(p, 0)来代替splay(p)
10.删除:
在前面做了那么多准备工作之后,终于开始进行删除啦。伸展树中只支持段删除(亦即删除区间[l,r]内的左右节点)。我们先找到l的前驱p和r的前驱q(此时保证q为根),然后将p splay到q的左儿子处,这是p的右儿子就是所有满足区间[l,r]的节点,直接删除即可。如果原树中没有l和r,我们直接把它insert进去就行(反正最后会被删掉)。记得特别处理前驱后继不存在的情况,最后需要进行update
void del(int l, int r)
{
if(!find(l))insert(l);
node *p = prev();
if(!find(r))insert(r);
node *q = succ();
if(!p && !q){root = ; return;}
if(!p){root->s[] = ; root->update(); return;}
if(!q){splay(p, ); root->s[] = ; root->update(); return;}
splay(p, q);
p->s[] = ;
p->update();
q->update();
}
小结:
到此为止,伸展树的全部基础操作已经讲解完了。我们可以在伸展树上维护很多其他的信息来达到某些效果(比如起到线段树的作用)。数据结构是固定的,但它的思想是灵活的。在实际应用中不应该拘泥于模板,而是应该大胆创新,突破现有的束缚
第二部分 应用:【NOI2004】郁闷的出纳员分析
题目大意:实现一个数据结构满足在序列中插入一个数k、将所有数加上k、将所有数减去k并删除所有小于min的数、查找第K大的数这四种操作。满足操作数m<=100000,所有的数<=200000
很显然这道题可以用一个裸的伸展树来实现,但是需要做一些小小的调整:
1.在每个节点上增加一个值num,表示当前节点重复出现的个数(因为二叉查找树默认为两两顶点之间是互异的,对于重复的数我们只能把它记在同一个顶点里),对应需要修改update()、findKth()两个操作
2.每次加减都是对所有数的操作,如果我们直接模拟这个操作一定会超时,应该开一个变量delta,表示所有数的变化量
3.题目中有一个地方没有描述清:如果一个人来了就走,不计在走的人数里
//date 20131201
#include <cstdio>
#include <cstring> #define INF 1000000 int ans; struct Splay
{
struct node
{
node *p, *s[];
int key, size, num;
node(){p = s[] = s[] = ; size = num = ; key = ;}
node(int key) :key(key) {p = s[] = s[] = ; size = num = ;}
bool getlr(){return p->s[] == this;}
node *link(int w, node *p){s[w] = p; if(p)p->p = this; return this;}
void update(){size = num + (s[] ? s[]->size : ) + (s[] ? s[]->size : );}
} *root;
void rot(node *p)
{
node *q = p->p->p;
p->getlr() ? p->link(, p->p->link(, p->s[])) : p->link(, p->p->link(, p->s[]));
p->p->update();
if(q)q->link(q->s[] == p->p, p); else{p->p = ; root = p;}
}
void splay(node *p, node *tar)
{
while(p->p != tar && p->p->p != tar)
p->getlr() == p->p->getlr() ? (rot(p->p), rot(p)) : (rot(p), rot(p));
if(p->p != tar)rot(p);
p->update();
}
void preset(){root = ;}
node *find(int x)
{
node *p = root;
while(p && p->key != x)p = p->s[p->key < x];
if(p)splay(p, );
return p;
}
void insert(int x)
{
if(!root){root = new node(x); return;}
if(find(x)){++root->num; root->update(); return; }
node *p = root, *p1;
while(p){p1 = p; p = p->s[p->key < x];}
p = new node(x);
p1->link(p1->key < x, p);
splay(p, );
}
node *findKth(int k)
{
if(root->size < k)return ;
node *p = root;
while(!(((p->s[] ? p->s[]->size : ) < k) && ((p->s[] ? p->s[]->size : ) + p->num >= k)))
if(!p->s[]){k -= p->num; p = p->s[];}
else {if(p->s[]->size >= k)p = p->s[];else{k = k - p->s[]->size - p->num;p = p->s[];}}
if(p)splay(p, );
return p;
}
node *prev()
{
node *p = root->s[];
if(!p)return ;
while(p->s[])p = p->s[];
splay(p, );
return p;
}
node *succ()
{
node *p = root->s[];
if(!p)return ;
while(p->s[])p = p->s[];
splay(p, );
return p;
}
void del(int l, int r)
{
if(!find(l)){insert(l);--ans;}
node *p = prev();
if(!find(r)){insert(r);--ans;}
node *q = succ();
if(!p && !q){ans += root->size; preset(); return;}
if(!p){ans += root->s[] ? root->s[]->size : ; root->s[] = ; root->update(); return;}
if(!q){splay(p, ); ans += root->s[] ? root->s[]->size : ; root->s[] = ; root->update(); return;}
splay(p, q);
if(p->s[])ans += p->s[]->size;
p->s[] = ;
p->update();
q->update();
}
}S; int n, m;
char sign; int x; int main()
{
scanf("%d%d\n", &n, &m);
int delta = ;
S.preset();
ans = ;
for(int i = ; i <= n; ++i)
{
scanf("%c %d\n", &sign, &x);
switch(sign)
{
case 'I': if(x >= m)S.insert(x - delta);else ++ans; break;
case 'A': delta += x; break;
case 'S': delta -= x; S.del(-INF, m - delta - ); break;
case 'F': if(!S.root || x > S.root->size)printf("-1\n");else{S.findKth(S.root->size + - x); printf("%d\n", S.root->key + delta);}
}
}
printf("%d\n", ans);
return ;
}
参考文献:
[1]数据结构与算法分析(C++描述 第三版),【美】Mark Allen Weiss, 张怀勇等 译,人民邮电出版社,2007
[2]算法导论(第二版),【美】Thomas H. Cormen , Charles E. Leiserson, Ronald L. Rivest, Clifford Stein, 潘金贵等 译,机械工业出版社,2011
[3]ACM国际大学生程序设计竞赛:知识与入门,俞勇,清华大学出版社,2012
伸展树的基本操作——以【NOI2004】郁闷的出纳员为例的更多相关文章
- 权值线段树+动态开点[NOI2004]郁闷的出纳员
#include<iostream> #include<stdio.h> #include<algorithm> #include<string.h> ...
- bzoj1503: [NOI2004]郁闷的出纳员(伸展树)
1503: [NOI2004]郁闷的出纳员 题目:传送门 题解: 修改操作一共不超过100 直接暴力在伸展树上修改 代码: #include<cstdio> #include<cst ...
- BZOJ_1503 [NOI2004]郁闷的出纳员 【Splay树】
一 题面 [NOI2004]郁闷的出纳员 二 分析 模板题. 对于全部员工的涨工资和跌工资,可以设一个变量存储起来,然后在进行删除时,利用伸展树能把结点旋转到根的特性,能够很方便的删除那些不符合值的点 ...
- bzoj1503 [NOI2004]郁闷的出纳员(名次树+懒惰标记)
1503: [NOI2004]郁闷的出纳员 Time Limit: 5 Sec Memory Limit: 64 MBSubmit: 8705 Solved: 3027[Submit][Statu ...
- BZOJ_1503_[NOI2004]郁闷的出纳员_权值线段树
BZOJ_1503_[NOI2004]郁闷的出纳员_权值线段树 Description OIER公司是一家大型专业化软件公司,有着数以万计的员工.作为一名出纳员,我的任务之一便是统计每位员工的 工资. ...
- bzoj 1503: [NOI2004]郁闷的出纳员 -- 权值线段树
1503: [NOI2004]郁闷的出纳员 Time Limit: 5 Sec Memory Limit: 64 MB Description OIER公司是一家大型专业化软件公司,有着数以万计的员 ...
- BZOJ 1503: [NOI2004]郁闷的出纳员
1503: [NOI2004]郁闷的出纳员 Time Limit: 5 Sec Memory Limit: 64 MBSubmit: 10526 Solved: 3685[Submit][Stat ...
- 1503: [NOI2004]郁闷的出纳员 (SBT)
1503: [NOI2004]郁闷的出纳员 http://www.lydsy.com/JudgeOnline/problem.php?id=1503 Time Limit: 5 Sec Memory ...
- [NOI2004]郁闷的出纳员(平衡树)
[NOI2004]郁闷的出纳员 题目链接 题目描述 OIER公司是一家大型专业化软件公司,有着数以万计的员工.作为一名出纳员,我的任务之一便是统计每位员工的工资.这本来是一份不错的工作,但是令人郁闷的 ...
- [BZOJ1503][NOI2004]郁闷的出纳员
[BZOJ1503][NOI2004]郁闷的出纳员 试题描述 OIER公司是一家大型专业化软件公司,有着数以万计的员工.作为一名出纳员,我的任务之一便是统计每位员工的工资.这本来是一份不错的工作,但是 ...
随机推荐
- Page Control
- RPN网络
Region Proposal Network RPN的实现方式:在conv5-3的卷积feature map上用一个n*n的sliding window(论文中n=3)生成一个长度为256(ZF网络 ...
- 爬虫之selenium使用
详细使用链接: 点击链接 selenium介绍: selenium最初是一个自动化测试工具,而爬虫中使用它主要是为了解决requests无法直接执行JavaScript代码的问题 selenium本质 ...
- centos Linux系统日常管理1 cpuinfo cpu核数 命令 w, vmstat, uptime ,top ,kill ,ps ,free,netstat ,sar, ulimit ,lsof ,pidof 第十四节课
centos Linux系统日常管理1 cpuinfo cpu核数 命令 w, vmstat, uptime ,top ,kill ,ps ,free,netstat ,sar, ulimit ...
- 006-markdown基础语法
1.标题 # 这是一级标题 ## 这是二级标题 ### 这是三级标题 #### 这是四级标题 ##### 这是五级标题 ###### 这是六级标题 2.字体 *这是倾斜的文字* **这是加粗的文字** ...
- c#通过webrequest请求远程http服务时出现的问题
用WebRequest和WebClient,两种方式,请求一个由http服务发布的应用,结果出现异常. 有三种,1.System.Net.WebException: 服务器提交了协议冲突. Secti ...
- zwPython,字王集成式python开发平台,比pythonXY更强大、更方便。
zwPython,字王集成式python开发平台,比pythonXY更强大.更方便. 更强大,内置opencv.cuda/opencl.NLTK自然语言.pygame游戏设计等多个重量级模块库. 更方 ...
- Python: 分数运算
fractions 模块可以被用来执行包含分数的数学运算 >>> from fractions import Fraction >>> a = Fraction(5 ...
- CEF3开发者系列之CefEnableHighDPISupport详解
在CEF3中,CefEnableHighDPISupport()这个接口函数在使用时一般不为人所注意,但是如果稍有不慎,会造成打开的网页不能填满窗口的问题.如果是需要flash插件才能运行的游戏.则会 ...
- nginx限制蜘蛛的频繁抓取
蜘蛛抓取量骤增,导致服务器负载很高.最终用nginx的ngx_http_limit_req_module模块限制了百度蜘蛛的抓取频率.每分钟允许百度蜘蛛抓取200次,多余的抓取请求返回503. ngi ...