0Pricing
Coding Interview Prep · 课时

单调栈:递增与递减

维护递增栈或递减栈,以 O(n) 的时间高效回答下一个更大元素和前一个更小元素查询。

单调栈:递增与递减 是 CoddyKit 上的免费 Coding Interview Prep 课时。 这是第 1 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Coding Interview Prep 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Coding Interview Prep 课程共包含 4 节课。

什么是单调栈

单调栈是一种维护元素有序排列的栈(从栈底到栈顶始终递增或始终递减)。在压入新元素之前,我们会弹出所有违反单调不变量的元素。这种受约束的数据结构可以将原本需要 O(n²) 嵌套循环的问题优化为 O(n) 解法。

关键洞察是:每个元素最多被压入和弹出一次,因此在遍历整个数组的过程中,操作总数为 O(n),而不是 O(n²)。当我们弹出某个元素的那一刻,就找到了它一直等待的答案。

# Monotonic increasing stack (bottom to top: smallest to largest)
stack = []
for val in [3, 1, 4, 1, 5, 9, 2, 6]:
    while stack and stack[-1] > val:
        stack.pop()          # maintain increasing invariant
    stack.append(val)
print('Increasing stack (left-to-right):', stack)  # [1, 1, 2, 6]

# Monotonic decreasing stack (bottom to top: largest to smallest)
stack = []
for val in [3, 1, 4, 1, 5, 9, 2, 6]:
    while stack and stack[-1] < val:
        stack.pop()          # maintain decreasing invariant
    stack.append(val)
print('Decreasing stack (left-to-right):', stack)  # [9, 6]

下一个更大元素 I

下一个更大元素问题要求:对于每个元素,找到它右侧第一个大于它的元素。暴力的 O(n²) 双重循环速度太慢。使用单调递减栈可以在 O(n) 时间内解决。

从左到右处理元素。在压入当前元素之前,弹出栈中所有小于当前元素的元素——当前元素就是这些元素的下一个更大元素。处理完所有元素后,栈中剩余的元素右侧都不存在更大的元素(答案为 -1)。

def next_greater_element(nums):
    n = len(nums)
    result = [-1] * n
    stack = []   # stores indices; stack values are decreasing

    for i in range(n):
        # Pop elements smaller than nums[i]
        while stack and nums[stack[-1]] < nums[i]:
            idx = stack.pop()
            result[idx] = nums[i]   # nums[i] is next greater for idx
        stack.append(i)
    # Remaining elements in stack have no next greater => keep -1
    return result

nums = [2, 1, 2, 4, 3]
print(next_greater_element(nums))  # [4, 2, 4, -1, -1]

nums2 = [1, 3, 2, 4]
print(next_greater_element(nums2)) # [3, 4, 4, -1]

下一个更大元素:追踪算法

让我们逐步追踪 [2, 1, 2, 4, 3]。我们维护一个由索引组成的单调递减栈,其中尚未找到其下一个更大元素。

  • i=0,值=2:栈为空,推入 0。栈:[0]
  • i=1,值=1:1 < 数组[0]=2,推入 1。栈:[0,1]
  • i=2,值=2:pop 1(数组[1]=1 < 2),结果[1]=2;此时数组[0]=2 不 < 2,推入 2。栈:[0,2]
  • i=3,值=4:pop 2(结果[2]=4),pop 0(结果[0]=4),推入 3。栈:[3]
  • i=4,值=3:3 < 数组[3]=4,推入 4。栈:[3,4]
  • 结束:栈 [3,4] 中的元素结果均为 -1
def next_greater_trace(nums):
    n = len(nums)
    result = [-1] * n
    stack = []
    for i in range(n):
        print(f'i={i} val={nums[i]}: stack={[nums[s] for s in stack]}', end=' => ')
        while stack and nums[stack[-1]] < nums[i]:
            idx = stack.pop()
            result[idx] = nums[i]
            print(f'pop {nums[idx]}, NGE={nums[i]};', end=' ')
        stack.append(i)
        print(f'push {nums[i]}, stack={[nums[s] for s in stack]}')
    print('Result:', result)
    return result

next_greater_trace([2, 1, 2, 4, 3])

前一个更小元素

单调栈也可以回答前一个更小元素(PSE)查询:对于每个元素,找到其左侧最近的更小元素。我们不再遇到更大元素时弹出,而是在遇到更大或相等的元素时执行 pop,并在推入当前元素之前,将栈顶记录为 PSE。

处理方向发生了变化:我们仍然从左到右处理,但不再在弹出元素时回答问题,而是在推入元素之前回答。此时的栈顶就是左侧最近的更小元素。如果栈为空,则左侧不存在更小元素(答案为 -1 或某个哨兵值)。

def previous_smaller_element(nums):
    n = len(nums)
    result = [-1] * n
    stack = []   # monotonic increasing (values increase bottom to top)

    for i in range(n):
        # Pop elements >= current (maintain strictly increasing invariant)
        while stack and nums[stack[-1]] >= nums[i]:
            stack.pop()
        # Top of stack is previous smaller element (if exists)
        if stack:
            result[i] = nums[stack[-1]]
        stack.append(i)
    return result

nums = [4, 5, 2, 10, 8]
print('PSE:', previous_smaller_element(nums))  # [-1, 4, -1, 2, 2]

nums2 = [1, 3, 2, 5, 4]
print('PSE:', previous_smaller_element(nums2)) # [-1, 1, 1, 2, 2]

每日温度:等待更暖的日子

每日温度问题(LeetCode 739):给定每天的温度,返回一个数组,其中每个元素表示等待更高温度所需的天数。这正是下一个更大元素的模式,只是我们不需要更大的数值,而是需要天数(索引差)。

使用由索引组成的单调递减栈。当我们在索引 i 处找到更高的温度时,pop 栈中所有满足 temps[j] < temps[i] 的索引 j,并设置 result[j] = i - j。剩余索引之后没有更高的温度(结果 = 0)。

def daily_temperatures(temperatures):
    n = len(temperatures)
    result = [0] * n
    stack = []   # indices of unresolved days

    for i in range(n):
        while stack and temperatures[stack[-1]] < temperatures[i]:
            j = stack.pop()
            result[j] = i - j   # days until warmer
        stack.append(i)
    return result

temps = [73, 74, 75, 71, 69, 72, 76, 73]
print(daily_temperatures(temps))  # [1, 1, 4, 2, 1, 1, 0, 0]

temps2 = [30, 40, 50, 60]
print(daily_temperatures(temps2)) # [1, 1, 1, 0]  (always warmer next day)

temps3 = [30, 60, 90]
print(daily_temperatures(temps3)) # [1, 1, 0]

递增栈与递减栈:分别何时使用

选择正确的栈方向至关重要:

  • 单调递减栈(当前值 > 栈顶时 pop):用于回答下一个更大元素和上一个更大元素查询。应用于每日温度、最大矩形和接雨水问题。
  • 单调递增栈(当前值 < 栈顶时 pop):用于回答下一个更小元素和上一个更小元素查询。应用于计算股票价格跨度和队列中可见人员数量。

请记住:导致 pop 的元素就是被弹出元素查询的答案——根据所维护的不变量,它可能是下一个更大元素,也可能是下一个更小元素。

# Summary: which stack type for which query?
queries = {
    'Next Greater Element':    'Decreasing stack (pop when new > top)',
    'Next Smaller Element':    'Increasing stack (pop when new < top)',
    'Previous Greater Element': 'Decreasing stack (answer = top before push)',
    'Previous Smaller Element': 'Increasing stack (answer = top before push)',
}
for query, approach in queries.items():
    print(f'{query}:\n  => {approach}\n')

# Mnemonic:
# NGE/PGE => decreasing stack (we pop smaller elements, finding their next/prev larger)
# NSE/PSE => increasing stack (we pop larger elements, finding their next/prev smaller)

循环数组中的下一个更大元素

下一个更大元素 II(LeetCode 503):给定一个循环数组(首尾相接),找出下一个更大元素。关键是将数组处理两次,通过重复索引来实现:从 0 遍历到 2n-1,并使用 index % n 循环回数组开头。我们只推入 0 到 n-1 的索引(第一次遍历),这样就不会重复计数。

另一种方法是在第二次遍历时处理数组但不推入新索引,只执行 pop。这样无需真正复制数组,也能正确处理循环查找,同时将空间复杂度保持为 O(n)。

def next_greater_element_circular(nums):
    n = len(nums)
    result = [-1] * n
    stack = []

    for i in range(2 * n):
        while stack and nums[stack[-1]] < nums[i % n]:
            idx = stack.pop()
            result[idx] = nums[i % n]
        if i < n:
            stack.append(i)   # only push real indices (0..n-1)
    return result

print(next_greater_element_circular([1, 2, 1]))    # [2, -1, 2]
print(next_greater_element_circular([1, 2, 3, 4, 3]))  # [2, 3, 4, -1, 4]
print(next_greater_element_circular([5, 4, 3, 2, 1]))  # [-1, 5, 5, 5, 5]

股票跨度问题

股票跨度问题:给定每天的股票价格,计算每天的跨度——价格小于或等于当天价格的连续此前天数。这实际上是上一个更大元素问题的另一种形式:跨度就是从今天向前到最近一个价格严格更高的日期之间的距离。

使用单调递减栈。处理第 i 天时,pop 所有价格 ≤ 当前价格的日期。如果栈不为空,跨度为 i - stack[-1];如果栈为空,跨度为 i + 1(说明当前价格是截至目前的最高价)。然后推入 i。

def stock_span(prices):
    spans = []
    stack = []   # indices of prices forming decreasing sequence

    for i, price in enumerate(prices):
        while stack and prices[stack[-1]] <= price:
            stack.pop()
        span = i - stack[-1] if stack else i + 1
        spans.append(span)
        stack.append(i)
    return spans

prices = [100, 80, 60, 70, 60, 75, 85]
print('Prices:', prices)
print('Spans: ', stock_span(prices))  # [1, 1, 1, 2, 1, 4, 6]

# Verification for day 5 (price=75): prev higher is day 1 (80), span = 5-1 = 4
# Day 6 (price=85): prev higher is day 0 (100), span = 6-0 = 6

用于计算队列中可见人员的单调栈

队列中可见人员数量问题:一些人站成一列,每个人都有一个身高。如果中间所有人的身高都同时小于两端的人,人员 i 可以看见人员 j(j > i)。这个问题使用单调递减栈。

从右到左处理。维护一个按身高单调递减的栈。对于每个人,计算其能看见多少人:pop 所有较矮的人(这些人可见,但之后会被挡住);如果 pop 后栈不为空,再加 1(第一个更高的人也可见)。由于每个人最多被推入和弹出一次,整体时间复杂度为 O(n)。

def visible_people(heights):
    n = len(heights)
    result = [0] * n
    stack = []   # decreasing monotonic stack (heights)

    for i in range(n - 1, -1, -1):   # right to left
        count = 0
        while stack and stack[-1] < heights[i]:
            stack.pop()
            count += 1   # can see this shorter person
        if stack:
            count += 1   # can see the first person >= heights[i]
        result[i] = count
        stack.append(heights[i])
    return result

heights = [10, 6, 8, 5, 11, 9]
print('Heights:', heights)
print('Visible:', visible_people(heights))  # [3, 1, 2, 1, 1, 0]

O(n) 保证:为什么每个元素最多被推入和弹出一次

单调栈算法的 O(n) 时间保证来自一个简单的摊还分析:每个元素恰好被推入栈一次,最多被弹出一次。没有元素会被推入或弹出超过一次。因此,整个循环中的推入和 pop 操作总数最多为 2n,尽管嵌套循环看起来可能暗示 O(n²),但总工作量仍为 O(n)。

在面试中,清楚地阐述这种摊还分析很重要。循环不会在每次迭代中运行 n 次——它只会弹出此前一直等待的元素,而这些元素一旦被弹出,就不会再回来。

def next_greater_instrumented(nums):
    result = [-1] * len(nums)
    stack = []
    pushes = pops = 0

    for i in range(len(nums)):
        while stack and nums[stack[-1]] < nums[i]:
            idx = stack.pop()
            result[idx] = nums[i]
            pops += 1
        stack.append(i)
        pushes += 1

    print(f'n={len(nums)}, pushes={pushes}, pops={pops}')
    print(f'Total operations = {pushes + pops} <= 2n = {2*len(nums)}')
    return result

import random
nums = random.sample(range(1000), 100)
next_greater_instrumented(nums)
# Confirm: total operations always <= 2n

识别单调栈问题

如果问题要求查找最近的更大或更小元素、价格跨度、一列中的可见元素或基于柱状图的面积,那么它很可能需要使用单调栈。请留意这些关键词和模式:每个元素都需要从某个方向(左侧或右侧)的最近相关元素中得到答案。

如果暴力解法需要从每个元素向左或向右扫描(O(n²)),请用单调栈替代这种扫描。栈会“记住”候选答案,丢弃无关候选,并在恰好需要答案的时刻弹出正确结果。

# Monotonic stack problem recognition guide
patterns = [
    ('Next/previous greater element', 'Decreasing stack; answer found on pop'),
    ('Next/previous smaller element', 'Increasing stack; answer found on pop'),
    ('Days until warmer/colder',       'Stack of indices; answer = i - j'),
    ('Stock span',                     'Decreasing stack; span = i - prev larger idx'),
    ('Largest rectangle in histogram', 'Increasing stack; area computed on pop'),
    ('Trapping rain water',            'Decreasing stack or two-pointer'),
    ('Sliding window maximum',         'Decreasing deque of indices'),
]
print('Monotonic Stack / Deque Pattern Guide:')
print('='*60)
for problem, approach in patterns:
    print(f'Problem: {problem}')
    print(f'  Approach: {approach}')
    print()

快速检查

请测试您对本课中数据结构与算法——编程面试准备相关概念的理解。

课程回顾

在本课中,您学习了:单调栈会在推入元素之前,弹出违反不变量的元素,从而维护递增或递减顺序;递减栈可以回答下一个或上一个更大元素问题,而递增栈可以回答下一个或上一个更小元素问题;以及每个元素最多被推入和弹出一次,因此总时间复杂度为 O(n),而不是 O(n²)。接下来,我们将使用单调栈查找柱状图中的最大矩形。

常见问题解答

「单调栈:递增与递减」课时是免费的吗?

是的 — 「单调栈:递增与递减」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Coding Interview Prep 课程的其余内容,请升级到 CoddyKit PRO。 Coding Interview Prep 课程共包含 4 节课。

「单调栈:递增与递减」这节课中我会学到什么?

维护递增栈或递减栈,以 O(n) 的时间高效回答下一个更大元素和前一个更小元素查询。 你通过在浏览器中直接运行的动手代码来练习 Coding Interview Prep,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Coding Interview Prep 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Coding Interview Prep 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 1 节课,共 4 节。

「单调栈:递增与递减」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Coding Interview Prep 课中编写并运行代码吗?

能。每节 Coding Interview Prep 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 单调栈:递增与递减
  2. 直方图中的最大矩形
  3. 使用单调双端队列求滑动窗口最大值
  4. 接雨水:栈与双指针
← 返回 Coding Interview Prep