1. 程式人生 > 實用技巧 >「演算法筆記」Treap

「演算法筆記」Treap

一、引入

隨機資料中,BST 一次操作的期望複雜度為 \(\mathcal{O}(\log n)\)

然而,BST 很容易退化,例如在 BST 中一次插入一個有序序列,將會得到一條鏈,平均每次操作的複雜度為 \(\mathcal{O}(n)\)。我們稱這種左右子樹大小相差很大的 BST 是“不平衡”的。

有很多方法可以維持 BST 的平衡,從而產生了各種平衡樹。

Treap 就是常見平衡樹中的一種。

二、簡介

滿足 BST 性質且中序遍歷為相同序列的二叉查詢樹是不唯一的。這些二叉查詢樹是等價的,它們維護的是相同的一組數值。在這些二叉查詢樹上執行同樣的操作,將得到相同的結果。

因此,我們可以在維持 BST 性質的基礎上,通過改變二叉查詢樹的形態

,使得樹上每個節點的左右子樹大小達到平衡,從而使整棵樹的深度維持在\(\mathcal{O}(\log n)\)級別。

Treap 改變形態並保持 BST 性質的方式為“旋轉”,並且保持平衡而不至於退化為鏈。

Treap=Tree+Heap。Treap 是利用堆的性質來維護平衡的一種平衡樹。對每個節點額外儲存一個隨機值,根據隨機值調整 Treap 的形態,使其滿足 BST 性質外,還滿足父節點的隨機值\(\geq\)子節點的隨機值。

三、Treap

前面說過,為了使 Treap 保持平衡而進行旋轉操作。

旋轉的本質是將某個節點上移一個位置。旋轉需要保證 :

  • 整棵樹的中序遍歷不變(不能破壞 BST 的性質)。

  • 受影響的節點維護的資訊依然正確有效。

每個節點在建立時,賦予其一個隨機值,通過旋轉操作使得隨機值滿足大根堆的性質。這樣可以使得樹高期望保持在 \(\mathcal{O}(\log n)\)

1. 旋轉操作

在 Treap 中的旋轉分為兩種:左旋右旋。如下圖所示。

以右旋為例。在初始情況下,\(x\)\(y\) 的左子節點,\(A\)\(B\) 分別是 \(x\) 的左右子樹,\(C\)\(y\) 的右子樹。

“右旋”操作在保持 BST 性質的基礎上,把 \(x\) 變為 \(y\) 的父節點。因為 \(x\) 的關鍵碼小於 \(y\) 的關鍵碼,所以 \(y\)

應該作為 \(x\) 的右子節點。

\(x\) 變成 \(y\) 的父節點後,\(y\) 的左子樹就空了出來,於是 \(x\) 原來的右子樹 \(B\) 就恰好作為 \(y\) 的左子樹。

  • 左旋:將右兒子提到當前節點,自己作為右兒子的左兒子,右兒子原來的左兒子變成自己新的右兒子。

  • 右旋:將左兒子提到當前節點,自己作為左兒子的右兒子,左兒子原來的右兒子變成自己新的左兒子。

旋轉後的二叉樹仍滿足二叉搜尋樹的條件。

void zig(int &p){    //右旋操作。zig(p) 可以理解成把 p 的左子節點繞著 p 向右旋轉。 
    int q=lc[p];
    lc[p]=rc[q],rc[q]=p,p=q;    //注意 p 是引用 
} 
void zag(int &p){    //左旋操作。zag(p) 可以理解成把 p 的右子節點繞著 p 向左旋轉。 
    int q=rc[p];
    rc[p]=lc[q],lc[q]=p,p=q;    //注意 p 是引用 
}

2. 隨機權值

合理的旋轉操作可使 BST 更“平衡”。如下圖,經過一些旋轉操作,這棵 BST 變得比較平衡了。

在隨機資料下,普通的 BST 就是趨近平衡的。Treap 的思想就是利用“隨機”來創造平衡條件。因為在旋轉過程中必須維持 BST 性質,所以 Treap 就把“隨機”作用在堆性質上。

具體來說,Treap 在插入每個新節點時,給該節點隨機生成一個額外的權值。當某個節點不滿足大根堆性質時,就執行旋轉操作,使該點與其父節點的關係發生對換。

每次刪除/插入時通過隨機的值決定要不要旋轉即可,其他操作與 BST 類似。

特別地,對於刪除操作,由於 Treap 支援旋轉,我們可以直接找到需要刪除的節點,並把它向下旋轉成葉節點,最後直接刪除。這樣就避免了採取類似普通 BST 的刪除方法可能導致的節點資訊更新、堆性質維護等複雜問題。

Treap 通過適當的旋轉操作,在維持節點關鍵碼滿足 BST 性質的同時,還使每個節點上隨機生成的額外權值滿足大根堆性質。Treap 是一種平衡的二叉查詢樹,檢索、插入、求前驅後繼以及刪除節點的時間複雜度都是\(\mathcal{O}(\log n)\)。

四、模板

Luogu P3369 普通平衡樹

題目大意:需要寫一種資料結構,來維護一些數,其中需要提供以下操作:

  1. 插入數值 \(x\)
  2. 刪除數值 \(x\)(若有多個相同的數,應只刪除一個)
  3. 查詢數值 \(x\) 的排名(若有多個相同的數,應輸出最小的排名)
  4. 查詢排名為 \(x\) 的數
  5. 求數值 \(x\) 的前驅(前驅定義為小於 \(x\) 的最大的數)
  6. 求數值 \(x\) 的後繼(後繼定義為大於 \(x\) 的最小的數)

\(1\leq n \leq 10^5,|x| \leq 10^7\)

Solution:平衡樹模板題,用 Treap 實現即可。

資料中可能有相同的數值。記 \(cnt(u)\) 表示節點 \(u\) 對應數值的出現次數,初始時為 \(1\)。(這裡的“對應數值”就是關鍵碼)

若插入已經存在的數值,就直接把 \(cnt\) 值加 \(1\)。刪除時,若 \(cnt(u)>1\),則把 \(cnt(u)\)\(1\);否則刪除該節點。

再記 \(sz(u)\) 表示以 \(u\) 為根的子樹中所有節點的 \(cnt\) 之和。在插入或刪除時從下往上更新 \(sz\) 資訊。另外,在旋轉操作時,也需要同時修改 \(sz\)

在 BST 檢索的基礎上,通過判斷 \(sz(lc(u))\)\(sz(rc(u))\) 的大小,選擇適當的一側遞迴,就能查詢排名了。

在插入和刪除操作時,Treap 的形態會發生變化,一般使用遞迴實現,以便於在回溯時更新 Treap 上儲存的 \(sz\) 等資訊。

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+5; 
int n,opt,x,tot,rt,lc[N],rc[N],val[N],rnd[N],sz[N],cnt[N],ans;    //rnd(u) 表示節點 u 的隨機值 
void upd(int p){
    sz[p]=sz[lc[p]]+sz[rc[p]]+cnt[p];
}
int getnew(int k){
    val[++tot]=k,rnd[tot]=rand(),cnt[tot]=sz[tot]=1;
    return tot;
}
void build(){
    getnew(-1e18),getnew(1e18),rt=1,rc[1]=2,upd(rt);
} 
void rotate(int &p,int dir){    //dir= 0 右旋  1 左旋 
    int q=!dir?lc[p]:rc[p];
    if(!dir) lc[p]=rc[q],rc[q]=p,p=q,upd(rc[p]),upd(p);
    else rc[p]=lc[q],lc[q]=p,p=q,upd(lc[p]),upd(p);
}
void insert(int &p,int k){
    if(!p){p=getnew(k);return ;}    
    if(val[p]==k){cnt[p]++,upd(p);return ;}
    if(k<val[p]){insert(lc[p],k);if(rnd[p]<rnd[lc[p]]) rotate(p,0);}    //不滿足堆性質,右旋 
    else{insert(rc[p],k);if(rnd[p]<rnd[rc[p]]) rotate(p,1);}    //不滿足堆性質,左旋 
    upd(p);
} 
void del(int &p,int k){
    if(!p) return ;
    if(val[p]==k){    //檢索到 k 
        if(cnt[p]>1){cnt[p]--,upd(p);return ;}     //有重複,讓 cnt 值減 1 即可 
        if(lc[p]||rc[p]){    //不是葉子節點,向下旋轉 
            if(!rc[p]||rnd[lc[p]]>rnd[rc[p]]) rotate(p,0),del(rc[p],k);
            else rotate(p,1),del(lc[p],k);
            upd(p);
        }
        else p=0; return ;    //葉子節點直接刪除 
    }
    del(k<val[p]?lc[p]:rc[p],k),upd(p);
}
int rank(int p,int k){
    if(!p) return 0;
    if(val[p]==k) return sz[lc[p]]+1;
    return k<val[p]?rank(lc[p],k):rank(rc[p],k)+sz[lc[p]]+cnt[p];
}
int Kth(int p,int rk){
    if(!p) return 1e18;
    if(sz[lc[p]]>=rk) return Kth(lc[p],rk);
    if(sz[lc[p]]+cnt[p]>=rk) return val[p];
    return Kth(rc[p],rk-sz[lc[p]]-cnt[p]); 
}
int pre(int k){
    int ans=1,p=rt;
    while(p){
        if(val[p]==k){
            if(lc[p]>0){p=lc[p]; while(rc[p]>0) p=rc[p]; ans=p;}    //左子樹上一直向右走 
            break;
        }
        if(val[p]<k&&val[p]>val[ans]) ans=p;
        p=k<val[p]?lc[p]:rc[p]; 
    }
    return val[ans];
}
int nxt(int k){
    int ans=2,p=rt;
    while(p){
        if(val[p]==k){
            if(rc[p]>0){p=rc[p]; while(lc[p]>0) p=lc[p]; ans=p;}    //右子樹上一直向左走 
            break;
        }
        if(val[p]>k&&val[p]<val[ans]) ans=p;
        p=k<val[p]?lc[p]:rc[p]; 
    }
    return val[ans];
}
signed main(){
    scanf("%lld",&n),build();
    while(n--){
        scanf("%lld%lld",&opt,&x),ans=-1;
        if(opt==1) insert(rt,x);
        else if(opt==2) del(rt,x);
        else if(opt==3) ans=rank(rt,x)-1;
        else if(opt==4) ans=Kth(rt,x+1); 
        else if(opt==5) ans=pre(x);
        else ans=nxt(x);
        if(~ans) printf("%lld\n",ans);
    }
    return 0;
}

少了一點壓行的版本:

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+5; 
int n,opt,x,tot,rt,lc[N],rc[N],val[N],rnd[N],sz[N],cnt[N],ans;    //rnd(u) 表示節點 u 的隨機值 
void upd(int p){
    sz[p]=sz[lc[p]]+sz[rc[p]]+cnt[p];
}
int getnew(int k){
    val[++tot]=k,rnd[tot]=rand(),cnt[tot]=sz[tot]=1;
    return tot;
}
void build(){
    getnew(-1e18),getnew(1e18),rt=1,rc[1]=2,upd(rt);
} 
void zig(int &p){    //右旋 
    int q=lc[p];
    lc[p]=rc[q],rc[q]=p,p=q,upd(rc[p]),upd(p);
}
void zag(int &p){    //左旋 
    int q=rc[p];
    rc[p]=lc[q],lc[q]=p,p=q,upd(lc[p]),upd(p);
}
void insert(int &p,int k){
    if(!p){p=getnew(k);return ;}    
    if(val[p]==k){cnt[p]++,upd(p);return ;}
    if(k<val[p]){
        insert(lc[p],k);
        if(rnd[p]<rnd[lc[p]]) zig(p);    //不滿足堆性質,右旋 
    }
    else{
        insert(rc[p],k);
        if(rnd[p]<rnd[rc[p]]) zag(p);    //不滿足堆性質,左旋 
    }
    upd(p);
} 
void del(int &p,int k){
    if(!p) return ;
    if(val[p]==k){    //檢索到 k 
        if(cnt[p]>1){cnt[p]--,upd(p);return ;}     //有重複,讓 cnt 值減 1 即可 
        if(lc[p]||rc[p]){    //不是葉子節點,向下旋轉 
            if(!rc[p]||rnd[lc[p]]>rnd[rc[p]]) zig(p),del(rc[p],k);
            else zag(p),del(lc[p],k);
            upd(p);
        }
        else p=0; return ;    //葉子節點直接刪除 
    }
    del(k<val[p]?lc[p]:rc[p],k),upd(p);
}
int rank(int p,int k){
    if(!p) return 0;
    if(val[p]==k) return sz[lc[p]]+1;
    if(k<val[p]) return rank(lc[p],k);
    return rank(rc[p],k)+sz[lc[p]]+cnt[p];
}
int Kth(int p,int rk){
    if(!p) return 1e18;
    if(sz[lc[p]]>=rk) return Kth(lc[p],rk);
    if(sz[lc[p]]+cnt[p]>=rk) return val[p];
    return Kth(rc[p],rk-sz[lc[p]]-cnt[p]); 
}
int pre(int k){
    int ans=1,p=rt;
    while(p){
        if(val[p]==k){
            if(!(p=lc[p])) break;
            while(rc[p]>0) p=rc[p];    //左子樹上一直向右走 
            ans=p;break;
        }
        if(val[p]<k&&val[p]>val[ans]) ans=p;
        p=k<val[p]?lc[p]:rc[p]; 
    }
    return val[ans];
}
int nxt(int k){
    int ans=2,p=rt;
    while(p){
        if(val[p]==k){
            if(!(p=rc[p])) break;
            while(lc[p]>0) p=lc[p];    //右子樹上一直向左走
            ans=p;break;
        }
        if(val[p]>k&&val[p]<val[ans]) ans=p;
        p=k<val[p]?lc[p]:rc[p]; 
    }
    return val[ans];
}
signed main(){
    scanf("%lld",&n),build();
    while(n--){
        scanf("%lld%lld",&opt,&x),ans=-1;
        if(opt==1) insert(rt,x);
        else if(opt==2) del(rt,x);
        else if(opt==3) ans=rank(rt,x)-1;
        else if(opt==4) ans=Kth(rt,x+1); 
        else if(opt==5) ans=pre(x);
        else ans=nxt(x);
        if(~ans) printf("%lld\n",ans);
    }
    return 0;
}

五、參考資料

  • 《演算法競賽進階指南》(大部分內容摘自這裡 QAQ)