Алгоритмы на массивах · модуль 9 · скользящее окно
Глава 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). Это решение пишется сразу и служит эталоном: с ним сравнивают быстрое.
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 должно быть не меньше 1Python в этой строке: 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.
| n | k | Окон n − k + 1 | Элементов во всех срезах (n − k + 1)·k |
|---|---|---|---|
| 7 | 3 | 5 | 15 |
| 100 000 | 100 | 99 901 | 9 990 100 |
| 100 000 | 50 000 | 50 001 | 2 500 050 000 |
| 1 000 000 | 1 000 | 999 001 | 999 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
- Первое окно. Сложить
nums[0:k]целиком:total = sum(nums[:k]). Это единственное окно, которое считается с нуля. - Сдвиг. Для каждого
rотkдоn − 1:total += nums[r] − nums[r − k]. Теперьtotal— сумма окнаnums[r − k + 1:r + 1]. - Запись. После первого окна и после каждого сдвига дописать
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Код и пошаговый прогон
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.
Сумма исправляется вычитанием ушедшего и сложением пришедшего.