普通平衡樹學習筆記之Splay演算法

liuchanglc發表於2020-07-14

一、二叉排序樹

1、定義

二叉排序樹(Binary Sort Tree),又稱二叉查詢樹(Binary Search Tree),亦稱二叉搜尋樹。

二叉排序樹或者是一棵空樹,或者是具有下列性質的二叉樹:

1、若左子樹不空,則左子樹上所有節點的值均小於它的根節點的值;

2、若右子樹不空,則右子樹上所有節點的值均大於它的根節點的值;

3、左、右子樹也分別為二叉排序樹。

下面的這幅圖就是一個二叉排序樹

2、二叉排序樹的查詢

二叉排序樹查詢在在最壞的情況下,需要的查詢時間取決於樹的深度:

1、當二叉排序樹接近於滿二叉樹時,其深度為\(log_2n\),因此最壞情況下的查詢時間為\(O(log_2n)\),與折半查詢是同數量級的。

2、但是當二叉樹如下圖所示形成單枝樹時,其深度為\(n\),最壞情況下查詢時間為\(O(n)\),與順序查詢屬於同一數量級。

所以,為了保證二叉排序樹的查詢有較高的查詢速度,希望該二叉樹接近於滿二叉樹

或者二叉樹的每一個節點的左、右子樹深度儘量相等

\(Splay\)可以很好地解決這一問題

二、Splay

伸展樹(Splay Tree),也叫分裂樹,是一種二叉排序樹,它能在\(O(log n)\)內完成插入、查詢和刪除操作。它由丹尼爾·斯立特Daniel Sleator 和 羅伯特·恩卓·塔揚Robert Endre Tarjan 在1985年發明的。

1、結構體定義

struct trr{
    int son,ch[2],fa,cnt,val;
}tr[maxn];

其中\(son\)為兒子數量

\(ch[0]\)為左兒子的編號,\(ch[1]\)為右兒子的編號

\(fa\)為當前節點的父親節點

\(cnt\)為當前節點的數量

\(val\)為當前節點的權值

2、旋轉操作

旋轉操作是\(Splay\)中的基本操作

每次有新節點加入、刪除或查詢時,我們都將其旋轉至根節點

這樣可以保持\(BST\)的平衡

複雜度證明

我們拿實際的圖來演示一下

在這幅圖中,\(x\)\(y\)的左兒子,而我們想要將\(x\)旋轉至\(y\)的位置

首先,根據\(BST\)的性質,\(x<y\)

因此旋轉後,\(y\)應該變為\(x\)的右兒子

\(x\)原來的右兒子\(b\)

根據性質有\(x<b<y\),而\(y\)在旋轉後恰好沒有左兒子,因此我們讓\(b\)\(y\)的左兒子

\(y\)的右兒子\(c\)\(x\)的左兒子\(b\)保持不變即可

旋轉後的圖變成了下面這個樣子

旋轉後的圖仍滿足\(BST\)的性質

但實際上,我們只列舉出了\(4\)種情況中的一種

1、\(y\)\(z\)的左兒子,\(x\)\(y\)的左兒子

2、\(y\)\(z\)的左兒子,\(x\)\(y\)的右兒子

3、\(y\)\(z\)的右兒子,\(x\)\(y\)的右兒子

4、\(y\)\(z\)的右兒子,\(x\)\(y\)的左兒子

如果對於每一種情況我們都分別列舉一遍會很麻煩

根據\(yyb\)神犇的總結

1、\(x\)變到原來\(y\)的位置

2、\(y\)變成了 \(x\)原來在\(y\)的相對 的那個兒子

3、\(y\)的非\(x\)的兒子不變 \(x\)\(x\)原來在\(y\)的 那個兒子不變

4、\(x\)\(x\)原來在\(y\)的 相對的 那個兒子 變成了 \(y\)原來是 \(x\)的那個兒子

程式碼如下

void push_up(int x){
    tr[x].son=tr[tr[x].ch[0]].son+tr[tr[x].ch[1]].son+tr[x].cnt;
    //當前節點兒子數量等於左兒子數量加右兒子數量加當前節點數量
}
void xuanzh(int x){
    int y=tr[x].fa;
    int z=tr[y].fa;
    int k=(tr[y].ch[1]==x);
    //判斷x是否是y的右兒子
    tr[z].ch[tr[z].ch[1]==y]=x;
    tr[x].fa=z;//x變到原來y的位置
    tr[y].ch[k]=tr[x].ch[k^1];
    tr[tr[x].ch[k^1]].fa=y;
    //x的原來在x在y的相對位置的那個兒子變成了y原來是x的那個兒子
    tr[x].ch[k^1]=y;
    tr[y].fa=x;
    //y變成了x原來在y的相對的那個兒子
    push_up(y);
    push_up(x);
    //更新節點資訊
}

3、將一個節點上旋至規定點

我們是不是對於某一個節點連續進行兩次旋轉操作就可以呢

一般情況下是可以的,但是如果遇到下面的情況就不可行了

我們要把\(4\)旋轉到\(1\)的位置

如果我們一直將\(4\)進行旋轉操作,那麼旋轉兩次後的圖變成了下面這樣

我們會發現\(1-3-5\)這一條鏈仍然存在

只不過是\(4\)號節點跑到了原來\(1\)號節點的位置

這樣的話,\(Spaly\)就失去了意義

因此,我們分情況討論:

(\(x\)\(y\)的兒子節點,\(y\)\(z\)的兒子節點,將\(x\)旋轉到\(z\))

1、\(x\)\(y\)分別是\(y\)\(z\)的同一個兒子

先旋轉\(y\)再旋轉\(x\)

2、\(x\)\(y\)分別是\(y\)\(z\)不同的兒子

\(x\)旋轉兩次

程式碼

void splay(int x,int goal){
//將x旋轉至目標節點goal的兒子
    while(tr[x].fa!=goal){
        int y=tr[x].fa;
        int z=tr[y].fa;
        if(z!=goal){
            (tr[y].ch[0]==x)^(tr[z].ch[0]==y)?xuanzh(x):xuanzh(y);
        }
        //分情況討論:同位置兒子旋轉y,不同位置兒子旋轉x
        xuanzh(x);
        //最後旋轉x
    }
    if(goal==0) rt=x;
    //如果旋轉到根節點,將根節點更新為x
}

4、查詢操作

類似於二分查詢

從根節點開始,如果要查詢的值大於該點的值,向右兒子遞迴

否則向左兒子遞迴

如果當前位置的值已經是要查詢的數,則將該節點旋轉至根節點,方便之後的操作

void zhao(int x){
//查詢x的位置,並將其旋轉至根節點
    int u=rt;
    if(!u) return;//樹為空
    while(tr[u].ch[x>tr[u].val] && x!=tr[u].val){
    //當存在兒子並且當前位置的值不等於x
        u=tr[u].ch[x>tr[u].val];//跳轉到兒子
    }
    splay(u,0);
    //將當前位置旋轉到根節點
}

5、插入操作

和查詢操作類似,也是從根節點開始

如果要插入的值大於該點的值,向右兒子遞迴

否則向左兒子遞迴

如果可以在原樹中找到當前值,把節點的數量加一即可

否則再新建一個節點

void ad(int x){
//插入價值為x的節點
    int u=rt,fa=0;
    while(u && tr[u].val!=x){
        fa=u;
        u=tr[u].ch[x>tr[u].val];
        //向兒子遞迴
    }
    if(u) tr[u].cnt++;
    //如果當前節點已經存在,節點的個數加一
    else {
    //如果不存在,建立一個新的節點
        u=++tot;
        if(fa) tr[fa].ch[x>tr[fa].val]=u;
        tr[tot].ch[1]=0;
        tr[tot].ch[0]=0;
        tr[tot].val=x;
        tr[tot].fa=fa;
        tr[tot].cnt=1;
        tr[tot].son=1;
    }
    splay(u,0);//將當前節點上旋至根節點
}

6、查詢前驅和後繼

我們要查詢某一個數\(x\)的前驅和字尾

首先我們要使用查詢操作,將\(x\)節點旋轉到根節點

如果查詢前驅,那麼前驅就是左子樹中權值最大的節點

那我們就從左子樹開始,一直向右子樹跳,直到沒有右子樹為止

查詢後繼也是同樣

int qq_hj(int x,int jud){
//jud為0查詢前驅,為1查詢字尾
    zhao(x);
    //將x旋轉至根節點
    int u=rt;
    if((tr[u].val>x && jud) || (tr[u].val<x && !jud)){
        return u;
    }
    //如果無法繼續向下跳,返回當前節點
    u=tr[u].ch[jud];
    while(tr[u].ch[jud^1]){
        u=tr[u].ch[jud^1];
    }
    //否則繼續向下跳
    return u;
}

7、刪除操作

如果我們要刪除某一個數\(x\)

那麼這一個數的權值一定介於它的前驅和它的後繼之間

所以我們可以先把它的前驅旋轉至根節點

然後把它的後繼旋轉到它的前驅作為前驅的右兒子

這時,前驅的左兒子恰好比前驅大、後繼小,正是我們想要刪除的值

void sc(int x){
    int qq=qq_hj(x,0);
    //求出前驅
    int hj=qq_hj(x,1);
    //求出後繼
    splay(qq,0);
    //將前驅旋轉至根節點
    splay(hj,qq);
    //將後繼旋轉至前驅的右兒子
    int willsc=tr[hj].ch[0];
    //找出要刪除的數
    if(tr[willsc].cnt>1){
        tr[willsc].cnt--;
        splay(willsc,0);
    } else {
        tr[hj].ch[0]=0;
    }
    //刪除該節點
}

8、查詢第k小的值

從根節點開始,如果左子樹的兒子數大於\(k\),向左子樹查詢

否則向右子樹查詢

遞迴解決問題即可

int kth(int x){
    int u=rt;
    if(tr[u].son<x) return 0;
    //如果樹的節點數小於x,查詢失敗
    while(1){
        int y=tr[u].ch[0];
        if(x>tr[y].son+tr[u].cnt){
            x-=(tr[y].son+tr[u].cnt);
            u=tr[u].ch[1];
            //向右子樹查詢
        } else {
            if(x<=tr[y].son) u=y;
            else return tr[u].val;
            //向左子樹查詢
        }
    }
}

練習題(洛谷P3369

一道很基礎的板子題,直接附上程式碼

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e6+5;
#define INF 0x3f3f3f3f
struct trr{
    int son,ch[2],fa,cnt,val;
}tr[maxn];
int n,tot,rt;
void push_up(int x){
    tr[x].son=tr[tr[x].ch[0]].son+tr[tr[x].ch[1]].son+tr[x].cnt;
}
void xuanzh(int x){
    int y=tr[x].fa;
    int z=tr[y].fa;
    int k=(tr[y].ch[1]==x);
    tr[z].ch[tr[z].ch[1]==y]=x;
    tr[x].fa=z;
    tr[y].ch[k]=tr[x].ch[k^1];
    tr[tr[x].ch[k^1]].fa=y;
    tr[x].ch[k^1]=y;
    tr[y].fa=x;
    push_up(y);
    push_up(x);
}
void splay(int x,int goal){
    while(tr[x].fa!=goal){
        int y=tr[x].fa;
        int z=tr[y].fa;
        if(z!=goal){
            (tr[y].ch[0]==x)^(tr[z].ch[0]==y)?xuanzh(x):xuanzh(y);
        }
        xuanzh(x);
    }
    if(goal==0) rt=x;
}
void zhao(int x){
    int u=rt;
    if(!u) return;
    while(tr[u].ch[x>tr[u].val] && x!=tr[u].val){
        u=tr[u].ch[x>tr[u].val];
    }
    splay(u,0);
}
void ad(int x){
    int u=rt,fa=0;
    while(u && tr[u].val!=x){
        fa=u;
        u=tr[u].ch[x>tr[u].val];
    }
    if(u) tr[u].cnt++;
    else {
        u=++tot;
        if(fa) tr[fa].ch[x>tr[fa].val]=u;
        tr[tot].ch[1]=0;
        tr[tot].ch[0]=0;
        tr[tot].val=x;
        tr[tot].fa=fa;
        tr[tot].cnt=1;
        tr[tot].son=1;
    }
    splay(u,0);
}
int qq_hj(int x,int jud){
    zhao(x);
    int u=rt;
    if((tr[u].val>x && jud) || (tr[u].val<x && !jud)){
        return u;
    }
    u=tr[u].ch[jud];
    while(tr[u].ch[jud^1]){
        u=tr[u].ch[jud^1];
    }
    return u;
}
void sc(int x){
    int qq=qq_hj(x,0);
    int hj=qq_hj(x,1);
    splay(qq,0);
    splay(hj,qq);
    int willsc=tr[hj].ch[0];
    if(tr[willsc].cnt>1){
        tr[willsc].cnt--;
        splay(willsc,0);
    } else {
        tr[hj].ch[0]=0;
    }
}
int kth(int x){
    int u=rt;
    if(tr[u].son<x) return 0;
    while(1){
        int y=tr[u].ch[0];
        if(x>tr[y].son+tr[u].cnt){
            x-=(tr[y].son+tr[u].cnt);
            u=tr[u].ch[1];
        } else {
            if(x<=tr[y].son) u=y;
            else return tr[u].val;
        }
    }
}
int main(){
    int n;
    scanf("%d",&n);
    ad(INF);
    ad(-INF);
    for(int i=1;i<=n;i++){
        int aa,bb;
        scanf("%d%d",&aa,&bb);
        if(aa==1) ad(bb);
        else if(aa==2) sc(bb);
        else if(aa==3) {
            zhao(bb);
            int ans=tr[tr[rt].ch[0]].son;
            printf("%d\n",ans);
        } else if(aa==4){
            int ans=kth(bb+1);
            printf("%d\n",ans);
        } else if(aa==5){
            int ans=qq_hj(bb,0);
            printf("%d\n",tr[ans].val);
        } else {
            int ans=qq_hj(bb,1);
            printf("%d\n",tr[ans].val);
        }
    }
    return 0;
}

相關文章