算法学习笔记——特殊数据结构:单调栈
作者:互联网
单调栈Monotonic Stack
- 单调栈本质上就是栈,但在使用栈的过程中,程序逻辑保证栈内的元素是单调的(单调递增或单调递减,具体视情况而定)
- 单调栈用于在数组中维护各元素的左侧/右侧第一个比自己大/小的数,即下一个更大元素(Next Greater Element)问题
- 单调栈时间复杂度为O(N)
- 具体做法是当前元素入栈时,若栈顶元素比当前元素大/小,则将其弹出,从而保证入栈后整个栈单调
单调栈模板
下一个更大元素:给出一个数组,返回一个等长数组,其中各索引出存储着原数组的该处元素的下一个更大元素(不存在则为-1),例如,输入
[2,1,2,4,3]
,返回[4,2,4,-1,-1]
如何用O(n)复杂度解决问题?(相当于只线性扫描一遍数组)
思路:把数组元素想象成站成一队的人,前面的人往后看,视线向上,第一个可见的人就是下一个更大元素(矮的都被高的挡住了,只看得到比自己高、更高、更更高的人…)
单调栈就是模拟这个过程:
- 从后往前扫描元素(倒着入栈,就是正着出栈)
- 每个元素入栈前,必须让栈中比自己小的元素都出栈(排除比自己矮的人),此后栈顶元素就是第一个比自己高的人(空栈代表后面没人比自己高)
- 最终,当前元素入栈,入栈后整个栈必定是单调的,栈顶最小(从而下一个人只看得到高、更高、更更高的人…并重复第2步,排除比自己矮的,留下栈顶为第一个比自己高的)
class Solution:
def nextGreaterElements(self, nums: List[int]) -> List[int]:
"""寻找下一个更大元素
从后往前扫描,同时维护单调栈
模拟每个元素向后看,只能看到高度依次递增的人
当前元素将高度小于自己的人全部出栈,栈顶就留下下一个更大元素"""
ans = [None for _ in range(len(nums))] # 答案
stk = [] # 单调栈
for i in range(len(nums) - 1, -1, -1):
# 当前元素为nums[i]
while len(stk) > 0 and stk[-1] < nums[i]: # 当前元素将高度小于自己的人全部出栈
stk.pop()
ans[i] = stk[-1] if len(stk) > 0 else -1 # 栈顶元素就是下一个更大元素
stk.append(nums[i]) # 入栈
return ans
应用
- LeetCode 496. 下一个更大元素 I(模板题)
- LeetCode 739. 每日温度(模板题,输出下一个更大元素的位置,栈中变为保存下标)
- LeetCode 901. 股票价格跨度(类似上题,维护上一个更大元素的下标,返回两者之间的距离,用虚拟下标-1处理没有上一个更大元素的特殊情况,称为哨兵)
- LeetCode 503. 下一个更大元素 II(题目变为循环数组,可以物理上拼接一个两倍长的数组/在逻辑上求模)
升级变式:
- LeetCode 581. 最短无序连续子数组(隐含的单调栈,希望找出一个子数组,其左侧所有元素 <= 子数组内所有元素 <= 其右侧所有元素,①从左往右遍历,寻找第一个有【右侧下一个更小元素】的位置,则这里是左边界②从右往左遍历,寻找第一个有【左侧下一个更大元素】的位置,则这里是右边界)
- LeetCode 84. 柱状图中最大的矩形(求柱形图中能勾勒出的最大面积,依次枚举每个柱子的高度作为矩形高度,并尽可能向左右延伸,利用单调栈遍历两次求出[某位置左侧/右侧第一个比他小的柱子下标],两者之差作为宽)
ps. 题目数据改为二维01矩阵形式,求最大全1矩形面积,就是【最大子矩阵的大小】的问题,同样可以转为柱形图的问题来解决- LeetCode 316. 去除重复字母/402. 移掉 K 位数字(隐含的单调栈问题,要获得字典序最小/数字最小的子序列,则应该尽量保证序列靠前的的部分单调递增)
部分题解
LeetCode 402. 移掉 K 位数字
给出一个数组,从中挑选k个数字,构成数字序列,求字典序最小的可能序列
最小字典序原则:对于11a5和11b5,前面的数字确定,则当前位上,选择a和b中更小的,字典序也一定最小
思路:
- 从左往右遍历,维护数字序列
- 每次有新数字,考虑要用这个数 / 序列中前一个数 (选择a/b)
- 前一个数>这个数,则应该用这个数 -> 符合单调栈特性:入栈前将更大的出栈
- 但是要注意,也不能无限制的出栈,否则最后可能什么也不剩,故使用del_chance维护还能丢弃的个数(保留k个->最多丢弃L-k个),丢弃机会用完后,后面的只能乖乖拼接,但要注意,前面部分已经保证是可能的最小字典序
- 原数组递增时,最终的栈大小大于k,这时截取前k个即可
class Solution:
def mostCompetitive(self, nums: List[int], k: int) -> List[int]:
"""同LeetCode 402. 移掉 K 位数字
要保留k个,等效于删除L-k个"""
# 对于11a5和11b5,前面的数字确定,则当前位上,选择a和b中更小的那个一定更好
# 从左往右遍历,维护数字序列->序列单调递增最好
# 每次有新数字,考虑是否要丢弃前面的更大数->单调栈
# 最多只能删除L-k次, 将L-k次机会用完后,再后面的数字只能直接拼接
stk = []
del_chance = len(nums) - k
for i, n in enumerate(nums):
# 入栈前删除所有更大的
while del_chance > 0 and stk and stk[-1] > n:
stk.pop()
del_chance -= 1
stk.append(n)
return stk[:k]
LeetCode 316. 去除重复字母
取出字符串s中的重复字符,返回所有可能结果中字典序最小的
分析:
首先,最理想的“最小字典序”是abcdefg...
,实际情况肯定不是这样,但是我们能发现构造答案的策略:如果可以的话,应该尽量让字母递增排列,从而满足“最小字典序”的特性(例如abcd
字典序小于abdc
,后者出现了“递减”,相对而言字典序更大的字符d
出现在了前面)
因此,我们的策略是:从左到右遍历,尽可能构造“递增”的序列 -> 单调栈
这样看来,这题就和之前的问题有相似之处,区别在于这里要保证每个字符出现且仅出现1次,因此入栈前的出栈有限制:仅当后面还有同样的字符时,才能将前一个字符出栈
- 字符入栈有限制:前面没有这个字符,才能入栈
(前面已有该字符,由于是单调栈,前面的字符一定处在一个使整体字典序最小的合理位置,不需要再处理这个字符,如abca
的最后一个a
,前面已是递增的最小字典序排列) - 入栈时,前一个更大字符的出栈有限制:前一个字符在后面还有出现才能删除
from collections import Counter
class Solution:
def removeDuplicateLetters(self, s: str) -> str:
"""同LeetCode 402. 移掉 K 位数字"""
# 前面的数字确定,则当前位上,字母更小的那个一定更好
# 维护单调栈,有新的字母入栈时:若前一个字符比他大,应该考虑丢掉前一个
# (但注意前提:前一个字符在后面还有出现,才能丢弃)
cnt = Counter(s) # 记录当前位置之后,某个字符还会出现几次
inStk = set()
stk = []
for i, ch in enumerate(s):
# 当前字符入栈有限制:前面没有这个字符,才能入栈(考虑bcab的后一个b)
if ch not in inStk:
# 入栈前删除所有更大的
while stk and stk[-1] > ch:
pre_ch = stk[-1]
if cnt[pre_ch] > 0: # 后面还有出现,可以删除
stk.pop()
inStk.remove(pre_ch)
else: # 不能删除
break
stk.append(ch)
inStk.add(ch)
cnt[ch] -= 1
return ''.join(stk)
LeetCode 321. 拼接最大数
给出两个数组,从这两个数组中选出 k个数字,拼接成一个新数组,求可能的字典序最大的数组(拼接时,同一数组内的相对位置要保持不变)
如nums1 = [3, 4, 6, 5],nums2 = [9, 1, 2, 5, 8, 3],k = 5
返回[9, 8, 6, 5, 3]
基础:Python提供数组的大小比较,比较大小标准也是字典序
如[0,1,0]<[0,1,7],[]<[1]
分析:
- 拆解问题,分别解决:将取k个拆分为子问题 [ 从nums1中取i个、nums2中取k-i个 ]
- 尝试所有可能的i,取最优解
- 如何从数组中取k个数,其字典序最大:
这是上文解决过的问题 - 分别从两数组中取出可能的最大字典序的序列,然后合并为一个最大字典序的序列
- 得到两个序列后,如何合并出最大字典序的数组:
每次从两序列开头取更大的,注意如果相等要考虑后面的部分,优先揭露更大的(如[0,1,0]<[0,1,7],应先取后一个的0,从而露出更大的1,7可供选择)
class Solution:
def maxNumber(self, nums1: List[int], nums2: List[int], k: int) -> List[int]:
# 将取k个拆分:从nums1中取i个+nums2中取k-i个,然后将两个结果合并为最大的
# 尝试所有可能,取最大值
def chooseNum(nums, k):
"""从nums中取k个数,使得其最大(等价于删除L-k个数)
同LeetCode 1673. 找出最具竞争力的子序列"""
if k == len(nums):
return nums
stk = []
del_chance = len(nums) - k
for i, n in enumerate(nums):
# 入栈前删除所有更大的
while del_chance > 0 and stk and stk[-1] < n:
stk.pop()
del_chance -= 1
stk.append(n)
return stk[:k]
def merge(n1, n2):
"""合并两个数组,得到最大的答案
策略:每次取出最大的拼接"""
res = []
l1, l2 = len(n1), len(n2)
i, j = 0, 0
while i < l1 and j < l2:
if n1[i] > n2[j]:
res.append(n1[i])
i += 1
elif n1[i] < n2[j]:
res.append(n2[j])
j += 1
else: # 两数相等,应该看后面的部分,优化揭露更大的(如[0,1,0]<[0,1,7],应取后一个)
if n1[i:] > n2[j:]:
res.append(n1[i])
i += 1
else:
res.append(n2[j])
j += 1
if i < l1:
res += n1[i:]
if j < l2:
res += n2[j:]
return res
L1, L2 = len(nums1), len(nums2)
ans = []
for i in range(0, k + 1):
j = k - i
if i <= L1 and j <= L2:
now = merge(chooseNum(nums1, i), chooseNum(nums2, j))
if not ans or now > ans:
ans = now
return ans
标签:入栈,nums,元素,stk,算法,数组,数据结构,单调 来源: https://blog.csdn.net/Insomnia_X/article/details/122412534