前言
大家好,我是小彭。
今天分享到一種棧的衍生數(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ù)組中元素 后面下一個比它更大的元素,這就是下一個更大元素問題。這個問題也可以形象化地思考:站在墻上向后看,問視線范圍內(nèi)所能看到的下一個更高的墻。例如,站在墻
[3]
上看,下一個更高的墻就是墻 [4]
。
形象化思考
這個問題的暴力解法很容易想到:就是遍歷元素 后面的所有元素,直到找到下一個比
更大的元素為止,時間復(fù)雜度是
,空間復(fù)雜度是
。單次查詢確實沒有優(yōu)化空間了,那多次查詢呢?如果要求輸出數(shù)組中每個元素的下一個更大元素,那么暴力解法需要的時間復(fù)雜度是
。有沒有更高效的算法呢?
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ù)雜度是
,整體時間復(fù)雜度是
;
-
方法 2 - 二叉堆: 不需要遍歷整個容器,只需要對比容器的最小值,直到容器的最小值都大于當(dāng)前元素。最壞情況(遞減序列)下所有數(shù)據(jù)都進(jìn)入堆中,單次操作的時間復(fù)雜度是
,整體時間復(fù)雜度是
;
-
方法 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ù)雜度是
。
下面,我們先從優(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ù)雜度是
,所以整體時間復(fù)雜度是
;
-
空間復(fù)雜度: 使用了額外的優(yōu)先隊列,所以整體的空間復(fù)雜度是
。
優(yōu)先隊列解法的時間復(fù)雜度從 優(yōu)化到
,還不錯,那還有優(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ù)雜度并不是
,而是
。因為每個元素最多只會入棧和出棧一次,所以整體的計算規(guī)模還是與數(shù)據(jù)規(guī)模成正比的,整體時間復(fù)雜度是
;
-
空間復(fù)雜度: 最壞情況下(遞減序列)所有元素被添加到棧中,所以空間復(fù)雜度是
。
這道題也可以用從后往前遍歷的寫法,也是參考資料中提到的解法。 但是,我覺得正向思維更容易理解,也更符合人腦的思考方式,所以還是比較推薦小彭的模板(王婆賣瓜)。
解題模板(從后往前遍歷)
// 從后往前遍歷
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ù)組
nums1
和nums2
; -
nums1
是nums2
的子集。
那么,我們完全可以先計算出 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 | 【題解】 |
參考資料
- LeetCode 專題 · 單調(diào)棧 —— LeetCode 出品
- LeetCode 題解 · 739. 每日溫度 —— LeetCode 出品
- 第 9 章 · 單調(diào)棧 —— liweiwei1419 著
- 單調(diào)棧解決 Next Greater Number 一類問題 —— labuladong 著