作为一名长期奋战在算法竞赛和面试辅导一线的开发者,我深知柱状图最大矩形问题在技术面试中的高频出现率。这道题看似简单,但想要在面试中游刃有余地解答,需要对单调栈这一数据结构有深刻理解。本文将带你从暴力解法开始,逐步深入,最终掌握最优的单调栈解法。
给定n个非负整数表示柱状图中柱子的高度,每个柱子宽度为1,求能勾勒出的最大矩形面积。例如,对于heights = [2,1,5,6,2,3],最大矩形面积为10(高度5,宽度2)。
最直观的解法是枚举所有可能的左右边界组合:
java复制int maxArea = 0;
for (int i = 0; i < heights.length; i++) {
int minHeight = heights[i];
for (int j = i; j < heights.length; j++) {
minHeight = Math.min(minHeight, heights[j]);
maxArea = Math.max(maxArea, minHeight * (j - i + 1));
}
}
时间复杂度O(n²),对于n=10⁵的数据量显然无法接受。
另一种暴力思路是对每个柱子,向左右扩展直到遇到更矮的柱子:
java复制int maxArea = 0;
for (int i = 0; i < heights.length; i++) {
int left = i, right = i;
while (left >= 0 && heights[left] >= heights[i]) left--;
while (right < heights.length && heights[right] >= heights[i]) right++;
maxArea = Math.max(maxArea, heights[i] * (right - left - 1));
}
虽然比双重循环稍好,但最坏情况下(如单调递增序列)仍然是O(n²)时间复杂度。
实际测试中,当n=10⁵时,这两种暴力解法都会超时。我们需要寻找更高效的算法。
单调栈是一种特殊的栈结构,它维护栈内元素的单调性(递增或递减)。在本题中,我们使用单调递增栈:
关键在于:当一个柱子被弹出时,它的左右边界就确定了:
这样就能高效计算出以被弹出柱子为高的最大矩形面积。
为了简化边界条件处理,我们可以在柱状图两端各添加一个高度为0的哨兵柱子:
java复制public int largestRectangleArea(int[] heights) {
int n = heights.length;
int[] left = new int[n]; // 左边第一个比当前柱子矮的位置
int[] right = new int[n]; // 右边第一个比当前柱子矮的位置
Deque<Integer> stack = new ArrayDeque<>();
// 从左到右遍历,确定左边界
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && heights[stack.peek()] >= heights[i]) {
stack.pop();
}
left[i] = stack.isEmpty() ? -1 : stack.peek();
stack.push(i);
}
stack.clear();
// 从右到左遍历,确定右边界
for (int i = n - 1; i >= 0; i--) {
while (!stack.isEmpty() && heights[stack.peek()] >= heights[i]) {
stack.pop();
}
right[i] = stack.isEmpty() ? n : stack.peek();
stack.push(i);
}
// 计算最大面积
int maxArea = 0;
for (int i = 0; i < n; i++) {
maxArea = Math.max(maxArea, heights[i] * (right[i] - left[i] - 1));
}
return maxArea;
}
更高效的实现是在一次遍历中同时确定左右边界:
java复制public int largestRectangleArea(int[] heights) {
Deque<Integer> stack = new ArrayDeque<>();
int maxArea = 0;
int n = heights.length;
for (int i = 0; i <= n; i++) {
// 处理右哨兵(i==n时高度为0)
int h = (i == n) ? 0 : heights[i];
while (!stack.isEmpty() && h < heights[stack.peek()]) {
int height = heights[stack.pop()];
// 栈空说明左边没有更矮的,宽度就是i
int width = stack.isEmpty() ? i : i - stack.peek() - 1;
maxArea = Math.max(maxArea, height * width);
}
stack.push(i);
}
return maxArea;
}
这个版本的优势在于:
虽然代码中有嵌套循环,但每个柱子最多被压入和弹出栈各一次,因此总操作次数是2n,时间复杂度为O(n)。
栈的空间消耗最坏情况下是O(n)(当输入是严格递增序列时)。因此空间复杂度为O(n)。
关键点在于:每当一个柱子被弹出时,它的左右边界就已经确定:
通过数学归纳法可以证明这种处理方式能够覆盖所有可能的矩形情况。
在二值图像处理中,我们经常需要找到最大的全白矩形区域。可以将每一行看作一个柱状图,连续的白像素数量就是柱子的高度,然后应用本算法。
数据库执行计划优化时,需要评估不同查询条件的过滤效果。将数据分布建模为柱状图后,本算法可以帮助确定最优的查询范围。
在内存管理或云计算资源分配中,需要找到连续可用的最大资源块。这可以转化为柱状图最大矩形问题来解决。
因为我们需要找到左右第一个比当前柱子矮的边界,单调递增栈正好满足这个需求。当遇到更矮的柱子时,栈中较高的柱子就找到了右边界。
在弹出条件中使用>=而非>,这样相同高度的柱子会被正确处理。虽然可能会重复计算一些情况,但最终的最大值不会受到影响。
在性能敏感的场景,可以用原生数组+指针来模拟栈,减少对象开销:
java复制public int largestRectangleArea(int[] heights) {
int n = heights.length;
int[] stack = new int[n + 1];
int top = -1;
int maxArea = 0;
for (int i = 0; i <= n; i++) {
int h = (i == n) ? 0 : heights[i];
while (top >= 0 && h < heights[stack[top]]) {
int height = heights[stack[top--]];
int width = (top == -1) ? i : i - stack[top] - 1;
maxArea = Math.max(maxArea, height * width);
}
stack[++top] = i;
}
return maxArea;
}
对于特别大的柱状图,可以考虑将数据分块,并行计算各块的最大矩形,然后合并结果。不过由于单调栈的线性特性,这种优化通常收益有限。
掌握这个算法后,可以解决一系列类似问题:
面试官可能会问:
在实际编码中,我发现以下几点特别重要:
我曾经在一次面试中因为没有正确处理相等高度的情况而错失机会,这让我深刻理解到边界条件的重要性。后来我养成了对每个算法问题都先考虑各种边界情况的习惯。