Алгоритмы на массивах · модуль 8 · два указателя
Глава 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, «решение в лоб», и предлагают первым — оно точно верное, и с ним сравнивают быстрое решение.
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 | Как растёт |
|---|---|---|
| 4 | 6 | как в прогоне выше |
| 6 | 15 | каталог из примера |
| 1 000 | 499 500 | n больше в 10 раз — пар больше примерно в 100 раз |
| 10 000 | 49 995 000 | ещё в 100 раз |
| 1 000 000 | 499 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 — на самую дорогую и посмотрим на их сумму.
- Сумма равна цели. Пара найдена, возвращаем
(i, j). - Сумма меньше цели. Сумму нужно увеличить. Правая цена уже самая большая из оставшихся, поэтому увеличить сумму может только более дорогая левая цена. Сдвигаем
iвправо. - Сумма больше цели. Сумму нужно уменьшить. Левая цена уже самая маленькая из оставшихся, поэтому уменьшить сумму может только более дешёвая правая цена. Сдвигаем
jвлево. - Указатели встретились. Непроверенных пар не осталось, ответ
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. Каждый ход уменьшает расстояние между указателями на одну позицию.