Skip to content

单调栈-上


基本介绍

应用场景

每个位置都求小,栈到栈,大压小

(1)当前位置的左侧比当前位置的数字且距离最近的位置在哪

(2)当前位置的右侧比当前位置的数字且距离最近的位置在哪

每个位置都求大,栈到栈,小压大

(1)当前位置的左侧比当前位置的数字且距离最近的位置在哪

(2)当前位置的右侧比当前位置的数字且距离最近的位置在哪

用单调栈的方式可以做到:求解过程中,单调栈所有调整的总代价为 O(n),单次操作的均摊代价为 O(1)

⚠️ 注意

单调栈可以和很多技巧交叉使用!比如:动态规划 + 单调栈优化,会在【扩展】课程里讲述

解题思路

⚠️ 注意:栈中存储的是元素的下标索引,以下的思路描述求小的情况,即从栈底到栈顶存储下标对应的元素大小依次从小到大排序,使用数组实现栈,优化常数时间

(1)无重复数值数组

如果即将压入的元素比栈顶元素大,直接压入,栈中维持的单调性是从栈底到栈顶是从小到大的

如果即将压入的元素比栈顶元素小,弹出栈顶元素,记为 n,则 n 左边比 n 小且最近的为新的栈顶元素,n 右边比 n 小且最近的为让 n 弹出的元素,如果不存在,记为 -1

遍历完所有元素,如果栈中还有元素,依次弹出,左边的答案符合上述规则,右边的答案全为 -1

(2)⭐有重复数值数组

首先以无重复数组为基础,先记录答案,后续修正右侧的答案,如果是 -1 无需修正

修正阶段:从右往左遍历,如果发现当前位置的值和当前位置右侧的答案相等,则修正当前位置右侧的答案为当前位置右侧答案值的右侧答案

💡 难点

当前遍历元素和栈顶元素相等时,此时栈顶元素右侧的答案按理说是当前遍历元素,但是可能是错的,而后续等待遍历的数才是当前栈顶元素右侧的正确答案,此时该如何结算答案?

根据题目具体分析,一般无需处理,因为后续的数可以使得最终答案正确

模板题

题目链接

https://www.nowcoder.com/practice/2a2c00e7a88a498693568cef63a4b7bb

代码实现

java
import java.io.*;

public class Main {

    public static int MAXN = 1000001;

    public static int[] arr = new int[MAXN];

    public static int[] stack = new int[MAXN];

    // ans[i] --> [左边的答案][右边的答案]
    public static int[][] ans = new int[MAXN][2];

    public static int n, r;

    public static void main(String[] args) throws Exception {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StreamTokenizer in = new StreamTokenizer(br);
        PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
        while (in.nextToken() != StreamTokenizer.TT_EOF) {
            n = (int) in.nval;
            for (int i = 0; i < n; i++) {
                in.nextToken();
                arr[i] = (int) in.nval;
            }
            compute();
            for (int i = 0; i < n; i++) {
                out.println(ans[i][0] + " " + ans[i][1]);
            }
        }
        out.flush();
        out.close();
        br.close();
    }

    // 使用数组实现栈,优化常数时间
    // 栈中存储的是元素的下标
    public static void compute() {
        // 使用静态空间,先清理脏数据
        // r 表示栈中元素的个数
        r = 0;
        int cur;

        // 遍历阶段
        for (int i = 0; i < n; i++) {
            // 只要遍历元素比当前栈顶元素小
            // 弹出栈顶元素,记录答案
            // 直到自己可以压入栈中才停止
            while (r > 0 && arr[stack[r - 1]] >= arr[i]) {
                // 栈顶元素对应的下标
                cur = stack[--r];
                // 记答案
                // 左侧答案:原栈中 cur 的下一个元素,即新的栈顶元素
                // 右侧答案:让 cur 弹出的元素
                ans[cur][0] = r > 0 ? stack[r - 1] : -1;
                ans[cur][1] = i;
            }
            // 将当前遍历的元素入栈
            stack[r++] = i;
        }

        // 清算阶段:元素遍历完,栈中还有元素
        while (r > 0) {
            cur = stack[--r];
            ans[cur][0] = r > 0 ? stack[r - 1] : -1;
            // 是自己弹出的,并不是某个元素让其弹出的
            // 所以右侧答案均为 -1
            ans[cur][1] = -1;
        }

        // 修正阶段:数组中出现了重复元素
        // 左侧答案一定正确,从右往左遍历修正右侧答案
        // n-1 位置的右侧答案一定为 -1,不需要修正
        for (int i = n - 2; i >= 0; i--) {
            if (ans[i][1] != -1 && arr[ans[i][1]] == arr[i]) {
                ans[i][1] = ans[ans[i][1]][1];
            }
        }
    }
}

每日温度

题目链接

https://leetcode.cn/problems/daily-temperatures/

思路分析

本题就是找比当前元素且最近的,计算差值,栈的结构是栈底到栈顶从大到小,小压大

如果右侧答案不存在,记为 0,因为不需要维护左侧,所以清算阶段可以省去,数组的初始值天然为 0,无需手动处理

⚠️ 注意

栈顶元素和遍历相等如何处理?

相等时让遍历的元素入栈,当遍历元素大于栈顶元素时,一定能将这些重复的元素处理对

代码实现

java
class Solution {
    // 原来是 int[] temperatures,这里改成 nums
    public int[] dailyTemperatures(int[] nums) {
        // 数组实现栈,优化常数时间
        int MAXN = 100001;
        int[] stack = new int[MAXN];

        int n = nums.length;
        int[] ans = new int[n];

        int r = 0;

        for (int i = 0; i < n; i++) {
            // 找右侧大且最近的,栈中元素小压大,严格小于,弹出的逻辑
            // 不取等,模板中取等是因为后续有修正逻辑可以修正对
            while (r > 0 && nums[stack[r - 1]] < nums[i]) {
                int cur = stack[--r];
                ans[cur] = i - cur;
            }
            // 相等也入栈,后续一定能将最终答案算对
            stack[r++] = i;
        }

        return ans;
    }
}

子数组的最小值之和

题目链接

https://leetcode.cn/problems/sum-of-subarray-minimums/

思路分析

题目要求对答案取模,使用同余定理

对于 i 位置的元素,找到左边比 i 小且最近的,记为 left 位置,找到右边比 i 小且最近的,记为 right 位置,那就说明 [ left + 1,right + 1 ] 这个范围内,i 位置的数一定是最小的

利用上面这个特性就可以找到所有包含 i 位置这个最小值的子数组,所有子数组左侧位置的可能性为 i - left,右侧位置的可能性为数组长度 n - i,则子数组的数量为 (i - left) * (n - i)

举例:i 遍历到 3 位置,左侧比 i 小且最近的元素再 1 位置,右侧比 i 小且最近的元素再 7 位置,则组成子数组可能的位置为 2-3,2-4,2-5,2-6,3-3,3-4,3-5,3-6,那子数组左侧的可能性为 i - left = 3 - 1 = 2,右侧的可能性为 n - i = 7 - 3 = 4,所有子数组的个数为 2 * 4 = 8

⚠️ 注意

i 位置的值与右侧比 i 小且最近元素的值相同如何处理?此时会导致 i 位置少算了子数组的个数

不算,后续遍历到值相同的元素时,该元素的计算会补上之前元素没有计算的,结果能算对

代码实现

java
class Solution {
    public int sumSubarrayMins(int[] arr) {
        int MOD = 1000000007;
        int MAXN = 30001;
        int[] stack = new int[MAXN];
        int r = 0;
        long ans = 0;
        for (int i = 0; i < arr.length; i++) {
            // 找右侧比 i 位置小且最近的,栈中元素大压小
            // 相等正常算,后面一定可以修正对
            while (r > 0 && arr[stack[r - 1]] >= arr[i]) {
                int cur = stack[--r];
                int left = r == 0 ? -1 : stack[r - 1];
                // 子数组开头位置的可能性:cur - left
                // 子数组结尾位置的可能性:arr.length - cur
                // 利用加法同余定理计算
                ans = (ans + (long) (cur - left) * (i - cur) * arr[cur]) % MOD;
            }
            stack[r++] = i;
        }
        // 清算阶段
        while (r > 0) {
            int cur = stack[--r];
            int left = r == 0 ? -1 : stack[r - 1];
            ans = (ans + (long) (cur - left) * (arr.length - cur) * arr[cur]) % MOD;
        }
        return (int) ans;
    }
}

柱状图中的最大矩形

题目链接

https://leetcode.cn/problems/largest-rectangle-in-histogram

思路分析

遍历每一个柱子,找当前柱子左边比柱子小且最近的柱子,记为 left,找当前柱子右边比柱子小且最近的柱子,记为 right,目的是能以当前柱子为基准往两边扩展,使得面积尽可能的大,如果不存在则记为 -1

面积为中间的部分:height[cur] * (right - left - 1)

代码实现

java
class Solution {
    public int largestRectangleArea(int[] heights) {
        int MAXN = 100001;
        int[] stack = new int[MAXN];
        int r = 0;
        int ans = 0;
        for (int i = 0; i < heights.length; i++) {
            // 相等也弹出,后面能算对
            while (r > 0 && heights[stack[r - 1]] >= heights[i]) {
                int cur = stack[--r];
                int left = r == 0 ? -1 : stack[r - 1];
                ans = Math.max(ans, heights[cur] * (i - left - 1));
            }
            stack[r++] = i;
        }
        // 清算阶段
        while (r > 0) {
            int cur = stack[--r];
            int left = r == 0 ? -1 : stack[r - 1];
            ans = Math.max(ans, heights[cur] * (heights.length - left - 1));
        }
        return ans;
    }
}

最大矩形

题目链接

https://leetcode.cn/problems/maximal-rectangle/

思路分析

压缩数组技巧,以每行作为底,加工柱状图,问题转为柱状图中的最大矩形问题,每行求一个答案,不断更新最大值,时间复杂度:O(n * m)

代码实现

java
class Solution {
    // 原来是 char[][] matrix,这里改成 grid
    public int maximalRectangle(char[][] grid) {
        int MAXN = 201;
        int[] height = new int[MAXN];
        int[] stack = new int[MAXN];

        int n = grid.length;
        int m = grid[0].length;

        Arrays.fill(height, 0, m, 0);

        int ans = 0;
        for (int i = 0; i < n; i++) {
            // 来到第 i 行,以改行为底,加工柱状图
            for (int j = 0; j < m; j++) {
                height[j] = grid[i][j] == '0' ? 0 : height[j] + 1;
            }
            ans = Math.max(largestRectangleArea(m, height, stack), ans);
        }
        return ans;
    }

    // 柱状图中的最大矩形面积,找左右比 i 小且位置最近的
    public int largestRectangleArea(int m, int[] height, int[] stack) {
        int r = 0;
        int ans = 0;
        for (int i = 0; i < m; i++) {
            while (r > 0 && height[stack[r - 1]] >= height[i]) {
                int cur = stack[--r];
                int left = r == 0 ? -1 : stack[r - 1];
                ans = Math.max(ans, height[cur] * (i - left - 1));
            }
            stack[r++] = i;
        }
        // 清算阶段
        while (r > 0) {
            int cur = stack[--r];
            int left = r == 0 ? -1 : stack[r - 1];
            ans = Math.max(ans, height[cur] * (m - left - 1));
        }
        return ans;
    }
}