如題,二叉堆是一種基礎數據結構
事實上支持的操作也是挺有限的(相對於其他數據結構而言),也就插入,查詢,刪除這一類
對了這篇文章中講到的堆都是二叉堆,而不是斜堆,左偏樹,斐波那契堆什么的 我都不會啊
更新概要:
無良博主終於想起來要更新辣
upd1:更新5.2.2-對於該子目所闡述的操作“用兩個堆來維護一些查詢第k小/大的操作”更新了一道例題-該操作對於中位數題目的求解
upd2:更新5.3-利用堆來維護可以“反悔的貪心”
continue...
一.堆的性質
1.堆是一顆完全二叉樹
2.堆的頂端一定是“最大”,最小”的,但是要注意一個點,這里的大和小並不是傳統意義下的大和小,它是相對於優先級而言的,當然你也可以把優先級定為傳統意義下的大小,但一定要牢記這一點,初學者容易把堆的“大小”直接定義為傳統意義下的大小,某些題就不是按數字的大小為優先級來進行堆的操作的
(但是為了講解方便,下文直接把堆的優先級定為傳統意義下的大小,所以上面跟沒講有什么區別?)
3.堆一般有兩種樣子,小根堆和大根堆,分別對應第二個性質中的“堆頂最大”“堆頂最小”,對於大根堆而言,任何一個非根節點,它的優先級都小於堆頂,對於小根堆而言,任何一個非根節點,它的優先級都大於堆頂(這里的根就是堆頂啦qwq)
來一張圖了解一下堆(這里是小根堆)(原諒我丑陋無比的圖)
不難看出,對於堆的每個子樹,它同樣也是一個堆(因為是完全二叉樹嘛)
二.堆的操作
1.插入
假設你已經有一個堆了,就是上面那個
這個時候你如果想要給它加入一個節點怎么辦,比如說0?
先插到堆底(嚴格意義上來說其實0是在5的左兒子的,圖沒畫好放不下去,不過也不影響)
然后你會發現它比它的父親小啊,那怎么辦?不符合小根堆的性質了啊,那就交換一下他們的位置
交換之后還是發現不符合小根堆的性質,那么再換
還是不行,再換
好了,這下就符合小根堆的性質了,是不是順眼很多了?(假的,圖越來越丑,原諒我不想再畫)
事實上堆的插入就是把新的元素放到堆底,然后檢查它是否符合堆的性質,如果符合就丟在那里了,如果不符合,那就和它的父親交換一下,一直交換交換交換,直到符合堆的性質,那么就插入完成了
Code:
void swap(int &x,int &y){int t=x;x=y;y=t;}//交換函數 int heap[N];//定義一個數組來存堆 int siz;//堆的大小 void push(int x){//要插入的數 heap[++siz]=x; now=siz; //插入到堆底 while(now){//還沒到根節點,還能交換 ll nxt=now>>1;//找到它的父親 if(heap[nxt]>heap[now])swap(heap[nxt],heap[now]);//父親比它大,那就交換 else break;//如果比它父親小,那就代表着插入完成了 now=nxt;//交換 } return; }
2.刪除
把0插入完以后,忽然你看這個0不爽了,本來都是正整數,怎么就混進來你這個0?
於是這時候你就想把它刪除掉
怎么刪除?在刪除的過程中還是要維護小根堆的性質
如果你直接刪掉了,那就沒有堆頂了,這個堆就直接亂了,所以我們要保證刪除后這一整個堆還是個完好的小根堆
首先在它的兩個兒子里面,找一個比較小的,和它交換一下,但是還是沒法刪除,因為下方還有節點,那就繼續交換
還是不行,再換
再換
好了,這個礙眼的東西終於的下面終於沒有節點了,這時候直接把它扔掉就好了
這樣我們就完成了刪除操作,但是在實際的代碼操作中,並不是這樣進行刪除操作的,有一定的微調,代碼中是直接把堆頂和堆底交換一下,然后把交換后的堆頂不斷與它的子節點交換,直到這個堆重新符合堆性質(但是上面的方式好理解啊)
手寫堆的刪除支持任意一個節點的刪除,不過STL只支持堆頂刪除,STL的我們后面再講
Code:
void pop(){ swap(heap[siz],heap[1]);siz--;//交換堆頂和堆底,然后直接彈掉堆底 int now=1; while((now<<1)<=siz){//對該節點進行向下交換的操作 int nxt=now<<1;//找出當前節點的左兒子 if(nxt+1<=siz&&heap[nxt+1]<heap[nxt])nxt++;//看看是要左兒子還是右兒子跟它換 if(heap[nxt]<heap[now])swap(heap[now],heap[nxt]);//如果不符合堆性質就換 else break;//否則就完成了 now=nxt;//往下一層繼續向下交換 } }
3.查詢
因為我們一直維護着這個堆使它滿足堆性質,而堆最簡單的查詢就是查詢優先級最低/最高的元素,對於我們維護的這個堆heap,它的優先級最低/最高的元素就是堆頂,所以查詢之后輸出heap[1]就好了
一般的題目里面查詢操作是和刪除操作捆綁的,查詢完后順便就刪掉了,這個主要因題而異
三.堆的STL實現
這年頭真的沒幾個人寫手寫堆(可能有情懷黨?)
一是手寫堆容易寫錯代碼又多,二是STL 直接給我們提供了一個實現堆的簡單方式:優先隊列
手寫堆和STL的優先隊列有什么 區別?沒有區別
速度方面,手寫堆會偏快一點,但是如果開了O2優化優先隊列可能會更快;
代碼實現難度方面:優先隊列完爆手寫堆
這兩方面綜合起來,一般都是用STL的優先隊列來實現堆,省選開O2啊
至於為什么前面講堆的操作時用手寫堆,好理解嘛,最好先根據上面的代碼和圖理解一下堆是怎么實現那些操作的,再來看一下下面的STL的操作
定義一個優先隊列:
首先你需要一個頭文件:#include<queue> priority_queue<int> q;//這是一個大根堆q priority_queue<int,vector<int>,greater<int> >q;//這是一個小根堆q //注意某些編譯器在定義一個小根堆的時候greater<int>和后面的>要隔一個空格,不然會被編譯器識別成位運算符號>>
優先隊列的操作:
q.top()//取得堆頂元素,並不會彈出 q.pop()//彈出堆頂元素 q.push()//往堆里面插入一個元素 q.empty()//查詢堆是否為空,為空則返回1否則返回0 q.size()//查詢堆內元素數量
常用也就這些,貌似還有其他,不過基本也用不到,知道上面那幾個也就可以了
不過有個小問題就是STL只支持刪除堆頂,而不支持刪除其他元素
但是問題不大,開一個數組del,在要刪除其他元素的時候直接就標記一下del[i]=1,這里的下標是元素的值,然后在查詢的時候碰到這個元素被標記了直接彈出然后繼續查詢就可以了 (前兩天剛從學長處get這個姿勢)
另外因為STL好寫,下面堆的應用全部都會采用STL的代碼實現(懶啊,如果有放代碼的話)
這里補一下重載運算符在STL的優先隊列中應用到的知識
重載運算符是什么?
把一種運算符變成另外一種運算符(注意,都必須是原有的運算符),比如把<號重載成>號,這個東西學過STL中的sort的同學應該會比較熟悉
這個在優先隊列中有什么用處呢?
之前我們就講到了,大根堆,小根堆的“大”和“小”都不是傳統意義下的“大”和“小”,重載運算符在STL的優先隊列中就是用來解決這種“非傳統意義的‘大’和‘小’”的
現在你有一個數列,它有權值和優先級兩種屬性,權值即該數的大小,優先級是給定的,現在要你按照優先級的大小從小到大輸出這個數列
這不是Treap嗎?這不是sort嗎?
以上兩個東西都可以用來實現這道題(逃,而且就實用性而言,sort用來解決這道題是最方便的,但是我們現在要講的做法是使用堆排序的方式來解決這道題(堆排序是什么?下文堆的應用中有提到)
首先應該想得到結構體,我們定義一個結構體
struct node{ int val,rnd; }int a[100];
但是使用傳統做法是行不通的,在小根堆中是通過比較數的大小來確定各個元素在堆中的位置的,但是對於這個a數組,你是要對比權值val的值,還是要對比優先級rnd的值?
這時候重載運算符就派上用場了
我們在結構體里面再加3行東西
struct node{ int val,rnd; bool operator < (const node&x) const { return rnd<x.rnd; } }a[100];
這個玩意為什么要這么寫呢?
首先這個玩意是bool類型的,因為你只需要判斷這兩個是大,還是小;然后,要重載運算符就必須加一個operator這個玩意,不然計算機怎么知道你要干嘛?后面接一個你要重載的運算符,這里是“<”,再后面的括號里面的東西則是你要比較的數據類型,這里是數據類型為node,並且加了一個指針&,將對這個x的修改同步到你實際上要修改的數據那里。然后就是記得加那兩個const
然后兩個大括號里面就是你重載的內容了,這里是把比較數的大小的小於號,重載成比較node這個數據類型里面的優先級的大小
這個玩意講的比較多,主要是因為是一個很難懂的東西(對我來說?反正當時學的時候就是感覺很晦澀難懂,這里就盡量寫詳細一點,給和當初的我一樣的萌新看一下)
而且在實際中,這個東西的用處也很大,就說在堆里面的應用,在NOIP提高,省選的那個級別,就絕對不可能考裸的堆的,往往你要比較的東西就不是數的大小了,而是按照題目要求靈活更改,這時候重載運算符就幫得上很大忙了
這也就是為什么我在前面反復強調,堆里面的大小,並非傳統意義下的大小
四.堆的復雜度
因為堆是一棵完全二叉樹,所以對於一個節點數為n的堆,它的高度不會超過log2n
所以對於插入,刪除操作復雜度為O(log2n)
查詢堆頂操作的復雜度為O(1)
五.堆的應用
1.堆排序
其實就是用要排序的元素建一個堆(視情況而定是大根堆還是小根堆),然后依次彈出堆頂元素,最后得到的就是排序后的結果了
但是裸的並沒有什么用,我們有sort而且sort還比堆排快,所以堆排一般都沒有這種模板題,一般是利用堆排的思想,然后來搞一些奇奇怪怪的操作,第2個應用就有涉及到一點堆排的思想
2.用兩個堆來維護一些查詢第k小/大的操作
利用一個大根堆一個小根堆來維護第k小,並沒有強制在線
不強制在線,所以我們直接讀入所有元素,枚舉詢問,因為要詢問第k小,所以把前面的第k個元素都放進大根堆里面,然后如果元素數量大於k,就把堆頂彈掉放到小根堆里面,使大根堆的元素嚴格等於k,這樣這次詢問的結果就是小根堆的堆頂了(前面k-1小的元素都在大根堆里面了)
記得在完成這次詢問后重新把小根堆的堆頂放到大根堆里面就好

#include <cstdio> #include <vector> #include <cstring> #include <queue> #define ll long long #define inf 1<<30 #define il inline #define in1(a) read(a) #define in2(a,b) in1(a),in1(b) #define in3(a,b,c) in2(a,b),in1(c) #define in4(a,b,c,d) in2(a,b),in2(c,d) il int max(int x,int y){return x>y?x:y;} il int min(int x,int y){return x<y?x:y;} il int abs(int x){return x>0?x:-x;} il void swap(int &x,int &y){int t=x;x=y;y=t;} il void readl(ll &x){ x=0;ll f=1;char c=getchar(); while(c<'0'||c>'9'){if(c=='-')f=-f;c=getchar();} while(c>='0'&&c<='9'){x=x*10+c-'0';c=getchar();} x*=f; } il void read(int &x){ x=0;int f=1;char c=getchar(); while(c<'0'||c>'9'){if(c=='-')f=-f;c=getchar();} while(c>='0'&&c<='9'){x=x*10+c-'0';c=getchar();} x*=f; } using namespace std; /*===================Header Template=====================*/ #define N 200010 priority_queue<int,vector<int>,greater<int> > q; priority_queue<int> q1; int n,m,a[N],b[N]; int main(){ in2(n,m); for(int i=1;i<=n;i++)in1(a[i]); for(int i=1;i<=m;i++)in1(b[i]); int i=1; for(int j=1;j<=m;j++){ for(;i<=b[j];i++){ q1.push(a[i]); if(q1.size()==j)q.push(q1.top()),q1.pop(); } printf("%d\n",q.top()); q1.push(q.top());q.pop(); } return 0; }
中位數
中位數也是這種操作可以解決的一種經典問題,但是實際應用不大(這種操作的復雜度為$O(nlogn)$,然而求解中位數有$O(n)$做法)
Luogu中也有此類例題,題解內也講的比較清楚了,此處不再贅述,讀者可當做拓展練習進行食用
提示:設序列長度為$N$,則中位數其實等價於序列中$N/2$大的元素
事實上堆在難度較高的題目方面更多的用於維護一些貪心操作,以降低復雜度,很少會有題目是以堆為正解來出的了,更多的,堆在這些題目中處於“工具”的位置
3.利用堆來維護可以“反悔的貪心”
題目:Luogu P2949 [USACO09OPEN]工作調度Work Scheduling
這道題的話算是這種類型應用的經典題了
首先只要有貪心基礎就不難想出一個解題思路:因為所有工作的花費時間都一樣,我們只要盡量的選獲得利潤高的工作,以及對於每個所選的工作,我們盡量讓它在更靠近它的結束時間的地方再來工作
但是兩種條件我們並不好維護,這種兩個限制條件的題目也是有一種挺經典的做法的:對一個限制條件進行排序,對於另一個限制條件使用某些數據結構來維護(如treap,線段樹,樹狀數組之類),但是這並不在我們今天的討論范疇QAQ
考慮怎么將這兩個條件“有機統一”。
排序的思路是沒有問題的,我們可以對每個工作按照它的結束時間進行排序,從而來維護我們的第二個貪心的想法。
那么對於這樣做所帶來的一個沖突:對於一個截止時間在d的工作,我們有可能把0~d秒全都安排滿了(可能會有多個任務的截止時間相同)
怎么解決這種沖突並保證答案的最有性呢?
一個直觀的想法就是把我們目前已選的工作全部都比較一下,然后選出一個創造的利潤最低的工作(假設當前正在決策的這個工作價值很高),然后舍棄掉利潤最低的工作,把這個工作放進去原來的那個位置。(因為我們已經按照結束時間排序了,所以舍棄的那個任務的截止完成時間一定在當前決策的工作的之前)
但是對於大小高達$10^6$的n,$O(n^2)$的復雜度顯然是無法接受的,結合上面的內容,讀者們應該也不難想出,可以使用堆來優化這個操作
我們可以在選用了這個工作之后,將當前工作放入小根堆中,如果堆內元素大於等於當前工作的截止時間了(因為這道題中,一個工作的執行時間是一個單位時間),我們就可以把當前工作跟堆頂工作的價值比較,如果當前工作的價值較大,就可以將堆頂彈出,然后將新的工作放入堆中,給答案加上當前工作減去堆頂元素的價值(因為堆頂元素在放入堆中的時候價值已經累加進入答案了)。如果堆內元素小於截止時間那么直接放入堆中就好
至此,我們已經可以以$O(nlogn)$的效率通過本題
而通過這道題我們也可以發現,只有在優化我們思考出來的貪心操作的時間復雜度時,我們才用到了堆。正如我們先前所說到的,在大部分有一定難度的題目里,堆都是以一個“工具”的身份出現,用於優化算法(大多時候是貪心)的時間復雜度等

#include <cstdio> #include <algorithm> #include <queue> #include <vector> #include <map> #include <set> using namespace std ; #define N 100010 #define int long long int n , m ; struct node { int d,p ; bool operator < ( const node &x ) const { return p>x.p; } } a[ N ] ; bool cmp( node a , node b ) { return a.d==b.d?a.p<b.p:a.d<b.d; } priority_queue< node > q ; signed main() { scanf( "%lld" , &n ) ; for( int i = 1 ; i <= n ; i ++ ) { scanf( "%lld%lld" , &a[i].d , &a[i].p ) ; } sort(a+1,a+n+1,cmp); int ans = 0 ; for( int i = 1 ; i <= n ; i ++ ) { if( a[i].d<=(int)q.size() ) { if( q.top().p<a[i].p ) { ans += a[i].p-q.top().p ; q.pop() ; q.push(a[i]) ; } } else q.push(a[i]) , ans += a[ i ].p ; } printf( "%lld\n" , ans ) ; }
continue...