Привет, Хабр!

Поисковые системы - без них не представить сегодняшний мир, они облегчают доступ к информации и улучшают пользовательский опыт. Однако, чтобы поисковая система работала эффективно, необходимы некоторые алгоритмы для обработки строк. Одни из них - Knuth-Morris-Pratt и Boyer-Moore.

Их мы и рассмотрим в сегодняшней статье, начнем с первого.

Алгоритм Knuth-Morris-Pratt (KMP)

Алгоритм Knuth-Morris-Pratt был разработан Дональдом Кнутом, Джеймсом Моррисом и Воном Праттом в 1977 году. Алгоритм стал революционным благодаря своей способности выполнять поиск за линейное время в худшем случае.

Алгоритм предназначен для поиска подстроки в строке. Он решает проблему наивного поиска, при котором возможны многократные сравнения одних и тех же символов, путем использования предварительно рассчитанной префикс-функции. Префикс-функция для строки P длины m определяется как массив \pi, где \pi[i] указывает длину наибольшего собственного префикса строки P[0..i], который одновременно является суффиксом этой строки.

Построение префикс-функции выполняется так:

  1. Инициализация: \pi[0] = 0.

  2. Для каждого символа P[i] (начиная с i = 1):

    • Устанавливаем k = \pi[i-1].

    • Пока k > 0 и P[k] \neq P[i], уменьшаем k до \pi[k-1].

    • Если P[k] == P[i], то k увеличивается на 1.

    • Устанавливаем \pi[i] = k.

Таким образом, префикс-функция позволяет понять, сколько символов из начала строки совпадают с её концом.

Пример построения префикс-функции для строки "ababaca":

  • P[0] = a : \pi[0] = 0

  • P[1] = b : \pi[1] = 0

  • P[2] = a : \pi[2] = 1(префикс "a" совпадает с суффиксом "a")

  • P[3] = b : \pi[3] = 2 (префикс "ab" совпадает с суффиксом "ab")

  • P[4] = a : \pi[4] = 3 (префикс "aba" совпадает с суффиксом "aba")

  • P[5] = c : \pi[5] = 0 (нет совпадений)

  • P[6] = a : \pi[6] = 1 (префикс "a" совпадает с суффиксом "a")

Процесс поиска

Процесс поиска строки P в строке T заключается в следующем:

  1. Инициализация: i = 0индекс для T,  j = 0 индекс для P.

  2. Повторяем, пока i < n длина T:

    • Если P[j] == T[i], увеличиваем i и j.

    • Если j == m длина P, значит, найдено совпадение:

      • Позиция совпадения: i - j.

      • Сбрасываем j на \pi[j-1] для продолжения поиска.

    • Если P[j] \neq T[i]:

      • Если j \neq 0, устанавливаем j = \pi[j-1].

      • Иначе, увеличиваем i.

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

Пример

Рассмотрим пример, где мы ищем подстроку "ababaca" в строке "bacbabababacaca":

  1. Находим префикс-функцию для "ababaca": 0, 0, 1, 2, 3, 0, 1.

  2. Применяем алгоритм поиска:

    • i = 0, j = 0 : "b" != "a", ( i++ ).

    • i = 1, j = 0 : "a" == "a", ( i++, j++ ).

    • i = 2, j = 1 : "c" != "b", ( j = 0 ), ( i++ ).

    • i = 3, j = 0 : "b" != "a", ( i++ ).

    • i = 4, j = 0 ): "a" == "a", ( i++, j++ ).

    • i = 5, j = 1 : "b" == "b", ( i++, j++ ).

    • i = 6, j = 2 : "a" == "a", ( i++, j++ ).

    • i = 7, j = 3 : "b" == "b", ( i++, j++ ).

    • i = 8, j = 4 : "a" == "a", ( i++, j++ ).

    • i = 9, j = 5 : "c" == "c", ( i++, j++ ).

    • i = 10, j = 6 : "a" == "a", ( i++, j++ ).

    • j = 7 : найдено совпадение на позиции i - j = 10 - 7 = 3 .

Пример в коде

Реализуем алгоритм на Питоне:

def kmp_search(pattern, text):
    def compute_prefix_function(pattern):
        m = len(pattern)
        pi = [0] * m
        k = 0

        for q in range(1, m):
            while k > 0 and pattern[k] != pattern[q]:
                k = pi[k - 1]
            if pattern[k] == pattern[q]:
                k += 1
            pi[q] = k

        return pi

    n = len(text)
    m = len(pattern)
    pi = compute_prefix_function(pattern)
    q = 0

    for i in range(n):
        while q > 0 and pattern[q] != text[i]:
            q = pi[q - 1]
        if pattern[q] == text[i]:
            q += 1
        if q == m:
            print(f"Pattern occurs at index {i - m + 1}")
            q = pi[q - 1]

# пример использования
text = "ABC ABCDAB ABCDABCDABDE"
pattern = "ABCDABD"
kmp_search(pattern, text)

Функция compute_prefix_function(pattern) функция вычисляет префикс-функцию для шаблона. Префикс-функция определяет для каждого индекса шаблона длину наибольшего собственного префикса, который также является суффиксом.

pi - массив, содержащий значения префикс-функции.

k - длина текущего наибольшего префикса, совпадающего с суффиксом.

Основная функция kmp_search(pattern, text):

n - длина текста, m - длина шаблона.

Вычисляем префикс-функцию для шаблона с помощью compute_prefix_function.

q - количество символов, совпадающих с началом шаблона.

Для каждого символа в тексте проверяем, совпадает ли он с текущим символом в шаблоне. Если да, увеличиваем q. Если q равно длине шаблона, значит найдено совпадение, и печатаем индекс начала совпадения в тексте. Затем сбрасываем q на значение из префикс-функции, чтобы продолжить поиск.

Переходим к следующему алгоритму.

Алгоритм Boyer-Moore

Алгоритм Boyer-Moore был разработан Робертом Бойером и Джейом Муром также в 1977 году.

Алгоритм использует две ключевые эвристики для ускорения поиска: правило плохого символа и правило хорошего суффикса. Эти эвристики позволяют алгоритму пропускать большие части строки при сравнении.

Правило плохого символа

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

Принцип работы:

  1. Создаётся таблица плохих символов для шаблона P. Эта таблица содержит позиции каждого символа в шаблоне, с учётом его последнего появления.

  2. Во время поиска, если символ в строке T не совпадает с символом в шаблоне P, шаблон смещается таким образом, чтобы несовпадающий символ в строке совпал с последним появлением этого символа в шаблоне.

Пример:

Пусть P = "EXAMPLE" и T = "HERE IS A SIMPLE EXAMPLE".

При первом несовпадении (например, на символе I в строке и Eв шаблоне, алгоритм смотрит в таблицу плохих символов и видит, что I отсутствует в шаблоне. Тогда шаблон смещается так, чтобы следующий символ строки совпал с последним символом шаблона.

Правило хорошего суффикса

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

Принцип работы:

  1. Создаётся таблица суффиксов для шаблона P. Эта таблица содержит информацию о позициях суффиксов в шаблоне, которые могут использоваться для смещения.

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

Пример:

Пусть P = "ABCDABD" и T = "ABC ABCDAB ABCDABCDABDE".

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

Пример использования

Рассмотрим строку T и шаблон P:

  • Шаг 1: Выравниваем шаблон с началом строки.

  • Шаг 2: Сравниваем символы с конца шаблона.

  • Шаг 3: При несовпадении применяем правило плохого символа или правило хорошего суффикса.

  • Шаг 4: Смещаем шаблон и повторяем процесс.

Пример:

Пусть P = "ABCDABD" и T = "ABC ABCDAB ABCDABCDABDE".

  1. Инициализация:

    • Таблица плохих символов: 'A': 6, 'B': 5, 'C': 4, 'D': 3, 'E': 1, 'L': 0

    • Таблица суффиксов: 1, 1, 1, 4, 4, 4, 7

  2. Поиск:

    • Сравниваем строку и шаблон.

    • При несовпадении находим подходящее смещение по таблицам.

    • Смещаем шаблон и продолжаем сравнение.

Алгоритм Boyer-Moore имеет среднюю сложность O(n/m) для практических случаев, где n — длина строки, а m — длина шаблона. Этот алгоритм очень хорош при работе с большими строками и сложными шаблонами.

Пример кода:

Реализуем аналогично предыдущему алгоритму на Питоне:

def boyer_moore_search(pattern, text):
    def bad_character_table(pattern):
        table = {}
        m = len(pattern)
        for i in range(m - 1):
            table[pattern[i]] = i
        return table

    def good_suffix_table(pattern):
        m = len(pattern)
        table = [0] * m
        last_prefix_position = m

        for i in range(m - 1, -1, -1):
            if is_prefix(pattern, i + 1):
                last_prefix_position = i + 1
            table[m - 1 - i] = last_prefix_position - i + m - 1

        for i in range(m - 1):
            slen = suffix_length(pattern, i)
            table[slen] = m - 1 - i + slen

        return table

    def is_prefix(pattern, p):
        m = len(pattern)
        for i in range(p, m):
            if pattern[i] != pattern[i - p]:
                return False
        return True

    def suffix_length(pattern, p):
        m = len(pattern)
        length = 0
        i = p
        j = m - 1
        while i >= 0 and pattern[i] == pattern[j]:
            length += 1
            i -= 1
            j -= 1
        return length

    n = len(text)
    m = len(pattern)
    if m == 0:
        return []

    bad_char_table = bad_character_table(pattern)
    good_suffix_table = good_suffix_table(pattern)

    s = 0
    while s <= n - m:
        j = m - 1
        while j >= 0 and pattern[j] == text[s + j]:
            j -= 1
        if j < 0:
            print(f"Pattern occurs at index {s}")
            s += good_suffix_table[0]
        else:
            bad_char_shift = j - bad_char_table.get(text[s + j], -1)
            good_suffix_shift = good_suffix_table[j]
            s += max(bad_char_shift, good_suffix_shift)

# пример
text = "HERE IS A SIMPLE EXAMPLE"
pattern = "EXAMPLE"
boyer_moore_search(pattern, text)

Функция bad_character_table(pattern) создаёт таблицу плохих символов для шаблона. Таблица содержит индексы последнего появления каждого символа в шаблоне, кроме последнего символа.

Функция good_suffix_table(pattern) создаёт таблицу хороших суффиксов для шаблона. Таблица содержит смещения, которые определяются для каждого суффикса шаблона.

Вспомогательные функции is_prefix(pattern, p) и suffix_length(pattern, p):

is_prefix проверяет, является ли подстрока от позиции p до конца шаблона префиксом всего шаблона.

suffix_length определяет длину наибольшего суффикса шаблона, который заканчивается в позиции p.

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


Как можно заметитиь, эти алгоритмы очень мощные и каждые предлагают уникальные подходы и преимущества.

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

А вот алгоритм Boyer-Moore использует две ключевые эвристики: правило плохого символа и правило хорошего суффикса, которые позволяют значительно ускорить процесс поиска за счёт пропуска множества символов.

Выбор между KMP и Boyer-Moore зависит от конкретного случая использования и требований к производительности.

Развивать алгоритмическое мышление и учиться увеличивать производительность программ можно на онлайн-курсе «Алгоритмы и структуры данных» в Отус.

Комментарии (1)


  1. wataru
    20.07.2024 09:33
    +1

    Поисковые системы не используют ни один из перечисленных вами методов. Или вы думаете, что на каждый запрос они ищут подстроку по всему интернету?