Алгоритм дерева суфіксів Укконена простою англійською мовою


1101

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

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

Для довідки, ось документ Ukkonen про алгоритм: http://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf

Моє основне розуміння поки що:

  • Мені потрібно повторити кожен префікс P заданого рядка T
  • Мені потрібно повторити кожен суфікс S у префіксі P і додати його до дерева
  • Щоб додати суфікс S до дерева, мені потрібно перебирати кожен символ у S, ітерації, що складаються з того, щоб перейти до існуючої гілки, яка починається тим самим набором символів C у S та потенційно розділити край на низхідні вузли, коли я доходять до різного символу в суфіксі, АБО якщо не було відповідного краю, щоб піти вниз. Якщо не знайдено відповідного краю, який може спуститися на C, створюється новий край листа для C.

Базовим алгоритмом виявляється O (n 2 ), як зазначено в більшості пояснень, оскільки нам потрібно переглядати всі префікси, тоді нам потрібно переглядати кожен із суфіксів для кожного префікса. Алгоритм Укконена, мабуть, унікальний через техніку вказівника суфікса, яку він використовує, хоча я думаю, що саме це я маю проблеми з розумінням.

У мене також виникають проблеми з розумінням:

  • саме коли і як «активна точка» призначається, використовується та змінюється
  • що відбувається з аспектом канонізації алгоритму
  • Чому реалізація, яку я бачив, потребує "виправлення" обмежувальних змінних, які вони використовують

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

https://gist.github.com/2373868


Оновити 2017-11-04

Через багато років я знайшов нове використання для суфіксних дерев і реалізував алгоритм в JavaScript . Суть нижче. Він повинен бути без помилок. Завантажте його у js-файл npm install chalkз того самого місця та запустіть з node.js, щоб побачити кольоровий вихід. У тому ж Gist є знята версія без жодного коду налагодження.

https://gist.github.com/axefrog/c347bf0f5e0723cbd09b1aaed6ec6fc6


2
Ви подивилися на опис, поданий у книзі Дена Гусфілда ? Я виявив, що це корисно.
jogojapan

4
Суть не визначає ліцензію - чи можу я змінити ваш код і повторно опублікувати під MIT (очевидно, з атрибутами)?
Юрік

2
Так, продовжуй своє життя. Вважайте це загальнодоступним. Як вже згадується в іншій відповіді на цій сторінці, помилка, яка все-таки потребує виправлення.
Натан Рідлі

1
можливо, ця реалізація допоможе іншим, goto code.google.com/p/text-indexing
cos

2
"Вважай, що це суспільне надбання", можливо, напрочуд дуже корисна відповідь. Причина в тому, що фактично неможливо розмістити роботу у відкритому доступі. Тому ваш «вважає це ...» коментар підкреслює той факт , що ліцензія є неясною і дає привід читача засумніватися в тому , що статус роботи насправді ясно вам . Якщо ви хочете, щоб люди могли використовувати ваш код, будь ласка, вкажіть ліцензію на нього, виберіть будь-яку ліцензію, яка вам подобається (але, якщо ви не юрист, виберіть попередню ліцензію!)
Джеймс Янгмен

Відповіді:


2377

Далі йде спроба описати алгоритм Укконена, спочатку показавши, що він робить, коли рядок є простим (тобто не містить повторних символів), а потім поширивши його на повний алгоритм.

По-перше, кілька попередніх заяв.

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

  2. Але : На відміну від пошукового трійника, крайові мітки не є одиничними символами. Натомість кожен край маркується за допомогою пари цілих чисел [from,to]. Це покажчики в тексті. У цьому сенсі кожне ребро несе мітку рядка довільної довжини, але займає лише O (1) пробіл (два вказівники).

Основний принцип

Спочатку я хотів би продемонструвати, як створити суфіксне дерево особливо простого рядка - рядок без повторних символів:

abc

Алгоритм працює поетапно, зліва направо . Для кожного символу рядка є один крок . Кожен крок може включати більше однієї окремої операції, але ми побачимо (див. Остаточні спостереження наприкінці), що загальна кількість операцій становить O (n).

Отже, ми починаємо зліва і спочатку вставляємо лише єдиний символ a, створюючи край від кореневого вузла (зліва) до аркуша і позначаючи його як [0,#], що означає, що край представляє підрядку, починаючи з позиції 0 і закінчуючи на поточному кінці . Я використовую символ #для позначення поточного кінця , який знаходиться в позиції 1 (відразу після a).

Отже, у нас є початкове дерево, яке виглядає приблизно так:

І що це означає:

Тепер ми переходимо до позиції 2 (відразу після b). Наша мета на кожному кроці - вставити всі суфікси до поточної позиції . Ми робимо це шляхом

  • розширення існуючого a-геджу доab
  • вставлення одного нового краю для b

У нашому представництві це виглядає так

введіть тут опис зображення

І що це означає:

Ми спостерігаємо дві речі:

  • Подання кромки для abє таким же , як це було у вихідному дереві: [0,#]. Його значення автоматично змінилося, оскільки ми оновили поточну позицію# з 1 до 2.
  • Кожен край споживає пробіл O (1), оскільки він складається лише з двох покажчиків у тексті, незалежно від кількості символів.

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

У нашому представництві це виглядає так

І що це означає:

Ми спостерігаємо:

  • Дерево - це правильне дерево суфіксів до поточного положення після кожного кроку
  • У тексті стільки кроків, скільки символів
  • Обсяг роботи на кожному кроці становить O (1), оскільки всі існуючі краї оновлюються автоматично шляхом збільшення #, а вставлення одного нового краю для остаточного символу може бути виконано за O (1) час. Отже, для рядка довжиною n потрібен лише час O (n).

Перше розширення: прості повтори

Звичайно, це працює так чудово лише тому, що наш рядок не містить повторень. Зараз ми розглянемо більш реалістичний рядок:

abcabxabcd

Починається з, abcяк у попередньому прикладі, потім abповторюється та слідує x, а потім abcповторюється d.

Етапи з 1 по 3: Після перших 3 кроків ми маємо дерево з попереднього прикладу:

Крок 4: Переходимо #до позиції 4. Це неявно оновлює всі існуючі краї до цього:

і нам потрібно вставити остаточний суфікс поточного кроку, a в корені.

Перш ніж ми це зробимо, ми введемо ще дві змінні (на додаток до #), які, звичайно, були там увесь час, але ми їх поки не використовували:

  • Активна точка , яка представляє собою потрійну (active_node,active_edge,active_length)
  • Це число remainder, яке вказує, скільки нових суфіксів нам потрібно вставити

Точне значення цих двох стане зрозумілим незабаром, але поки що скажемо лише:

  • У простому abcприкладі активна точка була завжди (root,'\0x',0), тобто active_nodeкореневий вузол, active_edgeвизначався як нульовий символ '\0x'і active_lengthдорівнював нулю. Ефект цього полягав у тому, що один новий край, який ми вставляли на кожному кроці, був вставлений у кореневий вузол як щойно створений край. Незабаром ми побачимо, чому для представлення цієї інформації необхідна трійка.
  • На remainderпочатку кожного кроку завжди було встановлено 1. Сенс цього полягав у тому, що кількість суфіксів, які нам довелося активно вставляти в кінці кожного кроку, становила 1 (завжди лише остаточний символ).

Зараз це зміниться. Коли ми вводимо поточний кінцевий символ aв корені, ми помічаємо , що вже виходить ребро , починаючи з a, а саме: abca. Ось що ми робимо в такому випадку:

  • Ми не вставляємо свіжий край [4,#]у кореневий вузол. Натомість ми просто помічаємо, що суфікс aвже є в нашому дереві. Він закінчується посередині довшого краю, але нас це не турбує. Ми просто залишаємо речі такими, якими вони є.
  • Ми встановлюємо активну точку на (root,'a',1). Це означає, що активна точка знаходиться десь посередині вихідного краю кореневого вузла, який починається a, зокрема, після позиції 1 на цьому краї. Ми помічаємо, що край задається просто його першим символом a. Цього достатньо, тому що може бути лише один край, починаючи з будь-якого конкретного символу (підтвердьте, що це правда після прочитання всього опису).
  • Ми також збільшуємо remainder, тож на початку наступного кроку це буде 2.

Спостереження: Коли фінальний суфікс, який нам потрібно вставити, вже існує у дереві , саме дерево взагалі не змінюється (ми лише оновлюємо активну точку та remainder). Тоді дерево вже не є точним поданням дерева суфіксів до поточного положення , але воно містить усі суфікси (тому що остаточний суфікс aміститься неявно ). Отже, окрім оновлення змінних (які мають фіксовану довжину, тому це O (1)), на цьому кроці не було виконано жодної роботи .

Крок 5: Ми оновлюємо поточну позицію #до 5. Це автоматично оновлює дерево до цього:

А оскільки remainderце 2 , нам потрібно вставити два остаточні суфікси поточної позиції: abі b. Це в основному тому, що:

  • aСуфікс з попереднього кроку ніколи не був правильно встановлений. Так він залишився , і так як ми просунулися на один крок, то зараз вона зросла з aдо ab.
  • І нам потрібно вставити новий заключний край b.

На практиці це означає, що ми переходимо до активної точки (яка вказує на відстань aвід того, що зараз є abcabкраєм), і вставляємо поточний остаточний символ b. Але: Знову ж таки, виявляється, що bвже є на тому самому краю.

Отже, знову ж, дерево ми не міняємо. Ми просто:

  • Оновіть активну точку на (root,'a',2)(той самий вузол і край, як і раніше, але тепер ми вказуємо на позаду b)
  • Збільшити remainderдо 3, оскільки ми все ще не ввели належним чином кінцевий край попереднього кроку і також не вставимо поточний кінцевий край.

Щоб було зрозуміло: нам довелося вставити abі bв поточному кроці, але оскільки abце вже було знайдено, ми оновили активну точку і навіть не намагалися вставити b. Чому? Тому що, якщо він abє на дереві, його має бути і кожен суфікс (включаючи b). Можливо, лише неявно , але воно повинно бути там, через те, як ми побудували дерево досі.

Переходимо до кроку 6 шляхом збільшення #. Дерево автоматично оновлюється до:

Тому що remainderце 3 , ми повинні вставити abx, bxі x. Активна точка підказує нам, де abзакінчується, тому нам потрібно лише стрибнути туди і вставити x. Дійсно, xще немає, тому ми розділили abcabxкрай і вставимо внутрішній вузол:

Крайові зображення все ще вказують на текст, тому розділення та вставлення внутрішнього вузла можна зробити за O (1) час.

Таким чином , ми мали справу з abxі декремент remainderдо 2. Тепер потрібно вставити наступний залишився суфікс bx. Але перш ніж це зробити, нам потрібно оновити активну точку. Правило для цього, після розбиття та вставлення ребра, буде називатися правилом 1 нижче, і воно застосовується всякий раз, коли active_nodeє root (ми дізнаємось правило 3 для інших випадків далі нижче). Ось правило 1:

Після вставки з кореня,

  • active_node залишається корінь
  • active_edge встановлюється на перший символ нового суфікса, який нам потрібно вставити, тобто b
  • active_length зменшується на 1

Отже, нова трійка активної точки (root,'b',1)вказує на те, що наступна вставка повинна бути зроблена на bcabxкраю, позаду 1 символу, тобто позаду b. Ми можемо визначити точку вставки за часом O (1) і перевірити, чи xвона вже є чи ні. Якби він був присутній, ми закінчили б поточний крок і залишимо все так, як є. Але x його немає, тому ми вставляємо його, розділяючи край:

Знову ж таки, це зайняло O (1) час, і ми оновлюємось remainderдо 1, а активна точка (root,'x',0)як правило 1.

Але є ще одне, що нам потрібно зробити. Ми називаємо це правило 2:

Якщо ми розділимо край і вставимо новий вузол, і якщо це не перший вузол, створений під час поточного кроку, ми з'єднуємо попередньо вставлений вузол та новий вузол через спеціальний вказівник, суфіксний зв’язок . Пізніше ми побачимо, чому це корисно. Ось що ми отримуємо, посилання суфікса представлено у вигляді пунктирного краю:

Нам все ще потрібно , щоб вставити кінцевий суфікс поточного кроку x. Оскільки active_lengthкомпонент активного вузла впав до 0, остаточна вставка робиться безпосередньо в корені. Оскільки у кореневого вузла немає вихідного краю, починаючи з x, ми вставляємо новий край:

Як ми бачимо, на поточному кроці були зроблені всі залишилися вставки.

Переходимо до кроку 7 , встановлюючи #= 7, яка автоматично додає наступний символ aдо всіх країв листа, як завжди. Потім ми намагаємося вставити новий остаточний символ в активну точку (корінь) і виявимо, що він вже є. Таким чином ми закінчуємо поточний крок, не вставляючи нічого і оновлюючи активну точку до (root,'a',1).

На кроці 8 , #= 8, ми додаємо b, і як це було показано раніше, це означає лише, що ми оновлюємо активну точку (root,'a',2)і збільшуємо, remainderне роблячи нічого іншого, оскільки bце вже є. Однак ми помічаємо (за О (1) час), що активна точка зараз знаходиться в кінці ребра. Ми відображаємо це, встановивши його на (node1,'\0x',0). Тут я використовую node1для позначення внутрішнього вузла, на якому abкрай закінчується.

Потім на кроці #= 9 нам потрібно вставити «c», і це допоможе нам зрозуміти остаточний трюк:

Друге розширення: Використання суфіксних посилань

Як завжди, #оновлення cавтоматично додається до країв аркушів, і ми переходимо до активної точки, щоб побачити, чи можемо ми вставити «c». Виявляється, "c" існує вже на цьому краю, тому ми встановлюємо активну точку (node1,'c',1), збільшуючи remainderі більше нічого не робимо.

Тепер у кроці #= 10 , remainderце 4, і тому нам спочатку потрібно вставити abcd(що залишилося від 3 кроків тому), вставивши dв активну точку.

Спроба вставити dв активну точку спричиняє розкол краю в O (1) час:

active_node, З якого був ініційований розкол, відзначений червоним кольором вище. Ось остаточне правило, правило 3:

Розщепивши край від того, active_nodeщо не є кореневим вузлом, ми переходимо по суфіксному посиланню, що виходить із цього вузла, якщо такий є, і скидаємо active_nodeтой вузол, на який він вказує. Якщо немає суфіксного зв’язку, ми встановлюємо active_nodeкорінь. active_edge і active_lengthзалишаються незмінними.

Отже, активна точка зараз є (node2,'c',1), і node2червоним кольором позначена нижче:

Так як вставка abcdзавершена, ми зменшуємо remainderдо 3 і розглянемо наступний залишився суфікс поточного кроку bcd. Правило 3 встановило активну точку лише на правильний вузол та край, тому вставлення bcdможна зробити, просто вставивши його остаточний символ dу активну точку.

Це спричиняє ще один розкол краю, і через правило 2 , ми повинні створити суфіксний зв’язок із раніше вставленого вузла до нового:

Ми зауважуємо: посилання суфікса дозволяють скинути активну точку, щоб ми могли зробити наступну вставку при зусиллях O (1). Подивіться на графік вище, щоб переконатися, що дійсно вузол на мітці abпов'язаний з вузлом в b(його суфіксом), а вузол at abcпов'язаний з bc.

Поточний крок ще не завершений. remainderзараз 2, і нам потрібно дотримуватися правила 3, щоб знову скинути активну точку. Оскільки поточний active_node(червоний верх) не має суфіксального зв’язку, ми скидаємо корінь. Активна точка зараз (root,'c',1).

Отже, наступна вставка виникає в одному вихідному краї кореневого вузла, мітка якого починається з c:, cabxabcdза першим символом, тобто позаду c. Це спричиняє ще один розкол:

А оскільки це передбачає створення нового внутрішнього вузла, ми дотримуємось правила 2 і встановлюємо новий суфіксний зв’язок із створеного раніше внутрішнього вузла:

(Я використовую Graphviz Dot для цих маленьких графіків. Нове посилання суфікса викликало крапку впорядкування існуючих країв, тому уважно перевірте, щоб підтвердити, що єдине, що було вставлено вище, - це нове суфіксне посилання.)

З цим remainderможе бути встановлено 1, а оскільки active_nodeє root, ми використовуємо правило 1 для оновлення активної точки до (root,'d',0). Це означає, що остаточною вставкою поточного кроку є вставлення одиниці d в корінь:

Це був заключний крок, і ми зробили. Однак є кілька підсумкових спостережень :

  • На кожному кроці ми рухаємось #вперед на 1 позицію. Це автоматично оновлює всі вузли листків за O (1) час.

  • Але він не стосується а) будь-яких суфіксів, що залишилися від попередніх кроків, і б) одного остаточного символу поточного кроку.

  • remainderговорить нам, скільки додаткових вставок нам потрібно зробити. Ці вставки відповідають один на один кінцевим суфіксам рядка, який закінчується в поточному положенні #. Розглядаємо одне за одним і робимо вставку. Важливо: Кожна вставка робиться в O (1) час, оскільки активна точка вказує нам, куди саме йти, і нам потрібно додати лише один єдиний символ в активній точці. Чому? Тому що інші символи містяться неявно (інакше активна точка не була б там, де вона є).

  • Після кожної такої вставки ми декрементуємо remainderі переходимо за суфіксним посиланням, якщо воно є. Якщо ні, ми переходимо до кореня (правило 3). Якщо ми вже в корені, ми змінюємо активну точку, використовуючи правило 1. У будь-якому випадку це займає лише час O (1).

  • Якщо під час однієї з цих вставок ми виявимо, що символ, який ми хочемо вставити, вже є, ми нічого не робимо і закінчуємо поточний крок, навіть якщо remainder> 0. Причина в тому, що будь-які вставлені вставки будуть суфіксами тієї, яку ми тільки що намагалися зробити. Отже, всі вони неявні в поточному дереві. Той факт, що remainder> 0, гарантує, що ми будемо мати справу з іншими суфіксами пізніше.

  • Що робити, якщо в кінці алгоритму remainder> 0? Так буде щоразу, коли кінець тексту є підрядком, який відбувся десь раніше. У цьому випадку ми повинні додати ще один додатковий символ в кінці рядка, який раніше не виникав. У літературі зазвичай знак долара $використовується як символ для цього. Чому це має значення? -> Якщо пізніше ми будемо використовувати завершене дерево суфіксів для пошуку суфіксів, ми повинні приймати збіги, лише якщо вони закінчуються на аркуші . Інакше у нас вийшло б багато хибних збігів, тому що в дереві є безліч неявних рядків , які не є фактичними суфіксами головної струни. Примусовийremainderбути 0 в кінці - це, по суті, спосіб гарантувати, що всі суфікси закінчуються у вузлі аркуша. Однак якщо ми хочемо використовувати дерево для пошуку загальних підрядів , а не лише суфіксів основного рядка, цей заключний крок дійсно не потрібен, як це запропоновано коментарем ОП нижче.

  • Тож у чому полягає складність всього алгоритму? Якщо в тексті довжина n символів, очевидно, є n кроків (або n + 1, якщо ми додамо знак долара). На кожному кроці ми або нічого не робимо (крім оновлення змінних), або робимо remainderвставки, кожен з яких бере час O (1). Оскільки remainderвказується, скільки разів ми нічого не робили в попередніх кроках, і зменшується для кожної вставки, яку ми робимо зараз, загальна кількість разів, коли ми щось робимо, рівно n (або n + 1). Отже, загальна складність дорівнює O (n).

  • Однак є одна невелика річ, яку я не правильно пояснив: може статися, що ми переходимо за суфіксним посиланням, оновлюємо активну точку, а потім виявляємо, що її active_lengthкомпонент не працює добре з новим active_node. Наприклад, розглянемо таку ситуацію:

(Пунктирними лініями вказується решта дерева. Пунктирною лінією є суфіксна посилання.)

Тепер нехай буде активна точка (red,'d',3), тому вона вказує на місце позаду fна defgкраю. Тепер припустимо, що ми здійснили необхідні оновлення, а тепер перейдіть за суфіксним посиланням, щоб оновити активну точку відповідно до правила 3. Нова активна точка є (green,'d',3). Однак d-едж, який виходить із зеленого вузла, є de, тому він містить лише 2 символи. Для того, щоб знайти правильну активну точку, нам, очевидно, потрібно слідувати за цим краєм до синього вузла і скинути на (blue,'f',1).

В особливо поганому випадку active_lengthможе бути стільки, скільки remainderможе бути, як і n. І може дуже статися, що для того, щоб знайти правильну активну точку, нам потрібно не лише перейти через один внутрішній вузол, але, можливо, багато, аж до n у гіршому випадку. Чи означає це, що алгоритм має приховану складність O (n 2 ), оскільки на кожному кроці, remainderяк правило, є O (n), і після коригування активного вузла після переходу на суфікс також може бути O (n)?

Ні. Причина полягає в тому, що якщо насправді нам доведеться відрегулювати активну точку (наприклад, від зеленого до синього, як зазначено вище), це приведе нас до нового вузла, який має власне суфіксне посилання, і active_lengthбуде зменшено. Коли ми слідуємо за ланцюжком суфіксних посилань, ми робимо інші вставки, а active_lengthлише зменшуємось, і кількість коригувань активної точки, які ми можемо внести на шляху, не може бути більшою, ніж active_lengthу будь-який момент часу. Оскільки active_lengthніколи не може бути більшим за remainderта remainder є O (n) не тільки на кожному етапі, але загальна сума приростів, коли-небудь зроблених remainderпротягом всього процесу, також є O (n), кількість активних коригувань точки становить також обмежена O (n).


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

68
І слід додати, що це не ґрунтується на описі, знайденому в книзі Дена Гусфілда. Це нова спроба описати алгоритм, спочатку розглядаючи рядок без повторів, а потім обговорюючи спосіб обробки повторів. Я сподівався, що це буде більш інтуїтивно зрозумілим.
jogojapan

8
Дякую @jogojapan, я зміг написати повноцінний приклад завдяки вашим поясненням. Я опублікував джерело, тож сподіваюся, що хтось інший може знайти його корисним: gist.github.com/2373868
Nathan Ridley

4
@NathanRidley Так (до речі, цей заключний біт - це те, що Укконен називає канонізацією). Один із способів запустити це - переконатися, що існує підряд, який з’являється тричі і закінчується рядком, який з’являється ще раз у ще іншому контексті. Напр abcdefabxybcdmnabcdex. Початкова частина параметра abcdповторюється в abxy(це створює внутрішній вузол після ab) і знову в abcdex, і закінчується в bcd, що з'являється не тільки в bcdexконтексті, але і в bcdmnконтексті. Після abcdexвставки ми bcdex
переходимо

6
Добре, мій код був повністю переписаний і тепер працює правильно для всіх випадків, включаючи автоматичну канонізацію, плюс має набагато приємніший вихід тексту графіків. gist.github.com/2373868
Натан Рідлі

132

Я спробував реалізувати суфіксне дерево з підходом, вказаним у відповіді jogojapan, але це не спрацювало в деяких випадках через формулювання, яке використовується для правил. Більше того, я згадував, що ніхто не зумів реалізувати абсолютно правильне суфіксне дерево за допомогою цього підходу. Нижче я напишу "огляд" відповіді jogojapan з деякими модифікаціями правил. Я також опишу випадок, коли ми забудемо створити важливі суфіксні посилання.

Використовуються додаткові змінні

  1. активна точка - потрійний (active_node; active_edge; active_length), що показує, звідки треба починати вставляти новий суфікс.
  2. залишок - показує кількість суфіксів, які ми повинні чітко додати . Наприклад, якщо наше слово "abcaabca", а решта = 3, це означає, що ми повинні обробити три останні суфікси: bca , ca і a .

Давайте скористаємося поняттям внутрішнього вузла - всі вузли, крім кореня та листя - це внутрішні вузли .

Спостереження 1

Коли в дереві вже існує остаточний суфікс, який нам потрібно вставити, саме дерево взагалі не змінюється (ми лише оновлюємо active pointі remainder).

Спостереження 2

Якщо в якийсь момент active_lengthбільша або дорівнює довжині поточного ребра ( edge_length), ми рухаємось active pointвниз, поки не edge_lengthбуде суворо більше active_length.

Тепер давайте переглянемо правила:

Правило 1

Якщо після вставки з активного вузла = root , активна довжина перевищує 0, то:

  1. активний вузол не змінено
  2. активна довжина зменшується
  3. активний край зміщений вправо (до першого символу наступного суфікса ми повинні вставити)

Правило 2

Якщо ми створюємо новий внутрішній вузол або зробити вставки з внутрішнього вузла , і це не перший ТАКИЙ внутрішній вузол на поточному кроці, то ми посилаємося попередній ТАКИЙ вузол з ЦИМИ один через посилання суфікса .

Це визначення Rule 2відрізняється від jogojapan ', оскільки тут ми враховуємо не тільки новостворені внутрішні вузли, але й внутрішні вузли, з яких робимо вставку.

Правило 3

Після вставки з активного вузла, який не є кореневим вузлом, ми повинні перейти за суфіксним посиланням та встановити активний вузол на той вузол, на який він вказує. Якщо зв’язку суфіксу немає, встановіть активний вузол на кореневий вузол. Так чи інакше, активний край та активна довжина залишаються незмінними.

У цьому визначенні Rule 3ми також розглянемо вставки листових вузлів (не тільки розділених вузлів).

І нарешті, Спостереження 3:

Коли символ, який ми хочемо додати до дерева, вже знаходиться на краю, ми, згідно з цим Observation 1, лише оновлюємо active pointі remainder, залишаючи дерево незмінним. АЛЕ якщо є внутрішній вузол, позначений як потрібний суфіксний зв’язок , ми мусимо з'єднати цей вузол із нашим поточним active nodeчерез суфіксний зв’язок.

Давайте подивимось на приклад дерева суфіксів для cdddcdc, якщо ми додамо суфіксне посилання в такому випадку, а якщо ми цього не зробимо:

  1. Якщо ми не з'єднуємо вузли за допомогою суфіксного посилання:

    • перед додаванням останньої літери c :

    • після додавання останньої літери c :

  2. Якщо ми НЕ підключаємо вузли за допомогою суфіксного посилання:

    • перед додаванням останньої літери c :

    • після додавання останньої літери c :

Схоже, суттєвої різниці немає: у другому випадку є ще два суфіксні посилання. Але ці суфіксні посилання є правильними , і один з них - від синього вузла до червоного - дуже важливий для нашого підходу з активною точкою . Проблема полягає в тому, що якщо ми не поставимо сюди суфікс-посилання, то пізніше, коли ми додамо нові дерева до дерева, ми можемо пропустити додавання до дерева деяких вузлів через Rule 3, оскільки, згідно з ним, якщо немає суфікс-посилання, тоді ми повинні поставити active_nodeкорінь.

Коли ми додавали останню букву до дерева, червоний вузол вже існував, перш ніж ми зробили вставку із синього вузла (край написано «c» ). Оскільки в блакитному вузлі з’явилася вставка, ми позначаємо її як потрібну суфіксну посилання . Потім, спираючись на активний підхід точки , active nodeбуло встановлено червоний вузол. Але ми не робимо вставки з червоного вузла, оскільки літера "с" вже на краю. Чи означає це, що синій вузол повинен залишатися без суфіксного зв’язку? Ні, ми повинні з'єднати синій вузол із червоним через суфіксний зв’язок. Чому це правильно? Тому що активна точкапідхід гарантує, що ми потрапимо в потрібне місце, тобто в наступне місце, де ми повинні обробити вставкукоротший суфікс.

Нарешті, ось мої реалізації Суфіксного дерева:

  1. Java
  2. C ++

Сподіваємось, що цей "огляд" у поєднанні з детальною відповіддю jogojapan допоможе комусь реалізувати своє власне дерево суфіксів.


3
Велике спасибі та +1 за ваші зусилля. Я впевнений, що ти маєш рацію .. хоча я не маю часу одразу продумати деталі. Я перевіряю пізніше і, можливо, потім зміню свою відповідь.
jogojapan

Велике спасибі, це справді допомогло. Хоча ви могли б бути більш конкретними щодо спостереження 3? Наприклад, наведення діаграм двох кроків, що вводять нове посилання суфікса. Чи пов’язаний вузол активним вузлом? (оскільки ми фактично не вставляємо 2-й вузол)
dyesdyes

@makagonov Ей, чи можете ви допомогти мені створити суфіксне дерево для вашого рядка "cdddcdc" Я трохи розгублений, роблячи це (початкові кроки).
tariq zafar

3
Що стосується правила 3, розумним способом є встановлення суфіксного зв’язку root на сам корінь і (за замовчуванням) встановлення зв'язку суфікса кожного вузла до кореня. Таким чином, ми можемо уникнути кондиціонування і просто перейти за суфіксним посиланням.
sqd

1
aabaacaad- це один із випадків, коли додавання додаткового суфіксного посилання може скоротити час оновлення потрійного. Висновок в останніх двох абзацах посади jogojapan є неправильним. Якщо ми не додамо посилання на суфікс, згаданий у цій публікації, середня часова складність повинна бути O (nlong (n)) або більше. Тому що потрібен додатковий час, щоб погуляти деревом, щоб знайти правильне active_node.
ІванаГіро

10

Дякую за добре пояснений підручник від @jogojapan , я реалізував алгоритм в Python.

Кілька незначних проблем, які згадував @jogojapan, виявляються складнішими, ніж я очікував, і до них потрібно ставитися дуже обережно. Мені обійшлося кілька днів, щоб зробити мою реалізацію достатньо надійною (я думаю). Проблеми та рішення наведені нижче:

  1. ЗакінчитьсяRemainder > 0 Виявляється, така ситуація може статися і під час розгортання кроку , а не лише в кінці всього алгоритму. Коли це станеться, ми можемо залишити залишок, actnode, actedge та actlength без змін , закінчити поточний крок розгортання та розпочати ще один крок, або продовжувати складати чи розгортати залежно від того, чи буде наступна таблиця в початковому рядку на поточному шляху або ні.

  2. Перестрибнути через вузли: Коли ми переходимо до суфіксного посилання, оновлюємо активну точку та виявляємо, що її компонент active_length не працює добре з новим active_node. Треба рухатися вперед до потрібного місця, щоб розділити, або вставити лист. Цей процес може бути не таким простим, тому що під час переміщення довжина та активність постійно змінюються, коли вам доведеться повернутися до кореневого вузла , дія і довжина активу можуть бути неправильними через ці рухи. Нам потрібні додаткові змінні, щоб зберегти цю інформацію.

    введіть тут опис зображення

Інші дві проблеми якось вказав @managonov

  1. Сплит може бути виродженим Коли ви намагаєтеся розділити край, колись ви виявите, що операція розділення є правильною на вузлі. У цьому випадку нам потрібно лише додати новий аркуш до цього вузла, прийняти його як стандартну операцію розділення краю, що означає, що зв'язки суфіксу, якщо такі є, повинні підтримуватися відповідно.

  2. Приховані суфіксні посилання Існує ще один особливий випадок, який виникає через проблему 1 та 2 . Іноді нам потрібно перейти через декілька вузлів до потрібної точки для розділення, ми можемо перевершити праву точку, якщо рухатимемося, порівнюючи рядок, що залишився, та мітки шляху. У цьому випадку суфіксним посиланням буде ненавмисно знехтуватися, якщо вони будуть такими. Цього можна уникнути, пам’ятаючи правильну точку при русі вперед. Суфіксний зв’язок слід підтримувати, якщо розділений вузол вже існує, або навіть проблема 1 трапляється під час кроку розгортання.

Нарешті, моя реалізація в Python полягає в наступному:

Поради: Він включає функцію друку наївного дерева в наведеному вище коді, що дуже важливо під час налагодження . Це заощадило мені багато часу і зручно для розміщення особливих справ.


10

Вибачте, якщо моя відповідь здається зайвою, але я нещодавно реалізував алгоритм Укконена, і я виявив, що борюся з ним цілими днями; Мені довелося прочитати кілька статей на цю тему, щоб зрозуміти, чому і як деякі основні аспекти алгоритму.

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

Я опублікував свою C # реалізацію тут: https://github.com/baratgabor/SuffixTree

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

Передумови

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

(Однак мені довелося додати деякі основні розповіді для потоку, щоб початок справді відчував себе зайвим.)

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

Листові вузли відкритого типу та їх обмеження

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

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

Це, мабуть, елементарно, і не потрібно зазначати, що повторювані підрядки не з’являються явно на дереві, оскільки дерево вже містить їх через те, що вони повторюються; однак, коли повторювана підрядка закінчується зустріччю неповторюваного символу, нам потрібно створити розгалуження в цій точці, щоб представити розбіжність з цієї точки далі.

Наприклад, у випадку рядка "ABCXABCY" (див. Нижче), розгалуження на X та Y потрібно додати до трьох різних суфіксів, ABC , BC та C ; інакше це не було б дійсним деревом суфіксів, і ми не змогли знайти всі підрядки рядка шляхом зіставлення символів від кореня вниз.

Ще раз підкреслимо - будь-яка операція, яку ми виконуємо на суфіксі на дереві, повинна бути відображена і послідовними суфіксами (наприклад, ABC> BC> C), інакше вони просто перестають бути дійсними суфіксами.

Повторне розгалуження в суфіксах

Але навіть якщо ми погоджуємось, що нам потрібно робити ці оновлення вручну, як ми можемо знати, скільки суфіксів потрібно оновити? Оскільки, коли ми додаємо повторний символ A (і решта повторених символів послідовно), ми ще не маємо уявлення, коли / де нам потрібно розділити суфікс на дві гілки. Необхідність розщеплення з'ясовується лише тоді, коли ми стикаємося з першим неповторюваним символом, в даному випадку Y (замість X, який вже існує у дереві).

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

Поняття "залишок" та "пересканування"

Змінна remainderговорить нам, скільки повторених символів ми додали неявно, без розгалуження; тобто скільки суфіксів нам потрібно відвідати, щоб повторити операцію розгалуження, як тільки ми знайшли перший символ, який ми не можемо зіставити. Це по суті дорівнює тому, скільки символів ми знаходимося в дереві від його кореня.

Отже, залишаючись з попереднім прикладом рядка ABCXABCY , ми співставляємо повторювану частину ABC "неявно", remainderщоразу збільшуючись , що призводить до залишку 3. Потім ми стикаємося з неповторюваним символом "Y" . Тут ми розколоти раніше доданий ABCX в ABC -> X і ABC -> Y . Потім ми декрементуємо remainderвід 3 до 2, тому що ми вже подбали про розгалуження ABC . Тепер ми повторюємо операцію, зіставляючи останні два символи - BC - від кореня, щоб дійти до точки, де нам потрібно розділити, і ми розділили BCX теж на BC->X і BC -> Y . Знову ми декрементуємо remainderдо 1 і повторюємо операцію; до remainder0. Нарешті, нам потрібно додати також поточний символ ( Y ) до кореня.

Ця операція за послідовними суфіксами з кореня просто дістається до того, де нам потрібно зробити операцію - це те, що в алгоритмі Укконена називається «пересканування» , і, як правило, це найдорожча частина алгоритму. Уявіть собі довший рядок, де вам потрібно «переробити» довгі підрядки через багато десятків вузлів (ми обговоримо це пізніше), потенційно тисячі разів.

В якості рішення ми вводимо те, що ми називаємо «суфіксними посиланнями» .

Поняття "суфіксні посилання"

Суфіксні посилання в основному вказують на позиції, до яких ми зазвичай мусимо б "пересканувати" , тож замість дорогої операції з перестановкою ми можемо просто перейти до пов'язаної позиції, виконати свою роботу, перейти до наступної зв’язаної позиції та повторити - поки не буде більше немає позицій для оновлення.

Звичайно, одне велике питання - як додати ці посилання. Існуюча відповідь полягає в тому, що ми можемо додавати посилання, коли ми вставляємо нові вузли гілок, використовуючи той факт, що в кожному розширенні дерева, вузли гілок, природно, створюються один за одним у тому порядку, у якому нам потрібно було б з'єднати їх разом . Хоча ми маємо зв'язати останній створений вузол гілки (найдовший суфікс) до створеного раніше, тому нам потрібно кешувати останній, який ми створюємо, прив’язати його до наступного, який ми створюємо, і кешувати новостворений.

Одним із наслідків є те, що ми насправді часто не маємо суфіксних посилань, які слід слідкувати, тому що даний вузол гілки щойно створений. У цих випадках нам доведеться все-таки повернутися до вищезгаданого «пересканування» з кореня. Ось чому після вставки вам доручається або використовувати посилання-суфікс, або перейти до кореня.

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

Поняття "активна точка"

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

Раніше пояснене поняття «залишок» корисне для відстеження того, де ми знаходимося на дереві, але ми маємо усвідомлювати, що воно не зберігає достатньо інформації.

По-перше, ми завжди перебуваємо на певному краю вузла, тому нам потрібно зберігати інформацію про край. Ми назвемо це «активним краєм» .

По-друге, навіть після додавання інформації про край, ми все ще не можемо визначити позицію, яка знаходиться далі в дереві і не безпосередньо пов'язана з кореневим вузлом. Тому нам потрібно також зберігати вузол. Назвемо цей «активний вузол» .

Нарешті, ми можемо помітити, що "залишок" є недостатнім для ідентифікації позиції на краю, який безпосередньо не пов'язаний з коренем, оскільки "залишок" - це довжина всього маршруту; і ми, мабуть, не хочемо турбуватися з запам'ятовуванням і відніманням довжини попередніх ребер. Тому нам потрібне подання, яке по суті є залишком на поточному краю . Це ми називаємо «активна довжина» .

Це призводить до того, що ми називаємо «активною точкою» - пакетом із трьох змінних, який містить всю інформацію, яку нам потрібно зберегти про наше становище у дереві:

Active Point = (Active Node, Active Edge, Active Length)

На наступному зображенні ви можете спостерігати, як узгоджений маршрут ABCABD складається з 2 символів на краю AB (від кореня ), плюс 4 символи на краю CABDABCABD (від вузла 4), що призводить до "залишку" з 6 символів. Отже, наше поточне положення можна ідентифікувати як Active Node 4, Active Edge C, Active Length 4 .

Залишок та активна точка

Ще одна важлива роль «активної точки» полягає в тому, що вона забезпечує рівень абстрагування для нашого алгоритму, тобто частина нашого алгоритму може виконувати свою роботу над «активною точкою» , незалежно від того, чи є ця активна точка в корені чи деінде . Це дозволяє легко реалізувати використання суфіксних посилань у нашому алгоритмі чітким та прямим способом.

Відмінність перегляду масштабів від використання суфіксних посилань

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

Розглянемо наступний приклад рядка 'AAAABAAAABAAC' :

Залишок по декількох краях

Ви можете спостерігати вище, як "залишок" 7 відповідає загальній сумі символів від кореня, а "активна довжина" 4 відповідає сумі відповідних символів з активного краю активного вузла.

Тепер, після виконання операції розгалуження в активній точці, наш активний вузол може містити або не містити суфіксного зв’язку.

Якщо суфікс є посиланням: нам потрібно лише обробити частину "активної довжини" . «Залишок» не має ніякого значення, так як вузол , де ми переходимо до по посиланню суфіксом вже кодує правильний «залишок» неявно , просто в силу того , в дереві , де він знаходиться.

Якщо посилання на суфікс НЕ присутній: нам потрібно 'пересканувати' з нуля / root, що означає обробку всього суфіксу з самого початку. З цією метою ми повинні використовувати весь «залишок» як основу для перегляду.

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

Розглянемо, що відбувається на наступному кроці наведеного вище прикладу. Порівняємо, як досягти того самого результату - тобто перейти до наступного суфіксу для обробки - із посиланням суфікса та без нього.

Використання "суфіксного посилання"

Досягнення послідовних суфіксів через суфіксні посилання

Зауважте, що якщо ми використовуємо суфікс-посилання, ми автоматично знаходимось у потрібному місці. Що часто не зовсім вірно через те, що "активна довжина" може бути "несумісною" з новою позицією.

У вищенаведеному випадку, оскільки "активна довжина" дорівнює 4, ми працюємо з суфіксом " ABAA" , починаючи з пов'язаного Вузла 4. Але після знаходження краю, який відповідає першому символу суфікса ( "A" ), ми помічаємо, що наша "активна довжина" переповнює цей край на 3 символи. Таким чином ми перестрибуємо через повний край, до наступного вузла, і декремент «активна довжина» символів, які ми споживали при стрибку.

Потім, після того як ми знайшли наступний край 'B' , що відповідає зменшеному суфіксу 'BAA ', ми нарешті зауважимо, що довжина ребра більша за решту "активної довжини" на 3, це означає, що ми знайшли потрібне місце.

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

Використання "пересканувати"

Досягнення послідовних суфіксів за допомогою пересканування

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

Довжина цього суфікса - це «залишок», про який ми говорили раніше. Ми повинні споживати всю цю залишок, поки вона не досягне нуля. Це може (і часто має місце) включати стрибки через кілька вузлів, при кожному стрибці зменшуючи залишок на довжину краю, через який ми проскочили. Потім, нарешті, ми досягаємо краю, який довший, ніж залишився наш «залишок» ; тут ми встановлюємо активний край на даний край, встановлюємо "активну довжину" на "залишок ", і ми закінчили.

Однак зауважте, що фактичну змінну 'залишок' потрібно зберегти та зменшити лише після кожного введення вузла. Отже, те, що я описав вище, передбачає використання окремої змінної, ініціалізованої до «залишку» .

Примітки про суфіксні посилання та ресканс

1) Зауважте, що обидва способи призводять до однакового результату. Однак у більшості випадків стрибки з суфіксом значно швидше; ось це все обґрунтування суфіксальних посилань.

2) Справжні алгоритмічні реалізації не повинні відрізнятися. Як я вже згадував вище, навіть у випадку використання суфіксального зв’язку, "активна довжина" часто не сумісна із пов'язаним положенням, оскільки ця гілка дерева може містити додаткові розгалуження. Тож по суті вам просто потрібно використовувати "активну довжину" замість "залишок" і виконувати ту саму логіку перегляду, поки ви не знайдете край, коротший від вашої залишкової довжини суфіксу.

3) Одне важливе зауваження, що стосується продуктивності, - це те, що під час повторного сканування немає необхідності перевіряти кожного персонажа. Зважаючи на те, як побудовано дійсне дерево суфіксів, ми можемо сміливо вважати, що символи відповідають. Таким чином, ви здебільшого підраховуєте довжини, і єдина потреба у перевірці еквівалентності символів виникає, коли ми переходимо до нового краю, оскільки краї ідентифікуються за їх першим символом (який завжди унікальний у контексті даного вузла). Це означає, що логіка «пересканування» відрізняється від логіки зіставлення повних рядків (тобто пошуку підрядків у дереві).

4) Описаний тут оригінальний суфікс - лише один із можливих підходів . Наприклад, NJ Larsson та ін. називає цей підхід " орієнтованим на вузол" згори вниз " і порівнює його з орієнтованим на вузол" знизу вгору " та двома різновидами, орієнтованими на край . Різні підходи мають різні типові та найгірші випадки роботи, вимоги, обмеження тощо, але, як правило, здається, що підхід, орієнтований на край , є загальним вдосконаленням оригіналу.


8

@jogojapan ви принесли приголомшливі пояснення та візуалізацію. Але, як згадував @makagonov, у ньому відсутні деякі правила щодо встановлення суфіксних посилань. Це добре видно, переходячи крок за кроком на http://brenden.github.io/ukkonen-animation/ через слово "aabaaabb". Коли ви переходите від кроку 10 до етапу 11, немає суфіксного зв’язку від вузла 5 до вузла 2, але активна точка раптово переміщується туди.

@makagonov, оскільки я живу в світі Java, я також намагався слідкувати за вашою реалізацією, щоб зрозуміти робочий процес будівництва ST, але мені було важко через:

  • поєднання країв з вузлами
  • використання вказівних покажчиків замість посилань
  • порушує заяви;
  • продовжувати заяви;

Тож я закінчив таку реалізацію на Java, яка, сподіваюся, відображає всі кроки більш чітко і скоротить час на навчання для інших людей Java:

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

public class ST {

  public class Node {
    private final int id;
    private final Map<Character, Edge> edges;
    private Node slink;

    public Node(final int id) {
        this.id = id;
        this.edges = new HashMap<>();
    }

    public void setSlink(final Node slink) {
        this.slink = slink;
    }

    public Map<Character, Edge> getEdges() {
        return this.edges;
    }

    public Node getSlink() {
        return this.slink;
    }

    public String toString(final String word) {
        return new StringBuilder()
                .append("{")
                .append("\"id\"")
                .append(":")
                .append(this.id)
                .append(",")
                .append("\"slink\"")
                .append(":")
                .append(this.slink != null ? this.slink.id : null)
                .append(",")
                .append("\"edges\"")
                .append(":")
                .append(edgesToString(word))
                .append("}")
                .toString();
    }

    private StringBuilder edgesToString(final String word) {
        final StringBuilder edgesStringBuilder = new StringBuilder();
        edgesStringBuilder.append("{");
        for(final Map.Entry<Character, Edge> entry : this.edges.entrySet()) {
            edgesStringBuilder.append("\"")
                    .append(entry.getKey())
                    .append("\"")
                    .append(":")
                    .append(entry.getValue().toString(word))
                    .append(",");
        }
        if(!this.edges.isEmpty()) {
            edgesStringBuilder.deleteCharAt(edgesStringBuilder.length() - 1);
        }
        edgesStringBuilder.append("}");
        return edgesStringBuilder;
    }

    public boolean contains(final String word, final String suffix) {
        return !suffix.isEmpty()
                && this.edges.containsKey(suffix.charAt(0))
                && this.edges.get(suffix.charAt(0)).contains(word, suffix);
    }
  }

  public class Edge {
    private final int from;
    private final int to;
    private final Node next;

    public Edge(final int from, final int to, final Node next) {
        this.from = from;
        this.to = to;
        this.next = next;
    }

    public int getFrom() {
        return this.from;
    }

    public int getTo() {
        return this.to;
    }

    public Node getNext() {
        return this.next;
    }

    public int getLength() {
        return this.to - this.from;
    }

    public String toString(final String word) {
        return new StringBuilder()
                .append("{")
                .append("\"content\"")
                .append(":")
                .append("\"")
                .append(word.substring(this.from, this.to))
                .append("\"")
                .append(",")
                .append("\"next\"")
                .append(":")
                .append(this.next != null ? this.next.toString(word) : null)
                .append("}")
                .toString();
    }

    public boolean contains(final String word, final String suffix) {
        if(this.next == null) {
            return word.substring(this.from, this.to).equals(suffix);
        }
        return suffix.startsWith(word.substring(this.from,
                this.to)) && this.next.contains(word, suffix.substring(this.to - this.from));
    }
  }

  public class ActivePoint {
    private final Node activeNode;
    private final Character activeEdgeFirstCharacter;
    private final int activeLength;

    public ActivePoint(final Node activeNode,
                       final Character activeEdgeFirstCharacter,
                       final int activeLength) {
        this.activeNode = activeNode;
        this.activeEdgeFirstCharacter = activeEdgeFirstCharacter;
        this.activeLength = activeLength;
    }

    private Edge getActiveEdge() {
        return this.activeNode.getEdges().get(this.activeEdgeFirstCharacter);
    }

    public boolean pointsToActiveNode() {
        return this.activeLength == 0;
    }

    public boolean activeNodeIs(final Node node) {
        return this.activeNode == node;
    }

    public boolean activeNodeHasEdgeStartingWith(final char character) {
        return this.activeNode.getEdges().containsKey(character);
    }

    public boolean activeNodeHasSlink() {
        return this.activeNode.getSlink() != null;
    }

    public boolean pointsToOnActiveEdge(final String word, final char character) {
        return word.charAt(this.getActiveEdge().getFrom() + this.activeLength) == character;
    }

    public boolean pointsToTheEndOfActiveEdge() {
        return this.getActiveEdge().getLength() == this.activeLength;
    }

    public boolean pointsAfterTheEndOfActiveEdge() {
        return this.getActiveEdge().getLength() < this.activeLength;
    }

    public ActivePoint moveToEdgeStartingWithAndByOne(final char character) {
        return new ActivePoint(this.activeNode, character, 1);
    }

    public ActivePoint moveToNextNodeOfActiveEdge() {
        return new ActivePoint(this.getActiveEdge().getNext(), null, 0);
    }

    public ActivePoint moveToSlink() {
        return new ActivePoint(this.activeNode.getSlink(),
                this.activeEdgeFirstCharacter,
                this.activeLength);
    }

    public ActivePoint moveTo(final Node node) {
        return new ActivePoint(node, this.activeEdgeFirstCharacter, this.activeLength);
    }

    public ActivePoint moveByOneCharacter() {
        return new ActivePoint(this.activeNode,
                this.activeEdgeFirstCharacter,
                this.activeLength + 1);
    }

    public ActivePoint moveToEdgeStartingWithAndByActiveLengthMinusOne(final Node node,
                                                                       final char character) {
        return new ActivePoint(node, character, this.activeLength - 1);
    }

    public ActivePoint moveToNextNodeOfActiveEdge(final String word, final int index) {
        return new ActivePoint(this.getActiveEdge().getNext(),
                word.charAt(index - this.activeLength + this.getActiveEdge().getLength()),
                this.activeLength - this.getActiveEdge().getLength());
    }

    public void addEdgeToActiveNode(final char character, final Edge edge) {
        this.activeNode.getEdges().put(character, edge);
    }

    public void splitActiveEdge(final String word,
                                final Node nodeToAdd,
                                final int index,
                                final char character) {
        final Edge activeEdgeToSplit = this.getActiveEdge();
        final Edge splittedEdge = new Edge(activeEdgeToSplit.getFrom(),
                activeEdgeToSplit.getFrom() + this.activeLength,
                nodeToAdd);
        nodeToAdd.getEdges().put(word.charAt(activeEdgeToSplit.getFrom() + this.activeLength),
                new Edge(activeEdgeToSplit.getFrom() + this.activeLength,
                        activeEdgeToSplit.getTo(),
                        activeEdgeToSplit.getNext()));
        nodeToAdd.getEdges().put(character, new Edge(index, word.length(), null));
        this.activeNode.getEdges().put(this.activeEdgeFirstCharacter, splittedEdge);
    }

    public Node setSlinkTo(final Node previouslyAddedNodeOrAddedEdgeNode,
                           final Node node) {
        if(previouslyAddedNodeOrAddedEdgeNode != null) {
            previouslyAddedNodeOrAddedEdgeNode.setSlink(node);
        }
        return node;
    }

    public Node setSlinkToActiveNode(final Node previouslyAddedNodeOrAddedEdgeNode) {
        return setSlinkTo(previouslyAddedNodeOrAddedEdgeNode, this.activeNode);
    }
  }

  private static int idGenerator;

  private final String word;
  private final Node root;
  private ActivePoint activePoint;
  private int remainder;

  public ST(final String word) {
    this.word = word;
    this.root = new Node(idGenerator++);
    this.activePoint = new ActivePoint(this.root, null, 0);
    this.remainder = 0;
    build();
  }

  private void build() {
    for(int i = 0; i < this.word.length(); i++) {
        add(i, this.word.charAt(i));
    }
  }

  private void add(final int index, final char character) {
    this.remainder++;
    boolean characterFoundInTheTree = false;
    Node previouslyAddedNodeOrAddedEdgeNode = null;
    while(!characterFoundInTheTree && this.remainder > 0) {
        if(this.activePoint.pointsToActiveNode()) {
            if(this.activePoint.activeNodeHasEdgeStartingWith(character)) {
                activeNodeHasEdgeStartingWithCharacter(character, previouslyAddedNodeOrAddedEdgeNode);
                characterFoundInTheTree = true;
            }
            else {
                if(this.activePoint.activeNodeIs(this.root)) {
                    rootNodeHasNotEdgeStartingWithCharacter(index, character);
                }
                else {
                    previouslyAddedNodeOrAddedEdgeNode = internalNodeHasNotEdgeStartingWithCharacter(index,
                            character, previouslyAddedNodeOrAddedEdgeNode);
                }
            }
        }
        else {
            if(this.activePoint.pointsToOnActiveEdge(this.word, character)) {
                activeEdgeHasCharacter();
                characterFoundInTheTree = true;
            }
            else {
                if(this.activePoint.activeNodeIs(this.root)) {
                    previouslyAddedNodeOrAddedEdgeNode = edgeFromRootNodeHasNotCharacter(index,
                            character,
                            previouslyAddedNodeOrAddedEdgeNode);
                }
                else {
                    previouslyAddedNodeOrAddedEdgeNode = edgeFromInternalNodeHasNotCharacter(index,
                            character,
                            previouslyAddedNodeOrAddedEdgeNode);
                }
            }
        }
    }
  }

  private void activeNodeHasEdgeStartingWithCharacter(final char character,
                                                    final Node previouslyAddedNodeOrAddedEdgeNode) {
    this.activePoint.setSlinkToActiveNode(previouslyAddedNodeOrAddedEdgeNode);
    this.activePoint = this.activePoint.moveToEdgeStartingWithAndByOne(character);
    if(this.activePoint.pointsToTheEndOfActiveEdge()) {
        this.activePoint = this.activePoint.moveToNextNodeOfActiveEdge();
    }
  }

  private void rootNodeHasNotEdgeStartingWithCharacter(final int index, final char character) {
    this.activePoint.addEdgeToActiveNode(character, new Edge(index, this.word.length(), null));
    this.activePoint = this.activePoint.moveTo(this.root);
    this.remainder--;
    assert this.remainder == 0;
  }

  private Node internalNodeHasNotEdgeStartingWithCharacter(final int index,
                                                         final char character,
                                                         Node previouslyAddedNodeOrAddedEdgeNode) {
    this.activePoint.addEdgeToActiveNode(character, new Edge(index, this.word.length(), null));
    previouslyAddedNodeOrAddedEdgeNode = this.activePoint.setSlinkToActiveNode(previouslyAddedNodeOrAddedEdgeNode);
    if(this.activePoint.activeNodeHasSlink()) {
        this.activePoint = this.activePoint.moveToSlink();
    }
    else {
        this.activePoint = this.activePoint.moveTo(this.root);
    }
    this.remainder--;
    return previouslyAddedNodeOrAddedEdgeNode;
  }

  private void activeEdgeHasCharacter() {
    this.activePoint = this.activePoint.moveByOneCharacter();
    if(this.activePoint.pointsToTheEndOfActiveEdge()) {
        this.activePoint = this.activePoint.moveToNextNodeOfActiveEdge();
    }
  }

  private Node edgeFromRootNodeHasNotCharacter(final int index,
                                             final char character,
                                             Node previouslyAddedNodeOrAddedEdgeNode) {
    final Node newNode = new Node(idGenerator++);
    this.activePoint.splitActiveEdge(this.word, newNode, index, character);
    previouslyAddedNodeOrAddedEdgeNode = this.activePoint.setSlinkTo(previouslyAddedNodeOrAddedEdgeNode, newNode);
    this.activePoint = this.activePoint.moveToEdgeStartingWithAndByActiveLengthMinusOne(this.root,
            this.word.charAt(index - this.remainder + 2));
    this.activePoint = walkDown(index);
    this.remainder--;
    return previouslyAddedNodeOrAddedEdgeNode;
  }

  private Node edgeFromInternalNodeHasNotCharacter(final int index,
                                                 final char character,
                                                 Node previouslyAddedNodeOrAddedEdgeNode) {
    final Node newNode = new Node(idGenerator++);
    this.activePoint.splitActiveEdge(this.word, newNode, index, character);
    previouslyAddedNodeOrAddedEdgeNode = this.activePoint.setSlinkTo(previouslyAddedNodeOrAddedEdgeNode, newNode);
    if(this.activePoint.activeNodeHasSlink()) {
        this.activePoint = this.activePoint.moveToSlink();
    }
    else {
        this.activePoint = this.activePoint.moveTo(this.root);
    }
    this.activePoint = walkDown(index);
    this.remainder--;
    return previouslyAddedNodeOrAddedEdgeNode;
  }

  private ActivePoint walkDown(final int index) {
    while(!this.activePoint.pointsToActiveNode()
            && (this.activePoint.pointsToTheEndOfActiveEdge() || this.activePoint.pointsAfterTheEndOfActiveEdge())) {
        if(this.activePoint.pointsAfterTheEndOfActiveEdge()) {
            this.activePoint = this.activePoint.moveToNextNodeOfActiveEdge(this.word, index);
        }
        else {
            this.activePoint = this.activePoint.moveToNextNodeOfActiveEdge();
        }
    }
    return this.activePoint;
  }

  public String toString(final String word) {
    return this.root.toString(word);
  }

  public boolean contains(final String suffix) {
    return this.root.contains(this.word, suffix);
  }

  public static void main(final String[] args) {
    final String[] words = {
            "abcabcabc$",
            "abc$",
            "abcabxabcd$",
            "abcabxabda$",
            "abcabxad$",
            "aabaaabb$",
            "aababcabcd$",
            "ababcabcd$",
            "abccba$",
            "mississipi$",
            "abacabadabacabae$",
            "abcabcd$",
            "00132220$"
    };
    Arrays.stream(words).forEach(word -> {
        System.out.println("Building suffix tree for word: " + word);
        final ST suffixTree = new ST(word);
        System.out.println("Suffix tree: " + suffixTree.toString(word));
        for(int i = 0; i < word.length() - 1; i++) {
            assert suffixTree.contains(word.substring(i)) : word.substring(i);
        }
    });
  }
}

6

Моя інтуїція така:

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

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

Після ітерацій len (string) у вас є дерево суфіксів, яке містить усі суфікси.

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

Наприклад, припустимо, ви бачили символів 'abcabc'. Активна точка буде представляти точку на дереві, що відповідає суфіксу 'abc'.

Активна точка представлена ​​символом (початок, перший, останній). Це означає, що ви зараз знаходитесь в точці дерева, до якої ви потрапляєте, починаючи з початкового вузла і потім подаючи символи в рядку [first: last]

Додаючи новий символ, ви дивитесь, чи є активна точка ще в існуючому дереві. Якщо так, то ви зробили. В іншому випадку вам потрібно додати новий вузол до дерева суфіксів у активній точці, повернутися до наступного найкоротшого збігу та перевірити ще раз.

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

Примітка 2. Коли ви додаєте новий вузол і резервний додаток, ви додаєте новий вказівник суфікса для нового вузла. Призначенням цього вказівника суфікса буде вузол у скороченій активній точці. Цей вузол або вже існує, або буде створений при наступній ітерації цього резервного циклу.

Примітка 3: Частина канонізації просто економить час на перевірку активної точки. Наприклад, припустимо, що ви завжди використовували origin = 0, а просто змінювали перше та останнє. Щоб перевірити активну точку, вам доведеться щоразу слідувати за суфіксним деревом уздовж усіх проміжних вузлів. Є сенс кешувати результат слідування цього шляху, записуючи лише відстань від останнього вузла.

Чи можете ви навести приклад коду того, що ви маєте на увазі під «виправленням» обмежувальних змінних?

Попередження щодо здоров’я: Я також вважав цей алгоритм особливо важким для розуміння, тому, будь ласка, розумійте, що ця інтуїція, ймовірно, невірна у всіх важливих деталях ...


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

3

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

Єдина відмінність реалізації полягає в тому, що я намагався використовувати крайовий об’єкт, а не просто використовувати символи.

його також присутній на https://gist.github.com/suchitpuri/9304856

    require 'pry'


class Edge
    attr_accessor :data , :edges , :suffix_link
    def initialize data
        @data = data
        @edges = []
        @suffix_link = nil
    end

    def find_edge element
        self.edges.each do |edge|
            return edge if edge.data.start_with? element
        end
        return nil
    end
end

class SuffixTrees
    attr_accessor :root , :active_point , :remainder , :pending_prefixes , :last_split_edge , :remainder

    def initialize
        @root = Edge.new nil
        @active_point = { active_node: @root , active_edge: nil , active_length: 0}
        @remainder = 0
        @pending_prefixes = []
        @last_split_edge = nil
        @remainder = 1
    end

    def build string
        string.split("").each_with_index do |element , index|


            add_to_edges @root , element        

            update_pending_prefix element                           
            add_pending_elements_to_tree element
            active_length = @active_point[:active_length]

            # if(@active_point[:active_edge] && @active_point[:active_edge].data && @active_point[:active_edge].data[0..active_length-1] ==  @active_point[:active_edge].data[active_length..@active_point[:active_edge].data.length-1])
            #   @active_point[:active_edge].data = @active_point[:active_edge].data[0..active_length-1]
            #   @active_point[:active_edge].edges << Edge.new(@active_point[:active_edge].data)
            # end

            if(@active_point[:active_edge] && @active_point[:active_edge].data && @active_point[:active_edge].data.length == @active_point[:active_length]  )
                @active_point[:active_node] =  @active_point[:active_edge]
                @active_point[:active_edge] = @active_point[:active_node].find_edge(element[0])
                @active_point[:active_length] = 0
            end
        end
    end

    def add_pending_elements_to_tree element

        to_be_deleted = []
        update_active_length = false
        # binding.pry
        if( @active_point[:active_node].find_edge(element[0]) != nil)
            @active_point[:active_length] = @active_point[:active_length] + 1               
            @active_point[:active_edge] = @active_point[:active_node].find_edge(element[0]) if @active_point[:active_edge] == nil
            @remainder = @remainder + 1
            return
        end



        @pending_prefixes.each_with_index do |pending_prefix , index|

            # binding.pry           

            if @active_point[:active_edge] == nil and @active_point[:active_node].find_edge(element[0]) == nil

                @active_point[:active_node].edges << Edge.new(element)

            else

                @active_point[:active_edge] = node.find_edge(element[0]) if @active_point[:active_edge]  == nil

                data = @active_point[:active_edge].data
                data = data.split("")               

                location = @active_point[:active_length]


                # binding.pry
                if(data[0..location].join == pending_prefix or @active_point[:active_node].find_edge(element) != nil )                  


                else #tree split    
                    split_edge data , index , element
                end

            end
        end 
    end



    def update_pending_prefix element
        if @active_point[:active_edge] == nil
            @pending_prefixes = [element]
            return

        end

        @pending_prefixes = []

        length = @active_point[:active_edge].data.length
        data = @active_point[:active_edge].data
        @remainder.times do |ctr|
                @pending_prefixes << data[-(ctr+1)..data.length-1]
        end

        @pending_prefixes.reverse!

    end

    def split_edge data , index , element
        location = @active_point[:active_length]
        old_edges = []
        internal_node = (@active_point[:active_edge].edges != nil)

        if (internal_node)
            old_edges = @active_point[:active_edge].edges 
            @active_point[:active_edge].edges = []
        end

        @active_point[:active_edge].data = data[0..location-1].join                 
        @active_point[:active_edge].edges << Edge.new(data[location..data.size].join)


        if internal_node
            @active_point[:active_edge].edges << Edge.new(element)
        else
            @active_point[:active_edge].edges << Edge.new(data.last)        
        end

        if internal_node
            @active_point[:active_edge].edges[0].edges = old_edges
        end


        #setup the suffix link
        if @last_split_edge != nil and @last_split_edge.data.end_with?@active_point[:active_edge].data 

            @last_split_edge.suffix_link = @active_point[:active_edge] 
        end

        @last_split_edge = @active_point[:active_edge]

        update_active_point index

    end


    def update_active_point index
        if(@active_point[:active_node] == @root)
            @active_point[:active_length] = @active_point[:active_length] - 1
            @remainder = @remainder - 1
            @active_point[:active_edge] = @active_point[:active_node].find_edge(@pending_prefixes.first[index+1])
        else
            if @active_point[:active_node].suffix_link != nil
                @active_point[:active_node] = @active_point[:active_node].suffix_link               
            else
                @active_point[:active_node] = @root
            end 
            @active_point[:active_edge] = @active_point[:active_node].find_edge(@active_point[:active_edge].data[0])
            @remainder = @remainder - 1     
        end
    end

    def add_to_edges root , element     
        return if root == nil
        root.data = root.data + element if(root.data and root.edges.size == 0)
        root.edges.each do |edge|
            add_to_edges edge , element
        end
    end
end

suffix_tree = SuffixTrees.new
suffix_tree.build("abcabxabcd")
binding.pry
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.