单调栈结构及其应用
解决的一般问题
对于一个数组中的每个元素,求出该元素左边比它大同时距离它最近的数,以及该元素右边比它大同时距离它最近的数。暴力法的复杂度在O(n2),要求在O(n)的时间复杂度内完成。
压入和弹出数据的规则:对于上面的一般问题,我们让一个栈从栈底到栈顶按从大到小的规则放入数据。
- 从左往右遍历数组,对于一个数组元素num,如果栈为空或者num小于栈顶元素,num直接入栈,此时符合单调的规则。
- 如果num大于栈顶元素,此时num不能直接入栈,需要将栈顶元素top弹出,那么top的左边最近比它大的就是num,top右边最近比它大的就是新的栈顶元素(因为此时top已经被弹出了)。特殊情况:如果top本身就是栈中唯一的元素,弹出之后栈就为空了,那么就表示top的右边没有比它大的元素。
- top元素考虑完之后,继续考虑num和新的栈顶元素之间的大小关系,重复上述步骤,直到num可以放入栈中。
- 当数组元素考虑完了,但此时栈中还有元素。栈中元素需要出栈,注意此时的出栈并不是像之前的情况(有新元素想要入栈,但是会破坏单调的条件),而是数组元素都考虑完了之后将栈中元素出栈。首先弹出栈顶元素top,top的右边最近比它大的元素不存在,左边最近比它大的元素是新的栈顶元素。对于剩下的元素重复上述步骤,直到栈为空。
对于数组{5,4,3,6,5,3}
,单调栈的入栈和出栈图示如下:
对于特殊情况:单调栈具有严格的单调性,如果将要入栈的元素num和栈顶元素top相等,则进行下标合并,也就是说栈顶元素包含两个下标。
正确性验证:当前栈中的元素从底向上依次是b和a,此时c要入栈,假设 c > a c > a c>a。
- 首先需要验证c是a的右边最近的大于a的数。反证法。假设a和c之间有一个比a大的数x,那么x入栈的时候肯定会把a弹出,也就轮不到c来把a弹出,所以假设不成立,验证了c是最近的大于a的数。
- 然后验证b是a的左边最近的大于a的数。根据单调栈的单调性, b > a b > a b>a 成立。b和a之间存在某一个数 x x x ,如果 x > b x > b x>b ,那么 x x x 入栈的时候,b已经被弹出了,假设不成立;如果 b > x > a b > x > a b>x>a,那么一定存在 x x x 位于栈中的b和a之间,假设不成立。所以b是a的左边最近的大于a的数。
单调栈的应用:求最大子矩阵的大小
题目:给定一个整型矩阵map,其中的值只有0和1两种,求其中全是1的所有矩形区域中,最大的矩形区域1的数量。
例如:
1、矩阵:
[
1
1
1
0
]
\begin{bmatrix} 1 & 1 & 1 & 0 \end{bmatrix}
[1110]
该矩阵中,最大的矩形区域有3个1,所以返回3。
2、矩阵:
[
1
0
1
1
1
1
1
1
1
1
1
0
]
\begin{bmatrix} 1 & 0 & 1 & 1\\ 1 & 1 & 1 & 1\\ 1 & 1 & 1 & 0 \end{bmatrix}
⎣⎡111011111110⎦⎤
该矩阵中,最大的矩形区域为左下角的小矩形,共有6个1,所以返回6。
思路:
解决这个问题必须要先解决一个前置问题,就是对于一个数组,比如{4,3,2,5,6}
,将它看成是一个直方图,每个数字表示直方图的高度,求整个直方图中的最大矩形面积。如下图所示,图中阴影部分就是最大矩形面积。
解决这个问题采用单调栈,因为直方图中当前位置的矩形要和边上的矩形合并出一个更大的矩形,那么边上的矩形必须要大于或者等于当前位置的矩形,所以我们用单调栈来找当前位置的矩形左右两边最近的小于它的位置。
代码如下:
public class Solution {
public static int maxRecArea(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
Stack<Integer> stack = new Stack<>();
int maxArea = 0;
for (int i = 0; i < nums.length; i++) {
// 不符合单调栈的单调规则的情况
while (!stack.empty() && nums[i] < nums[stack.peek()]) {
int j = stack.pop();
int left = stack.empty() ? -1 : stack.peek();
int curArea = (i - left - 1) * nums[j];
maxArea = Math.max(maxArea, curArea);
}
stack.push(i);
}
// 数组遍历完,处理栈中剩余的元素
while (!stack.empty()) {
int j = stack.pop();
int left = stack.empty() ? -1 : stack.peek();
int curArea = (nums.length - left - 1) * nums[j];
maxArea = Math.max(maxArea, curArea);
}
return maxArea;
}
}
解决了前置问题,对于题目中的矩阵,从上往下按行遍历,每一行加上这一行上面的所有矩阵元素都构成一个直方图,求此直方图的最大矩形面积,最后矩阵遍历完,就得到全局的最大矩形面积。时间复杂度:O(n * m)
代码如下:
public class Solution {
public static int maxRecArea(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
Stack<Integer> stack = new Stack<>();
int maxArea = 0;
for (int i = 0; i < nums.length; i++) {
while (!stack.empty() && nums[i] < nums[stack.peek()]) {
int j = stack.pop();
int left = stack.empty() ? -1 : stack.peek();
int curArea = (i - left - 1) * nums[j];
maxArea = Math.max(maxArea, curArea);
}
stack.push(i);
}
while (!stack.empty()) {
int j = stack.pop();
int left = stack.empty() ? -1 : stack.peek();
int curArea = (nums.length - left - 1) * nums[j];
maxArea = Math.max(maxArea, curArea);
}
return maxArea;
}
public static int maxRecAreaInMatrix(int[][] matrix) {
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
return 0;
}
int row = matrix.length;
int col = matrix[0].length;
int maxArea = 0;
int[] height = new int[col];
for (int i = 0; i < row; i++) {
for (int j = 0; j < col; j++) {
height[j] = matrix[i][j] != 0 ? height[j] + 1 : 0;
}
maxArea = Math.max(maxArea, maxRecArea(height));
}
return maxArea;
}
public static void main(String[] args) {
int[][] matrix = {{1,0,1,1}, {1,1,1,1}, {1,1,1,0}};
System.out.println(maxRecAreaInMatrix(matrix));
}
}
单调栈的应用:可以看到烽火的山峰对
题目:给出一个数组表示一个环形山峰,数组里面的元素表示山峰的高度。山峰上会放烽火,两座山峰可以互相看到烽火的条件是:
- 相邻的山峰必定可以看到;
- 如果两座山峰不相邻,存在顺时针和逆时针两条路,如果存在一条路,路上的其它山峰高度都不大于这两座山峰高度较小值,那么这两座山峰上的烽火可见。
需要求出给定数组中共有多少对山峰可见。
思路:
- 确定最大值所在的位置:从左往右遍历数组,找到数组中的最大值所在的位置,如果有多个最大值,取第一次出现的最大值的位置。
- 遍历数组:我们从最大值所在的位置开始遍历数组,注意是环形遍历,直到把所有数组元素都访问一遍。在遍历数组的过程中,将数组元素和出现的次数压入单调栈中。根据题意,需要知道某个位置的元素左右两边最近比它大的元素,所以单调栈的单调性是从栈底到栈顶由大到小。如果将要入栈的元素会破坏单调栈的单调性,就要将栈顶元素出栈,出栈的过程就需要计算山峰对。山峰对的数量是: C n 2 + n ∗ 2 C_{n}^{2} + n*2 Cn2+n∗2, 其中 n n n 表示出栈的元素出现的次数, C n 2 C_{n}^{2} Cn2 表示该元素之间互相可以构成的山峰对的个数, n ∗ 2 n*2 n∗2 表示该元素可以和左右两边更大的元素构成山峰对,该元素出现了 n n n 次,所以有 n ∗ 2 n*2 n∗2 次。
- 遍历结束:数组遍历结束之后,栈中可能还会有元素。
- 在出栈的过程中,从底往上数,倒数第三个元素及其上面的元素出栈的时候,山峰对数量的计算也是: C n 2 + n ∗ 2 C_{n}^{2} + n*2 Cn2+n∗2,因为这是环形数组,倒数第三个元素的左右两边是存在比它大的元素的,所以可以上面的公式计算。
- 对于倒数第二个元素,它的计算方式比较特殊,和倒数第一个元素的次数相关,如果倒数第一个元素出现大于等于两次,则计算方式: C n 2 + n ∗ 2 C_{n}^{2} + n*2 Cn2+n∗2,如果倒数第一个元素只出现一次,则计算方式: C n 2 + n C_{n}^{2} + n Cn2+n 。
- 对于倒数第一个元素,计算方式: C n 2 C_{n}^{2} Cn2 。
代码如下:
public class Solution {
public static long getNumOfMountainPair(int[] heights) {
if (heights == null || heights.length == 0) {
return 0;
}
// 首先确定最大值所在的位置
int maxPos = 0;
for (int i = 0; i < heights.length; i++) {
if (heights[maxPos] < heights[i]) {
maxPos = i;
}
}
// 从最大值所在的位置开始遍历数组
int value = heights[maxPos];
Stack<Pair> stack = new Stack<>();
stack.push(new Pair(value));
int index = getNextIndex(heights.length, maxPos);
long res = 0L;
while (index != maxPos) {
value = heights[index]; // 正在遍历的数组元素
while (!stack.empty() && value > stack.peek().value) {
int times = stack.pop().times;
res += getCombinationNum(times) + times * 2;
}
if (!stack.empty() && value == stack.peek().value) {
stack.peek().times++;
} else {
stack.push(new Pair(value));
}
index = getNextIndex(heights.length, index);
}
// 数组遍历结束,处理栈中剩余元素
while (!stack.empty()) {
int times = stack.pop().times;
res += getCombinationNum(times);
if (!stack.empty()) {
if (stack.size() > 1) {
// 表示弹出去的是栈中倒数第三个元素
res += times * 2;
} else {
// 表示弹出去的是栈中倒数第二个元素,还要看倒数第一个的次数
if (stack.peek().times > 1) {
res += times * 2;
} else {
res += times;
}
}
}
}
return res;
}
// 得到组合数,Cn,2
private static long getCombinationNum(int times) {
return times == 1L ? 0L : (long) times * (long) (times-1) / 2L;
}
// 遍历环形数组,返回当前位置cur的下一个位置
private static int getNextIndex(int length, int cur) {
return cur < length - 1 ? cur + 1 : 0;
}
// 单调栈中存储的结构,包含元素和出现的次数
private static class Pair {
public int value;
public int times;
public Pair (int value) {
this.value = value;
this.times = 1;
}
}
}
参考资料:左程云老师算法课程