写在前
单调栈(monotone-stack)是指栈内元素(栈底到栈顶)都是(严格)单调递增或者单调递减的。
如果有新的元素入栈,栈调整过程中 会将所有破坏单调性的栈顶元素出栈,并且出栈的元素不会再次入栈 。由于每个元素只有一次入栈和出栈的操作,所以 单调栈的维护时间复杂度是O(n) 。
单调栈性质:
- 单调栈里的元素具有单调性。
- 递增(减)栈中可以找到元素左右两侧比自身小(大)的第一个元素。
我们主要使用第二条性质,该性质主要体现在栈调整过程中,下面以自栈底到栈顶递增为例(假设所有元素都是唯一),当新元素入栈。
- 对于出栈元素来说:找到右侧第一个比自身小的元素。
- 对于新元素来说:等待所有破坏递增顺序的元素出栈后,找到左侧第一个比自身小的元素。
1.单调栈结构
问题描述:给定不含重复值的数组arr,找到每个i位置左边和右边距离i最近的且值比i小的位置(没有返回-1),返回所有的位置信息。
进阶问题:数组中含有重复值。
示例:
arr = {3, 4, 1, 0} { {-1, 2}, {0, 2}, {-1, 3}, {-1, -1} }
思路:常规时间复杂度O(n^2)实现简单,每个位置向左和向右遍历一遍。
单调栈实现:寻找两边距离arr[i]最近且arr[i]小的索引,保持栈顶到栈底单调递减(寻找比arr[i]大的值,单调递增),栈中存放索引值。
对于进阶问题,区别在于重复索引值用集合进行连接,栈中存放的是一个ArrayList。注意两点:
- arr[i]左边应该是上一个位置最晚加入的那个(如果有多个元素)
- 相等的情况直接在尾部加入,获取值的时候循环的获取该集合中的所有值(集合中元素值相等,索引值不同)
代码:原问题
public int[][] getNearLessNoRepeat(int[] arr) { int[][] ans = new int[arr.length][2]; Stack<Integer> stack = new Stack<>(); // 遍历数组,入栈 for (int i = 0; i < arr.length; ++i) { while (!stack.isEmpty() && arr[i] < arr[stack.peek()]) { int popIndex = stack.pop(); int leftLessIndex = stack.isEmpty() ? -1 : stack.peek(); ans[popIndex][0] = leftLessIndex; ans[popIndex][1] = i; } stack.push(i); } while (!stack.isEmpty()) { int popIndex = stack.pop(); int leftLessIndex = stack.isEmpty() ? -1 : stack.peek(); ans[popIndex][0] = leftLessIndex; // 说明该索引右边没有比当前小的元素,有的话该索引在上边循环就弹出了 ans[popIndex][1] = -1; } return ans; }
代码:进阶问题
public int[][] getNearLess(int[] arr) { int[][] ans = new int[arr.length][2]; Stack<List<Integer>> stack = new Stack<>(); // 遍历数组,入栈 for (int i = 0; i < arr.length; ++i) { while (!stack.isEmpty() && arr[i] < arr[stack.peek().get(0)]) { List<Integer> popIs = stack.pop(); int leftLessIndex = stack.isEmpty() ? -1 : stack.peek().get(stack.peek().size() - 1); for (int popi : popIs) { ans[popi][0] = leftLessIndex; ans[popi][1] = i; } } if (!stack.isEmpty() && arr[i] == arr[stack.peek().get(0)]) { stack.peek().add(Integer.valueOf(i)); } else { ArrayList<Integer> list = new ArrayList<>(); list.add(i); stack.push(list); } } while (!stack.isEmpty()) { List<Integer> popIs = stack.pop(); int leftLessIndex = stack.isEmpty() ? -1 : stack.peek().get(stack.peek().size() - 1); for (int popi : popIs) { ans[popi][0] = leftLessIndex; ans[popi][1] = -1; } } return ans; }
2.下一个更大的元素(leetcode496-易)
题目描述:给你两个 没有重复元素 的数组 nums1
和 nums2
,其中nums1
是 nums2
的子集。
请你找出 nums1
中每个元素在 nums2
中的下一个比其大的值。
nums1
中数字 x
的下一个更大元素是指 x
在 nums2
中对应位置的右边的第一个比 x
大的元素。如果不存在,对应位置输出 -1
。
示例:
输入: nums1 = [4,1,2], nums2 = [1,3,4,2]. 输出: [-1,3,-1] 解释: 对于 num1 中的数字 4 ,你无法在第二个数组中找到下一个更大的数字,因此输出 -1 。 对于 num1 中的数字 1 ,第二个数组中数字1右边的下一个较大数字是 3 。 对于 num1 中的数字 2 ,第二个数组中没有下一个更大的数字,因此输出 -1 。
思路:维护一个从栈顶到栈底的严格单调递增的栈,先遍历大数组记录每个元素右边第一个比当前元素大的值,然后遍历小数组输出结果。这里用一个hashmap映射两个数组的元素,栈中元素这里仍是索引,也可用元素。
代码:
public int[] nextGreaterElement(int[] nums1, int[] nums2) { Stack<Integer> stack = new Stack<>(); HashMap<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < nums2.length; ++i) { while (!stack.isEmpty() && nums2[i] > nums2[stack.peek()]) { int cur = stack.pop(); int rightMaxIdx = i; map.put(nums2[cur], nums2[rightMaxIdx]); } stack.push(i); } int[] ans = new int[nums1.length]; for (int j = 0; j < nums1.length; ++j) { ans[j] = map.getOrDefault(nums1[j], -1); } return ans; }
3.柱状图中最大的矩形(leetcode84-难)
问题描述:给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
示例:
输入: [2,1,5,6,2,3] 输出: 10
思路:有了单调栈的基本认识,我们可以遍历每根柱子,以当前柱子 i 的高度作为矩形的高,那么矩形的宽度边界即为向左找到第一个高度小于当前柱体 i 的柱体,向右找到第一个高度小于当前柱体 i 的柱体。对于每个柱子我们都如上计算一遍以当前柱子作为高的矩形面积,最终比较出最大的矩形面积即可。
单调栈实现:寻找两边距离arr[i]最近且arr[i]小的索引,保持栈顶到栈底单调递减,栈中存放索引值。
注意:头0如果不添加,寻找左边元素需要判断栈是否为空;尾0如果不添加,需要重新写一个循环弹出栈内元素。
代码:原问题
class Solution { // 单调栈(栈底到栈顶单调递增) public int largestRectangleArea(int[] heights) { int n = heights.length; Deque<Integer> stack = new LinkedList<>(); int ans = 0; for (int i = 0; i < n; i++) { while (!stack.isEmpty() && heights[i] < heights[stack.peek()]) { int curIndex = stack.pop(); while (!stack.isEmpty() && heights[stack.peek()] == heights[curIndex]) { stack.pop(); } int leftIndex = stack.isEmpty() ? -1 : stack.peek(); ans = Math.max(ans, (i - leftIndex - 1) * heights[curIndex]); } stack.push(i); } while (!stack.isEmpty()) { int curIndex = stack.pop(); while (!stack.isEmpty() && heights[stack.peek()] == heights[curIndex]) { stack.pop(); } int width = stack.isEmpty() ? n : n - stack.peek() - 1; ans = Math.max(ans, width * heights[curIndex]); } return ans; } // 优化1:添加尾0(推荐) public int largestRectangleArea(int[] heights) { int n = heights.length; heights = Arrays.copyOf(heights, n + 1); Deque<Integer> stack = new LinkedList<>(); int ans = 0; for (int i = 0; i < n + 1; i++) { while (!stack.isEmpty() && heights[i] < heights[stack.peek()]) { int curIndex = stack.pop(); while (!stack.isEmpty() && heights[stack.peek()] == heights[curIndex]) { stack.pop(); } int leftIndex = stack.isEmpty() ? -1 : stack.peek(); ans = Math.max(ans, (i - leftIndex - 1) * heights[curIndex]); } stack.push(i); } return ans; } // 优化2:首尾都扩容0 public int largestRectangleArea(int[] heights) { int n = heights.length; int[] tmp = new int[n + 2]; System.arraycopy(heights, 0, tmp, 1, n); Deque<Integer> stack = new LinkedList<>(); int ans = 0; for (int i = 0; i < n + 2; i++) { while (!stack.isEmpty() && tmp[i] < tmp[stack.peek()]) { int curIndex = stack.pop(); while (!stack.isEmpty() && tmp[stack.peek()] == tmp[curIndex]) { stack.pop(); } int leftIndex = stack.peek(); ans = Math.max(ans, (i - leftIndex - 1) * tmp[curIndex]); } stack.push(i); } return ans; } }
4. 最大矩形(leetcode85-难)
题目描述:给定一个仅包含 0
和 1
、大小为 rows x cols
的二维二进制矩阵,找出只包含 1
的最大矩形,并返回其面积。
示例:
image.png
输入:matrix = [["1","0","1","0","0"],["1","0","1","1","1"],["1","1","1","1","1"],["1","0","0","1","0"]] 输出:6 解释:最大矩形如上图所示。
思路:本题与上题思路相同,这里我们每遍历一行,更新代表柱子高度的函数heights。当前单元为0,高度为0;当前单元为1,高度+1.
利用动态规划的思想,我们不需要重新遍历之前走过的行,每遍历一行更新一下矩阵的最大面积。计算当前区域的最大矩形面积可以直接调用T84。
代码:
public int maximalRectangle(char[][] matrix) { if (matrix == null || matrix.length == 0 || matrix[0].length == 0) return 0; int row = matrix.length, col = matrix[0].length; int[] heights = new int[col]; int ans = 0; for (int i = 0; i < row; ++i) { for (int j = 0; j < col; ++j) { if (matrix[i][j] == '0') heights[j] = 0; else ++heights[j]; } ans = Math.max(ans, largestRectangleArea(heights)); } return ans; } public int largestRectangleArea(int[] heights) { int[] tmp = new int[heights.length + 2]; System.arraycopy(heights, 0, tmp, 1, heights.length); int maxArea = 0; Deque<Integer> stack = new LinkedList<>(); for (int i = 0; i < tmp.length; ++i) { while (!stack.isEmpty() && tmp[i] < tmp[stack.peek()]) { int h = tmp[stack.pop()]; maxArea = Math.max(maxArea, (i - stack.peek() - 1) * h); } stack.push(i); } return maxArea; }
5.接雨水(leetcode42-难)
题目描述:给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
示例:
输入:height = [0,1,0,2,1,0,1,3,2,1,2,1] 输出:6 解释:上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
思路:由示例我们可以看出上述可以划分为四部分积水区域,积水槽一定在两个柱子之间。只有左右元素都大于当前元素才能形成槽,那么可以维护从栈底到栈顶单调递减的单调栈:
- 这样可以找到左边第一个大于当前元素的值,当右边即将加入的值也大于它就形成了槽
- 栈中存放柱子对应的索引值。
- 注意:高的取值,边界较小的与当前槽高度的差值
代码:
class Solution { // 单调栈(从栈底到栈顶单调递减) public int trap(int[] height) { if (height == null || height.length == 0) { return 0; } Deque<Integer> stack = new LinkedList<>(); int ans = 0; for (int i = 0; i < height.length; i++) { while (!stack.isEmpty() && height[i] > height[stack.peek()]) { int curIndex = stack.pop(); // 优化:如果栈顶元素相等,则一直弹出,只留一个 while (!stack.isEmpty() && height[stack.peek()] == height[curIndex]) { stack.pop(); } if (!stack.isEmpty()) { int leftIndex = stack.peek(); ans += (i - leftIndex - 1) * (Math.min(height[leftIndex], height[i]) - height[curIndex]); } } stack.push(i); } return ans; } }
6.求区间最小数乘区间和的最大值(补充:字节高频面试题)
题目描述:给定一个数组,要求选出一个区间, 使得该区间是所有区间中经过如下计算的值最大的一个:区间中的最小数 * 区间所有数的和。注:数组中的元素都是非负数。
示例:
输入两行,第一行n表示数组长度,第二行为数组序列。输出最大值。
输入 3 6 2 1 输出 36 解释:满足条件区间是[6] = 6 * 6 = 36;
思路:最优解单调栈,注意单调栈内存的是索引
法1:使用暴力解,我们可以枚举数组中的最小值,然后向两边进行扩展,找到第一个比x小的元素,在寻找区间的过程中计算区间和。
法2:空间换时间,我们找边界的过程中可以使用单调栈,每个元素只进栈出栈一次,算法复杂度降到O(N)。这里在计算区间和时可以使用前缀和。
代码:
import java.util.Deque; import java.util.LinkedList; import java.util.Scanner; public class Solution004 { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(); int[] nums = new int[n]; int i = 0; while (n-- > 0) { nums[i++] = sc.nextInt(); } // System.out.println(solution(nums)); System.out.println(solution1(nums)); } public static int solution(int[] nums) { int n = nums.length; int l = 0, r = 0; int sum = 0; int max = 0; for (int i = 0; i < n; i++) { sum = nums[i]; l = i - 1; r = i + 1; while (l >= 0 && nums[l] >= nums[i]) { sum += nums[l--]; } while (r < n && nums[r] >= nums[i]) { sum += nums[r++]; } max = Math.max(max, sum * nums[i]); } return max; } // 单调栈优化 public static int solution1(int[] nums) { int n = nums.length; int l = 0, r = 0; int max = 0, cur = 0; Deque<Integer> stack = new LinkedList<>(); //前缀和便于快速求区间和,例如求[l,r]区间和=dp[r+1]-dp[l]。l和r的取值范围是[0,n) int[] sums = new int[n + 1]; for (int i = 1; i <= n; i++) { sums[i] = sums[i - 1] + nums[i - 1]; } for (int i = 0; i < n; i++) { while (!stack.isEmpty() && nums[i] <= nums[stack.peek()]) { cur = nums[stack.pop()]; //l和r是边界,因此区间是[l+1,r-1],其区间和dp[r]-dp[l+1] l = stack.isEmpty() ? -1 : stack.peek(); r = i; max = Math.max(max, cur * (sums[r] - sums[l + 1])); } stack.push(i); } while (!stack.isEmpty()) { cur = nums[stack.pop()]; l = stack.isEmpty() ? -1 : stack.peek(); r = n; max = Math.max(max, cur * (sums[r] - sums[l + 1])); } return max; } }
7.子数组最小值之和(907-中)
题目描述:给定一个整数数组 arr,找到 min(b) 的总和,其中 b 的范围为 arr 的每个(连续)子数组。
由于答案可能很大,因此 返回答案模 10^9 + 7 。
示例:
输入:arr = [3,1,2,4] 输出:17 解释: 子数组为 [3],[1],[2],[4],[3,1],[1,2],[2,4],[3,1,2],[1,2,4],[3,1,2,4]。 最小值为 3,1,2,4,1,1,2,1,1,1,和为 17。
思路: 这道题的本质在于找到数组中的每一个数作为最小值的范围,比如对于某个数nums[i]能够最小值以这种形式表示:左边连续m个数比nums[i]大,右边连续n个数比nums[i]大。
其实就是找以每个数左边和右边的最小值,中间的数一定都是大于当前这个数的(已经出栈)根据下标计算出这两个范围,根据上述公式计算即可。注意,可以在尾部添加0,保证剩余元素可以被弹出计算。
注意:在进行计算时,先将每个元素转成long型再计算,否则最后一个测试用例过不了。
代码:
private long mod = 1000000007; public int sumSubarrayMins(int[] arr) { long ans = 0; int n = arr.length; arr = Arrays.copyOf(arr, n + 1); arr[n] = 0; Deque<Integer> stack = new LinkedList<>(); for (int i = 0; i <= n; i++) { while (!stack.isEmpty() && arr[i] <= arr[stack.peek()]) { // 每个栈顶元素作为最小值 int index = stack.pop(); int lMin = !stack.isEmpty() ? stack.peek() : -1; int M = index - lMin - 1; int N = i - index - 1; ans += ((long)arr[index] * (M + 1) * (N + 1)) % mod; } stack.push(i); } return (int)(ans % mod); }
8.每日温度(739-中)
题目描述:请根据每日 气温 列表,重新生成一个列表。对应位置的输出为:要想观测到更高的气温,至少需要等待的天数。如果气温在这之后都不会升高,请在该位置用 0 来代替。
提示:气温 列表长度的范围是 [1, 30000]。每个气温的值的均为华氏度,都是在 [30, 100] 范围内的整数。
示例:
给定一个列表 temperatures = [73, 74, 75, 71, 69, 72, 76, 73], 你的输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]。
思路: 本题显然是计算当前元素与后边第一个比他大的元素距离,单调栈的典型性应用。
- 当前元素小于等于栈顶元素,入栈
- 当前元素大于栈顶元素,出栈,计算此时栈顶元素与下一个最大元素即当前元素的距离
注意:本题栈内元素可以不用出栈。
代码:
public int[] dailyTemperatures(int[] temperatures) { // 维护:从栈顶到栈底严格递增 Deque<Integer> stack = new LinkedList<>(); int n = temperatures.length; int[] ans = new int[n]; for (int i = 0; i < n; i++) { while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) { int peak = stack.pop(); ans[peak] = i - peak; } stack.push(i); } return ans; }