W tym momencie czuję się trochę gruby. Spędziłem dni próbując całkowicie owinąć głowę nad konstrukcją drzewa sufiksów, ale ponieważ nie mam matematycznego zaplecza, wiele wyjaśnień wymyka mi się, gdy zaczynają nadmiernie używać symboliki matematycznej. Najbliższe dobre wyjaśnienie, które znalazłem, to szybkie wyszukiwanie ciągów za pomocą drzewek sufiksów , ale przegląda różne punkty, a niektóre aspekty algorytmu pozostają niejasne.
Jestem pewien, że wyjaśnienie tego algorytmu krok po kroku na temat przepełnienia stosu byłoby nieocenione dla wielu innych osób.
Dla odniesienia, oto artykuł Ukkonena na temat algorytmu: http://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf
Moje dotychczasowe zrozumienie:
- Muszę iterować przez każdy prefiks P danego ciągu T
- Muszę iterować przez każdy przyrostek S w prefiksie P i dodać go do drzewa
- Aby dodać przyrostek S do drzewa, muszę iterować każdą postać w S, przy czym iteracje polegają na chodzeniu po istniejącej gałęzi, która zaczyna się od tego samego zestawu znaków C w S i potencjalnie dzieleniu krawędzi na potomne węzły, gdy I osiągnij inną postać w sufiksie LUB, jeśli nie było żadnej pasującej krawędzi do zejścia. Jeśli nie znaleziono pasującej krawędzi, która mogłaby zejść dla C, tworzona jest nowa krawędź skrzydła dla C.
Podstawowym algorytmem wydaje się być O (n 2 ), jak wskazano w większości wyjaśnień, ponieważ musimy przejść przez wszystkie przedrostki, a następnie musimy przejść przez każdy z przyrostków dla każdego przedrostka. Algorytm Ukkonena jest najwyraźniej unikalny ze względu na technikę stosowaną przez przyrostek, chociaż myślę, że właśnie z tego powodu mam problem ze zrozumieniem.
Mam również problemy ze zrozumieniem:
- kiedy i jak „aktywny punkt” jest przypisywany, wykorzystywany i zmieniany
- co się dzieje z aspektem kanonizacji algorytmu
- Dlaczego implementacje, które widziałem, muszą „naprawić” zmienne graniczne, których używają
Oto gotowy kod źródłowy C # . Działa nie tylko poprawnie, ale obsługuje automatyczną kanonizację i renderuje ładniejszy wygląd tekstu wyjściowego. Kod źródłowy i przykładowe dane wyjściowe znajdują się w:
Aktualizacja 2017-11-04
Po wielu latach znalazłem nowe zastosowanie do drzewek sufiksów i zaimplementowałem algorytm w JavaScript . Gist jest poniżej. Powinien być wolny od błędów. Zrzuć go do pliku js npm install chalk
z tej samej lokalizacji, a następnie uruchom z node.js, aby zobaczyć kolorowe wyjście. W tej samej Gist istnieje wersja uproszczona, bez żadnego kodu debugującego.
https://gist.github.com/axefrog/c347bf0f5e0723cbd09b1aaed6ec6fc6
źródło
Odpowiedzi:
Poniżej znajduje się próba opisania algorytmu Ukkonen poprzez pokazanie, co robi, gdy łańcuch jest prosty (tj. Nie zawiera żadnych powtarzających się znaków), a następnie rozszerzenie go na pełny algorytm.
Najpierw kilka wstępnych stwierdzeń.
To, co budujemy, to w zasadzie trie wyszukiwania. Więc jest węzeł główny, krawędzie wychodzą z niego, prowadząc do nowych węzłów, i dalsze krawędzie wychodzą z nich, i tak dalej
Ale : W przeciwieństwie do trie wyszukiwania, etykiety krawędzi nie są pojedynczymi znakami. Zamiast tego każda krawędź jest oznaczona za pomocą pary liczb całkowitych
[from,to]
. Są to wskaźniki do tekstu. W tym sensie każda krawędź ma etykietę o dowolnej długości, ale zajmuje tylko spację O (1) (dwa wskaźniki).Podstawowa zasada
Chciałbym najpierw zademonstrować, jak utworzyć drzewo sufiksów szczególnie prostego łańcucha, łańcucha bez powtarzających się znaków:
Algorytm działa w krokach, od lewej do prawej . Jest jeden krok dla każdego znaku ciągu . Każdy krok może obejmować więcej niż jedną pojedynczą operację, ale zobaczymy (patrz końcowe obserwacje na końcu), że całkowita liczba operacji wynosi O (n).
Zaczynamy od lewej i najpierw wstawiamy tylko pojedynczy znak
a
, tworząc krawędź od węzła głównego (po lewej) do liścia i oznaczając go jako[0,#]
, co oznacza, że krawędź reprezentuje podłańcuch zaczynający się w pozycji 0 i kończący się na obecnym końcu . Używam tego symbolu,#
aby oznaczać bieżący koniec , który jest na pozycji 1 (zaraz poa
).Mamy więc wstępne drzewo, które wygląda następująco:
A to oznacza:
Teraz przechodzimy do pozycji 2 (zaraz po
b
). Naszym celem na każdym kroku jest wstawienie wszystkich sufiksów do aktualnej pozycji . Robimy to przeza
-edge doab
b
W naszej reprezentacji wygląda to tak
A to oznacza:
Obserwujemy dwie rzeczy:
ab
jest samo jak dawniej w początkowym drzewa:[0,#]
. Jego znaczenie zmieniło się automatycznie, ponieważ zaktualizowaliśmy bieżącą pozycję#
z 1 na 2.Następnie zwiększamy pozycję ponownie i aktualizujemy drzewo,
c
dodając a do każdej istniejącej krawędzi i wstawiając jedną nową krawędź dla nowego sufiksuc
.W naszej reprezentacji wygląda to tak
A to oznacza:
Obserwujemy:
#
, a wstawienie jednej nowej krawędzi dla końcowego znaku można wykonać w czasie O (1). Dlatego dla ciągu o długości n wymagany jest tylko czas O (n).Pierwsze rozszerzenie: proste powtórzenia
Oczywiście działa to tak ładnie tylko dlatego, że nasz ciąg nie zawiera żadnych powtórzeń. Teraz patrzymy na bardziej realistyczny ciąg:
Zaczyna się
abc
tak, jak w poprzednim przykładzie, następnieab
jest powtarzane i następujex
, a następnieabc
jest powtarzane, a następnied
.Kroki od 1 do 3: Po pierwszych 3 krokach mamy drzewo z poprzedniego przykładu:
Krok 4: Przechodzimy
#
do pozycji 4. To domyślnie aktualizuje wszystkie istniejące krawędzie do tego:i musimy wstawić ostatni przyrostek bieżącego kroku
a
, w katalogu głównym.Zanim to zrobimy, wprowadzamy jeszcze dwie zmienne (oprócz
#
), które oczywiście były tam przez cały czas, ale jak dotąd ich nie używaliśmy:(active_node,active_edge,active_length)
remainder
, która jest liczbą całkowitą wskazującą, ile nowych sufiksów należy wstawićDokładne znaczenie tych dwóch wkrótce stanie się jasne, ale na razie powiedzmy:
abc
przykładzie punktem aktywnym był zawsze(root,'\0x',0)
, tj.active_node
Węzeł główny,active_edge
był określony jako znak zerowy'\0x'
iactive_length
wynosił zero. Efektem tego było to, że jedna nowa krawędź, którą wstawiliśmy na każdym kroku, została wstawiona w węźle głównym jako świeżo utworzona krawędź. Wkrótce przekonamy się, dlaczego potrójny jest potrzebny do przedstawienia tych informacji.remainder
Był zawsze ustawiony na 1 na początku każdego kroku. Oznaczało to, że liczba przyrostków, które musieliśmy aktywnie wstawiać na końcu każdego kroku, wynosiła 1 (zawsze tylko ostatni znak).Teraz to się zmieni. Kiedy wstawić bieżącą ostateczną postać
a
u nasady, zauważamy, że nie ma już wychodzących krawędzi zaczynająca
, a konkretnie:abca
. Oto, co robimy w takim przypadku:[4,#]
w węźle głównym. Zamiast tego po prostu zauważamy, że przyrosteka
jest już w naszym drzewie. Kończy się na środku dłuższej krawędzi, ale nie przeszkadza nam to. Po prostu zostawiamy rzeczy takimi, jakie są.(root,'a',1)
. Oznacza to, że punkt aktywny znajduje się teraz gdzieś pośrodku krawędzi wychodzącej węzła głównego, która zaczyna sięa
od pozycji 1 na tej krawędzi. Zauważamy, że krawędź jest określona po prostu przez jej pierwszy znaka
. To wystarczy, ponieważ może istnieć tylko jedna krawędź zaczynająca się od dowolnego określonego znaku (potwierdź, że jest to prawda po przeczytaniu całego opisu).remainder
, więc na początku następnego kroku będzie to 2.Obserwacja: kiedy okaże się, że ostatni sufiks, który musimy wstawić , już istnieje w drzewie , samo drzewo w ogóle się nie zmienia (aktualizujemy tylko punkt aktywny i
remainder
). Drzewo nie jest zatem dokładną reprezentacją drzewa sufiksów aż do bieżącej pozycji , ale zawiera wszystkie sufiksy (ponieważ końcowy sufiksa
jest zawarty niejawnie ). Dlatego oprócz aktualizacji zmiennych (wszystkie o stałej długości, więc jest to O (1)), w tym kroku nie wykonano żadnej pracy .Krok 5: Aktualizujemy bieżącą pozycję
#
do 5. To automatycznie aktualizuje drzewo do tego:A ponieważ
remainder
jest to 2 , musimy wstawić dwa końcowe sufiksy bieżącej pozycji:ab
ib
. Wynika to głównie z tego, że:a
Przyrostek z poprzedniego etapu nigdy nie został prawidłowo włożony. Tak to pozostało , a ponieważ postępują krok, obecnie uprawiana oda
celuab
.b
.W praktyce oznacza to, że przechodzimy do aktywnego punktu (który wskazuje za tym,
a
co jest terazabcab
krawędzią) i wstawiamy aktualny końcowy znakb
. Ale: Ponownie okazuje się, żeb
jest już obecny na tej samej krawędzi.Zatem znowu nie zmieniamy drzewa. Po prostu:
(root,'a',2)
(ten sam węzeł i krawędź jak poprzednio, ale teraz wskazujemy zab
)remainder
do 3, ponieważ nadal nie wstawiliśmy poprawnie końcowej krawędzi z poprzedniego kroku i nie wstawiamy również bieżącej krawędzi końcowej.Żeby było jasne: musieliśmy wstawić
ab
ib
w bieżącym kroku, ale ponieważab
już został znaleziony, zaktualizowaliśmy punkt aktywny i nawet nie próbowaliśmy wstawićb
. Dlaczego? Ponieważ jeśliab
jest w drzewie, każdy jego przyrostek (w tymb
) musi również znajdować się w drzewie. Być może tylko pośrednio , ale musi tam być, ponieważ dotychczas zbudowaliśmy drzewo.Przechodzimy do kroku 6 , zwiększając
#
. Drzewo jest automatycznie aktualizowane do:Ponieważ
remainder
jest 3 , musimy wstawićabx
,bx
ix
. Aktywny punkt mówi nam, gdzie sięab
kończy, więc musimy tylko wskoczyć tam i wstawićx
. Rzeczywiście,x
jeszcze go nie ma, więc podzieliliśmyabcabx
krawędź i wstawiliśmy wewnętrzny węzeł:Reprezentacje krawędzi są nadal wskaźnikami w tekście, więc dzielenie i wstawianie węzła wewnętrznego można wykonać w czasie O (1).
Mamy więc do czynienia z
abx
i ubytekremainder
na 2. Teraz musimy wstawić następny pozostały sufiks,bx
. Ale zanim to zrobimy, musimy zaktualizować punkt aktywny. Reguła tego, po podzieleniu i wstawieniu krawędzi, będzie nazywała się Regułą 1 poniżej i ma zastosowanie zawsze, gdyactive_node
jest zrootowana (zasady 3 poznamy w dalszych przypadkach poniżej). Oto reguła 1:Stąd nowy potrójny punkt aktywny
(root,'b',1)
wskazuje, że następna wstawka musi być wykonana nabcabx
krawędzi, za 1 znakiem, tjb
. Za . Możemy zidentyfikować punkt wstawienia w czasie O (1) i sprawdzić, czyx
jest już obecny, czy nie. Gdyby był obecny, zakończylibyśmy obecny krok i pozostawili wszystko tak, jak jest. Alex
nie jest obecny, więc wstawiamy go, dzieląc krawędź:Ponownie zajęło to czas O (1) i aktualizujemy
remainder
do 1, a punkt aktywny do stanu(root,'x',0)
reguły 1.Ale jest jeszcze jedna rzecz, którą musimy zrobić. Nazwiemy to regułą 2:
Nadal trzeba wstawić ostateczny przyrostek aktualnego kroku
x
. Ponieważactive_length
składnik aktywnego węzła spadł do 0, ostateczna wstawka jest wykonywana bezpośrednio w katalogu głównym. Ponieważ w węźle głównym nie ma krawędzi wychodzącejx
, zaczynamy od nowej, wstawiamy nową krawędź:Jak widać, w bieżącym kroku wykonano wszystkie pozostałe wkładki.
Przechodzimy do kroku 7 , ustawiając wartość
#
= 7, któraa
jak zawsze automatycznie dołącza następny znak do wszystkich krawędzi liści. Następnie próbujemy wstawić nowy końcowy znak do aktywnego punktu (root) i stwierdzimy, że już tam jest. Tak więc kończymy bieżący krok bez wstawiania czegokolwiek i aktualizujemy punkt aktywny do(root,'a',1)
.W punkcie 8 ,
#
= 8, możemy dołączyćb
, i jak dotąd, to tylko oznacza, że aktualizować aktywny wskaż(root,'a',2)
i przyrostremainder
nie robiąc nic innego, bob
jest już obecny. Zauważamy jednak (w czasie O (1)), że punkt aktywny znajduje się teraz na końcu krawędzi. Odzwierciedlamy to, ustawiając ponownie na(node1,'\0x',0)
. Tutaj używamnode1
w odniesieniu do wewnętrznego węzła, na którymab
kończy się krawędź.Następnie w kroku
#
= 9 musimy wstawić „c”, co pomoże nam zrozumieć ostatnią sztuczkę:Drugie rozszerzenie: Korzystanie z łączy sufiksów
Jak zawsze
#
aktualizacjac
automatycznie dołącza się do krawędzi liścia i przechodzimy do aktywnego punktu, aby sprawdzić, czy możemy wstawić „c”. Okazuje się, że „c” istnieje już na tej krawędzi, więc ustawiamy punkt aktywny na(node1,'c',1)
, zwiększamyremainder
i nic więcej nie robimy.Teraz w etapie
#
= 10 ,remainder
jest 4, a więc najpierw umieścićabcd
(która pozostaje z 3 stopni temu), umieszczającd
w aktywnym miejscu.Próba wstawienia
d
w punkcie aktywnym powoduje podział krawędzi w czasie O (1):Symbol
active_node
, od którego rozpoczął się podział, jest zaznaczony na czerwono powyżej. Oto ostatnia zasada, reguła 3:Aktywny punkt jest teraz
(node2,'c',1)
inode2
jest zaznaczony na czerwono poniżej:Ponieważ wstawienie
abcd
jest zakończone, możemy zmniejszyćremainder
do 3 i rozważyć następny pozostały sufiks bieżącego krokubcd
. Reguła 3 ustawiła punkt aktywny tylko na właściwy węzeł i krawędź, więc wstawianiebcd
można wykonać, po prostu wstawiając jego końcowy znakd
w punkcie aktywnym.Spowoduje to kolejny podział krawędzi i z powodu reguły 2 musimy utworzyć link do sufiksu z wcześniej wstawionego węzła do nowego:
Obserwujemy: Łącza sufiksów pozwalają nam zresetować punkt aktywny, abyśmy mogli wykonać następny pozostały wkład przy wysiłku O (1). Spójrz na powyższy wykres, aby potwierdzić, że rzeczywiście węzeł w etykiecie
ab
jest połączony z węzłem wb
(jego sufiksem), a węzeł wabc
jest połączony zbc
.Aktualny krok jeszcze się nie zakończył.
remainder
jest teraz 2 i musimy postępować zgodnie z regułą 3, aby ponownie zresetować punkt aktywny. Ponieważ bieżącyactive_node
(czerwony powyżej) nie ma linku do sufiksu, resetujemy do rootowania. Punkt aktywny jest teraz(root,'c',1)
.Stąd obok wkładki występuje na jednej krawędzi wychodzącej z węzła głównego, którego etykieta rozpoczyna się
c
:cabxabcd
, za pierwszego znaku, czyli z tyłuc
. Powoduje to kolejny podział:A ponieważ wiąże się to z utworzeniem nowego węzła wewnętrznego, postępujemy zgodnie z regułą 2 i ustawiamy nowy link do sufiksu z wcześniej utworzonego węzła wewnętrznego:
(Używam Graphviz Dot do tych małych wykresów. Nowy link do sufiksu spowodował, że kropka ponownie ułożyła istniejące krawędzie, więc dokładnie sprawdź, czy jedyną rzeczą, która została wstawiona powyżej, jest nowy link do sufiksu.)
Dzięki temu
remainder
można ustawić wartość 1, a ponieważactive_node
jest to root, do aktualizacji punktu aktywnego używamy reguły 1(root,'d',0)
. Oznacza to, że ostatnią wstawką w bieżącym kroku jest wstawienie jednegod
w katalogu głównym:To był ostatni krok i jesteśmy skończeni. Istnieje jednak kilka uwag końcowych :
Na każdym kroku poruszamy
#
się o 1 pozycję do przodu. To automatycznie aktualizuje wszystkie węzły liści w czasie O (1).Nie dotyczy to jednak: a) żadnych sufiksów pozostałych po poprzednich krokach, oraz b) z jednym ostatnim znakiem bieżącego kroku.
remainder
mówi nam, ile dodatkowych wkładek musimy wykonać. Te wstawki odpowiadają jeden-do-jednego końcowym sufiksom łańcucha, który kończy się w bieżącej pozycji#
. Rozważamy jeden po drugim i wykonujemy wkładkę. Ważne: Każda wstawka jest wykonywana w czasie O (1), ponieważ punkt aktywny mówi nam dokładnie, gdzie iść, i musimy dodać tylko jeden znak w punkcie aktywnym. Dlaczego? Ponieważ pozostałe znaki są zawarte niejawnie (w przeciwnym razie punkt aktywny nie byłby tam, gdzie jest).Po każdej takiej wstawce zmniejszamy
remainder
i podążamy za przyrostkiem, jeśli taki istnieje. Jeśli nie, przejdziemy do rootowania (reguła 3). Jeśli już jesteśmy w katalogu głównym, modyfikujemy punkt aktywny za pomocą reguły 1. W każdym razie zajmuje to tylko czas O (1).Jeśli podczas jednej z tych wstawek okaże się, że znak, który chcemy wstawić, już tam jest, nic nie robimy i kończymy bieżący krok, nawet jeśli
remainder
> 0. Powodem jest to, że wszelkie wstawki będą sufiksami tego, który właśnie próbowaliśmy zrobić. Dlatego wszystkie są ukryte w bieżącym drzewie. Fakt, żeremainder
> 0 gwarantuje, że zajmiemy się pozostałymi przyrostkami później.Co jeśli na końcu algorytmu
remainder
> 0? Dzieje się tak za każdym razem, gdy koniec tekstu jest podciągiem, który pojawił się gdzieś wcześniej. W takim przypadku musimy dodać jeden dodatkowy znak na końcu ciągu, który nie występował wcześniej. W literaturze zwykle$
jest to symbol dolara . Dlaczego to ma znaczenie? -> Jeśli później użyjemy wypełnionego drzewa sufiksów do wyszukiwania sufiksów, musimy zaakceptować dopasowania tylko wtedy, gdy kończą się liściem . W przeciwnym razie otrzymalibyśmy wiele fałszywych dopasowań, ponieważ w drzewie pośrednio zawartych jest wiele łańcuchów , które nie są rzeczywistymi przyrostkami łańcucha głównego. Zmuszanieremainder
bycie 0 na końcu jest zasadniczo sposobem na zapewnienie, że wszystkie przyrostki kończą się na węźle liścia. Jeśli jednak chcemy użyć drzewa do wyszukiwania ogólnych podciągów , a nie tylko sufiksów łańcucha głównego, ten ostatni krok rzeczywiście nie jest wymagany, jak sugeruje komentarz OP poniżej.Jaka jest złożoność całego algorytmu? Jeśli tekst ma długość n znaków, oczywiście jest n kroków (lub n + 1, jeśli dodamy znak dolara). Na każdym etapie albo nic nie robimy (oprócz aktualizacji zmiennych), albo wykonujemy
remainder
wstawki, z których każdy zajmuje czas O (1). Ponieważremainder
wskazuje, ile razy nic nie zrobiliśmy w poprzednich krokach i jest zmniejszana dla każdej wstawionej teraz wkładki, łączna liczba wykonywanych czynności wynosi dokładnie n (lub n + 1). Zatem całkowita złożoność wynosi O (n).Jest jednak jedna drobna rzecz, której nie wyjaśniłem poprawnie: może się zdarzyć, że podążymy za sufiksem, zaktualizujemy punkt aktywny, a następnie okaże się, że jego
active_length
komponent nie działa dobrze z nowymactive_node
. Rozważmy na przykład taką sytuację:(Linie przerywane wskazują resztę drzewa. Linia kropkowana jest łącznikiem z przyrostkiem.)
Teraz niech będzie aktywny punkt
(red,'d',3)
, więc zwraca się do miejsca tyłf
nadefg
krawędzi. Załóżmy teraz, że dokonaliśmy niezbędnych aktualizacji, a następnie podążamy za sufiksem, aby zaktualizować punkt aktywny zgodnie z regułą 3. Nowy punkt aktywny to(green,'d',3)
. Jednakd
krawędź wychodząca z zielonego węzła jestde
, więc ma tylko 2 znaki. Aby znaleźć właściwy punkt aktywny, oczywiście musimy podążać tą krawędzią do niebieskiego węzła i zresetować do(blue,'f',1)
.W szczególnie złym przypadku wartość
active_length
może być tak dużaremainder
, jak n może być tak duża jak n. I może się zdarzyć, że aby znaleźć właściwy punkt aktywny, potrzebujemy nie tylko przeskoczyć przez jeden wewnętrzny węzeł, ale być może wielu, w najgorszym przypadku do n. Czy to oznacza, że algorytm ma ukrytą złożoność O (n 2 ), ponieważ na każdym etapieremainder
jest generalnie O (n), a korekty po aktywacji węzła po połączeniu z sufiksem również mogą być O (n)?Nie. Powodem jest to, że jeśli rzeczywiście musimy dostosować punkt aktywny (np. Z zielonego na niebieski, jak powyżej), to przenosi nas do nowego węzła, który ma własne łącze do sufiksu i
active_length
zostanie zmniejszony. Gdy podążamy za łańcuchem łączy sufiksowych, robimy pozostałe wstawki,active_length
możemy tylko zmniejszyć, a liczba korekt punktów aktywnych, które możemy wprowadzić po drodze, nie może być większa niżactive_length
w danym momencie. Ponieważactive_length
nigdy nie może być większy niżremainder
iremainder
wynosi O (n) nie tylko na każdym etapie, ale całkowita suma przyrostów kiedykolwiek dokonanychremainder
w trakcie całego procesu wynosi również O (n), liczba korekt punktów aktywnych wynosi również ograniczone przez O (n).źródło
abcdefabxybcdmnabcdex
. Początkowa częśćabcd
jest powtarzana wabxy
(tworzy to wewnętrzny węzeł późniejab
) i ponownie wabcdex
, i kończy się wbcd
, co pojawia się nie tylko wbcdex
kontekście, ale także wbcdmn
kontekście. Poabcdex
wstawieniu postępujemy zgodnie z linkiem sufiksu, aby wstawićbcdex
, i tam rozpocznie sięPróbowałem zaimplementować Drzewo sufiksów zgodnie z podejściem podanym w odpowiedzi jogojapan, ale w niektórych przypadkach nie działało z powodu sformułowania użytego w regułach. Ponadto wspomniałem, że nikt nie zdołał zaimplementować absolutnie poprawnego drzewa sufiksów przy użyciu tego podejścia. Poniżej napiszę „przegląd” odpowiedzi jogojapana z pewnymi modyfikacjami zasad. Opiszę również przypadek, gdy zapomnimy utworzyć ważne linki do sufiksów.
Zastosowano dodatkowe zmienne
Użyjmy pojęcia węzła wewnętrznego - wszystkie węzły, z wyjątkiem korzenia i liści, są węzłami wewnętrznymi .
Obserwacja 1
Kiedy okaże się, że ostatni sufiks, który musimy wstawić, już istnieje w drzewie, samo drzewo w ogóle się nie zmienia (aktualizujemy tylko
active point
iremainder
).Obserwacja 2
Jeśli w którymś momencie
active_length
jest większa lub równa długości aktualnego zbocza (edge_length
), przesuwamy naszactive point
dół, ażedge_length
będzie ściśle większy niżactive_length
.Teraz ponownie zdefiniujmy zasady:
Zasada nr 1
Reguła 2
Ta definicja
Rule 2
różni się od jogojapan, ponieważ tutaj bierzemy pod uwagę nie tylko nowo utworzone węzły wewnętrzne, ale także węzły wewnętrzne, z których wykonujemy wstawianie.Zasada 3
W tej definicji
Rule 3
rozważamy również wstawki węzłów liści (nie tylko węzłów dzielonych).I wreszcie obserwacja 3:
Kiedy symbol, który chcemy dodać do drzewa, jest już na krawędzi, my, zgodnie z
Observation 1
aktualizacją,active point
iremainder
pozostawiając drzewo bez zmian. ALE jeśli istnieje wewnętrzny węzeł oznaczony jako wymagający łącza sufiksowego , musimy połączyć ten węzeł z naszym prądemactive node
poprzez łącze sufiksowe.Spójrzmy na przykład drzewa sufiksów dla cdddcdc, jeśli w takim przypadku dodamy link do sufiksu, a jeśli nie:
Jeśli NIE połączymy węzłów poprzez link sufiksu:
Gdybyśmy DO połączyć węzły za pośrednictwem łącza przyrostkiem:
Wygląda na to, że nie ma znaczącej różnicy: w drugim przypadku są jeszcze dwa linki przyrostków. Ale te linki sufiksów są poprawne , a jedno z nich - od niebieskiego do czerwonego - jest bardzo ważne dla naszego podejścia z punktem aktywnym . Problem polega na tym, że jeśli nie dodamy tutaj linku do sufiksu, później, gdy dodamy kilka nowych liter do drzewa, możemy pominąć dodawanie niektórych węzłów do drzewa z powodu
Rule 3
, ponieważ zgodnie z nim, jeśli nie ma link sufiks, to musimy umieścićactive_node
root w katalogu głównym.Kiedy dodawaliśmy ostatnią literę do drzewa, czerwony węzeł już istniał, zanim zrobiliśmy wstawkę z niebieskiego węzła (krawędź oznaczona „c” ). Ponieważ z niebieskiego węzła pojawiła się wstawka, oznaczamy ją jako wymagającą łącza do sufiksu . Następnie, bazując na podejściu do punktu aktywnego ,
active node
ustawiono czerwony węzeł. Ale nie robimy wstawki z czerwonego węzła, ponieważ litera „c” jest już na krawędzi. Czy to oznacza, że niebieski węzeł musi zostać pozostawiony bez linku do sufiksu? Nie, musimy połączyć niebieski węzeł z czerwonym poprzez link sufiksu. Dlaczego to jest poprawne? Ponieważ punkt aktywnypodejście gwarantuje, że dotrzemy do właściwego miejsca, tj. do następnego miejsca, w którym musimy przetworzyć wstawkę o krótszym sufiksie.Wreszcie, oto moje implementacje drzewa sufiksów:
Mam nadzieję, że ten „przegląd” w połączeniu ze szczegółową odpowiedzią jogojapana pomoże komuś wdrożyć jego własne drzewo sufiksów.
źródło
aabaacaad
jest jednym z przypadków, w których dodanie dodatkowego sufiksu może skrócić czas aktualizacji potrójnego. Wniosek w dwóch ostatnich akapitach postu jogojapan jest błędny. Jeśli nie dodamy linków sufiksowych wspomnianych w tym poście, średnia złożoność czasu powinna wynosić O (nlong (n)) lub więcej. Ponieważ znalezienie właściwego drzewa zajmuje więcej czasuactive_node
.Dzięki za dobrze wyjaśniony samouczek @jogojapan , zaimplementowałem algorytm w Pythonie.
Kilka drobnych problemów wspomnianych przez @jogojapan okazuje się bardziej wyrafinowanych niż się spodziewałem i należy je traktować bardzo ostrożnie. Zapewnienie wystarczającej niezawodności implementacji kosztowało mnie kilka dni ( tak sądzę). Problemy i rozwiązania są wymienione poniżej:
Koniec z
Remainder > 0
Okazuje się, że taka sytuacja może się zdarzyć podczas kroku rozwijania , a nie tylko na końcu całego algorytmu. Kiedy tak się stanie, możemy pozostawić pozostałą część, actnode, actedge i actlength bez zmian , zakończyć bieżący krok rozwijania i rozpocząć kolejny krok albo dalej składać lub rozkładać, w zależności od tego, czy następny znak w oryginalnym ciągu znajduje się na bieżącej ścieżce lub nie.Przeskocz nad węzłami: Kiedy podążamy za sufiksem, zaktualizuj punkt aktywny, a następnie stwierdzimy, że jego składnik active_length nie działa dobrze z nowym węzłem active_node. Musimy przejść do właściwego miejsca, aby podzielić lub wstawić liść. Proces ten może nie być taki prosty, ponieważ podczas przesuwania długości i kąta działania ciągle się zmieniają, kiedy trzeba wrócić do węzła głównego , skok i długość działania mogą być nieprawidłowe z powodu tych ruchów. Potrzebujemy dodatkowych zmiennych, aby zachować te informacje.
Pozostałe dwa problemy zostały w jakiś sposób wskazane przez @managonov
Podział może się zdegenerować Podczas próby podziału krawędzi, czasami okaże się, że operacja podziału jest odpowiednia dla węzła. W takim przypadku musimy tylko dodać nowy liść do tego węzła, weź go jako standardową operację podziału krawędzi, co oznacza, że linki przyrostków, jeśli takie istnieją, powinny być odpowiednio obsługiwane.
Ukryte linki sufiksowe Istnieje inny szczególny przypadek, który powstaje w wyniku problemu 1 i problemu 2 . Czasami musimy przeskoczyć przez kilka węzłów do odpowiedniego punktu do podziału, możemy przekroczyć właściwy punkt, jeśli poruszymy się, porównując pozostały ciąg i etykiety ścieżek. W takim przypadku link do sufiksu zostanie przypadkowo pominięty, jeśli taki będzie. Można tego uniknąć, zapamiętując właściwy punkt podczas ruchu do przodu. Link do sufiksu powinien zostać zachowany, jeśli węzeł podzielony już istnieje, lub nawet problem 1 występuje podczas etapu rozwijania.
Wreszcie moja implementacja w Pythonie wygląda następująco:
źródło
Przepraszam, jeśli moja odpowiedź wydaje się zbędna, ale niedawno zaimplementowałem algorytm Ukkonena i przez kilka dni miałem z nim problemy; Musiałem przeczytać wiele artykułów na ten temat, aby zrozumieć, dlaczego i jak niektóre podstawowe aspekty algorytmu.
Uznałem, że podejście oparte na „regułach” poprzednich odpowiedzi było nieprzydatne dla zrozumienia podstawowych przyczyn , dlatego napisałem wszystko poniżej, koncentrując się wyłącznie na pragmatyce. Jeśli masz problem z przestrzeganiem innych wyjaśnień, tak jak ja, być może moje dodatkowe wyjaśnienie sprawi, że klikniesz.
Tutaj opublikowałem moją implementację C #: https://github.com/baratgabor/SuffixTree
Pamiętaj, że nie jestem ekspertem w tym temacie, więc poniższe sekcje mogą zawierać nieścisłości (lub gorzej). Jeśli napotkasz jakieś, możesz je edytować.
Wymagania wstępne
Punktem początkowym poniższego wyjaśnienia jest założenie, że znasz treść i użycie drzewek sufiksów, a także cechy algorytmu Ukkonen, np. Sposób rozszerzania znaku drzewa sufiksów po znaku od początku do końca. Zasadniczo zakładam, że przeczytałeś już niektóre inne wyjaśnienia.
(Musiałem jednak dodać podstawową narrację dotyczącą przepływu, więc początek może się wydawać zbędny).
Najciekawsze jest wyjaśnienie różnicy między używaniem łączy sufiksowych a ponownym skanowaniem z katalogu głównego . To sprawiło mi wiele błędów i problemów podczas wdrażania.
Otwarte węzły liści i ich ograniczenia
Jestem pewien, że już wiesz, że najbardziej podstawową „sztuczką” jest uświadomienie sobie, że możemy po prostu zostawić koniec sufiksów „otwarty”, tj. Odwołując się do bieżącej długości łańcucha zamiast ustawiać koniec na wartość statyczną. W ten sposób, gdy dodamy dodatkowe znaki, znaki te zostaną domyślnie dodane do wszystkich etykiet sufiksów, bez konieczności odwiedzania i aktualizowania wszystkich.
Ale to otwarte zakończenie sufiksów - z oczywistych powodów - działa tylko w przypadku węzłów, które reprezentują koniec łańcucha, tj. Węzłów liści w strukturze drzewa. Operacje rozgałęziania, które wykonujemy na drzewie (dodanie nowych węzłów gałęzi i węzłów liści) nie będą propagowane automatycznie wszędzie tam, gdzie będą potrzebne.
Jest to prawdopodobnie elementarne i nie wymagałoby wzmianki, że powtarzające się podciągi nie pojawiają się jawnie w drzewie, ponieważ drzewo już je zawiera, ponieważ są to powtórzenia; jednak gdy powtarzające się podciągi kończą się napotkaniem nie powtarzającego się znaku, musimy utworzyć rozgałęzienie w tym punkcie, aby reprezentować rozbieżność od tego momentu.
Na przykład w przypadku ciągu „ABCXABCY” (patrz poniżej) rozgałęzienie do X i Y należy dodać do trzech różnych sufiksów, ABC , BC i C ; w przeciwnym razie nie byłoby prawidłowym drzewem sufiksów i nie moglibyśmy znaleźć wszystkich podciągów łańcucha, dopasowując znaki od katalogu głównego w dół.
Jeszcze raz, aby podkreślić - każda operacja, którą wykonujemy na sufiksie w drzewie, musi być odzwierciedlona również przez kolejne sufiksy (np. ABC> BC> C), w przeciwnym razie po prostu przestaną być poprawnymi sufiksami.
Ale nawet jeśli akceptujemy konieczność ręcznych aktualizacji, skąd wiemy, ile sufiksów należy zaktualizować? Ponieważ kiedy dodajemy powtarzający się znak A (i resztę powtarzających się znaków kolejno), nie mamy jeszcze pojęcia, kiedy / gdzie musimy podzielić sufiks na dwie gałęzie. Potrzeba podzielenia zostaje stwierdzona tylko wtedy, gdy napotkamy pierwszy nie powtarzający się znak, w tym przypadku Y (zamiast X, który już istnieje w drzewie).
Możemy dopasować najdłuższy powtarzający się ciąg znaków i policzyć, ile jego sufiksów potrzebujemy zaktualizować później. To właśnie oznacza „reszta” .
Pojęcie „reszty” i „ponownego skanowania”
Zmienna
remainder
mówi nam, ile powtórzonych znaków dodaliśmy domyślnie, bez rozgałęziania; tzn. ile sufiksów musimy odwiedzić, aby powtórzyć operację rozgałęziania, gdy znajdziemy pierwszy znak, którego nie możemy dopasować. Zasadniczo jest to równe liczbie „głębokich” postaci znajdujących się w drzewie od jego korzenia.Tak więc, pozostając przy poprzednim przykładzie ciągu ABCXABCY , dopasowujemy powtarzaną część ABC „niejawnie”, zwiększając za
remainder
każdym razem, co skutkuje pozostałą liczbą 3. Następnie napotykamy nie powtarzający się znak „Y” . Tutaj dzielimy wcześniej dodanej ABCX w ABC -> X i ABC -> Y . Następnie zmniejszamyremainder
z 3 do 2, ponieważ już zadbaliśmy o rozgałęzienie ABC . Teraz powtarzamy operację, dopasowując ostatnie 2 znaki - BC - od rdzenia do punktu, w którym musimy się podzielić, i dzielimy BCX również na BC-> X i BC -> Y . Ponownie zmniejszamyremainder
do 1 i powtarzamy operację; aż doremainder
0. Na koniec musimy również dodać bieżący znak ( Y ) do korzenia.Ta operacja, po kolejnych sufiksach z katalogu głównego, aby dotrzeć do punktu, w którym musimy wykonać operację, w algorytmie Ukkonena nazywa się „ponownym skanowaniem” , i zazwyczaj jest to najdroższa część algorytmu. Wyobraź sobie dłuższy ciąg, w którym musisz „przeskanować” długie podciągi w wielu dziesiątkach węzłów (omówimy to później), potencjalnie tysiące razy.
Jako rozwiązanie wprowadzamy tak zwane „linki przyrostków” .
Pojęcie „linków przyrostkowych”
Łącza sufiksów w zasadzie wskazują na pozycje, w których normalnie musielibyśmy „ponownie skanować” , więc zamiast kosztownej operacji ponownego skanowania możemy po prostu przeskoczyć do połączonej pozycji, wykonać naszą pracę, przejść do następnej połączonej pozycji i powtarzać - aż do tego miejsca nie ma już pozycji do aktualizacji.
Oczywiście jednym wielkim pytaniem jest, jak dodać te linki. Istniejąca odpowiedź jest taka, że możemy dodawać łącza podczas wstawiania nowych węzłów gałęzi, wykorzystując fakt, że w każdym rozszerzeniu drzewa węzły gałęzi są naturalnie tworzone jeden po drugim w dokładnie takiej kolejności, w jakiej musielibyśmy je połączyć . Chociaż musimy połączyć z ostatnio utworzonym węzłem gałęzi (najdłuższy sufiks) do poprzednio utworzonego, więc musimy buforować ostatnio tworzone, połączyć to z następnym, który tworzymy, i buforować nowo utworzony.
Jedną z konsekwencji jest to, że w rzeczywistości często nie mamy linków do przyrostków, ponieważ właśnie utworzono dany węzeł odgałęzienia. W takich przypadkach musimy jeszcze wrócić do wspomnianego „ponownego skanowania” z poziomu root. Dlatego po wstawieniu instruujesz się, aby albo użyć sufiksu, albo przejść do rootowania.
(Lub alternatywnie, jeśli przechowujesz wskaźniki nadrzędne w węzłach, możesz spróbować podążać za rodzicami, sprawdzić, czy mają link i użyć go. Stwierdziłem, że jest to bardzo rzadko wspominane, ale użycie linku w sufiksie nie jest osadzone w kamieniach. Istnieje wiele możliwych podejść, a jeśli zrozumiesz podstawowy mechanizm, możesz wdrożyć taki, który najlepiej odpowiada Twoim potrzebom.)
Pojęcie „punktu aktywnego”
Do tej pory omawialiśmy wiele wydajnych narzędzi do budowania drzewa i niejasno mówiliśmy o przechodzeniu przez wiele krawędzi i węzłów, ale nie zbadaliśmy jeszcze odpowiednich konsekwencji i złożoności.
Wyjaśniona wcześniej koncepcja „reszty” jest przydatna do śledzenia, gdzie jesteśmy w drzewie, ale musimy zdać sobie sprawę, że nie przechowuje wystarczającej ilości informacji.
Po pierwsze, zawsze rezydujemy na określonej krawędzi węzła, dlatego musimy przechowywać informacje o krawędzi. Nazwiemy to „aktywną przewagą” .
Po drugie, nawet po dodaniu informacji o krawędzi, nadal nie mamy możliwości zidentyfikowania pozycji, która jest bardziej niżej w drzewie i nie jest bezpośrednio połączona z węzłem głównym . Więc musimy również zapisać węzeł. Nazwijmy to „aktywnym węzłem” .
Wreszcie możemy zauważyć, że „reszta” jest nieodpowiednia do zidentyfikowania pozycji na krawędzi, która nie jest bezpośrednio połączona z korzeniem, ponieważ „reszta” jest długością całej trasy; i prawdopodobnie nie chcemy zawracać sobie głowy zapamiętywaniem i odejmowaniem długości poprzednich krawędzi. Potrzebujemy więc reprezentacji, która jest zasadniczo pozostałą częścią bieżącej krawędzi . Nazywamy to „aktywną długością” .
Prowadzi to do czegoś, co nazywamy „punktem aktywnym” - pakiet trzech zmiennych, które zawierają wszystkie informacje, które musimy zachować na temat naszej pozycji w drzewie:
Active Point = (Active Node, Active Edge, Active Length)
Na poniższym obrazku można zobaczyć, w jaki sposób dopasowana trasa ABCABD składa się z 2 znaków na krawędzi AB (z katalogu głównego ) oraz 4 znaków na krawędzi CABDABCABD (z węzła 4) - w wyniku czego „reszta” wynosi 6 znaków. Tak więc naszą obecną pozycję można określić jako Active Node 4, Active Edge C, Active Length 4 .
Inną ważną rolą „punktu aktywnego” jest to, że zapewnia on warstwę abstrakcji dla naszego algorytmu, co oznacza, że części naszego algorytmu mogą wykonywać swoją pracę na „punkcie aktywnym” , niezależnie od tego, czy ten punkt aktywny znajduje się w katalogu głównym, czy gdziekolwiek indziej . Ułatwia to implementację użycia linków sufiksowych w naszym algorytmie w przejrzysty i prosty sposób.
Różnice w ponownym skanowaniu w porównaniu z użyciem łączy sufiksowych
Teraz trudną częścią, która - z mojego doświadczenia - może powodować wiele błędów i bólów głowy i jest słabo wyjaśniona w większości źródeł, jest różnica w przetwarzaniu przypadków linków do sufiksów w porównaniu do przypadków ponownego skanowania.
Rozważ następujący przykład ciągu „AAAABAAAABAAC” :
Powyżej można zaobserwować, w jaki sposób „reszta” 7 odpowiada całkowitej sumie znaków z katalogu głównego, a „długość aktywna” 4 odpowiada sumie dopasowanych znaków z aktywnej krawędzi aktywnego węzła.
Teraz, po wykonaniu operacji rozgałęzienia w punkcie aktywnym, nasz aktywny węzeł może zawierać link sufiksowy lub nie.
Jeśli istnieje link do sufiksu: Musimy przetworzyć tylko część „aktywnej długości” . „Reszta” nie ma znaczenia, ponieważ węzeł, gdzie skakać przez link do przyrostkiem już koduje prawidłowy „resztę” bez zastrzeżeń , po prostu z racji bycia w drzewie, gdzie jest.
Jeśli link do sufiksu NIE jest obecny: Musimy ponownie przeskanować od zera / roota, co oznacza przetworzenie całego sufiksu od początku. W tym celu musimy wykorzystać całą „pozostałą część” jako podstawę do ponownego skanowania.
Przykładowe porównanie przetwarzania z łączem sufiksu i bez niego
Zastanów się, co stanie się w następnym kroku powyższego przykładu. Porównajmy, jak osiągnąć ten sam wynik - tj. Przejście do następnego sufiksu do przetworzenia - z łączem sufiksu i bez niego.
Korzystanie z „sufiksu linku”
Zauważ, że jeśli użyjemy linku do sufiksu, jesteśmy automatycznie „we właściwym miejscu”. Co często nie jest do końca prawdą ze względu na fakt, że „długość aktywna” może być „niezgodna” z nową pozycją.
W powyższym przypadku, ponieważ „długość aktywna” wynosi 4, pracujemy z sufiksem „ ABAA” , zaczynając od połączonego węzła 4. Ale po znalezieniu krawędzi odpowiadającej pierwszemu znakowi sufiksu ( „A” ), zauważamy, że nasza „długość aktywna” przepełnia tę krawędź o 3 znaki. Przeskakujemy więc przez całą krawędź do następnego węzła i zmniejszamy „długość aktywną” przez postacie, które pochłonęliśmy przez skok.
Następnie, gdy znaleźliśmy następną krawędź „B” , odpowiadającą zmniejszonemu przyrostkowi „BAA ”, w końcu zauważamy, że długość krawędzi jest większa niż pozostała „aktywna długość” 3, co oznacza, że znaleźliśmy właściwe miejsce.
Należy pamiętać, że wydaje się, że ta operacja zwykle nie jest określana jako „ponowne skanowanie”, chociaż wydaje mi się, że jest to bezpośredni odpowiednik ponownego skanowania, tylko ze skróconą długością i punktem początkowym innym niż root.
Używanie „ponownego skanowania”
Zauważ, że jeśli zastosujemy tradycyjną operację „ponownego skanowania” (tutaj udając, że nie mamy linku do sufiksu), zaczynamy od szczytu drzewa, w katalogu głównym, i musimy znów iść w dół do właściwego miejsca, wzdłuż całej długości bieżącego sufiksu.
Długość tego sufiksu to „reszta” , o której mówiliśmy wcześniej. Musimy skonsumować całość tej reszty, aż osiągnie zero. Może to (i często obejmuje) przeskakiwanie przez wiele węzłów, przy każdym skoku zmniejszanie reszty o długość krawędzi, przez którą przeskoczyliśmy. W końcu dochodzimy do krawędzi, która jest dłuższa niż nasza pozostała „reszta” ; tutaj ustawiamy aktywną krawędź na daną krawędź, ustawiamy „aktywną długość” na pozostającą „resztę ” i gotowe.
Należy jednak pamiętać, że rzeczywista zmienna „pozostała” musi zostać zachowana i zmniejszana tylko po każdym wstawieniu węzła. Więc to, co opisałem powyżej, zakładało użycie oddzielnej zmiennej zainicjowanej na „resztę” .
Uwagi na temat łączy sufiksowych i ponownego skanowania
1) Zauważ, że obie metody prowadzą do tego samego wyniku. Skakanie do sufiksu jest jednak w większości przypadków znacznie szybsze; to całe uzasadnienie linków sufiksowych.
2) Rzeczywiste implementacje algorytmiczne nie muszą się różnić. Jak wspomniałem powyżej, nawet w przypadku użycia linku do sufiksu „długość aktywna” często nie jest kompatybilna z połączoną pozycją, ponieważ ta gałąź drzewa może zawierać dodatkowe rozgałęzienia. Zasadniczo musisz więc użyć „długości aktywnej” zamiast „reszty” i wykonać tę samą logikę ponownego skanowania, aż znajdziesz krawędź krótszą niż pozostała długość sufiksu.
3) Jedną ważną uwagą dotyczącą wydajności jest to, że nie ma potrzeby sprawdzania każdej postaci podczas ponownego skanowania. Ze względu na sposób budowania prawidłowego drzewa sufiksów możemy bezpiecznie założyć, że znaki są zgodne. Więc najczęściej liczysz długości, a jedyna potrzeba sprawdzenia równoważności znaków pojawia się, gdy przeskakujemy do nowej krawędzi, ponieważ krawędzie są identyfikowane przez ich pierwszy znak (który jest zawsze unikalny w kontekście danego węzła). Oznacza to, że logika „ponownego skanowania” różni się od logiki pełnego dopasowania łańcucha (tj. Wyszukiwanie podłańcucha w drzewie).
4) Opisane tutaj oryginalne połączenie przyrostków jest tylko jednym z możliwych podejść . Na przykład NJ Larsson i in. nazywa to podejście Node-Oriented Top-Down i porównuje je do Node-Oriented Bottom-Up i dwóch odmian zorientowanych na krawędzie . Różne podejścia mają różne typowe i najgorsze wyniki, wymagania, ograniczenia itp., Ale ogólnie wydaje się, że podejścia zorientowane na krawędź są ogólną poprawą do oryginału.
źródło
@ jojojapan przyniosłeś niesamowite wyjaśnienie i wizualizację. Ale jak wspomniał @makagonov, brakuje niektórych zasad dotyczących ustawiania linków do sufiksów. Jest to dobrze widoczne, gdy krok po kroku przechodzisz na http://brenden.github.io/ukkonen-animation/ poprzez słowo „aabaaabb”. Kiedy przechodzisz od kroku 10 do kroku 11, nie ma łącza przyrostka od węzła 5 do węzła 2, ale punkt aktywny nagle się tam przenosi.
@makagonov, ponieważ mieszkam w świecie Java, również próbowałem śledzić twoją implementację, aby zrozumieć przepływ pracy w budowaniu ST, ale było to dla mnie trudne z powodu:
Skończyło się na takiej implementacji w Javie, która - mam nadzieję - odzwierciedla wszystkie kroki w jaśniejszy sposób i skróci czas nauki dla innych ludzi Java:
źródło
Moja intuicja jest następująca:
Po zakończeniu iteracji głównej pętli zbudowałeś drzewo sufiksów, które zawiera wszystkie sufiksy pełnego łańcucha rozpoczynającego się na pierwsze k znaków.
Na początku oznacza to, że drzewo sufiksów zawiera pojedynczy węzeł główny, który reprezentuje cały ciąg znaków (jest to jedyny sufiks zaczynający się od 0).
Po iteracjach len (string) masz drzewo sufiksów, które zawiera wszystkie sufiksy.
Podczas pętli klucz jest punktem aktywnym. Domyślam się, że reprezentuje to najgłębszy punkt w drzewie przyrostków, który odpowiada poprawnemu przyrostkowi pierwszych k znaków ciągu. (Myślę, że właściwe oznacza, że przyrostek nie może być całym ciągiem.)
Załóżmy na przykład, że widziałeś znaki „abcabc”. Punkt aktywny reprezentowałby punkt w drzewie odpowiadający przyrostkowi „abc”.
Punkt aktywny jest reprezentowany przez (początek, pierwszy, ostatni). Oznacza to, że aktualnie znajdujesz się w punkcie drzewa, do którego dochodzisz, zaczynając od początku węzła, a następnie wprowadzając znaki w ciągu [pierwszy: ostatni]
Po dodaniu nowej postaci sprawdzisz, czy punkt aktywny nadal znajduje się w istniejącym drzewie. Jeśli tak, to koniec. W przeciwnym razie musisz dodać nowy węzeł do drzewa sufiksów w punkcie aktywnym, wrócić do następnego najkrótszego dopasowania i sprawdzić ponownie.
Uwaga 1: Wskaźniki sufiksów dają link do następnego najkrótszego dopasowania dla każdego węzła.
Uwaga 2: Gdy dodajesz nowy węzeł i zastępujesz go, dodajesz nowy wskaźnik sufiksu dla nowego węzła. Miejscem docelowym tego wskaźnika sufiksu będzie węzeł w skróconym punkcie aktywnym. Ten węzeł będzie już istniał lub zostanie utworzony podczas następnej iteracji tej pętli rezerwowej.
Uwaga 3: Część kanonizacyjna po prostu oszczędza czas w sprawdzaniu aktywnego punktu. Załóżmy na przykład, że zawsze używałeś origin = 0, a tylko zmieniałeś pierwszy i ostatni. Aby sprawdzić punkt aktywny, należy za każdym razem podążać za drzewem sufiksu wzdłuż wszystkich węzłów pośrednich. Sens buforowania wyniku podążania tą ścieżką ma sens, rejestrując tylko odległość od ostatniego węzła.
Czy możesz podać kodowy przykład tego, co rozumiesz przez „naprawienie” zmiennych granicznych?
Ostrzeżenie zdrowotne: Uważam również, że ten algorytm jest szczególnie trudny do zrozumienia, więc pamiętaj, że ta intuicja może być niepoprawna we wszystkich ważnych szczegółach ...
źródło
Cześć, próbowałem wdrożyć powyższą implementację w Ruby, sprawdź to. wydaje się, że działa dobrze.
jedyną różnicą w implementacji jest to, że próbowałem użyć obiektu krawędzi zamiast tylko symboli.
jest również obecny na https://gist.github.com/suchitpuri/9304856
źródło