樹堆(Treap)詳解
本篇隨筆詳細講解一下一種隨機化數據結構——樹堆(\(Treap\))。
樹堆的概念
首先給一個字符串等式:
所以\(Treap\)樹堆其實就是樹+堆。樹是二叉查找樹\(BST\),堆是二叉堆,大根堆小根堆都可以。
關於\(BST\)的相關知識,請看官走這邊:
樹堆既是一棵二叉查找樹,也是一個二叉堆。但是這兩種數據結構貌似還是矛盾的存在,如果是二叉查找樹,就不能是一個堆,如果是一個堆,那么必然不是二叉查找樹。
所以樹堆用了一個很巧妙的方式解決這個問題:給每個鍵值一個隨機附加的優先級,讓鍵值滿足二叉查找樹的結構,讓優先級滿足二叉堆的結構。
就像下面這個樣子:(圖片摘自騰訊雲)
其中大寫字母是鍵值,滿足\(BST\)結構。數是優先級,滿足小根堆結構。
樹堆實現平衡樹的特點
我們在\(BST\)中講過,普通的\(BST\)具有很強的不確定性,如果數據特殊,建樹的時候可能直接變成一條鏈。不僅如此,插入刪除的時候也很麻煩。因為如果插入或者刪除,整個樹原來的結構就會被打亂,這會為遍歷和查找帶來災難性的后果。
所以我們推出了平衡樹。就是通過將樹旋轉來動態維護這個樹形態是平衡的,這樣查找的復雜度就是\(O(log)\)級別的,是一種穩定的復雜度。
樹堆是一種平衡樹,它通過為鍵值(也就是我們需要維護成\(BST\)的)賦予優先級,使之也滿足堆結構來進行旋轉,成為一棵平衡樹。
但是我們需要注意一點:樹堆的優先級是隨機賦予的。也就是說,這個數據結構其實是一個隨機化的數據結構。這不是樹堆的缺點,因為只有隨機化賦予優先級,才有可能保證樹堆的復雜度是\(O(log)\)的級別。
那么,上述性質也說明了,樹堆並不是一個規則形態的二叉樹,更不是堆需要滿足的完全二叉樹。甚至它也不符合平衡樹的定義:每個節點左右子樹高度相差\(\le1\),所以我們說樹堆是近似實現平衡。
但是通過形態定義二叉樹的方式並不絕對。我們換一種方式來對平衡樹進行定義:
能夠保證時間復雜度的\(BST\),就是平衡樹。
樹堆的操作
首先當我們理解\(Treap\)的操作的時候,需要先對旋轉這個事情有一個大體的定義。
上圖:
光看這個箭頭的話,還是很容易理解什么是旋轉的。但是可能給讀者造成困擾的是:這個B節點的父親怎么變了?
原因是這樣的:我們在進行旋轉操作的時候,要保證\(BST\)的節點遍歷順序是一樣的,而\(BST\)的節點遍歷順序是中序遍歷(這個是按\(BST\)的定義來的),也就是說只有這樣才能保證遍歷序不變的情況下調換節點位置。
所以,針對這個圖,我們把“P/F”看成一對節點,就能很好地理解這個“左/右旋”的操作了。
Treap的插入
首先我們了解一下\(BST\)的插入方式。其實很簡單,就是一個新節點插進去,從根節點開始不停地與當前節點比大小,一直到這個節點成為葉子節點為止。
因為\(Treap\)要同時維護\(BST\)和堆,所以我們還需要在里面加上旋轉操作。
如果是右兒子的話要左旋,左兒子要右旋(旋轉操作請結合上圖理解)。
然后我們又多統計了一個size的數據,這個數據表示子樹大小,在統計當前數x是第幾大的時候會很方便。
應該很簡單。
struct node
{
int val,pri,size,lson,rson;//val鍵值,pri優先級,size子樹大小。
}tree[maxn];
int tot;
void L_rotate(int &pos)
{
node x=tree[tree[pos].rson];//x表示要轉到上面的節點
tree[pos].rson=x.lson;
x.lson=pos;
x.size=tree[pos].size;
maintain(pos);
tree[pos]=x;
}
void R_rotate(int &pos)
{
node x=tree[tree[pos].lson];
tree[pos].lson=x.rson;
x.size=tree[pos].size;
maintain(pos);
tree[pos]=x;
}
void maintain(int pos)
{
tree[pos].size=tree[tree[pos].lson].size+tree[tree[pos].rson].size+1;
}
void insert(int &pos,int x)
{
if(!pos)
{
pos=++tot;
tree[pos].val=x;
tree[pos].pri=rand();
}
if(x<tree[pos].val)
{
insert(tree[pos].lson,x);
if(tree[pos].pri<tree[lson].pri)//如果優先級不匹配,就旋轉(這里維護的是大根堆)
R_rotate(pos);
}
else
{
insert(tree[pos].rson,x);
if(tree[pos].pri<tree[rson].pri)
L_rotate(pos);
}
maintain(pos);
}
Treap的刪除
刪除操作的大體思路和插入是一樣的。也是要保證刪除前后滿足\(Treap\)的雙重結構。
大致是這樣的思路:首先找到這個點在哪。然后,如果這個點已經是葉子節點,就直接將其刪除,如果不是,就一層層地將它轉到底部,然后進行刪除。這和我們的插入操作有異曲同工之妙,就是進行操作的一定是葉子節點,如果不是葉子的話是不能粗暴刪除的。需要轉。
void remove(int &pos,int x)
{
if(!pos)
return;
if(x==tree[pos].val)
{
if(tree[pos].lson|tree[pos].rson)//如果非葉子節點
{
if(tree[tree[pos].lson].pri>tree[tree[pos].rson].pri)
R_rotate(pos),remove(tree[pos].lson,x);
else
L_rotate(pos),remove(tree[pos].rson,x);
maintain(pos);
}
else
pos=0;
}
else
{
if(x<tree[pos].val)
remove(tree[pos].lson,x);
else
remove(tree[pos].rson,x);
}
if(pos)
maintain(pos);
}
Treap的查詢(某數排名)
查詢操作不涉及修改,根據樹堆的雙重性質,其操作是跟\(BST\)是一樣的。
int rank(int pos,int x)
{
if(!pos)
return 1;
if(x<tree[pos].val)
return rank(tree[pos].lson,x);
else
return rank(tree[pos].rson,x)+tree[tree[pos].lson].size+1;
}
int rank(int pos,int x,int &cnt)
{
if(!pos)
return -1;
if(x==tree[pos].val)
return cnt+=tree[tree[pos].lson].size;
else
{
if(x<tree[pos].val)
rank(tree[pos].lson,x,cnt);
else
cnt+=(tree[tree[pos].lson].size+1),rank(tree[pos].rson,x,cnt);
}
}
Treap的查詢(第K大)
int kth(int pos,int k)
{
if(!pos || k<=0 || k>tree[pos].size)
return -1;
if(k==tree[tree[pos].lson].size+1)
return tree[pos].val;
else if(k<=tree[tree[pos].lson].size)
return kth(tree[pos].lson,k);
else
return kth(tree[pos].rson,k-tree[tree[pos].lson].size-1);
}
Treap的查詢(前驅/后繼)
#define INF 1e9
int prev(int pos,int x)
{
if(!pos)
return -INF;
if(x<tree[pos].val)
return prev(tree[pos].lson,x);
else
return max(tree[pos].val,prev(tree[pos].rson,x));
}
int nxt(int pos,int x)
{
if(!pos)
return INF;
if(x>=tree[pos].val)
return nxt(tree[pos].rson,x);
else
return min(tree[pos].val,nxt(tree[pos].lson,x));
}
Treap的遍歷
Treap的遍歷是中序遍歷,遵循先左后右的原則。
void dfs(int pos)
{
if(tree[pos].lson)
dfs(tree[pos].lson);
printf("%d ",tree[pos].val);
if(tree[pos].rson)
dfs(tree[pos].rson);
}