“如果一個人比你年輕還比你強,那你就要被踢出去了……”——單調隊列
“來來來,神犇巨佬、金牌\(Au\)爺、\(AKer\)站在最上面,蒟蒻都靠下站!!!”——優先隊列
Part 1:單調隊列
單調隊列的功能
顧名思義,所謂單調隊列,那么其中的元素從隊頭到隊尾一定要具有單調性(單調升、單調降等)
它被廣泛地用於“滑動窗口”這一類\(RMQ\)問題,其功能是\(O(n)\)維護整個序列中長度為\(k\)的區間最大值或最小值
單調隊列實現原理
滑動窗口問題
給定一個長度為\(n\)的序列\(a\)和一個窗口長度\(k\),窗口初始覆蓋了\(1\rightarrow k\)這些元素
之后窗口每次向右移一個單位,即從覆蓋\(1\rightarrow k\)變成覆蓋\(2\rightarrow k+1\)
要求求出每次移動(包括初始時)窗口所覆蓋的元素中的最大值(如圖,花括號內即為被窗口覆蓋的元素)
數據范圍:\(1\leq k\leq n\leq 10^6,a_i\in[-2^{31},2^{31})\)
\(Solution\) \(1:\)暴力碾標算\(O(nk)\)
“越接近暴力的數據結構,能維護的東西就越多”——真理
線段樹和樹狀數組維護不了眾數,但分塊可以。你再看暴力,它什么都能維護……
很簡單,每次從窗口最左端掃到最右端,然后取最大值就\(OK\)了
顯然在這種數據強度下暴力是過不了的,代碼就不給了
\(Solution\) \(2:\)單調隊列\(O(n)\)
思考暴力為什么慢了:因為窗口每次才移動\(1\)個單位,但是暴力算法每次都重復統計了\(k-2\)個元素
那我們把中間那一大堆數的最大值記錄下來,每次進來一個元素,出去一個元素,統計一下最值,這不就快了嗎?
但是,不幸的是,如果出去的那個元素正好是最值,那就得重新統計了
考慮維護一個單調不升隊列,每次新元素進來之前,從這個隊列的最小值向最大值依次比較
如果這個隊列中的一個數\(a\)沒有新來的那個元素\(b\)大,那么把\(a\)踢出序列
因為\(a\)一定在新來的數之前出現,它的值沒有\(b\)大,所以在之后的統計中\(a\)永遠也不可能成為最大值,就沒必要記錄\(a\)了
處理完新元素,現在看看舊元素怎么處理:
一個數\(a\)如果不在窗口里,那么需要把它踢出這個隊列,但是如果我們每次移動都要找到這個\(a\)再踢出,那么復雜度又變成了\(O(nk)\),顯然不行
發現新元素不受舊元素的影響,每次一定會進入到隊列里,不會因為舊元素而把新元素卡掉,而且我們只是查詢最大值,所以沒有必要嚴格維護序列里每個值都在窗口里,只要保證最大值出自窗口里即可
因為這個隊列單調不升,所以隊頭一定是我們要查詢的最大值,那么我們可以對隊頭掃描,如果這個隊頭在窗口之外,把這個隊頭踢出去,新的隊頭是原來的第二個元素
重復上述操作,直到隊頭在窗口里即可,因為序列單調不升,所以隊頭一定是窗口內的最大值
以上就是單調隊列算法的全部內容
復雜度分析
有些剛學的同學,看到循環\(n\)重嵌套,馬上來一句:這個算法的復雜度是\(O(n^n)\) 的,這是不對的!!!
比如剛才我們的這個算法,看似每次窗口移動時都要對整個單調隊列進行掃描,但是,從總體來看,每個元素只會入隊一次,出隊一次,所以復雜度是\(O(n)\)的
核心\(Code\)
struct Node{
int num,und;//num是值,und是下標
Node(){}
}q[1e6+10];
int main(){
int i,head=1,tail=0;//建立單調隊列維護k個數中最大值,head是隊頭,tail是隊尾
for(i=1;i<k;i++){//先把k個元素都進來
while(head<=tial&&q[tail].num<a[i]) tail--;//如果隊尾沒有新元素大,那么在之后的統計中,它永遠不可能成為最大值,踢出
q[++tail].und=i,q[tail].num=a[i];//新元素插入隊尾
}
for(;i<=n;i++){
while(head<=tail&&q[tial].num<a[i]) tail--;
q[++tail].und=i,q[tail].num=a[i];
while(q[head].und<i-k+1) head++;//隊頭過時了,踢出
ans[i]=q[head].num;//統計答案
}
}
Part 2:優先隊列
一個悲傷的故事背景:
從前,NOI系列比賽禁止使用\(C++STL\)時,優先隊列是每一個\(OI\)選手一定會熟練手寫的數據結構。
但是自從\(STL\)盛行,會手寫優先隊列的選手越來越少了……傳統手藝沒有人繼承,真是世風日下(STL真香)啊……
優先隊列的功能
優先隊列有另一個名字:二叉堆
功能是維護一堆數的最大值(大根堆)/最小值(小根堆),存放在堆頂(也就是根)
注意:凡是\(STL\)都自帶常數
優先隊列實現原理
沒錯,實現原理就是\(C++STL\)
\(C++STL\)中\(#include<queue>\)頭文件為我們提供了一個免費的優先隊列——\(priority\)_\(queue\),但是不支持隨機刪除,只支持刪除堆頂
優先隊列的聲明和操作方法
聲明方法
std::priority_queue<int>Q;
上面就聲明了一個\(int\)類型的大根堆,想要一個小根堆?沒關系,你可以這么寫:
std::priority_queue< int,std::vector<int>,std::greater<int> >Q;
或者把每個數入堆時都取相反數,然后在用的時候再取相反數
對於結構體,我們還有更騷的操作:重載小於號運算符
struct Node{
int x,y;
Node(){}
}
bool operator < (const Node a,const Node b){ return a.x<b.x; }
std::priority_queue<Node>Q;
這樣就是按照\(x\)大小比較的大根堆,如果你想要小根堆,那么把重載運算符改成這句:
bool operator < (const Node a,const Node b){ return a.x>b.x; }
這樣,系統就會認為小的更大,所以小的就會跑到堆頂去
但是,如你想要\(int\)類型的小根堆,千萬不要重載運算符,這樣普通的兩個\(int\)數就不能正常比較了(系統會認為小的更大)
常用操作命令
//std priority_queue 操作命令
Q.push();//插入元素,復雜度O(logn)
Q.pop();//彈出堆頂,復雜度O(logn)
Q.size();//返回堆中元素個數,復雜度O(1)
Q.top();//返回堆頂元素,復雜度O(1)
奇技淫巧
什么?你想讓\(priority\)_\(queue\)支持隨機刪除,但是又不想手寫?(那你可真是懶
但是這能難倒人類智慧嗎?顯然不能,這里有一個玄學的延遲刪除法,可以滿足需求
我們可以維護另一個優先隊列(刪除堆),每次要刪除一個數(假設為\(x\))
當需要刪除\(x\)的時候,我們並不去真正的堆里面刪除\(x\),而是把\(x\)加入刪除堆
訪問維護最值的堆時,看看堆頂是不是和刪除堆堆頂一樣,如果一樣,說明這個數已經被刪掉了,在原堆和刪除堆中同時\(pop\)掉
這個方法為什么對呢?萬一原堆的堆頂\(x\)已經被刪了,而刪除堆的堆頂不是\(x\),導致找到了錯的最值,怎么辦呢?
其實這種情況不可能出現。假設我們維護了一個大根堆,如果刪除堆的堆頂不是\(x\),那必然是一個比\(x\)大的數\(y\)
如果\(y\)還沒有被刪除,那么比\(y\)小的\(x\)一定還不是堆頂,幾次彈出后,堆頂是\(y\),發現刪除堆堆頂同樣是\(y\),\(y\)從原堆和刪除堆中刪除
換句話說,當原堆的堆頂是\(x\)時,刪除堆堆頂和原堆中還需要刪除的數一定\(\leq x\),所以不會找到錯誤的最值