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

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

Глава 8.4

8.4

Пара с заданной суммой:
перебор и указатели

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

Категория
Алгоритм
Уровень
Начальный
Опирается на
Глава 8.3: условие задачи 1 после уточнений
Зачем это знать
Типичная первая задача алгоритмической секции

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

Условие уточнено в главе 8.3, здесь оно записано в окончательном виде. Во всех примерах главы используется один каталог из шести цен, чтобы сравнивать способы на одних данных.

pair_sum(prices, target)задача 1 · LeetCode 167

prices — цены по возрастанию, повторы возможны, список может быть пустым. target — сумма сертификата. Нужно вернуть пару индексов (i, j), i < j, у которой prices[i] + prices[j] == target, или None, если пары нет. Список не меняется, дополнительная память O(1).

Пример: prices = [300, 450, 700, 900, 1200, 1500], target = 1600. Ответ (2, 3): 700 + 900 = 1600.

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

Самое прямое решение — проверить все пары. Внешний цикл выбирает первый товар i, внутренний — второй j. Причём j начинается с i + 1: так товар не складывается сам с собой, и каждая пара проверяется один раз. На собеседовании такое решение называют brute force, «решение в лоб», и предлагают первым — оно точно верное, и с ним сравнивают быстрое решение.

solution.pyвремя O(n²) · память O(1)
def pair_sum_brute(prices, target):
    n = len(prices)
    for i in range(n):
        for j in range(i + 1, n):
            if prices[i] + prices[j] == target:
                return (i, j)
    return None
Python в этой строке: range(i + 1, n) и цикл внутри цикла

range(a, b) даёт числа от a до b − 1: правая граница не входит, как в срезах из занятия 7. Внутренний цикл выполняется целиком на каждом шаге внешнего. Поэтому j начинается заново с i + 1 для каждого нового i.

>>> list(range(2, 5))
[2, 3, 4]
>>> for i in range(3):
...     for j in range(i + 1, 3):
...         print(i, j)
...
0 1
0 2
1 2
Python в этой строке: return (i, j) и None

Скобки с двумя значениями через запятую — кортеж: неизменяемая пара, её можно вернуть одним значением и потом взять элементы по индексу. None — отдельное значение «ничего нет». Функция возвращает его, когда пара не найдена; проверяют его через is None.

>>> def find():
...     return (2, 3)
...
>>> r = find()
>>> r
(2, 3)
>>> r[0]
2
>>> def nothing():
...     return None
...
>>> print(nothing())
None
>>> nothing() is None
True

Прогон ниже сделан на четырёх ценах, чтобы все проверки поместились на экран. Нажимайте «шаг →» или стрелку вправо на клавиатуре, когда блок выделен. Строка кода, которая выполнится следующей, подсвечена; справа — значения переменных и указатели i, j под ячейками списка.

Пара нашлась на шестой проверке — последней из возможных. Это худший случай перебора: каждая пара проверена ровно один раз.

§3Сколько пар в списке

У первого товара n − 1 соседей справа, у второго n − 2 и так далее до последнего, у которого соседей справа нет. Сумма (n − 1) + (n − 2) + … + 1 равна n·(n − 1) / 2. Когда пары нет, перебор проверяет их все.

Длина списка nПар n·(n − 1) / 2Как растёт
46как в прогоне выше
615каталог из примера
1 000499 500n больше в 10 раз — пар больше примерно в 100 раз
10 00049 995 000ещё в 100 раз
1 000 000499 999 500 000полтриллиона проверок

Поэтому время перебора оценивается как O(n²): при увеличении списка вдвое проверок становится примерно вчетверо больше. Для масштаба: на 16 000 ценах перебор работает около 2,8 секунды на ноутбуке автора.

Напоминание из занятия 3: что означают O(n²) и O(n)

O-нотация показывает, как растёт число действий, когда растёт длина входа n. O(n) — действий примерно столько же, сколько элементов: список вдвое длиннее — работы вдвое больше. O(n²) — действий примерно квадрат длины: список вдвое длиннее — работы вчетверо больше. Константы не пишут: n·(n − 1) / 2 проверок перебора записывают как O(n²).

§4Идея указателей навстречу

Перебор не пользуется тем, что цены отсортированы. А по порядку сразу видно, какие пары проверять бесполезно. Поставим указатель i на самую дешёвую цену, j — на самую дорогую и посмотрим на их сумму.

  1. Сумма равна цели. Пара найдена, возвращаем (i, j).
  2. Сумма меньше цели. Сумму нужно увеличить. Правая цена уже самая большая из оставшихся, поэтому увеличить сумму может только более дорогая левая цена. Сдвигаем i вправо.
  3. Сумма больше цели. Сумму нужно уменьшить. Левая цена уже самая маленькая из оставшихся, поэтому уменьшить сумму может только более дешёвая правая цена. Сдвигаем j влево.
  4. Указатели встретились. Непроверенных пар не осталось, ответ None.

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

§5Поиск пары вручную

Прежде чем смотреть, как это делает код, пройдите алгоритм сами. Кнопки двигают указатели; ход, который противоречит правилу, не выполняется, и под кнопками появляется объяснение, какую пару он бы потерял. В наборе пять примеров, среди них два без ответа — там закончить нужно кнопкой «Пары нет», когда указатели встретятся.

В последнем примере, [100, 250, 600] с целью 500, указатели сходятся на цене 250. Сумма 250 + 250 как раз равна 500, но это один и тот же товар, поэтому пары нет. Отсюда правило остановки: поиск идёт, пока между указателями есть хотя бы два товара, то есть пока i < j.

§6Сборка функции из строк

Правило из §4 записывается функцией из одиннадцати строк. Строки перемешаны, отступы у них сохранены. Расставьте их по порядку стрелками или перетаскиванием и нажмите «Проверить порядок». Готовая функция появится ниже, в пошаговом прогоне §7.

Два места стоит разобрать отдельно. Проверка s == target стоит раньше сдвига: если найденную пару не вернуть сразу, один из указателей сдвинется, и функция вернёт уже другие индексы. А return None стоит после цикла, на уровне while: внутри цикла он завершил бы функцию на первом же шаге.

Python в этой строке: i, j = 0, len(prices) - 1

Слева два имени, справа два значения через запятую: Python присваивает их по порядку, i получает 0, j — len(prices) - 1. Это индекс последнего элемента: элементов len(prices), а нумерация с нуля.

>>> prices = [300, 450, 700]
>>> i, j = 0, len(prices) - 1
>>> i, j
(0, 2)
>>> prices[j]
700
Python в этой строке: while, += и -=

while условие: повторяет тело, пока условие истинно, и проверяет его перед каждым повтором. i += 1 — короткая запись i = i + 1, j -= 1 — j = j - 1. Если в теле цикла ни одна переменная из условия не меняется, цикл не закончится.

>>> i = 0
>>> while i < 3:
...     i += 1
...
>>> i
3

Напишите функцию сами в окне ниже, прямо на странице. «Запустить тесты» проверяет код на семи входах; часть из них заготовка проходит сразу: там, где верный ответ — None. Строка «Свой вызов» выполняет любой код с вашей функцией и печатает результат. Первый запуск загружает Python в браузер, около 10 МБ.

§7Пошаговый прогон кода

Собранная функция на двух входах. Бледные ячейки — цены, которые уже исключены. Подписи под кодом объясняют каждый ход тем же правилом, что в §4. Вкладки переключают два входа: в первом пара есть, во втором нет.

pair_sum на двух входахпереключите вкладку

(2, 3)Четыре хода: дважды сдвинулся j, дважды i. Перебор нашёл бы эту пару на 10-й проверке из 15.

NoneТри хода, и указатели встретились на индексе 2. Каждую исключённую цену исключило правило, поэтому пропущенной пары нет. Перебор проверил бы все 6 пар.

§8Сколько шагов делают указатели

Каждый ход сдвигает i вправо или j влево на одну позицию, и назад указатели не возвращаются. В начале между ними n − 1 позиция, цикл заканчивается, когда расстояние становится нулевым. Значит, ходов не больше n − 1, а время — O(n). Дополнительная память — три переменные i, j, s, то есть O(1).

Ползунок меняет длину списка. Полосы показывают число проверок в худшем случае, когда пары нет, а время рассчитано по замеру на Python 3.12.

На полях: где указатели не подходят

Если вход не отсортирован, указатели не применимы, а сортировка копии стоит O(n log n) и меняет индексы. Если нужны все пары с суммой, ответ сам может содержать O(n²) пар, и никакая техника не выдаст его быстрее. В обоих случаях условие задачи другое, и это выясняют вопросами из главы 8.3.

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

Вопрос 1prices = [100, 300, 500, 800], target = 900. Указатели стоят на 100 и 800, сумма 900. Что делает функция?

  • Сумма уже равна цели, дальше двигаться незачем: функция возвращает первую найденную пару.

  • Указатель двигают только тогда, когда сумма не равна цели. Здесь она равна.

  • Верно. Условие s == target проверяется первым, и функция сразу возвращает индексы.

Вопрос 2prices = [200, 400, 700, 1000], target = 1100. Первая сумма 200 + 1000 = 1200. Какой ход верный и почему?

  • Верно. С любой другой ценой, кроме 200, цена 1000 даст ещё больше. Значит, пары с 1000 нет, и её можно исключить.

  • Если заменить 200 на 400, сумма станет 1400 — ещё дальше от цели. Сумма велика, и уменьшить её можно только правой ценой.

  • Правило исключает только 1000: с любой оставшейся ценой она даёт больше цели. Про 200 такого не известно — с ценой 900 она дала бы ровно 1100, и будь 900 в списке, ответ потерялся бы. За ход сдвигается один указатель.

Вопрос 3Сколько ходов в худшем случае сделают указатели на списке из 1000 цен?

  • Это число пар, его проверяет перебор. Указатели каждым ходом исключают одну цену.

  • 500 — половина длины. Но ходить может и один указатель: например, j пройдёт почти весь список, если все суммы больше цели.

  • Расстояние между указателями в начале 999, и каждый ход уменьшает его на 1. Тысячного хода не будет.

  • Верно: не больше n − 1. Каждый ход уменьшает расстояние между указателями на одну позицию.

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