题干:
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
示例 1:
输入: heights = [2,1,5,6,2,3]
输出: 10
解释: 最大的矩形为图中红色区域,面积为 10
示例 2:
输入: heights = [2,4]
输出: 4
提示:
1 <= heights.length <=10^50 <= heights[i] <= 10^4
思路:
首先来了解一下单调栈: 单调栈是一种特殊的数据结构,它主要用于解决一些与数组中元素的单调性相关的问题。以下是关于单调栈的详细介绍:
定义与特点
- 单调栈是一种栈数据结构,其内部元素保持单调递增或单调递减的顺序。
- 它具有后进先出(LIFO)的特性,在进行入栈和出栈操作时,会根据元素的大小关系来维护栈的单调性。
操作方式
- 单调递增栈:
- 当新元素入栈时,会将栈顶元素与新元素进行比较,如果栈顶元素大于新元素,那么栈顶元素就会被弹出栈,直到栈为空或者栈顶元素小于新元素为止,然后将新元素入栈。这样可以确保栈内元素始终保持从小到大的顺序。
- 单调递减栈:
- 与单调递增栈相反,当新元素入栈时,若栈顶元素小于新元素,栈顶元素会被弹出,直到栈为空或栈顶元素大于新元素,再将新元素入栈,以此保证栈内元素从大到小排列。
应用场景
- 寻找下一个更大元素:给定一个数组,对于每个元素,需要找到其右侧第一个比它大的元素。可以使用单调递增栈来解决,遍历数组,将元素依次入栈,当遇到比栈顶元素大的元素时,说明找到了栈顶元素的下一个更大元素,将栈顶元素出栈并记录相关信息,继续比较新的栈顶元素与当前元素,直到栈为空或者栈顶元素大于当前元素,然后将当前元素入栈。
- 寻找下一个更小元素:类似于寻找下一个更大元素,只不过是找右侧第一个比当前元素小的元素,这时使用单调递减栈来解决。
- 计算直方图中的最大矩形面积:将直方图的高度数组作为输入,利用单调栈找到每个柱子左右两侧第一个比它矮的柱子,从而计算出以每个柱子为高度的最大矩形面积,通过遍历所有柱子的计算结果,得到整个直方图中的最大矩形面积。
可见这道题是单调栈的一个经典应用了。
实现:
class Solution {
public int largestRectangleArea(int[] heights) {
Deque<Integer> stack = new ArrayDeque<>();
int maxVal = 0;
int lastPopedIndex = -1;
for (int i = 0; i < heights.length; i++) {
while(!stack.isEmpty() && heights[stack.peek()] > heights[i]) {
int topIndex = stack.pop();
lastPopedIndex = topIndex;
int width = i - topIndex;
maxVal = Math.max(Math.max(maxVal, width * heights[topIndex]), (width+1)*heights[i]);
}
if (lastPopedIndex != -1) {
heights[lastPopedIndex] = heights[i];
stack.push(lastPopedIndex);
lastPopedIndex = -1;
stack.push(i);
} else {
stack.push(i);
}
}
if (!stack.isEmpty()) {
int lastHighestIndex = stack.peek();
while(!stack.isEmpty()) {
int currIndex = stack.pop();
maxVal = Math.max(maxVal, heights[currIndex] * (lastHighestIndex - currIndex + 1));
}
}
return maxVal;
}
}
考虑到了单调栈,但是我发现如果最后结尾存在升序的序列,while循环就不能捕获这一段了。所以我想着我需要找到最后最后降序时候处理的最后一个值,修改原数组来“抹平”,像这样:
[212] -> [112]
还没完,抹平之后要把被抹平的位置放回栈里。等到最后的时候,栈不是空,我就从栈尾巴获取最大的那个下标,然后往回找。类似这样:
[11223333335]
可能数据高的有问题,但是思路就是这样。
效率也还不错。
但是这个实现的缺点也很明显:很难复现出来,需要考虑的边界条件太多,很容易写错。我来来回回其实提交了好几次才通过。另外你也会看到,时间也花了很久。
所以我需要一个优化的,思维上负载更低的,更容易实现的方式。
DS的方案
我们可以将代码简化为标准的单调栈实现,避免修改原数组并正确处理边界。以下是优化后的代码:
import java.util.Deque;
import java.util.ArrayDeque;
class Solution {
public int largestRectangleArea(int[] heights) {
Deque<Integer> stack = new ArrayDeque<>();
int maxArea = 0;
int[] extendedHeights = new int[heights.length + 1]; // 追加一个0
// 复制原数组并追加末尾的0(用于触发剩余计算)
System.arraycopy(heights, 0, extendedHeights, 0, heights.length);
for (int i = 0; i < extendedHeights.length; i++) {
// 维护单调递增栈:遇到较小元素时触发面积计算
while (!stack.isEmpty() && extendedHeights[stack.peek()] > extendedHeights[i]) {
int height = extendedHeights[stack.pop()];
int width = stack.isEmpty() ? i : i - stack.peek() - 1;
maxArea = Math.max(maxArea, height * width);
}
stack.push(i);
}
return maxArea;
}
}
主要简化点:
-
追加末尾哨兵值:通过复制数组并在末尾追加0,确保所有元素都会被处理,移除了原代码中最后处理栈剩余元素的复杂逻辑。
-
删除冗余操作: • 不再需要
lastPopedIndex和修改原数组的逻辑 • 简化了面积计算的判断条件 -
正确计算宽度:
int width = stack.isEmpty() ? i : i - stack.peek() - 1;当栈为空时,说明当前弹出的柱子是迄今为止最矮的,宽度可以直接取
i;否则宽度为i - left - 1(其中left是新栈顶)
关键逻辑对比:
| 原代码问题 | 优化后方案 |
|---|---|
| 手动处理剩余栈元素 | 通过追加末尾0自动触发所有计算 |
| 错误修改原数组 | 使用新数组保持原始数据不变 |
| 宽度计算逻辑不准确 (i - topIndex) | 正确计算左右边界差 (i - left - 1) |
这种实现的时间复杂度保持为 O(n),且逻辑更加清晰,符合单调栈的标准范式。
与DS的讨论
说真的,DS的方法一眼看起来就很简洁,没有那么多逻辑分支,如果完全掌握就很容易实现出来。但是有个问题,DS代码的17行我看了好几遍没看明白。我就问,DS就这么回答:
你提到的这句代码是单调栈解法中宽度计算的核心逻辑。让我们通过一个具体例子来逐步拆解它的含义:
情景假设
假设输入柱状图为 heights = [2,1,5,6,2,3],处理到索引 i=4(对应高度2)时触发计算:
当前栈状态:stack = [1,2,3](对应高度1,5,6)
i=4的高度是2,比栈顶3号元素(高度6)小,触发弹出栈顶3号元素
关键变量定义
- 弹出元素高度:
height = heights[3] = 6 - 当前右边界:
i=4 - 左边界:弹出后的新栈顶
stack.peek() = 2(对应高度5)
宽度计算逻辑分解
int width = stack.isEmpty() ? i : i - stack.peek() - 1;
情况1:栈为空(即没有左边界)
• 何时发生:当弹出的是最后一个栈元素时 • 例子:假设栈中只剩一个元素0(高度2),当处理到i=1(高度1)时:
弹出0号元素(高度2)后栈为空
左边界 = -1(虚拟左边界)
右边界 = i=1
宽度 = 1 - (-1) - 1 = 1(即从0到0的宽度)
情况2:栈不为空(存在左边界)
• 例子中的场景:
弹出3号元素(高度6)后栈顶是2号元素(高度5)
左边界 = 2
右边界 = i=4
宽度 = 4 - 2 - 1 = 1(即从3到3的宽度)
• 几何意义:左右第一个比当前高度小的柱子之间的空隙
为什么需要 -1?
假设当前弹出柱子索引为 top,左边界为 left,右边界为 i:
left top i
| | |
[2, 6, 2]
↑ ↑ ↑
高度5 高度6 高度2
• 实际可用宽度是 (i - left - 1),即 i 和 left 之间的柱子数量
• 例如上例中 i=4, left=2,中间只有索引3的柱子(即宽度1)
对比错误写法
原代码中的 width = i - topIndex(即 i - top)的问题:
假设 top=3,i=4:
i - top = 1(实际应为1,但仅在左边界是-1时正确)
如果左边界存在(如left=2):
正确宽度应为1,但原写法仍得到1,这里可能碰巧正确?但其他情况会出错
但在更复杂的场景中,例如:
柱状图: [3,5,2,1]
当处理到i=2(高度2)时:
弹出5(索引1)后栈顶是0(高度3)
宽度应为 i - stack.peek() -1 = 2 - 0 -1 = 1
而原写法i - top = 2 -1 =1,此时结果相同
但若处理到i=3(高度1)时:
弹出2(索引2)后栈顶是0(高度3)
正确宽度 = 3 - 0 -1 = 2(即索引1和2的宽度)
原写法i - top = 3 -2 =1,明显错误
结论
• stack.isEmpty() ? i : i - stack.peek() -1 的正确性:
• 当栈为空时,左边界是虚拟的-1,宽度为 i - (-1) -1 = i
• 当栈不为空时,左边界是栈顶,宽度为 i - left -1
• 核心思想:通过栈找到左右第一个更小的柱子,计算它们之间的可用宽度
我的思考
我真的完全看懂DS的解释了吗?其实没有看懂 : ) 。但是没关系,我自己重新实现的时候思考了这个问题:
class Solution {
public int largestRectangleArea(int[] heights) {
Deque<Integer> stack = new ArrayDeque<>();
int maxVal = 0;
int[] hh = new int[heights.length + 1];
System.arraycopy(heights, 0, hh, 0, heights.length);
heights = hh;
for (int i = 0; i < heights.length; i++) {
while(!stack.isEmpty() && heights[stack.peek()] > heights[i]) {
int topIndex = stack.pop();
int width = 0;
if (stack.isEmpty()) {
// 原数组左边没有比height[i]更低的,已经被比更大的给清掉了
width = i;
} else {
// 找到被清掉的最后一个位置
width = i - stack.peek() - 1;
}
maxVal = Math.max(maxVal, width * heights[topIndex]);
}
stack.push(i);
}
return maxVal;
}
}
首先要搞明白一点,为什么我看不懂那一段。原因就在于对栈的使用的思考不一样。我一开始想的那个思路里,栈是用来记录地板值的。但是对于DS的方案来说,不一样。
好了,遇到一个比栈顶值小的值了。这时候需要计算矩形面积了。我们需要两个值:高和宽。
高用什么?高用栈顶下标对应的值。这时候先pop取出来。
宽呢?想想看,如果栈顶下标的值曾经“消消乐”过它左边的值,那么栈非空的情况下,栈顶下标再下面的那个下标是什么呢?是没有被“消消乐”的下标x,意味着这个x对应的高度是比“高”要低的,且这个x到当前下标i中间的这段,都是高于“高”的,否则x就不在栈里等着了。那只需要知道这段的宽度就好了。此时宽=i - stack.peek() - 1
那如果考虑宽,但是栈空了呢?这里很有技巧:
[55555467841]
等到最后一个4的时候,它是会把左边全都消消乐的。然后到1,1来的时候栈里就剩个4了。出栈,用作高。然后问这个高的宽度是多少?栈空了,说明左边全让它的消消乐了。这个例子里1和4相邻,没展现出我想说的情况,换个例子。
[555467835641]
来看这个唯一的3,它消消乐左边之后还留在栈里。然后4来了,消消乐了56,也留在栈里。1来了,消消乐了4,用的是找“3”下标的技巧,计算完毕,有一次循环,3出栈,这次栈可是空了。注意到3左边和右边(到1之前)都比它高,这就是为什么高用3,而宽度用i,也就是1的下标。 咱下标天然比数量少1. 宽= i。
这么一总结之后我就清晰很多了。这就是一个标准的单调栈的实现了。
总结
所以你看,后面的思考,宽,高什么的都基于“栈”是怎么用的这件事来定的。像我最开始那么用,那高和宽的计算就是那么算的。像DS那么用,那就像DS那么算。
我更喜欢DS的用法,对于一个栈顶的下标x,他的高度h,当前遍历到的下标i,,以及它的高度m,总有这样的关系:
如果h > m,取出来x之后:
如果栈空了,那意味着原数组[0, m) 之间h是最小的值。如果栈没空,那栈里剩下的那个下标y,它的值n肯定没有h大,不然就让人家消消乐了。但是y到x之间如果有gap,那gap里的都让h给消消乐了。同样的啊,如果i和x之间有gap,这意味着在while循环过程中让m给消消乐了,但是你想啊,如果x到i的gap里的值小于h,那也就轮不到m来消消乐他们了。所以这些值都大于h,这意味着x到i之间的值也都大于h。所以就是说(y, i)开区间内的所有值都大于等于h。
总之是对于每一个入了栈的x,你的h我会帮你找到一个最宽的宽度来计算它。
就这样。