7. Largest Rectangle in Histogram
«В архитектуре высота здания ограничена его самым слабым фундаментом. В алгоритмах ширина прямоугольника ограничена его самым низким столбиком».
— Брайан Керниган💡 Связанные темы:
- 1. Теория. Стек — базовая структура LIFO
- 2. Монотонный стек — монотонно возрастающий стек
- 5. Daily Temperatures — вычисление дистанций через монотонный стек
- 29. Сложные задачи: 5. Largest Rectangle in Histogram — глубокий разбор алгоритма в разделе сложных задач
Введение: Финальный босс монотонных стеков
Задача Largest Rectangle in Histogram (LeetCode 84, уровень Hard) по праву считается кульминацией темы монотонных стеков:
Дан массив неотрицательных целых чисел
heights, представляющий высоты смежных столбиков гистограммы, где ширина каждого столбика равна1.
Найти площадь наибольшего прямоугольника, который можно вписать в эту гистограмму.
Пример:
heights = [2, 1, 5, 6, 2, 3]
Ответ: 10 (прямоугольник высотой 5 и шириной 2, образованный столбиками 5 и 6).
Наивный перебор всех возможных левых и правых границ $(L, R)$ требует $O(N^3)$ или $O(N^2)$ времени. При $N = 100\,000$ квадратичный алгоритм потребует $10^{10}$ операций и безнадежно зависнет.
Монотонно возрастающий стек находит решение за один проход строго за $O(N)$!
🎯 Вопросы для уточнения условий (Interview Warm-up)
- Возможны ли нулевые высоты:
«Могут ли столбики иметь высоту 0?»
(Да, высота 0 означает разрыв гистограммы, площадь такого прямоугольника равна 0). - Размер массива и переполнение:
«Может ли площадь превысить 32-битныйint?»
(В LeetCode 84 $N \le 10^5, heights[i] \le 10^4$, максимальная площадь $10^9$ укладывается в 32-битныйint, но в продакшене лучше оперироватьint64). - Мутация входных данных:
«Разрешено ли дописывать элементы в конец срезаheights?»
(Категорически нет! Мутация чужого среза — опасный антипаттерн в Go).
Ключевой инсайт: Локальный минимум как ограничитель высоты
Площадь любого прямоугольника определяется формулой:
$$\text{Area} = \text{Height} \times \text{Width}$$
Если мы выберем некоторый столбик $i$ высотой $H = heights[i]$ в качестве минимального (ограничивающего) столбика прямоугольника, то насколько широко мы сможем растянуть этот прямоугольник влево и вправо?
- Правая граница ($R$): первый столбик справа, который строго ниже текущего ($heights[R] < H$);
- Левая граница ($L$): первый столбик слева, который строго ниже текущего ($heights[L] < H$).
Ширина такого максимального прямоугольника составит:
$$\text{Width} = R - L - 1$$
Магия Pop-операции в возрастающем стеке
Монотонно возрастающий стек находит обе границы одновременно в момент извлечения (Pop)!
Когда мы встречаем столбик $i$, который ниже вершины стека:
- Текущий индекс $i$ — это и есть правая граница $R$ для извлекаемой вершины;
- А левой границей $L$ является элемент, который лежит в стеке непосредственно под вершиной!
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
A["Текущий столбик i (высота h) ниже вершины стека"]:::entry --> B["Извлекаем mid = Pop()<br/>Высота прямоугольника: H = heights[mid]"]:::process
B --> C["Правая граница: R = i"]:::accent
C --> D{"Стек опустел?"}:::process
D -- "Да (mid был минимальным от самого начала)" --> E["Левая граница: L = -1, Ширина = i"]:::warning
D -- "Нет" --> F["Левая граница: L = stack[top]<br/>Ширина = i - stack[top] - 1"]:::accent
E --> G["Area = H * Width<br/>maxArea = max(maxArea, Area)"]:::success
F --> G
🛠️ Оптимальное решение на Go: Трюк с виртуальным нулем
Чтобы в конце алгоритма корректно вытолкнуть все оставшиеся в стеке столбики, не нужно делать heights = append(heights, 0) (это вызовет лишнюю аллокацию и сломает память вызывающего кода).
Мы просто виртуально расширяем цикл до i <= n, считая при i == n высоту равной 0:
package histogram
// LargestRectangleArea вычисляет наибольшую площадь за O(N) времени и O(N) памяти.
func LargestRectangleArea(heights []int) int {
n := len(heights)
if n == 0 {
return 0
}
// Стек хранит индексы столбиков
stack := make([]int, 0, n)
maxArea := 0
// Идем до n включительно. На шаге i = n виртуальная высота h = 0,
// что гарантированно выталкивает все оставшиеся элементы из стека!
for i := 0; i <= n; i++ {
var currentHeight int
if i < n {
currentHeight = heights[i]
} else {
currentHeight = 0 // Виртуальный замыкающий ноль
}
// Поддерживаем монотонно возрастающий стек
for len(stack) > 0 && currentHeight < heights[stack[len(stack)-1]] {
// Извлекаем вершину — столбик, для которого считаем площадь
midIdx := stack[len(stack)-1]
stack = stack[:len(stack)-1] // Pop
h := heights[midIdx]
// Определяем ширину
var width int
if len(stack) == 0 {
// Столбик midIdx был наименьшим от самого начала массива
width = i
} else {
leftBound := stack[len(stack)-1]
width = i - leftBound - 1
}
area := h * width
if area > maxArea {
maxArea = area
}
}
stack = append(stack, i)
}
return maxArea
}
⚙️ Mechanical Sympathy: Zero Allocations и Branch Predictor
- Ноль аллокаций в горячем цикле: Срез
stackвыделяется ровно один раз с емкостьюn. Все локальные переменные (currentHeight,h,width,area) компилируются в регистры CPU. - Идеальное предсказание ветвлений (Branch Prediction): Условие
if i < nоказывается истинным ровно $N$ раз подряд и только один раз (на последнем шаге) ложным. Процессор выполняет этот цикл на полной скорости без штрафов за ошибки спекулятивного исполнения (Branch Misprediction). - Строгая линейная сложность: Каждый индекс попадает в стек ровно 1 раз и извлекается ровно 1 раз. Общее число операций $\le 2N$.
🗺️ Навигация
| Назад | Вперед |
|---|---|
| ← 6. Next Greater Element I | 8. Decode String → |