DSA Interview Prep · Урок

Максимум в скользящем окне с монотонной декой

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

Урок 3 из 413 шагов

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

Задача о максимуме в скользящем окне

В задаче о максимуме в скользящем окне (LeetCode 239) вам даны массив и размер окна k. По мере того как окно сдвигается слева направо на одну позицию за раз, выведите максимальный элемент в каждом окне. Подход с полным перебором вычисляет максимум каждого окна из k элементов за O(k), что даёт общую сложность O(nk) — слишком медленно при больших k.

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

from collections import deque

# Brute force O(nk) for comparison
def sliding_max_brute(nums, k):
    return [max(nums[i:i+k]) for i in range(len(nums) - k + 1)]

nums = [1, 3, -1, -3, 5, 3, 6, 7]
k = 3
print('Input:', nums, 'k=', k)
print('Expected: [3, 3, 5, 5, 6, 7]')
print('Brute:   ', sliding_max_brute(nums, k))

Монотонный дек: ключевая идея

Поддерживайте монотонно убывающий дек, хранящий индексы, а не значения. Инвариант: nums[deque[0]] >= nums[deque[1]] >= ... >= nums[deque[-1]]. Перед добавлением индекса i:

  • Удалите вышедшие из окна индексы с начала: если deque[0] <= i - k, индекс уже покинул окно.
  • Удалите индексы меньших элементов с конца: пока nums[deque[-1]] <= nums[i], эти индексы уже никогда не смогут стать максимумом будущего окна (они находятся левее и соответствующие им элементы меньше), поэтому их следует отбросить.

После этих операций добавьте i в конец. В начале дека всегда находится максимум текущего окна.

from collections import deque

def sliding_window_max(nums, k):
    dq = deque()  # stores indices; values are decreasing
    result = []

    for i, n in enumerate(nums):
        # 1. Remove indices outside the current window
        while dq and dq[0] <= i - k:
            dq.popleft()

        # 2. Remove indices with smaller values from the back
        while dq and nums[dq[-1]] <= n:
            dq.pop()

        dq.append(i)

        # 3. Record max when first full window is complete
        if i >= k - 1:
            result.append(nums[dq[0]])   # front = max of current window

    return result

nums = [1, 3, -1, -3, 5, 3, 6, 7]
print(sliding_window_max(nums, 3))  # [3, 3, 5, 5, 6, 7]

Пошаговый разбор дека

Разберём пример [1, 3, -1, -3, 5, 3, 6, 7] при k=3:

  • i=0 (1): dq=[0]
  • i=1 (3): pop 0 (1<3), dq=[1]
  • i=2 (-1): -1<3, поэтому оставляем, dq=[1,2]. Окно [1,3,-1], максимум=nums[1]=3
  • i=3 (-3): -3<-1, dq=[1,2,3]. Проверяем начало: 1 > 3-3=0, OK. Максимум окна=3
  • i=4 (5): pop 3,2,1 (все меньше), dq=[4]. Индекс в начале 4 > 4-3=1, OK. Максимум=5
  • i=5 (3): 3<5, dq=[4,5]. Индекс в начале 4 > 5-3=2, OK. Максимум=5
  • i=6 (6): pop 5,4 (оба меньше), dq=[6]. Максимум=6
  • i=7 (7): pop 6, dq=[7]. Максимум=7
from collections import deque

def sliding_window_max_trace(nums, k):
    dq = deque()
    result = []
    for i, n in enumerate(nums):
        while dq and dq[0] <= i - k:
            print(f'  Remove expired index {dq[0]} from front')
            dq.popleft()
        while dq and nums[dq[-1]] <= n:
            print(f'  Remove smaller index {dq[-1]} (val={nums[dq[-1]]}) from back')
            dq.pop()
        dq.append(i)
        print(f'i={i} n={n}: dq={list(dq)} vals={[nums[j] for j in dq]}')
        if i >= k - 1:
            win_max = nums[dq[0]]
            result.append(win_max)
            print(f'  Window {nums[max(0,i-k+1):i+1]} -> max={win_max}')
    return result

nums = [1, 3, -1, -3, 5, 3, 6, 7]
result = sliding_window_max_trace(nums, 3)
print('Result:', result)

Почему каждый элемент добавляется и удаляется не более одного раза

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

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

from collections import deque

def sliding_window_max_instrumented(nums, k):
    dq = deque()
    result = []
    front_pops = back_pops = pushes = 0

    for i, n in enumerate(nums):
        while dq and dq[0] <= i - k:
            dq.popleft(); front_pops += 1
        while dq and nums[dq[-1]] <= n:
            dq.pop(); back_pops += 1
        dq.append(i); pushes += 1
        if i >= k - 1:
            result.append(nums[dq[0]])

    print(f'n={len(nums)}: pushes={pushes}, front_pops={front_pops}, back_pops={back_pops}')
    print(f'Total deque ops = {pushes + front_pops + back_pops} <= 3n = {3*len(nums)}')
    return result

import random; random.seed(0)
nums = [random.randint(-100, 100) for _ in range(20)]
sliding_window_max_instrumented(nums, 5)

Минимум в скользящем окне

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

Задачи на минимум в скользящем окне часто встречаются как подзадачи внутри более крупных алгоритмов. Например, для вычисления минимальной стоимости перемещения грузов по пути с k промежуточными остановками может потребоваться минимум в скользящем окне по массивам DP.

from collections import deque

def sliding_window_min(nums, k):
    dq = deque()  # increasing monotonic deque
    result = []

    for i, n in enumerate(nums):
        while dq and dq[0] <= i - k:
            dq.popleft()               # expired
        while dq and nums[dq[-1]] >= n:
            dq.pop()                   # pop larger values from back
        dq.append(i)
        if i >= k - 1:
            result.append(nums[dq[0]])  # front = min
    return result

nums = [1, 3, -1, -3, 5, 3, 6, 7]
print('Max k=3:', sliding_window_min.__name__, '->', end=' ')
print(sliding_window_min(nums, 3))   # [-1, -3, -3, -3, 3, 3]

from collections import deque
def sliding_window_max(nums, k):
    dq = deque(); result = []
    for i, n in enumerate(nums):
        while dq and dq[0] <= i-k: dq.popleft()
        while dq and nums[dq[-1]] <= n: dq.pop()
        dq.append(i)
        if i >= k-1: result.append(nums[dq[0]])
    return result

print('Max k=3:', sliding_window_max(nums, 3))   # [3,3,5,5,6,7]

Игра с прыжками VI: DP с монотонным деком

Игра с прыжками VI (LeetCode 1696) — классический пример сочетания DP и монотонного дека. Дан массив и максимальная длина прыжка k; начиная с индекса 0, на каждом шаге можно переместиться на 1–k позиций вперёд, прибавляя очки целевой ячейки. Максимизируйте общую сумму очков. Рекуррентное соотношение DP имеет вид dp[i] = nums[i] + max(dp[i-k], ..., dp[i-1]). Поиск максимума в скользящем окне по массиву DP даёт общую сложность O(n).

Этот шаблон — рекуррентное соотношение DP, в котором каждая ячейка зависит от максимума в окне фиксированного размера среди предыдущих ячеек, — встречается очень часто и всегда требует монотонного дека.

from collections import deque

def max_result(nums, k):
    n = len(nums)
    dp = [0] * n
    dp[0] = nums[0]
    dq = deque([0])   # indices of max dp values in current window

    for i in range(1, n):
        # Remove expired indices
        while dq and dq[0] < i - k:
            dq.popleft()
        # dp[i] = nums[i] + max dp in window [i-k, i-1]
        dp[i] = nums[i] + dp[dq[0]]
        # Maintain decreasing deque on dp values
        while dq and dp[dq[-1]] <= dp[i]:
            dq.pop()
        dq.append(i)

    return dp[n - 1]

print(max_result([1,-1,-2,4,-7,3], 2))    # 7: path 1->4->3
print(max_result([10,-5,-2,4,0,3], 3))    # 17: path 10->4->3
print(max_result([1,-5,-20,4,-1,3,-6,-3], 2))  # 0

Максимум в скользящем окне: альтернатива с деревом отрезков

Если размер окна меняется, а не равен фиксированному k, монотонный дек неприменим напрямую. Вместо него используйте разреженную таблицу для запросов максимума на статических диапазонах: после предварительной обработки за O(n log n) каждый запрос выполняется за O(1). Для динамических обновлений используйте дерево отрезков, обеспечивающее O(log n) на запрос. Однако для скользящих окон фиксированного размера k дек при сложности O(n) не имеет равных.

На собеседованиях всегда предпочитайте монотонный дек за O(n) дереву отрезков за O(n log n), если размер окна постоянен. Упомяните компромисс: дек не умеет работать с произвольными размерами окон или обновлениями, тогда как деревья отрезков это позволяют.

# Sparse table for static RMQ (range maximum query)
import math

def build_sparse_table(arr):
    n = len(arr)
    LOG = int(math.log2(n)) + 1 if n else 1
    table = [[0]*n for _ in range(LOG)]
    table[0] = arr[:]
    j = 1
    while (1 << j) <= n:
        for i in range(n - (1 << j) + 1):
            table[j][i] = max(table[j-1][i], table[j-1][i + (1 << (j-1))])
        j += 1
    return table

def query(table, l, r):
    k = int(math.log2(r - l + 1))
    return max(table[k][l], table[k][r - (1 << k) + 1])

arr = [1, 3, -1, -3, 5, 3, 6, 7]
table = build_sparse_table(arr)
k = 3
result = [query(table, i, i + k - 1) for i in range(len(arr) - k + 1)]
print('Sparse table result:', result)  # [3, 3, 5, 5, 6, 7]

Самый длинный подмассив из единиц после удаления одного элемента

LeetCode 1493: дан двоичный массив; найдите длину самого длинного подмассива из единиц после удаления ровно одного элемента (это может быть 0 или 1). Это задача на скользящее окно. Поддерживайте окно, содержащее не более одного 0. Когда в окне становится больше одного 0, сжимайте его слева.

Здесь используется шаблон скользящего окна переменного размера, а не дек. Однако его можно сочетать с техникой максимального окна: после нахождения всех допустимых окон ответом будет максимальная длина. Удаление одного элемента означает, что в нашем окне единиц допускается ровно один 0.

def longest_subarray(nums):
    left = 0
    zeros = 0
    max_len = 0

    for right in range(len(nums)):
        if nums[right] == 0:
            zeros += 1
        while zeros > 1:
            if nums[left] == 0:
                zeros -= 1
            left += 1
        # Window [left, right] has at most 1 zero
        # After deleting one element, length = right - left (not +1, since we delete one)
        max_len = max(max_len, right - left)

    return max_len

print(longest_subarray([1,1,0,1]))       # 3: delete the 0
print(longest_subarray([0,1,1,1,0,1,1,0,1]))  # 5
print(longest_subarray([1,1,1]))          # 2: must delete one 1

Сравнение дека, очереди и стека

Понимание того, когда использовать каждый контейнер, — ключевой навык на собеседованиях:

  • Стек (список): LIFO, доступ с одного конца. Используйте его для DFS, разбора выражений и задач на монотонный стек.
  • Очередь (дек с добавлением слева и popleft): FIFO, добавление с одного конца, удаление с другого. Используйте её для BFS и планирования задач.
  • Дек: доступ к обоим концам за O(1). Используйте его для скользящего окна с выходом элементов (удалением с начала) и монотонным инвариантом (удалением с конца). Максимум в скользящем окне — классическая задача на дек.

collections.deque в Python — инструмент для всех трёх случаев. Используйте append/pop для поведения стека и append/popleft или appendleft/pop для поведения очереди или дека.

from collections import deque

# deque as stack
stack = deque()
stack.append(1); stack.append(2); stack.append(3)
print('Stack pop:', stack.pop())  # 3 (LIFO)

# deque as queue
queue = deque()
queue.append(1); queue.append(2); queue.append(3)
print('Queue pop:', queue.popleft())  # 1 (FIFO)

# deque as sliding window with front expiry + back monotonic
dq = deque()
nums = [3, 1, 4, 1, 5, 9, 2, 6]
k = 3
for i, n in enumerate(nums):
    while dq and dq[0] <= i - k: dq.popleft()   # expire front
    while dq and nums[dq[-1]] <= n: dq.pop()     # maintain back
    dq.append(i)
    if i >= k - 1:
        print(f'Window {nums[max(0,i-k+1):i+1]}: max={nums[dq[0]]}')

Кратчайший подмассив с суммой не меньше K: дек и префиксные суммы

Кратчайший подмассив с суммой не меньше K (LeetCode 862) — сложная задача, сочетающая префиксные суммы с монотонным деком. Постройте префиксные суммы, затем с помощью дека для каждой правой границы найдите самую левую префиксную сумму, удовлетворяющую условию prefix[right] - prefix[left] >= k. Дек поддерживает возрастающий порядок префиксных сумм (выполняйте pop с конца, чтобы сохранить возрастание) и выполняет pops с начала, собирая допустимые ответы.

Это одна из самых сложных задач на скользящее окно, поскольку в ней встречаются отрицательные числа (что исключает простой двухуказательный подход), а дек должен одновременно служить монотонной структурой и механизмом удаления вышедших элементов.

from collections import deque

def shortest_subarray(nums, k):
    n = len(nums)
    prefix = [0] * (n + 1)
    for i in range(n):
        prefix[i + 1] = prefix[i] + nums[i]

    dq = deque()    # monotonic increasing deque of indices into prefix
    result = float('inf')

    for right in range(n + 1):
        # Pop from front: valid subarrays ending at `right`
        while dq and prefix[right] - prefix[dq[0]] >= k:
            result = min(result, right - dq.popleft())
        # Pop from back: maintain increasing deque
        while dq and prefix[dq[-1]] >= prefix[right]:
            dq.pop()
        dq.append(right)

    return result if result != float('inf') else -1

print(shortest_subarray([1], 1))               # 1
print(shortest_subarray([1, 2], 4))            # -1
print(shortest_subarray([2, -1, 2], 3))        # 3
print(shortest_subarray([84,-37,32,40,95], 167))  # 3

Стратегия решения задач на дек

Распознать задачу на монотонный дек можно по следующим признакам: (1) вам нужен максимум или минимум в скользящем окне фиксированного размера; (2) вам требуется рекуррентное соотношение DP dp[i] = f(nums[i], max(dp[i-k..i-1])); или (3) вам нужен ближайший допустимый индекс, удовлетворяющий монотонному условию.

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

from collections import deque

# Clean, interview-ready template
def sliding_window_max_template(nums, k):
    if not nums or k == 0:
        return []

    dq = deque()   # monotonic decreasing, stores indices
    result = []

    for i in range(len(nums)):
        # Invariant 1: remove expired indices (outside window)
        while dq and dq[0] < i - k + 1:
            dq.popleft()

        # Invariant 2: remove indices with smaller values (useless)
        while dq and nums[dq[-1]] < nums[i]:
            dq.pop()

        dq.append(i)

        # Record result once first full window is established
        if i >= k - 1:
            result.append(nums[dq[0]])

    return result

# Complexity: O(n) time, O(k) space
print(sliding_window_max_template([1,3,-1,-3,5,3,6,7], 3))
print(sliding_window_max_template([1], 1))
print(sliding_window_max_template([], 3))

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

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

Итоги урока

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

Можно начать бесплатно

Изучай Python с ИИ-репетитором — бесплатно

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

Курсы
30
Уроки
120

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

Урок «Максимум в скользящем окне с монотонной декой» бесплатный?

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

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

Поддерживайте убывающую деку индексов, чтобы отвечать на запросы о максимуме в окне за O(1) для каждого элемента и решать задачу о максимуме в скользящем окне за O(n) Ты практикуешь DSA Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

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

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

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

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

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

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

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

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