左偏樹——楊子曰數據結構
先扔出一道題(【洛谷】P3377 【模板】左偏樹(可並堆)):
題目描述
如題,一開始有N個小根堆,每個堆包含且僅包含一個數。接下來需要支持兩種操作:
操作1: 1 x y 將第x個數和第y個數所在的小根堆合併(若第x或第y個數已經被刪除或第x和第y個數在用一個堆內,則無視此操作)
操作2: 2 x 輸出第x個數所在的堆最小數,並將其刪除(若第x個數已經被刪除,則輸出-1並無視刪除操作)
輸入格式
第一行包含兩個正整數N、M,分別表示一開始小根堆的個數和接下來操作的個數。
第二行包含N個正整數,其中第i個正整數表示第i個小根堆初始時包含且僅包含的數。
接下來M行每行2個或3個正整數,表示一條操作,格式如下:
操作1 : 1 x y
操作2 : 2 x
輸出格式
輸出包含若干行整數,分別依次對應每一個操作2所得的結果。
輸入輸出樣例
輸入
5 5
1 5 4 2 3
1 1 5
1 2 5
2 2
1 4 2
2 2
輸出
1
2
說明/提示
當堆裏有多個最小值時,優先刪除原序列的靠前的,否則會影響後續操作1導致WA。
時空限制:1000ms,128M
數據規模:
對於30%的數據:N<=10,M<=10
對於70%的數據:N<=1000,M<=1000
對於100%的數據:N<=100000,M<=100000
樣例說明:
初始狀態下,五個小根堆分別爲:{1}、{5}、{4}、{2}、{3}。
第一次操作,將第1個數所在的小根堆與第5個數所在的小根堆合併,故變爲四個小根堆:{1,3}、{5}、{4}、{2}。
第二次操作,將第2個數所在的小根堆與第5個數所在的小根堆合併,故變爲三個小根堆:{1,3,5}、{4}、{2}。
第三次操作,將第2個數所在的小根堆的最小值輸出並刪除,故輸出1,第一個數被刪除,三個小根堆爲:{3,5}、{4}、{2}。
第四次操作,將第4個數所在的小根堆與第2個數所在的小根堆合併,故變爲兩個小根堆:{2,3,5}、{4}。
第五次操作,將第2個數所在的小根堆的最小值輸出並刪除,故輸出2,第四個數被刪除,兩個小根堆爲:{3,5}、{4}。
故輸出依次爲1、2。
說白了就是讓你維護好幾個堆,可以求最值,彈出,合併
讓我們用一個nb的數據結構——左偏樹,來解決這道題
首先,左偏樹長這樣:
哦,不好意思o( ̄┰ ̄*)ゞ,放錯圖了,是這張:
這棵樹明顯地向左偏了呀!這就是爲什馬它叫左偏樹
先來說一下對於這棵樹上的每個結點我們要記錄什麼:
- ls:左兒子
- rs:右兒子
- v:權值
- f:用來維護並查集的東西,用它來找到當前結點的根
- dis:左偏樹中最最重要的東西,就是從當前結點出發,不停往右兒子走,能走多遠
我們把dis標上來給大家瞅瞅(沒有標的dis是0):
然後我們來曰一曰左偏樹需要滿足的性質:
- 滿足小根堆或者大根堆的性質
- 對於每個節點,這也就是它左偏的性質
對於左偏樹上的每一個結點,,這就是它左偏的原因
那麼我們怎麼來維護這個dis捏?特別簡單,對於結點i的dis:dis[i]=dis[rs[i]]+1,特別好理解!
在開始講各種操作之前,我們先要說明一個事情,根結點的dis不會超過log n,也就是整棵樹最右邊那條鏈的長度不會超過log n:
我們假設最右邊那條鏈上的結點個數爲x,我們手動模一下會發現:爲了維護每個dis,點最少最少也需要大致上構成一個滿二叉樹So,最右邊那條鏈一定是log級別的,這也就是它複雜度如此優秀的原因
好的,接下來我們講一講怎麼解決上面的那道題目:
首先,我們要用一個並查集來維護每個結點所在左偏樹的根的編號,每個結點記錄的f值就是用來左並查集的,這我就不多講了
- 合併(merge)
我們需要維護堆的性質,So,我們就比較一下我們要合併的兩棵樹的根的權值由於是小根堆,那我們就要讓權值小的點成爲新的根,然後合併新的根的右兒子和另一棵樹。比如上面那個圖,經過一番比較以後發現v[1]<v[13],那麼我們就要讓1號結點成爲新的根,然後遞歸地去合併以3爲根節點的樹和13爲根節點的樹,並把這個子問題合併後新的根賦給1,如下圖:
然後就變成了一個新的子問題,不斷遞歸下去,直到兩顆子樹右一顆子樹空了。
當然,這樣合併完以後整條路徑上的dis可能都不準了,So,我們要在回溯的時候把dis數組更新一下。
“那如果驚悚的發現某個結點左兒子的dis小於了右兒子的dis,也就是不滿足左偏樹的性質了怎麼辦?”
“不要緊張,直接更換它的左右兒子!”
完事。
int merge(int l,int r){
if (l==0 || r==0) return l+r;
if (t[l].v>t[r].v || (t[l].v==t[r].v && l>r)) swap(l,r);
t[l].rs=merge(t[l].rs,r);
if (t[t[l].ls].dis<t[t[l].rs].dis) swap(t[l].ls,t[l].rs);
t[t[l].ls].f=t[t[l].rs].f=t[l].f=l;
t[l].dis=t[t[l].rs].dis+1;
return l;
}
- 彈出/刪除(del)
這個操作實在是太簡單了,我們要彈出這顆左偏樹的根,我們只要完全忽視根節點,把它的兩棵子樹合併,完事。
(不過要注意一點,假設我們要刪掉x,由於原來x的有些後代的f數組指向了x,而x被刪掉後他們應該指向的是x的兩個兒子合併後新的根,那我們這樣來處理:雖然x被刪了,我們把x的f值附成兩個兒子合併後新的根,這樣它的後代在並查集中找根的時候就可以找到正確的根了)
void del(int x){
t[x].v=-1;
t[t[x].ls].f=t[x].ls;
t[t[x].rs].f=t[x].rs;
t[x].f=merge(t[x].ls,t[x].rs);
}
- 查詢最值(query)
這個就更簡單了,我們用維護的並查集找到這棵樹的根,輸出根上的權值,完事。
int query(int x){
int fx=gf(x);
return t[fx].v;
}
由於在合併的時候我們只會往右兒子走,而我們又說明最右邊那條鏈的長度是log級別的,自然它的時間複雜度也是O(log n)的。
OK,完事
c++代碼(洛谷 P3377):
#include<bits/stdc++.h>
using namespace std;
const int maxn=150005;
struct Tr{
int ls,rs,dis,v,f;
}t[maxn];
int n,m;
int gf(int x){
return t[x].f==x?x:t[x].f=gf(t[x].f);
}
int merge(int l,int r){
if (l==0 || r==0) return l+r;
if (t[l].v>t[r].v || (t[l].v==t[r].v && l>r)) swap(l,r);
t[l].rs=merge(t[l].rs,r);
if (t[t[l].ls].dis<t[t[l].rs].dis) swap(t[l].ls,t[l].rs);
t[t[l].ls].f=t[t[l].rs].f=t[l].f=l;
t[l].dis=t[t[l].rs].dis+1;
return l;
}
void del(int x){
t[x].v=-1;
t[t[x].ls].f=t[x].ls;
t[t[x].rs].f=t[x].rs;
t[x].f=merge(t[x].ls,t[x].rs);
}
int query(int x){
int fx=gf(x);
return t[fx].v;
}
int main(){
scanf("%d%d",&n,&m);
for (int i=1;i<=n;i++){
scanf("%d",&t[i].v);
t[i].f=i;
}
while(m--){
int opt;
scanf("%d",&opt);
if (opt==1){
int x,y;
scanf("%d%d",&x,&y);
int fx=gf(x),fy=gf(y);
if (t[x].v==-1 || t[y].v==-1) continue;
if (fx==fy) continue;
t[fx].f=t[fy].f=merge(fx,fy);
}
else{
int x;
scanf("%d",&x);
if (t[x].v==-1){
puts("-1");
continue;
}
printf("%d\n",query(x));
del(gf(x));
}
}
return 0;
}
於HG機房