Algoritmo dell'albero dei suffissi di Ukkonen in un inglese semplice


1102

Mi sento un po 'spessa a questo punto. Ho trascorso giorni cercando di avvolgere completamente la mia testa attorno alla costruzione dell'albero dei suffissi, ma poiché non ho un background matematico, molte delle spiegazioni mi sfuggono mentre iniziano a fare un uso eccessivo della simbologia matematica. La più vicina a una buona spiegazione che ho trovato è la ricerca rapida di stringhe con alberi di suffissi , ma egli passa in rassegna vari punti e alcuni aspetti dell'algoritmo rimangono poco chiari.

Una spiegazione dettagliata di questo algoritmo qui su Stack Overflow sarebbe preziosa per molti altri oltre a me, ne sono sicuro.

Per riferimento, ecco l'articolo di Ukkonen sull'algoritmo: http://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf

La mia comprensione di base, finora:

  • Devo scorrere tutti i prefissi P di una determinata stringa T
  • Ho bisogno di scorrere ogni suffisso S nel prefisso P e aggiungerlo all'albero
  • Per aggiungere il suffisso S all'albero, devo scorrere tutti i caratteri in S, con le iterazioni che consistono nel camminare lungo un ramo esistente che inizia con lo stesso insieme di caratteri C in S e dividere potenzialmente un bordo in nodi discendenti quando I raggiungere un carattere diverso nel suffisso, OPPURE se non vi era alcun bordo corrispondente da percorrere. Quando non viene trovato alcun bordo corrispondente che cammina verso il basso per C, viene creato un nuovo bordo foglia per C.

L'algoritmo di base sembra essere O (n 2 ), come indicato nella maggior parte delle spiegazioni, poiché è necessario scorrere tutti i prefissi, quindi è necessario scorrere ciascuno dei suffissi per ciascun prefisso. L'algoritmo di Ukkonen è apparentemente unico a causa della tecnica del puntatore a suffisso che usa, anche se penso che sia quello che ho difficoltà a capire.

Ho anche difficoltà a capire:

  • esattamente quando e come il "punto attivo" viene assegnato, utilizzato e modificato
  • cosa sta succedendo con l'aspetto della canonizzazione dell'algoritmo
  • Perché le implementazioni che ho visto devono "correggere" le variabili di delimitazione che stanno utilizzando

Ecco il codice sorgente C # completato . Non solo funziona correttamente, ma supporta la canonizzazione automatica e rende un grafico di testo dall'aspetto migliore. Il codice sorgente e l'output di esempio sono:

https://gist.github.com/2373868


Aggiornamento 2017-11-04

Dopo molti anni ho trovato un nuovo utilizzo per gli alberi dei suffissi e ho implementato l'algoritmo in JavaScript . Gist è sotto. Dovrebbe essere privo di bug. Scaricalo in un file js, npm install chalkdalla stessa posizione, quindi eseguilo con node.js per vedere un output colorato. Esiste una versione ridotta nello stesso Gist, senza alcun codice di debug.

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


2
Hai dato un'occhiata alla descrizione fornita nel libro di Dan Gusfield ? L'ho trovato utile.
jogojapan,

4
L'essenziale non specifica la licenza: posso modificare il codice e ripubblicare sotto MIT (ovviamente con attribuzioni)?
Yurik,

2
Sì, vai per la tua vita. Consideralo di dominio pubblico. Come menzionato da un'altra risposta in questa pagina, c'è comunque un bug che deve essere corretto.
Nathan Ridley,

1
forse questa implementazione sarà aiutare gli altri, goto code.google.com/p/text-indexing
cos

2
"Consideralo di dominio pubblico" è, forse sorprendentemente, una risposta molto inutile. Il motivo è che in realtà è impossibile per te rendere il lavoro di dominio pubblico. Quindi il vostro "ritiene ..." commento sottolinea il fatto che la licenza non è chiara e dà la ragione lettore a dubbi sul fatto che lo stato del lavoro è in realtà chiaro a voi . Se desideri che le persone siano in grado di utilizzare il tuo codice, specifica una licenza, scegli la licenza che ti piace (ma, a meno che tu non sia un avvocato, scegli una licenza preesistente!)
James Youngman,

Risposte:


2379

Quello che segue è un tentativo di descrivere l'algoritmo di Ukkonen mostrando prima cosa fa quando la stringa è semplice (cioè non contiene caratteri ripetuti), e poi lo estende all'intero algoritmo.

Innanzitutto, alcune dichiarazioni preliminari.

  1. Ciò che stiamo costruendo è fondamentalmente come un trie di ricerca. Quindi c'è un nodo radice, i bordi che escono da esso portano a nuovi nodi e altri bordi che escono da quelli, e così via

  2. Ma : a differenza di un trie di ricerca, le etichette dei bordi non sono singoli caratteri. Invece, ogni fronte è etichettato usando una coppia di numeri interi [from,to]. Questi sono puntatori nel testo. In questo senso, ogni bordo porta un'etichetta di stringa di lunghezza arbitraria, ma occupa solo O (1) spazio (due puntatori).

Criterio basilare

Vorrei prima dimostrare come creare l'albero dei suffissi di una stringa particolarmente semplice, una stringa senza caratteri ripetuti:

abc

L'algoritmo funziona a passi, da sinistra a destra . C'è un passaggio per ogni carattere della stringa . Ogni passaggio può comportare più di una singola operazione, ma vedremo (vedere le osservazioni finali alla fine) che il numero totale di operazioni è O (n).

Quindi, partiamo da sinistra , e prima inseriamo solo il singolo carattere acreando un bordo dal nodo radice (a sinistra) su una foglia ed etichettandolo come [0,#], il che significa che il bordo rappresenta la sottostringa a partire dalla posizione 0 e terminando alla fine attuale . Uso il simbolo #per indicare la fine corrente , che si trova nella posizione 1 (subito dopo a).

Quindi abbiamo un albero iniziale, che assomiglia a questo:

E ciò significa che:

Ora passiamo alla posizione 2 (subito dopo b). Il nostro obiettivo in ogni passaggio è inserire tutti i suffissi fino alla posizione corrente . Lo facciamo per

  • espandendo l'attuale a-edge aab
  • inserendo un nuovo bordo per b

Nella nostra rappresentazione questo sembra

inserisci qui la descrizione dell'immagine

E ciò che significa è:

Osserviamo due cose:

  • La rappresentazione bordo per abè la stessa come usato essere nella struttura iniziale: [0,#]. Il suo significato è cambiato automaticamente perché abbiamo aggiornato la posizione corrente #da 1 a 2.
  • Ogni bordo consuma spazio O (1), poiché consiste solo di due puntatori nel testo, indipendentemente dal numero di caratteri che rappresenta.

Successivamente incrementiamo nuovamente la posizione e aggiorniamo l'albero aggiungendo un ca ogni bordo esistente e inserendo un nuovo bordo per il nuovo suffisso c.

Nella nostra rappresentazione questo sembra

E ciò che significa è:

Osserviamo:

  • L'albero è l'albero del suffisso corretto fino alla posizione corrente dopo ogni passaggio
  • Ci sono tanti passaggi quanti sono i caratteri nel testo
  • La quantità di lavoro in ogni passaggio è O (1), poiché tutti i bordi esistenti vengono aggiornati automaticamente incrementando #e l'inserimento di un nuovo bordo per il carattere finale può essere eseguito in O (1). Quindi per una stringa di lunghezza n, è richiesto solo il tempo O (n).

Prima estensione: ripetizioni semplici

Ovviamente funziona così bene solo perché la nostra stringa non contiene ripetizioni. Vediamo ora una stringa più realistica:

abcabxabcd

Inizia con abccome nell'esempio precedente, quindi abviene ripetuto e seguito da x, quindi abcviene ripetuto seguito da d.

Passaggi da 1 a 3: dopo i primi 3 passaggi abbiamo l'albero dell'esempio precedente:

Passaggio 4: passiamo #alla posizione 4. In questo modo si aggiorna implicitamente tutti i bordi esistenti a questo:

e dobbiamo inserire il suffisso finale del passaggio corrente a, alla radice.

Prima di farlo, introduciamo altre due variabili (oltre a #), che ovviamente sono sempre state lì ma non le abbiamo utilizzate finora:

  • Il punto attivo , che è una tripla (active_node,active_edge,active_length)
  • Il remainder, che è un numero intero che indica quanti nuovi suffissi dobbiamo inserire

Il significato esatto di questi due diventerà presto chiaro, ma per ora diciamo solo:

  • Nel semplice abcesempio, il punto attivo era sempre (root,'\0x',0), ovvero active_nodeera il nodo principale, active_edgeera specificato come carattere null '\0x'ed active_lengthera zero. L'effetto di questo è stato che l'unico nuovo bordo che abbiamo inserito in ogni passaggio è stato inserito nel nodo radice come bordo appena creato. Vedremo presto perché è necessaria una tripla per rappresentare queste informazioni.
  • Il valore remainderera sempre impostato su 1 all'inizio di ogni passaggio. Il significato di questo era che il numero di suffissi che dovevamo inserire attivamente alla fine di ogni passaggio era 1 (sempre solo il carattere finale).

Ora questo cambierà. Quando si inserisce il carattere finale corrente aalla radice, notiamo che v'è già un bordo di uscita iniziano a, in particolare: abca. Ecco cosa facciamo in questo caso:

  • Noi non inserire un bordo fresco [4,#]presso il nodo principale. Invece notiamo semplicemente che il suffisso aè già nel nostro albero. Termina nel mezzo di un bordo più lungo, ma non ne siamo disturbati. Lasciamo le cose così come sono.
  • Impostiamo il punto attivo su (root,'a',1). Ciò significa che il punto attivo è ora da qualche parte nel mezzo del bordo in uscita del nodo radice che inizia a, in particolare, dopo la posizione 1 su quel bordo. Notiamo che il bordo è specificato semplicemente dal suo primo carattere a. Questo è sufficiente perché può esserci un solo bordo che inizia con un carattere particolare (confermare che ciò è vero dopo aver letto l'intera descrizione).
  • Aumentiamo anche remainder, quindi all'inizio del prossimo passo sarà 2.

Osservazione: quando si trova che il suffisso finale che dobbiamo inserire esiste già nell'albero , l'albero stesso non viene affatto modificato (aggiorniamo solo il punto attivo e remainder). L'albero quindi non è più una rappresentazione accurata dell'albero del suffisso fino alla posizione corrente , ma contiene tutti i suffissi (poiché il suffisso finale aè implicitamente contenuto ). Quindi, a parte l'aggiornamento delle variabili (che sono tutte di lunghezza fissa, quindi questo è O (1)), non è stato fatto alcun lavoro in questo passaggio.

Passaggio 5: aggiorniamo la posizione corrente #su 5. In questo modo si aggiorna automaticamente l'albero a questo:

E poiché remainderè 2 , dobbiamo inserire due suffissi finali della posizione corrente: abe b. Questo è fondamentalmente perché:

  • Il asuffisso del passaggio precedente non è mai stato inserito correttamente. Così è rimasto , e dal momento che abbiamo progredito un passo, ora è cresciuto da aa ab.
  • E dobbiamo inserire il nuovo bordo finale b.

In pratica ciò significa che andiamo al punto attivo (che punta dietro aa quello che ora è il abcabbordo) e inseriamo il carattere finale corrente b. Ma: Ancora una volta, si scopre che bè già presente sullo stesso bordo.

Quindi, ancora una volta, non cambiamo l'albero. Semplicemente:

  • Aggiorna il punto attivo su (root,'a',2)(stesso nodo e bordo di prima, ma ora puntiamo a dietro il b)
  • Incrementa il remainder3 perché non abbiamo ancora inserito correttamente il bordo finale del passaggio precedente e non inseriamo nemmeno il bordo finale corrente.

Per essere chiari: abbiamo dovuto inserire abe bnel passaggio corrente, ma poiché abera già stato trovato, abbiamo aggiornato il punto attivo e non abbiamo nemmeno tentato di inserire b. Perché? Perché se si abtrova nell'albero, anche ogni suffisso (incluso b) deve trovarsi nell'albero. Forse solo implicitamente , ma deve essere lì, a causa del modo in cui abbiamo costruito l'albero finora.

Procediamo al passaggio 6 incrementando #. L'albero viene automaticamente aggiornato per:

Perché remainderè 3 , dobbiamo inserire abx, bxe x. Il punto attivo ci dice dove abfinisce, quindi dobbiamo solo saltare lì e inserire il x. Anzi, xnon c'è ancora, quindi dividiamo il abcabxbordo e inseriamo un nodo interno:

Le rappresentazioni dei bordi sono ancora dei puntatori nel testo, quindi la divisione e l'inserimento di un nodo interno possono essere eseguiti in O (1).

Così abbiamo affrontato abxe decremento remaindera 2. Ora abbiamo bisogno di inserire il suffisso restante prossimo, bx. Ma prima di farlo dobbiamo aggiornare il punto attivo. La regola per questo, dopo aver diviso e inserito un bordo, sarà chiamata Regola 1 di seguito, e si applica ogni volta che active_nodeè root (impareremo la regola 3 per altri casi più avanti). Ecco la regola 1:

Dopo un inserimento dalla radice,

  • active_node rimane root
  • active_edge è impostato sul primo carattere del nuovo suffisso che dobbiamo inserire, ad es b
  • active_length è ridotto di 1

Quindi, la nuova tripla del punto attivo (root,'b',1)indica che il successivo inserto deve essere effettuato sul bcabxbordo, dietro 1 carattere, cioè dietro b. Siamo in grado di identificare il punto di inserimento nel tempo O (1) e verificare se xè già presente o meno. Se fosse presente, termineremmo il passaggio attuale e lasceremmo tutto così com'è. Ma x non è presente, quindi lo inseriamo dividendo il bordo:

Ancora una volta, questo ha richiesto O (1) tempo e si aggiorna remaindera 1 e il punto attivo indica (root,'x',0)la regola 1.

Ma c'è un'altra cosa che dobbiamo fare. Chiameremo questa Regola 2:

Se dividiamo un bordo e inseriamo un nuovo nodo, e se questo non è il primo nodo creato durante il passaggio corrente, colleghiamo il nodo precedentemente inserito e il nuovo nodo tramite un puntatore speciale, un collegamento suffisso . Vedremo in seguito perché è utile. Ecco cosa otteniamo, il link del suffisso è rappresentato come un bordo tratteggiato:

Abbiamo ancora bisogno di inserire il suffisso finale del passo corrente, x. Poiché il active_lengthcomponente del nodo attivo è sceso a 0, l'inserimento finale viene effettuato direttamente nella radice. Poiché non esiste un bordo in uscita sul nodo radice che inizia con x, inseriamo un nuovo bordo:

Come possiamo vedere, nella fase attuale sono stati realizzati tutti gli inserti rimanenti.

Procediamo al passaggio 7 impostando #= 7, che aggiunge automaticamente il carattere successivo a, a tutti i bordi delle foglie, come sempre. Quindi tentiamo di inserire il nuovo carattere finale nel punto attivo (la radice) e scopriamo che è già lì. Quindi terminiamo il passaggio corrente senza inserire nulla e aggiorniamo il punto attivo su (root,'a',1).

Nel passaggio 8 , #= 8, aggiungiamo b, e come visto in precedenza, questo significa solo che aggiorniamo il punto attivo (root,'a',2)e lo incrementiamo remaindersenza fare altro, perché bè già presente. Tuttavia, notiamo (in O (1) tempo) che il punto attivo è ora alla fine di un bordo. Lo riflettiamo ripristinandolo su (node1,'\0x',0). Qui, mi node1riferisco al nodo interno aba cui termina il bordo.

Quindi, nel passaggio #= 9 , dobbiamo inserire 'c' e questo ci aiuterà a capire il trucco finale:

Seconda estensione: utilizzo dei collegamenti suffisso

Come sempre, l' #aggiornamento si aggiunge cautomaticamente ai bordi delle foglie e andiamo al punto attivo per vedere se possiamo inserire 'c'. Si scopre che 'c' esiste già su quel bordo, quindi impostiamo il punto attivo su (node1,'c',1), incrementiamo remaindere non facciamo altro.

Ora nel passaggio #= 10 , remainderè 4, quindi è necessario innanzitutto inserire abcd(che rimane di 3 passaggi fa) inserendolo dnel punto attivo.

Il tentativo di inserire dnel punto attivo provoca una divisione del bordo nel tempo O (1):

Il active_node, da cui è stata avviata la divisione, è contrassegnato in rosso sopra. Ecco la regola finale, Regola 3:

Dopo aver diviso un bordo da un active_nodenodo che non è il nodo principale, seguiamo il collegamento del suffisso che esce da quel nodo, se presente, e ripristiniamo active_nodeil nodo sul quale punta. Se non è presente alcun collegamento suffisso, impostiamo active_nodeil root. active_edge e active_lengthrimanere invariato.

Quindi il punto attivo è ora (node2,'c',1)ed node2è contrassegnato in rosso sotto:

Poiché l'inserimento di abcdcompletamento, si decrementa remaindera 3 e considerare il prossimo suffisso rimanente del passo corrente, bcd. La Regola 3 ha impostato il punto attivo solo sul nodo e sul bordo giusti, in modo che l'inserimento bcdpossa essere fatto semplicemente inserendo il suo carattere finale dnel punto attivo.

Ciò causa un'altra divisione del bordo e, a causa della regola 2 , dobbiamo creare un collegamento suffisso dal nodo precedentemente inserito a quello nuovo:

Osserviamo: i collegamenti di suffisso ci consentono di reimpostare il punto attivo in modo da poter effettuare il successivo inserimento rimanente con sforzo O (1). Guarda il grafico sopra per confermare che effettivamente il nodo in etichetta abè collegato al nodo in b(il suo suffisso), e il nodo in abcè collegato bc.

Il passaggio attuale non è ancora terminato. remainderè ora 2 e dobbiamo seguire la regola 3 per ripristinare nuovamente il punto attivo. Poiché l'attuale active_node(rosso sopra) non ha alcun link suffisso, ripristiniamo il root. Il punto attivo è ora (root,'c',1).

Quindi il prossimo inserto avviene a un bordo di uscita del nodo radice cui inizia un'etichetta con c: cabxabcd, dietro il primo carattere, cioè dietro c. Ciò provoca un'altra divisione:

E poiché ciò comporta la creazione di un nuovo nodo interno, seguiamo la regola 2 e impostiamo un nuovo link suffisso dal nodo interno precedentemente creato:

(Sto usando Graphviz Dot per questi piccoli grafici. Il nuovo link suffisso ha fatto sì che il punto riorganizzi i bordi esistenti, quindi controlla attentamente per confermare che l'unica cosa che è stata inserita sopra sia un nuovo link suffisso.)

Con questo, remainderpuò essere impostato su 1 e poiché active_nodeè root, usiamo la regola 1 per aggiornare il punto attivo (root,'d',0). Ciò significa che l'inserimento finale del passaggio corrente consiste nell'inserire un singolo d alla radice:

Questo è stato il passaggio finale e abbiamo finito. Esistono numerose osservazioni finali , tuttavia:

  • In ogni passaggio avanziamo #di 1 posizione. Ciò aggiorna automaticamente tutti i nodi foglia in O (1) tempo.

  • Ma non si occupa di a) eventuali suffissi rimanenti dai passaggi precedenti eb) con l'ultimo carattere finale del passaggio corrente.

  • remainderci dice quanti inserti aggiuntivi dobbiamo fare. Questi inserti corrispondono uno a uno ai suffissi finali della stringa che termina nella posizione corrente #. Consideriamo uno dopo l'altro e facciamo l'inserto. Importante: ogni inserimento viene eseguito in O (1) poiché il punto attivo ci dice esattamente dove andare e dobbiamo aggiungere solo un singolo carattere nel punto attivo. Perché? Perché gli altri personaggi sono contenuti implicitamente (altrimenti il ​​punto attivo non sarebbe dove si trova).

  • Dopo ciascuno di questi inserimenti, decrementiamo remaindere seguiamo il link del suffisso, se presente. Altrimenti andiamo alla radice (regola 3). Se siamo già alla radice, modifichiamo il punto attivo usando la regola 1. In ogni caso, ci vuole solo O (1) tempo.

  • Se, durante uno di questi inserti, troviamo che il personaggio che vogliamo inserire è già lì, non facciamo nulla e terminiamo il passaggio corrente, anche se remainder> 0. Il motivo è che tutti gli inserti rimanenti saranno suffissi di quello che abbiamo appena provato a realizzare. Quindi sono tutti impliciti nell'albero attuale. Il fatto che remainder> 0 si assicuri di gestire i suffissi rimanenti in un secondo momento.

  • E se alla fine dell'algoritmo remainder> 0? Questo avverrà ogni volta che la fine del testo è una sottostringa che si è verificata da qualche parte in precedenza. In tal caso, è necessario aggiungere un carattere aggiuntivo alla fine della stringa che non si è mai verificato prima. In letteratura, di solito il simbolo del dollaro $viene utilizzato come simbolo per questo. Perché è importante? -> Se in seguito utilizziamo l'albero dei suffissi completato per cercare i suffissi, dobbiamo accettare le corrispondenze solo se terminano in una foglia . Altrimenti avremmo molte corrispondenze spurie, perché ci sono molte stringhe implicitamente contenute nell'albero che non sono veri e propri suffissi della stringa principale. forzaturaremainderessere 0 alla fine è essenzialmente un modo per garantire che tutti i suffissi finiscano in un nodo foglia. Tuttavia, se vogliamo usare l'albero per cercare sottostringhe generali , non solo suffissi della stringa principale, questo passaggio finale non è effettivamente richiesto, come suggerito dal commento dell'OP di seguito.

  • Qual è la complessità dell'intero algoritmo? Se il testo è lungo n caratteri, ci sono ovviamente n passaggi (o n + 1 se aggiungiamo il simbolo del dollaro). In ogni passaggio o non facciamo nulla (oltre all'aggiornamento delle variabili), oppure facciamo remainderinserimenti, ognuno impiegando O (1) tempo. Dato che remainderindica quante volte non abbiamo fatto nulla nei passaggi precedenti ed è diminuito per ogni inserimento che facciamo ora, il numero totale di volte che facciamo qualcosa è esattamente n (o n + 1). Quindi, la complessità totale è O (n).

  • Tuttavia, c'è una piccola cosa che non ho spiegato correttamente: può succedere che seguiamo un link suffisso, aggiorniamo il punto attivo e quindi scopriamo che il suo active_lengthcomponente non funziona bene con il nuovo active_node. Ad esempio, considera una situazione come questa:

(Le linee tratteggiate indicano il resto dell'albero. La linea tratteggiata è un collegamento suffisso.)

Ora lascia che sia il punto attivo (red,'d',3), quindi punta al punto dietro fil defgbordo. Ora supponiamo che abbiamo fatto gli aggiornamenti necessari e ora seguiamo il link suffisso per aggiornare il punto attivo secondo la regola 3. Il nuovo punto attivo è (green,'d',3). Tuttavia, il d-edge che esce dal nodo verde è de, quindi ha solo 2 caratteri. Per trovare il punto attivo corretto, dobbiamo ovviamente seguire quel bordo fino al nodo blu e ripristinare (blue,'f',1).

In un caso particolarmente negativo, active_lengthpotrebbe essere grande quanto remainder, che può essere grande quanto n. E potrebbe benissimo accadere che per trovare il punto attivo corretto, non solo dobbiamo saltare su un nodo interno, ma forse anche molti, fino a n nel caso peggiore. Ciò significa che l'algoritmo ha una complessità O (n 2 ) nascosta , perché in ogni passaggio remainderè generalmente O (n) e anche le post-regolazioni al nodo attivo dopo aver seguito un collegamento suffisso potrebbero essere O (n)?

No. La ragione è che se davvero dovessimo regolare il punto attivo (es. Da verde a blu come sopra), questo ci porta a un nuovo nodo che ha il suo link suffisso e active_lengthsarà ridotto. Mentre seguiamo la catena di collegamenti dei suffissi, facciamo gli inserti rimanenti, active_lengthpossiamo solo diminuire e il numero di regolazioni del punto attivo che possiamo fare sulla strada non può essere maggiore di active_lengthin un dato momento. Poiché active_lengthnon può mai essere maggiore di remainder, ed remainder è O (n) non solo in ogni singolo passaggio, ma anche la somma totale degli incrementi mai fatti remaindernel corso dell'intero processo è O (n), il numero di regolazioni del punto attivo è delimitato anche da O (n).


74
Mi dispiace che sia finito un po 'più a lungo di quanto sperassi. E mi rendo conto che spiega una serie di cose banali che tutti sappiamo, mentre le parti difficili potrebbero non essere ancora perfettamente chiare. Modificiamolo insieme in forma.
jogojapan,

69
E dovrei aggiungere che questo non si basa sulla descrizione trovata nel libro di Dan Gusfield. È un nuovo tentativo di descrivere l'algoritmo considerando prima una stringa senza ripetizioni e quindi discutendo come vengono gestite le ripetizioni. Speravo fosse più intuitivo.
jogojapan,

8
Grazie @jogojapan, sono stato in grado di scrivere un esempio pienamente funzionante grazie alla tua spiegazione. Ho pubblicato la fonte quindi spero che qualcun altro possa trovarla utile: gist.github.com/2373868
Nathan Ridley

4
@NathanRidley Sì (a proposito, quel bit finale è ciò che Ukkonen chiama canonicizzare). Un modo per attivarlo è assicurarsi che ci sia una sottostringa che appare tre volte e termina in una stringa che appare ancora una volta in un contesto ancora diverso. Es abcdefabxybcdmnabcdex. La parte iniziale di abcdviene ripetuta in abxy(questo crea un nodo interno dopo ab) e di nuovo in abcdex, e termina in bcd, che appare non solo nel bcdexcontesto, ma anche nel bcdmncontesto. Dopo aver abcdexinserito, seguiamo il link del suffisso per inserire bcdex, e ci canonicize entrerà in azione.
jogojapan

6
Ok, il mio codice è stato completamente riscritto e ora funziona correttamente per tutti i casi, inclusa la canonizzazione automatica, inoltre ha un output grafico di testo molto più bello. gist.github.com/2373868
Nathan Ridley,

132

Ho cercato di implementare il Suffix Tree con l'approccio dato nella risposta di jogojapan, ma non ha funzionato in alcuni casi a causa della formulazione utilizzata per le regole. Inoltre, ho detto che nessuno è riuscito a implementare un albero di suffissi assolutamente corretto usando questo approccio. Di seguito scriverò una "panoramica" della risposta di jogojapan con alcune modifiche alle regole. Descriverò anche il caso in cui ci dimenticheremo di creare collegamenti con suffissi importanti .

Variabili aggiuntive utilizzate

  1. punto attivo - una tripla (active_node; active_edge; active_length), che mostra da dove dobbiamo iniziare a inserire un nuovo suffisso.
  2. resto - mostra il numero di suffissi che dobbiamo aggiungere esplicitamente . Ad esempio, se la nostra parola è 'abcaabca', e resto = 3, significa che dobbiamo elaborare 3 ultimi suffissi: bca , ca e a .

Usiamo un concetto di nodo interno : tutti i nodi, tranne la radice e le foglie sono nodi interni .

Osservazione 1

Quando si trova che il suffisso finale che dobbiamo inserire esiste già nell'albero, l'albero stesso non viene affatto modificato (aggiorniamo solo active pointe e remainder).

Osservazione 2

Se ad un certo punto active_lengthè maggiore o uguale alla lunghezza del bordo corrente ( edge_length), spostiamo active pointverso il basso fino a quando edge_lengthè strettamente maggiore di active_length.

Ora ridefiniamo le regole:

Regola 1

Se dopo un inserimento dal nodo attivo = root , la lunghezza attiva è maggiore di 0, quindi:

  1. il nodo attivo non è cambiato
  2. la lunghezza attiva è diminuita
  3. il bordo attivo viene spostato a destra (al primo carattere del suffisso successivo che dobbiamo inserire)

Regola 2

Se si crea un nuovo nodo interno O facciamo un inseritore da un nodo interno , e questo non è il primo TALE nodo interno al passo corrente, quindi colleghiamo precedente TALE nodo con PRESENTE uno attraverso un collegamento suffisso .

Questa definizione di Rule 2è diversa da jogojapan ', poiché qui prendiamo in considerazione non solo i nodi interni appena creati , ma anche i nodi interni, da cui facciamo un inserimento.

Regola 3

Dopo un inserimento dal nodo attivo che non è il nodo principale , dobbiamo seguire il collegamento suffisso e impostare il nodo attivo sul nodo a cui punta. Se non v'è un legame suffisso, impostare il nodo attivo alla radice nodo. In entrambi i casi, il bordo attivo e la lunghezza attiva rimangono invariati.

In questa definizione Rule 3consideriamo anche gli inserti di nodi foglia (non solo nodi divisi).

E infine, Osservazione 3:

Quando il simbolo che vogliamo aggiungere all'albero è già sul bordo, noi, secondo Observation 1, aggiorniamo solo active pointe remainder, lasciando l'albero invariato. MA se esiste un nodo interno contrassegnato come collegamento suffisso necessario , dobbiamo collegare quel nodo con la nostra corrente active nodeattraverso un collegamento suffisso.

Diamo un'occhiata all'esempio di un albero di suffisso per cdddcdc se in questo caso aggiungiamo un collegamento di suffisso e in caso contrario:

  1. Se NON colleghiamo i nodi tramite un collegamento suffisso:

    • prima di aggiungere l'ultima lettera c :

    • dopo aver aggiunto l'ultima lettera c :

  2. Se FACCIAMO connettere i nodi attraverso un link suffisso:

    • prima di aggiungere l'ultima lettera c :

    • dopo aver aggiunto l'ultima lettera c :

Sembra che non ci siano differenze significative: nel secondo caso ci sono altri due link suffisso. Ma questi collegamenti ai suffissi sono corretti e uno di essi - dal nodo blu a quello rosso - è molto importante per il nostro approccio con punto attivo . Il problema è che se non inseriamo un link suffisso qui, in seguito, quando aggiungiamo alcune nuove lettere all'albero, potremmo omettere di aggiungere alcuni nodi all'albero a causa di Rule 3, perché, secondo esso, se non c'è un link suffisso, quindi dobbiamo mettere active_nodeil root.

Quando stavamo aggiungendo l'ultima lettera all'albero, il nodo rosso era già esistito prima di effettuare un inserimento dal nodo blu (il bordo contrassegnato con 'c' ). Dato che c'era un inserto dal nodo blu, lo contrassegniamo come necessario per un collegamento suffisso . Quindi, basandosi sull'approccio del punto attivo , è active nodestato impostato il nodo rosso. Ma non facciamo un inserto dal nodo rosso, poiché la lettera 'c' è già sul bordo. Significa che il nodo blu deve essere lasciato senza un link suffisso? No, dobbiamo collegare il nodo blu con quello rosso tramite un collegamento suffisso. Perché è corretto? Perché il punto attivoL'approccio garantisce che arriviamo nel posto giusto, ovvero nel posto successivo in cui dobbiamo elaborare un inserto di un suffisso più breve .

Infine, ecco le mie implementazioni dell'albero dei suffissi:

  1. Giava
  2. C ++

Spero che questa "panoramica" combinata con la risposta dettagliata di jogojapan aiuterà qualcuno a implementare il proprio Suffix Tree.


3
Grazie mille e +1 per il tuo impegno. Sono sicuro che hai ragione .. anche se non ho il tempo di pensare subito ai dettagli. Controllerò più tardi e possibilmente modificherò anche la mia risposta.
jogojapan,

Grazie mille, mi ha davvero aiutato. Tuttavia, potresti essere più specifico su Observation 3? Ad esempio, fornendo i diagrammi dei 2 passaggi che introducono il nuovo link suffisso. Il nodo è collegato al nodo attivo? (dato che in realtà non inseriamo il secondo nodo)
coloranti

@makagonov Ehi, puoi aiutarmi a costruire l'albero dei suffissi per la tua stringa "cdddcdc". Sono un po 'confuso nel farlo (i passi iniziali).
Tariq Zafar,

3
Per quanto riguarda la regola 3, un modo intelligente è impostare il link del suffisso di root su root stesso e (di default) impostare il link del suffisso di ogni nodo su root. In questo modo possiamo evitare il condizionamento e seguire semplicemente il link del suffisso.
sq

1
aabaacaadè uno dei casi che mostra l'aggiunta di un link per il suffisso extra può ridurre i tempi di aggiornamento del triplo. La conclusione negli ultimi due paragrafi del post di jogojapan è errata. Se non aggiungiamo i collegamenti ai suffissi menzionati in questo post, la complessità del tempo medio dovrebbe essere O (nlong (n)) o più. Perché ci vuole più tempo per camminare sull'albero per trovare il giusto active_node.
IvanaGyro,

10

Grazie per il tutorial ben spiegato di @jogojapan , ho implementato l'algoritmo in Python.

Un paio di problemi minori menzionati da @jogojapan risultano essere più sofisticati di quanto mi aspettassi e devono essere trattati con molta attenzione. Mi è costato diversi giorni per ottenere la mia implementazione abbastanza solida (suppongo). I problemi e le soluzioni sono elencati di seguito:

  1. Fine conRemainder > 0 Si scopre questa situazione può accadere anche durante la fase di svolgimento , non solo la fine del l'intero algoritmo. Quando ciò accade, possiamo lasciare il resto, actnode, actedge e actlength invariati , terminare il passaggio di spiegamento corrente e iniziare un altro passaggio continuando a piegare o spiegando a seconda che il carattere successivo nella stringa originale sia sul percorso corrente o non.

  2. Salta su nodi: quando seguiamo un link suffisso, aggiorniamo il punto attivo e poi scopriamo che il suo componente active_length non funziona bene con il nuovo active_node. Dobbiamo andare avanti nel posto giusto per dividere o inserire una foglia. Questo processo potrebbe non essere così semplice perché durante lo spostamento la lunghezza effettiva e actedge continuano a cambiare fino in fondo, quando devi tornare al nodo principale , actedge e actlength potrebbero essere sbagliati a causa di quelle mosse. Abbiamo bisogno di ulteriori variabili per conservare tali informazioni.

    inserisci qui la descrizione dell'immagine

Gli altri due problemi sono stati in qualche modo evidenziati da @managonov

  1. La divisione potrebbe degenerare Quando si tenta di dividere un bordo, a volte si noterà che l'operazione di divisione si trova proprio su un nodo. In tal caso, dobbiamo solo aggiungere una nuova foglia a quel nodo, considerarla un'operazione di divisione dei bordi standard, il che significa che i collegamenti del suffisso, se presenti, devono essere mantenuti di conseguenza.

  2. Collegamenti ai suffissi nascosti C'è un altro caso speciale che si presenta con il problema 1 e 2 . A volte abbiamo bisogno di saltare diversi nodi nel punto giusto per la divisione, potremmo superare il punto giusto se ci spostiamo confrontando la stringa del resto e le etichette del percorso. In tal caso, il link del suffisso verrà trascurato involontariamente, se necessario. Ciò potrebbe essere evitato ricordando il punto giusto quando si procede in avanti. Il collegamento del suffisso dovrebbe essere mantenuto se il nodo diviso esiste già, o anche il problema 1 si verifica durante una fase di spiegamento.

Infine, la mia implementazione in Python è la seguente:

Suggerimenti: include una funzione di stampa ad albero ingenua nel codice sopra, che è molto importante durante il debug . Mi ha fatto risparmiare un sacco di tempo ed è comodo per individuare casi speciali.


10

Mi scuso se la mia risposta sembra ridondante, ma di recente ho implementato l'algoritmo di Ukkonen e mi sono ritrovato a lottare con esso per giorni; Ho dovuto leggere più articoli sull'argomento per capire perché e come alcuni aspetti fondamentali dell'algoritmo.

Ho trovato inutile l'approccio "regole" delle risposte precedenti per comprendere le ragioni sottostanti , quindi ho scritto tutto ciò che segue concentrandomi esclusivamente sulla pragmatica. Se hai avuto difficoltà a seguire altre spiegazioni, proprio come ho fatto io, forse la mia spiegazione supplementare lo farà "fare clic" per te.

Ho pubblicato la mia implementazione C # qui: https://github.com/baratgabor/SuffixTree

Tieni presente che non sono un esperto di questo argomento, quindi le seguenti sezioni potrebbero contenere imprecisioni (o peggio). Se ne incontri qualcuno, sentiti libero di modificare.

Prerequisiti

Il punto di partenza della seguente spiegazione presuppone che tu abbia familiarità con il contenuto e l'uso degli alberi dei suffissi e le caratteristiche dell'algoritmo di Ukkonen, ad esempio come estendi l'albero dei suffissi carattere per carattere, dall'inizio alla fine. Fondamentalmente, presumo che tu abbia già letto alcune delle altre spiegazioni.

(Tuttavia, ho dovuto aggiungere alcune narrazioni di base per il flusso, quindi l'inizio potrebbe davvero sembrare ridondante.)

La parte più interessante è la spiegazione della differenza tra l'uso dei collegamenti suffisso e la riconfigurazione dalla radice . Questo è ciò che mi ha dato molti bug e mal di testa nella mia implementazione.

Nodi foglia aperti e loro limiti

Sono sicuro che sai già che il "trucco" fondamentale è capire che possiamo semplicemente lasciare "aperta" la fine dei suffissi, ovvero fare riferimento alla lunghezza corrente della stringa anziché impostare la fine su un valore statico. In questo modo quando aggiungiamo altri personaggi, questi saranno aggiunti implicitamente a tutte le etichette dei suffissi, senza dover visitare e aggiornare tutti.

Ma questo finale aperto di suffissi - per ovvi motivi - funziona solo per i nodi che rappresentano la fine della stringa, cioè i nodi foglia nella struttura ad albero. Le operazioni di ramificazione che eseguiamo sull'albero (l'aggiunta di nuovi nodi di ramo e nodi foglia) non si propagheranno automaticamente ovunque debbano.

È probabilmente elementare, e non richiederebbe menzione, che le sottostringhe ripetute non compaiano esplicitamente nell'albero, dal momento che l'albero le contiene già in quanto ripetizioni; tuttavia, quando la sottostringa ripetitiva termina incontrando un carattere non ripetitivo, dobbiamo creare una ramificazione in quel punto per rappresentare la divergenza da quel punto in poi.

Ad esempio, nel caso della stringa "ABCXABCY" (vedi sotto), è necessario aggiungere una ramificazione a X e Y a tre diversi suffissi, ABC , BC e C ; altrimenti non sarebbe un albero di suffissi valido e non potremmo trovare tutte le sottostringhe della stringa abbinando i caratteri dalla radice in giù.

Ancora una volta, per sottolineare - qualsiasi operazione che eseguiamo su un suffisso nella struttura deve essere riflessa anche dai suoi suffissi consecutivi (ad es. ABC> BC> C), altrimenti cessano semplicemente di essere suffissi validi.

Ripetendo la ramificazione in suffissi

Ma anche se accettiamo che dobbiamo fare questi aggiornamenti manuali, come facciamo a sapere quanti suffissi devono essere aggiornati? Poiché, quando aggiungiamo il carattere ripetuto A (e il resto dei caratteri ripetuti in successione), non abbiamo ancora idea di quando / dove dobbiamo dividere il suffisso in due rami. La necessità di dividere è accertata solo quando incontriamo il primo carattere non ripetitivo, in questo caso Y (invece della X che già esiste nella struttura).

Quello che possiamo fare è abbinare la stringa ripetuta più lunga che possiamo e contare quanti dei suoi suffissi dobbiamo aggiornare in seguito. Questo è ciò che significa "resto" .

Il concetto di "resto" e "riscansione"

La variabile remainderci dice quanti caratteri ripetuti abbiamo aggiunto implicitamente, senza ramificazioni; vale a dire quanti suffissi dobbiamo visitare per ripetere l'operazione di ramificazione una volta trovato il primo carattere che non possiamo abbinare. Ciò equivale essenzialmente al numero di caratteri "profondi" che troviamo nell'albero dalla sua radice.

Quindi, rimanendo con l'esempio precedente della stringa ABCXABCY , abbiniamo 'implicitamente' la parte ABC ripetuta , aumentando remainderogni volta, il che risulta in resto di 3. Quindi incontriamo il carattere non ripetitivo 'Y' . Qui abbiamo diviso il precedentemente aggiunto ABCX in ABC -> X e ABC -> Y . Quindi diminuiamo remainderda 3 a 2, perché ci siamo già occupati della ramificazione ABC . Ora ripetiamo l'operazione abbinando gli ultimi 2 caratteri - BC - dalla radice per raggiungere il punto in cui dobbiamo dividere, e dividiamo BCX anche in BC-> X ) alla radice.e BC -> Y . Ancora una volta, diminuiamo remainderdi 1 e ripetiamo l'operazione; fino a remainder0. È infine necessario aggiungere il carattere corrente ( Y

Questa operazione, seguendo i suffissi consecutivi dalla radice semplicemente per raggiungere il punto in cui dobbiamo fare un'operazione è ciò che si chiama 'ripianificare' nell'algoritmo di Ukkonen, e in genere questa è la parte più costosa dell'algoritmo. Immagina una stringa più lunga in cui è necessario "ripetere la scansione" di sottostringhe lunghe, su molte decine di nodi (ne discuteremo più avanti), potenzialmente migliaia di volte.

Come soluzione, introduciamo quelli che chiamiamo "collegamenti suffisso" .

Il concetto di "collegamenti suffisso"

I collegamenti dei suffissi indicano sostanzialmente le posizioni in cui normalmente dovremmo "ripetere la scansione" , quindi al posto della costosa operazione di nuova scansione possiamo semplicemente saltare alla posizione collegata, fare il nostro lavoro, passare alla successiva posizione collegata e ripetere - fino a lì non ci sono più posizioni da aggiornare.

Naturalmente una grande domanda è come aggiungere questi collegamenti. La risposta esistente è che possiamo aggiungere i collegamenti quando inseriamo nuovi nodi di diramazione, utilizzando il fatto che, in ciascuna estensione dell'albero, i nodi di diramazione vengono naturalmente creati uno dopo l'altro nell'ordine esatto in cui avremmo bisogno di collegarli insieme . Tuttavia, dobbiamo collegare dall'ultimo nodo di diramazione creato (il suffisso più lungo) a quello creato in precedenza, quindi dobbiamo memorizzare nella cache l'ultimo che creiamo, collegarlo al successivo che creiamo e memorizzare nella cache quello appena creato.

Una conseguenza è che in realtà spesso non abbiamo collegamenti ai suffissi da seguire, perché il nodo del ramo dato è stato appena creato. In questi casi, dobbiamo ancora ricadere nella suddetta "riconcia" dalla radice. Questo è il motivo per cui, dopo un inserimento, ti viene richiesto di utilizzare il link del suffisso o di passare al root.

(O in alternativa, se si memorizzano i puntatori padre nei nodi, è possibile provare a seguire i genitori, verificare se hanno un collegamento e utilizzarlo. Ho scoperto che questo è menzionato molto raramente, ma l'utilizzo del collegamento suffisso non è incastonati in pietre. Esistono diversi approcci possibili e se capisci il meccanismo sottostante puoi implementarne uno che si adatta meglio alle tue esigenze.)

Il concetto di "punto attivo"

Finora abbiamo discusso di molteplici strumenti efficienti per la costruzione dell'albero e abbiamo vagamente riferito al passaggio su più bordi e nodi, ma non abbiamo ancora esplorato le conseguenze e le complessità corrispondenti.

Il concetto precedentemente spiegato di "resto" è utile per tenere traccia di dove siamo nella struttura, ma dobbiamo renderci conto che non memorizza abbastanza informazioni.

Innanzitutto, risiediamo sempre su un bordo specifico di un nodo, quindi è necessario memorizzare le informazioni sul bordo. Chiameremo questo "limite attivo" .

In secondo luogo, anche dopo aver aggiunto le informazioni sui bordi, non abbiamo ancora modo di identificare una posizione più in basso nell'albero e non direttamente connessa al nodo radice . Quindi dobbiamo anche memorizzare il nodo. Chiamiamo questo 'nodo attivo' .

Infine, possiamo notare che il 'resto' è inadeguato per identificare una posizione su un bordo che non è direttamente collegato alla radice, perché 'resto' è la lunghezza dell'intero percorso; e probabilmente non vogliamo preoccuparci di ricordare e sottrarre la lunghezza dei bordi precedenti. Quindi abbiamo bisogno di una rappresentazione che è essenzialmente il resto sul bordo corrente . Questo è ciò che chiamiamo "lunghezza attiva" .

Questo porta a quello che chiamiamo "punto attivo" - un pacchetto di tre variabili che contengono tutte le informazioni che dobbiamo mantenere sulla nostra posizione nella struttura:

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

Nell'immagine seguente è possibile osservare come il percorso abbinato di ABCABD sia composto da 2 caratteri sul bordo AB (dalla radice ), più 4 caratteri sul bordo CABDABCABD (dal nodo 4), risultando in un "resto" di 6 caratteri. Pertanto, la nostra posizione corrente può essere identificata come Nodo attivo 4, Bordo attivo C, Lunghezza attiva 4 .

Resto e punto attivo

Un altro ruolo importante del "punto attivo" è che fornisce un livello di astrazione per il nostro algoritmo, il che significa che parti del nostro algoritmo possono fare il loro lavoro sul "punto attivo" , indipendentemente dal fatto che quel punto attivo sia nella radice o in qualsiasi altro luogo . Ciò semplifica l'implementazione dell'utilizzo dei collegamenti ai suffissi nel nostro algoritmo in modo chiaro e diretto.

Differenze di ripianificazione rispetto all'utilizzo dei collegamenti suffisso

Ora, la parte difficile, qualcosa che - nella mia esperienza - può causare molti bug e mal di testa, ed è scarsamente spiegato nella maggior parte delle fonti, è la differenza nell'elaborazione dei casi di link suffisso rispetto ai casi di scansione.

Considera il seguente esempio della stringa "AAAABAAAABAAC" :

Resto su più bordi

Puoi osservare sopra come il 'resto' di 7 corrisponde alla somma totale dei caratteri dalla radice, mentre la 'lunghezza attiva' di 4 corrisponde alla somma dei caratteri corrispondenti dal bordo attivo del nodo attivo.

Ora, dopo aver eseguito un'operazione di diramazione nel punto attivo, il nostro nodo attivo potrebbe o meno contenere un collegamento suffisso.

Se è presente un collegamento suffisso: è necessario elaborare solo la parte "lunghezza attiva" . Il "resto" è irrilevante, perché il nodo in cui passiamo attraverso il link del suffisso codifica implicitamente il "resto" corretto , semplicemente in virtù del fatto di trovarsi nell'albero in cui si trova.

Se NON è presente un link suffisso: è necessario " eseguire nuovamente la scansione" da zero / root, il che significa elaborare l'intero suffisso dall'inizio. A tal fine dobbiamo usare l'intero "resto" come base per la riconcia.

Esempio di confronto di elaborazione con e senza un link suffisso

Considera cosa succede al passaggio successivo dell'esempio sopra. Confrontiamo come ottenere lo stesso risultato - ovvero passare al suffisso successivo da elaborare - con e senza un link suffisso.

Usando 'link suffisso'

Raggiungimento di suffissi consecutivi tramite collegamenti suffisso

Nota che se utilizziamo un link suffisso, siamo automaticamente "nel posto giusto". Ciò che spesso non è strettamente vero a causa del fatto che la "lunghezza attiva" può essere "incompatibile" con la nuova posizione.

Nel caso sopra, poiché la "lunghezza attiva" è 4, stiamo lavorando con il suffisso " ABAA" , iniziando dal nodo collegato 4. Ma dopo aver trovato il bordo che corrisponde al primo carattere del suffisso ( "A" ), notiamo che la nostra "lunghezza attiva" trabocca di questo bordo di 3 caratteri. Quindi saltiamo sul bordo completo, al nodo successivo, e diminuiamo la "lunghezza attiva" dei personaggi che abbiamo consumato con il salto.

Quindi, dopo aver trovato il bordo successivo 'B' , corrispondente al suffisso decrementato 'BAA ', notiamo infine che la lunghezza del bordo è maggiore della restante 'lunghezza attiva' di 3, il che significa che abbiamo trovato il posto giusto.

Si noti che questa operazione di solito non viene definita "ripianificazione", anche se a me sembra l'equivalente diretto della ripianificazione, solo con una lunghezza ridotta e un punto di partenza non root.

Utilizzando 'rescan'

Raggiungimento di suffissi consecutivi tramite nuova scansione

Nota che se utilizziamo una tradizionale operazione "rescan" (qui facendo finta di non avere un link suffisso), iniziamo dalla cima dell'albero, alla radice, e dobbiamo tornare di nuovo nel posto giusto, seguito per l'intera lunghezza del suffisso corrente.

La lunghezza di questo suffisso è il "resto" di cui abbiamo discusso in precedenza. Dobbiamo consumare l'intero resto, fino a quando non raggiunge lo zero. Questo potrebbe (e spesso lo fa) includere il salto attraverso più nodi, ad ogni salto diminuendo il resto della lunghezza del bordo che abbiamo attraversato. Quindi, infine, raggiungiamo un limite più lungo del nostro rimanente "resto" ; qui impostiamo il bordo attivo sul bordo dato, impostiamo 'lunghezza attiva' sul 'resto ' rimanente e il gioco è fatto.

Si noti, tuttavia, che la variabile 'resto' effettiva deve essere conservata e ridotta solo dopo l'inserimento di ciascun nodo. Quindi quello che ho descritto sopra presupponeva l'uso di una variabile separata inizializzata su "resto" .

Note sui collegamenti ai suffissi e riscans

1) Notare che entrambi i metodi portano allo stesso risultato. Nella maggior parte dei casi, il salto del suffisso è tuttavia molto più rapido; questa è l'intera logica alla base dei collegamenti ai suffissi.

2) Le attuali implementazioni algoritmiche non devono differire. Come ho accennato in precedenza, anche nel caso di utilizzare il collegamento suffisso, la "lunghezza attiva" spesso non è compatibile con la posizione collegata, poiché quel ramo dell'albero potrebbe contenere ramificazioni aggiuntive. Quindi, in sostanza, devi solo usare "lunghezza attiva" invece di "resto" ed eseguire la stessa logica di riscansione fino a trovare un bordo più corto della lunghezza del suffisso rimanente.

3) Un'osservazione importante relativa alle prestazioni è che non è necessario controllare tutti i personaggi durante la riconversione. A causa del modo in cui viene costruito un albero di suffisso valido, possiamo tranquillamente supporre che i caratteri corrispondano. Quindi stai principalmente contando le lunghezze e l'unica necessità del controllo dell'equivalenza dei caratteri sorge quando saltiamo su un nuovo bordo, poiché i bordi sono identificati dal loro primo carattere (che è sempre unico nel contesto di un dato nodo). Ciò significa che la logica di "riconversione" è diversa dalla logica di adattamento della stringa completa (ovvero ricerca di una sottostringa nella struttura).

4) Il collegamento del suffisso originale qui descritto è solo uno dei possibili approcci . Ad esempio NJ Larsson et al. nomina questo approccio come Top-Down orientato al nodo e lo confronta con il Bottom-Up orientato al nodo e due varietà orientate al bordo . I diversi approcci hanno prestazioni, requisiti, limitazioni, ecc. Tipici e peggiori, ma in generale sembra che gli approcci orientati ai bordi siano un miglioramento complessivo dell'originale.


8

@jogojapan hai portato una spiegazione e una visualizzazione fantastiche. Ma come menzionato da @makagonov mancano alcune regole relative all'impostazione dei collegamenti ai suffissi. È ben visibile quando si procede passo dopo passo su http://brenden.github.io/ukkonen-animation/ attraverso la parola 'aabaaabb'. Quando si passa dal passaggio 10 al passaggio 11, non esiste alcun collegamento suffisso dal nodo 5 al nodo 2 ma il punto attivo si sposta improvvisamente lì.

@makagonov da quando vivo nel mondo Java ho anche cercato di seguire la tua implementazione per comprendere il flusso di lavoro di costruzione di ST ma è stato difficile per me a causa di:

  • combinando i bordi con i nodi
  • utilizzando i puntatori di indice anziché i riferimenti
  • dichiarazioni di rotture;
  • continuare dichiarazioni;

Così ho finito con tale implementazione in Java che spero rifletta tutti i passaggi in modo più chiaro e ridurrà i tempi di apprendimento per altre persone 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

La mia intuizione è la seguente:

Dopo k iterazioni del ciclo principale è stato creato un albero di suffissi che contiene tutti i suffissi della stringa completa che iniziano con i primi k caratteri.

All'inizio, questo significa che l'albero dei suffissi contiene un singolo nodo radice che rappresenta l'intera stringa (questo è l'unico suffisso che inizia da 0).

Dopo le iterazioni di len (stringa) hai un albero di suffissi che contiene tutti i suffissi.

Durante il loop il tasto è il punto attivo. La mia ipotesi è che questo rappresenti il ​​punto più profondo nella struttura del suffisso che corrisponde a un suffisso adeguato dei primi k caratteri della stringa. (Penso che ciò significhi che il suffisso non può essere l'intera stringa.)

Ad esempio, supponiamo di aver visto i caratteri 'abcabc'. Il punto attivo rappresenterebbe il punto nella struttura corrispondente al suffisso "abc".

Il punto attivo è rappresentato da (origine, primo, ultimo). Ciò significa che si è attualmente nel punto dell'albero a cui si arriva iniziando dall'origine del nodo e quindi inserendo i caratteri nella stringa [primo: ultimo]

Quando aggiungi un nuovo personaggio, guardi per vedere se il punto attivo è ancora nell'albero esistente. Se lo è, allora hai finito. Altrimenti è necessario aggiungere un nuovo nodo all'albero dei suffissi nel punto attivo, ripristinare la corrispondenza più corta successiva e ricontrollare.

Nota 1: i puntatori suffisso forniscono un collegamento alla corrispondenza più breve successiva per ciascun nodo.

Nota 2: quando si aggiungono un nuovo nodo e un fallback si aggiunge un nuovo puntatore suffisso per il nuovo nodo. La destinazione per questo puntatore suffisso sarà il nodo nel punto attivo abbreviato. Questo nodo esisterà già o verrà creato nella successiva iterazione di questo ciclo di fallback.

Nota 3: la parte di canonizzazione consente semplicemente di risparmiare tempo nel controllo del punto attivo. Ad esempio, supponiamo che tu abbia sempre usato origin = 0 e che tu abbia appena cambiato il primo e l'ultimo. Per verificare il punto attivo dovresti seguire ogni volta l'albero dei suffissi lungo tutti i nodi intermedi. Ha senso memorizzare nella cache il risultato di seguire questo percorso registrando solo la distanza dall'ultimo nodo.

Puoi fornire un esempio di codice di cosa intendi per "correggere" le variabili di delimitazione?

Avvertenza per la salute: ho anche trovato questo algoritmo particolarmente difficile da capire, quindi ti preghiamo di tenere presente che questa intuizione è probabilmente errata in tutti i dettagli importanti ...


Uno dei documenti accademici definisce "proprio" nel senso che il "suffisso proprio" di una stringa non contiene il suo primo carattere. A volte si definisce un'intera sottostringa un "suffisso", ma quando si definisce l'algoritmo i termini "stringa" e "sottostringa" e "suffisso" vengono generati liberamente e talvolta è necessario essere molto chiari sul significato di "suffisso", quindi il termine "suffisso proprio" esclude il chiamare l'intero suffisso. Quindi una sottostringa di suffisso di una stringa può essere qualsiasi sottostringa legittima e può avere un suffisso adeguato che non è lo stesso suffisso. Perché la logica.
Blair Houghton,

3

Ciao, ho provato a implementare l'implementazione sopra spiegata in ruby, per favore dai un'occhiata. Sembra che funzioni bene.

l'unica differenza nell'implementazione è che, ho cercato di usare l'oggetto edge invece di usare solo simboli.

è presente anche su 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
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.