栈和队列的高级应用--单调栈

继《线性表中的数组、链表、栈和队列的概念和基本应用》,本文讲解栈和队列的高级应用。

单调栈

双端队列

滑动窗口

一、单调栈

介绍:单调 + 栈,因此其同时满足两个特性:单调性、栈的特点。

单调性:单调栈里面所存放的数据是有序的(单调递减或者递增)。

栈:后进先出。

因其满足单调性和每个数字只会入栈一次,所以可以在时间复杂度0(n)的情况下解决一些问题。

下图是单调栈图解,栈内数字满足单调性,且满足栈的后进先出的特性。

【例题】

leetcode 739 每日温度【中等】

给定一个整数数组 temperatures ,表示每天的温度,返回一个数组 answer ,其中 answer[i] 是指在第 i 天之后,才会有更高的温度。如果气温在这之后都不会升高,请在该位置用 0 来代替。

 

示例 1:

输入: temperatures = [73,74,75,71,69,72,76,73]
输出: [1,1,4,2,1,1,0,0]


示例 2:

输入: temperatures = [30,40,50,60]
输出: [1,1,1,0]


示例 3:

输入: temperatures = [30,60,90]
输出: [1,1,0]
 

提示:

1 <= temperatures.length <= 105
30 <= temperatures[i] <= 100

分析

使用单调栈:判断是否需要单调栈,如果需要找到左边或者右边第一个比当前位置的数大或者小,则可以考虑使用单调栈。

这里可以维护一个存储下标的单调栈,从栈底到栈顶的下标对应的温度列表中的温度依次递减。如果一个下标在单调栈里,则表示尚未找到下一次温度更高的下标

正向遍历温度列表。对于温度列表中的每个元素temperatures[i],如果栈为空,则直接将i进栈,如果栈不为空,则比较栈顶元素prevIndex对应的温度temperatures[prevIndex]和当前温度temperatures[i],如果temperatures[i] > temperatures[prevIndex],则将prevIndex移除,并将prevIndex对应的等待天数赋为 i - prevIndex重复上述操作直到栈为空或者栈顶元素对应的温度小于等于当前温度,然后将 i 进栈

为什么可以在弹栈的时候更新 ans[prevIndex] 呢?因为在这种情况下,即进栈的i对应的 temperatures[i] 一定是 temperatures[prevIndex] 右边第一个比它大的元素,试想如果 prevIndex 和 i 有比它大的元素,假设下标为 j ,那么 prevIndex 一定会在下标 j 的那一轮被弹掉。

由于单调栈满足从栈底到栈顶元素对应的温度递减,因此每次有元素进栈时,会将温度更低的元素全部移除,并更新出栈元素对应的等待天数,这样可以确保等待天数一定是最小的。

以下用一个具体例子帮助读者理解单调栈。对于温度列表[73,74,75,71,69,72,76,73],单调栈stack的初始状态为空,答案ans的初始状态为 [0,0,0,0,0,0,0,0]按照以下步骤更新单调栈和答案,其中单调栈内的元素都是下标,括号内的数字表示下标在温度列表中对应的温度。

设温度数组为a,从左向右依次遍历数组a,假设当前遍历到数组位置为j

(0)当 i = 0 时,单调栈为空,因此将0进栈。

  • stack=[0(73)]  # 括号内的数字表示下标在温度列表中对应的温度

  • ans=[0,0,0,0,0,0,0,0]

(1)当 i = 1 时,由于74 > 73,因此移除栈顶元素0,赋值ans[0] := 1 - 0,将i=1进栈。

  • stack=[1(74)]

  • ans=[1,0,0,0,0,0,0,0]

(2)当 i = 2 时,由于75 > 74,因此移除栈顶元素1,赋值ans[1] := 2 - 1, 将i=2进栈。

  • stack=[2(75)]

  • ans=[1,1,0,0,0,0,0,0]

(3)当 i = 3 时,由于71 < 75,此时不需要移除元素,将i=3进栈。

  • stack=[2(75),3(71)]

  • ans=[1,1,0,0,0,0,0,0]

(4)当 i = 4 时,由于64 < 71,此时不需要移除元素,将i=4进栈。

  • stack=[2(75),3(71),4(69)]

  • ans=[1,1,0,0,0,0,0,0]

(5)当 i = 5 时,由于72大于69和71,因此依次移除栈顶元素4和3,赋值ans[4] := 5-4 和ans[3] := 5-3,将i=5进栈。

  • stack=[2(75),5(72)]

  • ans=[1,1,0,2,1,0,0,0]

(6)当 i = 6 时,由于76大于72和75,因此依次移除栈顶元素 52,赋值 ans[5]:=6-5 和 ans[2]:=6-2,将i=6进栈。

  • stack=[6(76)]

  • ans=[1,1,4,2,1,1,0,0]

(7)当 i=7 时,由于73小于76,因此将i=7进栈。

  • stack=[6(76),7(73)]

  • ans=[1,1,4,2,1,1,0,0]

class Solution:
    def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
        length = len(temperatures)
        ans = [0] * length
        stack = []
        for i in range(length):
            temperature = temperatures[i]
            while stack and temperature > temperatures[stack[-1]]:
                prev_index = stack.pop()
                ans[prev_index] = i - prev_index
            stack.append(i)
        return ans

# 复杂度分析
# 时间复杂度:O(n),其中 n 是温度列表的长度。正向遍历温度列表一遍,对于温度列表中的每个下标,最多有一次进栈和出栈的操作。
# 空间复杂度:O(n),其中 n 是温度列表的长度。需要维护一个单调栈存储温度列表中的下标。

另外一个版本的单调栈描述

建立单调(非增)栈,栈存放每天的温度,为了方便计算天数,栈中存储的是每天温度在数组中的下标,我们可以通过该下标得到对应天的温度。

设温度数组为a,从左向右依次遍历数组a,假设当前遍历到的数组位置为j,则对应当天温度为a[j],设栈顶元素位置为i,对应当天的温度为a[i],分两种情况讨论:

(1)如果 a[j] > a[i],执行以下三步。

表示第j天温度比第i天更暖和,则第i天的答案为j-i,那么可以将栈顶元素弹出;

重复检查栈顶元素,直至栈顶元素的 a[j] <= a[i] 或者 栈为空;

将j入栈。

(2)如果 a[j] <= a[i]:

表明截止第i天没有找到更加暖和的一天,无需对栈操作;

将j入栈。

 

之后继续遍历温度数组a,考虑下一天,直至结束。

遍历结束,若栈不为空,则说明栈内的那一天之后找不到更暖和的一天了,记为0。

 

leetcode402. 移掉K位数字【中等】

我们从一个简单的问题入手,识别一下这种题的基本形式和套路,为之后的三道题打基础。

【题目描述】

给定一个以字符串表示的非负整数  num,移除这个数中的 k 位数字,使得剩下的数字最小。

注意:

num 的长度小于 10002 且  ≥ k。
num 不会包含任何前导零。

示例 1 :

输入: num = "1432219", k = 3
输出: "1219"
解释: 移除掉三个数字 4, 3, 和 2 形成一个新的最小的数字 1219。


示例 2 :

输入: num = "10200", k = 1
输出: "200"
解释: 移掉首位的 1 剩下的数字为 200. 注意输出不能有任何前导零。


示例 3 :

输入: num = "10", k = 2
输出: "0"
解释: 从原数字移除所有的数字,剩余为空就是 0。

【前置知识】数学

【思路】这道题让我们从一个字符串数字中删除k个数字,使得剩下的数最小。也就是说,我们要保持原来的数字的相对位置不变

以题目中的num=1432219,k=3为例,我们需要返回一个长度为4的字符串,问题在于:我们怎么才能求出这四个位置依次是什么呢?

 

暴力法的话,我们需要枚举Cn(n - k)     种序列(其中n为数字长度),并逐个比较最大。这个时间复杂度是指数级别的,必须进行优化。

一个思路是:

从左往右遍历;

对于每一个遍历到的元素,我们决定是丢弃还是保留。

问题的关键是:我们怎么知道,一个元素是应该保留还是丢弃呢?

这里有一个前置知识:对于两个数123a456和123b456,如果a > b,那么数字123a456大于数字123b456,否则二者是小于等于的关系。所以,两个相同位数的数字大小关系取决于第一个不同的数的大小。

因此我们的思路就是:

从左到右遍历;

对于遍历到的元素,我们选择保留;

但是我们可以选择性丢弃前面相邻的元素;

丢弃与否的依据如上的前置知识中阐述的方法。

以题目中的num=1432219,k=3为例的图解过程如下:

 

由于没有左侧相邻元素,因此没办法丢弃。

 

由于4比左侧相邻的1大。如果选择丢弃左侧的1,那么会使得剩下的数字更大(开头的数从1变成了4)。因此我们依然选择不丢弃。

 

由于3比左侧相邻的4小。如果选择丢弃左侧的4,那么会使得剩下的数字更小(开头的数从4变成了3)。因此我们选择丢弃。

。。。

后面的思路类似,就不继续分析了。

然而需注意的是,如果给定的数字是一个单调递增的数字,那么我们的算法会永远选择不丢弃。这个题目中要求的,我们要永远确保丢弃k个数字是相矛盾的。

一个简单的思路就是:

每次丢弃一次,k减去1。当k减到0,可以提前终止遍历;

而当遍历完成,如果k仍然大于0,不妨假设最终还剩下x个需要丢弃,那么我们需要选择删除末尾x个元素。

上述思路可行,但是稍显复杂。

此时需要把思路逆转过来。刚才我们的关注点一直是丢弃,题目要求我们丢弃k个。反过来说,不就是让我们保留n-k个元素么?其中n为数字长度。那么我们只需要按照上面的方法遍历完成之后,再截取前n-k个元素即可。

按照上面的思路,我们来选择数据结构。由于我们需要保留和丢弃相邻的元素,因此使用栈这种在一端进行添加和删除的数据结构是再合适不过的了。

class Solution:
    def removeKdigits(self, num: str, k: int) -> str:
        stack = [] # 初始化要使用的栈
        remain = len(num) - k # 假设要去除的数字个数为k,那么需要保留的数字遍为len(num)-k
        for digit in num: # 遍历
            while k and stack and stack[-1] > digit:
                # 当k尚且不为0,且栈不为空,且栈顶元素大于数字
                stack.pop() # 将栈顶元素出栈
                k -= 1 # 栈内一个元素出栈,k自然减1
            stack.append(digit) # 如果while循环条件不满足(stack为空),数字直接进栈。
        return ''.join(stack[:remain]).lstrip('0') or '0' # 返回结果(首部的0丢掉)。


# 复杂度分析
# 时间复杂度:虽然内层还有一个 while 循环,但是由于每个数字最多仅会入栈出栈一次,因此时间复杂度仍然为O(N),其中 N 为数字长度。
# 空间复杂度:我们使用了额外的栈来存储数字,因此空间复杂度为O(N),其中 N 为数字长度。

leetcode 316. 去除重复字母【中等】

给你一个字符串 s ,请你去除字符串中重复的字母,使得每个字母只出现一次。需保证 返回结果的字典序最小(要求不能打乱其他字符的相对位置)。

 

示例 1:

输入:s = "bcabc"
输出:"abc"


示例 2:

输入:s = "cbacdcbc"
输出:"acdb"
 

提示:

1 <= s.length <= 104
s 由小写英文字母组成

注意:该题与 1081 https://leetcode-cn.com/problems/smallest-subsequence-of-distinct-characters 相同

【分析】

前置知识:字典序、数学

思路:与上面题目不同,这道题没有一个全局的删除次数k。而是对于每一个在字符串s中出现的字母c都有一个k值。这个k是c出现的次数-1。

我们首先要做的就是计算每一个字符的k,可以用一个字典来描述这种关系,其中key为字符c,value为其出现的次数。

具体做法:

*建立一个字典。其中key为字符c,value为其出现的剩余次数。

*从左往右遍历字符串,每次遍历到一个字符,其剩余出现的次数-1。

*对于每一个字符,如果其对应的剩余出现次数大于1,我们可以选择丢弃(也可以选择不丢弃),如果剩余出现次数小于等于1则不可以丢弃。

*是否丢弃的标准和上面题目类似。如果栈中相邻的元素字典序更大,那么我们选择丢弃相邻的栈中元素

还记得上面题目的边界条件吗?如果栈中剩下的元素大于n-k,我们选择截取前n-k个数字。然而本题中的k是分散在各个字符中的,因此这种思路不可行。

不过不必担心。由于题目要求只各个字符只出现一次。我们可以在遍历的时候简单地判断是否在栈里面即可。

class Solution:
    def removeDuplicateLetters(self, s: str) -> str:
        stack = [] # 初始化空栈
        remain_counter = collections.Counter(s) # 通过collections工具包统计字符串中每个字符出现的次数。       
        for c in s:  # 遍历每个字符
            if c not in stack: # 如果字符不在栈中
                while stack and c < stack[-1] and remain_counter[stack[-1]] > 0: # 进一步判断是否满足栈非空且栈顶元素出现次数不止一次
                    stack.pop() # 若是,出栈一个重复字符
                stack.append(c) # 如果字符不在栈中,也不满足while条件,直接将字符入栈             
            remain_counter[c] -= 1 # 字符c遍历一轮之后,其出现次数对应减少1   
        return ''.join(stack) # 返回最终栈中剩余的不带重复的字符串结果

# 复杂度分析
# 时间复杂度:由于判断当前字符是否在栈上存在需要 O(N) 的时间,因此总的时间复杂度就是 O(N ^ 2),其中 N 为字符串长度。
# 空间复杂度:我们使用了额外的栈来存储数字,因此空间复杂度为 O(N),其中 N 为字符串长度。

 

查询给定字符是否在一个序列中存在的方法。根本上来说,有两种可能:

有序序列:可以二分法,时间复杂度大致是O(N)。

无序序列:可以使用遍历的方式,最坏的情况下时间复杂度为O(N)。我们也可以使用空间换时间的方式,使用N的空间换取O(1)的时间复杂度。

由于本题中的stack并不是有序的,因此我们的优化点考虑空间换时间。而由于每种字符仅出现一次,这里使用hashset即可。

class Solution:
    def removeDuplicateLetters(self, s: str) -> str:
        stack = [] # 初始化空栈
        seen = set() # 构建哈希表
        remain_counter = collections.Counter(s) # 通过collections工具包统计字符串中每个字符出现的次数。       
        for c in s:  # 遍历每个字符
            if c not in seen: # 如果字符表中
                while stack and c < stack[-1] and remain_counter[stack[-1]] > 0: # 进一步判断是否满足栈非空且栈顶元素出现次数不止一次
                    seen.discard(stack.pop()) 
                seen.add(c) 
                stack.append(c) # 如果字符不在栈中,也不满足while条件,直接将字符入栈             
            remain_counter[c] -= 1 # 字符c遍历一轮之后,其出现次数对应减少1   
        return ''.join(stack) # 返回最终栈中剩余的不带重复的字符串结果

# 复杂度分析
# 时间复杂度: O(N),其中 N 为字符串长度。
# 空间复杂度:我们使用了额外的栈和hashset来存储数字,因此空间复杂度为 O(N),其中 N 为字符串长度。
# LeetCode 《1081. 不同字符的最小子序列》 和本题一样,不再赘述。

leetcode 321. 拼接最大数【困难】

给定长度分别为 m 和 n 的两个数组,其元素由 0-9 构成,表示两个自然数各位上的数字。现在从这两个数组中选出 k (k <= m + n) 个数字拼接成一个新的数,要求从同一个数组中取出的数字保持其在原数组中的相对顺序。

求满足该条件的最大数。结果返回一个表示该最大数的长度为 k 的数组。

说明: 请尽可能地优化你算法的时间和空间复杂度。

示例 1:

输入:
nums1 = [3, 4, 6, 5]
nums2 = [9, 1, 2, 5, 8, 3]
k = 5
输出:
[9, 8, 6, 5, 3]
示例 2:

输入:
nums1 = [6, 7]
nums2 = [6, 0, 4]
k = 5
输出:
[6, 7, 6, 0, 4]
示例 3:

输入:
nums1 = [3, 9]
nums2 = [8, 9]
k = 3
输出:
[9, 8, 9]

【分析】

前置知识:分治;数学

思路:和题目402移掉k位数字类似,只不过这一次是两个数组,而不是一个,并且是求最大数。

最大最小是无关紧要的,关键在于是两个数组,并且要求从两个数组选取的元素个数加起来一共是k个。

然而在一个数组中取k个数字,并保持其最小(或者最大),我们已经学会了。但是如果问题扩展到两个数组,会有什么变化呢?

实际上,问题本质并没有发生变化。假设我们从n数组ums1中取了k1个,从数组nums2中取了k2个,其中k1 + k2 = k。而k1和k2这两个子问题我们是会解决的。由于这两个子问题是相互独立的,因此我们只需要分别求解,然后将结果合并即可。

例如k1和k2个数字已经取出来了。那么剩下要做的就是将这个长度分别为k1和k2的数字,合并成一个长度为k的大的数组。

以题目的nums1=[3,4,6,5]; nums2=[9,1,2,5,8,3]; k=5为例,假如我们从nums1中取出1个数字,那么要从nums2中取出4个数字。

运用402题目的方法,计算出应该取nums1的数字‘6’,应该取nums2的‘9,5,8,3’。如何将‘6’和‘9,5,8,3’组合,使得结果数字尽可能大,并且同时保持相对位置不变呢?

实际上这个过程有点类似归并排序中的“”,而上面我们分别计算nums1和nums2的最大数的过程类似归并排序中的“”。

 

 

我们将从nums1中挑选的k1个元素组成的数组称之为A,将从nums2挑选的k2个元素组成的数组称之为B。

def merge(A, B):
    ans = []
    while A or B: # 只要有数组不为空,就继续遍历
        bigger = A if A > B else B # 谁数值大谁赋值给bigger
        ans.append(bigger[0]) # 将上一步得到的bigger放入ans列表
        bigger.pop(0) # 同时将bigger置空,不断循环
    return ans # 返回循环完毕之后的ans

需要说明一下。在很多编程语言中,如果A和B是两个数组,当且仅当A的首个元素字典序大于B的首个元素,A>B返回True,否则返回False。

比如:

A = [1,2]
B = [2]
A < B # True

A = [1,2]
B = [1,2,3]
A < B # False

以合并 [6] 和 [9,5,8,3]为例,图解过程如下:

具体算法:

(1)从 nums1 中 取 min(i, len(nums1)) 个数形成新的数组 A(取的逻辑同第一题),其中 i 等于 0,1,2, ... k。
(2)从 nums2 中 对应取 min(j, len(nums2)) 个数形成新的数组 B(取的逻辑同第一题),其中 j 等于 k - i。
(3)将 A 和 B 按照上面的 merge 方法合并
上面我们暴力了 k 种组合情况,我们只需要将 k 种情况取出最大值即可。

代码:

class Solution:
    def maxNumber(self, nums1: List[int], nums2: List[int], k: int) -> List[int]:

        def pick_max(nums, k):
       # 如上面题目leetcode402. 移掉K位数字,该函数功能同该题目,可以参考复盘。 stack
= [] drop = len(nums) - k for num in nums: while drop and stack and stack[-1] < num: stack.pop() drop -= 1 stack.append(num) return stack[:k] def merge(A, B): ans = [] while A or B: bigger = A if A > B else B ans.append(bigger[0]) bigger.pop(0) return ans return max(merge(pick_max(nums1, i), pick_max(nums2, k-i)) for i in range(k+1) if i <= len(nums1) and k-i <= len(nums2)) # 复杂度分析 # 时间复杂度:pick_max 的时间复杂度为 O(M + N) ,其中 M 为 nums1 的长度,N 为 nums2 的长度。 merge 的时间复杂度为 O(k),再加上外层遍历所有的 k 中可能性。因此总的时间复杂度为 O(k2 * (M + N))。 # 空间复杂度:我们使用了额外的 stack 和 ans 数组,因此空间复杂度为 O(max(M, N, k)),其中 M 为 nums1 的长度,N 为 nums2 的长度。

【总结】

这3道题目都是删除或者保留若干字符,使得剩下的数字最小(或者最大)或字典序最小(或者最大)。而解决问题的前提是要有一定数学前提。而基于这个数学前提,我们贪心地删除栈中相邻的字符。如果你学会了这个套路,那么这三道题应该都可以轻松解决。

316.去除重复字母。我们使用hashmap代替了数组的遍历查找,属于典型的空间换时间方式,可以认识到数据结构的灵活使用是多么重要。背后的思路是怎么样的?为何想到空间换时间的方式,文中也进行了详细说明,这都是值得思考的问题。然而实际上,这些题目中使用的栈也都是空间换时间的思想。下次碰到需要空间换时间的场景,是否能够想到这里介绍的栈和哈希表呢?

321.拼接最大数。则需要我们能够对问题进行分解,这绝对不是一件简单的事情。但是对于难以解决的问题进行分解是一件很重要的技能,希望大家能够通过这道题加深这种分治思想的理解。

【再加点习题】

leetcode 496. 下一个更大元素I【简单】

nums1 中数字 x 的 下一个更大元素 是指 x 在 nums2 中对应位置 右侧 的 第一个 比 x 大的元素。

给你两个 没有重复元素 的数组 nums1 和 nums2 ,下标从 0 开始计数,其中nums1 是 nums2 的子集。

对于每个 0 <= i < nums1.length ,找出满足 nums1[i] == nums2[j] 的下标 j ,并且在 nums2 确定 nums2[j] 的 下一个更大元素 。如果不存在下一个更大元素,那么本次查询的答案是 -1 。

返回一个长度为 nums1.length 的数组 ans 作为答案,满足 ans[i] 是如上所述的 下一个更大元素 。

 

示例 1:

输入:nums1 = [4,1,2], nums2 = [1,3,4,2].
输出:[-1,3,-1]
解释:nums1 中每个值的下一个更大元素如下所述:
- 4 ,用加粗斜体标识,nums2 = [1,3,4,2]。不存在下一个更大元素,所以答案是 -1 。
- 1 ,用加粗斜体标识,nums2 = [1,3,4,2]。下一个更大元素是 3 。
- 2 ,用加粗斜体标识,nums2 = [1,3,4,2]。不存在下一个更大元素,所以答案是 -1 。


示例 2:

输入:nums1 = [2,4], nums2 = [1,2,3,4].
输出:[3,-1]
解释:nums1 中每个值的下一个更大元素如下所述:
- 2 ,用加粗斜体标识,nums2 = [1,2,3,4]。下一个更大元素是 3 。
- 4 ,用加粗斜体标识,nums2 = [1,2,3,4]。不存在下一个更大元素,所以答案是 -1 。
 

提示:

1 <= nums1.length <= nums2.length <= 1000
0 <= nums1[i], nums2[i] <= 104
nums1和nums2中所有整数 互不相同
nums1 中的所有整数同样出现在 nums2 中
 

进阶:你可以设计一个时间复杂度为 O(nums1.length + nums2.length) 的解决方案吗?

【复习】

栈(stack)是一种很简单的数据结构,先进后出的逻辑顺序,符合某些问题的特点,比如说函数调用栈。

单调栈实际上就是栈,只是利用了一些巧妙的逻辑,使得每次新元素入栈后,栈内的元素保持有序(单调递增或达单调递减)。

听起来有点像堆(heap)?不是的,单调栈用途不太广泛,只处理一种典型的问题,叫做next greater element。本文用讲解单调队列的算法模版解决这类问题,并且探讨处理循环数组的策略。

首先讲解next greater element的原始问题:给你一个数组,返回一个等长的数组,对应索引存储着下一个更大元素,如果没有更大的元素,就存-1。举例:

给你一个数组 [2,1,2,4,3],你返回数组 [4,2,4,-1,-1]。

解释:第一个 2 后面比 2 大的数是 4; 1 后面比 1 大的数是 2;第二个 2 后面比 2 大的数是 4; 4 后面没有比 4 大的数,填 -1;3 后面没有比 3 大的数,填 -1。

这道题的暴力解法很好想到,就是对每个元素之后的元素都进行扫描,找到第一个比它大的元素就行了。但是暴力解答时间复杂度为O(n2)。

这个问题可以这样抽象思考:把数组的元素想象成并列站立的人,元素大小想象成人的身高。这些人面对你站成一列,如何求元素“2”的next greater element呢?很简单,若能看到元素“2”,那么他后面可见的第一个人就是“2”的next greater element,因为比“2”小的元素的身高不够,都被“2”挡住了,那么第一个露出来的就是答案。

 

 

vector<int> nextGreaterElement(vector<int>& nums) {
    vector<int> ans(nums.size()); // 存放答案的数组
    stack<int> s;
    for (int i = nums.size() - 1; i >= 0; i--) { // 倒着往栈里放
        while (!s.empty() && s.top() <= nums[i]) { // 判定个子高矮
            s.pop(); // 矮个起开,反正也被挡着了。。。
        }
        ans[i] = s.empty() ? -1 : s.top(); // 这个元素身后的第一个高个
        s.push(nums[i]); // 进队,接受之后的身高判定吧!
    }
    return ans;
}

以上就是单调栈解决问题的模版。for循环要从后往前扫描元素,因为我们借助的是栈的结构,倒着入栈,其实是正着出栈。while循环是把两个“高个子”元素之间的元素排除,因为它们的存在没有意义,前面挡着更高的元素,所以它们不可能被作为后续进来的元素的next greater element了。

这个算法的时间复杂度不是那么直观,如果你看到 for 循环嵌套 while 循环,可能认为这个算法的复杂度也是 O(n2),但是实际上这个算法的复杂度只有 O(n)。

分析它的时间复杂度,要从整体来看:总共有n个元素,每个元素都被push入栈了一次,而最多会被pop一次,没任何冗余操作。所以总的计算规模是和元素规模规模n成正相关的,也就是O(n)的复杂度。

现在,你已经掌握了单调栈的使用技巧,来一个简单的变形来加深一下理解。

给你一个数组T=[73, 74, 75, 71, 69, 72, 76, 73],这个数组存放的是近几天的天气气温(这气温是铁板烧?不是的,这里用的华氏度)。你返回一个数组,计算:对于每一天,你还要至少等多少天才能等到一个更暖和的气温;如果等不到那一天,填 0 。

举例:给你 T = [73, 74, 75, 71, 69, 72, 76, 73],你返回 [1, 1, 4, 2, 1, 1, 0, 0]。

解释:第一天 73 华氏度,第二天 74 华氏度,比 73 大,所以对于第一天,只要等一天就能等到一个更暖和的气温。后面的同理。

你已经对 Next Greater Number 类型问题有些敏感了,这个问题本质上也是找 Next Greater Number,只不过现在不是问你 Next Greater Number 是多少,而是问你当前距离 Next Greater Number 的距离而已。

 

 

相同类型的问题,相同的思路,直接调用单调栈的算法模版,稍作改动就可以了,直接上代码吧:

vector<int> dailyTemperatures(vector<int>& T) {
    vector<int> ans(T.size());
    stack<int> s; // 这里放元素索引,而不是元素
    for (int i = T.size() - 1; i >= 0; i--) {
        while (!s.empty() && T[s.top()] <= T[i]) {
            s.pop();
        }
        ans[i] = s.empty() ? 0 : (s.top() - i); // 得到索引间距
        s.push(i); // 加入索引,而不是元素
    }
    return ans;
}

单调栈讲解完毕。下面开始另一个重点:如何处理「循环数组」。

同样是 Next Greater Number,现在假设给你的数组是个环形的,如何处理?

给你一个数组 [2,1,2,4,3],你返回数组 [4,2,4,-1,4]。拥有了环形属性,最后一个元素 3 绕了一圈后找到了比自己大的元素 4 。

首先,计算机的内存都是线性的,没有真正意义上的环形数组,但是我们可以模拟出环形数组的效果,一般是通过 % 运算符求模(余数),获得环形特效:

int[] arr = {1,2,3,4,5};
int n = arr.length, index = 0;
while (true) {
    print(arr[index % n]);
    index++;
}

明确问题,问题就已经解决了一半了。我们可以考虑这样的思路:将原始数组“翻倍”,就是在后面再接一个原始数组,这样的话,按照之前“比身高”的流程,每个元素不仅可以比较自己右边的元素,而且也可以和左边的元素比较了。

 

 

怎么实现呢?你当然可以把这个双倍长度的数组构造出来,然后套用算法模版,但是,我们可以不用构造新数组,而是利用循环数组的技巧来模拟。直接看代码:

vector<int> nextGreaterElements(vector<int>& nums) {
    int n = nums.size();
    vector<int> res(n); // 存放结果
    stack<int> s;
    // 假装这个数组长度翻倍了
    for (int i = 2 * n - 1; i >= 0; i--) {
        while (!s.empty() && s.top() <= nums[i % n])
            s.pop();
        res[i % n] = s.empty() ? -1 : s.top();
        s.push(nums[i % n]);
    }
    return res;
}

【本题解法】

暴力解法:

class Solution:
    def nextGreaterElement(self, nums1: List[int], nums2: List[int]) -> List[int]:
        m, n = len(nums1), len(nums2)
        res = [0] * m
        for i in range(m):
            j = nums2.index(nums1[i])
            k = j + 1
            while k < n and nums2[k] < nums2[j]:
                k += 1
            res[i] = nums2[k] if k < n else -1
        return res

单调栈+哈希表:

思路:可以先处理nums2,使查询nums1中的每个元素在nums2中对应位置的右边的第一个更大的元素时不需要再遍历nums2。于是我们讲题目分解为两个子问题:

第一个子问题:如何更高效的计算nums2中每个元素右边的第一个更大的值;

第二个子问题:如何存储第一个子问题的结果。

class Solution:
    def nextGreaterElement(self, nums1: List[int], nums2: List[int]) -> List[int]:
        res = {}
        stack = []
        for num in reversed(nums2):
            while stack and num >= stack[-1]:
                stack.pop()
            res[num] = stack[-1] if stack else -1
            stack.append(num)
        return [res[num] for num in nums1]

算法:我们可以使用单调栈来解决第一个子问题。倒序遍历nums2,并用单调栈维护一个当前位置右边的更大元素列表,从栈底到栈顶的元素是单调递减的。

具体地,每次我们移动到数组中一个新的位置i,就将当前单调栈中所有小于nums2[i]的元素弹出栈,当前位置右边的第一个更大的元素即为栈顶元素,如果栈为空则说明当前位置右边没有更大的元素。随后我们将位置i的元素入栈。

因为题目规定了nums2是没有重复元素的,所以我们可以使用哈希表来解决第 2 个子问题,将元素值与其右边第一个更大的元素值的对应关系存入哈希表。

细节:因为在这道题中我们只需要用到nums2中元素的顺序而不需要用到下标,所以栈中直接存储nums2的元素值即可。

复杂度分析

时间复杂度:O(m+n),其中m是nums1的长度,n是nums2的长度。我们需要遍历nums2以计算nums2中每个元素右边的第一个更大的值;需要遍历nums1以生成查询结果

空间复杂度:O(n),用于存储哈希表。

 

leetcode 503. 下一个更大元素II【中等】

给定一个循环数组 nums ( nums[nums.length - 1] 的下一个元素是 nums[0] ),返回 nums 中每个元素的 下一个更大元素 。

数字 x 的 下一个更大的元素 是按数组遍历顺序,这个数字之后的第一个比它更大的数,这意味着你应该循环地搜索它的下一个更大的数。如果不存在,则输出 -1 。

示例 1:

输入: nums = [1,2,1]
输出: [2,-1,2]
解释: 第一个 1 的下一个更大的数是 2;
数字 2 找不到下一个更大的数;
第二个 1 的下一个最大的数需要循环搜索,结果也是 2。


示例 2:

输入: nums = [1,2,3,4,3]
输出: [2,3,4,-1,4]
 

提示:

1 <= nums.length <= 104
-109 <= nums[i] <= 109

【方法】单调栈+循环数组

单调栈中保存下标,从栈底到栈顶的下标在数组nums中对应的值是单调不升的。

每次我们移动到数组中一个新的位置i,我们就将当前单调栈中所有对应值小于nums[i]的下标弹出单调栈,这些值的下一个更大元素即为nums[i](证明很简单:如果有更靠前的更大元素,那么这些位置将被提前弹出栈)。随后我们将位置i入栈。

注意到只遍历一次序列是不够的,例如序列[2,3,1],最后单调栈中将剩余[3, 1],其中元素[1]的下一个更大元素还是不知道的。

一个朴素的思想是,我们可以把这个循环数组拉直,即复制该序列的前n-1个元素拼接在原序列的后面。这样我们就可以将这个新序列当作普通序列,用上文的方法来处理。

在本题中,实际处理时不需要显性地将循环数组拉直,而只需要在处理时对下标取模即可

class Solution:
    def nextGreaterElements(self, nums: List[int]) -> List[int]:
        n = len(nums) # 取数组长度n
        ret = [-1] * n # 初始化一个长度等于数组长素的结果列表ret
        stack = list() # 定义空栈,用于存放数组中满足条件的元素 的 下标
        for i in range(n * 2 - 1): # 循环遍历数组nums
            while stack and nums[i%n] > nums[stack[-1]]: # 若栈不为空,并且数组元素大于栈顶元素
                ret[stack.pop()] = nums[i % n] # 栈中对应元素下标弹出,同时将数组当前元素赋值给ret结果列表。
            stack.append(i % n) # 若数组元素不满足while条件,直接将其入栈。
        return ret

# 复杂度分析
# 时间复杂度: O(n),其中 n 是序列的长度。我们需要遍历该数组中每个元素最多 2 次,每个元素出栈与入栈的总次数也不超过 4 次。
# 空间复杂度: O(n),其中 n 是序列的长度。空间复杂度主要取决于栈的大小,栈的大小至多为 2n−1。

leetcode 1475. 商品折扣后的最终价格【简单】

给你一个数组 prices ,其中 prices[i] 是商店里第 i 件商品的价格。

商店里正在进行促销活动,如果你要买第 i 件商品,那么你可以得到与 prices[j] 相等的折扣,其中 j 是满足 j > i 且 prices[j] <= prices[i] 的 最小下标 ,如果没有满足条件的 j ,你将没有任何折扣。

请你返回一个数组,数组中第 i 个元素是折扣后你购买商品 i 最终需要支付的价格。

 

示例 1:

输入:prices = [8,4,6,2,3]
输出:[4,2,4,2,3]
解释:
商品 0 的价格为 price[0]=8 ,你将得到 prices[1]=4 的折扣,所以最终价格为 8 - 4 = 4 。
商品 1 的价格为 price[1]=4 ,你将得到 prices[3]=2 的折扣,所以最终价格为 4 - 2 = 2 。
商品 2 的价格为 price[2]=6 ,你将得到 prices[3]=2 的折扣,所以最终价格为 6 - 2 = 4 。
商品 3 和 4 都没有折扣。


示例 2:

输入:prices = [1,2,3,4,5]
输出:[1,2,3,4,5]
解释:在这个例子中,所有商品都没有折扣。


示例 3:

输入:prices = [10,1,1,6]
输出:[9,0,1,6]
 

提示:

1 <= prices.length <= 500
1 <= prices[i] <= 10^3

【题解】

这是一道基础的单调栈问题,除去题目背景,转换成通俗易懂的说明就是:

遍历数组,若存在小于等于当前index的数字,将当前数字减去该index对应的数字。

class Solution:
    def finalPrices(self, prices: List[int]) -> List[int]:
        stack = [] # 定义空栈用于存放价格列表元素的下标
        for i, p in enumerate(prices): # 遍历价格列表
            while stack and p <= prices[stack[-1]]: 
                # 当栈非空,且当前价格列表中的元素值小于栈顶的下标对应的价格列表元素值
                prices[stack.pop()] -= p # 栈顶下标出栈,将元素值更新(即)
            stack.append(i) # 不满足while条件,直接将下标进栈
        return prices 

 

posted @ 2022-04-11 20:58  Ariel_一只猫的旅行  阅读(235)  评论(0编辑  收藏  举报