Splay演算法詳解
Splay演算法詳解
本篇隨筆淺談一下演算法競賽中的\(Splay\)演算法。
Splay的概念
Splay在我看來應該算作一種演算法而非資料結構。無論是Treap,AVL,SBT,替罪羊樹還是Splay其實都應該算作演算法,因為它們都在解決一種資料結構存在的問題:二叉搜尋樹\(BST\)。
對於二叉搜尋樹和Treap(平衡樹概念)不瞭解的,可以移步下面兩篇部落格:
有了前置知識,我們可以發現問題的根源在於\(BST\)在資料極端的情況下會退化成鏈。而什麼Treap啊,AVL啊,SBT啊,Splay啊,替罪羊樹啊都是在幹一件事情:如何讓這棵樹儘可能平衡,不要退化成鏈。
有個叫做Tarjan的大佬YY出了Splay這種演算法。
Splay的實現方式
Treap的實現原理在於為所有節點附加一個鍵值。這個鍵值按堆的形式排序。讓整個資料結構在節點權值上是BST,但在優先順序(鍵值)上卻是一個堆。
但Splay的實現方式是對節點的序號進行重構,從而達到平衡樹要求的標準。
splay操作
splay操作是指將一個點一直上旋,知道某個指定點。預設是根節點。
Splay的精髓在於旋轉,這已經是不爭的事實了。
如果對於樹的旋轉這個知識點有些不明白,請移步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;
}