資料結構專題-學習筆記:無旋平衡樹(替罪羊樹,fhq Treap)
1.概述
平衡樹,是一種高階資料結構,是基於二叉查詢樹的一種資料結構。
對二叉查詢樹不理解的讀者這裡有一個作者的簡單總結:
對於一棵二叉查詢樹(又名 BST):
- 這是一棵二叉樹。
- 對於樹上的任意節點,其左孩子的權值一定小於父親,右孩子的權值一定大於等於父親。
二叉查詢樹支援插入一個數,刪除一個數,查詢某數的排名,查詢某數的前驅/後繼等等。
比如下面這個就是一棵二叉查詢樹。
那麼想必現在你已經知道二叉查詢樹是什麼了。
根據理論證明,二叉查詢樹在 隨機資料 的情況下表現良好,平均時間複雜度是 \(O(n \log n)\)。
然而我們知道資料並不總是隨機的,也可以構造,比如這樣一組資料:
1 2 3 4 5 6 7......
那麼二叉查詢樹就變成了這樣:
於是在這種情況下,二叉查詢樹就被卡成了 \(O(n^2)\)。
甚至如果隨機資料不是那麼的隨機(指其中有幾段長區間是連續的),那麼二叉查詢樹同樣會退化。(比如今年的 CSP-J2 直播獲獎,如果沒有將兩個相同的分數合併到一個點上,考場上寫二叉查詢樹的人就會被卡成 \(O(n^2)\),只有 90pts)(話說這玩意桶排不就結束了,為什麼會有人寫 BST?)。
於是就在這個時候,平衡樹就出現了,而它的作用就是讓 BST 變得平衡。
那麼什麼是平衡的 BST 呢?直觀感受就是相對矮矮胖胖的 BST,而演算法中就是指樹高儘量靠近 \(O(\log n)\),因為這樣才能做到 \(O(n \log n)\)。
比如上面這棵樹,變成下面這樣就是比較平衡的 BST。
你看這樣是不是就好多了~
平衡樹的種類有很多種,比如紅黑樹,B樹等等。
但是這裡面我個人認為最重要的而且一定要掌握的是這四種:替罪羊樹,FHQ Treap,AVL 樹,Splay。
每種平衡樹都有它的“手段”來使 BST 平衡。
這裡對上述 4 個平衡樹作一個簡介以及優劣對比:
- 替罪羊樹:特別容易理解,時間比較優秀,空間有一點劣,碼量較大,但適合用於樹套樹等場合。
- FHQ Treap:也很容易理解,時間優秀,空間優秀,碼量特別小,而且能支援的操作最多,並且支援可持久化!
- AVL 樹:最早被發現的平衡樹,有一點點難理解(當然還是比較清晰的),時間最優秀,空間十分優秀,碼量稍微有點大,但是正在逐漸的從演算法競賽中退出,對於一些卡常的題目倒是有很好的表現。
- Splay:伸展樹,與 FHQ Treap 有“平衡樹雙子星”之稱,但是理解難度比較大,常數比較大,空間比較劣,而碼量並不是特別大。它能支援的操作不比 FHQ Treap 少(除了可持久化),而且 Splay 的擴充套件性特別強,能夠融合於很多的演算法/資料結構(比如 LCT),這一點是 FHQ Treap 無法媲美的。
而根據它們對 BST 的處理“手段”,又可以分為如下兩大類:
- 無旋平衡樹:替罪羊樹,FHQ Treap。
- 有旋平衡樹:AVL 樹,Splay。
對於每一類平衡樹將會使用一篇博文來講解。
那麼接下來,開始講解無旋平衡樹吧!
接下來的講解都將以 這道題 為模板。
2.無旋平衡樹-替罪羊樹
1.思路
替罪羊樹的思路在這裡面我認為是比較好理解的一種平衡樹,其優劣性在前面已經說過。
我們知道,一條鏈是我們在 BST 中最不希望看到的,也是最不平衡的一種樹,那麼替罪羊樹又會怎麼辦呢?或者是針對一棵不平衡的樹(或者是子樹),要怎麼辦呢?
很簡單,直接拍扁重構!
比如還是這棵樹:
顯然不平衡,那麼替罪羊樹就是要把這棵樹拍扁重構。
我們知道,最好的二叉查詢樹是完全二叉樹,那麼替罪羊樹拍扁重構的步驟如下:
- 首先得到這棵樹的中序遍歷,記長度為 \(n\) ,序列為 \(A_{1...n}\)。
- 然後將最中間的節點“拎”起來,作為這棵樹的根節點。
- 然後左右遞迴繼續執行得到左右子樹,遇到葉子節點時停止執行。
看不懂?沒關係,我畫幾張圖片。
我們要將上面那棵樹拍扁重構,那麼首先得到中序遍歷:
然後我們將最中間這個節點“拎”起來,也就是將 5 號節點“拎”起來。
於是這棵樹就變成了這樣:
紅色的是左右兩邊的遞迴區間。
現在我們對左右兩邊分別遞迴,將中間的節點仿照同樣的方式“拎”起來。
那麼這棵樹就變成了這樣:
於是我們繼續遞迴。
咦?左邊怎麼只有一個點了?此時說明已經到了葉子節點,直接提上去就好。
那麼這棵樹就變成了這樣:
最後將剩下的節點提上去,調整之後這棵樹最終變成了這樣:
這就是替罪羊樹拍扁重構的過程,是不是很簡單~
但是我們不能隨便拍扁重構啊,因為拍扁重構的時間是 \(O(n)\) 的,如果我們不加節制的拍扁重構那麼就會導致 TLE,我們只有在不平衡的時候才會拍扁重構。
那麼怎麼才算平衡呢?
替罪羊樹判斷平衡的原理:取一個平衡因子 \(alpha \in [0.5,1]\)(一般取 \(0.75\)),如果左右子樹中有一棵子樹其節點個數比上這棵樹的節點個數大於等於 \(alpha\),那麼就不平衡,需要重構。否則就不需要。
但是還有一種可能也會導致替罪羊樹需要拍扁重構,這個到 Delete 函式的時候再談。
那麼有了這個基礎之後,我們看看對於題目當中所給的 6 個操作我們要怎麼解決。
2.結構體建立
我們需要在替罪羊樹中記錄這樣 6 個值:
- \(l,r\):表示左右兒子的編號。
- \(size\):表示子樹大小。
- \(fact\):表示真實存在的節點個數(具體在 Delete 函式的時候談)。
- \(val\):表示這個節點的值。
- \(exist\):表示這個節點是否存在(具體在 Delete 函式的時候談)。
3.插入-Insert
替罪羊樹的 Insert 跟二叉查詢樹無異,直接模仿二叉查詢樹寫即可。
不過注意:在每一次插入資料之後都要檢查一下插入的路徑上的所有節點的子樹是否不平衡。
程式碼:
void make_node(int &now, int x)//注意 now 是引用
{
now = ++cnt;//計數器 +1
tree[now].size = tree[now].fact = 1;
tree[now].val = x;
tree[now].exist = 1;//三句初始化
}
void Insert(int &now, int x)//now 是現在的節點編號,注意 now 是引用
{
if (!now)//葉子節點
{
make_node(now, x);//新建一個節點
check(root, now);//檢查是否平衡
return ;
}
tree[now].size++;
tree[now].fact++;//修改 size 和 fact
if (x >= tree[now].val) Insert(tree[now].r, x);//按照二叉查詢樹的插入方式插入
else Insert(tree[now].l, x);
return ;
}
其中 check 函式在講完 Delete 函式後會重點講解。
4.刪除-Delete
替罪羊樹的刪除節點跟別的平衡樹都不一樣。替罪羊樹的刪除節點採取的是 懶刪除 的思想。
還記得 \(fact,exist\) 嗎?
我們在刪除節點的時候,首先要找到這個節點對不對?
然後呢?如果我們直接刪除,就會因為父親,左右兒子的一些奇奇怪怪的關係而導致非常難搞。那如果放到葉子節點再刪呢?那這樣就需要用到樹旋轉了,但這不是我們想要的。
於是替罪羊樹就搞了一個這樣的東西:懶刪除。
在搜到某個需要被刪除的節點的時候,我們先判斷它有沒有被刪除(相同的數可能有很多個),如果沒有,將 \(exist = 0\),\(fact--\),但是 \(size\) 不變,不刪除這個節點,然後判斷路徑上的節點是否平衡即可。
等等,既然這個節點沒有被真正的刪除,那麼我們為什麼還要判斷節點是否平衡呢?
是這樣的,對於一棵子樹而言,如果這棵子樹中被刪除的節點過多,我們同樣需要對這棵樹拍扁重構,以減少其對執行效率的干擾。一般情況下,如果被刪除的節點佔總節點個數的 30% ,那麼這棵樹需要拍扁重構。
而拍扁重構的過程上面已經詳細的說明過了,在找中序遍歷時我們需要 跳過所有被刪除的節點。
程式碼:
void Delete(int now, int x)//注意這裡 now 不是引用
{
if (tree[now].exist && tree[now].val == x)//找到了,注意判斷是否存在
{
tree[now].exist = false;//作懶刪除
tree[now].fact--;
check(root, now);//檢查是否平衡
return ;
}
tree[now].fact--;//fact 不要忘記減 1
if (x >= tree[now].val) Delete(tree[now].r, x);//按照二叉查詢樹的查詢方式找節點
else Delete(tree[now].l, x);
return ;
}
5.檢查平衡 & 拍扁重構-(一堆函式)
5.1 檢驗單棵樹是否平衡-Isimbalance
如果一棵樹不平衡有以下兩種可能(取 \(alpha = 0.75\)):
- 左右子樹當中某棵子樹的節點個數超過這棵子樹節點個數的 75%。
- 被刪除節點超過這棵子樹節點個數的 30%。
程式碼:
bool Isimbalance(int x)//可能有點長,請見諒
{
if (max(tree[tree[x].l].size, tree[tree[x].r].size) > tree[x].size * alpha || tree[x].size - tree[x].fact > tree[x].size * 0.3) return 1;
// 取出左右子樹中較大的節點數 超過 75% (alpha = 0.75) 被刪除節點超過 30% 返回 1
return 0;
}
5.2 檢查一條路徑上的點是否平衡-check & 更新函式 update
設當前節點為 \(now\),終點為 \(end\),那麼我們採取從上往下遞迴的形式檢查是否平衡。
注意重構完之後要更新重構之後路徑上的所有節點。
程式碼:
void update(int now, int end)
{
if (!now) return ;//葉子節點,返回
if (tree[end].val >= tree[now].val) update(tree[now].r, end);//繼續更新
else update(tree[now].l, end);
tree[now].size = tree[tree[now].l].size + tree[tree[now].r].size + 1;//更新 size
tree[now].fact = tree[tree[now].l].fact + tree[tree[now].r].fact + tree[now].exist;//更新 fact,注意判斷當前節點是否存在
}
void check(int &now, int end)
{
if (now == end) return ;//終點,返回
if (Isimbalance(now)) {rebuild(now); update(root, now); return ;}//不平衡就重構
if (tree[now].val > tree[end].val) check(tree[now].l, end);//二叉查詢樹法檢查
else check(tree[now].r, end);
}
5.3 重構函式-rebuild
先得到中序遍歷,然後判斷是否為空樹(如果子樹被刪光了?),不是就重構。我用 vector 存中序遍歷。
程式碼:
void rebuild(int &now)//注意 now 是引用
{
v.clear(); Get_ldr(now);//得到中序遍歷
if (v.empty()) {now = 0; return ;}//空樹不需要重構
lift(0, v.size() - 1, now);//重構子樹
}
5.4 中序遍歷-Get_ldr
樹結構基礎操作。唯一需要注意只有節點存在才能加入中序遍歷。這樣同時真正的刪除了節點。
程式碼:
void Get_ldr(int now)
{
if (!now) return ;
Get_ldr(tree[now].l);
if (tree[now].exist) v.push_back(now);//注意判斷是否存在
Get_ldr(tree[now].r);
}
5.5 重新建立子樹-lift
按照最開始說的方法建立即可。不過細節有一點多,需要注意。
void lift(int l, int r, int &now)//l,r 是中序遍歷的區間,now 是節點編號,注意 now 是引用
{
if (l == r)//到頭了
{
now = v[l];//取出編號
tree[now].l = tree[now].r = 0;
tree[now].size = tree[now].fact = 1;//重新建立節點
return ;
}
int mid = (l + r) >> 1;
while (mid && l < mid && tree[v[mid - 1]].val == tree[v[mid]].val) mid--;
now = v[mid];//這裡需要注意,替罪羊樹中所有相同的節點是在右子樹的
if (l < mid) lift(l, mid - 1, tree[now].l);
else tree[now].l = 0;//提左邊
lift(mid + 1, r, tree[now].r);//考慮到 >>1 是向 0 取整,因此右邊一定有沒有跑完的區間
tree[now].size = tree[tree[now].l].size + tree[tree[now].r].size + 1;
tree[now].fact = tree[tree[now].l].fact + tree[tree[now].r].fact + 1;//更新,由於保證每個節點都存在,所以 fact 可以和 size 一樣更新
}
6.找 x 的排名(Find_Rank) & 找第 k 大(Find_kth)
跟二叉查詢樹一個搞法,還是要注意判斷節點是否存在。
程式碼:
int Find_Rank(int x)
{
int o = root, ans = 1;
while (o)
{
if (x <= tree[o].val) o = tree[o].l;
else {ans += tree[tree[o].l].fact + tree[o].exist; o = tree[o].r;}//注意判斷節點是否存在
}
return ans;
}
int Find_kth(int x)
{
int o = root;
while (o)
{
if (tree[o].exist && tree[tree[o].l].fact + tree[o].exist == x) return tree[o].val;//注意判斷節點是否存在
if (tree[tree[o].l].fact < x) {x -= tree[tree[o].l].fact + tree[o].exist; o = tree[o].r;}//同上
else o = tree[o].l;
}
}
7.找前驅(Find_pre) & 找後繼(Find_aft)
因為作者比較懶,所以作者直接使用 Find_kth(Find_Rank(x) - 1)
和 Find_kth(Find_Rank(x + 1))
代替了。注意括號不要搞錯。程式碼不給了。
8.最後的程式碼
限於篇幅問題,完整程式碼請在 這裡 檢視。
9.替罪羊樹的好處
替罪羊樹還是比較好理解的,學起來相對輕鬆,時間跑的也不錯,適合用於樹套樹等場合(當然只是個人的觀點)。
10.替罪羊樹的問題
但是替罪羊樹有幾個問題:
- 如果寫法不優秀,可能會導致引用操作失誤。
- 如果某毒瘤出題人構造的資料都是完全一樣的(資料生成器,感謝本機房 jxw 大佬的幫助),那麼替罪羊樹就會被卡掉。目前為止作者除了縮點沒有別的辦法可以解決,
但是作者不會寫,如果有好的方法請不吝賜教,謝謝!
3.無旋平衡樹-FHQ Treap
FHQ Treap 好啊!
碼量小,時間快,常數小,可持久化,理解簡單,支援樹套樹等等······有“平衡樹雙子星”的美稱(另一棵是 Splay)。
那麼我們看看 FHQ Treap 又有什麼操作。不過在這之前你需要了解一下 Treap。
1.思路
1.1 前置知識-Treap
Treap者,Tree + Heap 也。是 BST 和堆的結合體。
我們知道 BST 在隨機資料下表現良好,那麼 Treap 正是利用了這一點的性質,來使得 BST 趨近平衡。
當然 Treap 是一種有旋平衡樹,不過我們重點是講 FHQ Treap,因此只要知道 Treap 的思路就好。
Treap 的核心思路就是:對每一個節點 隨機 分配一個 Key 值,使得在 BST 中 val 滿足 BST 的性質,而 Key 滿足堆的性質(至於是大根堆還是小根堆隨意)。
那麼這樣,BST 就能比較平衡 (我不會證明,不過用著就行了)。
當然如果這樣你的 BST 還是不平衡那隻能說明你的運氣不好。
那麼知道了這些,我們看看 FHQ Treap 又是怎麼一回事。
1.2 思路-FHQ Treap
FHQ Treap 是由神犇 fhq 發明的一種一種資料結構,基於 Treap,而 FHQ Treap 的核心操作只有兩個:分裂(Split)和合並(merge)。
在知道分裂(Split)和合並(merge)之後,你就可以玩轉 FHQ Treap了。
2.一些基礎函式
新建節點:
int Make_Node(int x)
{
++cnt;//不想寫引用了qwq
tree[cnt].size = 1;
tree[cnt].val = x;
tree[cnt].key = rand();//隨機賦予 Key 值
return cnt;
}
void update(int x)
{
tree[x].size = tree[tree[x].l].size + tree[tree[x].r].size + 1;
}//更新
3.分裂-Split
分裂有兩種形式:按值分裂和按大小分裂。
在題目當中使用哪種方法是不固定的,我們需要根據題目靈活應變。
3.1 按值分裂
比如現在有這樣一棵 Treap,節點上藍色的是 val,綠色的是 Key。
現在我們按值 17 分裂。
按值分裂的標準是:將所有小於等於 17 的節點分裂成一棵 FHQ Treap,將所有大於 17 的節點分裂成一棵 FHQ Treap。
分裂之後的樹如下。
那麼我們如何分裂呢?
首先看根節點。\(val = 19 > 17\),根據 BST 的性質,當前節點及其右子樹的 \(val\) 值都大於 17,直接將當前節點以及右子樹裂開,同時往左子樹查詢有沒有大於 17 的點(注意:雖然當前節點左兒子 \(val = 16 < 17\),但是還有一個葉子節點 \(val = 18\)),將其作為當前節點的左兒子。
然後看其左兒子。\(val = 16 < 17\),根據 BST 的性質,當前節點及其左子樹的 \(val\) 值都小於 17,直接將當前節點以及左子樹裂開,同時往右子樹查詢有沒有小於 17 的點(注意:雖然當前節點右兒子 \(val = 18 > 17\),但是下面可以在一開始的時候接一個 \(val = 15\) 的點),將其作為當前節點的右孩子。
其實從上面的話你就可以看出來,FHQ Treap 是復讀機式的操作。
那麼如何確定根節點呢?將第一個分裂的節點作為根節點。
程式碼:
void split(int now, int val, int &x, int &y)//好吧還是寫了引用
{
if (!now) x = y = 0;//葉子節點
else
{
if (tree[now].val <= val)
{
x = now;//分裂左子樹
split(tree[now].r, val, tree[now].r, y);//找右子樹
}
else
{
y = now;//分裂右子樹
split(tree[now].l, val, x, tree[now].l);//找左子樹
}
update(now);//不要忘記更新
}
}
3.2 按大小分裂
前面說了按值分裂,接下來我們看看如何按大小分裂。
按大小分裂的標準是:將前 \(k\) 小分裂到一棵樹上,將剩餘節點分裂到另一棵樹上。
比如上面這棵樹,如果我們按照大小 2 分裂,那麼最後 13,16 這兩個節點就會在一棵樹內。
而按大小分裂的程式碼與按值分裂的程式碼驚人搬的相似。
程式碼:
void split(int now, int k, int &x, int &y)
{
if (!now) x = y = 0;//葉子節點
else
{
if (tree[tree[now].l].size + 1 <= k)//往右子樹找
{
x = now;
split(tree[now].r, k - tree[tree[now].l].size + 1, tree[now].r, y);
}
else//往左子樹找
{
y = now;
split(tree[now].l, k, x, tree[now].l);
}
update(now);
}
}
4.合併-merge
將上面的操作倒過來就是合併了qwq,是不是很簡單。
圍觀群眾:你是不是在逗我。
作者:我好好講,我好好講。
實際上你會發現上面兩幅圖倒換一下就是合併了qwq,那麼合併的時候對於兩棵樹,我們按照其 Key 值合併,而且在合併的時候需要保證前面這棵子樹的 \(val_{max}\) 小於等於後面這棵子樹的 \(val_{min}\)。
圍觀群眾:這跟最前面的話不是沒區別嗎qwq
我們從兩棵子樹的根節點出發,每一次我們對比兩個的 Key 值,按照 Key 的大小合併(至於是大根堆還是小根堆隨意),然後不斷往下找即可。
程式碼:
int merge(int x, int y)//不想寫引用qwq
{
if (!x || !y) return x + y;//其中一棵子樹到了葉子節點
if (tree[x].key > tree[y].key)//按照 Key 值合併
{
tree[x].r = merge(tree[x].r, y);//繼續合併,將 x 往下走一層
update(x); return x;//不要忘記更新
}
else
{
tree[y].l = merge(x, tree[y].l);//繼續合併,將 y 往下走一層
update(y); return y;//不要忘記更新
}
}
5.插入(Insert) & 刪除(Delete)
插入:先按照插入值 \(val\) 分裂成 \(x,y\) 兩棵樹,然後新建一個節點,再合併回去即可。
刪除:先按照刪除值 \(val\) 分裂成 \(x,z\) 兩棵樹,然後再按照刪除值 \(val - 1\) 將 \(x\) 分裂成 \(x',y\) 兩棵樹,合併 \(y\) 的左右子樹(相當於自動刪除根節點)為 \(y'\),最後合併 \(x',y',z\)。
程式碼:
void Insert(int val)
{
int x, y;
split(root, val, x, y);
root = merge(merge(x, Make_Node(val)), y);
}
void Delete(int val)
{
int x, y, z;
split(root, val, x, z); split(x, val - 1, x, y);
y = merge(tree[y].l, tree[y].r); root = merge(merge(x, y), z);
}
6.其他操作
6.1 找 x 的排名(Find_Rank_of_x) & 找第 k 大(Find_xth)
找 x 的排名:按照 \(val-1\) 分裂成 \(x,y\) ,輸出 \(size(x) + 1\),然後再合併回去。
找第 k 大:替罪羊樹怎麼搞我們就怎麼搞。
程式碼:
void Find_Rank_of_x(int val)
{
int x, y;
split(root, val - 1, x, y);
printf("%d\n", tree[x].size + 1);
root = merge(x, y);
}
void Find_xth(int val)
{
int now = root;
while (now)
{
if (tree[tree[now].l].size + 1 == val) break;
if (tree[tree[now].l].size >= val) now = tree[now].l;
else {val -= tree[tree[now].l].size + 1; now = tree[now].r;}
}
printf("%d\n", tree[now].val);
}
6.2 找前驅(Find_pre) & 找後繼(Find_aft)
找前驅:按照 \(val - 1\) 分裂成 \(x,y\) 兩棵樹,在 \(x\) 內找最大值輸出,然後合併。
找後繼:按照 \(val\) 分裂成 \(x,y\) 兩棵樹,在 \(y\) 內找最小值輸出,然後合併。
程式碼:
void Find_pre(int val)
{
int x, y;
split(root, val - 1, x, y);//分裂
int now = x;
while (tree[now].r) now = tree[now].r;//找最大值
printf("%d\n", tree[now].val);
root = merge(x, y);//不要忘記合併
}
void Find_aft(int val)
{
int x, y;
split(root, val, x, y);//分裂
int now = y;
while (tree[now].l) now = tree[now].l;//找最小值
printf("%d\n", tree[now].val);
root = merge(x, y);//不要忘記合併
}
7.最後的程式碼
限於篇幅問題,完整程式碼請在 這裡 檢視。
8.FHQ Treap 的好處
FHQ Treap 好啊!
碼量小,容易理解,復讀機的操作,可以可持久化,支援區間操作,拿來樹套樹也是一個不錯的選擇。
9.FHQ Treap 的問題
FHQ Treap 哪裡都好,但是有一點不是很好:
- 如果寫法不優秀不嚴謹,可能稍不留神就會 TLE(詳見 這篇帖子)。
- 常數還是有一點點大(當然肯定比 Splay 小得多)。
4.總結
在這一片博文中,我們學習了替罪羊樹,FHQ Treap這兩種無旋平衡樹,其中 FHQ Treap 一定要重點掌握,在後面的程式碼中很多都是用 FHQ Treap寫的;但是替罪羊樹也要熟練,因為以後也有用得到的地方。
這裡再放一下這兩種無旋平衡樹的主要思路:
- 替罪羊樹:採取懶刪除,哪棵子樹不平衡就拍扁重構哪棵子樹。
- FHQ Treap:使用 Split 和 Merge 函式來實現各種操作。
接下來,在 平衡樹演算法總結&專題訓練2 中,將會詳細講解有旋平衡樹:AVL 樹,Splay樹,順便會根據自己寫的程式碼列一張表格做個對比。