1. 程式人生 > >左偏樹詳解

左偏樹詳解

引入

左偏樹也叫可並堆。
堆想必大家是很熟悉的了,手寫可能沒有過,但用絕對用過,priority_queue就是STL中的一個二叉堆。
priority_queue和手寫的二叉堆差不多,使用起來很方便,平均的時間複雜度都是在O(logN)。
但是一旦要求合併兩個堆,我們的priority_queue直接倒下了,因為是STL裡面實現的,我們只會pop,top,push,這幾個操作,只能把一個堆push進另一個堆裡。
手寫堆還有點意思,可以搞一搞,考慮考慮集體合併之類的,降低一點複雜度。
不過再怎麼優,也要接近O(N)的時間複雜度。不如直接用現成的演算法——左偏樹。

簡介

顧名思義,這是一棵極度左偏的樹,有沒有右偏樹呢

用樹的話來說就是左子樹特別深。

定義

先定義一個“外節點”:左孩子或右孩子為空的節點。
再定義一個“節點的距離”:它到子樹內最近的外節點的距離。

性質

1、堆的性質:一個點的點權大於(小於)子節點的點權。
2、左偏性一個點的左子結點的距離不小於右子節點的距離。

基本操作

在定義了這麼一棵個性鮮明的樹之後,我們就要來介紹樹的操作了。

Ⅰ合併

這個當之無愧是最最重要的操作了,這也是二叉堆所不具有的。
左偏樹的合併合併的是右子樹那條鏈。
兩個大根堆,比較它們根的權值,使得val[x]>val[y](否則交換),然後讓x的右子樹xr與y合併。同樣比較xr和y的權值,讓較小與較大的右子樹執行合併操作。如此迴圈下去,直到較大的右子樹為空,操作結束。這是一個大致的遞迴流程。
具體合併是很巧妙的。我自己先瞎JB先定義一個“左樹”為x節點及其左子樹,“左樹根”即為x,方便後面表達。
用程式的話來講,每次比較兩個左樹頂的權值,留下一個較大的左樹,讓比較小的左樹和較大的右子樹繼續合併。
我是這樣理解的,一開始比較後確定誰是x誰是y後,把x節點的左樹給取出來,放在一邊。接著比較的是xr和y,根據權值把xr或者y的左樹給取出來,把取出來的那個左樹接在x的右孩子位置。然後遞迴剩下的。相當於說每條左鏈都以其所處的左樹頂為關鍵字,有點像歸併排序的合併操作一樣,用左樹根表示這一整棵左樹的價值,或者說把一棵左樹縮成了一個權值為左樹根權值的點,這就把兩棵左偏樹變成兩個序列,然後從兩個序列中選取較大的數放入新序列。最終會生成一個序列,其中第一個數為根,其餘都是右孩子的關係,再把摺疊了的左樹展開,就是合併後的左偏樹了。

再提幾個合併的細節。
可以預見最終的合併一定是以x的右子樹為0結束的(即ch[x][1]=0),此時應當把剩餘的(一定有剩餘)y子樹接到ch[x][1]這個位置,然後結束遞迴。按理來說只會出現x=0而不會出現y=0的情況,可是我們的程式卻判斷了y=0的情況(請稍稍看看程式碼)。這裡先宣告如果只有merge操作,只需判斷x=0即可。
還有,以上的操作僅僅是維護了左偏樹的第1個性質,讀者可以用堆的性質來考慮以上的合併,只要明白堆是什麼,相信你可以看明白。

好的,下面講的是左偏性的維護了。
左偏性即dis[ch[x][0]]>=dis[ch[x][1]],所以只要當dis[ch[x][0]]<dis[ch[x][1]]時交換一下左右孩子就好了(放心,這樣做它還是一個堆)。
這就沒了,說真的
再來就是求出dis[x]就可以了,根據距離的定義及性質2,所以dis[x]的距離一定是從右孩子轉移過來的,所以dis[x]=dis[ch[x][1]]+1。

有人會想了,如果某個節點的右孩子是有的,左孩子是空的,如果這時能把這棵樹接到左孩子就好了。但是有這回事嗎?根據左偏性,設想如果左孩子為空,那麼右孩子一定也為空,所以這種想法是多餘的。再來補充一下“外節點”的定義,現在發現那些定義“左孩子或右孩子為空”就是多餘的,在左偏樹中根本3種情況,只有2種:要麼一起空,要麼右孩子空。
現在再來看看為什麼左偏樹效率會高。
再提一個性質:一棵n個節點的左偏樹距離最多為\left\lfloor \log(n+1) -1\right\rfloor。也就是說一棵左偏樹的右樹最大深度為\left\lfloor \log(n+1) -1\right\rfloor
細心的讀者會發現,我們合併的時間複雜度與右樹大小有關,因為我們是對右樹做了一次並排序。所以我們希望右樹的大小越小越好,這就導致了整棵樹結構偏向左邊,左樹的節點越多,那麼右樹的節點就越少。這就是維護一個看似不參與任何運算的dis的原因,也是左偏樹這麼定義dis的原因。

把以上兩個合併+維護合在一起,這個操作就大功告成了。

Ⅱ插入

新建一個新的節點,然後merge一下。

Ⅲ刪除堆頂

刪除堆頂,那麼可以把堆頂的左右子樹斷開與堆頂的父子關係後,merge一下左右節點,相當於把堆頂給孤立了,這時就完成了刪除。
這裡解決上面提到的問題,由於x是個一般的節點,所以它的左右子樹可能為空。如果這時候進行合併,x=0則取y,y=0則取x,若x=0&y=0則為0(空)。所以那兩句判斷必須一起寫著。

Ⅳ求最值

根據堆的性質,最值為堆頂。

模版

例題:洛谷3377 【模板】左偏樹(可並堆),此題為小根堆
程式碼

#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int MAXN=100010;

int n,m;

int f[MAXN];
int ch[MAXN][2],val[MAXN],dis[MAXN];

int findfa(int x)
{
    while(f[x]) x=f[x];
    return x;
}
int merge(int x,int y)
{
    if(x==0 || y==0) return x+y;//就這兩句話都要 
    if(val[x]>val[y] || val[x]==val[y]&&x>y) swap(x,y);//小根堆 
    ch[x][1]=merge(ch[x][1],y);//較小的右子樹與較大的繼續合併 
    f[ch[x][1]]=x;
    if(dis[ch[x][0]]<dis[ch[x][1]]) swap(ch[x][0],ch[x][1]);//維護左偏性 
    dis[x]=dis[ch[x][1]]+1;
    return x;
}
void pop(int x)
{
    val[x]=-1;
    f[ch[x][0]]=f[ch[x][1]]=0;
    merge(ch[x][0],ch[x][1]);
}

int main()
{
    scanf("%d %d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d",&val[i]);
    for(int i=1;i<=m;i++)
    {
        int opt,x,y;
        scanf("%d",&opt);
        if(opt==1)
        {
            scanf("%d %d",&x,&y);
            if(val[x]==-1 || val[y]==-1) continue;
            int fx=findfa(x),fy=findfa(y);
            if(fx==fy) continue;
            merge(fx,fy);
        }
        else
        {
            scanf("%d",&x);
            if(val[x]==-1){puts("-1");continue;}
            int fx=findfa(x);
            printf("%d\n",val[fx]);
            pop(fx);
        }
    }
    return 0;
}