Алгоритмы и структуры данных

Same-Direction Pointers

Два указателя в одну сторону

актуальноТекущий рабочий стандарт

Часть темы: Two Pointers and Sliding WindowДва указателя: семейство приёмов

Направления: Основы · Алгоритмы и структуры данных

Слияние, пересечение и запись на месте: каждому источнику свой указатель, ни один не откатывается назад. Основа слиянием и merge join в базах данных.

Ключевые тезисы

  • Слияние двух отсортированных наборов за O(n + m) — сортировать заново незачем.
  • Чтение и запись двумя индексами дают фильтрацию на месте с O(1) дополнительной памяти.
  • Инвариант «пишущий не обгоняет читающего» — то, почему запись не портит непрочитанные данные.

Какую задачу решает

Два отсортированных набора нужно объединить, пересечь или сравнить. Склеить и отсортировать — значит выбросить уже имеющийся порядок и заплатить за него второй раз; перебрать пары — получить квадрат там, где хватает линии.

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

Подробный разбор

10 подтем — объяснения, формулы, примеры и интерактивные графики.

Два отсортированных списка нужно объединить в один. В лоб: склеить и отсортировать — , хотя оба входа уже упорядочены и вся работа уходит впустую. Пересечь два списка перебором — и вовсе .

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

На практике

Каждому источнику — свой указатель; на каждом шаге двигается только тот, чей элемент обработан. Ни один указатель не откатывается назад.

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

a:  1  3  7        i -- первый необработанный в a
b:  2  4  9        j -- первый необработанный в b
На каждом шаге берём меньший из двух текущих элементов
Шагa[i]b[j]Кого берёмРезультат
112a[i]1
232b[j]1 2
334a[i]1 2 3
474b[j]1 2 3 4
579a[i]1 2 3 4 7
69хвост b1 2 3 4 7 9
a:  1  1  2  2  3

read = 1, write = 1:  a[1]=1 == a[0]  ->  пропускаем
read = 2, write = 1:  a[2]=2 != a[0]  ->  a[1] = 2, write = 2
read = 3, write = 2:  a[3]=2 == a[1]  ->  пропускаем
read = 4, write = 2:  a[4]=3 != a[1]  ->  a[2] = 3, write = 3

ответ: первые write = 3 элемента -- 1 2 3
Запись на месте: удаление дубликатов из отсортированного массива

i = j = 0
out = []

while i < len(a) and j < len(b):
    if a[i] <= b[j]:            # <= сохраняет стабильность
        out.append(a[i]); i += 1
    else:
        out.append(b[j]); j += 1

out.extend(a[i:])               # ровно один из хвостов непустой
out.extend(b[j:])
return out
Слияние: основной цикл и два хвоста
  • Время: каждый элемент обрабатывается один раз.
  • Память на результат при слиянии и при записи на месте.
  • Инвариант слияния: в out уже лежат все элементы, меньшие обоих текущих; минимум оставшихся — это a[i] или b[j].
  • Инвариант записи: write <= read, поэтому запись не портит ещё не прочитанное.

Оба массива упорядочены, значит минимум из всех необработанных элементов обязательно стоит на одном из : внутри каждого массива всё остальное больше. Выбирая меньший из a[i] и b[j], мы каждый раз добавляем в ответ действительно минимальный оставшийся элемент — этого достаточно, чтобы результат оказался отсортированным.

В задачах записи корректность следует из неравенства write <= read, которое сохраняется на каждом шаге: пишущий сдвигается не чаще читающего. Значит, ячейка, в которую мы пишем, либо уже прочитана, либо это та же ячейка, что и читаемая, — потерять данные невозможно.

На практике

Знак сравнения решает вопрос стабильности: при a[i] <= b[j] элементы из первого массива при равенстве идут раньше. Для слиянием это и есть источник её устойчивости.

Сигнал в условииЧто применять
Два (или k) отсортированных набора, нужно объединитьслияние указателями
«Пересечение», «разность» отсортированных списковтот же цикл, в ответ идут совпадения
«Сделайте на месте», «дополнительная память — константа»чтение и запись двумя индексами
«Сохраните относительный порядок»стабильная фильтрация записью на месте
«Является ли строка подпоследовательностью другой»указатель по образцу и по строке
Данные не помещаются в память, но приходят по порядкупотоковое слияние, как в внешней

ВариантЗадачаОсобенность
Слияниешаг слияниемхвосты дописываются после основного цикла
Пересечение и разностьобщие или уникальные элементыравенство двигает оба указателя
Слияние на местемассив с запасом в концеидти справа налево, чтобы не затирать
Удаление дубликатовотсортированный массивwrite хранит длину ответа
Стабильная фильтрацияперенос нулей, удаление по условиюпорядок оставшихся сохраняется
k списков сразуслияние k отсортированных по головам:

Прикладная версия того же приёма — merge join в базах данных и пересечение постинг-списков в поиске: два отсортированных потока, два курсора, один проход. Отличие только в том, что данные приходят с диска, а не из памяти.

  • Забытые хвосты. После основного цикла один из массивов почти всегда не исчерпан.
  • Слияние на месте слева направо. Запись затирает ещё не прочитанные элементы; идти нужно с конца.
  • Потеря стабильности. < вместо <= меняет порядок равных элементов — для по нескольким ключам это ошибка.
  • Дубликаты при пересечении. Если в обоих списках есть повторы, нужно решить, сколько раз элемент попадает в ответ, — и сдвигать указатели соответственно.
  • k списков попарно. Слияние по одному даёт ; по головам даёт .

ПриёмКогда он лучшеЦена
Хеш-таблицавходы не отсортированы, порядок в ответе не нужен памяти
Кучаисточников много (k списков, k потоков) на элемент
Бинарный поискодин список сильно короче другого вместо
Сортировкавходы не упорядочены и порядок нужен
На практике

Если один список в тысячу раз короче другого, слияние проигрывает: выгоднее для каждого элемента короткого списка искать место в длинном . Это тот случай, когда хуже, чем .

  1. Почему после основного цикла слияния достаточно дописать оба хвоста, не проверяя, какой именно непустой?
  2. Слияние на месте в массив с запасом: с какого конца идти и почему?
  3. Удаление дубликатов на месте: что означает значение write в конце работы?
  4. Пересечение двух отсортированных списков с повторами — сколько раз элемент должен попасть в ответ и как это обеспечить?
  5. Когда выгоднее не сливать, а искать ?

Чем заменяют

Видеолекции

Тайм-кода именно на эту тему в записях нет. Но глава «Алгоритмы и структуры данных» разобрана в курсе целиком — с той оговоркой, что место в записи придётся искать самому.

Связанные темы

Два указателя и окно

Two Pointers and Sliding Window90%

Два указателя: семейство приёмов · Алгоритмы и структуры данных

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

Opposite-Direction Pointers90%

Встречные указатели · Алгоритмы и структуры данных

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

Sliding Window90%

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

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

Fast and Slow Pointers90%

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

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

Prefix Sums90%

Префиксные суммы · Алгоритмы и структуры данных

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

Monotonic Stack and Deque90%

Монотонный стек и дек · Алгоритмы и структуры данных

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

Следующий шагДальше по связи: Two Pointers and Sliding WindowЗонтичная тема: что общего у четырёх приёмов с двумя индексами и как выбрать нужный. Сами приёмы разобраны каждый на своей странице.Перейти →

Глава «Алгоритмы и структуры данных» последний раз правилась . Нашли ошибку — напишите.