详解基于栈的算法
reference
单调栈算法
什么是单调栈算法
单调栈:要求每次入栈的元素必须要有序(如果新元素入栈不符合要求,则将之前的元素出栈,直到符合要求再入栈),使之形成单调递增/单调递减的一个栈。
分类:
- 单调递增栈(按出栈顺序递增,若用数组表示即是一个递减的数组):只有比栈顶小的才能入栈,否则就把栈顶出栈一直到没有比该值更大的,再入栈。出栈时可能会有一些计算。
- 单调递减栈(按出栈顺序递减,若用数组表示即是一个递增的数组):只有比栈顶大的才能入栈,否则就把栈顶出栈一直到没有比该值更小的,再入栈。
适用条件
当我们需要比较数组中前后元素的关系时即更具体的是如下场景:
第一个大于 **
第一个小于 **
连续大于 **
连续小于 **
下一个大于 * , 上一个大于 * 用单增栈 eg: leetcode-496 leetcode-503
下一个小于 *, 上一个小于 * 用单减栈
以下情况也约适合,但不完全符合:
无序数组中左侧小于 nums[i] 的最大值
无序数组中左侧大于 nums[i] 的最小值
无序数组中右侧小于 nums[i] 的最大值
无序数组中右侧大于 nums[i] 的最小值
单调栈变形
如果题目可以用单调栈的思想来做,但受限于剔除元素的同时剔除了正确答案,我们可以使用单调栈的变形,不剔除元素仅做元素替换
例题:
300. Longest Increasing Subsequence
334. Increasing Triplet Subsequence
# 300 题解 太牛了
class Solution_20230202:
def lengthOfLIS_0(self, nums: List[int]) -> int:
"""
暴力法:遍历所有的元素,最长的递增串肯定是以某个元素开始的
:param nums:
:return:
"""
res = float('-inf')
length = len(nums)
for i in range(length):
total = 1
last_val = nums[i]
for j in range(i + 1, length):
if nums[j] > last_val:
last_val = nums[j]
total += 1
res = max(res, total)
return res
def find_idx(self, max_v: List[int], length: int, target: int) -> int:
"""
二分查找 bisect_left(target)-1
:param max_v:
:param length:
:param target:
:return:
"""
l, r = 0, length
while l <= r:
mid = (l + r) // 2
if max_v[mid] >= target:
r = mid - 1
else:
l = mid + 1
return l - 1
def lengthOfLIS_1(self, nums: List[int]) -> int:
"""
《编程之美》
长度为 i 的子数组可能有多个,我们用 max_v[i] 存储其最小的最后一个元素,max_v 一定是递增的否则就和它的定义矛盾
用 lis[i] 存储以 nums[i] 结尾的最长递增子序列的长度
此时我们遍历数组中的元素来纠正 lis[i] 的值,与此同时纠正 max_v[lis[i]] 的值
时间复杂度:O(NlogN)
空间复杂度:O(N)
:param nums:
:return:
"""
length = len(nums)
max_v = [float('-inf')] * (length + 1)
max_v[1] = nums[0]
lis = [1] * length
res = 1
for i in range(1, length):
j = res
# while j >= 0:
# if nums[i] > max_v[j]:
# lis[i] = j + 1
# break
# j -= 1
# tip: 只需要找到最后一个值小于 nums[i] 的索引即可,只有基于此才能得到 lis[i]
# 前面的不考虑也不影响结果,因为前面的加上 nums[i] 之后对 max_v lis[i] 都没影响
idx = self.find_idx(max_v, j, nums[i])
if idx >= 0:
lis[i] = idx + 1
if lis[i] > res:
res = lis[i]
# tip 说明 list[i] 是从未达到过的长度
max_v[lis[i]] = nums[i]
elif nums[i] < max_v[lis[i]]:
max_v[lis[i]] = nums[i]
return res
def lengthOfLIS(self, nums: List[int]) -> int:
"""
单调栈变形,构造单减栈,遇到比栈顶小的是替换掉而不是删掉,因为如果替换的是已有栈中的元素那么栈的长度不变,如果比栈顶大那么新增
此时栈的长度变长
[10, 9, 2, 5, 3, 7, 101, 18] 为例
10 -> 9
9 -> 2
2 5 -> 2 3
2 3 7 101 -> 2 3 7 18
:param nums:
:return:
"""
stack = []
length = len(nums)
for i in range(length):
if stack and stack[-1] >= nums[i]:
idx = self.find_idx(stack, len(stack), nums[i])
stack[idx + 1] = nums[i]
else:
stack.append(nums[i])
return len(stack)
复杂度
时间复杂度 O(N)
空间复杂度 O(N)
如何实现
stack = []
# 假设是单调递减
for i in range(len(T)):
while len(stack)>0 and T[i] < stack[-1]:
stack.pop()
# 进行相关的运算
stack.append(T[i])
examples
股票价格跨度-leetcode-901
解题思路:
关键词:小于或等于今天价格的最大连续数,由此可见有必要使用单调栈来解决问题
# 典型的单调栈问题
class StockSpanner:
def __init__(self):
self.stack = []
def next(self, price: int) -> int:
weight = 1
# 我们希望找到连续的比当前元素小的元素,也就是出栈是单调递增的
# 所以数组是单减的,同时我们使用一个 weight 标识比当前值小的数目,我们用元组来标识栈中的每个元素
while self.stack and self.stack[-1][0] <= price:
weight += self.stack.pop()[1]
self.stack.append((price, weight))
return weight
每日温度-leetcode-739
解题思路:
和上面的「股票价格跨度」类似的问题:通过题目理解,我们发现其实是寻找下一个比当前元素大的元素的位置。
由此可以用单调栈来做,单调递增栈即单减列表。
时间复杂度:O(n)
空间复杂度:O(n)
class Solution:
# 通过题目理解,我们发现其实是寻找下一个比当前元素大的元素的位置
# 由此可以用单调栈来做,单调递增栈即单减列表
# 解题思路和[股票价格跨度](https://leetcode.cn/problems/online-stock-span)相同
# 时间复杂度:O(n)
# 空间复杂度:O(n)
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
length, stack = len(temperatures), []
res = [0] * length
for i in range(length - 1, -1, -1):
while len(stack) > 0 and stack[-1][0] <= temperatures[i]:
stack.pop()
if len(stack) > 0:
res[i] = stack[-1][1] - i
stack.append((temperatures[i], i))
return res
下一个更大元素 I-leetcode-496 🌟🌟🌟
解题思路:
通过题目关键词:下一个更大,可以发现这个题可以用单调栈来解决
class Solution:
# 从题目中我们知道这是要构建一个单调递减栈即出栈时是单减的,而整个数组是单增的
# 假如我们要做一个单增栈呢,整个数组是单减的
def nextGreaterElement0(self, nums1: List[int], nums2: List[int]) -> List[int]:
res = []
for i in range(len(nums1)):
idx = nums2.index(nums1[i])
if idx + 1 > len(nums2) - 1:
res.append(-1)
continue
for j in range(idx + 1, len(nums2)):
stack = [nums1[i]]
while len(stack) > 0 and nums2[j] < stack[-1]:
stack.pop()
if len(stack) > 0 and stack[-1] == nums1[i]:
res.append(nums2[j])
break
if len(res) < i + 1:
res.append(-1)
return res
# 上述的解决方法看起来复杂度还是很高,我们已经获悉 nums1和nums2中所有整数 互不相同
# 问题就变成了如何求每个元素的右边第一个比它大的元素,因此我们使用单增栈即数组是单减的
# 时间复杂度 O(max(m,n)) m n 分别是 nums1 nums2 的长度
# 空间复杂度 O(max(m,n))
def nextGreaterElement(self, nums1: List[int], nums2: List[int]) -> List[int]:
stack, map, res = [], {}, []
for i in range(len(nums2) - 1, -1, -1):
while len(stack) > 0 and stack[-1] <= nums2[i]:
stack.pop()
if len(stack) > 0:
map[nums2[i]] = stack[-1]
else:
map[nums2[i]] = -1
# print('====>', nums2[i], stack, map)
stack.append(nums2[i])
for i in range(len(nums1)):
res.append(map[nums1[i]])
return res
下一个更大元素 II-leetcode-503
解题思路:
其实是寻找下一个更大元素的变种。
class Solution:
# 以 [1,2,3] 为例 1 需遍历 2 3 1 2 3
# 2 需遍历 3 1 2 3 1
# 3 需遍历 1 2 3 1 2
# 穷举下来我们需要遍历的实则是 1 2 3 1 2
# [1,2,3,4,3] 需要遍历的则是 [1,2,3,4,3,1,2,3,4]
# [2,3,4,-1,4]
# 时间复杂度:O(n)
# 空间复杂度:O(n)
# 感谢 ac
def nextGreaterElements(self, nums: List[int]) -> List[int]:
length, stack = len(nums), []
res = [-1] * length
nums.extend(nums[0:(length - 1)])
for i in range(len(nums) - 1, -1, -1):
while len(stack) > 0 and nums[i] >= stack[-1]:
stack.pop()
if len(stack) > 0 and i < length:
res[i] = stack[-1]
stack.append(nums[i])
return res
def nextGreaterElements(self, nums: List[int]) -> List[int]:
"""
寻找右侧第一个大于当前值的数,显然可以使用单调栈来解决,我们可以顺序构建单增栈,在这个过程中由于会 pop 元素所以会得到右侧第一个大于
当前元素的值。由于数组是环形的,根据实践,可以将数组拉长为 2length 来做
时间复杂度:O(N)
空间复杂度:O(N)
:param nums:
:return:
"""
length = len(nums)
stack = []
res = [-1] * length
for i in range(2 * length):
while len(stack) and nums[i % length] > nums[stack[-1]]:
res[stack[-1]] = nums[i % length]
stack.pop()
stack.append(i % length)
return res
def nextGreaterElements_0(self, nums: List[int]) -> List[int]:
"""
反向遍历数组,思考此时构造的还是单增栈吗,答案是,如何使用单减栈即数组中元素单增的话无法满足需求,此时判断单调栈中元素的个数进行
结果收集,同时在 pop 值时进行相等的判断,这很重要⚠️
:param nums:
:return:
"""
length = len(nums)
res = [-1] * length
stack = []
for i in range(2 * length - 1, -1, -1):
while len(stack) and nums[i % length] >= nums[stack[-1]]:
stack.pop()
if len(stack):
res[i % length] = nums[stack[-1]]
stack.append(i % length)
return res
去除重复字母-leetcode-316 🌟🌟🌟
解题思路:
题目因为出现字典序这个关键词,相当于希望整体列表有一定连续的顺序,因此可以考虑用单调栈解决,同时也要注意题目中的限制,是一个值得多做几次的题目。
class Solution:
# 需要保证返回结果的字典序最小,也就是从左向右尽量是递增的,同时不影响相对位置
# 所以我们可以用单减栈即单增数组来做,为了不将某些元素遗漏,可以提前收集每个字母的数量
# 我们发现正是收集每个字母的数量这个做法导致题目最终没有通过,而我们可以通过检查 s 从 i 开始是否还有这个字母来判断
# 这样就不容易出错了
def removeDuplicateLetters(self, s: str) -> str:
length, str_map, res, tmp_map = len(s), {}, "", {}
for i in range(length):
if s[i] in str_map:
str_map[s[i]] = str_map[s[i]] + 1
else:
str_map[s[i]] = 1
for i in range(length):
if s[i] in res:
str_map[s[i]] = str_map[s[i]] - 1
continue
while len(res) > 0 and res[-1] > s[i] and str_map[res[-1]] >= 1:
# while len(res) > 0 and res[-1] > s[i] and res[-1] in s[i:]:
res = res[:-1]
res = res + s[i]
str_map[s[i]] = str_map[s[i]] - 1
return res
最短无序连续子数组-leetcode-581
解题思路
利用两个单调栈分别确定始末:
我们发现题目数组最终会升序排序,很自然的想到了单调栈
以 [2,6,4,8,10,9,15] 为例,正向我们构造一个单减栈,得到 start,注意像 [1, 3, 5, 4, 2]
这种数组,所以我们要遍历完所有元素才得到 start
反向我们构造一个单增栈,得到 end,同理拿到 end
class Solution:
# 我们发现题目数组最终会升序排序,很自然的想到了单调栈
# 时间复杂度 O(N)
# 空间复杂度 O(N)
def findUnsortedSubarray(self, nums: List[int]) -> int:
stack0, stack1, length = [], [], len(nums)
start, end = length - 1, 0
for i in range(length):
while len(stack0) > 0 and stack0[-1][1] > nums[i]:
start = min(start, stack0[-1][0])
stack0.pop()
stack0.append((i, nums[i]))
for i in range(length - 1, -1, -1):
while len(stack1) > 0 and stack1[-1][1] < nums[i]:
end = max(end, stack1[-1][0])
stack1.pop()
stack1.append((i, nums[i]))
# print("=====>", start, end)
return end - start + 1 if end > start else 0
移掉 K 位数字-leetcode-402
解题思路:
这个题之前是使用其他方法做出来的,学习了单调栈之后可以使用单调栈的方法来做。
我们发现解题时总是希望移走排在前面的比较大的数字,所以
- 方法 1:
可以这样去思考这个问题,当第一个数比后一个数大的时候,这个数肯定要移走,否则保留
指针位置不变,再将移到当前位置的数和后一个数比较
如果到最后 k 仍未消零,则将最后的几位删掉即可
时间复杂度 O(N)
空间复杂度 O(1)
- 方法 2:
我们可以用单调栈来解决
用单减栈即单增数组:1 4 pop 4 add 3,pop 3 add 2, pop 2 add 2
10200 1 pop 1 add 0,详情参考下面的代码
时间复杂度 O(N)
空间复杂度 O(N)
class Solution:
# 可以这样去思考这个问题,当第一个数比后一个数大的时候,这个数肯定要移走,否则保留
# 指针位置不变,再将移到当前位置的数和后一个数比较
# 如果到最后 k 仍未消零,则将最后的几位删掉即可
# 时间复杂度 O(N)
# 空间复杂度 O(1)
def removeKdigits1(self, num: str, k: int) -> str:
n, i = len(num), 0
if n <= k:
return '0'
while k > 0 and -1 < i < len(num) - 1:
if num[i] > num[i + 1]:
num = num[1:] if i == 0 else num[0:i] + num[i + 1:]
k = k - 1
i = i - 1 if i >= 1 else i
elif num[i] < num[i + 1]:
i = i + 1
else:
i = i + 1
if k > 0:
num = num[0:len(num) - k]
while num[0] == '0' and len(num) > 1:
num = num[1:]
return num
# 题目要求返回移除 k 个之后最小的数字
# 1432219 如果移除 1 个 那肯定移除高位上的高的数 132219
# 1432219 如果移除 2 个,12219
# 我们发现解题时总是希望移走排在前面的比较大的数字,所以我们可以用单调栈来解决
# 如果用单减栈即单增数组:1 4 pop 4 add 3,pop 3 add 2, pop 2 add 2
# 10200 1 pop 1 add 0
# 第二次练习,使用单减栈去解决问题
def removeKdigits(self, num: str, k: int) -> str:
stack, length, i = [], len(num), 0
if k >= length:
return '0'
while i < length:
while len(stack) > 0 and int(stack[-1]) > int(num[i]):
if k == 0:
break
else:
k = k - 1
stack.pop()
if k == 0:
break
else:
stack.append(num[i])
i = i + 1
# 因为我们已经拍好序了所以无需再反着来一遍
if k == 0:
nums = list(num)
stack.extend(nums[i:])
if k > 0:
# return self.remove_zero(stack0[:len(stack0)-k])
stack = stack[:len(stack) - k]
while len(stack) > 0 and stack[0] == '0':
stack = stack[1:]
return ''.join(stack) if len(stack) > 0 else '0'
柱状图中最大的矩形-leetcode-84
解题思路
-
通常对于单增的数组我们会怎样来计算最大矩形呢,我们会采用总是 pop 出最后一个元素,同时追加宽度的方式,比如 [1,5,6] 实则是 13, 52,6*1 对比
-
有了这个前提我们就可以通过构造单调栈的方式来解题了,单增数组即构造单减栈,同样弹出栈的那些元素可以采用与 1 一样的策略来算最大矩形
时间复杂度:O(N)
空间复杂度:O(N)
class Solution:
def largestRectangleArea(self, heights: List[int]) -> int:
# 单减栈中每个元素包含有当前矩形的高度和最大宽度
stack, length, res = [], len(heights), 0
for i in range(length):
width = 0
while len(stack) > 0 and heights[i] < stack[-1][0]:
width += stack[-1][1]
res = max(res, stack[-1][0] * width)
stack.pop()
stack.append((heights[i], width + 1))
# 对于完全单增的数组即规整的单减栈我们再进行一次比较
width = 0
while len(stack) > 0:
width += stack[-1][1]
res = max(res, stack[-1][0] * width)
stack.pop()
return res
最大矩形-leetcode-85
解题思路
实际可以转化成 84 题求最大矩形面积
时间复杂度:O(M*N)
空间复杂度:O(M*N)
class Solution:
def largestRectangleArea(self, heights: List[int]) -> int:
# 单减栈中每个元素包含有当前矩形的高度和最大宽度
stack, length, res = [], len(heights), 0
for i in range(length):
width = 0
while len(stack) > 0 and heights[i] < stack[-1][0]:
width += stack[-1][1]
res = max(res, stack[-1][0] * width)
stack.pop()
stack.append((heights[i], width + 1))
# 对于完全单增的数组即规整的单减栈我们再进行一次比较
width = 0
while len(stack) > 0:
width += stack[-1][1]
res = max(res, stack[-1][0] * width)
stack.pop()
return res
def maximalRectangle(self, matrix: List[List[str]]) -> int:
width = len(matrix[0])
height = len(matrix)
res = 0
for i in range(width):
matrix[0][i] = int(matrix[0][i])
res = max(res, self.largestRectangleArea(matrix[0]))
for i in range(1, height):
for j in range(width):
if matrix[i][j] == '0':
matrix[i][j] = 0
else:
k = i - 1
matrix[i][j] = int(matrix[i][j])
if matrix[k][j] > 0:
matrix[i][j] = int(matrix[i][j]) + matrix[k][j]
k -= 1
res = max(res, self.largestRectangleArea(matrix[i]))
return res
leetcode-456-132 模式 🌟🌟🌟🌟
这个题符合比较数组中前后元素关系,以及求当前元素右边最大的小于当前元素的值的特征,所以可以使用单调栈来解决,当然也可以使用特殊的数据结构 SortedList 来解决。详细题解参考 github repo
class Solution:
def find132pattern(self, nums: List[int]) -> bool:
"""
换一下思路,如果我们知道 3,其实左侧求最小值,右侧求最靠近 3 的值即可,求最小值,只需要遍历一次即可
如何求某个元素右侧最靠近它但比它小的值呢,如果数组有序当然是使用二分查找,但数组无序,比如 1 3 2 4
4 没有
2 没有
3 是 2,此时栈为 [4,3]
发现了规律,可以使用单调栈,且为单增栈,即数组元素单减
时间复杂度:O(N)
空间复杂度:O(N)
:param nums:
:return:
"""
length = len(nums)
min_arr = []
stack = []
for i in range(length):
if i == 0:
min_arr.append(nums[0])
else:
min_arr.append(min(min_arr[-1], nums[i]))
for i in range(length - 1, -1, -1):
near = float('-inf')
while len(stack) > 0 and stack[-1] < nums[i]:
near = stack[-1]
stack.pop()
if near > min_arr[i]:
return True
stack.append(nums[i])
return False
友情链接🔗:123 模式 334. Increasing Triplet Subsequence
leetcode-1653-使字符串平衡🌟🌟🌟🌟🌟
初读这道题的时候可能会一下子想到这个不就是变形版的求最长的非连续子串吗,所以用这种方法去解题,实际上这种方式时间复杂度为 O(NlogN),使用动态规划或者前缀和来将时间复杂度降为 O(N)
其他栈
适用条件
求最大的 j-i
符合 nums[j]>nums[i]&&j>i
思路:
-
维护一个从索引 0 开始单减的栈,用以标识 i 即坡的左边界,为什么递减,如果递增,a,b 其中 b>a 那么如果 b 是左边界,a 肯定可以也可以做左边界,这也就意味着我们存 b 是没必要的,所以要严格递减。
-
且要从索引 0 开始,如果维护单调栈,那么就不能保证坡最大。
-
得到这样一个单调的栈之后,我们贪心的从后向前遍历原数组,因为我们想让坡的右索引也尽可能的大
- 与此同时如果有符合条件的坡,在我们向前遍历的途中,符合条件的栈中的元素已经没必要再被使用,因为即使复合条件它已经不可能组成最大的坡。
经典例题
leetcode-962-Maximum Width Ramp
def maxWidthRamp_0(self, nums: List[int]) -> int:
"""
暴力法,遍历所有的元素,找到最大的答案
时间复杂度:O(N^2)
空间复杂度:O(1)
:param nums:
:return:
"""
res = 0
length = len(nums)
for i in range(length):
tmp = 0
for j in range(i + 1, length):
if nums[j] >= nums[i]:
tmp = max(tmp, j - i)
res = max(res, tmp)
return res
def maxWidthRamp(self, nums: List[int]) -> int:
"""
优化暴力法,由于是嵌套循环,可以考虑单调栈、双指针、哈希表、分治等方法来进行优化
由于无序,使用对撞指针无效,使用滑动窗口无法固定窗口大小,与 1124. Longest Well-Performing Interval 这个题不同的是它的值不固定
无法用哈希表来确认,分治更不可能
[6, 0, 8, 2, 1, 5]
[6,0]
[9, 8, 1, 0, 1, 9, 4, 0, 4, 1]
[9,8,1,0]
时间复杂度:O(N)
空间复杂度:O(N)
:param nums:
:return:
"""
# 构建一个单减的栈
stack = []
length = len(nums)
for i in range(length):
if i == 0:
stack.append((nums[i], 0))
elif nums[i] < stack[-1][0]:
stack.append((nums[i], i))
res = 0
for i in range(length - 1, -1, -1):
while stack and stack[-1][0] <= nums[i]:
res = max(res, i - stack[-1][1])
stack.pop()
return res
其他的也可以使用排序以及维护一个最小索引来做
class Solution:
def maxWidthRamp(self, nums: List[int]) -> int:
"""
将原数组排序,登记位于左侧的最小的索引
时间复杂度:O(NlogN)
空间复杂度:O(N)
:param nums:
:return:
"""
length = len(nums)
for i in range(length):
nums[i] = (nums[i], i)
nums.sort(key=lambda x: x[0])
res = 0
min_left = length
for i in range(length):
res = max(res, nums[i][1]-min_left)
min_left = min(min_left, nums[i][1])
return res
leetcode-1124-Longest Well-Performing Interval
class Solution:
def longestWPI(self, hours: List[int]) -> int:
"""
参考 962. Maximum Width Ramp 中的做法,本题可以将大于 8 的记为 1,小于 8 的记为 -1,求前缀和后题目转化为求最长的 j-i, 其中
j>i 且 nums[j]>nums[i]
[9,9,6,0,6,6,9]
[1,1,-1,-1,-1,-1,1]
[0,1,2,1,0,-1,-2,-1]
:param hours:
:return:
"""
length = len(hours)
summary = [0]
for i in range(length):
if hours[i] > 8:
summary.append(summary[-1] + 1)
else:
summary.append(summary[-1] - 1)
summary = summary[1:]
stack = []
res = 0
for i in range(length):
if summary[i] > 0:
res = max(res, i + 1)
if i == 0:
stack.append(0)
else:
if summary[i] < summary[stack[-1]]:
stack.append(i)
for i in range(length - 1, -1, -1):
while stack and summary[i] > summary[stack[-1]]:
res = max(res, i - stack[-1])
stack.pop()
return res
也可以使用排序来做,此处不赘述,可以参考github repo