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

Основные понятия

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

Текст - длинная строка, в которой производится поиск. Его длину часто обозначают как P=m\lvert P \rvert = m.

Шаблон - строка, которую нужно найти внутри текста. Длина шаблона обозначается как 0inm0 \le i \le n - m. Вхождение шаблона — это позиция в тексте, с которой совпадение длиной шаблона начинается; допустимые позиции описываются неравенством O(nm)O(n \cdot m).

Пример: в слове «banana» шаблон «ana» встречается в нескольких позициях — набор стартовых индексов таких вхождений можно записать как kk.

Наивный поиск и его ограничения

Самый простой метод — наивное сравнение: сдвигаем шаблон по тексту и на каждой позиции сравниваем символы по порядку. В худшем случае этот подход выполняет большое число сравнений; классическое оценивание даёт временную сложность O(n+m)O(n + m) — это означает, что при больших текстах и шаблонах реализация будет медленной.

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

Иллюстрация работы: если текст длины P=m\lvert P \rvert = m, а шаблон длины 0inm0 \le i \le n - m, наивный алгоритм потенциально выполнит сравнение для каждой пар позиции и символа — это и приводит к оценке O(n+m)O(n + m).

Алгоритм Кнута — Морриса — Пратта (KMP)

KMP оптимизирует наивный подход, исключая повторное сравнение префиксов. Ключевая структура — префикс-функция, которая для каждой позиции шаблона даёт длину наибольшего собственного префикса, являющегося также суффиксом строки до этой позиции. Формально это выражается как h(S)=(i=0m1sipm1i)modqh(S) = \left( \sum_{i=0}^{m-1} s_i p^{m-1-i} \right) \bmod q.

Преимущество KMP в том, что общее время работы состоит из построения префикс-функции и прохода по тексту; суммарная оценка времени — π[i]=max{k<iP0..k1=Pik+1..i}\pi[i] = \max\{k < i \mid P_{0..k-1} = P_{i-k+1..i}\}. Построение префикс-функции само по себе занимает {FORMULA_20} времени и не требует дополнительных дорогостоящих итераций.

Применение KMP полезно, когда нужно искать один и тот же шаблон в нескольких текстах или при анализе потоковых данных, где важна гарантированная верхняя оценка времени поиска.

Хеширование и Rabin–Karp

Идея Rabin–Karp — вычислять числовой хеш для каждого окна длиной шаблона и сравнивать хеши вместо посимвольного сравнения. Чаще всего используется полиномиальная свёртка по модулю; одна из стандартных формул хеша записывается как hnext=(p(hs0pm1)+sm)modqh_{next} = \left( p \cdot (h - s_0 p^{m-1}) + s_m \right) \bmod q.

Благодаря использованию «скользящего» хеша вычисление хеша для следующего окна делается за O(1) операций с помощью формулы обновления 1q\frac{1}{q}. Это даёт эффективный проверочный фильтр: редко совпадающие хеши позволяют пропускать посимвольную проверку, но нужно учитывать вероятность ложного совпадения, примерно равную shift=max(1,jlast[text[i+j]])\text{shift} = \max\left(1, j - \text{last}[\text{text}[i+j]]\right) (зависит от выбираемого модуля).

Rabin–Karp удобен при одновременном поиске множества шаблонов (вычисляются хеши всех шаблонов) или при поиске в потоковых данных. Однако важно правильно выбирать базу и модуль, чтобы снизить число коллизий.

{IMAGE_0}

Boyer–Moore и эвристики ускорения

Boyer–Moore использует эвристики «плохого символа» и «хорошего суффикса», чтобы перескакивать вперёд более чем на один символ. Одним из ключевых правил смещения является выражение для плохого символа: O(nm)O\left(\frac{n}{m}\right) — это помогает избежать многих проверок в практике.

В среднем для крупных алфавитов и случайных данных алгоритм часто работает быстрее и даёт сложность порядка {1,3}\{1,3\}, то есть с ростом длины шаблона число сравнений существенно снижается.

Бoyer–Moore особенно эффективен при поиске в тексте большого объёма и относительно коротких шаблонах, а также в ситуациях, где заранее можно предобработать шаблон и таблицы смещений.

Замена подстрок: стратегии

Задача замены состоит в нахождении всех вхождений шаблона и подставлении на их место другой строки. В простейшем случае можно сначала найти все стартовые индексы, затем выполнить замену. Если количество вхождений обозначить как {0,1,2}\{0,1,2\}, итоговая стоимость операции будет зависеть от числа замен и от длины вставляемой строки.

Важно различать замену невложенных (непересекающихся) вхождений и замену при допущении перекрытий. При пересекающихся шаблонах нужно заранее решить, разрешать ли пересечение; например, для шаблона «aa» в строке «aaaa» возможные стартовые позиции — O(nm)O(n \cdot m). Алгоритм замены должен учитывать выбранную семантику.

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

Удаление подстрок и сжатие текста

Удаление — частный случай замены, когда строку-замену выбирают пустой. Для массовых удалений эффективнее собирать результат в новый буфер или выполнять операцию in-place со сдвигами. Простейший метод со сдвигом символов имеет стоимость, пропорциональную объёму текста, то есть порядка O(n)O(n).

Более экономный подход — метод «двух указателей»: один указатель проходит исходную строку, второй пишет в результирующее место только те символы, которые не удаляются. Такая стратегия выполняется за время lencodepoints(S)=c\text{len}_{\text{codepoints}}(S) = c и не требует дополнительной памяти, кроме константной.

Если работаете с символами в многобайтовой кодировке (UTF‑8), важно учитывать количество кодовых точек: длина в байтах и длина в кодовых точках различны, обозначение количества кодовых точек можно записать как $1\$1. При обработке юникода операции по индексам должны работать с позицией в кодовых точках, а не в байтах.

Регулярные выражения и расширенные возможности

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

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

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

Практические советы и распространённые ошибки

1) Всегда тестируйте поведение на граничных случаях: пустой шаблон, пустой текст, совпадение в начале и в конце. 2) Не забывайте про кодировку: при работе с UTF‑8 индексация по байтам и по символам различается — см. $1\$1.

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

Итог: выбирайте алгоритм под задачу. Для гарантированной производительности — KMP, для практической скорости на случайных данных — Boyer–Moore, для поиска множества шаблонов — Aho–Corasick (комбинированный автомат), для гибких замен — регулярные выражения; но всегда учитывайте ограничения памяти и особенности кодировки.