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

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

Глава 9.6

9.6

Границы окна
и неверный k

В этой главе разобран контроль КТП занятия 9: «тесты k=0/1/n и неверного k» и review на корректность обновления окна. Сначала — разбор best_window по строкам и запуск версий, в которых одна строка записана иначе. Затем — что функции модуля возвращают на граничных входах и почему на неверный k отвечают исключением. В конце — тесты границ с настоящим выводом pytest, песочница с функцией, которую нужно исправить, и вопросы, которые задают на review.

Категория
Код и тесты
Уровень
Средний
Опирается на
Главы 9.2–9.5; глава 8.6: разбор кода по строкам
Зачем это знать
Пройти review и тесты границ из контроля КТП

§1Разбор best_window по строкам

В главе 9.4 функция собрана из строк и пройдена по шагам. Здесь каждая строка разобрана отдельно: за что она отвечает и что получится, если записать её иначе. Под таблицей изменённые версии запущены.

solution.pybest_window из главы 9.4
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)
СтрокаЧто делаетЕсли записать иначе
if k < 1 or k > len(nums):Отсекает неверный k и пустой список до работы с окнамиБез строки при k = 0, k > n и на пустом списке функция молча возвращает ответ для окна, которого нет. С k >= len(nums) она отказывается работать на допустимом k = n
total = sum(nums[:k])Сумма первого окна — единственного, которое складывается целикомsum(nums[:k - 1]) — в первом окне на элемент меньше, и каждая сумма отличается от верной на nums[k − 1]
best, start = total, 0Первое окно — пока лучшееbest = 0 — неверный ответ при всех отрицательных суммах, глава 9.4, §6
for r in range(k, len(nums)):r — индекс элемента, который входит в окно, от k до последнегоrange(k + 1, …) — пропущен один сдвиг, все суммы после него неверны
total += nums[r] - nums[r - k]Сдвиг: плюс пришедший, минус ушедшийnums[r - k + 1] — вычитается элемент, который остаётся в окне, глава 9.3, §8
if total > best:Новое окно лучше только при строго большей сумме>= — при равных суммах побеждает окно правее, глава 9.4, §5
best, start = total, r - k + 1Запоминает сумму и начало окна, которое заканчивается на rstart = r - k — начало на одну позицию левее настоящего

§2Запуск изменённых версий

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

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

Проверки k нет. Вход — [1, 2], k = 0. Верный ответ — ValueError: окна длины 0 не бывает.

(0, 0)Срез nums[:0] пустой, его сумма 0. На каждом шаге прибавляется и вычитается один и тот же элемент, сумма остаётся 0. Функция возвращает ответ, и ничто не говорит, что вход был неверным.

Проверки k нет. Вход — [1, 2], k = 3. Верный ответ — ValueError.

(0, 3)Срез за границей списка в Python ошибки не даёт: nums[:3] возвращает весь список из двух элементов. Цикл range(3, 2) пуст. Функция возвращает сумму «окна длины 3» в списке из двух элементов.

Цикл начинается с k + 1. Вход — список посещений, k = 3. Верный ответ — (4, 50).

(4, 42)На шаге 4 цикл начинается с r = 4, и сдвиг, на котором в окно входит nums[3] = 20, не выполняется. Дальше total на каждом шаге отличается от настоящей суммы окна: 39 вместо 47, 37 вместо 45, 42 вместо 50.

Проверка k >= len(nums). Вход — [4, 1, 2], k = 3: одно окно — весь список, верный ответ (0, 7).

ValueErrorУсловие 3 >= 3 истинно, и функция отказывается работать на допустимом входе. Это ошибка на единицу в проверке: окно длины n в списке из n элементов есть.

Верная версия на том же входе [4, 1, 2], k = 3.

(0, 7)Проверка пропускает k = 3, первое окно — весь список. Цикл range(3, 3) пуст: сдвигов нет, окно одно.

Первые две вкладки показывают, почему проверку пишут первой строкой. Без неё функция на неверном входе возвращает правдоподобный ответ: (0, 0) и (0, 3) выглядят как обычные результаты.

§3Граничные входы всех функций

В модуле четыре функции. Три написаны по контракту курса и проверяют вход. max_vowels — решение LeetCode 1456, где условие гарантирует 1 ≤ k ≤ len(s), поэтому проверки в нём нет. Все значения в таблице получены запуском.

Входwindow_sums([4, 1, 2], k)best_window([4, 1, 2], k)max_vowels("aba", k)
k = 0ValueErrorValueError0
k = −1ValueErrorValueErrorIndexError
k = 1[4, 1, 2](0, 4)1
k = n = 3[7](0, 7)2
k = 4 > nValueErrorValueError2
пустой вход, k = 1ValueErrorValueError0

При k = 1 окно — один элемент: суммы окон совпадают со списком, лучшее окно — наибольший элемент. При k = n окно одно, цикл сдвигов не выполняется ни разу. Эти два значения k проверяют, что код верен и тогда, когда сдвигов максимальное число, и тогда, когда их нет совсем.

У max_events вместо k — длина окна по времени w. w = 0 и отрицательное w дают ValueError, пустой список — 0, а очень большое w допустимо: все события попадают в одно окно. На [1, 2] при w = 100 функция возвращает 2.

На полях: решения LeetCode и проверка входа

На LeetCode ограничения входа записаны в разделе Constraints, и тесты сайта их не нарушают. Поэтому решения задач с сайта обычно не проверяют k, и на собеседовании это допустимо, если ограничение названо вслед за условием. В коде, который вызывают другие функции, такой гарантии нет — отсюда проверка в best_window.

§4Почему исключение

На неверный k функция могла бы ответить по-разному: вернуть None, вернуть (0, 0) или бросить исключение. Интервьюер в главе 9.2 выбрал исключение. Причины видны в §2.

Ответ на неверный kЧто видит вызывающий кодЧем это опасно
Число или пара, например (0, 0)Обычный результатНеверный вход неотличим от настоящего ответа, ошибка уходит дальше по программе
NoneОсобое значениеКод, который не проверяет None, упадёт позже и в другом месте — там, где с результатом начнут работать
raise ValueErrorИсключение в строке вызоваПрограмма останавливается сразу и сообщает, какой аргумент неверен; перехватить можно через try … except ValueError

ValueError выбран потому, что тип аргумента верный — целое число, — а значение недопустимое. Так же поступает и стандартная библиотека: int("abc") бросает ValueError.

Проверку можно было бы записать через assert, но assert выключается при запуске Python с ключом -O. Тогда проверки нет совсем, и функция возвращает ответ на неверный вход:

Терминалassert и ключ -O · Python 3.12
$ cat a_test.py
def f(k):
    assert k >= 1, "k должно быть не меньше 1"
    return k
print(f(0))
$ python a_test.py
AssertionError: k должно быть не меньше 1
$ python -O a_test.py
0

Поэтому проверку входных данных пишут через if … raise, а assert оставляют для тестов и проверок, без которых программа может работать.

§5Тесты границ

Тесты ниже входят в tests.py стартового набора занятия 9. Тест неверного k запускается для двух функций и четырёх входов — восемь проверок одной функцией благодаря двум декораторам parametrize. Ожидание исключения записывается через pytest.raises: тест проходит, только если внутри блока with было брошено ValueError.

tests.pyграницы k и w
import pytest

from solution import best_window, max_events, window_sums

K_FUNCS = [window_sums, best_window]


@pytest.mark.parametrize("func", K_FUNCS)
@pytest.mark.parametrize("nums, k", [([1, 2], 0), ([1, 2], -1), ([1, 2], 3), ([], 1)],
                         ids=["k_zero", "k_negative", "k_over_n", "empty"])
def test_bad_k_raises(func, nums, k):
    with pytest.raises(ValueError):
        func(nums, k)


def test_k_is_one():
    assert window_sums([3, -2, 7], 1) == [3, -2, 7]
    assert best_window([3, -2, 7], 1) == (2, 7)


def test_k_is_n():
    assert window_sums([4, 1, 2], 3) == [7]
    assert best_window([4, 1, 2], 3) == (0, 7)


def test_bad_w_raises():
    for w in (0, -1):
        with pytest.raises(ValueError):
            max_events([1, 2], w)


def test_events_right_edge_and_empty():
    assert max_events([0, 5], 5) == 1
    assert max_events([], 3) == 0

В полном tests.py 34 теста. Ключ -k оставляет только тесты, в имени которых есть одно из перечисленных слов, — это двенадцать проверок границ. Вывод на эталонном решении:

Терминалpython -m pytest tests.py -v -k "bad_k or k_is or bad_w or right_edge"
collected 34 items / 22 deselected / 12 selected

tests.py::test_bad_k_raises[k_zero-window_sums] PASSED                   [  8%]
tests.py::test_bad_k_raises[k_zero-best_window] PASSED                   [ 16%]
tests.py::test_bad_k_raises[k_negative-window_sums] PASSED               [ 25%]
tests.py::test_bad_k_raises[k_negative-best_window] PASSED               [ 33%]
tests.py::test_bad_k_raises[k_over_n-window_sums] PASSED                 [ 41%]
tests.py::test_bad_k_raises[k_over_n-best_window] PASSED                 [ 50%]
tests.py::test_bad_k_raises[empty-window_sums] PASSED                    [ 58%]
tests.py::test_bad_k_raises[empty-best_window] PASSED                    [ 66%]
tests.py::test_k_is_one PASSED                                           [ 75%]
tests.py::test_k_is_n PASSED                                             [ 83%]
tests.py::test_bad_w_raises PASSED                                       [ 91%]
tests.py::test_events_right_edge_and_empty PASSED                        [100%]

====================== 12 passed, 22 deselected in 0.01s =======================

Вывод, если в best_window убрать проверку k, как на первой вкладке §2:

Терминалbest_window без проверки k
FAILED tests.py::test_bad_k_raises[k_zero-best_window] - Failed: DID NOT RAIS...
FAILED tests.py::test_bad_k_raises[k_negative-best_window] - IndexError: list...
FAILED tests.py::test_bad_k_raises[k_over_n-best_window] - Failed: DID NOT RA...
FAILED tests.py::test_bad_k_raises[empty-best_window] - Failed: DID NOT RAISE...
4 failed, 8 passed, 22 deselected in 0.02s

DID NOT RAISE означает, что функция вернула значение там, где тест ждал исключения. На k = −1 функция без проверки падает, но не с тем исключением: IndexError вместо ValueError. Тесты window_sums проходят — в ней проверка на месте.

§6Исправьте функцию

В окне ниже best_window с двумя ошибками из §2: нет проверки k, и цикл начинается с k + 1. Исправьте функцию так, чтобы прошли все семь тестов. Сначала запустите тесты и посмотрите, какие падают и с каким сообщением.

§7Review обновления окна

В КТП контроль занятия 9 включает «Git review на корректность обновления окна»: решение сдаётся Pull Request, и проверяющий читает код окна по строкам. Ниже — что он проверяет, затем три фрагмента для самостоятельного review.

Обновление окна проверено, если

Вход проверен до работы с окнамиk < 1 or k > len(nums) → ValueError; граница k = n пропускается.
Первое окно посчитано целикомsum(nums[:k]) — ровно k элементов, и оно учтено в ответе.
Цикл начинается с первого сдвигаrange(k, len(nums)): первый пришедший элемент — nums[k].
Ушедший элемент — nums[r − k]Для пришедшего nums[r] окно после сдвига — nums[r − k + 1:r + 1].
Состояние обновлено до сравненияСначала сдвиг, потом сравнение с лучшим, иначе сравнивается прошлое окно.
Знак сравнения совпадает с условием> — самое левое из равных окон, >= — самое правое.
Есть тесты k = 1, k = n и неверного kИ тест, который сравнивает ответ с перебором срезов.
Review 1Функция должна вернуть сумму каждого окна длины k. Какая строка неверна?
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)):
        sums.append(total)
        total += nums[r] - nums[r - k]
    return sums
  • Границы цикла верные: r проходит индексы пришедших элементов от k до последнего.

  • Верно. Записывается сумма прошлого окна, поэтому первое окно попадает в ответ дважды, а последнее не попадает. На [1, 2, 3] с k = 2 функция вернёт [3, 3] вместо [3, 5].

  • Первое окно должно быть в ответе, эта строка верна. Ошибка в порядке двух строк в теле цикла.

Review 2Функция должна вернуть начало и сумму лучшего окна, при равных — самого левого. Какая строка неверна?
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
    return (start, best)
  • Строгий знак соответствует условию «самое левое из равных».

  • Первое окно начинается с индекса 0, эта строка верна.

  • Верно. Окно, которое заканчивается на r, начинается с r − k + 1. r − k — индекс ушедшего элемента, на единицу левее. На списке посещений функция вернёт (3, 50) вместо (4, 50).

Review 3В Pull Request к best_window добавлены тесты. Каких тестов не хватает до контроля КТП?
def test_example():
    assert best_window([12, 15, 9, 20, 18, 7, 25], 3) == (4, 50)

def test_negative():
    assert best_window([-5, -2, -8, -1], 2) == (0, -7)

def test_bad_k():
    with pytest.raises(ValueError):
        best_window([1, 2], 0)
  • Верно. Контроль КТП — «тесты k=0/1/n и неверного k». Есть только k = 0; нет одного элемента в окне, одного окна на весь список и k больше n.

  • Скорость проверяют замером, глава 9.7. Тесты границ проверяют правильность на крайних входах.

  • Пример не проверяет ни k = 1, ни k = n: на нём цикл выполняется обычное число раз.

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