Алгоритм Кнута-Морриса-Пратта для поиска подстрок на Go
- пятница, 7 февраля 2025 г. в 00:00:17
Поиск подстроки в строке — важная задачка в текстовой обработке. В Go стандартная библиотека имеет strings.Index
, но он использует простой перебор символов, который работает с O(n × m) в худшем случае, где n
— длина текста, m
— длина подстроки.
Алгоритм Кнута-Морриса-Пратта решает эту проблему, используя префикс-функцию, которая позволяет пропускать заведомо ненужные сравнения. В результате его сложность O(n + m), что делает его подходящим для больших текстов и множественных поисковых запросов.
Допустим, ищем "needle"
в строке "aaaaaaneedle"
.
Сравниваем "needle"
с "aaaaaa"
, получаем несовпадение.
Сдвигаемся на 1 позицию и сравниваем снова.
Повторяем процесс, пока не найдем "needle"
.
В худшем случае O(n × m) операций. Например, поиск "aaaab"
в "aaaaaaaaaaaaaaaaaaaaaaaab"
будет перебирать почти всю строку.
Алгоритм заранее анализирует паттерн, создавая вспомогательный массив π
. Он показывает, насколько далеко можно откатиться назад, чтобы не начинать сравнение заново. Например, при поиске "abcabc"
в "abcabcabcabc"
не нужно проверять уже известные совпадения.
Префикс-функция π[i]
— это максимальная длина префикса подстроки, который также является ее суффиксом.
Пример для строки "abcaby"
:
i | Подстрока | π[i] |
---|---|---|
0 | "a" | 0 |
1 | "ab" | 0 |
2 | "abc" | 0 |
3 | "abca" | 1 |
4 | "abcab" | 2 |
5 | "abcaby" | 0 |
Как строится π?
π[0] = 0
.
Перебираем строку и проверяем, совпадает ли pattern[i]
с pattern[j]
.
Если да, увеличиваем j
и записываем π[i]
.
Если нет, откатываем j
по π[j-1]
.
Код на Golang:
func computePrefixFunction(pattern string) []int {
n := len(pattern)
π := make([]int, n)
j := 0
for i := 1; i < n; i++ {
for j > 0 && pattern[i] != pattern[j] {
j = π[j-1] // Откатываемся по π
}
if pattern[i] == pattern[j] {
j++
}
π[i] = j
}
return π
}=
Сложность: O(m).
Используем π
для поиска.
func KMPSearch(text, pattern string) []int {
positions := []int{}
π := computePrefixFunction(pattern)
j := 0
for i := 0; i < len(text); i++ {
for j > 0 && text[i] != pattern[j] {
j = π[j-1]
}
if text[i] == pattern[j] {
j++
}
if j == len(pattern) {
positions = append(positions, i-j+1)
j = π[j-1] // Продолжаем поиск
}
}
return positions
}
К примеру, нужно найти все вхождения "ERROR"
в файле логов:
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
file, _ := os.Open("logfile.txt")
defer file.Close()
scanner := bufio.NewScanner(file)
pattern := "ERROR"
for scanner.Scan() {
line := scanner.Text()
if len(KMPSearch(line, pattern)) > 0 {
fmt.Println(line) // Выводим строки, содержащие "ERROR"
}
}
}
π
строится за O(m), а поиск выполняется за O(n).
Go хранит строки как байтовые слайсы в UTF-8, но KMP работает с символами(рунами).
Пример с Unicode:
package main
import "fmt"
func main() {
text := "Привет, мир! 你好,世界!"
runes := []rune(text)
fmt.Println(len(text)) // 27 байт
fmt.Println(len(runes)) // 15 символов
}
Поэтому KMP должен работать по рунам:
func KMPSearchUnicode(text, pattern string) []int {
textRunes := []rune(text)
patternRunes := []rune(pattern)
positions := []int{}
π := computePrefixFunction(string(patternRunes))
j := 0
for i := 0; i < len(textRunes); i++ {
for j > 0 && textRunes[i] != patternRunes[j] {
j = π[j-1]
}
if textRunes[i] == patternRunes[j] {
j++
}
if j == len(patternRunes) {
positions = append(positions, i-j+1)
j = π[j-1]
}
}
return positions
}
Как сделать код быстрее?
Используем strings.Index
для коротких строк
if len(pattern) < 4 {
return []int{strings.Index(text, pattern)}
}
Предварительная аллокация массива результатов
positions := make([]int, 0, len(text)/len(pattern))
Уменьшение количества обращений к памяти. Используем байтовые срезы там, где не нужен rune
.
Компиляционные оптимизации. Собираем код с -gcflags="-B"
, убирая лишние проверки компилятора.
KMP — крутая штука, если надо быстро искать подстроки без лишних проверок. KMP полезен для поиска в логах, фильтрации контента и анализа больших файлов. Но если строка часто меняется, лучше смотреть в сторону Aho-Corasick или Suffix Array — всё зависит от задачи.
17 февраля в Otus пройдет открытый урок на тему «Топологическая сортировка вершин в ориентированном графе. Алгоритм Демукрона».
На этом уроке мы поработаем с ориентированным графом. Найти «исток» и «сток» в графе не сложно, а вот как разместить вершины в таком порядке, чтобы все дуги шли в одном направлении, то есть от вершин с меньшим номером к вершинам с большим номером? Такая топологическая сортировка вершин может потребоваться, например, для формирования календаря работ или плана компиляции файлов проекта. Лучше всего с этим справляется алгоритм Демукрона, который мы разберём и реализуем во время вебинара.
Записаться можно на странице курса «Алгоритмы и структуры данных».