伸展树的基本操作——以【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公司是一家大型专业化软件公司,有着数以万计的员工.作为一名出纳员,我的任务之一便是统计每位员工的工资.这本来是一份不错的工作,但是 ...
随机推荐
- 七夕节---hdu1215(打表求因子和)
题目链接:http://acm.hdu.edu.cn/showproblem.php?pid=1215 给你一个数n(1<=n<=50w)求n的所有因子和, 由于n的范围比较大,所以要采用 ...
- 小米范工具系列之五:小米范WEB口令扫描器
最新版本1.2,下载地址:http://pan.baidu.com/s/1c1NDSVe 文件名 webcracker,请使用java1.8运行 小米范WEB口令扫描器的主要功能是批量扫描web口令 ...
- modelform动态显示select标签的对象范围
既根据当前登录人,动态显示对象相关的的select的选项,例如 A登录,只显示A的客户,B登录,只显示B自己的客户 先了解form的ModelChoiceField字段(这个表格没意义,就是引出参数q ...
- day13(JSTL和自定义标签&MVC模型&javaweb三层框架)
day13 JSTL标签库(重点) 自定义标签(理解) MVC设计模式(重点中的重点) Java三层框架(重点中的重点) JSTL标签库 1 什么是JSTL JSTL是apache对EL表达式的扩 ...
- sql server 里的文件和文件组使用
转自:https://www.cnblogs.com/woodytu/p/5821827.html 参考:https://www.sqlskills.com/blogs/paul/files-and- ...
- 002-spring cache 基于声明式注解的缓存-01-Cacheable annotation
一.简述 对于缓存声明,抽象提供了一组Java注解: @Cacheable触发缓存填充(这里一般放在创建和获取的方法上) @CacheEvict触发缓存驱逐(用于删除的方法上) @CachePut更新 ...
- Linux相关知识总结
查看CPU使用情况 查看内存 ps命令显示所有运行中的进程等命令 top 命令用来显示CPU的使用情况free命令用来显示内存的使用情况 select和epoll区别select,poll,epoll ...
- vue项目多页配置
文件目录 ├─build ├─config ├─dist │ └─static │ ├─css │ ├─img │ └─js ├─src │ ├─assets │ │ ├─img │ │ ├─js │ ...
- (1)了解cocostudio基础
操作界面 Cocos Studio的界面主要分为菜单栏.工具栏.对象面板.资源面板.画布面板.属性面板.动画面板.输出窗口.状态栏九部分组成,如下图: 菜单栏 菜单栏为Cocos Studio ...
- 手把手教你学node.js 之使用 eventproxy 控制并发
使用 eventproxy 控制并发 目标 建立一个 lesson4 项目,在其中编写代码. 代码的入口是 app.js,当调用 node app.js 时,它会输出 CNode(https://cn ...