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

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

Глава 9.4

9.4

Лучший отрезок
длины k

В этой главе решается первая задача КТП занятия 9 — найти отрезок длины k с наибольшей суммой. Решение добавляет к сдвигу окна из главы 9.3 одну проверку: стала ли сумма окна лучше прежней. Разобраны две ошибки этой проверки, о которых предупреждали вопросы главы 9.2, — каждая с прогоном ошибочной и исправленной версии. В конце — два варианта задачи с LeetCode: наибольшее среднее и наибольшее число гласных в окне.

Категория
Алгоритм
Уровень
Начальный
Опирается на
Глава 9.3: сдвиг окна; глава 9.2: контракт и примеры
Зачем это знать
Задача КТП и LeetCode 643 — типичные задачи на окно

§1Условие задачи

Условие уточнено в главе 9.2, здесь оно записано в окончательном виде. Во всех примерах главы используется тот же список посещений по часам, что в главах 9.1 и 9.3.

best_window(nums, k)задача 1 занятия 9 · лучший отрезок длины k

nums — список целых чисел, числа могут быть отрицательными. k — длина окна, 1 ≤ k ≤ len(nums), иначе ValueError. Вернуть (start, total): начало окна длины k с наибольшей суммой и эту сумму. При равных суммах — самое левое окно. Список не меняется.

Пример: nums = [12, 15, 9, 20, 18, 7, 25], k = 3. Суммы окон — [36, 44, 47, 45, 50], наибольшая у последнего окна, ответ (4, 50).

§2Лучшее окно на ходу

Задачу можно решить готовой функцией из главы 9.3: посчитать все суммы и выбрать наибольшую. Время O(n), но список сумм занимает O(n) памяти, а нужна из него одна сумма.

через window_sumsвремя O(n) · память O(n)
def best_window_via_sums(nums, k):
    sums = window_sums(nums, k)          # ValueError — внутри window_sums
    best = max(sums)
    return (sums.index(best), best)    # index — первое вхождение

Лучшую сумму можно не хранить в списке, а обновлять сразу после каждого сдвига. Вместо списка sums — две переменные: best, лучшая сумма среди уже пройденных окон, и start, начало этого окна.

  1. Первое окно. total = sum(nums[:k]). Пока оно единственное, оно же лучшее: best = total, start = 0.
  2. Сдвиг. Для каждого r от k до n − 1: total += nums[r] − nums[r − k], как в главе 9.3.
  3. Сравнение. Если total строго больше best, запомнить: best = total, start = r − k + 1 — это начало окна, которое заканчивается на r.
  4. Ответ. После цикла вернуть (start, best).

Обе детали правила — «строго больше» и «начать с первого окна» — следуют из ответов интервьюера в главе 9.2. Что происходит, если записать их иначе, показано в §5 и §6.

§3Сборка функции и песочница

Функция отличается от window_sums тремя строками: вместо списка ответа — best и start, вместо записи в список — сравнение. Расставьте десять строк по порядку.

Python в этой строке: best, start = total, r - k + 1

Два имени слева, два значения справа: Python сначала вычисляет оба значения, потом присваивает их по порядку. Так обе переменные меняются одной строкой, и не бывает момента, когда best уже новое, а start ещё старое.

>>> total, r, k = 50, 6, 3
>>> best, start = total, r - k + 1
>>> best, start
(50, 4)

Напишите функцию сами в окне ниже. Восемь тестов — это примеры из главы 9.2 и сравнение с перебором срезов на двухстах случайных списках. Часть тестов заготовка проходит сразу.

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

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

Прогон на списке посещений. Голубые ячейки — окно после сдвига. В таблице переменных видно, как best и start меняются на шагах 7, 11 и 18 и остаются прежними на шаге 14, когда сумма 45 оказалась меньше лучшей 47.

Инвариантлучший отрезок длины k

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

Инициализация: пройдено одно окно, оно и лучшее. Сохранение: после сдвига total — сумма нового окна; если она строго больше best, новое окно становится лучшим, при равенстве лучшим остаётся прежнее, более левое. Завершение: пройдены все окна, (start, best) — ответ.

§5Равные суммы: знак сравнения

Интервьюер в главе 9.2 ответил, что при равных суммах нужно самое левое окно. Окна проходятся слева направо, поэтому первое из окон с наибольшей суммой встречается раньше остальных. Чтобы его не заменило окно правее с той же суммой, сравнение должно быть строгим. Ниже обе версии запущены на [5, 1, 5, 1] с k = 2: все три окна дают сумму 6.

best_window([5, 1, 5, 1], 2)переключите вкладку

(2, 6)На шагах 6 и 10 сумма 6 равна лучшей, и при >= лучшим каждый раз становится окно правее. Функция возвращает последнее из равных окон, а условие требует первое.

(0, 6)На шагах 6 и 9 равная сумма не больше лучшей, и лучшим остаётся первое окно.

Если бы интервьюер попросил самое правое окно, верной была бы как раз версия с >=. Знак сравнения — это запись ответа на вопрос о равных суммах, поэтому вопрос задают до кода.

§6Отрицательные числа: начальное значение

Вторая ошибка — начать с best = 0, как будто сумма не бывает меньше нуля. Пока в списке есть окно с положительной суммой, ответ получается верным, и ошибка незаметна. Обе версии запущены на [-5, -2, -8, -1] с k = 2: суммы окон −7, −10, −9.

best_window([-5, -2, -8, -1], 2)переключите вкладку

(0, 0)На шаге 3 best получает 0, хотя сумма первого окна −7. Ни одна сумма не больше нуля, best не меняется до конца, и функция возвращает сумму 0, которой нет ни у одного окна.

(0, −7)Лучшая сумма начинается с суммы первого окна. Окна с суммами −10 и −9 хуже, ответ — первое окно.

Есть и другой распространённый вариант — best = float("-inf"), минус бесконечность: любая сумма больше неё. Он тоже верен, но сумма первого окна уже посчитана, и начать с неё проще: не нужен особый объект и start сразу имеет смысл.

§7Наибольшее среднее, LeetCode 643

В LeetCode 643 нужно вернуть наибольшее среднее отрезка длины k. Среднее — это сумма, делённая на k, а k одно для всех окон. Поэтому окно с наибольшим средним — то же, что окно с наибольшей суммой, и задача решается одной строкой поверх best_window. Делить сумму каждого окна не нужно: делится только ответ.

LeetCode 643время O(n) · память O(1)
def find_max_average(nums, k):
    start, best = best_window(nums, k)
    return best / k
Терминалpython -i · Python 3.12
>>> find_max_average([1, 12, -5, -6, 50, 3], 4)
12.75
>>> find_max_average([5], 1)
5.0

Первый вызов — пример из условия LeetCode: лучшее окно [12, −5, −6, 50], сумма 51, среднее 51 / 4 = 12,75. Оператор / в Python всегда даёт число с плавающей точкой, поэтому во втором вызове получается 5.0. В LeetCode 643 числа бывают отрицательными, и ошибка с best = 0 из §6 там тоже приводит к неверному ответу.

§8Счётчик в окне, LeetCode 1456

Состояние окна не обязательно сумма чисел. В LeetCode 1456 дана строка и число k, нужно найти наибольшее число гласных в подстроке длины k. Состояние окна — счётчик гласных count. При сдвиге пришедшая гласная прибавляет 1, ушедшая — вычитает 1. Остальное устроено так же, как в best_window.

LeetCode 1456время O(n) · память O(1)
def max_vowels(s, k):
    vowels = set("aeiou")
    count = sum(1 for ch in s[:k] if ch in vowels)
    best = count
    for r in range(k, len(s)):
        count += (s[r] in vowels) - (s[r - k] in vowels)
        best = max(best, count)
    return best
Python в этой строке: sum(1 for ch in … if …) и True − False

Выражение 1 for ch in s[:k] if ch in vowels выдаёт единицу для каждой гласной в первом окне, sum их складывает — получается число гласных. В строке сдвига используется то, что True и False в арифметике равны 1 и 0. Поэтому (s[r] in vowels) − (s[r − k] in vowels) даёт +1, если пришла гласная и ушла согласная, и −1 — наоборот. Если обе буквы одного вида, получается 0.

>>> sum(1 for ch in "abc" if ch in "aeiou")
1
>>> True - False, False - True, True - True
(1, -1, 0)

Прогон на примере из условия LeetCode: строка "abciiidef", k = 3, ответ 3 — окно "iii". В прогоне строка передана списком букв, чтобы показать каждую букву в своей ячейке; код работает и со строкой. На шагах 5 и 17 счётчик не меняется: гласная пришла, и гласная ушла.

Здесь best обновляется через max: начало окна в этой задаче не нужно, достаточно числа, и отдельный if не требуется. Напишите функцию сами.

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

Вопрос 1В best_window строку if total > best: заменили на if total >= best:. Что вернёт функция на [3, 3, 3] с k = 1?

  • При >= каждое следующее окно с той же суммой заменяет лучшее. Первое окно не сохранится.

  • Верно. Все три окна дают 3, и при нестрогом сравнении лучшим каждый раз становится окно правее.

  • Равные суммы — допустимый вход. Исключение бывает только при неверном k.

Вопрос 2Почему окно с наибольшим средним в LeetCode 643 совпадает с окном с наибольшей суммой?

  • В LeetCode 643 числа бывают отрицательными: от −104 до 104. Знак здесь ни при чём.

  • В LeetCode 643 среднее не округляется: ответ принимается с точностью до 10−5.

  • Верно. Если одна сумма больше другой, то и после деления обеих на одно положительное k она больше.

Вопрос 3Окно "cii" сдвигается, справа приходит "d", слева уходит "c". Как меняется счётчик гласных?

  • Верно. (False) − (False) = 0. Окно "iid" содержит те же две гласные.

  • Ушла тоже согласная, поэтому гласных в окне столько же.

  • Пересчёт не нужен: счётчик исправляется по двум изменившимся буквам за O(1).

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