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.
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
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
a
creando 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
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 c
a 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 abc
come nell'esempio precedente, quindi ab
viene ripetuto e seguito da x
, quindi abc
viene 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
abc
esempio, il punto attivo era sempre
(root,'\0x',0)
, ovvero active_node
era il nodo principale, active_edge
era specificato come carattere null '\0x'
ed active_length
era 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
remainder
era 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 a
alla 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: ab
e b
. Questo è fondamentalmente perché:
- Il
a
suffisso del passaggio precedente non è mai stato inserito correttamente. Così è rimasto , e dal momento che abbiamo progredito un passo, ora è cresciuto da a
a ab
.
- E dobbiamo inserire il nuovo bordo finale
b
.
In pratica ciò significa che andiamo al punto attivo (che punta dietro a
a quello che ora è il abcab
bordo) 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
remainder
3 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 ab
e b
nel passaggio corrente, ma poiché ab
era già stato trovato, abbiamo aggiornato il punto attivo e non abbiamo nemmeno tentato di inserire b
. Perché? Perché se si ab
trova 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
, bx
e
x
. Il punto attivo ci dice dove ab
finisce, quindi dobbiamo solo saltare lì e inserire il x
. Anzi, x
non c'è ancora, quindi dividiamo il abcabx
bordo 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 abx
e decremento remainder
a 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 bcabx
bordo, 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 remainder
a 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_length
componente 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 remainder
senza 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 node1
riferisco al nodo interno ab
a 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 c
automaticamente 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 remainder
e non facciamo altro.
Ora nel passaggio #
= 10 , remainder
è 4, quindi è necessario innanzitutto inserire
abcd
(che rimane di 3 passaggi fa) inserendolo d
nel punto attivo.
Il tentativo di inserire d
nel 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_node
nodo che non è il nodo principale, seguiamo il collegamento del suffisso che esce da quel nodo, se presente, e ripristiniamo active_node
il nodo sul quale punta. Se non è presente alcun collegamento suffisso, impostiamo active_node
il root. active_edge
e active_length
rimanere invariato.
Quindi il punto attivo è ora (node2,'c',1)
ed node2
è contrassegnato in rosso sotto:
Poiché l'inserimento di abcd
completamento, si decrementa remainder
a 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 bcd
possa essere fatto semplicemente inserendo il suo carattere finale
d
nel 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, remainder
può 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.
remainder
ci 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 remainder
e 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. forzaturaremainder
essere 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 remainder
inserimenti, ognuno impiegando O (1) tempo. Dato che remainder
indica 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_length
componente 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 f
il defg
bordo. 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_length
potrebbe 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_length
sarà ridotto. Mentre seguiamo la catena di collegamenti dei suffissi, facciamo gli inserti rimanenti, active_length
possiamo solo diminuire e il numero di regolazioni del punto attivo che possiamo fare sulla strada non può essere maggiore di active_length
in un dato momento. Poiché
active_length
non 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 remainder
nel corso dell'intero processo è O (n), il numero di regolazioni del punto attivo è delimitato anche da O (n).