Алгоритмы поиска подстроки на JavaScript

от автора

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

Как говорит Википедия “Поиск подстроки в строке — одна из простейших задач поиска информации”, но это не совсем так, ниже я расскажу про разные алгоритмы решения и покажу примеры их реализации. Начнем!

“Алгоритм грубой силы” или “Алгоритм простого поиска”

Алгоритм грубой силы — это простой алгоритм поиска подстроки, он заключается в последовательном переборе всех символов строки и проверки совпадения с искомой подстрокой. Если совпадение не найдено, то алгоритм сдвигает искомую подстроку на один символ вправо и начинает поиск снова.

const findStr = (haystack, needle) => {   const m = needle.length   const n = haystack.length    for (let windowStart = 0; windowStart <= n - m; windowStart++) {     for (let i = 0; i < m; i++) {       if (needle[i] !== haystack[windowStart + i]) {         break       }       if (i === m - 1) {         return windowStart       }     }   }    return -1 }

Функция поиска состоит из цикла, который ограничивается до n — m (длина искомой строки — длина строки в которой ищем), что позволяет обойти проблему, если строка имеет меньше символов чем искомая строка и избежать лишних проверок. И вложенного цикла, который имеет в себе логику сравнения символов.

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

Вторая проверка срабатывает в случае, если мы дошли до конца, и все значения совпали, то мы возвращаем индекс первого символа искомого слова в строке. В противном случае возвращаем -1, что означает что строка не найдена.

Плюсы использования:

  • Простота реализации и понимания;

  • Работает для любых типов данных, включая текстовые, числовые и т.д.

Минусы использования:

  • Низкая производительность, особенно на больших строках и при поиске длинных подстрок;

  • Использование большого количества времени и памяти.

“Алгоритм Робина — Карпа”

Алгоритм Робина — Карпа является одним из эффективных алгоритмов поиска, он основан на концепции хеширования.

Принцип работы алгоритма заключается в следующем:

  • Сначала происходит вычисление хешей для первого окна тестов строки и подстроки;

  • Далее происходит последовательное сравнение хешей. Если хеши совпадают, то происходит дополнительное сравнение посимвольно. Если совпадение найдено — алгоритм завершается;

  • Если совпадение не найдено- вычисляется хеш-значение следующей подстроки путем сдвига окна на один символ вправо и вычисления хеш-значения для новой подстроки;

const findStr = (haystack, needle) => {   const haystackLen = haystack.length   const needleLen = needle.length    const prime = 101   const d = 256    let haystackHash = 0   let needleHash = 0   let fastHash = 1    // Цикл 1   for (let i = 0; i < needleLen - 1; i++) {     fastHash = (fastHash * d) % prime   }    // Цикл 2   for (let i = 0; i < needleLen; i++) {     haystackHash = (d * haystackHash + haystack.charCodeAt(i)) % prime     needleHash = (d * needleHash + needle.charCodeAt(i)) % prime   }    // Цикл 3   for (let i = 0; i <= haystackLen - needleLen; i++) {     if (needleHash === haystackHash) {       let j        for (j = 0; j < needleLen; j++) {         if (haystack[i + j] !== needle[j]) {           break         }       }        if (j === needleLen) {         return i       }     }      if (i < haystackLen - needleLen) {       haystackHash = (d * (haystackHash - haystack.charCodeAt(i) * fastHash) + haystack.charCodeAt(i + needleLen)) % prime          if (haystackHash < 0) {         haystackHash = (haystackHash + prime)       }     }   }    return -1 }

Теперь подробнее расскажу про функцию поиска.

1-й цикл используется для вычисления значения, которое используется для ускорения вычисления хешей, она работает по алгоритму fastHash = d^(needleLen-1) % prime.
2-й цикл просто вычисляет хеш для строки и подстроки.

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

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

Плюсы использования:

  • Быстрый поиск подстроки в среднем случае, особенно для длинных строк и подстрок;

  • Возможность эффективного поиска нескольких подстрок сразу, используя одинаковый хеш-код для всех искомых подстрок;

  • Хеширование позволяет пропустить множество неподходящих подстрок, сокращая количество необходимых сравнений;

Минусы использования:

  • В некоторых случаях, например, при использовании простых хеш-функций, возможны ложные совпадения, которые необходимо дополнительно проверять посимвольно;

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

  • Если встречаются слишком маленькие или слишком большие хеш-значения, то может возникнуть проблема переполнения, которую необходимо обрабатывать;

Подробнее можно почитать тут

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

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

  • Поиск вхождений гена в последовательности ДНК;

  • Автодополнение в поисковых движках;

  • Распознавание сигналов в цифровой обработке сигналов;

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

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

Вот пример реализации префикс функции:

const prefix = (str) => {   const n = str.length   const p = Array(n).fill(0)    let i = 1, j = 0    while (i < str.length) {     if (str[i] === str[j]) {       p[i] = j + 1       i ++       j ++     } else {       if (j === 0) {         p[i] = 0         i ++       } else {         j = p[j - 1]       }     }   }    return p }

Пример: для строки «abcabcd» префикс функция будет выглядеть следующим образом:
[0, 0, 0, 1, 2, 3, 0], где первые три элемента равны 0, потому что ни один префикс не является суффиксом первых трех символов;
1, 2 и 3, потому что «a», «ab» и «abc» являются префиксами, которые также являются суффиксами; и наконец, последний элемент равен 0, потому что «abcabc» является префиксом строки «abcabcd», но не является суффиксом последнего элемента «d».

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

const findStr = (str, searchString) => {   const searchStringPrefix = prefix(searchString)   let i = 0, j = 0    while (i < str.length) {     if (str[i] === searchString[j]) {       j ++       i ++        if (j === searchString.length) {         return i - searchString.length       }     } else {       if (j > 0) {         j = searchStringPrefix[j - 1]       } else {         i ++       }     }   }    if (i === str.length && j !== searchString.length) {     return -1   } }

Плюсы использования:

  • Линейное время выполнения: O(n+m), где n — длина строки, m — длина подстроки;

  • Эффективно обрабатывает большие тексты с множеством вхождений;

Минусы использования:

  • Требует заранее построенной таблицы префиксов, что может занять O(m) времени и O(m) памяти;

  • Может быть сложным для понимания и реализации, особенно для новичков в программировании;

Также хочу отметить, что КМП дает выигрыш только в случае если в таблицы префиксов есть положительные значения. Лишь в этом случае указатель будет сдвигается более чем на единицу. Но даже в случае “всех нулей” в таблице поиск работает достаточно неплохо.

Подробнее про то, как реализована префикс-функция и сам алгоритм можно посмотреть тут

Уже перед публикацией нашел несколько статей про алгоритмы поиска, где автор очень подробно описал, как они работают и привел примеры скорости работы:

Алгоритм Кнута — Морриса — Пратта
Алгоритм Бойера — Мура
Алгоритм Рабина — Карпа

Надеюсь, эта статья была для Вас полезна. Спасибо!


ссылка на оригинал статьи https://habr.com/ru/articles/733998/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *