本文的网课内容学习自B站左程云老师的算法详解课程,旨在对其中的知识进行整理和分享~
一.单调栈结构
题目:单调栈结构(进阶)
算法原理
-
单调栈的基本概念
- 单调栈是一种特殊的数据结构,在这个问题中,我们使用单调递增栈(栈顶到栈底元素值单调递增)。
- 它的主要作用是在一次遍历中高效地找到每个元素左右两侧满足特定条件(比当前元素值小)的元素位置。
-
算法的遍历阶段(compute函数中的第一个循环)
- 当遍历数组
arr
时,对于每个元素arr[i]
:- 只要栈不为空(
r > 0
)并且栈顶元素对应的数组值arr[stack[r - 1]]
大于等于arr[i]
,就弹出栈顶元素。 - 对于弹出的元素
cur = stack[--r]
:- 计算其左边最近且值比
arr[cur]
小的位置,如果栈不为空(r > 0
),则是stack[r - 1]
,否则是 -1。 - 其右边最近且值比
arr[cur]
小的位置就是当前遍历到的i
。
- 计算其左边最近且值比
- 然后将当前位置
i
压入栈中(stack[r++] = i
)。
- 只要栈不为空(
- 这个过程实际上是在维护一个单调递增的栈,并且在弹出元素时,确定了该元素左右两侧满足条件的位置。
- 当遍历数组
-
算法的清算阶段(compute函数中的第二个循环)
- 当遍历完整个数组后,栈中可能还剩余一些元素。
- 对于这些剩余元素
cur = stack[--r]
:- 计算其左边最近且值比
arr[cur]
小的位置,如果栈不为空(r > 0
),则是stack[r - 1]
,否则是 -1。 - 其右边最近且值比
arr[cur]
小的位置是 -1,因为这些元素是遍历完数组后栈中剩余的,右边没有更小的值了。
- 计算其左边最近且值比
-
算法的修正阶段(compute函数中的第三个循环)
- 由于在前面的计算中,当右侧值相等时可能得到不准确的结果,所以需要从右往左修正右侧的答案。
- 对于位置
i
(从n - 2
到0):- 如果
ans[i][1]
(右侧最近且小的位置)不为 -1并且arr[ans[i][1]]==arr[i]
,这意味着右侧位置的值与当前位置的值相等。 - 此时,将
ans[i][1]
更新为ans[ans[i][1]][1]
,也就是右侧位置的右侧最近且小的位置,以得到准确的结果。
- 如果
代码实现
// 单调栈求每个位置左右两侧,离当前位置最近、且值严格小于的位置
// 给定一个可能含有重复值的数组 arr
// 找到每一个 i 位置左边和右边离 i 位置最近且值比 arr[i] 小的位置
// 返回所有位置相应的信息。
// 输入描述:
// 第一行输入一个数字 n,表示数组 arr 的长度。
// 以下一行输入 n 个数字,表示数组的值
// 输出描述:
// 输出n行,每行两个数字 L 和 R,如果不存在,则值为 -1,下标从 0 开始。
// 测试链接 : https://www.nowcoder.com/practice/2a2c00e7a88a498693568cef63a4b7bb
// 请同学们务必参考如下代码中关于输入、输出的处理
// 这是输入输出处理效率很高的写法
// 提交以下的code,提交时请把类名改成"Main",可以直接通过
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
public class Code01_LeftRightLess {
public static int MAXN = 1000001;
public static int[] arr = new int[MAXN];
public static int[] stack = new int[MAXN];
public static int[][] ans = new int[MAXN][2];
public static int n, r;
public static void main(String[] args) throws IOException {
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();
}
// arr[0...n-1]
public static void compute() {
r = 0;
int cur;
// 遍历阶段
for (int i = 0; i < n; i++) {
// i -> arr[i]
while (r > 0 && arr[stack[r - 1]] >= arr[i]) {
cur = stack[--r];
// 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;
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];
}
}
}
}
二.每日温度
题目:每日温度
算法原理
-
整体思路
- 这个算法主要利用了单调栈的思想来解决“每日温度”的问题。单调栈是一种特殊的数据结构,在这个算法中,栈中的元素索引对应的温度值是单调递增的。通过遍历温度数组,利用单调栈可以高效地找到每个温度下一个更高温度出现的天数间隔。
-
算法步骤
- 初始化
- 定义了一个最大长度为
MAXN = 100001
的数组stack
,用于存储温度数组的索引,r
用于表示栈顶指针(初始化为0),同时创建一个与输入温度数组nums
长度相同的结果数组ans
,用于存储每个温度下一个更高温度出现的天数间隔,初始化为0。
- 定义了一个最大长度为
- 遍历温度数组
nums
(for
循环)- 对于每个温度
nums[i]
:- 处理单调栈(
while
循环)- 当栈不为空(
r > 0
)并且栈顶元素对应的温度nums[stack[r - 1]]
小于当前温度nums[i]
时:- 弹出栈顶元素,将其索引存储在
cur
中(cur = stack[--r];
)。 - 计算当前温度
nums[i]
与弹出元素对应的温度nums[cur]
的天数间隔,将其存储在结果数组ans
中(ans[cur] = i - cur;
)。这个间隔就是当前索引i
减去弹出元素的索引cur
。
- 弹出栈顶元素,将其索引存储在
- 当栈不为空(
- 将当前索引
i
压入栈(stack[r++] = i;
)- 无论是否弹出了栈顶元素,都将当前索引
i
压入栈中。如果没有弹出元素,说明当前温度nums[i]
小于等于栈顶元素对应的温度,需要等待后续更高的温度来处理;如果弹出了元素,将当前索引压入栈后,栈仍然保持单调递增的性质。
- 无论是否弹出了栈顶元素,都将当前索引
- 处理单调栈(
- 对于每个温度
- 最终结果
- 遍历完整个温度数组后,结果数组
ans
中存储了每个温度下一个更高温度出现的天数间隔,返回这个结果数组。
- 遍历完整个温度数组后,结果数组
- 初始化
代码实现
// 每日温度
// 给定一个整数数组 temperatures ,表示每天的温度,返回一个数组 answer
// 其中 answer[i] 是指对于第 i 天,下一个更高温度出现在几天后
// 如果气温在这之后都不会升高,请在该位置用 0 来代替。
// 测试链接 : https://leetcode.cn/problems/daily-temperatures/
public class Code02_DailyTemperatures {
public static int MAXN = 100001;
public static int[] stack = new int[MAXN];
public static int r;
public static int[] dailyTemperatures(int[] nums) {
int n = nums.length;
int[] ans = new int[n];
r = 0;
for (int i = 0, cur; i < n; i++) {
// 相等时候的处理,相等也加入单调栈
while (r > 0 && nums[stack[r - 1]] < nums[i]) {
cur = stack[--r];
ans[cur] = i - cur;
}
stack[r++] = i;
}
return ans;
}
}
三.子数组的最小值之和
题目:子数组的最小值之和
算法原理
-
整体思路
- 这个算法利用单调栈来高效地计算给定数组所有连续子数组的最小值之和。通过维护一个单调栈(栈中元素对应的数组值单调递增),可以快速确定每个元素作为最小值的子数组的数量,进而计算出这些子数组最小值之和。
-
算法步骤
- 初始化
- 定义了取模值
MOD = 1000000007
,栈的最大长度MAXN = 30001
,用于存储数组索引的栈stack
,栈顶指针r
初始化为0,以及用于存储结果的ans
初始化为0。
- 定义了取模值
- 遍历数组
arr
(外层for
循环)- 对于数组中的每个元素
arr[i]
:- 处理单调栈(内层
while
循环)- 当栈不为空(
r > 0
)且栈顶元素对应的数组值arr[stack[r - 1]]
大于等于arr[i]
时:- 弹出栈顶元素,将其索引存储为
cur
(int cur = stack[--r];
)。 - 确定
cur
左边的索引left
,如果r == 0
,则left = -1
,否则left = stack[r - 1];
。 - 计算以
arr[cur]
为最小值的子数组数量对结果的贡献,并累加到ans
中。这个贡献的计算基于子数组的左右边界,即(cur - left)
表示左边的子数组数量,(i - cur)
表示右边的子数组数量,所以这部分子数组最小值之和为(cur - left) * (i - cur) * arr[cur]
,然后将其对MOD
取模后累加到ans
(ans = (ans+(long)(cur - left) * (i - cur) * arr[cur]) % MOD;
)。
- 弹出栈顶元素,将其索引存储为
- 当栈不为空(
- 将当前索引
i
压入栈(stack[r++] = i;
)- 无论是否弹出了栈顶元素,都将当前索引
i
压入栈中,以保持单调栈的性质,为后续元素的处理做准备。
- 无论是否弹出了栈顶元素,都将当前索引
- 处理单调栈(内层
- 对于数组中的每个元素
- 处理栈中剩余元素(外层
while
循环之后的while
循环)- 当遍历完整个数组后,栈中可能还剩余一些元素。对于每个剩余元素
cur
(从栈顶开始依次处理):- 同样确定
cur
左边的索引left
,计算以arr[cur]
为最小值的剩余子数组数量对结果的贡献,并累加到ans
中。这里(cur - left)
表示左边的子数组数量,(arr.length - cur)
表示右边的子数组数量,所以这部分子数组最小值之和为(cur - left) * (arr.length - cur) * arr[cur]
,然后将其对MOD
取模后累加到ans
(ans = (ans+(long)(cur - left) * (arr.length - cur) * arr[cur]) % MOD;
)。
- 同样确定
- 当遍历完整个数组后,栈中可能还剩余一些元素。对于每个剩余元素
- 最终结果
- 最后将
ans
转换为int
类型并返回,这个值就是所有连续子数组最小值之和对MOD
取模的结果。
- 最后将
- 初始化
代码实现
// 子数组的最小值之和
// 给定一个整数数组 arr,找到 min(b) 的总和,其中 b 的范围为 arr 的每个(连续)子数组。
// 由于答案可能很大,答案对 1000000007 取模
// 测试链接 : https://leetcode.cn/problems/sum-of-subarray-minimums/
public class Code03_SumOfSubarrayMinimums {
public static int MOD = 1000000007;
public static int MAXN = 30001;
public static int[] stack = new int[MAXN];
public static int r;
public static int sumSubarrayMins(int[] arr) {
long ans = 0;
r = 0;
// 注意课上讲的相等情况的修正
for (int i = 0; i < arr.length; i++) {
while (r > 0 && arr[stack[r - 1]] >= arr[i]) {
int cur = stack[--r];
int left = r == 0 ? -1 : stack[r - 1];
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;
}
}
四.柱状图中最大的矩形
题目:柱状图中最大的矩形
算法原理
-
整体思路
- 这个算法主要利用单调栈的思想来求解柱状图中能够勾勒出的矩形的最大面积。通过维护一个单调栈(栈中元素对应的柱状高度单调递增),可以快速确定每个柱子作为矩形高度时,矩形的最大宽度,从而计算出最大面积。
-
算法步骤
- 初始化
- 定义了栈的最大长度
MAXN = 100001
,用于存储柱子索引的栈stack
,栈顶指针r
初始化为0。同时定义变量ans
用于存储最大矩形面积,初始值为0,还有用于临时存储栈顶元素索引的cur
和栈顶元素左边索引的left
。
- 定义了栈的最大长度
- 遍历高度数组(
for
循环)- 对于高度数组
height
中的每个元素height[i]
:- 处理单调栈(
while
循环)- 当栈不为空(
r > 0
)且栈顶元素对应的高度height[stack[r - 1]]
大于等于height[i]
时:- 弹出栈顶元素,将其索引存储为
cur
(cur = stack[--r];
)。 - 确定
cur
左边的索引left
,如果r == 0
,则left = -1
,否则left = stack[r - 1];
。 - 计算以
height[cur]
为高的矩形面积,宽度为i - left - 1
(ans = Math.max(ans, height[cur] * (i - left - 1));
)。
- 弹出栈顶元素,将其索引存储为
- 当栈不为空(
- 将当前索引
i
压入栈(stack[r++] = i;
)- 无论是否弹出了栈顶元素,都将当前索引
i
压入栈中,以保持单调栈的性质。
- 无论是否弹出了栈顶元素,都将当前索引
- 处理单调栈(
- 对于高度数组
- 处理栈中剩余元素(
while
循环)- 当遍历完整个高度数组后,栈中可能还剩余一些元素。对于每个剩余元素
cur
(从栈顶开始依次处理):- 确定
cur
左边的索引left
,计算以height[cur]
为高的矩形面积,宽度为height.length - left - 1
(ans = Math.max(ans, height[cur] * (n - left - 1));
)。
- 确定
- 当遍历完整个高度数组后,栈中可能还剩余一些元素。对于每个剩余元素
- 最终结果
- 最后返回
ans
,这个值就是在给定柱状图中能够勾勒出来的矩形的最大面积。
- 最后返回
- 初始化
代码实现
// 柱状图中最大的矩形
// 给定 n 个非负整数,用来表示柱状图中各个柱子的高度
// 每个柱子彼此相邻,且宽度为 1 。求在该柱状图中,能够勾勒出来的矩形的最大面积
// 测试链接:https://leetcode.cn/problems/largest-rectangle-in-histogram
public class Code04_LargestRectangleInHistogram {
public static int MAXN = 100001;
public static int[] stack = new int[MAXN];
public static int r;
public static int largestRectangleArea(int[] height) {
int n = height.length;
r = 0;
int ans = 0, cur, left;
for (int i = 0; i < n; i++) {
// i -> arr[i]
while (r > 0 && height[stack[r - 1]] >= height[i]) {
cur = stack[--r];
left = r == 0 ? -1 : stack[r - 1];
ans = Math.max(ans, height[cur] * (i - left - 1));
}
stack[r++] = i;
}
while (r > 0) {
cur = stack[--r];
left = r == 0 ? -1 : stack[r - 1];
ans = Math.max(ans, height[cur] * (n - left - 1));
}
return ans;
}
}
五. 全是1的最大矩形
题目:最大矩形
算法原理
-
整体思路
- 这个算法用于解决在一个仅包含0和1的二维二进制矩阵中找出只包含1的最大矩形的问题。算法采用了一种基于高度数组和单调栈的方法,将二维问题转化为多次处理一维的最大矩形面积问题。
-
算法步骤
maximalRectangle
方法- 初始化
- 定义了矩阵的行数为
n
,列数为m
,并初始化高度数组height
中的元素为0。同时定义变量ans
用于存储最大矩形面积,初始值为0。
- 定义了矩阵的行数为
- 逐行处理矩阵(外层
for
循环)- 对于每一行
i
:- 构建高度数组(内层
for
循环)- 遍历当前行的每一列
j
,如果当前位置grid[i][j]
为0
,则将高度数组height[j]
设为0;如果为1
,则将height[j]
的值在原来基础上加1(表示以当前行为底,从第0行到当前行的连续1的高度)。
- 遍历当前行的每一列
- 计算以当前行作为底的最大矩形面积
- 调用
largestRectangleArea
方法计算以当前高度数组height
为基础的最大矩形面积,并将结果与ans
比较,取较大值更新ans
(ans = Math.max(largestRectangleArea(m), ans);
)。
- 调用
- 构建高度数组(内层
- 对于每一行
- 初始化
largestRectangleArea
方法- 初始化
- 定义栈顶指针
r
为0,变量ans
用于存储最大矩形面积(初始值为0),以及用于临时存储栈顶元素索引的cur
和栈顶元素左边索引的left
。
- 定义栈顶指针
- 遍历高度数组(外层
for
循环)- 对于高度数组中的每个元素
height[i]
:- 处理单调栈(内层
while
循环)- 当栈不为空(
r > 0
)且栈顶元素对应的高度height[stack[r - 1]]
大于等于height[i]
时:- 弹出栈顶元素,将其索引存储为
cur
(cur = stack[--r];
)。 - 确定
cur
左边的索引left
,如果r == 0
,则left = -1
,否则left = stack[r - 1];
。 - 计算以
height[cur]
为高的矩形面积,宽度为i - left - 1
(ans = Math.max(ans, height[cur] * (i - left - 1));
)。
- 弹出栈顶元素,将其索引存储为
- 当栈不为空(
- 将当前索引
i
压入栈(stack[r++] = i;
)- 无论是否弹出了栈顶元素,都将当前索引
i
压入栈中,以保持单调栈的性质。
- 无论是否弹出了栈顶元素,都将当前索引
- 处理单调栈(内层
- 对于高度数组中的每个元素
- 处理栈中剩余元素(外层
while
循环之后的while
循环)- 当遍历完整个高度数组后,栈中可能还剩余一些元素。对于每个剩余元素
cur
(从栈顶开始依次处理):- 确定
cur
左边的索引left
,计算以height[cur]
为高的矩形面积,宽度为m - left - 1
(ans = Math.max(ans, height[cur] * (m - left - 1));
)。
- 确定
- 当遍历完整个高度数组后,栈中可能还剩余一些元素。对于每个剩余元素
- 最终结果
- 最后返回
ans
,这个值就是以当前高度数组为基础的最大矩形面积。
- 最后返回
- 初始化
代码实现
import java.util.Arrays;
// 最大矩形
// 给定一个仅包含 0 和 1 、大小为 rows * cols 的二维二进制矩阵
// 找出只包含 1 的最大矩形,并返回其面积
// 测试链接:https://leetcode.cn/problems/maximal-rectangle/
public class Code05_MaximalRectangle {
public static int MAXN = 201;
public static int[] height = new int[MAXN];
public static int[] stack = new int[MAXN];
public static int r;
public static int maximalRectangle(char[][] grid) {
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行,长方形一定要以i行做底!
// 加工高度数组(压缩数组)
for (int j = 0; j < m; j++) {
height[j] = grid[i][j] == '0' ? 0 : height[j] + 1;
}
ans = Math.max(largestRectangleArea(m), ans);
}
return ans;
}
public static int largestRectangleArea(int m) {
r = 0;
int ans = 0, cur, left;
for (int i = 0; i < m; i++) {
// i -> arr[i]
while (r > 0 && height[stack[r - 1]] >= height[i]) {
cur = stack[--r];
left = r == 0 ? -1 : stack[r - 1];
ans = Math.max(ans, height[cur] * (i - left - 1));
}
stack[r++] = i;
}
while (r > 0) {
cur = stack[--r];
left = r == 0 ? -1 : stack[r - 1];
ans = Math.max(ans, height[cur] * (m - left - 1));
}
return ans;
}
}
六.总结
单调栈最经典的用法是解决如下问题:
每个位置都求:
1)当前位置的左侧比当前位置的数字小,且距离最近的位置在哪
2)当前位置的右侧比当前位置的数字小,且距离最近的位置在哪
或者
每个位置都求:
1)当前位置的左侧比当前位置的数字大,且距离最近的位置在哪
2)当前位置的右侧比当前位置的数字小,且距离最近的位置在哪
用单调栈的方式可以做到:求解过程中,单调栈所有调整的总代价为O(n),单次操作的均摊代价为O(1)
注意:这是单调栈最经典的用法,可以解决很多题目,下篇文章将继续介绍其他的用法
注意:单调栈可以和很多技巧交叉使用!比兔:动态规划+单调栈优化,会在【扩展】系列讲述