Алгоритмы на массивах · модуль 9 · скользящее окно
Глава 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 функция собрана из строк и пройдена по шагам. Здесь каждая строка разобрана отдельно: за что она отвечает и что получится, если записать её иначе. Под таблицей изменённые версии запущены.
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 | Запоминает сумму и начало окна, которое заканчивается на r | start = 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 = 0 | ValueError | ValueError | 0 |
k = −1 | ValueError | ValueError | IndexError |
k = 1 | [4, 1, 2] | (0, 4) | 1 |
k = n = 3 | [7] | (0, 7) | 2 |
k = 4 > n | ValueError | ValueError | 2 |
пустой вход, k = 1 | ValueError | ValueError | 0 |
При k = 1 окно — один элемент: суммы окон совпадают со списком, лучшее окно — наибольший элемент. При k = n окно одно, цикл сдвигов не выполняется ни разу. Эти два значения k проверяют, что код верен и тогда, когда сдвигов максимальное число, и тогда, когда их нет совсем.
У max_events вместо k — длина окна по времени w. w = 0 и отрицательное w дают ValueError, пустой список — 0, а очень большое w допустимо: все события попадают в одно окно. На [1, 2] при w = 100 функция возвращает 2.
На 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. Тогда проверки нет совсем, и функция возвращает ответ на неверный вход:
$ 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.
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 оставляет только тесты, в имени которых есть одно из перечисленных слов, — это двенадцать проверок границ. Вывод на эталонном решении:
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:
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] окно после сдвига — nums[r − k + 1:r + 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)):
sums.append(total)
total += nums[r] - nums[r - k]
return sumsГраницы цикла верные:
rпроходит индексы пришедших элементов отkдо последнего.Верно. Записывается сумма прошлого окна, поэтому первое окно попадает в ответ дважды, а последнее не попадает. На
[1, 2, 3]сk = 2функция вернёт[3, 3]вместо[3, 5].Первое окно должно быть в ответе, эта строка верна. Ошибка в порядке двух строк в теле цикла.
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).
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: на нём цикл выполняется обычное число раз.