1. Метод двух указателей: классификация паттернов и переход от квадратичной сложности к линейной
Метод двух указателей: классификация паттернов и переход от квадратичной сложности к линейной
Представьте массив из 100 000 записей о банковских транзакциях, отсортированных по времени. Система безопасности ищет две конкретные транзакции, сумма которых с точностью до копейки совпадает с подозрительным переводом в 1 000 000 рублей. Очевидное решение — взять первую транзакцию и сложить её по очереди со всеми остальными, затем взять вторую и повторить процесс. Для 100 000 элементов этот вложенный цикл совершит около 5 миллиардов проверок. На Python это займет несколько секунд. Если транзакций будет миллион — вычисления растянутся на часы. Алгоритм работает со сложностью , и для высоконагруженных систем это неприемлемо.
Проблема кроется не в языке программирования, а в избыточности самого перебора. Мы проверяем пары, которые заведомо не могут дать нужного результата. Чтобы избавиться от лишней работы и снизить сложность до , алгоритмика предлагает элегантный подход, не требующий сложных структур данных — метод двух указателей.
Анатомия указателя в Python
В языках вроде C или C++ указатель — это переменная, хранящая физический адрес в оперативной памяти. В контексте алгоритмических собеседований по Python термин «указатель» (pointer) используется в абстрактном смысле. Здесь указатель — это просто целочисленная переменная-индекс, которая указывает на текущую позицию в массиве или строке.
Обычно мы используем один неявный указатель, когда пишем цикл for i in range(len(arr)). Переменная i бежит по массиву слева направо. Метод двух указателей вводит вторую переменную-индекс — j, left, right, slow или fast. Управляя движением этих двух индексов по определенным правилам, мы можем анализировать пары элементов, подмассивы или сравнивать две разные последовательности за один проход.
Главная сила этого метода заключается в оптимизации вспомогательной памяти. Поскольку мы храним только два целых числа (индексы), пространственная сложность алгоритма остается , независимо от размера входных данных.
Как два указателя ломают квадратичную сложность
Чтобы понять механику перехода от к , необходимо рассмотреть концепцию сокращения пространства поиска (search space pruning).
Вернемся к задаче поиска двух чисел с заданной суммой. Пусть дан отсортированный массив [2, 7, 11, 15] и целевая сумма 18.
Пространство поиска — это все возможные пары индексов . Их можно представить в виде двумерной матрицы, где строки — это левый элемент пары, а столбцы — правый. При квадратичном переборе мы добросовестно проверяем каждую ячейку этой матрицы.
!Матрица пространства поиска и отсечение вариантов
Метод двух указателей использует свойства данных (в данном случае — отсортированность), чтобы отбрасывать целые строки и столбцы этой матрицы за одну операцию. Если сумма элементов на текущих указателях больше целевой, нам не нужно проверять текущий правый элемент ни с какими другими левыми элементами — они все дадут еще большую сумму. Мы сдвигаем правый указатель, тем самым вычеркивая из пространства поиска целый столбец вариантов. Одно сравнение исключает десятки, сотни или тысячи бесполезных проверок.
В зависимости от того, как именно движутся индексы, выделяют три основных паттерна.
Паттерн 1: Встречные указатели (Opposite Direction)
Это классический сценарий, применяемый к отсортированным массивам или строкам, когда нужно найти пару элементов, удовлетворяющую условию, либо проверить симметрию.
Один указатель ставится на начало структуры (left = 0), второй — на конец (right = len(arr) - 1). На каждом шаге цикла while left < right алгоритм принимает решение, какой из указателей сдвинуть навстречу другому.
Разберем реализацию задачи о поиске суммы (Two Sum II) на отсортированном массиве:
!Анимация встречных указателей для поиска суммы (Примечание: маркер виджета заменен для соответствия нумерации, правильный маркер ниже)
!Анимация встречных указателей для поиска суммы
Логика движения строго детерминирована. Если current_sum < target, сдвиг right влево сделал бы сумму еще меньше (ведь массив отсортирован по возрастанию). Значит, единственный логичный шаг — сдвинуть left вправо.
Этот паттерн гарантирует, что каждый элемент массива будет прочитан не более одного раза. Указатели встретятся в середине, совершив в сумме ровно шагов. Временная сложность строго .
Где еще применяются встречные указатели
left и right сравнивают символы с краев строки, двигаясь к центру. Если символы не совпадают — это не палиндром.left и right меняют местами элементы, на которые указывают, и делают шаг навстречу друг другу.Паттерн 2: Параллельные указатели (Same Direction / Fast and Slow)
В этом паттерне оба указателя начинают движение с одного конца массива (обычно с индекса 0) и движутся в одном направлении, но с разной скоростью или при разных условиях.
Чаще всего один указатель выступает в роли «разведчика» или «читателя» (fast), а второй — в роли «хранителя состояния» или «писателя» (slow). Этот подход незаменим для модификации массивов in-place (без выделения дополнительной памяти), когда удалять элементы через методы вроде .remove() или del слишком дорого из-за сдвига оставшихся элементов за .
Рассмотрим задачу: дан массив чисел. Нужно переместить все нули в конец массива, сохранив относительный порядок остальных элементов. Создавать новый массив нельзя.
Если использовать метод .pop() для нулей и .append(0) в конец, каждый .pop() из середины списка в Python потребует времени на сдвиг элементов. В худшем случае (массив из одних нулей) сложность станет .
Решим это параллельными указателями:
!Анимация параллельных указателей при перемещении нулей
Как это работает на концептуальном уровне:
reader (в коде это переменная цикла) безусловно проходит каждый элемент массива.writer фиксирует границу обработанной части. Всё, что находится строго до индекса writer, гарантированно не является нулем.writer и reader — это зона, где скапливаются найденные нули.reader встречает ненулевое число, оно «перепрыгивает» через зону нулей на позицию writer, а первый ноль из этой зоны отправляется на место reader.Сложность такого решения — по времени (каждый элемент посещается один раз) и по памяти.
Разновидности параллельных указателей
Параллельные указатели могут двигаться не только по одному массиву, но и по двум разным. Классический пример — слияние двух отсортированных массивов в один. Указательi бежит по первому массиву, указатель j — по второму. На каждом шаге мы сравниваем arr1[i] и arr2[j], выбираем меньший элемент для итогового массива и сдвигаем только тот указатель, чей элемент был выбран.Паттерн 3: Скользящее окно (Sliding Window)
Скользящее окно — это специализированная и более сложная форма параллельных указателей. Если в стандартных параллельных указателях нас интересуют сами элементы, на которые указывают индексы, то в скользящем окне нас интересует подмножество элементов между левым и правым указателями.
Окно имеет границы [left, right]. Правый указатель расширяет окно, добавляя новые элементы и меняя текущее состояние (например, увеличивая текущую сумму подмассива). Левый указатель сужает окно, когда текущее состояние нарушает заданное условие (например, сумма превысила лимит).
Этот паттерн настолько объемен и имеет столько нюансов при работе со строками и словарями, что требует отдельного глубокого изучения. Главное, что нужно зафиксировать сейчас: скользящее окно — это эволюция метода двух указателей, где индексы всегда движутся в одном направлении (вправо), никогда не возвращаясь назад, что и обеспечивает линейную сложность .
Границы применимости и подводные камни
Несмотря на эффективность, метод двух указателей — не серебряная пуля. Его применение жестко ограничено структурой данных и условиями задачи.
Зависимость от сортировки. Встречные указатели для поиска сумм или разностей работают только на отсортированных данных. Если массив не отсортирован, предварительная сортировка займет времени. В таком случае общая сложность алгоритма будет определяться сортировкой, а не проходом указателей. Если задача требует сохранить исходные индексы элементов (как в оригинальной задаче Two Sum на LeetCode), сортировка разрушит эти индексы, и метод двух указателей применять нельзя без дополнительных структур данных.
Поиск комбинаций, а не пар. Если задача требует найти три числа, дающих в сумме ноль (3Sum), два указателя сами по себе не справятся. Потребуется зафиксировать первое число внешним циклом, а для оставшейся части массива применить метод двух указателей. Сложность возрастет до , что, впрочем, все равно лучше наивного перебора за .
Ошибки сдвига (Infinite Loops). Самая частая ошибка при реализации — забыть сдвинуть указатель в одной из веток if/else, либо сдвинуть его не в ту сторону. Это приводит к бесконечному циклу. При написании кода всегда проверяйте инвариант: на каждой итерации цикла while расстояние между указателями должно сокращаться (для встречных) или хотя бы один указатель должен двигаться вперед (для параллельных).
Переход от вложенных циклов к двум указателям требует изменения образа мышления. Вместо того чтобы перебирать все возможные состояния системы, мы выстраиваем логику так, чтобы каждое сравнение элементов давало нам информацию о том, в какой части данных правильного ответа точно нет. Умение видеть монотонность данных и использовать её для отсечения лишних проверок — ключевой навык оптимизации алгоритмов.