Перейти до основного вмісту

Суфіксний автомат

Суфіксний автомат — це потужна структура даних, яка дозволяє розв'язувати багато задач, пов'язаних із рядками.

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

Інтуїтивно суфіксний автомат можна розуміти як стиснуту форму всіх підрядків заданого рядка. Вражає те, що суфіксний автомат містить усю цю інформацію в дуже стиснутому вигляді. Для рядка довжини nn він потребує лише O(n)O(n) пам'яті. Більше того, його також можна побудувати за O(n)O(n) часу (якщо вважати розмір kk алфавіту константою), інакше і пам'ять, і часова складність становитимуть O(nlogk)O(n \log k).

Лінійність розміру суфіксного автомата вперше виявили 1983 року Blumer та інші, а 1985 року Crochemore та Blumer представили перші лінійні алгоритми його побудови.

Коли підходить цей алгоритм?
  • Багато запитів про підрядки одного тексту (входження, кількість різних підрядків, найдовший спільний підрядок) за лінійний час? (якщо задача суто лексикографічна на суфіксах → Суфіксний масив)
  • Структуру будуєте по одному тексту, а потім багаторазово запитуєте? (якщо багато взірців шукаєте у тексті за один прохід → Ахо-Корасік)
  • Лінійних методів (КМП/хеші) недостатньо для ваших підрядкових запитів? (для простого пошуку одного взірця → КМП)

Означення суфіксного автомата

Суфіксний автомат для заданого рядка ss — це мінімальний DFA (детермінований скінченний автомат / детермінована скінченна машина станів), який приймає всі суфікси рядка ss.

Іншими словами:

  • Суфіксний автомат — це орієнтований ациклічний граф. Вершини називаються станами, а ребра — переходами між станами.
  • Один зі станів t0t_0 є початковим станом, і він має бути джерелом графа (усі інші стани досяжні з t0t_0).
  • Кожен перехід позначено деяким символом. Усі переходи, що виходять зі стану, мають мати різні позначки.
  • Один або декілька станів позначено як термінальні стани. Якщо ми вирушимо з початкового стану t0t_0 і рухатимемося переходами до термінального стану, то позначки пройдених переходів мають складати один із суфіксів рядка ss. Кожен суфікс рядка ss має бути виразним через деякий шлях із t0t_0 до термінального стану.
  • Суфіксний автомат містить мінімальну кількість вершин серед усіх автоматів, що задовольняють описані вище умови.

Властивість підрядків

Найпростіша і найважливіша властивість суфіксного автомата полягає в тому, що він містить інформацію про всі підрядки рядка ss. Будь-який шлях, що починається в початковому стані t0t_0, якщо записати позначки переходів, утворює підрядок рядка ss. І навпаки, кожен підрядок рядка ss відповідає певному шляху, що починається в t0t_0.

Щоб спростити пояснення, будемо казати, що підрядок відповідає цьому шляху (який починається в t0t_0, і позначки якого складають цей підрядок). І навпаки, будемо казати, що будь-який шлях відповідає рядку, який складають його позначки.

До стану може вести один або декілька шляхів. Тому будемо казати, що стан відповідає множині рядків, які відповідають цим шляхам.

Приклади побудованих суфіксних автоматів

Тут ми наведемо кілька прикладів суфіксних автоматів для кількох простих рядків.

Початковий стан ми позначатимемо синім, а термінальні стани — зеленим.

Для рядка s= ""s =~ \text{""}:

Суфіксний автомат для ""

Для рядка s= "a"s =~ \text{"a"}:

Суфіксний автомат для "a"

Для рядка s= "aa"s =~ \text{"aa"}:

Суфіксний автомат для "aa"

Для рядка s= "ab"s =~ \text{"ab"}:

Суфіксний автомат для "ab"

Для рядка s= "aba"s =~ \text{"aba"}:

Суфіксний автомат для "aba"

Для рядка s= "abb"s =~ \text{"abb"}:

Суфіксний автомат для "abb"

Для рядка s= "abbb"s =~ \text{"abbb"}:

Суфіксний автомат для "abbb"

Побудова за лінійний час

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

Кінцеві позиції endposendpos

Розглянемо будь-який непорожній підрядок tt рядка ss. Через endpos(t)endpos(t) ми позначатимемо множину всіх позицій у рядку ss, в яких закінчуються входження tt. Наприклад, для рядка "abcbc"\text{"abcbc"} маємо endpos("bc")={2,4}endpos(\text{"bc"}) = \{2, 4\}.

Два підрядки t1t_1 і t2t_2 ми називатимемо endposendpos-еквівалентними, якщо їхні множини кінцевих позицій збігаються: endpos(t1)=endpos(t2)endpos(t_1) = endpos(t_2). Таким чином, усі непорожні підрядки рядка ss можна розбити на кілька класів еквівалентності відповідно до їхніх множин endposendpos.

Виявляється, що в суфіксному автоматі endposendpos-еквівалентні підрядки відповідають одному й тому самому стану. Іншими словами, кількість станів у суфіксному автоматі дорівнює кількості класів еквівалентності серед усіх підрядків плюс початковий стан. Кожен стан суфіксного автомата відповідає одному або кільком підрядкам, що мають однакове значення endposendpos.

Пізніше ми опишемо алгоритм побудови, користуючись цим припущенням. Тоді ми побачимо, що всі необхідні властивості суфіксного автомата, окрім мінімальності, виконуються. А мінімальність випливає з теореми Нероуда (яку ми не доводитимемо в цій статті).

Зробимо кілька важливих спостережень щодо значень endposendpos:

Лема 1: Два непорожні підрядки uu і ww (де length(u)length(w)length(u) \le length(w)) є endposendpos-еквівалентними тоді й лише тоді, коли рядок uu зустрічається в ss лише у вигляді суфікса рядка ww.

Доведення очевидне. Якщо uu і ww мають однакові значення endposendpos, то uu є суфіксом ww і з'являється в ss лише у вигляді суфікса ww. А якщо uu є суфіксом ww і з'являється в ss лише у вигляді суфікса, то значення endposendpos рівні за означенням.

Лема 2: Розглянемо два непорожні підрядки uu і ww (де length(u)length(w)length(u) \le length(w)). Тоді їхні множини endposendpos або зовсім не перетинаються, або endpos(w)endpos(w) є підмножиною endpos(u)endpos(u). А це залежить від того, чи є uu суфіксом ww, чи ні.

{endpos(w)endpos(u)якщо u є суфіксом wendpos(w)endpos(u)=інакше\begin{cases} endpos(w) \subseteq endpos(u) & \text{якщо } u \text{ є суфіксом } w \\\\ endpos(w) \cap endpos(u) = \emptyset & \text{інакше} \end{cases}

Доведення: Якщо множини endpos(u)endpos(u) і endpos(w)endpos(w) мають принаймні один спільний елемент, то рядки uu і ww обидва закінчуються в цій позиції, тобто uu є суфіксом ww. Але тоді при кожному входженні ww також з'являється підрядок uu, а це означає, що endpos(w)endpos(w) є підмножиною endpos(u)endpos(u).

Лема 3: Розглянемо endposendpos-клас еквівалентності. Відсортуємо всі підрядки цього класу за спаданням довжини. Тоді в отриманій послідовності кожен підрядок буде на один коротшим за попередній і водночас буде суфіксом попереднього. Іншими словами, в одному класі еквівалентності коротші підрядки фактично є суфіксами довших підрядків і набувають усіх можливих довжин на певному проміжку [x;y][x; y].

Доведення: Зафіксуємо деякий endposendpos-клас еквівалентності. Якщо він містить лише один рядок, то лема очевидно правильна. Тепер припустимо, що кількість рядків у класі більша за один.

Згідно з Лемою 1, два різні endposendpos-еквівалентні рядки завжди такі, що коротший є власним суфіксом довшого. Отже, у класі еквівалентності не може бути двох рядків однакової довжини.

Позначимо через ww найдовший, а через uu найкоротший рядок у класі еквівалентності. Згідно з Лемою 1, рядок uu є власним суфіксом рядка ww. Розглянемо тепер будь-який суфікс ww з довжиною в проміжку [length(u);length(w)][length(u); length(w)]. Легко бачити, що цей суфікс також міститься в тому самому класі еквівалентності. Адже цей суфікс може з'являтися в рядку ss лише у вигляді суфікса ww (оскільки й коротший суфікс uu зустрічається в ss лише у вигляді суфікса ww). Отже, згідно з Лемою 1, цей суфікс є endposendpos-еквівалентним рядку ww.

Суфіксні посилання linklink

Розглянемо деякий стан vt0v \ne t_0 в автоматі. Як ми знаємо, стан vv відповідає класу рядків з однаковими значеннями endposendpos. І якщо ми позначимо через ww найдовший із цих рядків, то всі інші рядки є суфіксами ww.

Ми також знаємо, що кілька перших суфіксів рядка ww (якщо розглядати суфікси в порядку спадання їхньої довжини) усі містяться в цьому класі еквівалентності, а всі інші суфікси (принаймні один — порожній суфікс) перебувають у деяких інших класах. Позначимо через tt найбільший такий суфікс і зробимо до нього суфіксне посилання.

Іншими словами, суфіксне посилання link(v)link(v) веде до стану, який відповідає найдовшому суфіксу рядка ww, що перебуває в іншому endposendpos-класі еквівалентності.

Тут ми вважаємо, що початковий стан t0t_0 відповідає власному класу еквівалентності (що містить лише порожній рядок), і для зручності покладаємо endpos(t0)={1,0,,length(s)1}endpos(t_0) = \{-1, 0, \dots, length(s)-1\}.

Лема 4: Суфіксні посилання утворюють дерево з коренем t0t_0.

Доведення: Розглянемо довільний стан vt0v \ne t_0. Суфіксне посилання link(v)link(v) веде до стану, що відповідає рядкам зі строго меншою довжиною (це випливає з означення суфіксних посилань і з Леми 3). Тому, рухаючись суфіксними посиланнями, ми рано чи пізно прийдемо до початкового стану t0t_0, який відповідає порожньому рядку.

Лема 5: Якщо ми побудуємо дерево за допомогою множин endposendpos (за правилом, що множина батьківського вузла містить множини всіх дітей як підмножини), то ця структура збігатиметься з деревом суфіксних посилань.

Доведення: Те, що ми можемо побудувати дерево за допомогою множин endposendpos, випливає безпосередньо з Леми 2 (будь-які дві множини або не перетинаються, або одна міститься в іншій).

Розглянемо тепер довільний стан vt0v \ne t_0 і його суфіксне посилання link(v)link(v). З означення суфіксного посилання і з Леми 2 випливає, що

endpos(v)endpos(link(v)),endpos(v) \subseteq endpos(link(v)),

що разом з попередньою лемою доводить твердження: дерево суфіксних посилань по суті є деревом множин endposendpos.

Ось приклад дерева суфіксних посилань у суфіксному автоматі, побудованому для рядка "abcbc"\text{"abcbc"}. Вузли позначено найдовшим підрядком із відповідного класу еквівалентності.

Суфіксний автомат для "abcbc" із суфіксними посиланнями

Підсумок

Перш ніж переходити до самого алгоритму, підсумуємо накопичені знання і введемо кілька допоміжних позначень.

  • Підрядки рядка ss можна розбити на класи еквівалентності відповідно до їхніх кінцевих позицій endposendpos.
  • Суфіксний автомат складається з початкового стану t0t_0, а також з одного стану для кожного endposendpos-класу еквівалентності.
  • Кожному стану vv відповідає один або декілька підрядків. Через longest(v)longest(v) ми позначатимемо найдовший такий рядок, а через len(v)len(v) — його довжину. Через shortest(v)shortest(v) ми позначатимемо найкоротший такий підрядок, а його довжину — через minlen(v)minlen(v). Тоді всі рядки, що відповідають цьому стану, є різними суфіксами рядка longest(v)longest(v) і мають усі можливі довжини на проміжку [minlen(v);len(v)][minlen(v); len(v)].
  • Для кожного стану vt0v \ne t_0 суфіксне посилання визначається як посилання, що веде до стану, який відповідає суфіксу рядка longest(v)longest(v) довжини minlen(v)1minlen(v) - 1. Суфіксні посилання утворюють дерево з коренем у t0t_0, і водночас це дерево утворює відношення включення між множинами endposendpos.
  • Ми можемо виразити minlen(v)minlen(v) для vt0v \ne t_0 через суфіксне посилання link(v)link(v) так:
minlen(v)=len(link(v))+1minlen(v) = len(link(v)) + 1
  • Якщо ми почнемо з довільного стану v0v_0 і рухатимемося суфіксними посиланнями, то рано чи пізно досягнемо початкового стану t0t_0. При цьому ми отримаємо послідовність неперетинних проміжків [minlen(vi);len(vi)][minlen(v_i); len(v_i)], які в об'єднанні утворюють неперервний проміжок [0;len(v0)][0; len(v_0)].

Алгоритм

Тепер ми можемо перейти до самого алгоритму. Алгоритм буде онлайновим, тобто ми додаватимемо символи рядка по одному і на кожному кроці відповідно модифікуватимемо автомат.

Щоб досягти лінійного споживання пам'яті, у кожному стані ми зберігатимемо лише значення lenlen, linklink і список переходів. Ми не будемо позначати термінальні стани (але пізніше покажемо, як розставити ці позначки після побудови суфіксного автомата).

Спочатку автомат складається з одного стану t0t_0, який матиме індекс 00 (решта станів отримають індекси 1,2,1, 2, \dots). Для зручності призначаємо йому len=0len = 0 і link=1link = -1 (1-1 буде фіктивним, неіснуючим станом).

Тепер уся задача зводиться до реалізації процесу додавання одного символу cc до кінця поточного рядка. Опишемо цей процес:

  • Нехай lastlast — стан, що відповідає всьому рядку до додавання символу cc. (Спочатку покладаємо last=0last = 0, і відповідно змінюватимемо lastlast на останньому кроці алгоритму.)

  • Створюємо новий стан curcur і призначаємо йому len(cur)=len(last)+1len(cur) = len(last) + 1. Значення link(cur)link(cur) на цей момент невідоме.

  • Тепер виконуємо таку процедуру: Починаємо зі стану lastlast. Доки немає переходу за буквою cc, ми додаватимемо перехід до стану curcur і йтимемо суфіксним посиланням. Якщо в якийсь момент перехід за буквою cc уже існує, то ми зупиняємося і позначаємо цей стан через pp.

  • Якщо ми не знайшли такого стану pp, тобто дійшли до фіктивного стану 1-1, то ми можемо просто покласти link(cur)=0link(cur) = 0 і завершити.

  • Припустимо тепер, що ми знайшли стан pp, з якого існує перехід за буквою cc. Стан, до якого веде цей перехід, ми позначимо через qq.

  • Тепер маємо два випадки. Або len(p)+1=len(q)len(p) + 1 = len(q), або ні.

  • Якщо len(p)+1=len(q)len(p) + 1 = len(q), то ми можемо просто покласти link(cur)=qlink(cur) = q і завершити.

  • Інакше все трохи складніше. Необхідно клонувати стан qq: ми створюємо новий стан cloneclone, копіюємо всі дані з qq (суфіксне посилання й переходи), окрім значення lenlen. Покладаємо len(clone)=len(p)+1len(clone) = len(p) + 1.

    Після клонування ми спрямовуємо суфіксне посилання з curcur на cloneclone, а також з qq на clone.

    Нарешті, нам потрібно пройтися зі стану pp назад суфіксними посиланнями, доки існує перехід за cc до стану qq, і перенаправити всі ці переходи на стан cloneclone.

  • У будь-якому з трьох випадків після завершення процедури ми оновлюємо значення lastlast станом curcur.

Якщо ми також хочемо знати, які стани є термінальними, а які ні, то можемо знайти всі термінальні стани після побудови повного суфіксного автомата для всього рядка ss. Для цього ми беремо стан, що відповідає всьому рядку (збережений у змінній lastlast), і йдемо його суфіксними посиланнями, доки не досягнемо початкового стану. Усі відвідані стани ми позначимо як термінальні. Легко зрозуміти, що в такий спосіб ми позначимо саме ті стани, що відповідають усім суфіксам рядка ss, а це і є термінальні стани.

У наступному розділі ми детально розглянемо кожен крок і покажемо його коректність.

Тут лише зауважимо, що оскільки ми створюємо лише один або два нові стани на кожен символ ss, суфіксний автомат містить лінійну кількість станів.

Лінійність кількості переходів і взагалі лінійність часу роботи алгоритму менш очевидні, і їх ми доведемо після того, як доведемо коректність.

Коректність

  • Перехід (p,q)(p, q) ми називатимемо неперервним, якщо len(p)+1=len(q)len(p) + 1 = len(q). Інакше, тобто коли len(p)+1<len(q)len(p) + 1 < len(q), перехід називатимемо розривним.

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

  • Щоб уникнути неоднозначності, рядок, для якого було побудовано суфіксний автомат до додавання поточного символу cc, ми позначатимемо через ss.

  • Алгоритм починається зі створення нового стану curcur, який відповідатиме всьому рядку s+cs + c. Зрозуміло, чому ми маємо створити новий стан. Разом із новим символом створюється новий клас еквівалентності.

  • Після створення нового стану ми проходимо суфіксними посиланнями, починаючи зі стану, що відповідає всьому рядку ss. Для кожного стану ми намагаємося додати перехід із символом cc до нового стану curcur. Таким чином ми дописуємо до кожного суфікса рядка ss символ cc. Однак ми можемо додавати ці нові переходи лише тоді, коли вони не конфліктують із уже наявним. Тому, як тільки ми знаходимо вже наявний перехід за cc, ми маємо зупинитися.

  • У найпростішому випадку ми дійшли до фіктивного стану 1-1. Це означає, що ми додали перехід за cc до всіх суфіксів рядка ss. Це також означає, що символ cc раніше не входив до рядка ss. Тому суфіксне посилання curcur має вести до стану 00.

  • У другому випадку ми натрапили на наявний перехід (p,q)(p, q). Це означає, що ми спробували додати до машини рядок x+cx + c (де xx — суфікс рядка ss), який уже існує в машині (рядок x+cx + c уже зустрічається як підрядок ss). Оскільки ми припускаємо, що автомат для рядка ss побудовано правильно, новий перехід тут додавати не слід.

    Однак тут є складність. До якого стану має вести суфіксне посилання зі стану curcur? Ми маємо зробити суфіксне посилання до стану, в якому найдовший рядок — це саме x+cx + c, тобто lenlen цього стану має дорівнювати len(p)+1len(p) + 1. Однак можливо, що такого стану ще не існує, тобто len(q)>len(p)+1len(q) > len(p) + 1. У цьому випадку нам доведеться створити такий стан, розщепивши стан qq.

  • Якщо перехід (p,q)(p, q) виявляється неперервним, то len(q)=len(p)+1len(q) = len(p) + 1. У цьому випадку все просто. Ми спрямовуємо суфіксне посилання з curcur на стан qq.

  • Інакше перехід є розривним, тобто len(q)>len(p)+1len(q) > len(p) + 1. Це означає, що стан qq відповідає не лише суфіксу рядка s+cs + c довжини len(p)+1len(p) + 1, а й довшим підрядкам рядка ss. Нам не лишається нічого іншого, окрім як розщепити стан qq на два підстани так, щоб перший мав довжину len(p)+1len(p) + 1.

    Як можна розщепити стан? Ми клонуємо стан qq, що дає нам стан cloneclone, і покладаємо len(clone)=len(p)+1len(clone) = len(p) + 1. Ми копіюємо всі переходи з qq до cloneclone, бо не хочемо змінювати шляхи, що проходять через qq. Також ми встановлюємо суфіксне посилання з cloneclone на ціль суфіксного посилання qq, а суфіксне посилання qq — на cloneclone.

    І після розщеплення стану ми встановлюємо суфіксне посилання з curcur на cloneclone.

    На останньому кроці ми змінюємо деякі переходи до qq, перенаправляючи їх на cloneclone. Які переходи ми маємо змінити? Достатньо перенаправити лише переходи, що відповідають усім суфіксам рядка w+cw + c (де ww — найдовший рядок pp), тобто нам потрібно продовжувати рух суфіксними посиланнями, починаючи з вершини pp, доки ми не досягнемо фіктивного стану 1-1 або переходу, що веде до іншого стану, ніж qq.

Лінійна кількість операцій

Спочатку ми одразу робимо припущення, що розмір алфавіту сталий. Якщо це не так, то про лінійну часову складність говорити не вдасться. Список переходів з однієї вершини зберігатиметься у збалансованому дереві, що дозволяє швидко виконувати операції пошуку за ключем і додавання ключів. Тому якщо ми позначимо через kk розмір алфавіту, то асимптотична поведінка алгоритму буде O(nlogk)O(n \log k) з O(n)O(n) пам'яті. Однак якщо алфавіт достатньо малий, то можна пожертвувати пам'яттю, відмовившись від збалансованих дерев, і зберігати переходи в кожній вершині як масив довжини kk (для швидкого пошуку за ключем) і динамічний список (для швидкого обходу всіх наявних ключів). Так ми досягаємо часової складності O(n)O(n) для алгоритму, але ціною складності пам'яті O(nk)O(n k).

Отже, ми вважатимемо розмір алфавіту сталим, тобто кожну операцію пошуку переходу за символом, додавання переходу, пошуку наступного переходу — усі ці операції можна виконати за O(1)O(1).

Якщо ми розглянемо всі частини алгоритму, то він містить три місця, в яких лінійна складність не очевидна:

  • Перше місце — обхід суфіксними посиланнями зі стану lastlast із додаванням переходів із символом cc.
  • Друге місце — копіювання переходів, коли стан qq клонується в новий стан cloneclone.
  • Третє місце — зміна переходів, що ведуть до qq, з перенаправленням їх на cloneclone.

Ми користуємося тим фактом, що розмір суфіксного автомата (як за кількістю станів, так і за кількістю переходів) є лінійним. (Доведенням лінійності кількості станів є сам алгоритм, а доведення лінійності кількості станів наведено нижче, після реалізації алгоритму.)

Таким чином, загальна складність першого і другого місць очевидна, адже кожна операція додає лише один амортизований новий перехід до автомата.

Залишається оцінити загальну складність третього місця, в якому ми перенаправляємо переходи, що спочатку вказували на qq, на cloneclone. Позначимо v=longest(p)v = longest(p). Це суфікс рядка ss, і з кожною ітерацією його довжина зменшується — а отже, позиція vv як суфікса рядка ss монотонно зростає з кожною ітерацією. У цьому випадку, якщо перед першою ітерацією циклу відповідний рядок vv перебував на глибині kk (k2k \ge 2) від lastlast (рахуючи глибину як кількість суфіксних посилань), то після останньої ітерації рядок v+cv + c буде 22-м суфіксним посиланням на шляху від curcur (який стане новим значенням lastlast).

Таким чином, кожна ітерація цього циклу призводить до того, що позиція рядка longest(link(link(last))longest(link(link(last)) як суфікса поточного рядка монотонно зростає. Тому цей цикл не може виконатися більш ніж nn ітерацій, що й потрібно було довести.

Реалізація

Спочатку опишемо структуру даних, яка зберігатиме всю інформацію про конкретний перехід (lenlen, linklink і список переходів). За потреби сюди можна додати прапорець термінального стану, а також іншу інформацію. Список переходів ми зберігатимемо у вигляді mapmap, що дозволяє нам досягти сумарно O(n)O(n) пам'яті та O(nlogk)O(n \log k) часу на обробку всього рядка.

struct state {
int len, link;
map<char, int> next;
};

Сам суфіксний автомат зберігатиметься в масиві цих структур statestate. Ми зберігаємо поточний розмір szsz, а також змінну lastlast — стан, що відповідає всьому рядку на цей момент.

const int MAXLEN = 100000;
state st[MAXLEN * 2];
int sz, last;

Наведемо функцію, що ініціалізує суфіксний автомат (створює суфіксний автомат з одним станом).

void sa_init() {
st[0].len = 0;
st[0].link = -1;
sz++;
last = 0;
}

І нарешті наведемо реалізацію головної функції — яка додає наступний символ до кінця поточного рядка, відповідно перебудовуючи машину.

void sa_extend(char c) {
int cur = sz++;
st[cur].len = st[last].len + 1;
int p = last;
while (p != -1 && !st[p].next.count(c)) {
st[p].next[c] = cur;
p = st[p].link;
}
if (p == -1) {
st[cur].link = 0;
} else {
int q = st[p].next[c];
if (st[p].len + 1 == st[q].len) {
st[cur].link = q;
} else {
int clone = sz++;
st[clone].len = st[p].len + 1;
st[clone].next = st[q].next;
st[clone].link = st[q].link;
while (p != -1 && st[p].next[c] == q) {
st[p].next[c] = clone;
p = st[p].link;
}
st[q].link = st[cur].link = clone;
}
}
last = cur;
}

Як зазначено вище, якщо пожертвувати пам'яттю (O(nk)O(n k), де kk — розмір алфавіту), то можна досягти часу побудови машини O(n)O(n) навіть для будь-якого розміру алфавіту kk. Але для цього доведеться зберігати в кожному стані масив розміру kk (для швидкого переходу до переходу за буквою) і додатково список усіх переходів (для швидкого ітерування по переходах).

Додаткові властивості

Кількість станів

Кількість станів у суфіксному автоматі рядка ss довжини nn не перевищує 2n12n - 1 (для n2n \ge 2).

Доведенням є сам алгоритм побудови, оскільки спочатку автомат складається з одного стану, на першій і другій ітерації створюється лише один стан, а на решті n2n-2 кроків створюється щонайбільше 22 стани на кожному.

Однак ми можемо також показати цю оцінку без знання алгоритму. Нагадаємо, що кількість станів дорівнює кількості різних множин endposendpos. Крім того, ці множини endposendpos утворюють дерево (батьківська вершина містить у своїй множині всі дочірні множини). Розглянемо це дерево і трохи його перетворимо: доки в ньому є внутрішня вершина лише з однією дитиною (а це означає, що множина дитини не містить принаймні однієї позиції з батьківської множини), ми створюємо нову дитину з множиною відсутніх позицій. Зрештою ми отримуємо дерево, в якому кожна внутрішня вершина має степінь більший за один, а кількість листків не перевищує nn. Тому в такому дереві не більш ніж 2n12n - 1 вершин.

Цю межу кількості станів насправді можна досягти для кожного nn. Можливий рядок такий:

"abbbbbb"\text{"abbb}\dots \text{bbb"}

На кожній ітерації, починаючи з третьої, алгоритм розщеплюватиме стан, унаслідок чого вийде рівно 2n12n - 1 станів.

Кількість переходів

Кількість переходів у суфіксному автоматі рядка ss довжини nn не перевищує 3n43n - 4 (для n3n \ge 3).

Доведемо це:

Спочатку оцінимо кількість неперервних переходів. Розглянемо кістякове дерево найдовших шляхів в автоматі, що починаються у стані t0t_0. Цей каркас складатиметься лише з неперервних ребер, а тому їхня кількість менша за кількість станів, тобто не перевищує 2n22n - 2.

Тепер оцінимо кількість розривних переходів. Нехай поточний розривний перехід — це (p,q)(p, q) із символом cc. Візьмемо відповідний рядок u+c+wu + c + w, де рядок uu відповідає найдовшому шляху з початкового стану до pp, а ww — найдовшому шляху з qq до будь-якого термінального стану. З одного боку, кожен такий рядок u+c+wu + c + w для кожного неповного рядка буде різним (оскільки рядки uu і ww утворюються лише повними переходами). З іншого боку, кожен такий рядок u+c+wu + c + w за означенням термінальних станів буде суфіксом усього рядка ss. Оскільки існує лише nn непорожніх суфіксів ss, а жоден із рядків u+c+wu + c + w не може містити ss (бо весь рядок містить лише повні переходи), загальна кількість неповних переходів не перевищує n1n - 1.

Поєднання цих двох оцінок дає нам межу 3n33n - 3. Однак, оскільки максимальної кількості станів можна досягти лише на тесті "abbbbbb"\text{"abbb\dots bbb"}, а цей випадок очевидно має менше за 3n33n - 3 переходів, ми отримуємо точнішу межу 3n43n - 4 для кількості переходів у суфіксному автоматі.

Цю межу також можна досягти рядком:

"abbbbbbc"\text{"abbb}\dots \text{bbbc"}

Застосування

Тут ми розглянемо деякі задачі, які можна розв'язати за допомогою суфіксного автомата. Для простоти ми вважаємо розмір алфавіту kk сталим, що дозволяє нам вважати складність дописування символу й обходу сталою.

Перевірка входження

Дано текст TT і кілька взірців PP. Нам потрібно перевірити, чи з'являються рядки PP як підрядок TT.

Ми будуємо суфіксний автомат тексту TT за O(length(T))O(length(T)) часу. Щоб перевірити, чи з'являється взірець PP у TT, ми йдемо переходами, починаючи з t0t_0, відповідно до символів PP. Якщо в якийсь момент переходу не існує, то взірець PP не з'являється як підрядок TT. Якщо ми можемо обробити в такий спосіб увесь рядок PP, то рядок з'являється в TT.

Зрозуміло, що це займе O(length(P))O(length(P)) часу для кожного рядка PP. Більше того, цей алгоритм фактично знаходить довжину найдовшого префікса PP, що з'являється в тексті.

Кількість різних підрядків

Дано рядок SS. Потрібно обчислити кількість різних підрядків.

Побудуємо суфіксний автомат для рядка SS.

Кожен підрядок SS відповідає деякому шляху в автоматі. Тому кількість різних підрядків дорівнює кількості різних шляхів в автоматі, що починаються в t0t_0.

Оскільки суфіксний автомат є орієнтованим ациклічним графом, кількість різних шляхів можна обчислити за допомогою динамічного програмування.

А саме, нехай d[v]d[v] — кількість шляхів, що починаються у стані vv (включно зі шляхом нульової довжини). Тоді маємо рекурсію:

d[v]=1+w:(v,w,c)DAWGd[w]d[v] = 1 + \sum_{w : (v, w, c) \in DAWG} d[w]

Тобто d[v]d[v] можна виразити як суму відповідей для всіх кінців переходів vv.

Кількість різних підрядків — це значення d[t0]1d[t_0] - 1 (оскільки порожній підрядок ми не рахуємо).

Загальна часова складність: O(length(S))O(length(S))

Як альтернативу, ми можемо скористатися тим, що кожному стану vv відповідають підрядки довжини [minlen(v),len(v)][minlen(v),len(v)]. Тому, з огляду на minlen(v)=1+len(link(v))minlen(v) = 1 + len(link(v)), маємо, що загальна кількість різних підрядків у стані vv становить len(v)minlen(v)+1=len(v)(1+len(link(v)))+1=len(v)len(link(v))len(v) - minlen(v) + 1 = len(v) - (1 + len(link(v))) + 1 = len(v) - len(link(v)).

Це лаконічно продемонстровано нижче:

long long get_diff_strings(){
long long tot = 0;
for(int i = 1; i < sz; i++) {
tot += st[i].len - st[st[i].link].len;
}
return tot;
}

Хоча це теж O(length(S))O(length(S)), цей підхід не потребує додаткової пам'яті й рекурсивних викликів, а отже, на практиці працює швидше.

Сумарна довжина всіх різних підрядків

Дано рядок SS. Ми хочемо обчислити сумарну довжину всіх його різних підрядків.

Розв'язок схожий на попередній, тільки тепер для частини з динамічним програмуванням необхідно розглядати дві величини: кількість різних підрядків d[v]d[v] та їхню сумарну довжину ans[v]ans[v].

Як обчислювати d[v]d[v], ми вже описали в попередній задачі. Значення ans[v]ans[v] можна обчислити за допомогою рекурсії:

ans[v]=w:(v,w,c)DAWGd[w]+ans[w]ans[v] = \sum_{w : (v, w, c) \in DAWG} d[w] + ans[w]

Ми беремо відповідь кожної суміжної вершини ww і додаємо до неї d[w]d[w] (оскільки кожен підрядок на один символ довший, коли починається зі стану vv).

І знову цю задачу можна обчислити за O(length(S))O(length(S)) часу.

Як альтернативу, ми можемо знову скористатися тим, що кожному стану vv відповідають підрядки довжини [minlen(v),len(v)][minlen(v),len(v)]. Оскільки minlen(v)=1+len(link(v))minlen(v) = 1 + len(link(v)) і за формулою суми арифметичної прогресії Sn=na1+an2S_n = n \cdot \frac{a_1+a_n}{2} (де SnS_n позначає суму nn доданків, a1a_1 — перший доданок, а ana_n — останній доданок), ми можемо обчислити сумарну довжину підрядків у стані за сталий час. Потім ми підсумовуємо ці суми для кожного стану vt0v \neq t_0 в автоматі. Це показано в коді нижче:

long long get_tot_len_diff_substings() {
long long tot = 0;
for(int i = 1; i < sz; i++) {
long long shortest = st[st[i].link].len + 1;
long long longest = st[i].len;

long long num_strings = longest - shortest + 1;
long long cur = num_strings * (longest + shortest) / 2;
tot += cur;
}
return tot;
}

Цей підхід працює за O(length(S))O(length(S)) часу, але експериментально працює у 20 разів швидше за версію з мемоїзованим динамічним програмуванням на випадкових рядках. Він не потребує додаткової пам'яті й рекурсії.

Лексикографічно kk-й підрядок

Дано рядок SS. Нам потрібно відповісти на кілька запитів. Для кожного заданого числа KiK_i ми маємо знайти KiK_i-й рядок у лексикографічно впорядкованому списку всіх підрядків.

Розв'язок цієї задачі ґрунтується на ідеї двох попередніх задач. Лексикографічно kk-й підрядок відповідає лексикографічно kk-му шляху в суфіксному автоматі. Тому, підрахувавши кількість шляхів з кожного стану, ми можемо легко шукати kk-й шлях, починаючи з кореня автомата.

Це займає O(length(S))O(length(S)) часу на попередню обробку, а потім O(length(ans)k)O(length(ans) \cdot k) на кожен запит (де ansans — відповідь на запит, а kk — розмір алфавіту).

Найменший циклічний зсув

Дано рядок SS. Ми хочемо знайти лексикографічно найменший циклічний зсув.

Ми будуємо суфіксний автомат для рядка S+SS + S. Тоді автомат міститиме в собі як шляхи всі циклічні зсуви рядка SS.

Отже, задача зводиться до пошуку лексикографічно найменшого шляху довжини length(S)length(S), що можна зробити тривіально: ми починаємо в початковому стані й жадібно проходимо переходами з мінімальним символом.

Загальна часова складність — O(length(S))O(length(S)).

Кількість входжень

Дано текст TT. Нам потрібно відповісти на кілька запитів. Для кожного заданого взірця PP ми маємо з'ясувати, скільки разів рядок PP з'являється в рядку TT як підрядок.

Ми будуємо суфіксний автомат для тексту TT.

Далі робимо таку попередню обробку: для кожного стану vv в автоматі ми обчислюємо число cnt[v]cnt[v], що дорівнює розміру множини endpos(v)endpos(v). Насправді всі рядки, що відповідають одному й тому самому стану vv, з'являються в тексті TT однакову кількість разів, що дорівнює кількості позицій у множині endposendpos.

Однак ми не можемо будувати множини endposendpos явно, тому розглядаємо лише їхні розміри cntcnt.

Щоб їх обчислити, ми робимо так. Для кожного стану, якщо його було створено не клонуванням (і якщо це не початковий стан t0t_0), ми ініціалізуємо його значенням cnt=1cnt = 1. Потім ми пройдемося по всіх станах у порядку спадання їхньої довжини lenlen і додамо поточне значення cnt[v]cnt[v] до суфіксних посилань:

cnt[link(v)] += cnt[v]cnt[link(v)] \text{ += } cnt[v]

Це дає правильне значення для кожного стану.

Чому це правильно? Загальна кількість станів, отриманих не через клонування, дорівнює точно length(T)length(T), і перші ii з них з'явилися, коли ми додали перші ii символів. Отже, для кожного з цих станів ми рахуємо відповідну позицію, на якій його було оброблено. Тому спочатку ми маємо cnt=1cnt = 1 для кожного такого стану і cnt=0cnt = 0 для всіх інших.

Потім ми застосовуємо таку операцію для кожного vv: cnt[link(v)] += cnt[v]cnt[link(v)] \text{ += } cnt[v]. Сенс цього в тому, що якщо рядок vv з'являється cnt[v]cnt[v] разів, то й усі його суфікси з'являються в тих самих кінцевих позиціях, а отже, теж cnt[v]cnt[v] разів.

Чому ми не рахуємо забагато в цій процедурі (тобто не рахуємо деякі позиції двічі)? Бо ми додаємо позиції стану лише до одного іншого стану, тож не може статися, щоб один стан спрямовував свої позиції до іншого стану двічі двома різними способами.

Таким чином, ми можемо обчислити величини cntcnt для всіх станів автомата за O(length(T))O(length(T)) часу.

Після цього відповідь на запит — це просто значення cnt[t]cnt[t], де tt — стан, що відповідає взірцю, якщо такий стан існує. Інакше відповідаємо 00. Відповідь на запит займає O(length(P))O(length(P)) часу.

Позиція першого входження

Дано текст TT і кілька запитів. Для кожного рядка запиту PP ми хочемо знайти позицію першого входження PP у рядок TT (позицію початку PP).

Ми знову будуємо суфіксний автомат. Додатково ми попередньо обчислюємо позицію firstposfirstpos для всіх станів в автоматі, тобто для кожного стану vv ми хочемо знайти позицію firstpos[v]firstpos[v] кінця першого входження. Іншими словами, ми хочемо заздалегідь знайти мінімальний елемент кожної множини endposendpos (оскільки, очевидно, ми не можемо підтримувати всі множини endposendpos явно).

Щоб підтримувати ці позиції firstposfirstpos, ми розширюємо функцію sa_extend(). Коли ми створюємо новий стан curcur, ми покладаємо:

firstpos(cur)=len(cur)1firstpos(cur) = len(cur) - 1

А коли ми клонуємо вершину qq як cloneclone, ми покладаємо:

firstpos(clone)=firstpos(q)firstpos(clone) = firstpos(q)

(оскільки єдиний інший варіант значення — це firstpos(cur)firstpos(cur), який, безперечно, завеликий)

Таким чином, відповідь на запит — це просто firstpos(t)length(P)+1firstpos(t) - length(P) + 1, де tt — стан, що відповідає рядку PP. Відповідь на запит знову займає лише O(length(P))O(length(P)) часу.

Усі позиції входжень

Цього разу нам потрібно вивести всі позиції входжень у рядок TT.

Знову будуємо суфіксний автомат для тексту TT. Подібно до попередньої задачі ми обчислюємо позицію firstposfirstpos для всіх станів.

Очевидно, firstpos(t)firstpos(t) є частиною відповіді, якщо tt — стан, що відповідає рядку запиту PP. Отже, ми врахували стан автомата, що містить PP. Які ще стани нам потрібно врахувати? Усі стани, що відповідають рядкам, для яких PP є суфіксом. Іншими словами, нам потрібно знайти всі стани, з яких можна досягти стану tt через суфіксні посилання.

Тому для розв'язання задачі нам потрібно зберігати для кожного стану список суфіксних посилань, що ведуть до нього. Тоді відповідь на запит міститиме всі firstposfirstpos для кожного стану, який ми можемо знайти за допомогою DFS / BFS, починаючи зі стану tt і користуючись лише суфіксними посиланнями.

Загалом це потребує O(length(T))O(length (T)) на попередню обробку і O(length(P)+answer(P))O(length(P) + answer(P)) на кожен запит, де answer(P)answer(P) — це розмір відповіді.

Спочатку ми спускаємося автоматом для кожного символу взірця, щоб знайти наш початковий вузол, що потребує O(length(P))O(length(P)). Потім ми застосовуємо наш прийом, який працюватиме за час O(answer(P))O(answer(P)), бо ми не відвідуватимемо жоден стан двічі (оскільки з кожного стану виходить лише одне суфіксне посилання, тож не може бути двох різних шляхів, що ведуть до того самого стану).

Ми лише маємо врахувати, що два різні стани можуть мати однакове значення firstposfirstpos. Це трапляється, якщо один стан було отримано клонуванням іншого. Однак це не псує складність, оскільки кожен стан може мати щонайбільше один клон.

Більше того, ми можемо також позбутися повторюваних позицій, якщо не виводитимемо позиції з клонованих станів. Насправді стан, якого може досягти клонований стан, також досяжний з оригінального стану. Тому, якщо ми запам'ятаємо прапорець is_cloned для кожного стану, ми можемо просто ігнорувати клоновані стани й виводити firstposfirstpos лише для всіх інших станів.

Ось кілька начерків реалізації:

struct state {
...
bool is_clone;
int first_pos;
vector<int> inv_link;
};

// after constructing the automaton
for (int v = 1; v < sz; v++) {
st[st[v].link].inv_link.push_back(v);
}

// output all positions of occurrences
void output_all_occurrences(int v, int P_length) {
if (!st[v].is_clone)
cout << st[v].first_pos - P_length + 1 << endl;
for (int u : st[v].inv_link)
output_all_occurrences(u, P_length);
}

Найкоротший рядок, що не зустрічається

Дано рядок SS і певний алфавіт. Нам потрібно знайти рядок найменшої довжини, який не зустрічається в SS.

Ми застосуємо динамічне програмування на суфіксному автоматі, побудованому для рядка SS.

Нехай d[v]d[v] — відповідь для вузла vv, тобто ми вже обробили частину підрядка, наразі перебуваємо у стані vv і хочемо знайти найменшу кількість символів, які треба додати, щоб знайти неіснуючий перехід. Обчислення d[v]d[v] дуже просте. Якщо немає переходу хоча б за одним символом алфавіту, то d[v]=1d[v] = 1. Інакше одного символу недостатньо, і тому нам потрібно взяти мінімум з усіх відповідей усіх переходів:

d[v]=1+minw:(v,w,c)SAd[w].d[v] = 1 + \min_{w:(v,w,c) \in SA} d[w].

Відповіддю до задачі буде d[t0]d[t_0], а сам рядок можна відновити за допомогою обчисленого масиву d[]d[].

Найдовший спільний підрядок двох рядків

Дано два рядки SS і TT. Нам потрібно знайти найдовший спільний підрядок, тобто такий рядок XX, що з'являється як підрядок у SS і також у TT.

Ми будуємо суфіксний автомат для рядка SS.

Тепер ми візьмемо рядок TT і для кожного префікса шукатимемо найдовший суфікс цього префікса в SS. Іншими словами, для кожної позиції в рядку TT ми хочемо знайти найдовший спільний підрядок SS і TT, що закінчується в цій позиції.

Для цього ми використаємо дві змінні — поточний стан vv і поточну довжину ll. Ці дві змінні описуватимуть поточну зіставлену частину: її довжину і стан, що їй відповідає.

Спочатку v=t0v = t_0 і l=0l = 0, тобто зіставлення порожнє.

Тепер опишемо, як ми можемо додати символ T[i]T[i] і перерахувати для нього відповідь.

  • Якщо з vv є перехід за символом T[i]T[i], то ми просто йдемо цим переходом і збільшуємо ll на один.
  • Якщо такого переходу немає, ми маємо скоротити поточну зіставлену частину, що означає необхідність піти суфіксним посиланням: v=link(v)v = link(v). Водночас поточну довжину треба скоротити. Очевидно, нам потрібно покласти l=len(v)l = len(v), оскільки після проходження суфіксним посиланням ми опиняємося у стані, найдовший відповідний рядок якого є підрядком.
  • Якщо переходу за потрібним символом усе ще немає, ми повторюємо і знову йдемо суфіксним посиланням і зменшуємо ll, доки не знайдемо перехід або не досягнемо фіктивного стану 1-1 (що означає, що символ T[i]T[i] зовсім не з'являється в SS, тож ми покладаємо v=l=0v = l = 0).

Відповіддю до задачі буде максимум усіх значень ll.

Складність цієї частини — O(length(T))O(length(T)), оскільки за один крок ми можемо або збільшити ll на один, або зробити кілька проходів суфіксними посиланнями, кожен з яких зрештою зменшує значення ll.

Реалізація:

string lcs (string S, string T) {
sa_init();
for (int i = 0; i < S.size(); i++)
sa_extend(S[i]);

int v = 0, l = 0, best = 0, bestpos = 0;
for (int i = 0; i < T.size(); i++) {
while (v && !st[v].next.count(T[i])) {
v = st[v].link ;
l = st[v].len;
}
if (st[v].next.count(T[i])) {
v = st [v].next[T[i]];
l++;
}
if (l > best) {
best = l;
bestpos = i;
}
}
return T.substr(bestpos - best + 1, best);
}

Найдовший спільний підрядок кількох рядків

Задано kk рядків SiS_i. Нам потрібно знайти найдовший спільний підрядок, тобто такий рядок XX, що з'являється як підрядок у кожному рядку SiS_i.

Ми об'єднуємо всі рядки в один великий рядок TT, розділяючи рядки спеціальними символами DiD_i (по одному на кожен рядок):

T=S1+D1+S2+D2++Sk+Dk.T = S_1 + D_1 + S_2 + D_2 + \dots + S_k + D_k.

Потім ми будуємо суфіксний автомат для рядка TT.

Тепер нам потрібно знайти в машині рядок, який міститься в усіх рядках SiS_i, і це можна зробити за допомогою спеціально доданих символів. Зауважимо, що якщо підрядок входить до деякого рядка SjS_j, то в суфіксному автоматі існує шлях, що починається з цього підрядка, містить символ DjD_j і не містить інших символів D1,,Dj1,Dj+1,,DkD_1, \dots, D_{j-1}, D_{j+1}, \dots, D_k.

Таким чином, нам потрібно обчислити досяжність, яка для кожного стану машини й кожного символу DiD_i показує, чи існує такий шлях. Це легко обчислити за допомогою DFS чи BFS і динамічного програмування. Після цього відповіддю до задачі буде рядок longest(v)longest(v) для стану vv, з якого існували шляхи для всіх спеціальних символів.

Задачі для практики