Модуль 8 · Два указателя · ОП.04

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

Глава 8.6

8.6

Код, крайние случаи
и тесты задачи 1

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

Категория
Код и тесты
Уровень
Начальный
Опирается на
Главы 8.4 и 8.5
Зачем это знать
Написать решение без подсказок и доказать его тестами

§1Код функции

В главе 8.4 функция собрана из строк и пройдена по шагам. Здесь она записана целиком, как в solution.py, чтобы разобрать каждую строку отдельно.

solution.pyвремя O(n) · память O(1)
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: их обрабатывают те же строки, что и обычный случай.

Терминалpython -i · Python 3.12
>>> 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], причём списком. Алгоритм от этого не меняется, меняется только строка с ответом. Такие детали и выясняют вопросом «в каком виде вернуть ответ».

LeetCode 167отличается одна строка
        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_inputs300 случайных отсортированных списковуказатели находят пару тогда же, когда перебор

Вспомогательная функция check_pair в тестах не требует конкретной пары. Она проверяет, что индексы идут по порядку и сумма равна цели. Поэтому и перебор, и указатели проходят его, даже если находят разные пары.

§6Запуск тестов

Ключ -k pair оставляет только тесты, в имени которых есть слово pair, ключ -v печатает каждый тест отдельной строкой. На заготовке из стартового набора все 21 падают с NotImplementedError. Ниже — вывод после того, как обе функции написаны.

Терминалpython -m pytest tests.py -k pair -v
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), и тест упадёт.

  • На одном элементе скорость не измерить, и тест её не проверяет.

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