目录
知识点概要知识点详解 平衡因子子树的重构基础操作复杂度分析关于替罪羊树代码(luogu3369 && BZOJ3224)在各种二叉平衡树中,大多数的平衡树都是通过旋转来维护这棵二叉查找树的性质,并且尽量保证每次的查找的复杂度为\(log\)的。然而说实话,各种情况的旋转很容易写挂,考场上一旦写挂掉就会心态爆炸,所以我们或许可以通过一些奇妙的方法来使这棵二叉平衡树能够拥有与有需要旋转的二叉查找树同样的性质。而替罪羊树则是这众多非旋转的二叉平衡树中比较好些也比较好理解的一种了(至少比无旋Treap要简单的多了)。 接下来讲替罪羊树是如何实现非旋转的。首先问何为数据结构的优美?
暴力啊~
数据结构最优美的莫过于最暴力却又仍然满足复杂度的数据结构了(类似的还有分块啊,莫队啊,都是很优美的 逃)。所以替罪羊树秉承着最暴力的思想:如果树的结构不够优美了(即查找的复杂度要退化成\(O(n)\)或者比\(log\)要大了),那么就把这棵子树拍扁了重构,具体如何重构将在下面讲述。这样就能重新回复二叉查找树优美的性质了。所以只要是没有需要提取子树来进行干什么的一些操作,那么替罪羊树无论在代码量和运行时间上都可以优于splay许多。
注:接下来的代码都以luogu3369为例
平衡因子,顾名思义,是为了判断这棵树是否平衡的一个系数,而在替罪羊树中,这个平衡因子就是用来判断这一棵子树是否需要重构的标准,我们用\(\alpha\)来表示。当以x为根这颗子树的满足\(max(sz[lson,sz[rson])>=sz[x]*\alpha\)时,我们就可以对这颗子树进行重构了。一般\(\alpha\)取值为0.5~1左右,普通题目一般取0.7左右就行了。如果取的\(\alpha\)较大,那么重构的次数就比较小,所以插入的效率较高,而查询的效率会相对较低,反之也是如此。
子树重构的代码(由于比较短,就直接现在\(node\)结构体里了),代码也顺便给出了\(node\)结构体里的各种变量的定义。
struct node { node *l,*r;//左右孩子 int v,sz,valid;//节点的值,节点的字数大小,节点合法(未被删除)子树大小 bool del;//节点是否被删除 bool Bad() {//字数是否需要重构 return (double)l->sz>alpha*(double)sz||(double)r->sz>alpha*(double)sz; } void Update() {//节点的各个值的更新 sz=!del+l->sz+r->sz; valid=!del+l->valid+r->valid; } };当我们的树不够平衡的时候,我们就要对于这个子树进行重构了,然而由于这是一棵二叉查找树,所以我们仍要维护这个性质,即保持重建后整棵树的中序遍历与原树相同。所以我们就可以对需要重构的子树进行中序遍历一下,然后二分递归下去进行重构(有点类似与线段树的构建)。 下面是构造之前与构造之后的图: 构造前: 构造后: 子树重构的代码:
node *Build(std::vector<node*> &v,int l,int r) { if(l>=r) return null; int mid=(l+r)>>1; node *o=v[mid]; o->l=Build(v,l,mid); o->r=Build(v,mid+1,r); o->Update(); return o; } void Dfs(node *o,std::vector<node*> &v) { if(o==null) return ; Dfs(o->l,v); if(!o->del) v.push_back(o);//如果这个节点被删除了,那么就不用进行重构了 Dfs(o->r,v); if(o->del) delete o; //节点删除 } void ReBuild(node* &o) { std::vector<node*>v;//v数组记录中序遍历之后各个需要重构的节点 Dfs(o,v); o=Build(v,0,v.size());//o表示重构之后子树的根节点 }替罪羊树的基础的操作基本都与普通平衡树的操作类似。 插入: 与普通的splay大致相同,只不过插入之后需要判断子树是否需要重构。
void Insert(int x,node* &o) { if(o==null) {//新建节点 o=new node; o->l=o->r=null; o->del=false; o->sz=o->valid=1; o->v=x; return ; } ++o->sz;++o->valid;//在递归下去的时候就把该维护的信息维护好 if(x>=o->v) Insert(x,o->r); else Insert(x,o->l); if(o->Bad()) ReBuild(o);//判断是否需要重构 }删除: 这里的删除并不是直接的删除,而是给这个节点打上一个删除标记,直到重构子树的时候再把无用的节点删除掉。
void Delete(node *o,int rnk) {//当前节点、需要删除的节点的排名(所以在删除之前需要查询一下rank) if(!o->del&&rnk==o->l->valid+1) { o->del=1;//打上删除标记 --o->valid; return ; } --o->valid;//维护子树未被删除节点的信息 if(rnk<=o->l->valid+!o->del) Delete(o->l,rnk); else Delete(o->r,rnk-o->l->valid-!o->del); }查询rank与kth也都与其他的平衡树差不多。
int GetRank(node* o,int x) { int ans=1; while(o!=null) { if(o->v>=x) o=o->l; else { ans+=o->l->valid+!o->del; o=o->r; } } return ans; } int FindKth(node* o,int x) { while(o!=null) { if(!o->del&&o->l->valid+1==x) return o->v; if(o->l->valid>=x) o=o->l; else { x-=o->l->valid+!o->del; o=o->r; } } }虽然说是要重构子树,但是复杂度并不会太高,重构一次为\(O(n)\)的,并且只有插入的节点达到\(\alpha*size[t]\)的时候才有可能会进行重建,所以总共会重建\(log\)次,而其他操作的复杂度也是不高的,所以均摊下来总体的复杂度是\(O(nlogn)\)的,也是十分优美的。
替罪羊树虽然在时间复杂度和代码量上都远超splay,然而为什呢替罪羊树运用的却并不广泛呢。原因或许就在于他的非旋转性吧,splay虽然需要用到旋转的操作,但是他灵活性确实无可质疑的,因为splay在维护一个序列的操作中近乎完美。所以替罪羊树的应用也并没有splay那么广泛,不过如果碰到题目可以用替罪羊树代替splay的话,那么就可以毫无异议的使用替罪羊树了。
转载于:https://www.cnblogs.com/Apocrypha/p/9430380.html
相关资源:JAVA上百实例源码以及开源项目