Вибачте, якщо моя відповідь здається зайвою, але я нещодавно реалізував алгоритм Укконена, і я виявив, що борюся з ним цілими днями; Мені довелося прочитати кілька статей на цю тему, щоб зрозуміти, чому і як деякі основні аспекти алгоритму.
Я вважав, що підхід "правил" попередніх відповідей не є корисним для розуміння основних причин , тому я написав усе нижче, зосередившись лише на прагматиці. Якщо ви боролися з наступними поясненнями, як і я, можливо, моє додаткове пояснення зробить це «кліком» для вас.
Я опублікував свою 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 і повторюємо операцію; до remainder
0. Нарешті, нам потрібно додати також поточний символ ( 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 та ін. називає цей підхід " орієнтованим на вузол" згори вниз " і порівнює його з орієнтованим на вузол" знизу вгору " та двома різновидами, орієнтованими на край . Різні підходи мають різні типові та найгірші випадки роботи, вимоги, обмеження тощо, але, як правило, здається, що підхід, орієнтований на край , є загальним вдосконаленням оригіналу.