|
| 1 | +# 84. Largest Rectangle in Histogram |
| 2 | +# 🔴 Hard |
| 3 | +# |
| 4 | +# https://leetcode.com/problems/largest-rectangle-in-histogram/ |
| 5 | +# |
| 6 | +# Tags: Array - Stack - Monotonic Stack |
| 7 | + |
| 8 | +import timeit |
| 9 | +from typing import List |
| 10 | + |
| 11 | +# 10e3 calls. |
| 12 | +# » Naive 0.02904 seconds |
| 13 | +# » MonotonicStack 0.01783 seconds |
| 14 | +# » DivideAndConquer 0.08459 seconds |
| 15 | + |
| 16 | +# Use a min monotonic stack where each entry is a tuple (height, index). |
| 17 | +# While correct, this implementation fails to pass the tests because it |
| 18 | +# computes the rectangle between all the heights in the stack and a |
| 19 | +# newly added height each time one is added. The next solution shows how |
| 20 | +# we can avoid doing this O(n^2) work. |
| 21 | +# |
| 22 | +# Time complexity: O(n^2) - In the worst case, we keep pushing new |
| 23 | +# heights into the stack without popping any, at O(n) per push. |
| 24 | +# Space complexity: O(n) - We store all heights in the stack. |
| 25 | +# |
| 26 | +# Fails with Time Limit Exceeded. |
| 27 | +class Naive: |
| 28 | + def largestRectangleArea(self, heights: List[int]) -> int: |
| 29 | + if len(heights) == 1: |
| 30 | + return heights[0] |
| 31 | + # Store the largest rectangle seen so far. |
| 32 | + res = 0 |
| 33 | + # Initialize the stack with the height 0 just left of the |
| 34 | + # first index. |
| 35 | + stack = [(0, -1)] |
| 36 | + for idx, height in enumerate(heights): |
| 37 | + # Avoid adding this element as long as the next element has |
| 38 | + # the same height and it is not the last one. Pass LC 96. |
| 39 | + if idx < len(heights) - 1 and heights[idx + 1] == height: |
| 40 | + continue |
| 41 | + # Pop any heights greater than or equal to the current one. |
| 42 | + while stack and stack[-1][0] >= height: |
| 43 | + stack.pop() |
| 44 | + stack.append((height, idx)) |
| 45 | + # Calculate the area of all rectangles that we can build |
| 46 | + # using the newly added height. |
| 47 | + for i in range(len(stack) - 1): |
| 48 | + # Area = base * h |
| 49 | + # The base for this height goes all the way from this |
| 50 | + # index to the current index. |
| 51 | + base = idx - stack[i][1] |
| 52 | + # The height is the height of the next lowest histogram |
| 53 | + # column. |
| 54 | + h = stack[i + 1][0] |
| 55 | + area = base * h |
| 56 | + if area > res: |
| 57 | + res = area |
| 58 | + |
| 59 | + return res |
| 60 | + |
| 61 | + |
| 62 | +# Use a monotonic non-decreasing stack. Add boundary 0s to the start and |
| 63 | +# end of the heights array, to symbolize that rectangles cannot be |
| 64 | +# extended to include this positions. The left 0 boundary also |
| 65 | +# guarantees that the stack will never be empty, letting us avoid having |
| 66 | +# to check in each iteration of the inner loop. Iterate over the heights |
| 67 | +# array, when we see a height smaller than the top of the stack, we pop |
| 68 | +# it and calculate the rectangle that can be obtained between the |
| 69 | +# popped height and the first smaller height to its left. We keep the |
| 70 | +# largest area found to return as the result of the function. |
| 71 | +# |
| 72 | +# Time complexity: O(n) - We iterate once over the input array, each |
| 73 | +# height can be, at most, pushed into and popped from the stack once, |
| 74 | +# and we calculate an area every time we pop from the stack, that is a |
| 75 | +# maximum of n area calculations. |
| 76 | +# Space complexity: O(n) - The stack can grow to the same size as the |
| 77 | +# input. |
| 78 | +# |
| 79 | +# Runtime: 2061 ms, faster than 66.19% |
| 80 | +# Memory Usage: 27.1 MB, less than 99.30% |
| 81 | +class MonotonicStack: |
| 82 | + def largestRectangleArea(self, heights: List[int]) -> int: |
| 83 | + # Add two boundaries to the heights array. Initialize the stack |
| 84 | + # to contain the left 0 height to avoid having to check for |
| 85 | + # an empty stack at each iteration. Initialize the max at 0. |
| 86 | + heights, stack, res = [0] + heights + [0], [0], 0 |
| 87 | + for i in range(1, len(heights)): |
| 88 | + # Shortcut to avoid computing each rectangle in a set of |
| 89 | + # bars of the same height, it will only compute the max. |
| 90 | + if heights[i] == heights[stack[-1]]: |
| 91 | + stack[-1] = i |
| 92 | + continue |
| 93 | + # Keep the stack non-decreasing by popping any greater |
| 94 | + # elements before appending. |
| 95 | + while heights[i] < heights[stack[-1]]: |
| 96 | + # Use any indexes popped from the stack to compute the |
| 97 | + # area of the rectangle that could be obtained between |
| 98 | + # that height and the next smaller height to its left. |
| 99 | + h = heights[stack.pop()] |
| 100 | + w = i - stack[-1] - 1 |
| 101 | + res = max(res, h * w) |
| 102 | + stack.append(i) |
| 103 | + return res |
| 104 | + |
| 105 | + |
| 106 | +# Use a divide-and-conquer strategy, the max area of each section will |
| 107 | +# be the max area of the bars left of the middle, the bars right of the |
| 108 | +# middle, or the section that contains the middle bar. |
| 109 | +# |
| 110 | +# Time complexity: O(n*log(n)) - At each step the algorithm divides the |
| 111 | +# input into two halves and also calculates the result of using the |
| 112 | +# middle heights with O(n), the division can happen O(log(n)) times. |
| 113 | +# Space complexity: O(log(n)) - The call stack can grow to a height of |
| 114 | +# log(n), each call holds a reference to the input array and some |
| 115 | +# pointers, all of them constant space. |
| 116 | +# |
| 117 | +# Runtime: 9960 ms, faster than 5.1% |
| 118 | +# Memory Usage: 27.6 MB, less than 87.99% |
| 119 | +class DivideAndConquer: |
| 120 | + def maxCombinedArea( |
| 121 | + self, heights: List[int], l: int, mid: int, r: int |
| 122 | + ) -> int: |
| 123 | + # Expand from the middle in O(1) to find the max area containing |
| 124 | + # the two central bars heights[mid] and heights[mid + 1] |
| 125 | + i, j, area = mid, mid + 1, 0 |
| 126 | + h = min(heights[i], heights[j]) |
| 127 | + while i >= l and j <= r: |
| 128 | + h = min(h, min(heights[i], heights[j])) |
| 129 | + area = max(area, (j - i + 1) * h) |
| 130 | + # If one side has reached the boundary, expand on the other |
| 131 | + # side only. |
| 132 | + if i == l: |
| 133 | + j += 1 |
| 134 | + elif j == r: |
| 135 | + i -= 1 |
| 136 | + # If both sides still have room to expand, do it on the side |
| 137 | + # that has a bigger height. |
| 138 | + else: |
| 139 | + if heights[i - 1] > heights[j + 1]: |
| 140 | + i -= 1 |
| 141 | + else: |
| 142 | + j += 1 |
| 143 | + return area |
| 144 | + |
| 145 | + # Worker function that computes the max area between two points. |
| 146 | + def maxArea(self, heights: List[int], l: int, r: int) -> int: |
| 147 | + # The area of a single bar is its height. |
| 148 | + if l == r: |
| 149 | + return heights[l] |
| 150 | + mid = l + (r - l) // 2 |
| 151 | + # Divide and conquer, the max area is either left, right or |
| 152 | + # contains mid. |
| 153 | + return max( |
| 154 | + self.maxArea(heights, l, mid), # Left. |
| 155 | + self.maxArea(heights, mid + 1, r), # Right. |
| 156 | + self.maxCombinedArea(heights, l, mid, r), # Overlapping mid. |
| 157 | + ) |
| 158 | + |
| 159 | + # Main function, exposes a public API expected by consumers. |
| 160 | + def largestRectangleArea(self, heights: List[int]) -> int: |
| 161 | + if not heights: |
| 162 | + return 0 |
| 163 | + # For tests that contain only one value. LC 92 is 10e5 8793 |
| 164 | + if len(set(heights)) == 1: |
| 165 | + return len(heights) * heights[0] |
| 166 | + # Call the worker function with the entire input array. |
| 167 | + return self.maxArea(heights, 0, len(heights) - 1) |
| 168 | + |
| 169 | + |
| 170 | +# DivideAndConquer solution inspired on the Java version here: |
| 171 | +# https://leetcode.com/problems/largest-rectangle-in-histogram/solutions/ |
| 172 | +# 28910/simple-divide-and-conquer-ac-solution-without-segment-tree/ |
| 173 | + |
| 174 | + |
| 175 | +def test(): |
| 176 | + executors = [ |
| 177 | + Naive, |
| 178 | + MonotonicStack, |
| 179 | + DivideAndConquer, |
| 180 | + ] |
| 181 | + tests = [ |
| 182 | + [[0], 0], |
| 183 | + [[0, 0], 0], |
| 184 | + [[2, 4], 4], |
| 185 | + [[2, 1, 5, 6, 2, 3], 10], |
| 186 | + [[0, 0, 0, 2, 4, 5, 0, 0, 0, 7, 0], 8], |
| 187 | + [ |
| 188 | + [3, 1, 6, 4, 3, 6, 6, 2, 7, 1, 1, 4, 3, 2, 3, 4, 5, 6, 7, 6, 5, 4], |
| 189 | + 28, |
| 190 | + ], |
| 191 | + [ |
| 192 | + [3, 2, 6, 4, 3, 6, 6, 2, 7, 2, 2, 4, 3, 2, 3, 4, 5, 6, 7, 6, 5, 4], |
| 193 | + 44, |
| 194 | + ], |
| 195 | + ] |
| 196 | + for executor in executors: |
| 197 | + start = timeit.default_timer() |
| 198 | + for _ in range(1): |
| 199 | + for col, t in enumerate(tests): |
| 200 | + sol = executor() |
| 201 | + result = sol.largestRectangleArea(t[0]) |
| 202 | + exp = t[1] |
| 203 | + assert result == exp, ( |
| 204 | + f"\033[93m» {result} <> {exp}\033[91m for" |
| 205 | + + f" test {col} using \033[1m{executor.__name__}" |
| 206 | + ) |
| 207 | + stop = timeit.default_timer() |
| 208 | + used = str(round(stop - start, 5)) |
| 209 | + cols = "{0:20}{1:10}{2:10}" |
| 210 | + res = cols.format(executor.__name__, used, "seconds") |
| 211 | + print(f"\033[92m» {res}\033[0m") |
| 212 | + |
| 213 | + |
| 214 | +test() |
0 commit comments