0Pricing
DSA Interview Prep · Урок

Заполнение водой: стек и два указателя

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

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

Задача: задержка дождевой воды

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

Для каждой позиции i уровень воды равен min(max_left[i], max_right[i]) - height[i]. Если это значение отрицательно, вода не задерживается (столбец выше хотя бы одной границы). Существуют три подхода: предварительно вычисленные массивы O(n)/O(n), два указателя O(n)/O(1) и монотонный стек O(n)/O(n).

height = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]
# Water trapped at each position:
# pos 2: min(1,3)-0=1
# pos 4: min(2,3)-1=1
# pos 5: min(2,3)-0=2
# pos 6: min(2,3)-1=1
# pos 9: min(3,2)-1=1
# Total = 6
print('height:', height)
print('Expected trapped water: 6')

# Visualise
max_h = max(height)
for row in range(max_h, 0, -1):
    line = ''
    for h in height:
        line += '#' if h >= row else ' '
    print(line)

Подход 1: предварительно вычисленные массивы максимумов

Прямолинейное решение со сложностью O(n) по времени и O(n) по памяти заранее вычисляет два массива: max_left[i] = максимальная высота от индекса 0 до i, а max_right[i] = максимальная высота от индекса i до n-1. Количество воды в позиции i равно max(0, min(max_left[i], max_right[i]) - height[i]).

Построение max_left требует одного прохода слева направо, а построение max_right — одного прохода справа налево. Последний проход суммирует воду. Этот подход понятен и прост для объяснения, но использует O(n) дополнительной памяти.

def trap_prefix(height):
    n = len(height)
    if n < 3:
        return 0

    max_left = [0] * n
    max_right = [0] * n

    max_left[0] = height[0]
    for i in range(1, n):
        max_left[i] = max(max_left[i-1], height[i])

    max_right[-1] = height[-1]
    for i in range(n-2, -1, -1):
        max_right[i] = max(max_right[i+1], height[i])

    water = 0
    for i in range(n):
        water += max(0, min(max_left[i], max_right[i]) - height[i])
    return water

print(trap_prefix([0,1,0,2,1,0,1,3,2,1,2,1]))  # 6
print(trap_prefix([4,2,0,3,2,5]))                # 9

Подход 2: два указателя (память O(1))

Подход с двумя указателями обеспечивает сложность O(n) по времени и O(1) по памяти. Используйте указатели left и right, начинающие движение с двух концов. Поддерживайте max_left и max_right как текущие максимумы, обнаруженные к этому моменту с каждой стороны.

На каждом шаге обрабатывайте сторону с меньшим текущим максимумом, поскольку именно она является ограничивающим фактором. Если max_left < max_right, вода в позиции левого указателя равна max_left - height[left] (правая сторона достаточно высокая). Сдвиньте левый указатель внутрь. В противном случае симметрично обработайте правый указатель. Предварительно вычисленные массивы не нужны.

def trap_two_pointer(height):
    left, right = 0, len(height) - 1
    max_left = max_right = 0
    water = 0

    while left < right:
        if height[left] < height[right]:
            if height[left] >= max_left:
                max_left = height[left]    # new max on the left
            else:
                water += max_left - height[left]  # trapped by max_left
            left += 1
        else:
            if height[right] >= max_right:
                max_right = height[right]
            else:
                water += max_right - height[right]
            right -= 1
    return water

print(trap_two_pointer([0,1,0,2,1,0,1,3,2,1,2,1]))  # 6
print(trap_two_pointer([4,2,0,3,2,5]))                # 9
print(trap_two_pointer([3,0,3]))                      # 3

Почему работают два указателя: инвариант

Ключевая идея такова: когда мы обрабатываем левый указатель, потому что height[left] < height[right], мы знаем, что max_right >= height[right] > height[left]. Следовательно, фактическая правая граница воды находится не ниже height[right], а это значение уже больше max_left. Поэтому min(max_left, effective_max_right) = max_left, и формула для воды упрощается до max_left - height[left].

Нам не нужно знать точное значение max_right — достаточно знать, что оно не меньше height[right] > height[left], чтобы использовать max_left как уровень воды. Именно этот изящный инвариант делает возможным использование O(1) памяти.

# Trace two-pointer on [4, 2, 0, 3, 2, 5]
height = [4, 2, 0, 3, 2, 5]
left, right = 0, len(height) - 1
max_l = max_r = water = 0
print('height:', height)
print(f'{'Step':5} {'L':3} {'R':3} {'maxL':5} {'maxR':5} {'water':6} {'total':6}')
step = 0
while left < right:
    side = 'L' if height[left] < height[right] else 'R'
    if side == 'L':
        if height[left] >= max_l: max_l = height[left]
        else:
            w = max_l - height[left]; water += w
        left += 1
    else:
        if height[right] >= max_r: max_r = height[right]
        else:
            w = max_r - height[right]; water += w
        right -= 1
    step += 1
    print(f'{step:5} {left:3} {right:3} {max_l:5} {max_r:5} {water:6}')
print('Total trapped:', water)

Подход 3: монотонный стек (горизонтальные слои)

Подход с монотонным стеком вычисляет объём воды в горизонтальных слоях между соседними столбцами. Поддерживайте монотонный стек индексов, упорядоченный по убыванию. Когда столбец i выше вершины стека j, образуется впадина: дно имеет высоту height[j], левая стенка — высоту height[stack[-1]] после извлечения j, а правая стенка — высоту height[i]. Вода заполняет впадину до уровня min(left_wall, right_wall) - floor, а ширина равна i - stack[-1] - 1.

Каждая «впадина» вычисляется при встрече с более высоким столбцом. Так вода обрабатывается в ограниченных прямоугольных сегментах, что полезно, когда нужно также отслеживать, какие столбцы участвуют в формировании уровня воды.

def trap_stack(height):
    stack = []   # monotonic decreasing indices
    water = 0

    for i in range(len(height)):
        while stack and height[stack[-1]] < height[i]:
            bottom_idx = stack.pop()        # the floor of the valley
            if not stack:
                break                       # no left wall, no water
            left_idx = stack[-1]
            floor = height[bottom_idx]
            water_height = min(height[left_idx], height[i]) - floor
            width = i - left_idx - 1
            water += water_height * width
        stack.append(i)
    return water

print(trap_stack([0,1,0,2,1,0,1,3,2,1,2,1]))  # 6
print(trap_stack([4,2,0,3,2,5]))                # 9

Отслеживание работы монотонного стека

Рассмотрим работу подхода со стеком на [0,1,0,2,1,0,1,3,...]. Когда на позиции i=3 встречаем столбец 3 (h=2): на вершине стека находится i=2 (h=0), извлекаем его. Левая стенка находится в i=1 (h=1), правая стенка имеет высоту h=2. Высота воды = min(1,2)-0=1, ширина=3-1-1=1, площадь=1. Продолжаем: столбец на вершине стека i=1 (h=1) не ниже 2, останавливаемся. Добавляем 3 в стек.

Метод со стеком сложнее в реализации, чем метод двух указателей, но показывает, какие именно столбцы образуют каждую ячейку воды. Это наблюдение полезно в дополнительных задачах на восстановление расположения воды или подсчёт отдельных впадин.

def trap_stack_trace(height):
    stack = []
    water = 0
    for i in range(len(height)):
        print(f'i={i} h={height[i]}: stack={[height[s] for s in stack]}')
        while stack and height[stack[-1]] < height[i]:
            bot = stack.pop()
            if not stack:
                print(f'  Pop {height[bot]}: no left wall, skip')
                break
            left = stack[-1]
            h = min(height[left], height[i]) - height[bot]
            w = i - left - 1
            water += h * w
            print(f'  Pop {height[bot]}: floor={height[bot]}, left_wall={height[left]}, right_wall={height[i]}, h={h}, w={w}, +{h*w}')
        stack.append(i)
    return water

result = trap_stack_trace([0,1,0,2,1,0,1,3,2,1,2,1])
print('Total:', result)

Сравнение всех трёх подходов

Краткий обзор трёх подходов к задаче о сборе дождевой воды:

  • Префиксные массивы: время O(n), память O(n). Проще всего понять и проверить. Лучше всего подходят для собеседований, где ясность важнее эффективности использования памяти.
  • Два указателя: время O(n), память O(1). Оптимальны и по времени, и по памяти. Лучше всего подходят для дополнительных вопросов «можно ли использовать O(1) памяти?».
  • Монотонный стек: время O(n), память O(n). Обрабатывает воду в горизонтальных слоях. Лучше всего подходит, когда нужно определить, какие столбцы участвуют в формировании воды, или когда эта задача встречается как подзадача в более крупном алгоритме на основе стека.
height = [0,1,0,2,1,0,1,3,2,1,2,1]

# All three methods — verify they agree
def trap_prefix(h):
    n = len(h)
    ml = [0]*n; mr = [0]*n; ml[0]=h[0]; mr[-1]=h[-1]
    for i in range(1,n): ml[i]=max(ml[i-1],h[i])
    for i in range(n-2,-1,-1): mr[i]=max(mr[i+1],h[i])
    return sum(max(0,min(ml[i],mr[i])-h[i]) for i in range(n))

def trap_two_ptr(h):
    l,r,ml,mr,w = 0,len(h)-1,0,0,0
    while l<r:
        if h[l]<h[r]:
            ml=max(ml,h[l]); w+=ml-h[l]; l+=1
        else:
            mr=max(mr,h[r]); w+=mr-h[r]; r-=1
    return w

def trap_stk(h):
    stk,w = [],[]
    for i in range(len(h)):
        while stk and h[stk[-1]]<h[i]:
            b=stk.pop()
            if not stk: break
            w.append(max(0,min(h[stk[-1]],h[i])-h[b])*(i-stk[-1]-1))
        stk.append(i)
    return sum(w)

for h in [height, [4,2,0,3,2,5], [3,0,3], [1,0,1]]:
    p=trap_prefix(h); t=trap_two_ptr(h); s=trap_stk(h)
    print(f'{h}: prefix={p}, two-ptr={t}, stack={s}, match={p==t==s}')

Контейнер с максимальным количеством воды

Контейнер с максимальным количеством воды (LeetCode 11) часто путают с задачей о сборе дождевой воды. Здесь вы выбираете ровно два столбца, и вода ограничена только этими двумя столбцами (внутренние столбцы не имеют значения). Максимизируйте площадь min(height[l], height[r]) × (r - l).

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

def max_water_container(height):
    left, right = 0, len(height) - 1
    max_area = 0

    while left < right:
        area = min(height[left], height[right]) * (right - left)
        max_area = max(max_area, area)
        # Move the shorter bar: moving taller bar can only reduce min
        if height[left] < height[right]:
            left += 1
        else:
            right -= 1
    return max_area

print(max_water_container([1,8,6,2,5,4,8,3,7]))  # 49: bars 8 and 7
print(max_water_container([1,1]))                  # 1
print(max_water_container([4,3,2,1,4]))            # 16

# Key difference from trapping rain water:
# Container: choose 2 bars, water fills freely between them (no internal barriers)
# Trapping:  water fills ALL valleys in the full elevation map

Продвинутый подход: сбор дождевой воды II (3D)

Сбор дождевой воды II (LeetCode 407) расширяет задачу до двумерной матрицы высот. Вода может течь во всех четырёх направлениях и должна вытекать через границу. Решение использует минимальную кучу: сначала добавьте в кучу все граничные ячейки, затем выполните расширение, похожее на BFS. Обрабатывайте ячейку с наименьшей высотой — любой сосед с меньшей высотой должен удерживать воду как минимум на уровне текущей ячейки.

Это принципиально другой алгоритм по сравнению со случаем 1D, и он проверяет как операции с кучей, так и обход BFS. Приём с двумя указателями для 1D не обобщается на 2D, а подход с кучей обобщается.

import heapq

def trap_rain_water_2d(heightMap):
    if not heightMap or not heightMap[0]:
        return 0
    m, n = len(heightMap), len(heightMap[0])
    visited = [[False]*n for _ in range(m)]
    heap = []  # (height, row, col)

    # Add all border cells to the heap
    for i in range(m):
        for j in [0, n-1]:
            heapq.heappush(heap, (heightMap[i][j], i, j))
            visited[i][j] = True
    for j in range(n):
        for i in [0, m-1]:
            if not visited[i][j]:
                heapq.heappush(heap, (heightMap[i][j], i, j))
                visited[i][j] = True

    total = 0
    max_h = 0
    while heap:
        h, r, c = heapq.heappop(heap)
        max_h = max(max_h, h)
        for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]:
            nr, nc = r+dr, c+dc
            if 0<=nr<m and 0<=nc<n and not visited[nr][nc]:
                visited[nr][nc] = True
                total += max(0, max_h - heightMap[nr][nc])
                heapq.heappush(heap, (max(max_h, heightMap[nr][nc]), nr, nc))
    return total

map2d = [[1,4,3,1,3,2],[3,2,1,3,2,4],[2,3,3,2,3,1]]
print(trap_rain_water_2d(map2d))  # 4

Когда использовать каждый метод на собеседованиях

Руководство по выбору подхода для собеседования по задаче о сборе дождевой воды:

  • Начните с: префиксных массивов — их легко объяснить, они наглядны и явно корректны
  • Дополнительный вопрос «O(1) памяти?»: два указателя — объясните инвариант, согласно которому меньшая сторона является ограничивающим фактором
  • Если интервьюер спросит «есть ли другой подход?»: монотонный стек — объясните вычисление горизонтальных слоёв

Всегда начинайте с чёткого определения того, что задаёт уровень воды в каждой позиции (минимум из самых высоких столбцов с каждой стороны), прежде чем переходить к коду. Это показывает понимание задачи и облегчает объяснение решения.

# Quick summary of all three approaches
approaches = [
    {
        'name': 'Prefix max arrays',
        'time': 'O(n)', 'space': 'O(n)',
        'description': '3 passes: build max_left, max_right, sum water column-by-column',
    },
    {
        'name': 'Two pointers',
        'time': 'O(n)', 'space': 'O(1)',
        'description': 'Process smaller side: its max is the limiting wall, no array needed',
    },
    {
        'name': 'Monotonic stack',
        'time': 'O(n)', 'space': 'O(n)',
        'description': 'Compute water in horizontal layers when a taller bar is encountered',
    },
]
for a in approaches:
    print(f'{a["name"]} [{a["time"]} / {a["space"]}]')
    print(f'  {a["description"]}')
    print()

Граничные случаи и распространённые ошибки

Распространённые ошибки в задаче о сборе дождевой воды:

  • Забыть про min: уровень воды равен min(max_left, max_right), а не одному из этих значений. Высокие стенки нужны столбцу с обеих сторон.
  • Отрицательный объём воды: используйте max(0, ...), чтобы заменить отрицательные значения на 0, когда высота столбца в позиции превышает уровень воды.
  • Крайние позиции: крайний левый и крайний правый столбцы никогда не могут удерживать воду (с одной стороны нет стенки). Подход с префиксным массивом обрабатывает это естественным образом, поскольку max_left[0] = height[0] гарантирует, что в позиции с индексом 0 объём воды всегда равен 0.
  • Пустые или очень маленькие массивы: возвращайте 0 для массивов, содержащих менее 3 элементов.
def trap(height):
    n = len(height)
    if n < 3:
        return 0   # need at least 3 bars to trap anything

    left, right = 0, n - 1
    max_l = max_r = water = 0
    while left < right:
        if height[left] <= height[right]:
            if height[left] >= max_l:
                max_l = height[left]
            else:
                water += max_l - height[left]  # never negative: max_l > height[left]
            left += 1
        else:
            if height[right] >= max_r:
                max_r = height[right]
            else:
                water += max_r - height[right]
            right -= 1
    return water

# Edge cases
print(trap([]))          # 0: empty
print(trap([1]))         # 0: single bar
print(trap([1,2]))       # 0: two bars
print(trap([3,0,3]))     # 3: simple valley
print(trap([3,3,3]))     # 0: flat top, no water

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

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

Итоги урока

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

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

Урок «Заполнение водой: стек и два указателя» бесплатный?

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

Чему я научусь в уроке «Заполнение водой: стек и два указателя»?

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

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

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

Сколько времени занимает урок «Заполнение водой: стек и два указателя»?

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

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

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

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

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