平衡樹是一種十分有用的數據結構,它能支持如下操做:node
瞭解平衡樹,先從最普通的\(\text{Treap}\)開始。(注:下文的平衡樹實現均用指針)c++
平衡樹是一種特殊的二叉查找樹,所謂二叉查找樹,就是知足全部子樹中,根節點的權值大於(小於)左子樹中任一結點,小於(大於)右子樹中任一結點,而特殊就特殊在它是平衡的——任一結點的左右子樹高度差\(\leq 1\),換句話說知足中序遍歷遞增。好比下圖就是一個平衡的二叉查找樹。算法
固然處理序列問題時只考慮中序遍歷的有序性。這樣的二叉查找樹在插入、刪除、查詢的過程當中都可以保證複雜度爲\(\text{O}(\log{N})\),\(N\)表示當前二叉樹內結點數。
但事實上咱們在正常操做過程當中,沒法保證二叉查找樹必定是平衡的。若是須要插入的數據是單調遞增或單調遞減的,獲得的二叉查找樹將會退化成一條鏈,就像這樣:數組
因此有關於平衡樹的算法就誕生了,好比下面的\(\text{Treap}\)。\(\text{Treap}=\text{Tree}+\text{Heap}\),即擁有二叉搜索樹的結構,也擁有大根堆的性質。事實上\(\text{Treap}\)和大多數平衡樹並不能作到徹底平衡,但能儘量保持平衡保證複雜度。不過再說這個算法前,須要作一些準備。數據結構
struct Node *nil; // 自定義的空指針,防止翻車(RE) struct Node { Node *ch[2]; // 結點的左右孩子。爲何不分開寫成lc,rc呢?日後就知道了 int v, s, c; // v表示該結點的值,s表示以該結點爲根的子樹大小,c表示權值v的結點數量(合併了相同權值的結點),即v的副本數量 int r; // Treap專有的隨機數,是大根堆的關鍵字 void maintain() { // 維護當前結點的信息 s = ch[0]->s + ch[1]->s + c; } Node(int v) : v(v), c(1), s(1), r(rand()) { ch[0] = ch[1] = nil; } // 新建結點 } *root; void init() { srand(0); // 隨機數初始化 nil = new Node(0); // 空指針初始化 root = nil->ch[0] = nil->ch[1] = nil; // 左右孩子指向自身 nil->s = nil->c = 0; // 防止空指針影響後面的操做 }
這裏操做的是可重複集合,爲了方便,將重複出現的數合併成一個結點。測試
旋轉分爲左旋和右旋。旋轉是大多數平衡樹的核心,由於調節樹的平衡全靠它。且調節平衡後,二叉查找樹的性質沒有變。ui
先看左旋:\(k=o->ch[1]\),\(x=k->ch[0]\),爲何先看\(o,k,x\)呢?由於它們三個點是旋轉過程當中很是重要的三個點,這三個點換了爸爸(對父親結點親切的稱呼),而另外兩個並無。
再看這三個結點鏈接的變化。選擇恰當的順序很重要。
①將\(o\)的右兒子指針由\(k\)改指向\(k\)的左兒子\(x\),即\(o->ch[1]=k->ch[0]\);this
②將\(k\)的左兒子指針由\(x\)改到\(o\),即\(k->ch[0]=o\);spa
③將根\(o\)提到\(k\)的位置,即\(o=k\)。3d
這樣左旋就完成了。等等,還沒!由於\(o,k\)的兒子有變更,因此它們的信息要及時維護,須要在\(o\)提到\(k\)以前執行\(o->maintain()\)和\(k->maintain()\),這兩句更新順序不能寫錯,由於\(o\)的兒子是\(k\),因此必須等\(k\)更新完後\(o\)才能更新數據。
再看右旋:\(k=o->ch[0]\),\(x=k->ch[1]\)...別急,左右兒子和左旋正好相反!\(0\)變成了\(1\),\(1\)變成了\(0\)!繼續發現①將\(o\)的左兒子指針由\(k\)改指向\(k\)的右兒子\(x\),即\(o->ch[0]=k->ch[1]\),相反;②將\(k\)的左兒子指針由\(x\)改到\(o\),即\(k->ch[1]=o\),相反!並且\(\text{!}0=1\),\(\text{!}1=0\),若是咱們給左旋和右旋標一個號\(d\),分別爲\(0\)和\(1\),那麼經過上面分析的特徵,咱們能夠把左旋和右旋的代碼合併,而不用分別寫左右旋的過程了!
代碼要細細對照上面來看。
// d=0表明左旋,d=1表明右旋 void rotate(Node* &o, int d) { // 注意這裏o須要加上引用,由於旋轉事後換的根要傳出去的,不然仍是原來的o Node *k = o->ch[!d]; o->ch[!d] = k->ch[d]; // STEP 1 k->ch[d] = o; // STEP 2 o->maintain(); k->maintain(); // STEP 2.5 o = k; // STEP 3 }
一次旋轉的複雜度爲\(\text{O}(1)\)。
對於插入操做,咱們採起這樣的方式:從根開始一直根據結點大小關係選擇向左仍是向右走,直到走到了空結點(\(\text{nil}\)),或者走到了一個權值相等的結點,此時咱們將其合併,不然插入的數據在這裏做爲葉子結點代替(\(\text{nil}\))。
好比在這樣的一棵平衡樹中插入元素\(2\),首先從根開始。
\(2<6\),因此會往左邊走,到了\(4\)這個結點。
\(2<4\),繼續往左走,到達\(3\)。
\(2<3\),繼續往左走,到達\(1\)。
\(2>1\),往右走到達了空結點,因而將\(2\)做爲\(1\)的右兒子。
但光這樣作可不行,無法保證複雜度,因此還須要經過一路向上旋轉來保證大根堆堆的性質。而關鍵字是隨機的,能夠證實這樣作的複雜度爲指望\(\text{O}(\log N)\)。因此代碼就出來了。
void insert(Node* &o, int v) { if (o == nil) { o = new Node(v); return; } // 走到了空結點要新建 if (o->v == v) { o->c++, o->s++; return; } // 若是遇到了相同權值的結點要合併&&維護信息 int d = v < o->v ? 0 : 1; // d表示方向,0表示左,1表示右 insert(o->ch[d], v); // 遞歸插入 if (o->r < o->ch[d]->r) rotate(o, !d); // 維護大根堆性質,旋轉方向與遞歸的方向相反 o->maintain(); // 注意哦!o不必定參與rotate,因此要及時維護信息 }
對於要刪除的數值,若是它有別的副本,就保留該結點,不然將其移至葉子結點處刪除。細細來講,當找到目標結點時,有\(4\)種狀況:
①目標結點存有大於\(1\)的副本數,那麼只須要減去一個副本便可。
②目標結點只有\(1\)個副本。考慮將其移至葉子結點,而其自己就是葉子。直接刪除便可。
③目標結點只有\(1\)個副本。考慮將其移至葉子結點,而其只有一個兒子。將兒子提上來代替目標結點,而後刪除。
④目標結點只有\(1\)個副本,而其有兩個兒子,爲知足堆性質,比較兩個兒子的隨機關鍵字的大小關係,選擇大的將其右旋上去,目標結點就會被轉下去,而後又會變成這幾種可能,遞歸處理便可。
(這是個栗子,假設\(ch[0]->r > ch[1]->r\))
(而後就變成狀況③了)
在實際實踐中,通常操做②和③合併起來,由於操做②至關於將空結點(\(\text{nil}\))提上來再刪。不管如何,最後對於根節點都須要維護下信息,但必定要注意判斷是否爲空結點。
void remove(Node* &o, int v) { if (o == nil) return; // 沒找到 if (o->v == v) { // 找到了關鍵字爲v的結點 if (!o->c || !--o->c) { // 若是o->c = 0,說明這個結點要移到葉子處刪除,不然減去1且僅一次且同時判斷是否要移動 if (o->ch[0] == nil || o->ch[1] == nil) { // 若是隻有一個兒子(與沒有兒子的狀況合併),直接提上來代替當前的根 Node *p = o; o = o->ch[o->ch[0] == nil ? 1 : 0]; delete p; } else { // 不然要知足堆性質,隨機關鍵字大的提上來 int d = o->ch[0]->r > o->ch[1]->r ? 0 : 1; rotate(o, !d); // 注意旋轉的方向 remove(o->ch[!d], v); // 注意遞歸方向 } } } else remove(o->ch[v < o->v ? 0 : 1], v); // 繼續找v if (o != nil) o->maintain(); // 同理移除過程當中也要維護信息,但必定要判其是否爲nil! }
根據二叉搜索樹的性質以及維護的信息,能夠很方便簡單地完成該操做。
考慮查找數值\(v\)的排名,咱們從樹根開始找。根據二叉搜索樹的性質,咱們能夠將\(v\)與根\(o\)中的\(v\)比較,判斷出\(v\)的位置:在左子樹中(\(v < o->v\)),在右子樹中(\(v > o->v\)),在根中(\(v == o->v\))。
好比如下圖中:
這種狀況直接遞歸左子樹。
這種狀況直接返回左子樹大小\(+1\)。
這種狀況遞歸右子樹,以後加上左子樹大小和\(o\)的副本數。
// 注意:這裏查找的排名爲相同數值v中最小的排名 int get_rank(Node* o, int v) { if (o->v == v) return o->ch[0]->s + 1; // 找到v if (v < o->v) return get_rank(o->ch[0], v); // v在左子樹中 return get_rank(o->ch[1], v) + o->ch[0]->s + o->c; // v在右子樹中,此時要將左子樹中的全部元素以及根的元素數統計起來 }
根據排名查值幾乎就是上面反過來,注意\(3\)中可能的界線。這裏直接放代碼了。
int get_val(Node* o, int k) { if (k <= o->ch[0]->s) return get_val(o->ch[0], k); // 排名爲k的數在左子樹中 else if (k <= o->ch[0]->s + o->c) return o->v; // 爲o else return get_val(o->ch[1], k - o->ch[0]->s - o->c); // 在右子樹中 }
這裏的前驅後繼不與待查找的數相等(固然相等也能夠),例如\(\{1,2,2\}\)中任一的\(2\)的前驅爲\(1\)而不爲\(2\)。前驅後繼的查詢很簡單、極其類似。查\(v\)的前驅等於\(\text{get_val}(\text{get_rank}(v)-1)\),但常數較大,根據這個啓發咱們要找一個數的排名儘量大(或者小),但這個數要小於\(v\)(或者大於),因此就有了如下的代碼。
int get_pre(Node *o, int v) { // 查前驅 if (o == nil) return -INF; // 找到空結點返回無窮小目的是不干擾其它解 if (o->v >= v) return get_pre(o->ch[0], v); // v大於當前結點o的值,向左走找小一點的值 return max(o->v, get_pre(o->ch[1], v)); // 不然向右走找更大的值並與當前值比較 } int get_next(Node *o, int v) { // 查後繼,與上面徹底相反 if (o == nil) return INF; if (o->v <= v) return get_next(o->ch[1], v); return min(o->v, get_next(o->ch[0], v)); }
以上即爲\(\text{Treap}\)的大部份內容,能夠過掉洛谷P3369【模板】普通平衡樹。
下面的操做涉及序列操做,就不進行結點合併了。我不會告訴你這個是無旋\(\text{Treap}\)的核心操做哦QwQ
分裂分爲兩種:按值分裂、按排名(或序列位置)分裂。分裂指從某兩個元素之間切開,分紅左右兩部分。先說按值分裂。
按值分裂指從第一個元素爲\(v\)的結點前面劃分,前面的在左邊,後面的在右邊。好比下面的一棵樹以關鍵字\(5\)進行分裂。
可是右邊的不是一棵樹啊。
咱們採起這樣的策略:對於當前的根結點,若是結點的值\(<\)關鍵字,說明該結點以及左子樹的全部結點的值都小於關鍵字,因此不用考慮,而右子樹不肯定,因此左半邊就爲當前的根,而其右子樹須要遞歸分裂;反之說明右子樹不用考慮,往左子樹遞歸。直到遞歸到空結點。
放上面的栗子。首先走到整棵樹的根結點,發現\(5<6\),因此根和右子樹所有劃入右邊,向左子樹遞歸。
接着發現\(5>4\),根和左子樹劃入左邊,向右子樹遞歸。
而後\(5=5\),根和右子樹(空樹)劃入右邊,向左子樹遞歸。
最後走到了空結點,結束。
代碼以下:
void split(Node *o, int v, Node* &l, Node* &r) { if (o == nil) { l = r = nil; return; } // 到了空結點兩邊附上空後結束 if (o->v < v) { l = o; split(o->ch[1], v, l->ch[1], r); l->maintain(); } // 第一種狀況。注意分裂後要及時維護信息 else { r = o; split(o->ch[0], v, l, r->ch[0]); r->maintain(); } // 第二種狀況。同上 }
不過這樣作兩棵樹並不太平衡,不過不影響大局的複雜度(深度沒變深)。
按照序列位置分裂其實只跟排名有關,這裏指將前\(k\)個元素分在左邊,其他在右邊,大致上與按值分裂類似,直接放代碼了。
void split(Node *o, int k, Node* &l, Node* &r) { if (o == nil) { l = r = nil; return; } if (k >= o->ch[0]->s + 1) { l = o; split(o->ch[1], k, l->ch[1], r); l->maintain(); } else { r = o; split(o->ch[0], k - o->ch[0]->s - 1, l, r->ch[0]); r->maintain(); } }
合併顧名思義就是將兩棵平衡樹合併起來,在序列上的意義就是兩段序列拼起來。方法也不難:從兩棵樹的頂端開始,爲了保證堆性質,先肯定隨機關鍵字大的,再遞歸子樹合併,最後直到空結點中止。若是是左邊的樹先(加劇爲了區分),能夠肯定的是其左子樹必定不會有右邊的樹的結點,不然不知足中序遍歷,因此遞歸合併右子樹;反之亦然。
好比這個栗子,藍色數字表示隨機關鍵字。先比較左右兩棵樹根的隨機關鍵字,發現\(7<9\),因此先選右邊的,而右子樹全部內容已肯定,只需遞歸左子樹了。
繼續,\(7>6\),再選左邊的,左子樹的全部內容已肯定,只需遞歸右子樹了。
左邊的數空了,直接選上右邊的就結束了。
Node* merge(Node *a, Node *b) { if (a == nil) return b; // 若是a空返回b if (b == nil) return a; // 若是b空返回a if (a->r > b->r) { a->ch[1] = merge(a->ch[1], b); a->maintain(); return a; } // 比較二者的隨機關鍵字,大的先合併 else { b->ch[0] = merge(a, b->ch[0]); b->maintain(); return b; } }
事實上提早生成隨機數再根據其合併能夠被卡(不過也懶得卡,本人只被卡過一次),因此不生成隨機數,在合併時根據兩邊樹的大小分配機率隨機合併這樣更穩。代碼上就是
if (a->r > b->r) { ... }
改爲
if (rd() % (a->s + b->s) < a->s) { ... }
這裏用\(\text{rd}()\):
ll rd() { return rand() * RAND_MAX + rand(); }
不用\(\text{rand}()\),緣由在於\(\text{rand}()\)的最大值可能不夠。
以上兩個操做即爲無旋\(\text{Treap}\)的核心操做,利用它能夠完成其餘操做。
插入:就是找對位置一次拆分再兩次合併。
void insert(Node* &rt, int v) { Node *l, *r; split(rt, v, l, r); rt = merge(merge(l, new Node(v)), r); }
刪除:就是找對位置兩次拆分刪除中間的一個結點再所有依次合併。
void remove(Node* &rt, int v) { Node *l, *mid, *r; split(rt, v, l, r); split(r, v+1, mid, r); rt = merge(merge(l, merge(mid->ch[0], mid->ch[1])), r); delete mid; }
其餘操做相似。是否是簡單了好多?
因此對於上面的那題,至關於維護一個有序的序列,支持插入刪除查詢第\(k\)位等等。是否是辣樣\(nie\)?
而它的優點不只僅是簡單,還能用來可持久化呢!
平衡樹的可持久化與主席樹的思想很相像,實現其實不難:不去破壞原有結點,遇到修改操做新建結點。
可是帶旋\(\text{Treap}\)的可持久化十分複雜:涉及了很多結點的修改,下面要說的\(\text{Splay}\)和替罪羊樹複雜度是均攤\(\text{O}(\log N)\)的,某些對過去版本進行操做的單次複雜度又可能過高,因此不可以徹底作到可持久化,而無旋\(\text{Treap}\)就可以很好地勝任這一工做。
void split(Node *o, int v, Node* &l, Node* &r) { if (o == nil) { l = r = nil; return; } if (v > o->v) { l = cpynode(o); split(o->ch[1], v, l->ch[1], r); l->maintain(); } // 須要修改的結點新建 else { r = cpynode(o); split(o->ch[0], v, l, r->ch[0]); r->maintain(); } } Node* merge(Node *a, Node *b) { if (a == nil) return b; if (b == nil) return a; if (a->r > b->r) { a->ch[1] = merge(a->ch[1], b); a->maintain(); return a; } else { b->ch[0] = merge(a, b->ch[0]); b->maintain(); return b; } } (這種寫法的常數比數組版要大,若是有常數更小的指針寫法,望你們告知!)
合併能夠不用新建,直接套用上面的代碼便可。爲啥?由於咱們分裂的目的就是要合併成新的平衡樹,而分裂的版本無需保留,因此直接合並。
悄悄地告訴你線段樹能作的可持久化平衡樹全能作!並且支持的操做更多更強,就是無旋\(\text{Treap}\)的常數很大!(跑的賊慢空間賊大)
最強的操做就是複製啦!不用將待複製結點所有新建,只要——拷貝指針便可!
到這裏能夠切掉洛谷P3835【模板】可持久化平衡樹辣。
跟線段樹同樣,區間修改不免要\(\text{pushdown}\),而平衡樹也有下傳,好比區間翻轉。這裏給出帶區間翻轉的平衡樹的\(\text{pushdown}\)。
void pushdown() { if (!rev) return; ch[0]->rev ^= 1, ch[1]->rev ^= 1; swap(ch[0], ch[1]); rev = 0; }
而後認真思考下該在什麼時候下傳標記呢?發現應該在被操做結點操做以前下傳,具體的看下面的代碼。
void split(Node *o, int k, Node* &l, Node* &r) { if (o == nil) { l = r = nil; return; } o->pushdown(); // 在o被修改以前下傳下標記 if (k >= o->ch[0]->s + 1) { l = o; split(o->ch[1], k - o->ch[0]->s - 1, l->ch[1], r); l->maintain(); } else { r = o; split(o->ch[0], k, l, r->ch[0]); r->maintain(); } }
到這裏能夠切掉洛谷P3391【模板】文藝平衡樹\(\text{AND}\)洛谷P5055【模板】可持久化文藝平衡樹辣。
\(\text{Splay}\)又叫做伸展樹,也基於旋轉,它的核心也就是把某個結點旋轉到根,普通的旋轉到根沒法保證複雜度,而經過某種策略的旋轉能夠保證複雜度爲均攤\(\text{O}(\log N)\)。這也就說\(\text{Splay}\)不太適合可持久化(上文也分析了)。而\(\text{Splay}\)與\(\text{LCT}\)搭配簡直完美無缺,其它的平衡樹就作不到了。
下文的代碼可能爲了幹練犧牲了可讀性。
旋轉大致上類似,只不過在結點的定義上多出個指向父節點的指針\(fa\),旋轉時注意維護。這裏旋轉有另外一個直觀的解釋:將待旋轉結點旋轉成父親。好比像下面。
不難發現左旋其實是將\(k\)變成父親,右旋將\(o\)變成了父親。
因此數據結構體改爲如下的代碼(詳見註釋)。
struct Node { Node *fa, *ch[2]; // 指向父親和孩子的指針 int v, s, c; // 與上面同樣 int id() { return fa->ch[1] == this; // 返回該結點對於父節點是左兒子仍是右兒子(左0右1) } void maintain() { s = ch[0]->s + ch[1]->s + c; // 維護子樹大小 } void rotate() { // 如下順序有講究,請細細體會 int d = id(); Node *f = fa; (f->fa->ch[f->id()] = this)->fa = f->fa; // 改鏈接this和f->fa (f->ch[d] = ch[!d])->fa = f; // 改鏈接ch[!d]和f (ch[!d] = f)->fa = this; // 改鏈接f和this f->maintain(); // 在Splay中其實只要維護原先父親的信息便可(除自身之外其它信息不會變) } void splay(Node *top) ; // 這個核心下文會講到 Node(int v, Node *f) : v(v), s(1), c(1), fa(f) { ch[0] = ch[1] = nil; } // 新建結點傳入參數 } *rt; void init() { // 切記必定要調用 nil = new Node(0, nil); rt = nil->fa = nil->ch[0] = nil->ch[1] = nil; nil->s = nil->c = 0; }
\(\text{Splay}\)操做就是將某一個結點\(o\)一直旋轉到根。也許你已經想到要一直調用\(o->\text{rotate}()\),直到\(o->fa = \text{nil}\),但這樣太過簡單了,沒辦法保證複雜度,採起下面的策略就能保證複雜度爲均攤\(\text{O}(\log N)\)了(具體證實我不會)
對於結點\(o\),咱們看它的爸爸的爸爸(爺爺咯)(目光要長遠啊——)
①\(o\)的爸爸是\(\text{nil}\)。得了吧已是根了就結束了。
②\(o\)的爺爺是\(\text{nil}\),這種狀況\(o\)只要單旋上去就成了根,結束。
③\(o\)的爺爺不是\(\text{nil}\),且從爺爺到\(o\)的方向所有相同,這種狀況先將爸爸轉上去(此時\(o\)的深度減少了)再把\(o\)轉上去\(o\)就成了爺爺,狀況又變成了①②③④。
④\(o\)的爺爺不是\(\text{nil}\),且從爺爺到\(o\)的方向不相同,這種狀況就將\(o\)連轉兩次就成了爺爺,狀況又變成了①②③④。
很複雜?其實代碼很簡單。
void splay(Node *top) { // 核心操做splay,其中傳參目的是將其旋轉到top的下面。當top=nil時即爲旋轉到根。LCT中能夠不傳 for (/* 這裏能夠加上標記下傳,下文將提到 */; fa != top; rotate()) // ①循環會結束;rotate()旋轉本身是②③④操做的共同點 if (fa->fa != top) (id() == fa->id() ? fa : this)->rotate(); // 極簡的三種狀況寫法:if爲false則是②,id()==fa->id()成當即爲③,反之爲④ maintain(); // 補上對本身信息的維護 }
將\(o\)旋轉到根只需調用\(o->\text{splay(nil)}\)便可。
插入其實沒什麼特別的,相似\(\text{Treap}\)的插入,只是要把結果旋轉到根。注意要鏈接父親便可。
刪除會有小區別。找到待刪除結點,先旋轉到根,若是刪完後副本數大於\(0\)就沒啥事了,不然將左子樹的最後一位伸展上來鏈接右子樹。
void insert(Node *&rt, int v) { if (rt == nil) { rt = new Node(v, nil); return; } // 特判空樹 for (Node *o = rt;;) { if (o->v == v) { o->c++, o->s++; // 加一個副本 (rt = o)->splay(nil); // 旋轉到根保證複雜度 return; } int d = v > o->v; if (o->ch[d] == nil) { (rt = o->ch[d] = new Node(v, o))->splay(nil); // 先新建結點,再變成新的根旋轉上去 return; } o = o->ch[d]; // 繼續找 } } void remove(Node *&rt, int v) { for (Node *o = rt; o != nil; o = o->ch[v > o->v]) // 一直找v,找不到v會退出 if (o->v == v) { // 找到了 o->splay(nil); // 先將o旋轉到根 if (!(--o->s, --o->c)) { // 同時減維護信息,並判斷副本數是否減完了 if (o->ch[0] == nil || o->ch[1] == nil) // 相似於帶旋Treap刪除的狀況 (rt = o->ch[o->ch[0] == nil])->fa = nil; else { Node *p = o->ch[0]; while (p->ch[1] != nil) p = p->ch[1]; // 將o的左子樹中權值最大的結點找出來,再旋轉到根,保證o的左孩子無右子樹 (rt = p)->splay(o); // 旋轉到根 ((p->ch[1] = o->ch[1])->fa = p)->fa = nil; // 操做有點多:左孩子與o的右子樹創建聯繫,左孩子的父節點設成空(成根結點了) delete o; // 刪除 } } else rt = o; // 不然副本未刪完的o結點爲根 return; } }
也沒什麼特別的,只是要把含有答案的結點旋轉到根,不然有可能一直找一個深度很大很大的點許屢次而被卡複雜度。
// 值與排名之間的查詢 // 如下兩個操做爲非遞歸寫法,但有少量不一樣之處 int get_rank(Node *&rt, int v) { int rk = 0; Node *o = rt; while (1) { if (o->v == v) { rk += o->ch[0]->s + 1; (rt = o)->splay(nil); // 找到答案要旋轉到根 break; } if (v < o->v) o = o->ch[0]; else rk += o->ch[0]->s + o->c, o = o->ch[1]; } return rk; } int get_val(Node *&rt, int rk) { Node *o = rt; while (1) { if (rk <= o->ch[0]->s) o = o->ch[0]; else if (rk <= o->ch[0]->s + o->c) { (rt = o)->splay(nil); // 同上 return rt->v; } else rk -= o->ch[0]->s + o->c, o = o->ch[1]; } }
這兩個操做不方便\(\text{Splay}\)就沒有寫了。
// 查前驅後繼 // 如下兩個操做將遞歸改爲了非遞歸的寫法 int get_pre(Node *o, int v) { int res = -INF; while (o != nil) { if (o->v >= v) o = o->ch[0]; else res = max(res, o->v), o = o->ch[1]; } return res; } int get_next(Node *o, int v) { int res = INF; while (o != nil) { if (o->v <= v) o = o->ch[1]; else res = min(res, o->v), o = o->ch[0]; } return res; }
\(\text{SBT}\)即爲重量平衡樹。主要的區別就在於它的平衡原理是每棵樹的大小不小於其兄弟樹的子樹的大小。記\(x\)爲根的結點的子樹大小爲\(size[x]\),那麼知足:\[size[x->lc] \geq size[x->rc->lc] \&\& size[x->rc->rc]\]和\[size[x->rc] \geq size[x->lc->lc] \&\& size[x->lc->rc]\] 這裏爲表示直觀,用\(lc,rc\)代替\(ch[0]\)和\(ch[1]\)。若是不知足上面的條件,經過左旋或右旋來進行調整。經過這樣可以保證單次操做複雜度爲\(\text{O}(\log N)\)。在\(\text{OI}\)中不太常見,具體實現再也不贅述,下文詳講一下比較常見、寫法簡單、不基於旋轉的一種根據重量調節平衡的平衡樹:替罪羊樹。
const int maxn = 111111; const double alpha = 0.7; // 平衡因數 int n, opt, x; struct Node *nil; struct Node { Node *lc, *rc; int v, s, c, size; void maintain() { s = lc->s + rc->s + c; size = lc->size + rc->size + (c ? 1 : 0); } bool bad() { return max(lc->size, rc->size) > size * alpha; } Node(int v) : v(v), s(1), c(1), size(1) { lc = rc = nil; } } *rt; void init() { nil = new Node(0); rt = nil->lc = nil->rc = nil; nil->s = nil->c = nil->size = 0; } Node **id; Node *cur[maxn]; int pos; void dfs(Node *o) { if (o == nil) return; dfs(o->lc); if (o->c) cur[pos++] = o; dfs(o->rc); if (!o->c) delete o; } Node *build(int l, int r) { if (l > r) return nil; int mid = l+r>>1; cur[mid]->lc = build(l, mid-1); cur[mid]->rc = build(mid+1, r); cur[mid]->maintain(); return cur[mid]; } void rebuild(Node *&rt) { pos = 0; dfs(rt); rt = build(0, pos-1); }
和普通\(\text{BST}\)插入很像?只是要注意信息維護,以及在最高的地方拍扁重建便可。
void insert(Node *&o, int v) { if (o == nil) { o = new Node(v); return; } if (o->v == v) o->c++; else insert(o->v > v ? o->lc : o->rc, v); if (o->bad()) id = &o; o->maintain(); } void remove(Node *&o, int v) { if (o->v == v) { o->c--; o->maintain(); return; } remove(o->v > v ? o->lc : o->rc, v); if (o->bad()) id = &o; o->maintain(); }
沒什麼特別的吧。並非!注意到使用的是惰性刪除,因此——要有些改動。好比說前驅後繼就不能用了,要改用查數值排名再查數值的辦法獲得前驅後繼。這裏不放代碼了。
對了說下替罪羊樹最大的做用:解決旋轉沒法維護或者不方便維護的問題。詳見論文【陳立傑-重量平衡樹和後綴平衡樹在信息學奧賽中的應用】。
平衡樹實現算法及細節 | 耗時(無O2) |
---|---|
帶旋Treap不帶結點合併 | 210ms |
帶旋Treap帶結點合併 | 216ms |
無旋fhq-Treap無合併 | 280ms |
Splay | 327ms |
替罪羊樹 | 291ms |
紅黑樹 | Unknown |
P.S.這裏用【洛谷P3369-普通平衡樹】測試,結果爲經過全部測試點的時間總和
P.P.S.支持操做:\(\text{insert(), remove(), get_rank(), get_val(), get_pre(), get_next()}\)