单调栈
单调栈基础概念
单调栈是一种特殊的栈数据结构,其核心特性是栈内元素保持单调递增或递减的顺序。它主要用于高效解决数组中元素与其最近更大或更小值的查找问题,时间复杂度可优化至 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N ) O(N) </math>O(N)
核心特性
-
单调性:
- 单调递增栈:从栈顶到栈底,元素严格递增。常用于寻找元素右侧第一个更小值。
- 单调递减栈:从栈顶到栈底,元素严格递减。常用于寻找元素右侧第一个更大值。
-
工作原理:
- 遍历数组时,每个元素与栈顶元素比较:
- 若当前元素破坏栈的单调性,则弹出栈顶元素,直到满足单调性为止。
- 将当前元素压入栈中。
- 通过这一过程,栈内始终维持单调性,从而快速定位目标值。
- 遍历数组时,每个元素与栈顶元素比较:
典型应用场景
-
寻找最近更大/更小元素:
- 对每个元素,找到其左侧或右侧第一个比它大或小的元素。例如:
- 数组
[4, 2, 6, 1, 7]
中,使用单调递增栈可得到每个元素右侧最近的更小值[2, 1, 1, -1, -1]
。 - 数组
[2, 1, 4, 3, 5]
中,单调递减栈可找到右侧最近的更大值[4, 4, 5, 5, -1]
[2]。
- 数组
- 对每个元素,找到其左侧或右侧第一个比它大或小的元素。例如:
-
复杂问题优化:
- 柱状图最大矩形面积、滑动窗口极值、循环数组问题等。
实现示例(以单调递增栈为例)
java
Stack<Integer> stack = new Stack<>();
for (int num : nums) {
while (!stack.isEmpty() && stack.peek() > num) {
stack.pop(); // 弹出破坏单调性的元素
}
stack.push(num);
}
总结
单调栈通过维护元素的单调顺序,将原本需 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N 2 ) O(N^2) </math>O(N2) 的遍历问题优化为线性复杂度。其关键在于通过弹栈操作动态调整栈结构,确保每次入栈后仍保持单调性。
T739-每日温度
见LeetCode第739题[每日温度]
题目描述
给定一个整数数组 temperatures
,表示每天的温度,返回一个数组 answer
,其中 answer[i]
是指对于第 i
天,下一个更高温度出现在几天后。如果气温在这之后都不会升高,请在该位置用 0
来代替。
示例 1:
ini
输入: temperatures = [73,74,75,71,69,72,76,73]
输出: [1,1,4,2,1,1,0,0]
我的思路
本题要求解右侧最近较大元素,如果使用暴力求解,则时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N 2 ) O(N^2) </math>O(N2),可以考虑使用单调栈求解。
因为要求得右侧最近最大的元素的下标,因此,我们需要保证单调栈中的元素单调递减。对于破坏单调栈中的元素,需要将其弹出,记录待入栈元素的索引。
问题:如何获得栈顶元素的索引???
可以在栈中,将元素的下标和值都存储起来。
java
/**
* 每日温度
* @param temperatures
* @return
*/
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] records = new int[n];
if (n <= 1) return records;
Stack<int[]> stack = new Stack<>();
for (int i = 0; i < n; i++) {
while (!stack.empty() && stack.peek()[0] < temperatures[i]) {
// 将栈顶元素弹出,并记录当前坐标,
int[] top = stack.pop();
records[top[1]] = i - top[1];
}
// 将当前元素入栈
stack.push(new int[]{temperatures[i], i});
}
return records;
}
计算复杂度分析
- 时间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N ) O(N) </math>O(N)
- 空间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N ) O(N) </math>O(N)
优化思路
栈中不必要存储值,只需要存储坐标即可,可以使用坐标从数组中获取到值。
T496-下一个更大元素I
见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]
**是如上所述的 下一个更大元素 。
示例
ini
输入: 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 。
我的思路
首先处理集合nums2
,使用单调栈,记录每一个元素的最近较大的元素值,然后将其存储到map
中。因为是最近较大值,所以应该保证单调栈中的元素是单调递减的。
然后遍历子集nums1
,对于元素num
,从map
中取得其最近较大值即可。
java
private final HashMap<Integer, Integer> nextGreaterMap = new HashMap<>();
/**
* 下一个更大的元素
* @param nums1
* @param nums2
* @return
*/
public int[] nextGreaterElement(int[] nums1, int[] nums2) {
// 通过单调栈获取每个元素的最近较大元素
Stack<Integer> stack = new Stack<>();
for (int i = 0; i < nums2.length; i++) {
// 保证单调递减
while (!stack.isEmpty() && nums2[stack.peek()] < nums2[i]) {
nextGreaterMap.put(nums2[stack.peek()], nums2[i]);
stack.pop();
}
stack.push(i);
}
for (int i = 0; i < nums1.length; i++) {
nums1[i] = nextGreaterMap.getOrDefault(nums1[i], -1);
}
return nums1;
}
计算复杂度分析
- 时间复杂度: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( M + N ) O(M + N) </math>O(M+N),分别遍历了两个数组中的元素
- 空间复杂度: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( 2 × N ) O(2\times N) </math>O(2×N),一个哈希表,一个栈
T503-下一个更大的元素II
见LeetCode第503题[下一个更大的元素II]
题目描述
给定一个循环数组 nums
( nums[nums.length - 1]
的下一个元素是 nums[0]
),返回 nums
中每个元素的 下一个更大元素 。
数字 x
的 下一个更大的元素 是按数组遍历顺序,这个数字之后的第一个比它更大的数,这意味着你应该循环地搜索它的下一个更大的数。如果不存在,则输出 -1 。
示例 1:
ini
输入: nums = [1,2,1]
输出: [2,-1,2]
解释:
第一个 1 的下一个更大的数是 2;
数字 2 找不到下一个更大的数;
第二个 1 的下一个最大的数需要循环搜索,结果也是 2。
我的思路
循环数组的最近较大元素。
循环数组右边最近最大元素,可能出现在其左边。如果循环两遍数组的话,是不是就可以发现某个数的右边较大数在左边的情况呢?
答案貌似是肯定的,可以先试一下。
java
private final HashMap<Integer, Integer> nextGreaterMap = new HashMap<>();
/**
* 循环数组的下一个最大元素
* @param nums
* @return
*/
public int[] nextGreaterElements(int[] nums) {
if (nums.length == 1) return new int[]{-1};
int n = nums.length;
Stack<Integer> stack = new Stack<>();
for (int i = 0; i < 2 * n - 1; i++) {
while (!stack.isEmpty() && nums[stack.peek()] < nums[i % n]) {
// 记录当前值的最近最大值
nextGreaterMap.putIfAbsent(stack.pop(), nums[i % n]);
}
stack.push(i % n);
}
Arrays.fill(nums, -1);
for (Map.Entry<Integer, Integer> entry : nextGreaterMap.entrySet()) {
nums[entry.getKey()] = entry.getValue();
}
return nums;
}
计算复杂度分析
- 时间复杂度: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( 2 × N ) O(2\times N) </math>O(2×N)
- 空间复杂度: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( 2 × N ) O(2\times N) </math>O(2×N)
优化思路
- 使用数组来替代栈
- 根本不需要使用
Map
,在迭代的过程中,直接将计算结果存储到结果数组中
T42-接雨水
见LeetCode第42题[接雨水]
题目描述
给定 n
个非负整数表示每个宽度为 1
的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
示例
css
输入: 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 个单位的雨水(蓝色部分表示雨水)。
我的思路
先从左边开始遍历,用变量curMax
记录当前左边的最大值。如果当前的高度小于左边最大高度,则将当前的差值,记录在int[] waters
中。如果当前的高度大于左边最大值,则更新最大值curMax
。经过一轮循环之后,得到的数组waters
右边柱子无限大的时候,理论能够盛到的最多的雨水。
同理,再从右边往左边遍历,相同的思路,再次更新waters
,其中waters
在更新的时候,为两次遍历的最小值 。此外,当当前柱子小于右边最大高度的时候,除了要更新最大高度curMax
之外,还需要将当前的理论接水值waters[i]
记为0。
最后,将waters[]
中的所有值进行累加即可得到最终结果。
java
/**
* 接雨水
* @param height
* @return
*/
public int trap(int[] height) {
if (height.length <= 2) return 0;
int[] waters = new int[height.length];
int leftMax = height[0];
// 首先从左往右遍历
for (int i = 1; i < height.length - 1; i++) {
if (height[i] < leftMax) {
waters[i] = leftMax - height[i];
} else {
leftMax = height[i];
}
}
// 从右往左遍历
int rightMax = height[height.length - 1];
for (int i = height.length - 2; i > 0; i--) {
if (height[i] < rightMax) {
waters[i] = Math.min(waters[i], rightMax - height[i]);
} else {
waters[i] = 0;
rightMax = height[i];
}
}
return Arrays.stream(waters).sum();
}
计算复杂度分析
- 时间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N ) O(N) </math>O(N),遍历了3次数组
- 空间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N ) O(N) </math>O(N),中间数组用来存储理论可以接到的雨水
优化思路
其实不必要遍历两次数组,只需要一次遍历即可。
使用双指针的方式,左右两个指针交替更新,直到两个指针相遇。
使用变量分别记录左边的最大值leftMax
,右边的最大值rightMax
。
先从左边开始遍历,如果当前的值height[l] < rightMax
,则表示可以储水,并且水量为leftMax - height[l]
。直到height[l] > rightMax
,开始移动右边的指针right
。
java
/**
* 双指针解决接雨水
* @param height
* @return
*/
public int trapI(int[] height) {
if (height.length <= 2) return 0;
int n = height.length;
int l = 0;
int r = n - 1;
int leftMax = 0;
int rightMax = 0;
int waters = 0;
while (l < r) {
// 更新左右的最高柱子
leftMax = Math.max(leftMax, height[l]);
rightMax = Math.max(rightMax, height[r]);
// 如果当前的柱子小于右边最大柱子,则表示当前的柱子可以储水
if (height[l] < rightMax) {
waters += leftMax - height[l++];
} else {
// 移动右边的指针,右边柱子小于左边最高柱子
waters += rightMax - height[r--];
}
}
return waters;
}
计算复杂度分析
- 时间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N ) O(N) </math>O(N)
- 空间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( 1 ) O(1) </math>O(1)
T84-柱状图的最大矩形
见LeetCode第84题[柱状图的最大矩形]
题目描述
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
示例
ini
输入: heights = [2,1,5,6,2,3]
输出: 10
解释: 最大的矩形为图中红色区域,面积为 10
我的思路
这个题目像是木桶效应,如何算出来木桶的面积?
首先思考一个问题,从左到右的每一根木头,我们想要木块是怎么样的规律?单调递增还是单调递减?
答案显而易见,单调递增。因为我们是从左往右遍历,如果不满足递增,还可以拐回来,将之前的木头锯一下,总不可能是为了单调递减补一下之前的木头吧。
有了目标,这样我们就可以使用我们本章的主题【单调栈】来确保我们的木头都是单调递增的了。从左到右依次遍历我们的木头集合,
如果当前待入栈的木头大于栈顶木头,应该怎么处理?先把栈顶木头拉出来,计算一下他的长度,然后跟新一下最大的面积,然后将其用锯子锯为次高值。注意,此时可能有多根木头被锯为次高值。
如果发现次高值还是不行,还是大于当前的木头,那么再次重复此步骤,并更新最大值,直到满足栈顶木头小于等于当前的木头,然后当前的木头进栈。
遍历完最后一根木头之后,我们需要往里面添加最后一根0长度的木头,重复上面的步骤,这样就会计算出整个递增木头集合的最大面积,然后更新最大面积。