首页 > 技术文章 > SLT学习——leafes tree扩展 【文艺平衡树】

hyfhaha 2019-04-09 17:15 原文

这是一个全新的数据结构

md,别看这篇文章了,这篇已经废了。

百折不饶,再交一次,更新复杂度证明

这里是HYF,蒟蒻一只,最近因某些原因开始学数据结构了,然后就写了这篇题解。


下面给大家介绍一个全新的数据结构,暂且称作IST(Immortal segment tree),你们也可以称作YYC Segment Tree(像FHQ_Treap 一样)

本人是这数据结构的第一弹使用者,对这个数据结构起到几乎没用的作用。这篇题解也算是这个数据结构的一个预告,给广大OIer了解一下这个数据结构。

由于我们学校信息学团实在是太菜了(除了YYC巨佬,就是他发明了这个数据结构;还有ZLH巨佬,他初二LCT)所以这个数据结构还没有被完全研究透彻,只能做一些比较正常的操作,例如:

1、P3391 【模板】文艺平衡树

2、P5055 【模板】可持久化文艺平衡树

3、全部线段树

4、全部平衡树,额,只不过这毕竟是线段树,所以用来写平衡树的操作有点复杂,这个数据结构更偏向于像文艺平衡树的区间树。

而且这个数据结构有几个十分优秀的特点:

1、它思想简单!!!你只需要会线段树就可以了。

2、实现简单,代码不超过100行

3、跑得飞快,不卡常大概300多ms。主要还是因为才刚发明,没有特殊数据去卡。

4、易于可持久化,额,我还没有掌握,反正就是很简单很简单啦,具体可以看YYC巨佬的题解

(其实这个数据结构并没有怎么强大)

YYC Segment Tree简介

辣么,下面给大家介绍YYC Segment Tree的基本思想。

由名可知,这个数据结构是基于线段树进行改造的,作为一个线段树,他却可以做一些平衡树的操作,这究竟是为什么呢?

因为他虽然是一个线段树,但是却基于区间划分,也就是分裂和合并操作。但是悲伤的是,经过区间划分后,这个线段树就没有那么平衡了,不过他还是有很多线段树的性质。所以我们还是可以当线段树来写的QAQ。

有很多人(至少有一个审题解的管理员)认为这是一个FHQ_Treap的改造版。这也是这篇题解第一次被拒了的原因,我有点不开心,因为这和FHQ_Treap根本是两回事,虽然IST也是基于区间划分。但是我们把IST建好树后会发现,真正有用的也就是最底下的那一层节点,他们存的是真实数据,而其他的只不过是虚节点,存的只是存储了标记与上传的数据。而FHQ_Treap每一个点都存的是实节点,存的是实在的数据,所以它是平衡树,而IST却是一棵线段树。

IST长这样!!!:
IST

操作介绍

1、分裂

把整颗树分成三个部分,左,中,右,最后中部分就是我们要操作的部分。例如这道题的旋转操作,我们像线段树一样递归下去,如果要此时的区间完全在要操作的内,那么就分到中区间;如果在完全在要操作的区间左部,那就分到左区间;右区间同理。

最后把分裂出的节点删掉,因为那已经没用了,删掉的节点放入垃圾回收,以便于下次可以重复利用该节点,节省空间。

看下代码吧:

void spilt(int node,int begin,int end,int x,int y){
    if(end<x){Left[++le]=node;return;}	//如果查询区间在要修改区间左部,并入左数组 
    if(x<=begin&&end<=y){middle[++mi]=node;return;}	//如果查询区间在要修改区间中,并入中数组 
    if(y<begin){Right[++ri]=node;return;}	//如果查询区间在要修改区间右部,并入右数组 
    pushdown(node);						//pushdown更新tag标记 
    int mid=begin+tree[tree[node].l].size-1;	//注意,因为经过修改后的树不像线段树那么平衡,所以mid值有所变动 
    spilt(tree[node].l,begin,mid,x,y);		//向左子树递归分裂 
    spilt(tree[node].r,mid+1,end,x,y);		//向左子树递归分裂 
    rub[++tot]=node;					//这里是垃圾回收 
}

2、合并操作

我们先把分裂好的三个部分:左,中,右.合并在一个数组里,直接三个for赋值就可以了。像这样:

int cnt=0;
    for(int i=1;i<=le;++i)t[++cnt]=Left[i];
    for(int i=1;i<=mi;++i)t[++cnt]=middle[i];
    for(int i=1;i<=ri;++i)t[++cnt]=Right[i];

然后我们就对合并好的数组进行操作,就是把他们两两合并,每一次新建一个节点存他们合并后的数据,然后把这个新节点的左儿子和右儿子分别指向要合并的两个节点,最后再pushup就可以了。

我觉得这里可能大家会比较难理解,其实就是一个重新建树的一个过程(我们也可以理解为一个石子合并的过程,只不过是相邻两个合并而已)。下面看看完整代码比较好理解:

void marge(){					//合并 
	int cnt=0;	//记得数组清0,要按顺序合并哦 
    for(int i=1;i<=le;++i)t[++cnt]=Left[i];	//把左部分合并 
    for(int i=1;i<=mi;++i)t[++cnt]=middle[i];	//把中部分合并 
    for(int i=1;i<=ri;++i)t[++cnt]=Right[i];	//把右部分合并 
    while(cnt>1){	//重新建树过程 
        int mid=(cnt+1)/2;	//取mid值
        for(int i=1;i<=mid;++i){	//将线段树底部节点一一合并 
            int x=t[i*2-1],y=t[i*2];
            if(!x||!y)t[i]=x+y;	//如果有一个是空,特判 
            else{
                int node=New();	//新建一个节点 
                tree[node].l=x;tree[node].r=y;	//新节点指儿子 
                pushup(node);	//pushup 
                t[i]=node;		//把新节点并入数组里 
            }
        }
        for(int i=mid+1;i<=cnt;++i)t[i]=0;//记得把剩下节点清0,原因是他们已经在上面合并过了,而且避免了奇偶的错误 
        cnt=(cnt+1)/2;	//到下一层继续建树 
    }root=t[1];			//根更新 
}

3、新建节点

在这里我们的垃圾回收就起到作用了,我们的垃圾会收就是把一些被删除的节点记在垃圾堆里,那么现在我们就可以把他们重新取出来,作为新节点的编号。嗯~,还有就是记得给新节点数据清0哦。

int New(){
    int pos=rub[tot--];
    tree[pos].l=tree[pos].r=tree[pos].size=tree[pos].x=tree[pos].tag=0;
    return pos;
}

细心的读者可能发现tree[node].size也清0了,这是为什么了,一个节点的大小怎么会是0呢?

因为它每一次新建是新建一个虚节点,虚节点刚开始是什么都没有的,只有在连向其他节点时才能有信息。

我们可以发现新建节点的操作只在合并出现,而新建节点后有个pushup操作……

4、建树

哎呀,这个操作和线段树差不多的啦,看注释吧:

void build(int node,int l,int r){
    if(l==r){
        tree[node].x=l;	//初值是i嘛 
        tree[node].size=1;	//树大小是1 
        return; 
    }int mid=(l+r)>>1;	//和线段树一毛一样 
    tree[node].l=++top;	//新建左子树的编号 
    build(top,l,mid);	//递归左子树建树 
    tree[node].r=++top;	//新建右子树的编号 
    build(top,mid+1,r);	//递归右子树建树 
    pushup(node);		//pushup
}

旋转

分三步,一分裂,二操作,三合并

主要讲一下操作部分

就是把中部分取出来,然后打标记,再翻过来,交换左右子树。

没了……

void rotate(int x,int y){
    le=0,ri=0,mi=0;		//记得左,中,右部分数组要清0 
    spilt(root,1,tree[root].size,x,y);	//分裂 
    for(int i=1;i<=mi;++i)tmp[i]=middle[i];	//把中部分提取出来 
    for(int i=1;i<=mi;++i){
        middle[i]=tmp[mi-i+1];			//翻过来,就是直接赋值嘛 
        tree[middle[i]].tag^=1;			//打标记 
        swap(tree[middle[i]].l,tree[middle[i]].r);	//交换左右子树 
    }marge();			//合并 
}

下面再扯一下pushup和pushdown吧,怕广大读者不懂

pushup and pushdown

其实pushup只是更新树大小而已,就这样:

void pushup(int node){
    tree[node].size=tree[tree[node].l].size+tree[tree[node].r].size;
}

至于pushdown下传标记,和文艺平衡树广大算法一样,我也不知道怎么讲好,看代码吧:

void pushdown(int node){
    if(tree[node].tag){			//如果有标记就操作 
        tree[node].tag=0;		//原标记清0,和线段树一样 
        tree[tree[node].l].tag^=1;	//左标记取相反值,就是0变成1,1变成0 
        swap(tree[tree[node].l].l,tree[tree[node].l].r);	//交换左子树的左右孩子,因为要翻转嘛 
        tree[tree[node].r].tag^=1;	//右标记取相反值
        swap(tree[tree[node].r].l,tree[tree[node].r].r);	//交换右子树的左右孩子
    }
}

哦,对了,忘记讲怎么输出了。

输出

直接递归最后的那颗树,然后如果是叶子节点就输出它的权值。

void print(int node){
    pushdown(node);		//pushdown 
    if(tree[node].size==1){
        printf("%d ",tree[node].x);				//输出 
        return ;
    }print(tree[node].l);print(tree[node].r);	//递归左右子树 
}

辣么,你已经学会了YYC Segment Tree的基本操作。

复杂度证明

这里引用的是YYC's blog

注:EX线段树值IST

当然EX线段树也有好有劣,深度为O(log(n))的线段树当然是最好的了。

(下文的“线段树”大多指EX线段树)

深度为O(log(n)) 的线段树,我们称之为平衡线段树。

能发现,平衡线段树满足线段树的不少性质。

如查询时最多分成O(log(n)) 几个区间。

证明十分简单,请读者自己研究。

其他性质:

我们把叶节点称为实节点,因为它存储了实际的数据。

非叶节点称为虚节点,因为它只是存储了标记与上传的数据。

(1)容易发现,虚节点都一定有两个孩子。

虚节点的分布,影响着IST的复杂度,虚节点越 平衡 ,跑的就越快。

至于如何分布,对于正确性来说无关紧要。

平衡树的每个节点必须都是存储原值的节点,所以维护不方便。

这里的虚节点可以随便删除新建(没有懒标记的情况下)。

(2)一棵树有n个实节点,那么就有(n-1)个虚节点。

很好理解,不多讲。

所以我们有一种神奇的操作。

把两颗IST合并可以O(1)。即建一个新的虚节点然后指一下。

很明显FHQ_Treap就要O(logn)

最后在说一下

建树复杂度O(n)

分裂复杂度O(树高)

合并复杂度O(树高)

复杂度讨论:

这里引用的是YYC's blog

由于本人太菜,不会严格证明

“方式1”的marge可以卡。

只要询问[1,1],[1,2],[1,3]...[1,n][1,1],[1,2],[1,3]...[1,n]可以使IST退化成链,但是在某一次询问后又会变成完全二叉树,所以复杂度应该是均摊的。

而且在随机数据中,IST每次操作都是期望logn的。

向大家提出疑问1:“方式1”的marge可以卡吗(不考虑可持久化,卡栈)?

由于现在主流的区间树都不依赖数据随机性,所以所有的区间树题目都是随机造的数据。

IST可以在其中大显身手。

而且,如果人为地在其中加入随机询问,理论上是卡不掉的.

向大家提出疑问2:“方式1”的marge加入随机询问之后可以卡吗(不考虑可持久化卡空间)?

我们定义一颗IST的“深度和”为所有实节点的深度总和。

可见如果“深度和”在O(nlogn)左右是最好的。

先把一整颗IST,split变为森林,则这个森林的“深度和”小于原来IST的深度和(具体的减少大概是O(n)~O(nloglogn)(大家可以证明一下很好证的))。

“方式1”的marge,是直接build完全二叉树。

森林中每棵树的深度增加了O(loglogn),“深度和”增加了O(nloglogn)

由于O(nloglogn)>O(n),“方式1”的marge有或没有被卡掉的可能。

当然还有第二种marge方式。

先看道题:

给出n堆果子,每次把相邻的两堆合并,需要消耗这两堆的重量总和的体力,问把这些果子合成一堆最少需要多少体力。

marge过程就可以抽象为上述题目,最少需要多少体力就是最少增加的“深度和”。

我们直接选取目前相邻的和最小的两堆合并即可。

marge变为O(log^2n)。

明显,每一次marge都会是比之前更优或相等,所以不可能被卡。

下面是第二种合并方式的代码:

int _marge(int x,int y){
	if(!x||!y)return x+y;	//如果有一个是空,特判 
    else{
        int node=New();	//新建一个节点 
        tree[node].l=x;tree[node].r=y;	//新节点指儿子 
        pushup(node);	//pushup 
        return node;
    }
}
void marge()
{
  	int cnt=0;
  	for(int i=1;i<=le;i++)t[++cnt]=Left[i];
  	for(int i=1;i<=mi;i++)t[++cnt]=middle[i];
  	for(int i=1;i<=ri;i++)t[++cnt]=Right[i];
  	int minn,pos;
  	while(cnt>1){
    	minn=1<<30;
    	for(int i=1;i<cnt;i++){
   			if (tree[t[i]].size+tree[t[i+1]].size<minn){
       			minn=tree[t[i]].size+tree[t[i+1]].size;
       			pos=i;
     		}
		}t[pos]=_marge(t[pos],t[pos+1]);
    	for(int i=pos+1;i<cnt;i++)
     	t[i]=t[i+1];
    	cnt--;
  }root=t[1];
}

由于文艺平衡树没有卡的数据,所以我写了第一种写法


最后,代码一贴,马上走人

总的关于这个数据结构的可以看YYC的blog,所有的更新和优化都会在那里声明。

后记

新的一个数据结构需要大家的鼓励呢,希望大家了解这个数据结构后为它点赞,让它让更多人知道。如果有什么疑问可以在讨论中询问,我们将为您解答。

完结

推荐阅读