使用單調(diào)隊列解決 “滑動窗口最大值” 問題

前言

大家好排嫌,我是小彭对粪。

在上一篇文章中芯勘,我們介紹了單調(diào)棧這種特殊的棧結(jié)構(gòu)箱靴,單調(diào)棧是一種非常適合處理 “下一個更大元素問題” 的數(shù)據(jù)結(jié)構(gòu)。今天荷愕,分享到單調(diào)棧的孿生兄弟 —— 單調(diào)隊列(Monotonic Queue)衡怀。類似地,單調(diào)隊列也是在隊列的基礎(chǔ)上增加了單調(diào)的性質(zhì)(單調(diào)遞增或單調(diào)遞減)路翻。那么單調(diào)隊列是用來解決什么問題的呢狈癞?


學(xué)習(xí)路線圖:


1. 單調(diào)隊列的典型問題

單調(diào)隊列是一種用來高效地解決 “滑動窗口最大值” 問題的數(shù)據(jù)結(jié)構(gòu)茄靠。

舉個例子茂契,給定一個整數(shù)數(shù)組,要求輸出數(shù)組中大小為 K 的窗口中的最大值慨绳,這就是窗口最大值問題掉冶。而如果窗口從最左側(cè)逐漸滑動到數(shù)組的最右端,要求輸出每次移動后的窗口最大值脐雪,這就是滑動窗口問題厌小。這個問題同時也是 LeetCode 上的一道典型例題:LeetCode · 239. 滑動窗口最大值

LeetCode 例題

這個問題的暴力解法很容易想到:就是每次移動后遍歷整個滑動窗口找到最大值,單次遍歷的時間復(fù)雜度是 O(k)战秋,整體的時間復(fù)雜度是 O(n·k)璧亚,空間復(fù)雜度是 O(1)。當然脂信,暴力解法里的重復(fù)比較運算也是很明顯的癣蟋,我們每次僅僅往窗口里增加一個元素,都需要與窗口中所有元素對比找到最大值狰闪。

那么疯搅,有沒有更高效的算法呢?

滑動窗口最大值問題

或許埋泵,我們可以使用一個變量來記錄上一個窗口中的最大值幔欧,每增加一個新元素,只需要與這個 “最大值” 比較即可丽声。

然而礁蔗,窗口大小是固定的,每加入一個新元素后雁社,也要剔除一個元素浴井。如果剔除的元素正好是變量記錄的最大值,那說明這個值已經(jīng)失效歧胁。我們還是需要花費 O(k) 時間重新找出最大值滋饲。那還有更快的方法尋找最大值嗎厉碟?


2. 解題思路

我們先用 “空間換時間” 的通用思路梳理一下:

在暴力解法中,我們每移動一次窗口都需要遍歷整個窗口中的所有元素找出 “滑動窗口的最大值”⊥犁裕現(xiàn)在我們不這么做箍鼓,我們把滑動窗口中的所有元素緩存到某種數(shù)據(jù)容器中,每次窗口滑動后也向容器增加一個新的元素呵曹,而容器的最大值就自然是滑動窗口的最大值款咖。

現(xiàn)在,我們的問題已經(jīng)發(fā)生轉(zhuǎn)變奄喂,問題變成了:“如何尋找數(shù)據(jù)容器中的最大值”铐殃。 另外,根據(jù)題目條件限制跨新,這個容器是帶有約束的:因為窗口大小是固定的富腊,所以每加入一個新元素后,必然也要剔除一個元素域帐。 我們的數(shù)據(jù)容器也要滿足這個約束赘被。 現(xiàn)在,我們把注意力集中在這個容器上肖揣,思考一下用什么數(shù)據(jù)結(jié)構(gòu)民假、用什么算法可以更高效地解決問題。由于這個容器是我們額外增加的龙优,所以我們有足夠的操作空間羊异。

先說結(jié)論:

  • 方法 1 - 暴力: 遍歷整個數(shù)據(jù)容器中所有元素,單次操作的時間復(fù)雜度是 O(k)彤断,整體時間復(fù)雜度是 O(n·k)野舶;
  • 方法 2 - 二叉堆: 不需要遍歷整個數(shù)據(jù)容器,可以用大頂堆維護容器的最大值瓦糟,單次操作的時間復(fù)雜度是 O(lgk)筒愚,整體時間復(fù)雜度是 O(n·lgk)
  • 方法 3 - 雙端隊列: 我們發(fā)現(xiàn)二叉堆中很多中間元素是冗余的菩浙,拉低了效率巢掺。我們可以在每處理一個元素時,可以先觀察容器中剛剛加入的元素劲蜻,如果剛剛加入的元素小于當前元素陆淀,則直接將其丟棄(后進先出邏輯)。最先加入容器的元素先嬉,如果超出了滑動窗口的范圍轧苫,也直接將其丟棄(先進先出邏輯)。所以這個容器數(shù)據(jù)結(jié)構(gòu)要用雙端隊列實現(xiàn)。因為每個元素最多只會入隊和出隊一次含懊,所以整體的計算規(guī)模還是與數(shù)據(jù)規(guī)模成正比的身冬,整體時間復(fù)雜度是 O(n)

下面岔乔,我們先從優(yōu)先隊列說起酥筝。


3. 優(yōu)先隊列解法

尋找最值的問題第一反應(yīng)要想到二叉堆。

我們可以維護一個大頂堆雏门,初始時先把第一個滑動窗口中的前 k - 1 個元素放入大頂堆嘿歌。滑動窗口每移動一步茁影,就把一個新的元素放入大頂堆宙帝。大頂堆會自動進行堆排序,堆頂?shù)脑鼐褪钦麄€堆中 k 個元素的最值募闲。然而步脓,這個最大值可能是失效的(不在滑動窗口的范圍內(nèi)),我們要怎么處理這個約束呢蝇更?

我們可以用一個小技巧:將元素的下標放入隊列中沪编,用 nums[index] 比較元素大小,用 index 判斷元素有效性年扩。 此時,每次取堆頂元素時访圃,如果發(fā)現(xiàn)該元素的下標超出了窗口范圍厨幻,就直接丟棄。

題解

class Solution {
    fun maxSlidingWindow(nums: IntArray, k: Int): IntArray {
        // 結(jié)果數(shù)組
        val result = IntArray(nums.size - k + 1) {-1}
        // 大頂堆
        val heap = PriorityQueue<Int> { first, second ->
            nums[second] - nums[first]
        }
        for (index in nums.indices) {
            if (index < k - 1) {
                heap.offer(index)
                continue
            }
            heap.offer(index)
            // while:丟棄堆頂超出滑動窗口范圍的失效元素
            while (!heap.isEmpty() && heap.peek() < index - k + 1) {
                // 丟棄
                heap.poll()
            }
            // 堆頂元素就是最大值
            result[index - k + 1] = nums[heap.peek()]
        }
        return result
    }
}

我們來分析優(yōu)先隊列解法的復(fù)雜度:

  • 時間復(fù)雜度: 最壞情況下(遞增序列)腿时,所有元素都被添加到優(yōu)先隊列里况脆,優(yōu)先隊列的單次操作時間復(fù)雜度是 O(lgn),所以整體時間復(fù)雜度是 O(n·lgn)批糟;
  • 空間復(fù)雜度: 使用了額外的優(yōu)先級隊列格了,所以整體的時間復(fù)雜度是 O(n)

優(yōu)先隊列解法的時間復(fù)雜度從 O(n·k) 變成 O(n·lgn)徽鼎,這個效果很難讓人滿意盛末,有更好的辦法嗎?

我們繼續(xù)分析發(fā)現(xiàn)否淤,由于滑動窗口是從左往右移動的悄但,所以當一個元素 nums[i] 的后面出現(xiàn)一個更大的元素 nums[j],那么 nums[i] 就永遠不可能是滑動窗口的最大值了石抡,我們可以永久地將它剔除掉檐嚣。然而,在優(yōu)先隊列中啰扛,失去價值的 nums[i] 會一直存儲在隊列中嚎京,從而拉低了優(yōu)先隊列的效率嗡贺。


4. 單調(diào)隊列解法

我們可以維護一個數(shù)據(jù)容器,第一個元素先放到容器中鞍帝,后續(xù)每處理一個元素暑刃,先觀察容器中剛剛加入的元素,如果剛剛加入的元素小于當前元素膜眠,則直接將其丟棄(因為新增加的元素排在后面岩臣,會更晚地離開滑動窗口,所以中間的小元素永遠沒有資格了)宵膨,避免拉低效率架谎。

繼續(xù)分析我們還發(fā)現(xiàn):

  • 這個數(shù)據(jù)容器中后進入的元素需要先彈出與當前元素對比,符合 “后進先出” 邏輯辟躏,所以這個數(shù)據(jù)結(jié)構(gòu)要用棧谷扣;
  • 這個數(shù)據(jù)容器中先進入的元素需要先彈出丟棄(離開滑動窗口),符合 “先進先出” 邏輯捎琐,所以這個數(shù)據(jù)結(jié)構(gòu)要用隊列会涎;

因此,這個數(shù)據(jù)結(jié)構(gòu)同時具備棧和隊列的特點瑞凑,是需要同時在兩端操作的雙端隊列末秃。這個問題也可以形象化地思考:把數(shù)字想象為有 “重量” 的的杠鈴片,滑動窗口每移動一次后籽御,新增加的大杠鈴片會把中間小的杠鈴片壓扁练慕,后續(xù)不再考慮它們。

形象化思考

Untitled 3.png

題解

class Solution {
    fun maxSlidingWindow(nums: IntArray, k: Int): IntArray {
        // 結(jié)果數(shù)組
        val result = IntArray(nums.size - k + 1) { -1 }
        // 單調(diào)隊列(基于雙端隊列)
        val queue = LinkedList<Int>()
        for (index in nums.indices) {
            // while:隊尾元素比當前元素小技掏,說明隊尾元素不再可能是最大值铃将,后續(xù)不再考慮它
            while (!queue.isEmpty() && nums[queue.peekLast()] <= nums[index]) {
                // 拋棄隊尾元素
                queue.pollLast()
            }
            queue.offerLast(index)
            if (index < k - 1) {
                continue
            }
            // if:移除隊頭超出滑動窗口范圍的元素
            if (!queue.isEmpty() && queue.peekFirst() < index - k + 1) {
                queue.pollFirst()
            }
            // 從隊頭取目標元素
            result[index - k + 1] = nums[queue.peekFirst()]
        }
        return result
    }
}

單調(diào)隊列與單調(diào)棧的思路是非常類似的,單調(diào)棧在每次遍歷中哑梳,會將棧頂 “被壓扁的小杠鈴片” 彈出棧劲阎,而單調(diào)隊列在每次遍歷中,會將隊尾中 “被壓扁的小杠鈴片” 彈出隊鸠真。 單調(diào)棧在棧頂過濾無效元素悯仙,在棧頂獲取目標元素,單調(diào)隊列在隊尾過濾無效元素弧哎,在隊頭獲取目標元素雁比。

理解了單調(diào)隊列的解題模板后,我們來分析它的復(fù)雜度:

  • 時間復(fù)雜度: 雖然代碼中有嵌套循環(huán)撤嫩,但它的時間復(fù)雜度并不是 O(n^2)偎捎,而是 O(n)。因為每個元素最多只會入棧和出棧一次,所以整體的計算規(guī)模還是與數(shù)據(jù)規(guī)模成正比的茴她,整體時間復(fù)雜度是 O(n)寻拂;
  • 空間復(fù)雜度: 最壞情況下(遞增序列),所有元素被添加到隊列中丈牢,所以空間復(fù)雜度是 O(n)祭钉。

5. 單調(diào)棧、單調(diào)隊列己沛、優(yōu)先隊列對比

5.1 單調(diào)棧與單調(diào)隊列的選擇

單調(diào)隊列和單調(diào)棧在很大程度上是類似的慌核,它們均是在原有數(shù)據(jù)結(jié)構(gòu)的基礎(chǔ)上增加的單調(diào)的性質(zhì)。那么申尼,什么時候使用單調(diào)棧垮卓,什么時候使用單調(diào)隊列呢? 主要看你的算法中元素被排除的順序师幕,如果先進入集合的元素先排除粟按,那么使用棧(LIFO);如果先進入集合的元素后排除霹粥,那么使用隊列(FIFO)灭将。 在例題中,甚至出現(xiàn)了同時結(jié)合棧和隊列的情況后控。

上一篇文章里我們討論過單調(diào)棧庙曙,單調(diào)棧是一種非常適合解決 ”下一個更大元素“ 的數(shù)據(jù)結(jié)構(gòu)。在文章最后我也指出忆蚀,單調(diào)棧的關(guān)鍵是 “單調(diào)性”矾利,而不是 “棧”馋袜。我們學(xué)習(xí)單調(diào)棧和單端隊列,應(yīng)該當作學(xué)習(xí)單調(diào)性的思想在棽案或者隊列上的應(yīng)用欣鳖。

我們已經(jīng)不是第一次討論 “單調(diào)性” 了,老讀者應(yīng)該有印象茴厉,在討論二分查找時泽台,我們曾經(jīng)指出 “單調(diào)性是二分查找的必要條件”。舉個例子矾缓,對于一個單調(diào)遞增序列怀酷,當中位數(shù)小于目標數(shù)時,那我們可以確定:左半?yún)^(qū)間一定不是解嗜闻,右半?yún)^(qū)間可能有解蜕依,問題規(guī)模直接縮小一半。最終整個二分查找算法的時間復(fù)雜度從暴力查找 O(n),降低到 O(lgn)样眠。反之友瘤,如果數(shù)據(jù)并不是單調(diào)的,那么跟中位數(shù)比較就沒有意義檐束。

5.2 優(yōu)先隊列與單調(diào)隊列對比

優(yōu)先隊列也叫小頂堆或大頂堆辫秧,每從堆頂取一個數(shù)據(jù),底層的二叉堆會自動進行堆排序被丧,保持堆頂元素是整個堆的最值盟戏。所以,雖然整個堆不是單調(diào)的甥桂,但堆頂是動態(tài)單調(diào)的柿究。優(yōu)先隊列雖然也叫隊列,但它并不能維護元素的位置關(guān)系格嘁,出隊順序和入隊順序無關(guān)笛求。

數(shù)據(jù)結(jié)構(gòu) 特點 單調(diào)性 / 有序性
單調(diào)棧 后進先出(LIFO),出隊順序由入棧順序決定 靜態(tài)
單調(diào)隊列 先進先出(FIFO)糕簿,出隊順序由入隊順序決定 靜態(tài)單調(diào)序列
優(yōu)先隊列 出隊順序與入隊順序無關(guān)探入,而由優(yōu)先級順序決定 整個堆不是單調(diào)的,但堆頂是動態(tài)單調(diào)的

6. 總結(jié)

到這里懂诗,單調(diào)隊列和單調(diào)棧的內(nèi)容都講完了蜂嗽。和單調(diào)棧一樣,除了典型例題之外殃恒,大部分題目會將 “滑動窗口最大值素” 的語義隱藏在題目細節(jié)中植旧,需要找出題目的抽象模型或轉(zhuǎn)換思路才能找到,這是難的地方离唐。

還是那句話病附,學(xué)習(xí)單調(diào)隊列和單調(diào)棧,應(yīng)該當作學(xué)習(xí)單調(diào)性的思想在這兩種數(shù)據(jù)結(jié)構(gòu)上的應(yīng)用亥鬓。你覺得呢完沪?

更多同類型題目:

單調(diào)隊列 難度 題解
239. 滑動窗口最大值 Hard 【題解】
918. 環(huán)形子數(shù)組的最大和 Medium 【題解】
面試題59 - II. 隊列的最大值 Medium 【題解】

參考資料

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市嵌戈,隨后出現(xiàn)的幾起案子覆积,更是在濱河造成了極大的恐慌,老刑警劉巖熟呛,帶你破解...
    沈念sama閱讀 222,590評論 6 517
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件宽档,死亡現(xiàn)場離奇詭異,居然都是意外死亡庵朝,警方通過查閱死者的電腦和手機吗冤,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,157評論 3 399
  • 文/潘曉璐 我一進店門又厉,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人欣孤,你說我怎么就攤上這事馋没。” “怎么了降传?”我有些...
    開封第一講書人閱讀 169,301評論 0 362
  • 文/不壞的土叔 我叫張陵篷朵,是天一觀的道長。 經(jīng)常有香客問我婆排,道長声旺,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 60,078評論 1 300
  • 正文 為了忘掉前任段只,我火速辦了婚禮腮猖,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘赞枕。我一直安慰自己澈缺,他們只是感情好,可當我...
    茶點故事閱讀 69,082評論 6 398
  • 文/花漫 我一把揭開白布炕婶。 她就那樣靜靜地躺著姐赡,像睡著了一般。 火紅的嫁衣襯著肌膚如雪柠掂。 梳的紋絲不亂的頭發(fā)上项滑,一...
    開封第一講書人閱讀 52,682評論 1 312
  • 那天,我揣著相機與錄音涯贞,去河邊找鬼枪狂。 笑死,一個胖子當著我的面吹牛宋渔,可吹牛的內(nèi)容都是我干的州疾。 我是一名探鬼主播,決...
    沈念sama閱讀 41,155評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼皇拣,長吁一口氣:“原來是場噩夢啊……” “哼孝治!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起审磁,我...
    開封第一講書人閱讀 40,098評論 0 277
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎岂座,沒想到半個月后态蒂,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,638評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡费什,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,701評論 3 342
  • 正文 我和宋清朗相戀三年钾恢,在試婚紗的時候發(fā)現(xiàn)自己被綠了手素。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,852評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡瘩蚪,死狀恐怖泉懦,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情疹瘦,我是刑警寧澤崩哩,帶...
    沈念sama閱讀 36,520評論 5 351
  • 正文 年R本政府宣布,位于F島的核電站言沐,受9級特大地震影響邓嘹,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜险胰,卻給世界環(huán)境...
    茶點故事閱讀 42,181評論 3 335
  • 文/蒙蒙 一汹押、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧起便,春花似錦棚贾、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,674評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至奖年,卻和暖如春细诸,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背陋守。 一陣腳步聲響...
    開封第一講書人閱讀 33,788評論 1 274
  • 我被黑心中介騙來泰國打工震贵, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人水评。 一個月前我還...
    沈念sama閱讀 49,279評論 3 379
  • 正文 我出身青樓猩系,卻偏偏與公主長得像,于是被迫代替她去往敵國和親中燥。 傳聞我的和親對象是個殘疾皇子寇甸,可洞房花燭夜當晚...
    茶點故事閱讀 45,851評論 2 361

推薦閱讀更多精彩內(nèi)容