0Pricing
Coding Interview Prep · Урок

Наибольший прямоугольник в гистограмме

Используйте монотонный стек для отслеживания левых границ и вычислите максимальную площадь прямоугольника, помещающегося в гистограмме, за один проход

«Наибольший прямоугольник в гистограмме» — бесплатный урок Coding Interview Prep на CoddyKit. Это урок 2 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения Coding Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс Coding Interview Prep содержит 4 уроков всего.

Задача: наибольший прямоугольник в гистограмме

В задаче о наибольшем прямоугольнике в гистограмме (LeetCode 84) задан массив неотрицательных целых чисел, представляющих высоты столбцов гистограммы, где ширина каждого столбца равна 1. Необходимо найти площадь наибольшего прямоугольника, который можно построить внутри гистограммы. Прямоугольник должен охватывать последовательные столбцы, а его высота ограничена самым низким охваченным столбцом.

Решение перебором: для каждой пары (i, j) вычислить минимальную высоту на отрезке [i, j] и умножить её на (j - i + 1). Это даёт O(n³) или O(n²) при предварительно вычисленных минимумах — слишком медленно. Решение с монотонным стеком работает за O(n).

# Example: heights = [2, 1, 5, 6, 2, 3]
# Rectangles:
# width=1, height=6 at index 3 => area=6
# width=2, height=5 at indices 2-3 => area=10 (maximum!)
# width=6, height=1 across all => area=6
# width=3, height=2 at indices 2-4 => area=6
heights = [2, 1, 5, 6, 2, 3]
print('Heights:', heights)
print('Expected max area: 10 (bars of height 5 and 6, width 2)')

# Brute force for small inputs:
def brute_force(heights):
    n = len(heights)
    max_area = 0
    for i in range(n):
        min_h = heights[i]
        for j in range(i, n):
            min_h = min(min_h, heights[j])
            max_area = max(max_area, min_h * (j - i + 1))
    return max_area

print('Brute force answer:', brute_force(heights))  # 10

Главная идея: что ограничивает прямоугольник каждого столбца?

Для каждого столбца i высоты h наибольший прямоугольник, в котором он может быть минимальным, простирается влево до первого столбца ниже h и вправо до первого столбца ниже h. Ширина равна right_boundary - left_boundary - 1, а площадь — h × width.

Так задача принимает другой вид: для каждого столбца нужно найти его предыдущий меньший элемент (PSE) и следующий меньший элемент (NSE). Именно это вычисляет монотонный возрастающий стек. В момент выполнения pop для столбца i (поскольку найден более низкий столбец) текущий столбец является его NSE, а вершина стека после извлечения — его PSE.

heights = [2, 1, 5, 6, 2, 3]
n = len(heights)

# Find PSE and NSE for each bar
pse = [-1] * n   # index of previous smaller element
nse = [n] * n    # index of next smaller element (default: beyond array)

# PSE
stack = []
for i in range(n):
    while stack and heights[stack[-1]] >= heights[i]:
        stack.pop()
    pse[i] = stack[-1] if stack else -1
    stack.append(i)

# NSE
stack = []
for i in range(n - 1, -1, -1):
    while stack and heights[stack[-1]] >= heights[i]:
        stack.pop()
    nse[i] = stack[-1] if stack else n
    stack.append(i)

max_area = 0
for i in range(n):
    width = nse[i] - pse[i] - 1
    area = heights[i] * width
    print(f'Bar {i} (h={heights[i]}): PSE={pse[i]}, NSE={nse[i]}, width={width}, area={area}')
    max_area = max(max_area, area)
print('Max area:', max_area)

Однопроходное решение с монотонным стеком

Описанный выше двухпроходный подход работает, но его можно объединить в один проход. Обрабатывайте столбцы слева направо с помощью монотонного возрастающего стека. Когда столбец i ниже вершины стека, выполните pop для вершины стека: высота извлечённого столбца — это высота прямоугольника, его правая граница равна i, а левая граница равна новой вершине стека + 1.

Стандартный приём — выполнить append маркера 0 в конец массива высот. Это гарантирует, что в конце все столбцы будут извлечены из стека, даже если более низкий столбец естественным образом не встретится. Без маркера потребуется отдельный этап очистки после цикла для оставшихся элементов стека.

def largest_rectangle(heights):
    stack = []   # monotonic increasing: indices of bars
    max_area = 0
    heights = heights + [0]  # sentinel: forces all bars to be popped

    for i, h in enumerate(heights):
        while stack and heights[stack[-1]] > h:
            height = heights[stack.pop()]       # height of the rectangle
            width = i if not stack else i - stack[-1] - 1  # left boundary
            max_area = max(max_area, height * width)
        stack.append(i)
    return max_area

print(largest_rectangle([2, 1, 5, 6, 2, 3]))  # 10
print(largest_rectangle([2, 4]))               # 4
print(largest_rectangle([1, 1]))               # 2
print(largest_rectangle([0, 9]))               # 9
print(largest_rectangle([6, 7, 5, 2, 4, 5, 9, 3]))  # 16

Трассировка однопроходного алгоритма

Пошагово разберём [2, 1, 5, 6, 2, 3, 0] (с маркером):

  • i=0, h=2: помещаем 0. Стек: [0]
  • i=1, h=1: pop 0 (h=2, ширина=1, площадь=2). Стек пуст, помещаем 1. Стек: [1]
  • i=2, h=5: 5>1, помещаем 2. Стек: [1,2]
  • i=3, h=6: 6>5, помещаем 3. Стек: [1,2,3]
  • i=4, h=2: pop 3 (h=6,ширина=4-2-1=1,площадь=6), pop 2 (h=5,ширина=4-1-1=2,площадь=10★), 2>1 — остановиться. Помещаем 4. Стек: [1,4]
  • i=5, h=3: 3>2, помещаем 5. Стек: [1,4,5]
  • i=6, маркер h=0: выполнить pop для всех элементов, вычисляя площади...
def largest_rectangle_trace(heights):
    stack = []
    max_area = 0
    hs = heights + [0]

    for i, h in enumerate(hs):
        while stack and hs[stack[-1]] > h:
            top = stack.pop()
            w = i if not stack else i - stack[-1] - 1
            area = hs[top] * w
            print(f'  Pop bar {top} (h={hs[top]}): width={w}, area={area}', end='')
            if area > max_area:
                max_area = area
                print(' *** NEW MAX ***', end='')
            print()
        print(f'i={i} h={h}: push {i}, stack={[hs[s] for s in stack + [i]]}')
        stack.append(i)
    print(f'Max area: {max_area}')
    return max_area

largest_rectangle_trace([2, 1, 5, 6, 2, 3])

Вычисление ширины: почему i - stack[-1] - 1?

Когда выполняется pop для столбца j из стека, мы знаем следующее: правая граница прямоугольника столбца j — это i (первый столбец справа, который ниже j). Левая граница — столбец, находящийся непосредственно под j в стеке после извлечения; обозначим его k. Поэтому ширина равна i - k - 1 (столбцы от k+1 до i-1 включительно).

Если после извлечения стек пуст, прямоугольник столбца j простирается до самого левого края (индекс 0). Тогда ширина просто равна i (индексы от 0 до i-1, и все соответствующие столбцы не ниже, чем heights[j]). Это особый случай: width = i if not stack else i - stack[-1] - 1.

# Illustrating left/right boundary logic
heights = [1, 3, 5, 2]
# After processing with stack:
# When we pop bar 2 (h=5) at i=3 (h=2):
#   stack after pop = [0, 1]   => left boundary = 1+1=2, right=3-1=2 => width=1
# When we pop bar 1 (h=3) at i=3 (h=2):
#   stack after pop = [0]       => left boundary = 0+1=1, right=3-1=2 => width=2
# etc.

def compute_boundaries(heights):
    hs = heights + [0]
    stack = []
    for i, h in enumerate(hs):
        while stack and hs[stack[-1]] > h:
            top = stack.pop()
            if stack:
                left = stack[-1] + 1
                width = i - stack[-1] - 1
            else:
                left = 0
                width = i
            print(f'Bar {top} (h={hs[top]}): extends from {left} to {i-1}, width={width}')
        stack.append(i)

compute_boundaries([2, 1, 5, 6, 2, 3])

Максимальный прямоугольник в двоичной матрице

Максимальный прямоугольник (LeetCode 85) расширяет задачу о гистограмме на двумерную двоичную матрицу. Для каждой строки вычислите высоту последовательных единиц над каждой ячейкой. Это создаёт гистограмму для данной строки. Примените алгоритм поиска наибольшего прямоугольника в гистограмме к гистограмме каждой строки. Ответом будет максимум среди всех строк.

Так двумерная задача сводится к m повторяющимся одномерным задачам о гистограмме. Временная сложность для матрицы из m строк и n столбцов равна O(m × n): по одному проходу по гистограмме на строку, каждый проход занимает O(n).

def maximal_rectangle(matrix):
    if not matrix or not matrix[0]:
        return 0
    n = len(matrix[0])
    heights = [0] * n
    max_area = 0

    def hist_max_area(h):
        stack, area = [], 0
        for i, hh in enumerate(h + [0]):
            while stack and h[stack[-1]] > hh:
                top = stack.pop()
                w = i if not stack else i - stack[-1] - 1
                area = max(area, h[top] * w)
            stack.append(i)
        return area

    for row in matrix:
        for j in range(n):
            heights[j] = heights[j] + 1 if row[j] == '1' else 0
        max_area = max(max_area, hist_max_area(heights[:]))
    return max_area

matrix = [['1','0','1','0','0'],
          ['1','0','1','1','1'],
          ['1','1','1','1','1'],
          ['1','0','0','1','0']]
print(maximal_rectangle(matrix))  # 6

Граничные случаи в задачах о гистограммах

Важные граничные случаи:

  • Одинаковая высота всех столбцов: весь массив образует один прямоугольник; ответ = n × высота
  • Монотонное возрастание: извлечение не происходит до маркера; площадь последнего столбца максимальна
  • Один столбец: ответ = height[0]
  • Столбцы высоты 0: они служат естественными маркерами и разделяют гистограмму на независимые сегменты

Маркер (операция append с аргументом 0) в конце обрабатывает случай монотонного возрастания, заставляя все оставшиеся столбцы извлечься в конце. Без него после основного прохода потребуется отдельный цикл очистки.

def largest_rectangle(heights):
    stack = []
    max_area = 0
    heights = heights + [0]
    for i, h in enumerate(heights):
        while stack and heights[stack[-1]] > h:
            top = stack.pop()
            w = i if not stack else i - stack[-1] - 1
            max_area = max(max_area, heights[top] * w)
        stack.append(i)
    return max_area

# Edge cases
print(largest_rectangle([5, 5, 5, 5]))    # 20 (all same)
print(largest_rectangle([1, 2, 3, 4, 5])) # 9 (increasing: 3*3)
print(largest_rectangle([5, 4, 3, 2, 1])) # 9 (decreasing: 3*3)
print(largest_rectangle([5]))              # 5 (single bar)
print(largest_rectangle([0, 0, 0]))        # 0 (all zero)
print(largest_rectangle([3, 0, 3]))        # 3 (zero splits)

Альтернатива: разделяй и властвуй

Задачу о гистограмме также можно решить методом «разделяй и властвуй»: разделить массив по столбцу минимальной высоты, рекурсивно решить задачу для каждой половины и сравнить результаты с прямоугольником, охватывающим всю ширину и использующим минимальную высоту. В среднем это даёт O(n log n), но для отсортированных входных данных в худшем случае — O(n²).

Подход с монотонным стеком строго лучше: его сложность в худшем случае равна O(n). Однако понимание подхода «разделяй и властвуй» углубляет интуитивное понимание задачи и объясняет, почему столбец минимальной высоты в любом сегменте всегда ограничивает прямоугольники на всю ширину.

def largest_rectangle_dc(heights, lo=0, hi=None):
    if hi is None:
        hi = len(heights) - 1
    if lo > hi:
        return 0
    # Find the index of the minimum height in [lo, hi]
    min_idx = lo
    for i in range(lo, hi + 1):
        if heights[i] < heights[min_idx]:
            min_idx = i
    # Three options:
    # 1. Max rect entirely in left half
    # 2. Max rect entirely in right half
    # 3. Max rect spanning entire [lo, hi] with height = min
    full_width_area = heights[min_idx] * (hi - lo + 1)
    left_area  = largest_rectangle_dc(heights, lo, min_idx - 1)
    right_area = largest_rectangle_dc(heights, min_idx + 1, hi)
    return max(full_width_area, left_area, right_area)

print(largest_rectangle_dc([2, 1, 5, 6, 2, 3]))  # 10

Шаблон гистограммы: количество подмассивов

Связанная задача с использованием той же техники работы со стеком: подсчитать количество подмассивов в гистограмме, где минимальный элемент равен заданной цели. Для этого вычисляются PSE и NSE каждого столбца, а затем используется формула (i - pse[i]) × (nse[i] - i), которая подсчитывает подгистограммы, в которых столбец i является минимальным.

Эта техника «количество слева × количество справа» встречается в нескольких задачах LeetCode: сумма минимумов подмассивов (907), подсчёт подстрок со всеми уникальными символами и задачах на вычисление вклада. Монотонный стек вычисляет PSE и NSE за O(n), обеспечивая вклад каждого элемента за O(1).

def sum_of_subarray_minimums(arr):
    n = len(arr)
    pse = [-1] * n   # previous strictly smaller element
    nse = [n] * n    # next smaller or equal element

    stack = []
    for i in range(n):
        while stack and arr[stack[-1]] >= arr[i]:
            stack.pop()
        pse[i] = stack[-1] if stack else -1
        stack.append(i)

    stack = []
    for i in range(n - 1, -1, -1):
        while stack and arr[stack[-1]] > arr[i]:
            stack.pop()
        nse[i] = stack[-1] if stack else n
        stack.append(i)

    MOD = 10**9 + 7
    total = 0
    for i in range(n):
        left_count = i - pse[i]          # subarrays where i is leftmost min
        right_count = nse[i] - i        # subarrays where i is the min
        total += arr[i] * left_count * right_count
    return total % MOD

print(sum_of_subarray_minimums([3, 1, 2, 4]))  # 17
print(sum_of_subarray_minimums([11, 81, 94, 43, 3]))  # 444

Практические советы для собеседования

Если на собеседовании вам встретилась задача о гистограмме, придерживайтесь следующего списка проверок:

  1. Уточните: могут ли высоты быть равны 0? Что требуется вывести — площадь, индексы или количество?
  2. Начните с полного перебора и укажите сложность O(n²) или O(n³)
  3. Упомяните, что вклад каждого столбца зависит от его протяжённости влево и вправо до ближайшего более низкого столбца
  4. Представьте PSE/NSE → монотонный стек → решение за O(n)
  5. Обработайте приём с сигнальным элементом (append 0), чтобы упростить код
  6. Разберите небольшой пример на доске

Частый дополнительный вопрос: обобщите решение на двумерный случай (максимальный прямоугольник). Покажите, что задачу можно свести к n задачам о гистограмме, каждая из которых решается за O(n), то есть общая сложность составит O(m×n).

# Final clean solution for interview
def largest_rectangle_in_histogram(heights):
    stack = []
    max_area = 0
    for i, h in enumerate(heights + [0]):  # sentinel forces final pops
        while stack and heights[stack[-1]] > h:
            height = heights[stack.pop()]
            width = i if not stack else i - stack[-1] - 1
            max_area = max(max_area, height * width)
        stack.append(i)
    return max_area

# Verify all test cases from earlier
test_cases = [
    ([2, 1, 5, 6, 2, 3], 10),
    ([6, 7, 5, 2, 4, 5, 9, 3], 16),
    ([1], 1),
    ([2, 0, 2], 2),
    ([], 0),
]
for heights, expected in test_cases:
    if not heights:
        result = 0
    else:
        result = largest_rectangle_in_histogram(heights)
    status = 'PASS' if result == expected else 'FAIL'
    print(f'{status}: {heights} => {result} (expected {expected})')

Сумма диапазонов подмассивов и похожие варианты

Техника PSE/NSE обобщается на несколько задач LeetCode. Сумма диапазонов подмассивов (2104) — это задача на сумму (максимум - минимум) по всем подмассивам. Она равна (сумма максимумов подмассивов) минус (сумма минимумов подмассивов), причём каждую из этих величин можно вычислить с помощью монотонного стека за O(n). Количество видимых людей в очереди (1944) использует убывающий стек, где каждый pop засчитывает одного видимого человека. Понять, что задача относится к этому семейству, помогает формулировка «для каждого элемента: насколько далеко он может доминировать?» — ответом всегда служат PSE/NSE с монотонным стеком.

def sum_subarray_ranges(nums):
    n = len(nums)
    # Sum of subarray max - sum of subarray min
    def contrib(arr, is_max):
        # Count contribution of each element as max (or min)
        n = len(arr)
        left = [0]*n; right = [0]*n
        stack = []
        for i in range(n):
            while stack and (arr[stack[-1]] < arr[i] if is_max else arr[stack[-1]] > arr[i]):
                stack.pop()
            left[i] = i - (stack[-1] if stack else -1)
            stack.append(i)
        stack = []
        for i in range(n-1, -1, -1):
            while stack and (arr[stack[-1]] <= arr[i] if is_max else arr[stack[-1]] >= arr[i]):
                stack.pop()
            right[i] = (stack[-1] if stack else n) - i
            stack.append(i)
        return sum(arr[i] * left[i] * right[i] for i in range(n))
    return contrib(nums, True) - contrib(nums, False)

print(sum_subarray_ranges([1, 2, 3]))    # 4
print(sum_subarray_ranges([1, 3, 3]))    # 4
print(sum_subarray_ranges([4, -2, -3, 4, 1]))  # 59

Быстрая проверка

Проверьте, насколько вы поняли концепции структур данных и алгоритмов — подготовки к собеседованию по программированию, изложенные в этом уроке.

Итоги урока

В этом уроке вы узнали: для каждого столбца границы содержащего его максимального прямоугольника задаются ближайшим более низким столбцом с каждой стороны (PSE и NSE), монотонный возрастающий стек вычисляет все границы PSE/NSE за один проход O(n), находя обе границы по мере удаления столбцов, а добавление сигнального элемента 0 гарантирует удаление всех столбцов из стека, упрощая код до одного цикла. Далее мы применим монотонный дек для решения задачи о максимуме в скользящем окне за O(n).

Часто задаваемые вопросы

Урок «Наибольший прямоугольник в гистограмме» бесплатный?

Да — полный текст урока «Наибольший прямоугольник в гистограмме» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс Coding Interview Prep, подпишись на CoddyKit PRO. Курс Coding Interview Prep содержит 4 уроков всего.

Чему я научусь в уроке «Наибольший прямоугольник в гистограмме»?

Используйте монотонный стек для отслеживания левых границ и вычислите максимальную площадь прямоугольника, помещающегося в гистограмме, за один проход Ты практикуешь Coding Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать Coding Interview Prep?

Предыдущий опыт не требуется. Coding Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 2 из 4.

Сколько времени занимает урок «Наибольший прямоугольник в гистограмме»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке Coding Interview Prep?

Да. Каждый урок Coding Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. Монотонный стек: возрастание и убывание
  2. Наибольший прямоугольник в гистограмме
  3. Максимум в скользящем окне с монотонной декой
  4. Заполнение водой: стек и два указателя
← Назад к Coding Interview Prep