看完这个,单调栈问题还能不会么?

1,177 阅读4分钟

引言

掘金.001.jpeg

本篇文章将介绍什么是单调栈,单调栈用来解决什么问题,以及它在算法体用的灵活运用,帮助你快速解决类似问题,算法学习的是思维,切勿死记代码流程。

栈结构:

一种先进后出的数据结构,类似于一个桶,只有一个出口,最先放入的最后取出。

单调栈

单调栈就是保证栈内元素按照某种规则单调递增,或单调递减,注意不是栈内的元素的值的单调性。

举例arr=[3,2,5,4,7,8]arr = [3,2,5,4,7,8] , 递增子序列为:[3,5,7,8][3,5,7,8]递增栈内存放的是值的索引stack=[0,2,4,5]stack = [0,2,4,5]

解决什么问题?

给定一个数组 arr=[3,1,4,5,3,7]arr = [3,1,4,5,3,7]

  • a=arr[i]a = arr[i] 位置, arr[l]<a,a>arr[r]arr[l] < a , a > arr[r] ,求 l,rl,r 的最近的位置。
    • a=4a = 4,左边比 4 小的最近元素是 1,右边比 4 小的最近的元素是3。

image-20220415102729430.png

  • a=arr[i]a = arr[i] 位置, arr[l]>a,a<arr[r]arr[l] > a , a < arr[r] ,求 l,rl,r 的最近的位置。

image-20220415102931324.png

暴力解思路

  • 每个位置往左找,每个位置往右找,时间复杂度为 O(N)O(N),总的时间复杂度为 O(N2)O(N^2)

单调栈:

  • 单调栈: 时间复杂度O(N)O(N) ,空间复杂度 O(N)O(N) 解决上述问题。

查找数组区间边界

1.查找小边界

题意:给定一个数组 arrarr ,找到左边和右边比这个数小、且离这个数最近的位置,如果对每一个数都想求到这样的信息,能不能整体代价达到 O(N)O(N)?

示例

  • arr=[3,1,4,5,2,7]arr = [3,1,4,5,2,7]
  • 返回:[[1,1],[,1,1],[1,4],[2,4],[1,1],[4,1]][[-1,1],[,-1,-1],[1,4],[2,4],[1,-1],[4,-1]]

暴力解

  • 遍历数组,每个位置 ii, 遍历 [0,i1][0,i-1][i+1,n1][i + 1,n - 1] 找到左边第一个比自己小的值,右边第一个比自己小的值。
  • 遍历数组复杂度为 O(N)O(N) ,查找左右两边比自己小的值时间复杂度为 O(N)O(N) , 整体时间复杂度为 O(N2)O(N^2)
public static int[][] violenceMethod(int[] arr) {
    if (arr == null || arr.length == 0) return null;
    int n = arr.length;
    int[][] ans = new int[n][2];
    for (int i = 0; i < n; i++) {
        int leftIndex = -1, rightIndex = -1;
        for (int l = i - 1; l >= 0; l--) {
            if (arr[l] < arr[i]){
                leftIndex = l;
                break;
            }
        }
        for (int r = i + 1; r < n; r++) {
            if (arr[r] < arr[i]){
                rightIndex = r;
                break;
            }
        }
        ans[i][0] = leftIndex;
        ans[i][1] = rightIndex;
    }
    return ans;
}

单调栈解法

  • 栈中存放的是数组对应的索引,索引对应的值维持单调递增。
  • 当待加入的元素比栈顶对应的元素小时,开始结算栈内元素,弹出栈顶元素结算左右边界,直到当前位置 arr[i]>arr[stack.peek()]arr[i] > arr[stack.peek()] 为止,将 ii 压入栈中。
  • 最后遍历完数组后,还需要结算栈内元素。
  • 事件复杂度 O(N)O(N) ,空间复杂度 O(N)O(N)

image-20220414195736278.png

/**
 * 单调栈问题:在数组中想找到一个数,左边和右边比这个数小、且离这个数最近的位置
 * 如果对每一个数都想求到这样的信息,能不能整体代价达到O(n)?
 * eg: arr = [3,1,4,5,3,7]
 * <p>
 * 思路:借助栈来实现,假定数组中不包含重复元素,步骤如下:
 * 1.栈内存储的是索引,保持栈内索引对应的值严格单调递增。
 * 2.当遇到一个元素 arr[i] > arr[stack.peek()], 入栈。
 * 3.当遇到一个元素 arr[i] < arr[stack.peek],此时栈内元素 j 出栈,左边比 j 小的在它的下面
 * 右边比 j 小的就是让它出栈的元素,这样就能拿到左边离它最近和右边离它最近的位置
 */
public static int[][] getNearLessArray(int[] arr) {
    if (arr == null || arr.length == 0) return null;
    int n = arr.length;
    int[][] result = new int[n][2];
    //栈中存放的索引,索引对应值保持单调递增
    Stack<Integer> stack = new Stack<>();
    for (int i = 0; i < n; i++) {
        while (!stack.isEmpty() && arr[stack.peek()] > arr[i]) {
            //当前元素使得栈内元素无法保持单调次增,则开始结算栈内元素
            int index = stack.pop();
            int preMin = stack.isEmpty() ? -1 : stack.peek();
            result[index][0] = preMin;
            result[index][1] = i;
        }
        //结算完成后,将当前元素入栈
        stack.add(i);
    }
    //最后再次结算栈内元素,没有元素让其弹出了,它的右边没有比它小的元素,否则它一定在之前的过程中被弹出计算了
    //eg:[1,2,3,4,5]
    while (!stack.isEmpty()) {
        int index = stack.pop();
        int preMin = stack.isEmpty() ? -1 : stack.peek();
        result[index][0] = preMin;
        result[index][1] = -1;
    }
    return result;
}

2.查找大边界

题意:给定一个数组 arrarr ,找到左边和右边比这个数大、且离这个数最近的位置,如果对每一个数都想求到这样的信息,能不能整体代价达到 O(N)O(N)?

示例

  • arr=[3,1,4,5,2,7]arr = [3,1,4,5,2,7]
  • 返回:[[1,2],[0,2],[1,3],[1,4],[3,5],[1,1]][[-1,2],[0,2],[-1,3],[-1,4],[3,5],[-1,-1]]

单调栈解法:保持栈内索引对应的值单调递减,只用改动 while 循环的一个符号

public static int[][] getNearUpperArray(int[] arr) {
    if (arr == null || arr.length == 0) return null;
    int n = arr.length;
    int[][] result = new int[n][2];
    //栈中存放的索引,索引对应值保持单调递增
    Stack<Integer> stack = new Stack<>();
    for (int i = 0; i < n; i++) {
        //保证单调递减
        while (!stack.isEmpty() && arr[stack.peek()] < arr[i]) {
            //当前元素使得栈内元素无法保持单调次增,则开始结算栈内元素
            int index = stack.pop();
            int preMin = stack.isEmpty() ? -1 : stack.peek();
            result[index][0] = preMin;
            result[index][1] = i;
        }
        //结算完成后,将当前元素入栈
        stack.add(i);
    }
    //最后再次结算栈内元素,没有元素让其弹出了,它的右边没有比它小的元素,否则它一定在之前的过程中被弹出计算了
    //eg:[8,6,4,2]
    while (!stack.isEmpty()) {
        int index = stack.pop();
        int preMin = stack.isEmpty() ? -1 : stack.peek();
        result[index][0] = preMin;
        result[index][1] = -1;
    }
    return result;
}

实战环节

1.子数组累加及与其最小值的乘积问题

题目:给定一个只包含正数的 arrarrarrarr 中任何一个子数组 subsub,一定都可以算出 sum(sub)min(sub)sum(sub) * min(sub) 是什么,那么所有子数组中,这个最大值是多少?

暴力解

思路分析

  • 对于一个长度为 NN 的数组,其子数组的个数就是两层 for 循环中的区间 [l,r][l,r]
  • 然后遍历区间 [l,r][l,r] 求解区间的和,及找到区间最小值,然后更新答案。
  • 时间复杂度为,两层 for 循环 O(N2)O(N^2),遍历区间求解和及最小值时间复杂度为 O(N)O(N),总的时间复杂度为 O(N3)O(N^3)
/**
 * 暴力思路:首先以每个位置为开头,[l,r] 的子子数组的所有情况,更新最大值
 * 1.查找区间复杂度 O(n^2),计算累加和和确定最小值复杂度为 O(n),总的复杂度为 O(n^3)
 */
public static int maxSubSunMinMax(int[] arr) {
    int n = arr.length;
    int max = Integer.MIN_VALUE;
    for (int l = 0; l < n; l++) {
        for (int r = l; r < n; r++) {
            int min = Integer.MAX_VALUE;
            int sum = 0;
            for (int k = l; k <= r; k++) {
                sum += arr[k];
                min = Math.min(min, arr[k]);
            }
            max = Math.max(max, sum * min);
        }
    }
    return max;
}

单调栈解法

  • 以区间内的最小值为突破口,换种思路来想这个问题。
  • 假定以位置 arr[i]arr[i] 为最小值,往左窗口最远能到达的位置,往右窗口能最远达的位置。该区间内能确保一定是以 arr[i]arr[i] 为最小值的。
  • 那么问题就变成单调栈中,找左边比自己小的最近距离,右边比自己小的最近距离。
  • 注意:题目求: sum(sub)min(sub)sum(sub) * min(sub) ,那既然最小值我们固定了,窗口当然是越大越好,累加和与窗口的大小成正比。
  • 那么就遍历整个数组,以每个位置为最小值,求区间能扩的最大范围。

image-20220414210430276.png

此题还用到预处理技巧

  • 求区间内的累加和问题,典型的预处理数组技巧。首先遍历一遍数组,求出每个位置 sum[i]sum[i] 表示 [0,i][0,i] 的累加和。
  • 这样区间累加就可以直接通过 sumsum 数组拿到。

复杂度计算

  • 预处理累加和数组,时间复杂度 O(N)O(N) ,空间复杂度 O(N)O(N)
  • 遍历数组,每个元素最多只会进栈一次,出栈一次,所以总的时间复杂度为 O(N)O(N)
  • 时间复杂度O(N)O(N) ,空间复杂度 O(N)O(N) 拿下
/**
 * 优化思路:
 * 1.求sub累加和,用预处理技巧。
 * 2.区间定义:必须以 l 位置为子数组的最小值求最大答案,每个位置都遍历求一个答案,O(n) 拿下。
 * 3.等价, 找 arr[l] 左边第一个比自己小的位置,右边第一个比自己小的位置,这不就是单调栈的定义么。
 * 3.eg:arr[3,4,5,6,3,2,7],此时如果以 4 位置为最小值,左边比自己小的位置在 0 位置,右边比自己小的位置在4位置
 * 4.这样就能确定一个区间:[4,5,6],每个位置都尝试以自己为最小值。
 */
public static int maxSubSunMinMax1(int[] arr) {
    int n = arr.length;
    //1.数组预处理,求前缀和
    int[] sum = new int[n];
    sum[0] = arr[0];
    for (int i = 1; i < n; i++) {
        sum[i] = sum[i - 1] + arr[i];
    }
    //2.单调栈解决左右两边比自己小的最远距离,单调递增栈
    Stack<Integer> stack = new Stack<>();
    int max = Integer.MIN_VALUE;
    for (int i = 0; i < n; i++) {
        //等价于求左边 leftIndex,右边 rightIndex 这个区间内
        while (!stack.isEmpty() && arr[stack.peek()] >= arr[i]) {
            //当前有一个元素使得不能单调递增了,右边的最小值为 rightIndex= i
            int index = stack.pop();//以当前元素为最小值
            int subSum = sum[i - 1] - (stack.isEmpty() ? 0 : sum[stack.peek()]);
            max = Math.max(max, subSum * arr[index]);
        }
        stack.add(i);
    }
    //3.最后结算stack中剩余的元素
    while (!stack.isEmpty()){
        int index = stack.pop();
        int subSum = sum[n - 1] - (stack.isEmpty() ? 0 : sum[stack.peek()]);
        max = Math.max(max,subSum * arr[index]);
    }
    return max;
}

2.柱状图中最大的矩形

image-20220415174133135.png

思路

  • 这不就是以每个柱子为高,找左边离自己最近的位置,右边离自己最近的位置。中间就是以当前位置为高能扩的最远距离。
  • 注意:在最后结算的时候,右边界都是 -1,即右边没有比起小的值,不然肯定让其出栈了,注意宽度计算。
/**
 * 题目:求柱状图中,能勾勒出的最大矩形面积。
 * 思路:以每个位置为矩形的高度,找到左边比自己小的位置,右边比自己小的位置,中间的区间就是矩形面积
 * 这不就是典型的单调栈思路么
 */
public static int largestRectangleArea(int[] heights) {
    if (heights == null || heights.length == 0) return 0;
    int n = heights.length;
    int maxArea = 0;
    //单调栈,找左边最近最小值,右边最近最小值,递增栈
    Stack<Integer> stack = new Stack<>();
    for (int i = 0; i < n; i++) {
        while (!stack.isEmpty() && heights[stack.peek()] > heights[i]) {
            //当前要添加的元素比栈顶对应的值小了,结算
            int index = stack.pop();
            int preIndex = stack.isEmpty() ? 0 : stack.peek();
            int width = i - preIndex - 1;
            maxArea = Math.max(maxArea,width * heights[index]);
        }
        stack.add(i);
    }
    //最后结算栈内元素
    while (!stack.isEmpty()){
        int index = stack.pop();
        int preIndex = stack.isEmpty() ? 0 : stack.peek();
        int width = n - preIndex - 1;
        maxArea = Math.max(maxArea,width * heights[index]);
    }
    return maxArea;
}