引言
本篇文章将介绍什么是单调栈,单调栈用来解决什么问题,以及它在算法体用的灵活运用,帮助你快速解决类似问题,算法学习的是思维,切勿死记代码流程。
栈结构:
一种先进后出的数据结构,类似于一个桶,只有一个出口,最先放入的最后取出。
单调栈:
单调栈就是保证栈内元素按照某种规则单调递增,或单调递减,注意不是栈内的元素的值的单调性。
举例: , 递增子序列为:,递增栈内存放的是值的索引:
解决什么问题?
给定一个数组
- 以 位置, ,求 的最近的位置。
- ,左边比 4 小的最近元素是 1,右边比 4 小的最近的元素是3。
- 以 位置, ,求 的最近的位置。
暴力解思路:
- 每个位置往左找,每个位置往右找,时间复杂度为 ,总的时间复杂度为
单调栈:
- 单调栈: 时间复杂度 ,空间复杂度 解决上述问题。
查找数组区间边界
1.查找小边界
题意:给定一个数组 ,找到左边和右边比这个数小、且离这个数最近的位置,如果对每一个数都想求到这样的信息,能不能整体代价达到 ?
示例:
- 返回:
暴力解:
- 遍历数组,每个位置 , 遍历 和 找到左边第一个比自己小的值,右边第一个比自己小的值。
- 遍历数组复杂度为 ,查找左右两边比自己小的值时间复杂度为 , 整体时间复杂度为
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;
}
单调栈解法:
- 栈中存放的是数组对应的索引,索引对应的值维持单调递增。
- 当待加入的元素比栈顶对应的元素小时,开始结算栈内元素,弹出栈顶元素结算左右边界,直到当前位置 为止,将 压入栈中。
- 最后遍历完数组后,还需要结算栈内元素。
- 事件复杂度 ,空间复杂度
/**
* 单调栈问题:在数组中想找到一个数,左边和右边比这个数小、且离这个数最近的位置
* 如果对每一个数都想求到这样的信息,能不能整体代价达到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.查找大边界
题意:给定一个数组 ,找到左边和右边比这个数大、且离这个数最近的位置,如果对每一个数都想求到这样的信息,能不能整体代价达到 ?
示例:
- 返回:
单调栈解法:保持栈内索引对应的值单调递减,只用改动 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.子数组累加及与其最小值的乘积问题
题目:给定一个只包含正数的 , 中任何一个子数组 ,一定都可以算出 是什么,那么所有子数组中,这个最大值是多少?
暴力解
思路分析:
- 对于一个长度为 的数组,其子数组的个数就是两层 for 循环中的区间 。
- 然后遍历区间 求解区间的和,及找到区间最小值,然后更新答案。
- 时间复杂度为,两层 for 循环 ,遍历区间求解和及最小值时间复杂度为 ,总的时间复杂度为
/**
* 暴力思路:首先以每个位置为开头,[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;
}
单调栈解法
- 以区间内的最小值为突破口,换种思路来想这个问题。
- 假定以位置 为最小值,往左窗口最远能到达的位置,往右窗口能最远达的位置。该区间内能确保一定是以 为最小值的。
- 那么问题就变成单调栈中,找左边比自己小的最近距离,右边比自己小的最近距离。
- 注意:题目求: ,那既然最小值我们固定了,窗口当然是越大越好,累加和与窗口的大小成正比。
- 那么就遍历整个数组,以每个位置为最小值,求区间能扩的最大范围。
此题还用到预处理技巧:
- 求区间内的累加和问题,典型的预处理数组技巧。首先遍历一遍数组,求出每个位置 表示 的累加和。
- 这样区间累加就可以直接通过 数组拿到。
复杂度计算:
- 预处理累加和数组,时间复杂度 ,空间复杂度 。
- 遍历数组,每个元素最多只会进栈一次,出栈一次,所以总的时间复杂度为
- 时间复杂度 ,空间复杂度 拿下
/**
* 优化思路:
* 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.柱状图中最大的矩形
思路:
- 这不就是以每个柱子为高,找左边离自己最近的位置,右边离自己最近的位置。中间就是以当前位置为高能扩的最远距离。
- 注意:在最后结算的时候,右边界都是 -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;
}