1. 程式人生 > 實用技巧 >Splay演算法詳解

Splay演算法詳解

Splay演算法詳解

本篇隨筆淺談一下演算法競賽中的\(Splay\)演算法。

Splay的概念

Splay在我看來應該算作一種演算法而非資料結構。無論是Treap,AVL,SBT,替罪羊樹還是Splay其實都應該算作演算法,因為它們都在解決一種資料結構存在的問題:二叉搜尋樹\(BST\)

對於二叉搜尋樹和Treap(平衡樹概念)不瞭解的,可以移步下面兩篇部落格:

淺談二叉搜尋樹

詳解Treap

有了前置知識,我們可以發現問題的根源在於\(BST\)在資料極端的情況下會退化成鏈。而什麼Treap啊,AVL啊,SBT啊,Splay啊,替罪羊樹啊都是在幹一件事情:如何讓這棵樹儘可能平衡,不要退化成鏈。

有個叫做Tarjan的大佬YY出了Splay這種演算法。

Splay的實現方式

Treap的實現原理在於為所有節點附加一個鍵值。這個鍵值按堆的形式排序。讓整個資料結構在節點權值上是BST,但在優先順序(鍵值)上卻是一個堆。

但Splay的實現方式是對節點的序號進行重構,從而達到平衡樹要求的標準。


splay操作

splay操作是指將一個點一直上旋,知道某個指定點。預設是根節點。

Splay的精髓在於旋轉,這已經是不爭的事實了。

如果對於樹的旋轉這個知識點有些不明白,請移步Treap的部落格:

Treap詳解


相比於Treap只有單旋這一種旋轉方式,Splay的優秀之處在於它有著4種旋轉方式:單旋和雙旋。

什麼是雙旋?為什麼要雙旋?

雙旋的適用範圍在於:祖父、父親和兒子節點連成一條線。

就像這樣:

如果這樣的結構不去雙旋而簡單粗暴地用兩次單旋(先將需要旋轉的節點上旋,再轉他的父親)來解決,最後造成的結果就像是右邊的樣子(自己手畫模擬一下兩次單旋即可發現)。我們發現,左側的樹是四層,右側的樹仍然是四層:對於複雜度來講,沒有任何優化。因為我們的平衡樹是儘量把複雜度變成\(\log\)級別的。

那麼,我們就需要使用雙旋。雙旋的操作方式是:先將父節點上旋,再將需要旋轉的節點上旋。別看這個雙旋只是改變了其旋轉順序,但是可是大有文章,經過這樣的旋轉策略,最終的旋轉結果就變成了這樣:

嗯,不錯,少了一層,看起來很平衡。

所以,我們的Splay的精華是Splay操作,也就是把某個節點一直轉到根節點,順道改變整棵樹的形態。具體實現方式是:如果當前節點的父親節點就是根節點或者自己、父親、祖父不在一條直線上,就單旋;如果自己、父親、祖父正好在同一條直線上,那麼就雙旋:雙旋的策略是:先轉父親後轉自己。

所以我們有四種旋轉方式:單旋左旋,單旋右旋,雙旋左旋,雙旋右旋。

這也是Splay的基本操作。


Splay和Treap在旋轉上的區別

學過Treap的小夥伴(包括我)都在覺得Splay的單旋和Treap的左右旋好像是一樣的。但是實際上它們卻有所不同。在本蒟蒻的理解上,這種不同體現在“物件”上。

Treap的旋轉是將兒子節點旋到自己的位置。左旋是把右兒子轉到自己的位置,右旋是把左兒子轉到自己的位置。

但是Splay的旋轉則是將自己轉到父親節點的位置,雖然形式上都是上旋,但是作用物件是不一樣的。


Splay的程式碼實現

一般來講,Splay支援的操作和Treap是差不多的。其實所有的平衡樹都是這種操作。動態插入刪除,然後去動態查詢第k大、數的排名、前驅、後繼等等。因為平衡樹的本質是BST,所以BST所擁有的性質,Splay全都可以支援和維護。

就拿洛谷的例題來講吧:P3369的模板。

題目傳送門

前置資訊

前置資訊包括三個函式:maintain函式(來維護節點的size,後期統計排名用),get函式(確認當前節點是它爹的左兒子還是右兒子,旋轉要用)以及find函式(在樹中找到某個值的節點位置)

void maintain(int x)
{
    size[x]=cnt[x]+size[ch[x][0]]+size[ch[x][1]];
}
int find(int x)
{
    int pos=root;
    while(val[pos]!=x)
        pos=ch[pos][x>val[pos]];
    return pos;
}
int get(int x)
{
    int f=fa[x];
    return ch[f][1]==x;
}

程式碼很簡單,應該一看就能懂。

旋轉&Splay操作

上面的Splay的實現原理就在講旋轉和Splay操作的原理。依照上面講的模擬即可得到下面的模板:

void rotate(int x)
{
    int y=fa[x],z=fa[y];
    int k=get(x);//k表示x是y的什麼兒子
    ch[y][k]=ch[x][k^1],fa[ch[y][k]]=y;
    ch[x][k^1]=y,fa[y]=x,fa[x]=z;
    if(z)
        ch[z][ch[z][1]==y]=x;
    maintain(y),maintain(x);
}
void splay(int x,int &goal)
{
    int f=fa[goal];
    while(fa[x]!=f)
    {
        if(fa[fa[x]]!=f)
            rotate(get(x)==get(fa[x])?fa[x]:x);
        rotate(x);
    }
    goal=x;
}

插入

插入的時候需要一個動態開點。也就是用到哪開到哪,所以一開始首先要判整棵樹是不是空的。然後再去進行插入。插入的時候要注意:因為有些數可以重複出現。所以當我們在原樹中找到與插入節點完全相同的節點之後,不用去插入,直接把計數變數+1就可。

如果這是一個全新的節點,那麼它一定是個葉子。所以如果我們沒在原樹中找到這個節點的話,就得用動態開點把點加進去。

動態開點不太會的小夥伴走這邊:

淺談動態開點

插入要注意兩點:第一點是maintain,也就是統計節點的大小。第二點是Splay,每次插入和刪除之後都要Splay,來維護樹的形態。這也是Splay演算法的精髓所在,一定不要忽略。這兩種操作應該maintain在前,splay在後,因為在進入splay之前要保證當前狀態下所有節點的資訊都是對的,才能保證Splay之後所有節點的資訊也都是對的。

插入程式碼:

void insert(int x)
{
    if(!root)
    {
        ++tot;
        root=tot;
        val[root]=x;
        cnt[root]=1;
        maintain(root);
        return;
    }
    int pos,f;
    pos=f=root;
    while(pos && val[pos]!=x)
        f=pos,pos=ch[pos][x>val[pos]];
    if(!pos)
    {
        ++tot;
        cnt[tot]=1;
        val[tot]=x;
        fa[tot]=f;
        ch[f][x>val[f]]=tot;
        maintain(tot);
        splay(tot,root);
        return;
    }
    ++cnt[pos];
    maintain(pos);
    splay(pos,root);
}

刪除

刪除的操作其實和Treap的有些像,但是又有些不同。作為一個樹中節點來講,尤其是一個BST的節點,我們肯定不能直接上來就暴力刪掉這個節點,否則無法維護BST的性質。為了解決這個問題,我們選擇把節點直接Splay轉到根節點,這樣的話,直接刪根對左右子樹是無影響的,直接合並就好。

但是要分幾種情況討論:(Splay之後)

首先,如果這個點有很多數,但是我們只刪一個,所以直接cnt-1就可,其他不用變。

如果只有一個,那麼就要刪除,但是刪除之後誰來當根節點呢?這又有三種情況可以討論:根節點只有左兒子,根節點只有右兒子,根節點兒女雙全。

前兩種只能給唯一的兒子。如果兒女雙全,就得在刪掉當前根節點後再找一個根節點,但是問題來了,新的根節點到底是用左兒子合適還是用右兒子合適?

都不合適。根據BST的性質,因為左子樹都比當前節點小,右子樹都比當前節點大,所以我們選擇根節點左子樹中最大的點作為根節點(就是根節點向左,然後一直向右到頭)。

別忘了Splay維護和maintain更新。

程式碼:

void del(int x)
{
    int pos=find(x);
    splay(pos,root);
    if(cnt[root]>1)
    {
        cnt[root]--;
        maintain(root);
        return;
    }
    if((!ch[root][0])&&(!ch[root][1]))
        root=0;
    else if(!ch[root][0])
        root=ch[root][1],fa[root]=0;
    else if(!ch[root][1])
        root=ch[root][0],fa[root]=0;
    else
    {
        pos=ch[root][0];
        while(ch[pos][1])
            pos=ch[pos][1];
        splay(pos,ch[root][0]);
        ch[pos][1]=ch[root][1];
        fa[ch[root][1]]=pos,fa[pos]=0;
        maintain(pos);
        root=pos;
    }
}

查詢排名

當然可以用BST的性質來遍歷樹計算排名。但是好笨的樣子。

我們會Splay啊,直接把要查的節點Splay上去,然後直接呼叫其左子樹的size+1不就可以了嘛!

int rank(int x)
{
    int pos=find(x);
    splay(pos,root);
    return size[ch[root][0]]+1;
}

查詢第k大

int kth(int x)
{
    int pos=root;
    while(1)
    {
        if(x<=size[ch[pos][0]])
            pos=ch[pos][0];
        else 
        {
            x-=(size[ch[pos][0]]+cnt[pos]);
            if(x<=0)
            {
                splay(pos,root);
                return val[pos];
            }
            pos=ch[pos][1];
        }
    }
}

查詢前驅/後繼

前驅和後繼的定義已經給出。我們驚喜地發現和BST的性質真的是無比的契合。

所以我們直接用BST查就可以,任何平衡樹演算法都是一樣的。

程式碼:

int pre(int x)//前驅
{
    int ans;
    int pos=root;
    while(pos)
    {
        if(x>val[pos])
        {
            ans=val[pos];
            pos=ch[pos][1];
        }
        else
            pos=ch[pos][0];
    }
    return ans;
}
int nxt(int x)//後繼
{
    int ans;
    int pos=root;
    while(pos)
    {
        if(x<val[pos])
        {
            ans=val[pos];
            pos=ch[pos][0];
        }
        else
            pos=ch[pos][1];
    }
    return ans;
}