1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

# теория · шаг 2 из 7

LCP: сколько общего у соседей по суффиксному массиву

Коротко

Массив LCP записывает для каждого места суффиксного массива, сколько первых символов совпадает у суффикса на этом месте и у его соседа сверху. Для banana это 0 1 3 0 0 2. Вместе с суффиксным массивом он отвечает на вопросы о повторах без всякого поиска: самая длинная повторяющаяся подстрока - это максимум LCP; число различных подстрок - n(n + 1) / 2 минус сумма LCP; наибольшая общая подстрока двух строк ищется по склейке. Алгоритм Касаи строит массив за линейное время: на тексте из тысячи букв a он делает 999 сравнений вместо 499 500 по определению.

Если ты посчитаешь LCP для kokos, найдёшь по нему число различных подстрок и объяснишь, почему алгоритм Касаи идёт по суффиксам в порядке текста, - листай до «Теперь сам».

Как думаешь, сколько пар придётся сравнить

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

Прежде чем читать дальше, подумай: суффиксный массив этого текста уже построен. Какие пары позиций ДОСТАТОЧНО сравнить, чтобы не пропустить самый длинный повтор?

На что это похоже

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

Причина в сортировке. Между двумя словами с общим началом перех могут стоять только слова с тем же началом: любое другое слово оказалось бы либо выше обоих, либо ниже. Значит, общее начало двух далёких слов не длиннее, чем у любых двух соседей между ними. Самая длинная общность всегда достигается на соседях.

Механизм: массив общих начал

LCP (от английского «самое длинное общее начало») - массив той же длины, что суффиксный: lcp[i] равно числу первых символов, общих у суффиксов sa[i − 1] и sa[i]. У верхнего суффикса соседа нет, lcp[0] = 0.

место    sa    суффикс     lcp    общее начало с соседом сверху
  0       5    a            0     -
  1       3    ana          1     a
  2       1    anana        3     ana
  3       0    banana       0
  4       4    na           0
  5       2    nana         2     na

Что из этой таблицы читается сразу:

максимум lcp = 3, на месте 2        самый длинный повтор - ana (позиции 3 и 1)
сумма lcp = 6                       различных подстрок 6 · 7 / 2 − 6 = 15

Вторая строка требует объяснения. Суффикс длины d даёт d подстрок - все свои начала. Всего суффиксы дают 1 + 2 + … + n = n(n + 1) / 2 подстрок, но с повторами. Начало суффикса уже встречалось раньше ровно тогда, когда оно же является началом соседа сверху, - а таких начал lcp[i] штук. Вычитаем повторы - остаются различные.

Правило

Общее начало любых двух суффиксов равно минимуму LCP на отрезке массива между ними; поэтому самая длинная общность всегда достигается на соседях. Отсюда: самая длинная повторяющаяся подстрока имеет длину max lcp; различных подстрок n(n + 1) / 2 − Σ lcp (знак Σ - сумма по всем местам массива). Алгоритм Касаи считает LCP за линейное время, обходя суффиксы в порядке ТЕКСТА: если у суффикса i с его соседом сверху общих символов h, то у суффикса i + 1 со своим соседом их не меньше h − 1.

Разбор: алгоритм Касаи на banana

По определению LCP считается так: для каждой пары соседей сравнивать символы с начала. На тексте из одинаковых букв каждая пара совпадает почти на всю длину - это квадрат.

Наблюдение Касаи. Пусть суффикс i и его сосед сверху, суффикс j, совпадают на h символов. Отрежем у обоих первый символ: получатся суффиксы i + 1 и j + 1, и они совпадают на h − 1 символ. Суффикс j + 1 при этом стоит в массиве выше суффикса i + 1. Значит, сосед суффикса i + 1 сверху совпадает с ним не меньше чем на h − 1: он зажат между ними. Начинать сравнение с нуля не нужно. При h = 0 гарантировать нечего, и сравнение честно идёт с начала - за этим в коде проверка if h > 0.

def kasai(t, sa):
    n = len(t)
    mesto = [0] * n                   # mesto[i] - на каком месте массива стоит суффикс i
    i = 0
    while i < n:
        mesto[sa[i]] = i
        i = i + 1
    lcp = [0] * n
    h = 0
    sravn = 0
    i = 0
    while i < n:                      # суффиксы в порядке текста
        if mesto[i] > 0:
            j = sa[mesto[i] - 1]      # сосед сверху
            while i + h < n and j + h < n:
                sravn = sravn + 1
                if t[i + h] != t[j + h]:
                    break
                h = h + 1
            lcp[mesto[i]] = h
            if h > 0:
                h = h - 1             # следующему суффиксу гарантировано h - 1
        else:
            h = 0                     # у верхнего суффикса соседа нет
        i = i + 1
    return [lcp, sravn]

Проход по banana, sa = [5, 3, 1, 0, 4, 2]:

i   суффикс   место   сосед сверху    h на входе   сравнения            lcp    h на выходе
0   banana      3     anana (1)           0        b ≠ a                 0         0
1   anana       2     ana (3)             0        a = a, n = n, a = a   3         2
2   nana        5     na (4)              2        нет: сосед кончился   2         1
3   ana         1     a (5)               1        нет: сосед кончился   1         0
4   na          4     banana (0)          0        n ≠ b                 0         0
5   a           0     нет                 -        -                     -         0

Пять сравнений на весь массив. Строки i = 2 и i = 3 получили свои значения готовыми: два и один символ гарантированы предыдущим шагом, а продлевать некуда.

Разбор: три вопроса без поиска и замер

Текст t = tekst(10000, 4, 1) из прошлого урока, суффиксный массив построен.

Первый вопрос - самый длинный повтор. Максимум LCP равен 11: кусок aaadcbbaabb стоит в тексте с позиций 1988 и 9412. Ответ на вопрос из начала урока: сравнить достаточно n − 1 пару соседей по массиву, а не пятьдесят миллионов пар.

Второй - число различных подстрок. Всего подстрок 10000 · 10001 / 2 = 50 005 000, сумма LCP 58 255, различных 49 946 745. Почти все подстроки случайного текста различны; повторяются только короткие.

Третий - наибольшая общая подстрока двух строк. Склеиваем их через символы, которых нет ни в одной: a + "#" + b + "$". Строим массив и LCP для склейки и ищем максимум LCP среди соседей, из которых один суффикс начинается в первой строке, а другой - во второй. Суффикс начинается в первой строке, если его начало sa[i] меньше длины a. Суффиксы, начинающиеся с разделителей, в максимум не попадают: их общее начало с соседями равно нулю.

banana  и  ananas      →   anana   (5 символов)
kolobok и  kolokol     →   kolo    (4 символа)

Динамическое программирование из раздела 15 решает ту же задачу таблицей n × m; здесь цена - построение массива по склейке.

Теперь цена самого LCP, в сравнениях символов:

текст                    по определению     Касаи
tekst(10000, 4, 1)           68 248         19 989
"a" * 1000                  499 500            999
"ab" * 500                  498 502            999

«По определению» - это та же программа, в которой перед каждым сравнением стоит h = 0.

На случайном тексте общие начала короткие, и счёт по определению терпим. На повторах он квадратичен, а Касаи остаётся под 2n: h растёт только при успешном сравнении, убывает не больше чем на единицу за шаг, и выше n подняться не может.

Что ломается без этого

Первое: h = 0 вместо h − 1. Каждое сравнение начинается с нуля. Ответ останется верным, а цена вернётся к квадрату: на тысяче букв a - полмиллиона сравнений. Ни один тест на правильность этого не покажет.

Второе: обход в порядке массива. Если перебирать суффиксы по местам массива, а не по позициям текста, гарантия h − 1 не действует: соседние места массива - это не «тот же суффикс без первого символа». Значение, оставшееся от прошлого шага, окажется больше настоящего, и LCP выйдет завышенным.

Третье: склейка без разделителей. Строки xa и bxab склеены как xabxab. Суффиксы с позиций 0 и 3 совпадают на три символа - xab, - и один из них начинается в первой строке, другой во второй. Но в первой строке всего два символа: «общая подстрока» перешагнула через стык. Разделители обязаны быть чужими для обеих строк и разными: с одинаковыми склейка x#x# даёт суффиксам x#x# и x# общее начало x# длиной два, хотя общая подстрока - один символ.

Четвёртое: максимум по всем соседям при поиске общей подстроки. Если не проверять, что соседи из РАЗНЫХ строк, найдётся повтор внутри одной строки: для banana и xyz ответом станет ana.

Пятое: сдвиг индекса. В одних книгах lcp[i] - общее начало с соседом СВЕРХУ, в других - СНИЗУ. Формулы те же, индексы сдвинуты на единицу. Смешать две договорённости в одной программе - самый частый источник ошибок на единицу в этой теме.

Зачем это в работе

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

Второй - общность двух версий. Самые длинные общие куски старого и нового файла - основа двоичных заплаток: новый файл записывается как «скопируй отсюда столько-то, допиши вот это».

Третий - ускорение самого поиска. С таблицей LCP и структурой для минимума на отрезке (разреженная таблица из раздела про деревья) общее начало ЛЮБЫХ двух суффиксов получается за одно действие. На этом построены поиск образца за m + log n вместо m · log n и десятки задач про палиндромы, повторы и периоды.

Теперь сам

Суффиксный массив слова kokos - [0, 2, 1, 3, 4]. Посчитай LCP. Какая подстрока повторяется и сколько в слове различных подстрок?

Ответ.

место    sa    суффикс    lcp    общее начало с соседом сверху
  0       0    kokos       0     -
  1       2    kos         2     ko
  2       1    okos        0
  3       3    os          1     o
  4       4    s           0

Максимум LCP равен двум: самая длинная повторяющаяся подстрока - ko, она начинается в позициях 0 и 2. Сумма LCP равна трём. Всего подстрок 5 · 6 / 2 = 15, различных 15 − 3 = 12. Три «лишних» - это k, ko и o: каждая встречается в слове дважды и посчитана на один раз больше, чем нужно.

Главное

Суффиксный массив ставит рядом суффиксы с общим началом, а LCP записывает, насколько оно длинное. Вдвоём они превращают вопросы о повторах в чтение массива: самый длинный повтор - максимум, число различных подстрок - формула с суммой, общая подстрока двух строк - максимум по соседям из разных строк. Алгоритм Касаи строит LCP за линейное время на одном наблюдении: если отрезать первый символ, общее начало укорачивается не больше чем на единицу. Поэтому идти надо по суффиксам в порядке текста, а не массива.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.