Модуль 9 · Скользящее окно · ОП.04

Алгоритмы на массивах · модуль 9 · скользящее окно

Глава 9.3

9.3

Окно
и пересчёт за O(1)

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

Категория
Алгоритм
Уровень
Начальный
Опирается на
Главы 9.1 и 9.2; занятие 7: срезы и полуинтервалы
Зачем это знать
На этом сдвиге построены все задачи модуля

§1Задача: сумма каждого окна

Задача КТП о лучшем отрезке длины k разобрана в главе 9.4. Здесь — более простая задача, которая лежит внутри неё: посчитать сумму каждого окна. Лучшему отрезку остаётся только выбрать наибольшую из этих сумм. Контракт тот же, что в главе 9.2: числа могут быть отрицательными, неверное k даёт ValueError.

window_sums(nums, k)сумма каждого окна · скользящая сумма

nums — список целых чисел, k — длина окна, 1 ≤ k ≤ len(nums), иначе ValueError. Вернуть новый список из n − k + 1 сумм: на месте s — сумма nums[s:s + k]. Исходный список не меняется.

Пример: nums = [12, 15, 9, 20, 18, 7, 25], k = 3. Ответ [36, 44, 47, 45, 50] — то же, что rolling(3).sum() в главе 9.1, без двух значений nan в начале.

§2Решение срезами

Самое прямое решение — для каждого начала окна s взять срез nums[s:s + k] и сложить его функцией sum. Начала окон — от 0 до n − k включительно, поэтому цикл идёт по range(len(nums) - k + 1). Это решение пишется сразу и служит эталоном: с ним сравнивают быстрое.

solution.pyвремя O(n·k) · память O(k) на срез
def window_sums_slices(nums, k):
    if k < 1 or k > len(nums):
        raise ValueError("k должно быть от 1 до len(nums)")
    sums = []
    for s in range(len(nums) - k + 1):
        sums.append(sum(nums[s:s + k]))
    return sums
Python в этой строке: raise ValueError(...)

raise прерывает функцию исключением. ValueError — стандартное исключение для случая, когда тип аргумента верный, а значение недопустимое: здесь k — целое число, но вне допустимых границ. Вызывающий код может перехватить его через try … except ValueError.

>>> def check(k):
...     if k < 1:
...         raise ValueError("k должно быть не меньше 1")
...     return k
...
>>> check(0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in check
ValueError: k должно быть не меньше 1
Python в этой строке: sum(nums[s:s + k])

Срез nums[s:s + k] создаёт новый список из k элементов, начиная с индекса s: правая граница не входит, как в занятии 7. sum складывает элементы списка. Обе операции проходят все k элементов.

>>> nums = [12, 15, 9, 20, 18]
>>> nums[1:4]
[15, 9, 20]
>>> sum(nums[1:4])
44

Прогон ниже показывает, что каждое окно считается с нуля. Голубые ячейки — текущее окно, указатель s стоит на его начале.

На шагах 4 и 6 видно повторение работы. Окно [12, 15, 9] и окно [15, 9, 20] имеют общую часть 15 + 9, но во втором окне она складывается заново. При k = 3 общая часть — два элемента, при k = 1000 — 999.

§3Сколько стоят срезы

Окон n − k + 1. В каждом k элементов копируются в срез и ещё раз читаются при сложении. Число элементов во всех срезах — (n − k + 1)·k, время O(n·k). Сильнее всего это заметно, когда k — примерно половина n: окон около n/2, в каждом около n/2 элементов, итого около n²/4.

nkОкон n − k + 1Элементов во всех срезах (n − k + 1)·k
73515
100 00010099 9019 990 100
100 00050 00050 0012 500 050 000
1 000 0001 000999 001999 001 000

Кроме времени, срез занимает память: на каждом шаге создаётся список из k элементов. Он удаляется сразу после sum, поэтому дополнительная память — O(k), но копирование всё равно стоит времени.

§4Идея сдвига и состояние окна

Соседние окна отличаются двумя элементами: при сдвиге на одну позицию слева уходит nums[r − k], справа приходит nums[r]. Здесь r — индекс элемента, который входит в окно. Остальные k − 1 элементов общие, их сумма уже известна. Поэтому новую сумму получают из старой:

сдвиг окнаодно вычитание и одно сложение
новая сумма = старая сумма + nums[r] − nums[r − k]

[12, 15, 9] 20     →     12 [15, 9, 20]
сумма 36                 36 + 20 − 12 = 44
  1. Первое окно. Сложить nums[0:k] целиком: total = sum(nums[:k]). Это единственное окно, которое считается с нуля.
  2. Сдвиг. Для каждого r от k до n − 1: total += nums[r] − nums[r − k]. Теперь total — сумма окна nums[r − k + 1:r + 1].
  3. Запись. После первого окна и после каждого сдвига дописать total в ответ.

Состояние окнаrolling state

Переменная или несколько переменных, в которых хранится ответ для текущего окна: сумма, число гласных, число событий. При сдвиге состояние не вычисляют заново, а исправляют: учитывают элемент, который вошёл, и элемент, который вышел. В КТП это называется rolling state.

Требование к состоянию одно: его можно исправить по двум изменившимся элементам за O(1). Сумма и счётчик этому требованию отвечают. Максимум — нет: если из окна ушёл сам максимум, новый неизвестен без просмотра окна. Поэтому максимум окна — задача занятия 25.

§5Сдвиг окна вручную

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

§6Сборка функции из строк

Правило из §4 записывается функцией из девяти строк. Расставьте их по порядку: сначала проверка k, затем первое окно и начало ответа, затем цикл со сдвигом и записью. Готовая функция — ниже, в прогоне §7.

Python в этой строке: range(k, len(nums)) и += с выражением

range(k, len(nums)) выдаёт индексы от k до последнего: это индексы элементов, которые по очереди входят в окно. total += a - b — то же, что total = total + (a - b): сначала вычисляется правая часть, потом она прибавляется.

>>> list(range(3, 7))
[3, 4, 5, 6]
>>> total = 36
>>> total += 20 - 12
>>> total
44

Напишите функцию сами в окне ниже, прямо на странице. «Запустить тесты» проверяет её на восьми тестах. Среди них k = 1, k = n, неверное k, пустой список и сравнение с суммами срезов на двухстах случайных списках. Часть тестов заготовка проходит сразу. Первый запуск загружает Python в браузер, около 10 МБ.

§7Код и пошаговый прогон

solution.pyвремя O(n) · память O(1) кроме ответа
def window_sums(nums, k):
    if k < 1 or k > len(nums):
        raise ValueError("k должно быть от 1 до len(nums)")
    total = sum(nums[:k])
    sums = [total]
    for r in range(k, len(nums)):
        total += nums[r] - nums[r - k]
        sums.append(total)
    return sums

Прогон на том же списке, что в §2. Голубые ячейки — окно после сдвига, перечёркнутая — элемент, который из него ушёл. Указатель r стоит на элементе, который вошёл.

Инвариантсумма каждого окна

Перед каждой итерацией цикла с индексом r: total — сумма окна nums[r − k:r], а в sums записаны суммы всех окон, которые заканчиваются раньше r.

Инициализация: до цикла r = k, total — сумма nums[0:k], она же в sums. Сохранение: после total += nums[r] − nums[r − k] в сумме нет ушедшего элемента и есть пришедший. Это сумма nums[r − k + 1:r + 1], и она дописывается в ответ. Завершение: цикл прошёл r до n − 1, в sums все n − k + 1 сумм.

Цикл выполняется n − k раз, каждая итерация — одно вычитание, одно сложение и запись в ответ. Вместе с первым окном это k + 2·(n − k) обращений к элементам, время O(n) при любом k. Дополнительной памяти, кроме списка ответа, не нужно: срезов нет. Замер обоих способов — в главе 9.7.

§8Две ошибки сдвига

В каждой ошибке код отличается от верной версии одной строкой, и Python выполняет его без исключений: неверен только ответ. Каждая запущена на том же списке, что и верная версия выше, и её можно пройти по шагам. Ответ для сравнения: [36, 44, 47, 45, 50].

window_sums с одной изменённой строкойпереключите вкладку

Строка сдвига: total += nums[r] - nums[r - k + 1] — на единицу правее нужного индекса.

[36, 41, 50, 37, 44]На шаге 5 вычитается nums[1] = 15 — элемент, который остаётся в окне, а ушедший nums[0] = 12 остаётся в сумме. Сумма становится 41 вместо 44, и ошибка переносится на все следующие окна. При k = 1 эта версия вычитает только что пришедший элемент и все суммы, кроме первой, остаются равны первой.

Строка начала ответа: sums = [] вместо sums = [total].

[44, 47, 45, 50]Сумма первого окна посчитана на шаге 2, но на шаге 3 ответ начинается с пустого списка. Все сдвиги верные, а сумм в ответе четыре вместо пяти. При k = n цикл не выполняется ни разу, и функция возвращает пустой список.

Верная версия — прогон в §7. Обе ошибки ловят тесты песочницы: первую — test_example, test_k_is_one, test_negative и сравнение со срезами, вторую — те же тесты и test_k_is_n.

§9Проверьте себя

Вопрос 1Окно [20, 18, 7] с суммой 45 сдвигается на одну позицию, следующий элемент — 25. Какая новая сумма?

  • Окно сохраняет длину 3, поэтому вместе с приходом 25 из него уходит 20.

  • Верно. Уходит самый левый элемент 20, приходит 25.

  • Из окна уходит один элемент — самый левый, 20. Его и вычитают.

Вопрос 2Что хранит total сразу после строки total += nums[r] - nums[r - k]?

  • Это сумма до строки сдвига: в ней ещё есть nums[r − k] и нет nums[r].

  • В total всегда ровно k элементов, ушедшие вычитаются.

  • Верно. Ушёл nums[r − k], пришёл nums[r]: окно — с r − k + 1 по r включительно.

Вопрос 3Какое состояние окна нельзя исправить за O(1) при сдвиге?

  • Это счётчик: если ушедший отрицательный, вычесть 1, если пришедший отрицательный — прибавить 1.

  • Верно. Если ушёл сам максимум, новый неизвестен без просмотра окна. Для максимума нужен дек, занятие 25.

  • Сумма исправляется вычитанием ушедшего и сложением пришедшего.

Модуль 9 · Скользящее окно · ОП.04 · 3 курс Автор: Макаров Максим Николаевич