Алгоритмы на массивах · модуль 8 · два указателя
Глава 8.6
Код, крайние случаи
и тесты задачи 1
В этой главе функция pair_sum разбирается построчно: за что отвечает каждая строка и что получится, если записать её иначе. Каждая изменённая версия запущена, и её прогон можно пройти по шагам. Затем разобрано, как функция ведёт себя на крайних входах и почему для них не нужны отдельные проверки. В конце — тесты из стартового набора и их запуск.
- Категория
- Код и тесты
- Уровень
- Начальный
- Опирается на
- Главы 8.4 и 8.5
- Зачем это знать
- Написать решение без подсказок и доказать его тестами
§1Код функции
В главе 8.4 функция собрана из строк и пройдена по шагам. Здесь она записана целиком, как в solution.py, чтобы разобрать каждую строку отдельно.
def pair_sum(prices, target): i, j = 0, len(prices) - 1 while i < j: s = prices[i] + prices[j] if s == target: return (i, j) if s < target: i += 1 else: j -= 1 return None
§2Разбор по строкам
В таблице каждая строка функции разобрана отдельно. Во втором столбце — что она делает, в третьем — что получится, если записать её иначе. Под таблицей эти изменённые версии запущены.
| Строка | Что делает | Если записать иначе |
|---|---|---|
i, j = 0, len(prices) - 1 | Указатели на самую дешёвую и самую дорогую цену. Участок — весь список | j = len(prices): первое же обращение prices[j] даст IndexError |
while i < j: | Пока на участке больше одного товара, пары ещё возможны | i <= j: указатели сойдутся на одном товаре и сложат его цену с ней же |
s = prices[i] + prices[j] | Сумма текущей пары, считается один раз за шаг | Без переменной сумма считается в двух условиях дважды, результат тот же |
if s == target: return (i, j) | Пара найдена — сразу выходим | Если поставить после сдвига, вернутся сдвинутые индексы: на каталоге из примера — (2, 2), один товар дважды |
if s < target: i += 1 | Сумма мала — исключаем левую цену | Двигать j вместо i — ответ теряется |
else: j -= 1 | Сумма велика — исключаем правую цену | Двигать оба указателя — вместе с правой ценой исключается левая, которая может входить в ответ |
return None | Указатели встретились, пары нет | Если поставить внутрь цикла, функция закончит работу на первом шаге. Если убрать совсем, функция всё равно вернёт None: так Python завершает любую функцию без return. Ответ тот же, но по коду не видно, что None — задуманный результат |
Ниже запущены шесть изменённых версий pair_sum, по одной на вкладку. В каждой версии отличается одна строка, остальной код тот же, что в §1. На большинстве вкладок вход — каталог из главы 8.4, [300, 450, 700, 900, 1200, 1500] с целью 1600; верный ответ на нём (2, 3). Две версии на этом каталоге дают верный ответ: их ошибка проявляется на других входах. Для них взят другой вход, он назван на вкладке.
pair_sum с одной изменённой строкойпереключите вкладку
Строка 2: i, j = 0, len(prices) — без - 1.
IndexErrorИндексы списка из шести цен — от 0 до 5, а j = 6. На шаге 3 обращение prices[6] прерывает функцию до первого сравнения с целью.
Строка 3: while i <= j:. На каталоге из примера эта версия отвечает верно: пара находится раньше, чем указатели сойдутся. Вход здесь — [100, 250, 600] с целью 500, верный ответ None.
(1, 1)На шаге 12 указатели стоят на одной цене 250, условие 1 <= 1 истинно, и на шаге 13 цена складывается сама с собой: 250 + 250 = 500. Функция возвращает один товар дважды.
Строки 5–6 перенесены в конец тела цикла: равенство суммы и цели проверяется после сдвига указателя.
(2, 2)На шаге 23 сумма 700 + 900 = 1600 посчитана для нужной пары. Но сумма не меньше цели, поэтому сначала выполняется ветка else и сдвигает j с 3 на 2. На шаге 27 функция возвращает уже сдвинутые индексы — один товар дважды.
Строка 8: при сумме меньше цели выполняется j -= 1 вместо i += 1.
NoneНачиная с шага 11 сумма меньше цели, а сдвигается правый указатель, и сумма уменьшается ещё: 1500, 1200, 1000, 750. На шаге 16 исключается цена 900 из ответа, и пара уже не может быть найдена.
В ветке else после j -= 1 добавлено i += 1. На каталоге из примера эта версия отвечает верно. Вход здесь — [100, 200, 300, 900] с целью 400, верный ответ (0, 2): 100 + 300.
NoneНа шаге 6 исключается 900 — это верно: с любой ценой она даёт больше цели. На шаге 7 вместе с ней исключается и 100, хотя 100 + 300 = 400. Про 100 правило ничего не утверждало, и пара из ответа потеряна.
Строка return None сдвинута вправо и оказалась внутри цикла, на одном уровне с if.
NoneПервый шаг цикла выполняется как обычно: исключается 1500. На шаге 7 выполняется return None, и функция заканчивает работу. Проверена одна пара из 15.
§3Крайние входы
Крайние входы из главы 8.3 проверим запуском. Ни для одного из них в функции нет отдельного if: их обрабатывают те же строки, что и обычный случай.
>>> from solution import pair_sum >>> print(pair_sum([], 500)) None >>> print(pair_sum([250], 500)) None >>> pair_sum([250, 250], 500) (0, 1) >>> pair_sum([100, 250, 400], 500) (0, 2) >>> pair_sum([-7, -3, 0, 2, 9], -1) (1, 3)
| Вход | Почему ответ верный без отдельной проверки |
|---|---|
[] | j = -1, условие 0 < -1 ложно, цикл не выполняется, функция возвращает None. Обращения к prices[-1] не происходит. |
[250] | j = 0, условие 0 < 0 ложно. Цена 250 не складывается сама с собой, хотя 250 + 250 = 500. |
[250, 250] | Два разных товара с одной ценой: i = 0, j = 1, сумма 500 — пара найдена. |
[100, 250, 400] | 100 + 400 = 500 найдено первым ходом. Товар 250 сам с собой не рассматривается, потому что до i == j цикл не доходит. |
[-7, -3, 0, 2, 9] | Правило сдвига сравнивает суммы и знака чисел не касается. |
На пустом списке j = -1. В занятии 7 разобрано, что prices[-1] — это последний элемент, и исключения при таком обращении не будет. Если бы условие цикла пропустило такой j, код прочитал бы последний элемент вместо несуществующего, и ошибки никто бы не заметил. Условие i < j отсекает этот случай до обращения к списку.
§4Индексы с единицы на LeetCode
В условии LeetCode 167 индексы в ответе нумеруются с единицы: для пары на позициях 2 и 3 нужно вернуть [3, 4], причём списком. Алгоритм от этого не меняется, меняется только строка с ответом. Такие детали и выясняют вопросом «в каком виде вернуть ответ».
if s == target: return [i + 1, j + 1] # нумерация с единицы по условию задачи
§5Тесты задачи 1
В стартовом наборе 11 тестов задачи 1. Десять из них запускаются дважды — для перебора и для указателей, поэтому в выводе pytest 21 строка. Каждый тест проверяет свой класс входа, и имя теста его называет. Последний тест сравнивает две функции между собой на трёхстах случайных входах: если указатели где-то пропускают пару, тест это найдёт.
| Тест | Вход | Что проверяет |
|---|---|---|
test_pair_example | каталог из шести цен, 1600 | обычный случай |
test_pair_first_and_last | [100, 200, 300, 400], 500 | пар несколько, подходит любая |
test_pair_neighbours_in_the_middle | [100, 200, 300, 400, 1000], 500 | правый указатель сначала уходит с 1000 |
test_pair_not_found | [300, 450, 700, 900], 100 | пары нет |
test_pair_empty | [] | пустой вход |
test_pair_single_item_is_not_a_pair | [250], 500 | товар не складывается сам с собой |
test_pair_equal_prices | [250, 250], 500 | равные цены у разных товаров |
test_pair_same_item_twice_is_forbidden | [100, 250, 400], 500 | ответ (0, 2); (1, 1) — один товар дважды |
test_pair_negative_numbers | [-7, -3, 0, 2, 9], −1 | отрицательные числа |
test_pair_does_not_change_input | каталог из четырёх цен | список после вызова тот же |
test_pair_answers_match_on_many_inputs | 300 случайных отсортированных списков | указатели находят пару тогда же, когда перебор |
Вспомогательная функция check_pair в тестах не требует конкретной пары. Она проверяет, что индексы идут по порядку и сумма равна цели. Поэтому и перебор, и указатели проходят его, даже если находят разные пары.
§6Запуск тестов
Ключ -k pair оставляет только тесты, в имени которых есть слово pair, ключ -v печатает каждый тест отдельной строкой. На заготовке из стартового набора все 21 падают с NotImplementedError. Ниже — вывод после того, как обе функции написаны.
collected 37 items / 16 deselected / 21 selected
tests.py::test_pair_example[pair_sum_brute] PASSED [ 4%]
tests.py::test_pair_example[pair_sum] PASSED [ 9%]
tests.py::test_pair_first_and_last[pair_sum_brute] PASSED [ 14%]
tests.py::test_pair_first_and_last[pair_sum] PASSED [ 19%]
…
tests.py::test_pair_does_not_change_input[pair_sum] PASSED [ 95%]
tests.py::test_pair_answers_match_on_many_inputs PASSED [100%]
====================== 21 passed, 16 deselected in 0.02s =======================
Строка [pair_sum_brute] в квадратных скобках — это параметр теста: один и тот же тест запущен для двух функций. Это делает декоратор @pytest.mark.parametrize, он стоит перед десятью тестами задачи 1.
§7Проверьте себя
Вопрос 1Проверку if s == target: return (i, j) перенесли в конец тела цикла, после блока if s < target … else …. Что вернёт функция на каталоге [300, 450, 700, 900, 1200, 1500] с целью 1600?
Верно, это видно в прогоне из §2, вкладка «проверка после сдвига». Сумма 700 + 900 равна цели, но сначала выполняется
j -= 1, и возвращаются уже сдвинутые индексы.Когда сумма равна 1600, условие
s < targetложно, и веткаelseуспевает сдвинутьjдо того, как функция вернёт индексы.Равенство проверяется по старому значению
s, поэтому функция всё же выходит из цикла — но с неверными индексами.
Вопрос 2Зачем нужен тест test_pair_single_item_is_not_a_pair с входом [250] и целью 500?
Для этого хватило бы любой цели. Цель 500 выбрана так, что 250 + 250 = 500.
Верно. С условием
i <= jфункция вернула бы(0, 0), и тест упадёт.На одном элементе скорость не измерить, и тест её не проверяет.