使用單調(diào)棧解決 “下一個更大元素” 問題

前言

大家好,我是小彭。

今天分享到一種棧的衍生數(shù)據(jù)結(jié)構(gòu) —— 單調(diào)棧(Monotonic Stack)。棧(Stack)是一種滿足后進(jìn)先出(LIFO)邏輯的數(shù)據(jù)結(jié)構(gòu),而單調(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ù)組中元素 i 后面下一個比它更大的元素,這就是下一個更大元素問題。這個問題也可以形象化地思考:站在墻上向后看,問視線范圍內(nèi)所能看到的下一個更高的墻。例如,站在墻 [3] 上看,下一個更高的墻就是墻 [4]

形象化思考

這個問題的暴力解法很容易想到:就是遍歷元素 i 后面的所有元素,直到找到下一個比 i 更大的元素為止,時間復(fù)雜度是 O(n),空間復(fù)雜度是 O(1)。單次查詢確實沒有優(yōu)化空間了,那多次查詢呢?如果要求輸出數(shù)組中每個元素的下一個更大元素,那么暴力解法需要的時間復(fù)雜度是 O(n^2) 。有沒有更高效的算法呢?


2. 解題思路

我們先轉(zhuǎn)變一下思路:

在暴力解法中,我們每處理一個元素就要去求它的 “下一個更大元素”?,F(xiàn)在我們不這么做,我們每處理一個元素時,由于不清楚它的解,所以先將它緩存到某種數(shù)據(jù)容器中。后續(xù)如果能確定它的解,再將其從容器中取出來。 這個思路可以作為 “以空間換時間” 優(yōu)化時間復(fù)雜度的通用思路。

回到這個例子上:

  • 在處理元素 [3] 時,由于不清楚它的解,只能先將 [3] 放到容器中,繼續(xù)處理下一個元素;

  • 在處理元素 [1] 時,我們發(fā)現(xiàn)它比容器中所有元素都小,只能先將它放到容器中,繼續(xù)處理下一個元素;

  • 在處理元素 [2] 時,我們觀察容器中的 [1] 比當(dāng)前元素小,說明當(dāng)前元素就是 [1] 的解。此時我們可以把 [1] 彈出,記錄結(jié)果。再將 [2] 放到容器中,繼續(xù)處理下一個元素;

  • 在處理元素 [1] 時,我們發(fā)現(xiàn)它比容器中所有元素都小,只能先將它放到容器中,繼續(xù)處理下一個元素;

  • 在處理元素 [4] 時,我們觀察容器中的 [3] [2] [1] 都比當(dāng)前元素小,說明當(dāng)前元素就是它們的解。此時我們可以把它們彈出,記錄結(jié)果。再將 [4] 放到容器中,繼續(xù)處理下一個元素;

  • 在處理元素 [1] 時,我們發(fā)現(xiàn)它比容器中所有元素都小,只能先將它放到容器中,繼續(xù)處理下一個元素;

  • 遍歷結(jié)束,所有被彈出過的元素都是有解的,保留在容器中的元素都是無解的。

分析到這里,我們發(fā)現(xiàn)問題已經(jīng)發(fā)生轉(zhuǎn)變,問題變成了:“如何尋找在數(shù)據(jù)容器中小于當(dāng)前元素的數(shù)”。 現(xiàn)在,我們把注意力集中在這個容器上,思考一下用什么數(shù)據(jù)結(jié)構(gòu)、用什么算法可以更高效地解決問題。由于這個容器是我們額外增加的,所以我們有足夠的操作空間。

先說結(jié)論:

  • 方法 1 - 暴力: 遍歷整個數(shù)據(jù)容器中所有元素,最壞情況(遞減序列)下所有數(shù)據(jù)都進(jìn)入容器中,單次操作的時間復(fù)雜度是 O(N),整體時間復(fù)雜度是 O(N^2);
  • 方法 2 - 二叉堆: 不需要遍歷整個容器,只需要對比容器的最小值,直到容器的最小值都大于當(dāng)前元素。最壞情況(遞減序列)下所有數(shù)據(jù)都進(jìn)入堆中,單次操作的時間復(fù)雜度是 O(lgN),整體時間復(fù)雜度是 O(N·lgN);
  • 方法 3 - 單調(diào)棧: 我們發(fā)現(xiàn)元素進(jìn)入數(shù)據(jù)容器的順序正好是有序的,且后進(jìn)入容器的元素會先彈出做對比,符合 “后進(jìn)先出” 邏輯,所以這個容器數(shù)據(jù)結(jié)構(gòu)用棧就可以實現(xiàn)。因為每個元素最多只會入棧和出棧一次,所以整體的計算規(guī)模還是與數(shù)據(jù)規(guī)模成正比的,整體時間復(fù)雜度是 O(n)。

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


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

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

我們可以維護(hù)一個小頂堆,每處理一個元素時,先觀察堆頂?shù)脑兀?/p>

  • 如果堆頂元素小于當(dāng)前元素,則說明已經(jīng)確定了堆頂元素的解,我們將其彈出并記錄結(jié)果;
  • 如果堆頂元素不小于當(dāng)前元素,則說明小頂堆內(nèi)所有元素都是不小于當(dāng)前元素的,停止觀察。

觀察結(jié)束后,將當(dāng)前元素加入小頂堆,堆會自動進(jìn)行堆排序,堆頂就是整個容器的最小值。此時,繼續(xù)在后續(xù)元素上重復(fù)這個過程。

題解

fun nextGreaterElements(nums: IntArray): IntArray {
    // 結(jié)果數(shù)組 
    val result = IntArray(nums.size) { -1 }
    // 小頂堆
    val heap = PriorityQueue<Int> { first, second ->
        nums[first] - nums[second]
    }
    // 從前往后查詢
    for (index in 0 until nums.size) {
        // while:當(dāng)前元素比堆頂元素大,說明找到下一個更大元素
        while (!heap.isEmpty() && nums[index] > nums[heap.peek()]) {
            result[heap.poll()] = nums[index]
        }
        // 當(dāng)前元素入堆
        heap.offer(index)
    }
    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^2) 優(yōu)化到 O(N·lgN),還不錯,那還有優(yōu)化空間嗎?


4. 單調(diào)棧解法

我們繼續(xù)分析發(fā)現(xiàn),元素進(jìn)入數(shù)據(jù)容器的順序正好是逆序的,最后加入容器的元素正好就是容器的最小值。此時,我們不需要用二叉堆來尋找最小值,只需要獲取最后一個進(jìn)入容器的元素就能輕松獲得最小值。這符合 “后進(jìn)先出” 邏輯,所以這個容器數(shù)據(jù)結(jié)構(gòu)用棧就可以實現(xiàn)。

這個問題也可以形象化地思考:把數(shù)字想象成有 “重量” 的杠鈴片,每增加一個杠鈴片,會把中間小的杠鈴片壓扁,當(dāng)前的大杠鈴片就是這些被壓扁杠鈴片的 “下一個更大元素”。

形象化思考

解題模板

// 從前往后遍歷
fun nextGreaterElements(nums: IntArray): IntArray {
    // 結(jié)果數(shù)組 
    val result = IntArray(nums.size) { -1 }
    // 單調(diào)棧
    val stack = ArrayDeque<Int>()
    // 從前往后遍歷
    for (index in 0 until nums.size) {
        // while:當(dāng)前元素比棧頂元素大,說明找到下一個更大元素
        while (!stack.isEmpty() && nums[index] > nums[stack.peek()]) {
            result[stack.pop()] = nums[index]
        }
        // 當(dāng)前元素入隊
        stack.push(index)
    }
    return result
}

理解了單點棧的解題模板后,我們來分析它的復(fù)雜度:

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

這道題也可以用從后往前遍歷的寫法,也是參考資料中提到的解法。 但是,我覺得正向思維更容易理解,也更符合人腦的思考方式,所以還是比較推薦小彭的模板(王婆賣瓜)。

解題模板(從后往前遍歷)

// 從后往前遍歷
fun nextGreaterElement(nums: IntArray): IntArray {
    // 結(jié)果數(shù)組
    val result = IntArray(nums.size) { -1 }
    // 單調(diào)棧
    val stack = ArrayDeque<Int>()
    // 從后往前查詢
    for (index in nums.size - 1 downTo 0) {
        // while:棧頂元素比當(dāng)前元素小,說明棧頂元素不再是下一個更大元素,后續(xù)不再考慮它
        while (!stack.isEmpty() && stack.peek() <= nums[index]) {
            stack.pop()
        }
        // 輸出到結(jié)果數(shù)組
        result[index] = stack.peek() ?: -1
        // 當(dāng)前元素入隊
        stack.push(nums[index])
    }
    return result
}

5. 典型例題 · 下一個更大元素 I

理解以上概念后,就已經(jīng)具備解決單調(diào)棧常見問題的必要知識了。我們來看一道 LeetCode 上的典型例題:LeetCode 496.

LeetCode 例題

第一節(jié)的示例是求 “在當(dāng)前數(shù)組中尋找下一個更大元素” ,而這道題里是求 “數(shù)組 1 元素在數(shù)組 2 中相同元素的下一個更大元素” ,還是同一個問題嗎?其實啊,這是題目拋出的煙霧彈。注意看細(xì)節(jié)信息:

  • 兩個沒有重復(fù)元素的數(shù)組 nums1nums2 ;
  • nums1nums2 的子集。

那么,我們完全可以先計算出 nums2 中每個元素的下一個更大元素,并把結(jié)果記錄到一個散列表中,再讓 nums1 中的每個元素去散列表查詢結(jié)果即可。

題解

class Solution {
    fun nextGreaterElement(nums1: IntArray, nums2: IntArray): IntArray {
        // 臨時記錄
        val map = HashMap<Int, Int>()
        // 單調(diào)棧
        val stack = ArrayDeque<Int>()
        // 從前往后查詢
        for (index in 0 until nums2.size) {
            // while:當(dāng)前元素比棧頂元素大,說明找到下一個更大元素
            while (!stack.isEmpty() && nums2[index] > stack.peek()) {
                // 輸出到臨時記錄中
                map[stack.pop()] = nums2[index]
            }
            // 當(dāng)前元素入隊
            stack.push(nums2[index])
        }

        return IntArray(nums1.size) {
            map[nums1[it]] ?: -1
        }
    }
}

6. 典型例題 · 下一個更大元素 II(環(huán)形數(shù)組)

第一節(jié)的示例還有一道變型題,對應(yīng)于 LeetCode 上的另一道典型題目:503. 下一個更大元素 II

LeetCode 例題

兩道題的核心考點都是 “下一個更大元素”,區(qū)別只在于把 “普通數(shù)組” 變?yōu)?“環(huán)形數(shù)組 / 循環(huán)數(shù)組”,當(dāng)元素遍歷到數(shù)組末位后依然找不到目標(biāo)元素,則會循環(huán)到數(shù)組首位繼續(xù)尋找。這樣的話,除了所有數(shù)據(jù)中最大的元素,其它每個元素都必然存在下一個更大元素。

其實,計算機(jī)中并不存在物理上的循環(huán)數(shù)組,在遇到類似的問題時都可以用假數(shù)據(jù)長度和取余的思路處理。如果你是前端工程師,那么你應(yīng)該有印象:我們在實現(xiàn)無限循環(huán)輪播的控件時,有一個小技巧就是給控件 設(shè)置一個非常大的數(shù)據(jù)長度 ,長到永遠(yuǎn)不可能輪播結(jié)束,例如 Integer.MAX_VALUE。每次輪播后索引會加一,但在取數(shù)據(jù)時會對數(shù)據(jù)長度取余,這樣就實現(xiàn)了循環(huán)輪播了。

無限輪播偽代碼

class LooperView {

    private val data = listOf("1", "2", "3")        

    // 假數(shù)據(jù)長度
    fun getSize() = Integer.MAX_VALUE

    // 使用取余轉(zhuǎn)化為 data 上的下標(biāo)
    fun getItem(index : Int) = data[index % data.size]
}

回到這道題,我們的思路也更清晰了。我們不需要無限查詢,所以自然不需要設(shè)置 Integer.MAX_VALUE 這么大的假數(shù)據(jù),只需要 設(shè)置 2 倍的數(shù)據(jù)長度 ,就能實現(xiàn)循環(huán)查詢(3 倍、4倍也可以,但沒必要),例如:

題解

class Solution {
    fun nextGreaterElements(nums: IntArray): IntArray {
        // 結(jié)果數(shù)組 
        val result = IntArray(nums.size) { -1 }
        // 單調(diào)棧
        val stack = ArrayDeque<Int>()
        // 數(shù)組長度
        val size = nums.size
        // 從前往后遍歷
        for (index in 0 until nums.size * 2) {
            // while:當(dāng)前元素比棧頂元素大,說明找到下一個更大元素
            while (!stack.isEmpty() && nums[index % size] > nums[stack.peek() % size]) {
                result[stack.pop() % size] = nums[index % size]
            }
            // 當(dāng)前元素入隊
            stack.push(index)
        }
        return result
    }
}

7. 總結(jié)

到這里,相信你已經(jīng)掌握了 “下一個更大元素” 問題的解題模板了。除了典型例題之外,大部分題目會將 “下一個更大元素” 的語義隱藏在題目細(xì)節(jié)中,需要找出題目的抽象模型或轉(zhuǎn)變思路才能找到,這是難的地方。

小彭在 20 年的文章里說過單調(diào)棧是一個相對冷門的數(shù)據(jù)結(jié)構(gòu),包括參考資料和網(wǎng)上的其他資料也普遍持有這個觀點。 單調(diào)棧不能覆蓋太大的問題域,應(yīng)用價值不及其他數(shù)據(jù)結(jié)構(gòu)。 —— 2 年前的文章

2 年后重新思考,我不再持有此觀點。我現(xiàn)在認(rèn)為:單調(diào)棧的關(guān)鍵是 “單調(diào)性”,而棧只是為了配合問題對操作順序的要求而搭配的數(shù)據(jù)結(jié)構(gòu)。 我們學(xué)習(xí)單調(diào)棧,應(yīng)該當(dāng)作學(xué)習(xí)單調(diào)性的思想在棧這種數(shù)據(jù)結(jié)構(gòu)上的應(yīng)用,而不是學(xué)習(xí)一種新的數(shù)據(jù)結(jié)構(gòu)。對此,你怎么看?

下一篇文章,我們來學(xué)習(xí)單調(diào)性的思想在隊列上數(shù)據(jù)結(jié)構(gòu)上的應(yīng)用 —— 單調(diào)隊列

更多同類型題目:

單調(diào)棧 難度 題解
496. 下一個更大元素 I Easy 【題解】
1475. 商品折扣后的最終價格 Easy 【題解】
503. 下一個更大元素 II Medium 【題解】
739. 每日溫度 Medium 【題解】
901. 股票價格跨度 Medium 【題解】
1019. 鏈表中的下一個更大節(jié)點 Medium 【題解】
402. 移掉 K 位數(shù)字 Medium 【題解】
42. 接雨水 Hard 【題解】
84. 柱狀圖中最大的矩形 Hard 【題解】

參考資料

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 47,958評論 2 373

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