TREAP


TREAP

Treap = Tree + Heap.
樹堆,在數據結構中也稱Treap,是指有一個隨機附加域滿足堆的性質的二叉搜索樹,其結構相當於以隨機數據插入的二叉搜索樹。其基本操作的期望時間復雜度為O(logn)。相對於其他的平衡二叉搜索樹,Treap的特點是實現簡單,且能基本實現隨機平衡的結構。

Treap 維護堆的性質的方法只用到了左旋和右旋, 編程復雜度比Splay小一點(??), 並且在兩者可完成的操作速度有明顯優勢

然而一個裸的treap是不能支持區間操作的,所以可能功能沒有splay那么強大.

treap的操作方式和splay差不多,但因為treap的結構是不能改變的,而splay的形態可以隨意改變,所以在實現上會有一點小區別.

treap具有以下性質:

  1. Treap是關於key的二叉排序樹(也就是規定的排序方式)。
  2. Treap是關於priority的堆(按照隨機出的優先級作為小根/大根堆)。(非二叉堆,因為不是完全二叉樹)
  3. key和priority確定時,treap唯一。

為什么除了權值還要在用一個隨機值作為排序方法呢?隨機分配的優先級,使數據插入后更不容易退化為鏈。就像是將其打亂再插入。所以用於平衡二叉樹。

首先是treap的定義:

struct treap{
	int ch[2], cnt, size, val, rd;
	//treap不需要記錄父指針,rd表示節點的隨機值
}t[N];

更新

直接統計子樹大小.

void up(int x){
	t[x].size = t[t[x].ch[0]].size+t[t[x].ch[1]].size+t[x].cnt;
}

旋轉

treap需要支持平衡樹的性質,所以是需要用到旋轉的.這里旋轉的方法是和splay的旋轉方法是一樣的,就不貼圖了.因為treap中並沒有記錄父節點,所以需要傳一個參數表示旋轉方向.

void rotate(int &x, int d){//x代表的是旋轉時作為父節點的節點,d代表的是旋轉的方向
//d==0時是左兒子旋上來, d==1是右兒子旋上來.
    int son = t[x].ch[d];
    t[x].ch[d] = t[son].ch[d^1];
    t[son].ch[d^1] = x; up(x), up(x=son);//相當於up(son)
}

插入/刪除

因為treap在其他操作過程中是並不改變樹的形態的,所以在插入或是刪除時要先找到要插入/刪除的節點的位置,然后再創建一個新的節點/刪除這個節點.

然后考慮到插入/刪除后樹的形態有可能會改變,所以要考慮要通過旋轉維護treap的形態.我們這里分類討論一下:
插入

  • 可以直接按照中序遍歷結果找到最終的對應位置,然后再通過隨機值維護它堆的性質.
void insert(int &x, int val){
    if(!x){//找到對應位置就新建節點
        x = ++cnt;
        t[x].cnt = t[x].size = 1;
        t[x].val = val, t[x].rd = rand();
        return;
    }
    t[x].size++;//因為插入了數,所以在路徑上每個節點的size都會加1
    if(t[x].val == val){t[x].cnt++; return;}//找到了直接返回
    int d = t[x].val < val; insert(t[x].ch[d], val);//否則遞歸查找插入位置
    if(t[x].rd > t[t[x].ch[d]].rd) rotate(x, d);
}

刪除

  • 先找到要刪除的節點的位置.
  • 如果這個節點位置上有多個相同的數,則直接cnt--.
  • 如果只有一個兒子或者沒有兒子,直接將那個兒子接到這個節點下面(或將兒子賦值為0).
  • 如果有兩個兒子,現將隨機值小的那個旋到這個位置,將根旋下去,然后將旋之后的情況轉化為前幾種情況遞歸判斷.
void delet(int &x, int val){
    if(!x) return;//防止越界
    if(t[x].val == val){
        if(t[x].cnt > 1){t[x].cnt--, t[x].size--;return;}//有相同的就直接cnt--
        bool d = t[ls].rd > t[rs].rd;
        if(ls == 0 || rs == 0) x = ls+rs;//只有一個兒子就直接把那個兒子放到這個位置
        else rotate(x, d), delet(x, val);//否則將x旋下去,找一個隨機值小的替代,直到回到1,2種情況
    }
    else t[x].size--, delet(t[x].ch[t[x].val<val], val);//遞歸找到要刪除的節點.
}

查排名

還是因為treap不能改變形態,所以不能像splay一樣直接找到這個點旋轉到根,所以我們用遞歸的方式求解,我們用到的目前這個點的權值作為判斷的依據,並在找到節點的路上不斷累加小於該權值的個數.

看代碼理解一下吧.

int rank(int x, int val){
    if(!x) return 0;
    if(t[x].val == val) return t[ls].size+1;//找到了就返回最小的那個
    if(t[x].val > val) return rank(ls, val);//如果查找的數在x的左邊,則直接往左邊查
    return rank(rs, val)+t[ls].size+t[x].cnt;//否則往右邊查,左邊的所有數累加進答案
}

查找第k小的數

因為只需要找到中序遍歷中的第k個,所以在找第k小的時候可以直接用splay一樣的方法,也是遞歸求解.

int kth(int root, int k){
    int x = root;
    while(1){
        if(k <= t[ls].size) x = ls;
        else if(k > t[ls].size+t[x].cnt)
            k -= t[ls].size+t[x].cnt, x = rs;
		else return t[x].val;
    }
}

查找前驅/后繼

仍然是因為不能改變樹的形態,需要遞歸求解.同樣的找一個節點的前驅就直接在它左半邊中找一個最大值就可以了.如果是在這個節點的右邊的話就一直向下遞歸,如果遞歸有一個分支直到葉子節點以下都一直沒找到一個比該權值要小的值,那么最后要返回一個-inf/inf來防止答案錯誤(同時找到葉子節點下面也是要及時return防止越界).

int pre(int x, int val){
    if(!x) return -inf;//防止越界,同時-inf無法更新答案,
    if(t[x].val >= val) return pre(ls, val);//如果該節點的權值大於等於要找的權值
    //則不能成為前驅,遞歸查找左子樹(有可能找到前驅)
    return max(pre(rs, val), t[x].val);//找右子樹中是否存在前驅
}

int nex(int x, int val){//同上
    if(!x) return inf;
    if(t[x].val <= val) return nex(rs, val);
    return min(nex(ls, val), t[x].val);
}

既然treap有這么多不能實現的操作,那這個treap有什么用呢?

顯然是有的,我們因為支持旋轉的treap不能改變樹的形態來完成操作,所以這里介紹一中更加強大的數據結構:

無旋treap

簡介

無旋treap具有treap的一些性質,比如二叉樹和堆的性質,同時也是一顆平衡樹.

無旋treap是怎么個無旋的方法的呢?其實相比於帶旋轉的treap,無旋treap只是多了兩個特殊的操作:splitmerge .

那么這兩個操作到底是什么這么厲害呢?說簡單點,就是一個分離子樹和一個合並子樹的過程.

我們可以用split操作分離出1~前k個節點,這樣就可以通過兩次split操作就可以提取出任意一段區間了.

而merge操作可以將兩個子樹合並,並同時維護好新合並的樹的treap所有的性質.

下面終點講一下這兩個操作:

split

首先看一張split的動圖:

操作過程如上,節點中間的值代表權值,右邊的數字代表隨機值.
我們在操作的過程中需要將一顆樹剖成兩顆,然后為了還能進行之后的操作,分離出的兩顆字數必須也是滿足性質的,為了找到這兩顆子樹,我們在分離的過程中需要記錄下這兩顆子樹的根.

從圖中可以看出,其實這個分離的操作也可以理解為將一棵樹先剖開,然后再按照一定的順序連接起來,也就是將從x節點一直到最坐下或是最右下剖出來,然后再繼續處理剖出來鏈的剩余部分.

看代碼理解一下吧.

void split(int x, int k, int &a, int &b){//開始時a,b傳的是用來記錄兩顆子樹根的變量
//x代表目前操作到了哪一個節點,k是要分離出前k個節點
	if(!x){a = b = 0; return;}//如果沒有節點則需要返回
	if(k <= t[ls].size)	b = x, split(ls, k, a, ls);//如果第k個在左子樹中
	//則往左走,同時左子樹的根就可以確定了,那么就把ls賦值為根.
	//同時為了之后要把接下來處理出的節點再連上去,要再傳ls作為參數,將之后改變為根的接到現在的x的左兒子
	else a = x, split(rs, k-t[ls].size-1, rs, b);//同理
}

當然,要實現查找前驅后繼的話可以不用分離前k個節點的方法來分離,可以直接按照權值來分離節點,方法類似.

void split(int x, int val, int &a, int &b){
    if(!x){a = b = 0; return;}
    if(t[x].val <= val) a = x, split(rs, val, rs, b);
    //如果帶等於就是把>val的節點分到第二顆子樹中.
    //否則就是將<=val的節點分到第一顆子樹中.
    else b = x, split(ls, val, a, ls); up(x);
}

merge


注:圖中最后插入的9位置錯了,應該是在8的右下.
首先merge操作是有前提條件的,要求是必須第一顆樹權值最大的節點要大於第二棵樹權值最小的節點.

因為有了上面那個限制條件,所以右邊的子樹只要是插在左邊的這顆樹的右兒子上就可以維護它的中序遍歷,那么我們就只需要考慮如何維護它平衡樹的性質.

這里我們就需要通過玄學的隨機值來維護這個樹的性質了.我們在合並兩顆樹時,因為左邊的權值是一定小於右邊的,所以左邊的那棵樹一定是以一個根和它的左子樹的形式合並的,而右邊的那棵樹就是以根和右子樹的形式合並的,那么如果這次選擇的是將左邊的樹合並上來的話,那么下一次合並過來的位置一定是在這個節點位置的右兒子位置(可以看圖理解一下).

你可以把這個過程理解為在第一個Treap的左子樹上插入第二個樹,也可以理解為在第二個樹的左子樹上插入第一棵樹。因為第一棵樹都滿足小於第二個樹,所以就變成了比較隨機值來確定樹的形態。

int merge(int x, int y){
    if(x == 0 || y == 0) return x+y;
    if(t[x].rd < t[y].rd){//最好merge函數不要用宏定義的變量
//因為這個比較的兩顆樹的根的隨機值,用宏定義容易寫錯
        t[x].ch[1] = merge(t[x].ch[1], y);
        up(x); return x;
    }
    else {
        t[y].ch[0] = merge(x, t[y].ch[0]);
        up(y); return y;
    }
}

到這里兩個核心操作就完成了,那么那些insert,delete,get_rank,get_pre,get_nex的操作該怎么做呢?其實很簡單,就考慮一下將這顆樹如何分離,然后重新合並好就可以了.

插入

插入還是老套路,先找到位置然后插入.這里我們可以先分離出val和它之前的節點,然后把val權值的節點加到第一顆樹的后面,然后合並.

void insert(int val){
    split(root, val, r1, r2);
    root = merge(r1, merge(newnode(val), r2));
}

至於newnode的話就直接新建一個節點就可以了.

int newnode(int val){
    t[++cnt].val = val; t[cnt].rd = rand(), t[cnt].size = 1;
    return cnt;
}

刪除

先將val和它前面的權值分離出來,用r1記錄這個根,再在r1樹中分離出val-1的權值的樹,用r2記錄這顆樹,那么val這個權值一定是已經被分離到以r2為根的樹中,刪掉這個數(可以直接把這個位置的節點用它左右兒子合並后的根代替),最后將分離的這幾顆樹按順序合並回去就可以了.

void delet(int val){
    r1 = r2 = r3 = 0; split(root, val, r1, r3);
    split(r1, val-1, r1, r2);
    r2 = merge(t[r2].ch[0], t[r2].ch[1]);
    root = merge(r1, merge(r2, r3));
}

查找某數的排名

可以直接將所有比它小的權值分離到一顆樹中,那么此時排名就是這顆樹的大小+1了.

int rank(int val){
    r1 = r2 = 0; split(root, val-1, r1, r2);
    ans = t[r1].size+1;
    root = merge(r1, r2);
    return ans;
}

查找第k小的數

可以直接在整顆樹中直接找,操作方法類似splay.

int kth(int rt, int k){
    int x = rt;
    while(1){
        if(k <= t[ls].size) x = ls;
        else if(k > t[ls].size+t[x].cnt)
            k -= t[ls].size+t[x].cnt, x = rs;
        else return x;
    }
}

查找前驅/后繼

以val-1為分界線將整棵樹分離開,那么前驅就是第一顆樹中的最大的數字(嚴格比val小).
以val為分界線將整顆樹分離開,后繼就是第二顆樹中的最小的數字(嚴格比val大).

int pre(int val){
    r1 = r2 = 0; split(root, val-1, r1, r2);
    ans = t[kth(r1, t[r1].size)].val;
    root = merge(r1, r2);
    return ans;
}

int nex(int val){
    r1 = r2 = 0; split(root, val, r1, r2);
    ans = t[kth(r2, 1)].val;
    root = merge(r1, r2);
    return ans;
}

通過節點編號找節點在中序遍歷中的位置

因為treap本身是不能通過編號來進行操作的,它只能通過\(split\)來分離子樹,所以在只知道節點編號的時候不能很快找到節點的位置.

所以為了方便尋找節點.我們在treap中多記錄一個\(father\),然后就可以通過不斷的向上跳來記錄中序遍歷比它小的節點的個數.

而中序遍歷比它小的節點也就是在它左子樹中的節點或是在它祖先的左子樹中的節點.我們只需要在向根跳的時候將所有左子樹記錄下來就可以了.但是因為有可能某個節點是根的左兒子,這樣在向上跳的時候就不用重復統計答案了.

int find(int cnt){
    int node = cnt, res = t[t[cnt].ch[0]].size+1;
    while(node != root && cnt){
		if(get(cnt)) res += t[t[t[cnt].fa].ch[0]].size+1;
		cnt = t[cnt].fa;
    }
    return res;
}

然后我們需要考慮\(father\)的修改.因為會改變樹的形態的只有\(split\)\(merge\)操作,所以只需要在這兩個函數進行修改就可以了.其實很簡單,只要在修改兒子的同時修改父親就可以了.

\(split\)

void split(int x, int k, int &a, int &b, int faa = 0, int fab = 0){
    if(x == 0){ a = b = 0; return; }
    if(k <= t[t[x].ch[0]].size) t[x].fa = fab, b = x, split(t[x].ch[0], k, a, t[x].ch[0], faa, x);
    else t[x].fa = faa, a = x, split(t[x].ch[1], k-t[t[x].ch[0]].size-1, t[x].ch[1], b, x, fab); up(x);
}

\(merge\)

int merge(int x, int y){
    if(x == 0 || y == 0) return x+y;
	    if(t[x].rd < t[y].rd){
	    t[x].ch[1] = merge(t[x].ch[1], y);
	    t[t[x].ch[1]].fa = x; up(x); return x;
    }
    else {
	    t[y].ch[0] = merge(x, t[y].ch[0]);
	    t[t[y].ch[0]].fa = y; up(y); return y;
    }
}

如何在\(O(n)\)時間內構造一顆平衡treap

可以考慮用棧實現.
每次將一個節點壓入棧中,為了滿足構造的這顆平衡樹的中序遍歷是按照我們的輸入順序的,所以每次插入的節點一定是在之前構造的部分的右邊的.

所以這里用了一個棧,每次加入一個節點就把新節點編號加入棧.並且棧中的元素都在棧的右邊.

簡單點說,也就是如果不考慮這個隨機值的話,構造的平衡樹就是一條鏈.從棧頂到棧底是一條從下到上的鏈,像這樣:

但是我們顯然是不能讓它構造一條鏈的,所以我們通過隨機值來判斷什么時候要將這條鏈縮短,並把這條鏈接到新的根的右兒子上.我們再假設現在已經插入了4個數,並且隨機值正好是滿足它成為一條鏈的情況的,那么該如何插入5呢?(節點邊上的括號內的值是隨機值).

顯然按照隨機值維護堆的性質的原理,5節點應該是要到1節點的下面的,那么那一重\(while\)循環就會進行判斷,並保存\(last\)最后到哪個位置.(如下圖)

顯然我們這樣可以將一條鏈分開來,並且不會改變它原有的鏈.最后將節點按順序接上,這樣新插入一個節點就完成了.

之后每次插入節點都是這個方法,這樣造出的平衡樹也是相對平衡的(隨機值保證了樹平衡的性質).

復雜度的話雖然里面套了個\(while\),但是每個點都是只會入棧出棧一次的,所以均攤下來是\(O(n)\)的.

int build(int len){
    for(int i=1;i<=len;i++){
        int temp = newnode(s[i]), last = 0;
        while(top && t[stk[top]].rd > t[temp].rd)
            up(stk[top]), last = stk[top], stk[top--] = 0;
        if(top) t[stk[top]].ch[1] = temp;
        t[temp].ch[0] = last, stk[++top] = temp;
    }
    while(top) up(stk[top--]); return stk[1];
}

總結

無旋treap到這里就差不多講完了,感覺這個確實是很好用的,起碼這個調試難度比其他的平衡樹要簡單一些.

分離區間什么的操作也是這個道理,可以自己想一下.

例題當然還是跳到這里看呀.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM