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

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

Суффиксный массив: отсортировать все места, откуда можно читать

Коротко

Суффикс - это кусок текста от какой-то позиции до конца. Суффиксный массив - список начал всех суффиксов, отсортированный по самим суффиксам в алфавитном порядке. Он хранит n чисел, а не n строк. Любая подстрока текста - начало какого-то суффикса, поэтому все вхождения образца стоят в массиве подряд, и их границы находит двоичный поиск: около log₂ n шагов по m символов. На тексте из 10 000 символов образец в шесть символов находится за 27 кругов двоичного поиска - это меньше 250 сравнений символов вместо 12 455 у КМП. Плата - построение: сортировка срезами квадратична по памяти, удвоением - log n кругов по n · log n каждый.

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

Как думаешь, что готовить - образец или текст

Текст из 10 000 символов лежит на диске и не меняется. К нему приходит тысяча запросов: «встречается ли такая строка и где». КМП на каждый запрос читает текст целиком - 12 с половиной тысяч сравнений, на тысячу запросов двенадцать с половиной миллионов.

Все алгоритмы прошлых глав готовили образец. Прежде чем читать дальше, подумай: что можно один раз сделать с ТЕКСТОМ, чтобы каждый следующий запрос стоил сотню действий? Подсказка лежит на последних страницах любого справочника.

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

Представь алфавитный указатель в конце книги. Слова в нём отсортированы, поэтому нужное находится делением пополам: открыл середину, понял, раньше или позже, - и так несколько раз. Рядом со словом стоят номера страниц.

Такую заранее построенную вспомогательную структуру, по которой ищут вместо самого текста, называют индексом. Обычный указатель составлен по словам, и найти в нём можно только то, что автор счёл словом. Суффиксный массив - указатель, составленный по ВСЕМ местам текста, откуда можно начать читать. В нём нет ни одного слова, только номера позиций, но стоят они в таком порядке, как если бы текст, читаемый с каждой позиции, был словом словаря.

Механизм: суффиксы, их порядок и массив чисел

Суффикс текста - его кусок от позиции i до конца. У текста длины n суффиксов n. Сравниваются суффиксы как слова в словаре: по первому различающемуся символу, а если одна строка - начало другой, меньше та, что короче. Так же работает знак < между строками в Python. Суффиксный массив sa - номера начал суффиксов, выписанные в порядке возрастания самих суффиксов.

текст: banana

начало   суффикс              после сортировки:    sa     суффикс
  0      banana                                     5     a
  1      anana                                      3     ana
  2      nana                                       1     anana
  3      ana                                        0     banana
  4      na                                         4     na
  5      a                                          2     nana

Массив - это столбец sa: [5, 3, 1, 0, 4, 2]. Правый столбец хранить не нужно: суффикс с номером 3 - это t[3:], он и так лежит в тексте.

Теперь главное наблюдение. Любая подстрока - это начало какого-то суффикса: nan - начало суффикса nana. Значит, все вхождения образца - это суффиксы, которые с него начинаются. А в отсортированном списке такие суффиксы стоят подряд:

образец ana:    sa[1] = 3,  sa[2] = 1        вхождения с позиций 3 и 1
образец na:     sa[4] = 4,  sa[5] = 2        вхождения с позиций 4 и 2
образец nab:    таких суффиксов нет          образец не встречается

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

Правило

Суффиксный массив - перестановка чисел от 0 до n − 1, в которой суффикс t[sa[i]:] меньше суффикса t[sa[i + 1]:]. Все вхождения образца длины m занимают в массиве сплошной отрезок; его левая и правая границы находятся двумя двоичными поисками, каждый - не больше log₂ n + 1 кругов по m символов. Число вхождений равно длине отрезка и получается без их перечисления. Позиции внутри отрезка идут в порядке суффиксов, а не в порядке текста.

Разбор: строим срезами и ищем двоичным поиском

def suf_massiv(t):
    pary = []
    i = 0
    while i < len(t):
        pary.append([t[i:], i])       # суффикс и номер его начала
        i = i + 1
    pary.sort()                       # списки сравниваются по первому элементу
    sa = []
    for para in pary:
        sa.append(para[1])            # суффиксы больше не нужны, остаются номера
    return sa

def granica(t, sa, p, strogo):
    m = len(p)
    lo = 0
    hi = len(sa)
    krugov = 0
    while lo < hi:
        krugov = krugov + 1
        mid = (lo + hi) // 2
        kusok = t[sa[mid]:sa[mid] + m]            # первые m символов суффикса
        if kusok < p or (strogo and kusok == p):
            lo = mid + 1
        else:
            hi = mid
    return [lo, krugov]

granica с strogo = False возвращает первое место в массиве, где начало суффикса не меньше образца; с strogo = True - первое место, где оно строго больше. Между ними лежат вхождения.

sa = suf_massiv("banana")                     [5, 3, 1, 0, 4, 2]
granica("banana", sa, "ana", False)           [1, 3]     левая граница 1, три круга
granica("banana", sa, "ana", True)            [3, 3]     правая граница 3
вхождений 3 − 1 = 2,  позиции sa[1], sa[2] = 3, 1

Теперь на большом тексте: t = tekst(10000, 4, 1), образец - шесть символов t[5000:5006].

левая граница     8535     13 кругов
правая граница    8538     14 кругов
вхождений 3:   позиции (после сортировки по возрастанию) 1430, 4847, 5000

Ответ на вопрос из начала урока. 27 кругов, в каждом кусок из шести символов сравнивается с образцом один или два раза - меньше 250 сравнений символов вместо 12 455 у КМП. Образец ccc даёт отрезок от 6515 до 6674: 159 вхождений, и это число известно сразу, без единого шага по самим вхождениям.

Разбор: цена построения и удвоение

Построение срезами честное, но дорогое. Список pary держит все суффиксы целиком: n + (n − 1) + … + 1 символов, около n² / 2. Для текста в сто тысяч символов это пять миллиардов. Второй способ суффиксы не копирует вовсе.

Идея удвоения: сначала отсортировать суффиксы по первому символу. Потом по первым двум, потом по четырём, по восьми. Трюк в том, что первые 2k символов суффикса i - это первые k символов суффикса i и первые k символов суффикса i + k, а порядок по k символам уже известен для ВСЕХ суффиксов и записан числами-рангами. Значит, на каждом круге сортируются не строки, а пары чисел.

def suf_udvoenie(t):
    n = len(t)
    rang = []
    for c in t:
        rang.append(ord(c))                       # порядок по одному символу
    k = 1
    while True:
        klyuchi = []
        i = 0
        while i < n:
            vtoroj = -1                           # суффикс короче - идёт раньше
            if i + k < n:
                vtoroj = rang[i + k]
            klyuchi.append([rang[i], vtoroj, i])
            i = i + 1
        klyuchi.sort()                            # пары чисел, а не строки
        novyj = [0] * n
        r = 0
        j = 1
        while j < n:
            if klyuchi[j][0] != klyuchi[j - 1][0] or klyuchi[j][1] != klyuchi[j - 1][1]:
                r = r + 1                         # новая пара - новый ранг
            novyj[klyuchi[j][2]] = r
            j = j + 1
        rang = novyj
        if r == n - 1:
            break                                 # все ранги разные - порядок установлен
        k = k * 2
    sa = []
    for kl in klyuchi:
        sa.append(kl[2])
    return sa

Кругов не больше log₂ n, потому что длина сравниваемого начала удваивается. Каждый круг - сортировка n пар, то есть порядка n · log n действий; всего n · log n · log n, что записывают как n · log² n. Замер в шагах движка, случайный текст из четырёх букв:

  n       срезами     удвоением
 200       23 643       13 372
 400       87 672       36 073
 800      336 129       75 152

При удвоении n первый столбец растёт почти вчетверо, второй - в два-три раза. И это ещё льстит первому способу: движок берёт за сортировку n · log n шагов и не считает, сколько символов читает каждое сравнение суффиксов. На тексте из одинаковых букв оно читает суффиксы почти целиком.

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

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

Первое: сравнение с суффиксом целиком. Если в двоичном поиске сравнивать p с t[sa[mid]:], а не с первыми m его символами, то для образца an в слове banana суффиксы ana и anana окажутся «больше» образца, правая граница совпадёт с левой, и выйдет ноль вхождений вместо двух. Вхождение - это суффикс, который НАЧИНАЕТСЯ с образца, а не равен ему.

Второе: позиции приняты за отсортированные. Отрезок массива даёт 3, 1 для ana и 1430, 4847, 5000 только после отдельной сортировки. Если вхождения нужны в порядке текста, их надо упорядочить.

Третье: текст изменился, индекс остался. Символ, дописанный в конец, меняет КАЖДЫЙ суффикс и может переставить их: в тексте aa суффикс с позиции 1 (a) меньше суффикса с позиции 0 (aa), а после дописывания b наоборот - aab меньше ab. Суффиксный массив - структура для неизменного текста; после правки он перестраивается.

Четвёртое: ранги не пересчитаны, а только переставлены. В удвоении равные пары обязаны получить РАВНЫЙ ранг. Если раздать ранги просто по порядку после сортировки, на следующем круге случайный порядок среди равных будет принят за настоящий - и массив выйдет неверным на строках с повторами.

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

Первый сюжет - поиск по неизменному корпусу: архив документов, исходный код, справочник. Индекс строится один раз, запрос любой строки стоит m · log n, а не длину корпуса.

Второй - геномы. Запись генома человека - три миллиарда букв, прочитанных фрагментов - сотни миллионов, и каждый надо найти в геноме. Суффиксный массив и его сжатые потомки - основа программ, которые это делают. Четыре байта на позицию дают двенадцать гигабайт на один массив, и отсюда вырос целый раздел про сжатые индексы; к ним вернёмся в главе про преобразование Барроуза-Уилера.

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

Теперь сам

Выпиши суффиксный массив слова kokos. Затем по нему найди, сколько раз и с каких позиций входит ko.

Ответ.

начало   суффикс         после сортировки:    sa     суффикс
  0      kokos                                 0     kokos
  1      okos                                  2     kos
  2      kos                                   1     okos
  3      os                                    3     os
  4      s                                     4     s

Сравнение kokos и kos: первые два символа равны, третий - k против s, значит kokos меньше. Сравнение okos и os: k меньше s. Массив: [0, 2, 1, 3, 4].

С ko начинаются суффиксы на местах 0 и 1 массива. Вхождений два, позиции sa[0] = 0 и sa[1] = 2.

Главное

Суффиксный массив переносит работу с запроса на подготовку. Текст читается и сортируется один раз; после этого образец ищется не проходом по тексту, а двоичным поиском по n числам, и цена запроса зависит от длины образца и логарифма длины текста. Держится это на одном факте: подстрока - начало суффикса, а суффиксы с общим началом в отсортированном порядке стоят рядом. Массив не хранит ни одной строки, только порядок, - и этим он дешевле любой другой структуры, которая умеет то же самое.

Комментарии

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

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

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