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

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

Глава 9.5

9.5

События
во временном окне

В этой главе решается вторая задача КТП занятия 9 — найти наибольшее число событий в окне фиксированной длины по времени. Окно здесь задано не числом элементов, а длительностью в секундах, поэтому при сдвиге из него может уйти сразу несколько событий. Сначала — окно на оси времени и решение перебором, затем — два указателя, где левая граница догоняет правую циклом while. В конце — две ошибки границы окна, каждая с прогоном ошибочной и исправленной версии.

Категория
Алгоритм
Уровень
Средний
Опирается на
Главы 9.2–9.4; модуль 8: монотонность указателей
Зачем это знать
Так считают запросы и ошибки «за последние N секунд»

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

Сервер записывает момент каждого запроса в секундах от начала суток. Нужно узнать, сколько запросов в самый нагруженный промежуток длиной w секунд, — например, чтобы сравнить с лимитом «не больше 100 запросов за минуту». Вопросы к условию разобраны в главе 9.2, §7; здесь условие записано в окончательном виде.

max_events(times, w)задача 2 занятия 9 · события во временном окне

times — моменты событий по возрастанию, одинаковые моменты возможны и считаются разными событиями. w — длина окна в секундах, w > 0, иначе ValueError. Окно — полуинтервал [s, s + w) с любым началом s. Вернуть наибольшее число событий, которые попадают в одно окно. Пустой список даёт 0. Список не меняется.

Пример: times = [1, 3, 4, 10, 12, 13, 14, 30], w = 5. В окно [10, 15) попадают 10, 12, 13, 14 — ответ 4.

§2Окно на оси времени

В задачах глав 9.3 и 9.4 окно — это k соседних элементов списка. Здесь окно — отрезок времени: в нём может оказаться и одно событие, и десять, в зависимости от того, как плотно они идут. Блок ниже показывает события примера на оси времени и окно длиной w. Кнопки переставляют начало окна на следующее событие, длину окна можно менять.

Начало окна достаточно ставить только на моменты событий. Если окно начинается в момент, где события нет, его можно сдвинуть вправо до первого события внутри. Слева от нового начала событий в окне не было, поэтому ни одно не теряется. Правая граница сдвигается вместе с началом, и в окно могут только добавиться события. Поэтому лучшее окно всегда начинается с какого-то события, и окон для проверки не больше n.

При w = 5 переставьте окно на момент 10: в него попадают 10, 12, 13 и 14. Момент 15 уже не входит — правая граница открытая. Поставьте w = 9 и окно на момент 1: событие 10 оказывается ровно на границе 1 + 9 = 10 и в окно не попадает.

§3Решение перебором

Перебор следует из §2 напрямую: для каждого события i поставить окно началом на times[i] и посчитать события, которые в него попадают. Список отсортирован, поэтому достаточно смотреть события с индекса i и дальше.

solution.pyвремя O(n²) · память O(1)
def max_events_brute(times, w):
    if w <= 0:
        raise ValueError("w должно быть больше 0")
    best = 0
    for i in range(len(times)):
        count = 0
        for j in range(i, len(times)):
            if times[j] < times[i] + w:
                count += 1
        best = max(best, count)
    return best

Внутренний цикл для события i делает n − i проверок, всего n·(n + 1) / 2, время O(n²). При ста тысячах запросов за сутки это около 5·10⁹ проверок. Перебор можно ускорить: внутренний цикл прерывать на первом событии за окном, но в худшем случае, когда все события попадают в одно окно, проверок столько же.

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

§4Два указателя по времени

Окно можно описать двумя указателями на события: l — первое событие в окне, r — последнее. Окно — [times[l], times[l] + w), событий в нём r − l + 1. Правый указатель проходит все события по одному. Левый двигается, когда событие times[l] уже не помещается в одно окно с times[r], то есть когда times[r] − times[l] ≥ w.

  1. Старт. l = 0, best = 0.
  2. Новое событие. Для каждого r по порядку событие times[r] добавляется в окно справа.
  3. Сжатие слева. Пока times[r] − times[l] ≥ w, событие times[l] слишком старое: сдвинуть l. Это цикл while, потому что старых событий может быть несколько.
  4. Подсчёт. Теперь все события с l по r помещаются в окно, их r − l + 1. Обновить best.

Отличие от глав 9.3 и 9.4 — в шаге 3. Там при сдвиге уходил ровно один элемент, и вместо цикла хватало одного вычитания. Здесь длина окна постоянна по времени, а число событий в нём меняется: после паузы из окна уходит сразу несколько событий.

На полях: почему вложенный while не даёт O(n²)

Внутри for стоит while, и по виду это два вложенных цикла. Но l монотонный, как указатели модуля 8: он только растёт и не превышает r. За всю работу функции l += 1 выполняется не больше n раз, сколько бы раз ни запускался while. Вместе с n шагами r это не больше 2n сдвигов, время O(n).

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

Правило из §4 записывается функцией из десяти строк. Расставьте их по порядку. Обратите внимание на отступы: l += 1 стоит внутри while, а обновление best — после него, на уровне тела for.

Python в этой строке: while внутри for

На каждом шаге for цикл while выполняется столько раз, сколько его условие истинно: ноль, один или несколько. Переменная l объявлена до for, поэтому сохраняет значение между шагами и не сбрасывается в 0.

>>> l = 0
>>> for r in range(4):
...     while r - l >= 2:
...         l += 1
...     print(r, l)
...
0 0
1 0
2 1
3 2

Напишите функцию сами. Восемь тестов: пример, событие на правой границе, одинаковые моменты, пустой список, одно событие, неверное w, неизменность входа и сравнение с перебором окон на трёхстах случайных списках.

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

solution.pyвремя O(n) · память O(1)
def max_events(times, w):
    if w <= 0:
        raise ValueError("w должно быть больше 0")
    best = 0
    l = 0
    for r in range(len(times)):
        while times[r] - times[l] >= w:
            l += 1
        best = max(best, r - l + 1)
    return best

Прогон на примере из §1. Голубые ячейки — события окна, бледные — события, которые из окна уже ушли. На шагах 14–19 видно, чем это окно отличается от окна по элементам: событие 10 приходит после паузы, и цикл while трижды сдвигает l, убирая события 1, 3 и 4. На шагах 32–39 то же повторяется для события 30.

Инвариантсобытия во временном окне

После цикла while на шаге r индекс l — самое раннее событие, которое помещается в одно окно длиной w с событием times[r]. Все события с l по r лежат в окне [times[l], times[l] + w), их r − l + 1.

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

На полях: if вместо while

Если заменить while на if, l за шаг сдвигается не больше чем на одно событие, и после паузы окно в переменных получается шире настоящего. Наибольшее число при этом всё равно получается верным: на трёхстах случайных списках из тестов и на двадцати тысячах при проверке главы эта версия ответила так же, как перебор. Окно, которое не сжимается быстрее, чем растёт, не может показать больше событий, чем уже было найдено. Но значения l и r − l + 1 на отдельных шагах неверны, и для задач, где нужно само окно, такая версия не подходит. На review за такую замену спрашивают, поэтому в коде пишут while.

§7Две ошибки границы

Обе ошибки связаны с границами: одна — с правой границей окна по времени, другая — с подсчётом событий между указателями. Каждая запущена на входе, где она меняет ответ.

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

Условие while times[r] - times[l] > w. Вход — [0, 5], w = 5, верный ответ 1: окно [0, 5) не включает момент 5.

2На шаге 8 разность 5 − 0 = 5, и 5 > 5 ложно: событие 0 остаётся в окне вместе с событием 5. Строгое сравнение делает правую границу закрытой, [s, s + w], а интервьюер в главе 9.2 назвал полуинтервал.

Тот же вход с верным условием >= w.

1На шаге 8 5 ≥ 5 истинно, событие 0 уходит из окна, и в окне [5, 10) остаётся одно событие.

Подсчёт best = max(best, r - l). Вход — пример из §1, верный ответ 4.

3Событий с индекса l по r включительно r − l + 1. Это правило «от a до b включительно — b − a + 1» из занятия 7. Без + 1 каждое окно недосчитывает одно событие, на шаге 30 вместо 4 получается 3. Верная версия — прогон в §6.

Ошибку границы из тестов ловят test_right_edge и сравнение с перебором. На примере из §1 обе версии сравнения дают 4, и без отдельного примера на границу ошибка была бы незаметна. Поэтому вопрос «[s, s + w) или [s, s + w]?» задают до кода и сразу записывают пример на эту границу.

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

Вопрос 1times = [2, 4, 9], w = 7. Сколько событий в лучшем окне?

  • 9 − 2 = 7, и окно [2, 9) момент 9 не включает. Все три события в одно окно не помещаются.

  • Верно. В [2, 9) попадают 2 и 4, в [4, 11) — 4 и 9.

  • 4 − 2 = 2 < 7, события 2 и 4 помещаются в одно окно.

Вопрос 2Почему для левой границы нужен цикл while, а в главе 9.4 хватало одного вычитания?

  • Длина окна ни при чём. Дело в том, сколько элементов может уйти из окна за один шаг.

  • Верно. В окне из k элементов при сдвиге уходит ровно один. В окне по времени после паузы устаревают сразу несколько событий.

  • Скорость здесь ни при чём: это разные операции для разных задач.

Вопрос 3Сколько раз в худшем случае выполнится l += 1 за всю работу функции на n событиях?

  • Вложенность не означает n² шагов. l не уменьшается и не превышает r, поэтому всего вырасти может не больше чем на n − 1.

  • Верно. Это монотонность указателя из модуля 8: каждый сдвиг l навсегда убирает одно событие.

  • Если все события помещаются в одно окно, l не сдвинется ни разу.

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