Все темы
19

Сходимся с двух концов

Tier 1Бесплатно

Где применяется

1. Задача

Проверить каждую пару элементов стоит O(n2), и урок про Big-O показал, как быстро это растёт. Когда данные отсортированы или тебе важны только соседние элементы, два подвижных маркера за один проход делают то, на что вложенным циклам нужно n2 шагов.

У тебя есть прайс-лист, уже отсортированный от дешёвого к дорогому, и подарочная карта на точную сумму. Нужно найти два товара, цены которых в сумме дают ровно эту сумму, чтобы потратить карту до копейки.

Первое, что приходит в голову: перебрать все пары. Товар 1 с товаром 2, товар 1 с товаром 3 и так далее. Для короткого списка это нормально. Для списка из миллиона цен это миллион на миллион пар, та самая ловушка O(n2) из урока 09.

Но список отсортирован, и это всё меняет. Благодаря порядку можно отбрасывать целые части списка, не проверяя их, и найти ответ за один проход.

2. Простыми словами

Возьми первый тест: цены [1, 3, 4, 6, 8] и нужная сумма 10. Поставь одну метку на самую дешёвую цену, 1, а другую на самую дорогую, 8. Вместе они дают 9, это мало.

1 не дотягивает до 10 даже вместе с 8, самой большой ценой. С любой ценой поменьше ей не хватит ещё больше. Значит, 1 не входит ни в один ответ, и её можно отбросить: левая метка переходит на 3. Теперь 3 + 8 = 11, это много. То же рассуждение с другой стороны: 8 слишком велика даже с самой маленькой из оставшихся цен, отбрасываем её, и правая метка переходит на 6. Дальше 3 + 6 = 9, снова мало, и левая метка переходит на 4. Наконец 4 + 6 = 10. Пара найдена.

Каждый сдвиг отбрасывал одну цену вместе со всеми парами, которые она могла составить, и ничего не проверялось дважды. Хватило четырёх сумм вместо всех десяти пар.

3. Идея

Поставь маркер lo на самый дешёвый товар, а маркер hi на самый дорогой. Сложи две цены, на которые они указывают.

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

Каждый шаг безопасен, потому что список отсортирован. Пусть сумма мала. Тогда nums[lo] не дотягивает до цели даже вместе с самой дорогой из оставшихся цен, nums[hi]. С остальными, более дешёвыми, он не дотянет тем более. Значит, nums[lo] не входит ни в одну подходящую пару, и его можно отбросить. Останавливайся, когда маркеры встретятся.

1 3 4 6 8 lo → ← hi двигаем ту сторону, что исправит сумму

Проследим на [1, 3, 4, 6, 8] с целью 10. lo указывает на 1, hi на 8: сумма 9, мало, двигаем lo. lo на 3, hi на 8: сумма 11, много, двигаем hi. lo на 3, hi на 6: сумма 9, мало, двигаем lo. lo на 4, hi на 6: сумма 10, нашли. Ответ: позиции 2 и 3.

4. Во что это обходится

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

Сравни с подходом на хеше из урока 18. Он тоже находит пару за O(n) по времени, но тратит O(n) памяти на множество. На уже отсортированных данных два указателя дают ту же скорость и не требуют лишней памяти.

5. Где это встречается

Сверка платежей: в журнале операций, отсортированном по сумме, ищут два исходных списания, которые вместе дают сумму возврата. Биржевой стакан (список заявок, упорядоченный по цене) сводит заявку на покупку с заявкой на продажу. Системы антифрода (поиска мошенничества) и удаления дубликатов ищут в отсортированных записях пару, которая отличается не больше чем на допустимую величину. Везде, где сопоставляют деньги или упорядоченные записи, задача выглядит одинаково: отсортированный вход и поиск пары. Решают её двумя указателями.

Пара с суммой 14 в отсортированном массиве
twoPointers([1, 2, 4, 6, 8, 11, 15], 14)
arr (отсортирован)
1
2
4
6
8
11
15

lo

hi
target = 14sum = -return = ?

Ищем два числа с суммой 14. Массив отсортирован, поэтому lo стоит на самом маленьком числе, а hi на самом большом.

Гонка скорости
Два вложенных цикла и два указателя ищут пару с заданной суммой в одних и тех же 40 отсортированных числах

Столбики: отсортированные числа. Ровно одна пара из них даёт нужную сумму.

Одна операция: одна проверенная пара. За каждый кадр все дорожки делают одинаковое число операций, поэтому медленная отстаёт.

Два вложенных цикла O(n²)
операций: 0
Два указателя O(n)
операций: 0

 

А если чисел больше

Операции для n чисел и примерное время при 100 миллионах простых операций в секунду. Это оценка по тому же счёту, а не замер.

n = 1 000
Два вложенных цикла455 тыс. · 4,5 мс
Два указателя850 · меньше 1 мс
n = 100 000
Два вложенных цикла4,6 млрд · 46 с
Два указателя85 тыс. · меньше 1 мс
n = 10 000 000
Два вложенных цикла45,5 трлн · 5,3 дн.
Два указателя8,5 млн · 85 мс

Как узнать

Два указателя (с концов) (Two pointers (opposite ends))

Эти слова в условии значат этот паттерн

  • отсортированный массив (или можно отсортировать)
  • найти пару/тройку с заданной суммой
  • сжать с обоих концов (контейнер, площадь)
  • проверка палиндрома

Похоже, но не то

  • Скользящее окно (переменное) (Sliding window (variable))

    Окно хранит живой отрезок, и оба конца идут вперёд. Эти указатели стартуют врозь и сходятся.

  • Хеш-множество / словарь (Hash set / map)

    Хеш находит дополнение в неотсортированных данных ценой O(n) памяти. Указателям нужен порядок, но не память.

  • Два указателя (в одну сторону) (Two pointers (same direction))

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

  • Бинарный поиск (массив) (Binary search (array))

    Бинарный поиск ищет одну фиксированную цель и делит отрезок пополам. Здесь сравниваемая пара меняется каждый шаг.

n до 1e6 на отсортированных данных даёт O(n). Каждый указатель делает не больше n шагов, память O(1).

На LeetCode

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

Твоя задача (Tier 1)

Напиши функцию twoSumSorted(nums, target).

Найди два числа, которые в сумме дают target. Список отсортирован по возрастанию, поэтому можно идти с двух концов.

  • Вход: nums, список чисел по возрастанию, и target, число. Пример: [2, 7, 11, 15], 9.
  • Возвращает: две позиции [lo, hi] чисел, которые в сумме дают target. Если такой пары нет, верни null (в Python это None).

Шаги:

  1. Поставь маркер lo в начало (позиция 0), а маркер hi на последнюю позицию.
  2. Пока lo < hi, считай nums[lo] + nums[hi]. Если сумма равна target, верни [lo, hi]. Если сумма меньше, сдвинь lo на шаг вправо. Если больше, сдвинь hi на шаг влево.
  3. Если маркеры встретились, а пара не нашлась, верни null.

Готово, когда проходят все тесты:

  • [1, 3, 4, 6, 8], target 10 -> [2, 3]
  • [2, 7, 11, 15], target 9 -> [0, 1]
  • [1, 2, 3, 4, 4], target 8 -> [3, 4] (есть повторы)
  • [5], target 5 -> null (одно число, пары нет)
  • [], target 0 -> null (пустой список, не должно падать)
  • [1, 2, 3], target 100 -> null (ни одна пара не дотягивает)

Практика

twoSumSorted

twoSumSorted(nums, target) -> [i, j] (0-based indices, i < j) or null

export function twoSumSorted(nums, target) {
  // Your code here. The task is described in the box above the editor.
}

Ты здесь как гость

Тесты запускаются прямо здесь, без аккаунта. Код сохраняется только в этом браузере. Бесплатный аккаунт сохранит его на любом устройстве, отметит шаг пройденным и покажет, где ты в курсе.

Лестница подсказок

Застрял? Открывай по одной, но сначала попробуй сам.

    Мы используем необходимые куки для работы сайта (вход и язык). Если ты согласишься, мы также загрузим Google Analytics, чтобы видеть, какие страницы читают, и Google reCAPTCHA для защиты форм обратной связи и сообщений об ошибке от спама. Политика конфиденциальности