题目描述(困难难度)

84. Largest Rectangle in Histogram - 图1

给一个柱状图,输出一个矩形区域的最大面积。

解法一 暴力破解

以题目给出的例子为例,柱形图高度有 1, 2, 3, 5, 6,我们只需要找出每一个高度对应的最大的面积,选出最大的即可。如果求高度为 3 的面积最大的,我们只需要遍历每一个高度,然后看连续的大于等于 3 的柱形有几个,如果是 n 个,那么此时的面积就是 3 * n。所以高度确定的话,我们只需要找连续的大于等于 3 的柱形个数,也就是高度。

  1. public int largestRectangleArea(int[] heights) {
  2. HashSet<Integer> heightsSet = new HashSet<Integer>();
  3. //得到所有的高度,也就是去重。
  4. for (int i = 0; i < heights.length; i++) {
  5. heightsSet.add(heights[i]);
  6. }
  7. int maxArea = 0;
  8. //遍历每一个高度
  9. for (int h : heightsSet) {
  10. int width = 0;
  11. int maxWidth = 1;
  12. //找出连续的大于等于当前高度的柱形个数的最大值
  13. for (int i = 0; i < heights.length; i++) {
  14. if (heights[i] >= h) {
  15. width++;
  16. //出现小于当前高度的就归零,并且更新最大宽度
  17. } else {
  18. maxWidth = Math.max(width, maxWidth);
  19. width = 0;
  20. }
  21. }
  22. maxWidth = Math.max(width, maxWidth);
  23. //更新最大区域的面积
  24. maxArea = Math.max(maxArea, h * maxWidth);
  25. }
  26. return maxArea;
  27. }

时间复杂度:O(n²)。

空间复杂度:O ( n)。存所有高度。

解法二

参考这里。有一些快排的影子,大家不妨先去回顾一下快排。快排中,我们找了一个基准点,把数组分成了小于基准点的数,和大于基准点的数。然后递归的完成了排序。

类似的,这里我们也可以找一个柱子。然后把所有柱子分成左半区域和右半区域。

84. Largest Rectangle in Histogram - 图2

这样要找的最大矩形区域就是三种情况了。

  1. 最大矩形区域在不包含选定柱子的左半区域当中。
  2. 最大矩形区域在不包含选定柱子的右半区域当中。
  3. 最大矩形区域包含选定柱子的区域。

对于 1、2 两种情况,我们只需要递归的去求就行了。而对于第 3 种情况,我们找一个特殊的柱子作为分界点以方便计算,哪一个柱子呢?最矮的那个!有什么好处呢?这样包含该柱子的最大区域,一定是涵盖了当前所有柱子。

84. Largest Rectangle in Histogram - 图3

所以面积当然是当前选定柱子的高度乘以当前的最大宽度了。

对于当前的时间复杂度,如果每次选定的柱子都可以把区域一分为二,递推式就是

T(n) = 2 * T(n / 2 ) + n。

上边多加的 n 是找最小柱子耗费,因为需要遍历一遍柱子。然后和快排一样,这样递归下去,时间复杂度就是 O(n log(n))。当然和快排一样的问题,最坏的情况,如果最小柱子每次都出现在末尾,这样会使得只有左半区域,右半区域是 0。递推式就变成了

T(n) = T(n - 1 ) + n。

时间复杂度就变成 O(n²)了,怎么优化呢?

重点就在上边找最小柱子多加的 n 上了,如果我们找最小柱子时间复杂度优化成 log(n)。那么在最坏情况下,递推式变成

T(n) = T(n - 1 ) + log(n)。

最坏的情况,递推式代入,依旧是 O(n log(n))。而找最小柱子,就可以抽象成,在一个数组区间内找最小值问题,而这个问题前人已经提出了一个数据结构,可以使得时间复杂度是 log(n),完美!名字叫做线段树,可以参考这里线段树空间复杂度,我就不重复讲了。主要思想就是利用二叉树,使得查找时间复杂度变成了 O(log(n))。

我们以序列 { 5, 9, 7, 4 ,6, 1} 为例,线段树长下边的样子。节点的值代表当前区间内的最小值。

84. Largest Rectangle in Histogram - 图4

  1. class Node// 节点
  2. {
  3. int start;// 区间的左端点
  4. int end;// 区间的右端点
  5. int data;// 该区间的最小值
  6. int index; // 该节点最小值对应数组的下标
  7. public Node(int start, int end)// 构造方法中传入左端点和右端点
  8. {
  9. this.start = start;
  10. this.end = end;
  11. }
  12. }
  13. class SegmentTree {
  14. private int[] base;;// 给定数组
  15. Node[] nodes;// 存储线段树的数组
  16. public SegmentTree(int[] nums) {
  17. base = new int[nums.length];
  18. for (int i = 0; i < nums.length; i++) {
  19. base[i] = nums[i];
  20. }
  21. //需要 4n 的空间,上边链接给出了原因
  22. nodes = new Node[base.length * 4];
  23. }
  24. public void build(int index)// 构造一颗线段树,传入下标
  25. {
  26. Node node = nodes[index];// 取出该下标下的节点
  27. if (node == null) {// 根节点需要手动创建
  28. nodes[index] = new Node(0, this.base.length - 1);
  29. node = nodes[index];
  30. }
  31. if (node.start == node.end) {// 如果这个线段的左端点等于右端点则这个点是叶子节点
  32. node.data = base[node.start];
  33. node.index = node.start;
  34. } else { // 否则递归构造左右子树
  35. int mid = (node.start + node.end) >> 1;// 现在这个线段的中点
  36. nodes[(index << 1) + 1] = new Node(node.start, mid);// 左孩子线段
  37. nodes[(index << 1) + 2] = new Node(mid + 1, node.end);// 右孩子线段
  38. build((index << 1) + 1);// 构造左孩子
  39. build((index << 1) + 2);// 构造右孩子
  40. if (nodes[(index << 1) + 1].data <= nodes[(index << 1) + 2].data) {
  41. node.data = nodes[(index << 1) + 1].data;
  42. node.index = nodes[(index << 1) + 1].index;
  43. } else {
  44. node.data = nodes[(index << 1) + 2].data;
  45. node.index = nodes[(index << 1) + 2].index;
  46. }
  47. }
  48. }
  49. public Node query(int index, int start, int end) {
  50. Node node = nodes[index];
  51. if (node.start > end || node.end < start)
  52. return null;// 当前区间和待查询区间没有交集,那么返回一个极大值
  53. if (node.start >= start && node.end <= end)
  54. return node;// 如果当前的区间被包含在待查询的区间内则返回当前区间的最小值
  55. Node left = query((index << 1) + 1, start, end);
  56. int dataLeft = left == null ? Integer.MAX_VALUE : left.data;
  57. Node right = query((index << 1) + 2, start, end);
  58. int dataRight = right == null ? Integer.MAX_VALUE : right.data;
  59. return dataLeft <= dataRight ? left : right;
  60. }
  61. }
  62. class Solution {
  63. public int largestRectangleArea(int[] heights) {
  64. if (heights.length == 0) {
  65. return 0;
  66. }
  67. //构造线段树
  68. SegmentTree tree = new SegmentTree(heights);
  69. tree.build(0);
  70. return getMaxArea(tree, 0, heights.length - 1, heights);
  71. }
  72. /**
  73. * 查询某个区间的最小值
  74. * @param tree 构造好的线段树
  75. * @param start 待查询的区间的左端点
  76. * @param end 待查询的区间的右端点
  77. * @param heights 给定的数组
  78. * @return 返回当前区间的矩形区域的最大值
  79. */
  80. private int getMaxArea(SegmentTree tree, int start, int end, int[] heights) {
  81. if (start == end) {
  82. return heights[start];
  83. }
  84. //非法情况,返回一个最小值,防止影响正确的最大值
  85. if (start > end) {
  86. return Integer.MIN_VALUE;
  87. }
  88. //找出最小的柱子的下标
  89. int min = tree.query(0, start, end).index;
  90. //最大矩形区域包含选定柱子的区域。
  91. int area1 = heights[min] * (end - start + 1);
  92. //最大矩形区域在不包含选定柱子的左半区域。
  93. int area2 = getMaxArea(tree, start, min - 1, heights);
  94. //最大矩形区域在不包含选定柱子的右半区域。
  95. int area3 = getMaxArea(tree, min + 1, end, heights);
  96. //返回最大的情况
  97. return Math.max(Math.max(area1, area2), area3);
  98. }
  99. }

时间复杂度:O(n log(n))。

空间复杂度:O(n),用来存储线段树。

解法三

参考这里,思考下解法二中遇到的问题,利用了类似快排的思想,最好情况的递推式是

T(n) = 2 * T(n / 2 ) + n。

就是 n log(n),但是由于分界点的柱子的选择,并不能总保证两部分的柱子数量均分。所以如果这个问题解决,那么我们就可以保证时间复杂度是 n log(n)了。如何让它均分呢?我们强行把它分成 3 部分呗。

84. Largest Rectangle in Histogram - 图5

  1. 左半区域,含有一半柱子,当然如果总数是奇数个,这里会多一个。

  2. 右半区域,含有一半柱子,当然如果总数是奇数个,这里会少一个。

  3. 包含最中间柱子的部分,最大区域一定会包含橙色部分,这样才会横跨两个区域。

情况 1、2 的最大区域面积可以用递归来解决,情况 3 的话,我们只要保证是 O(n),就满足了我们的递推式,从而保证时间复杂度是O(n log(n))。怎么做呢?

贪婪的思想,每次选两边较高的柱子扩展柱子。然后其实就是求出了 2 个柱子,3 个柱子,4 个柱子,5 个柱子…每种情况的最大值,然后选最大的就可以了。

  1. 初始的时候是两个柱子,记录此时的面积。

  2. 然后加 1 个柱子,选取两边较高的柱子,然后计算此时的面积,更新最大区域面积。

  3. 不停的重复过程 2 ,直到所有柱子遍历完

  1. public int largestRectangleArea(int[] heights) {
  2. if (heights.length == 0) {
  3. return 0;
  4. }
  5. return getMaxArea(heights, 0, heights.length - 1);
  6. }
  7. private int getMaxArea(int[] heights, int left, int right) {
  8. if (left == right) {
  9. return heights[left];
  10. }
  11. int mid = left + (right - left) / 2;
  12. //左半部分
  13. int area1 = getMaxArea(heights, left, mid);
  14. //右半部分
  15. int area2 = getMaxArea(heights, mid + 1, right);
  16. //中间区域
  17. int area3 = getMidArea(heights, left, mid, right);
  18. //选择最大的
  19. return Math.max(Math.max(area1, area2), area3);
  20. }
  21. private int getMidArea(int[] heights, int left, int mid, int right) {
  22. int i = mid;
  23. int j = mid + 1;
  24. int minH = Math.min(heights[i], heights[j]);
  25. int area = minH * 2;
  26. //向两端扩展
  27. while (i >= left && j <= right) {
  28. minH = Math.min(minH, Math.min(heights[i], heights[j]));
  29. //更新最大面积
  30. area = Math.max(area, minH * (j - i + 1));
  31. if (i == left) {
  32. j++;
  33. } else if (j == right) {
  34. i--;
  35. //选择较高的柱子
  36. } else if (heights[i - 1] >= heights[j + 1]) {
  37. i--;
  38. } else {
  39. j++;
  40. }
  41. }
  42. return area;
  43. }

时间复杂度:O(n log(n))。

空间复杂度:O(log(n)),压栈的空间。

解法四

参考这里-Java-solution-explained-(beats-96)>)。解法一暴力破解中,我们把所有矩形区域按高度依次求出来,选出了最大的。这里我们想另外一个分类方法。分别求出包含每个柱子的矩形区域的最大面积,然后选最大的。要包含这个柱子,也就是这个柱子是当前矩形区域的高度。也就是,这个柱子是当前矩形区域中柱子最高的。如下图中包含橙色柱子的矩形区域的最大面积。

84. Largest Rectangle in Histogram - 图6

求当前的矩形区域,我们只需要知道比当前柱子到左边第一个小的 leftLessMin 和到右边第一个小的 rightLessMin 两个柱子下标,就可以求出矩形的面积为 (rightLessMin - leftLessMin - 1) * 当前柱子高度。然后遍历每个柱子,按同样的方法求出矩形面积,选最大的就可以了。

现在的问题就是,怎么知道 rightLessMin 和 leftLessMin 。

我们可以用一个数组,leftLessMin[ ] 保存各自的左边第一个小的柱子。

  1. leftLessMin[0] = -1;//第一个柱子前边没有柱子,所以赋值为 -1,便于计算面积
  2. for (int i = 1; i < heights.length; i++) {
  3. int p = i - 1;
  4. //p 一直减少,找到第一个比当前高度小的柱子就停止
  5. while (p >= 0 && height[p] >= height[i]) {
  6. p--;
  7. }
  8. leftLessMin[i] = p;
  9. }

上边的时间复杂度是 O(n²),我们可以进行优化。参考下边的图,当前柱子 i 比上一个柱子小的时候,因为我们是找比当前柱子矮的,之前我们进行减 1,判断上上个。但是我们之前已经求出了上一个柱子的 leftLessMin[ i - 1],也就是第一个比上个柱子小的柱子,所以其实我们可以直接跳到 leftLessMin[ i - 1] 比较。因为从 leftLessMin[ i - 1] + 1到 i - 1 的柱子一定是比当前柱子 i 高的,所以可以跳过。

84. Largest Rectangle in Histogram - 图7

这样的话时间复杂度达到了O(n)。开始自己不理解,问了一下同学。其实证明的话,可以结合解法五,我们寻找 leftLessMin 其实可以看做压栈出栈的过程,每个元素只会被访问两次。

  1. int[] leftLessMin = new int[heights.length];
  2. leftLessMin[0] = -1;
  3. for (int i = 1; i < heights.length; i++) {
  4. int l = i - 1;
  5. //当前柱子更小一些,进行左移
  6. while (l >= 0 && heights[l] >= heights[i]) {
  7. l = leftLessMin[l];
  8. }
  9. leftLessMin[i] = l;
  10. }

求到右边第一个小的柱子同理,下边是完整的代码。

  1. public int largestRectangleArea(int[] heights) {
  2. if (heights.length == 0) {
  3. return 0;
  4. }
  5. //求每个柱子的左边第一个小的柱子的下标
  6. int[] leftLessMin = new int[heights.length];
  7. leftLessMin[0] = -1;
  8. for (int i = 1; i < heights.length; i++) {
  9. int l = i - 1;
  10. while (l >= 0 && heights[l] >= heights[i]) {
  11. l = leftLessMin[l];
  12. }
  13. leftLessMin[i] = l;
  14. }
  15. //求每个柱子的右边第一个小的柱子的下标
  16. int[] rightLessMin = new int[heights.length];
  17. rightLessMin[heights.length - 1] = heights.length;
  18. for (int i = heights.length - 2; i >= 0; i--) {
  19. int r = i + 1;
  20. while (r <= heights.length - 1 && heights[r] >= heights[i]) {
  21. r = rightLessMin[r];
  22. }
  23. rightLessMin[i] = r;
  24. }
  25. //求包含每个柱子的矩形区域的最大面积,选出最大的
  26. int maxArea = 0;
  27. for (int i = 0; i < heights.length; i++) {
  28. int area = (rightLessMin[i] - leftLessMin[i] - 1) * heights[i];
  29. maxArea = Math.max(area, maxArea);
  30. }
  31. return maxArea;
  32. }

时间复杂度:O(n),取决于找 leftLessMin [ i ] 的复杂度。

空间复杂度:O(n),保存每个柱子左边右边第一个小的柱子下标。

解法五 栈

参考这里。之前也遇到利用栈巧妙解题的,例如42题的解法五,和这道题的共同点就是配对问题。思路的话,本质上和解法四是一样的,可以先看下解法四,左边第一个小于当前柱子和右边第一个小于当前柱子是一对。通过它俩可以求出当前柱子的最大矩形区域。那么具体怎么操作呢?

遍历每个柱子,然后分下边几种情况。

  • 如果当前栈空,或者当前柱子大于栈顶柱子的高度,就将当前柱子的下标入栈
  • 当前柱子的高度小于栈顶柱子的高度。那么就把栈顶柱子出栈,当做解法四中的当前要求面积的柱子。而右边第一个小于当前柱子的下标就是当前在遍历的柱子,左边第一个小于当前柱子的下标就是当前新的栈顶。

遍历完成后,如果栈没有空。就依次出栈,出栈元素当做解法四中的要求面积的柱子,然后依次计算矩形区域面积。

84. Largest Rectangle in Histogram - 图8

结合图可以看一下,从头开始遍历,遍历柱子开始的时候都大于前一个柱子高度,所以依次入栈。直到遍历到橙色部分,栈顶元素出栈,计算包含栈顶柱子的矩形区域。而左边第一个小于要求柱子的就是新栈顶,右边第一个小于要求柱子的就是当前遍历柱子。

  1. public int largestRectangleArea(int[] heights) {
  2. int maxArea = 0;
  3. Stack<Integer> stack = new Stack<>();
  4. int p = 0;
  5. while (p < heights.length) {
  6. //栈空入栈
  7. if (stack.isEmpty()) {
  8. stack.push(p);
  9. p++;
  10. } else {
  11. int top = stack.peek();
  12. //当前高度大于栈顶,入栈
  13. if (heights[p] >= heights[top]) {
  14. stack.push(p);
  15. p++;
  16. } else {
  17. //保存栈顶高度
  18. int height = heights[stack.pop()];
  19. //左边第一个小于当前柱子的下标
  20. int leftLessMin = stack.isEmpty() ? -1 : stack.peek();
  21. //右边第一个小于当前柱子的下标
  22. int RightLessMin = p;
  23. //计算面积
  24. int area = (RightLessMin - leftLessMin - 1) * height;
  25. maxArea = Math.max(area, maxArea);
  26. }
  27. }
  28. }
  29. while (!stack.isEmpty()) {
  30. //保存栈顶高度
  31. int height = heights[stack.pop()];
  32. //左边第一个小于当前柱子的下标
  33. int leftLessMin = stack.isEmpty() ? -1 : stack.peek();
  34. //右边没有小于当前高度的柱子,所以赋值为数组的长度便于计算
  35. int RightLessMin = heights.length;
  36. int area = (RightLessMin - leftLessMin - 1) * height;
  37. maxArea = Math.max(area, maxArea);
  38. }
  39. return maxArea;
  40. }

时间复杂度:O(n),因为对于每个柱子只会经历入栈出栈,所以最多 2n 次。

空间复杂度:O(n),栈的大小。

这道题经典呀,第一次用快排的思想去解决问题,太优雅了。另外通过对问题的挖掘,时间复杂度优化到 O(n),也是惊叹。

windliang wechat

添加好友一起进步~

如果觉得有帮助的话,可以点击 这里 给一个 star 哦 ^^

如果想系统的学习数据结构和算法,强烈推荐一个我之前学过的课程,可以点击 这里 查看详情