Nel microcosmo in cui sono inserito, la struttura dati trie non è così popolare. Questo ambiente è composto principalmente da scienziati di dati e, in un numero minore, da ingegneri del software, che lavorano con l’elaborazione del linguaggio naturale (NLP).
Recentemente mi sono trovato in un’interessante discussione su quando usare trie o hash table. Anche se non c’era nulla di nuovo in quella discussione che posso riportare qui, come i pro e i contro di ciascuna di queste due strutture dati (se cercate un po’ su Google troverete molti link che le trattano), ciò che era nuovo per me era un tipo speciale di trie chiamato Patricia Trie e, che in alcuni scenari, questa struttura dati ha prestazioni comparabili sia nel tempo di elaborazione che nello spazio con la tabella hash, mentre affronta compiti come elencare stringhe con prefisso comune senza costi aggiuntivi.
Con questo in mente, ho cercato una storia o un blog che mi aiutasse a capire in modo semplice (eufemismo per pigrizia) come funziona l’algoritmo PATRICIA, specialmente come scorciatoia per leggere il documento di 21 pagine pubblicato nel 1968. Sfortunatamente non sono riuscito in questa ricerca, lasciandomi senza alternative alla lettura.
Il mio obiettivo con questa storia è quello di colmare la suddetta lacuna, cioè, avere una storia che descriva in termini profani l’algoritmo in questione.
Per raggiungere il mio obiettivo, prima, e brevemente, introdurrò, per chi non ha familiarità con i tentativi, questa struttura dati. Dopo questa introduzione, elencherò i trie più popolari là fuori, permettendomi di implementare e descrivere in dettaglio come funziona PATRICIA Trie e perché si comportano così bene. Concluderò questa storia confrontando i risultati (prestazioni e consumo di spazio) in due diverse applicazioni: la prima è un conteggio di parole (<WORD, FREQUENCY> tuple) basato su un testo abbastanza grande; e la seconda è la costruzione di un <URL, IP> tuple, basato su una lista di più di 2 milioni di URL, che consiste nell’elemento centrale di un server DNS. Entrambe le applicazioni sono state costruite sulla base di Hash Map e Patricia Trie.
Come definito da Sedgewick e Wayne della Princeton University nel loro libro Algorithms – Fourth Edition (editore: Addison-Wesley) un trie, o albero dei prefissi, è “una struttura dati costruita dai caratteri della chiave di ricerca per guidare la ricerca”. Questa “struttura di dati è composta da nodi che contengono collegamenti che sono nulli o riferimenti ad altri nodi. Ogni nodo è indicato da un solo altro nodo, che è chiamato il suo genitore (ad eccezione di un nodo, la radice, che non ha nodi che puntano ad esso), e ogni nodo ha S collegamenti, dove S è la dimensione dell’alfabeto (o charset)”.
La figura 1 qui sotto, seguendo la definizione di cui sopra, contiene un esempio di un trie.
Il nodo radice non ha etichetta. Ha tre figli: nodi con chiave ‘a’, ‘h’ e ‘s’. Nell’angolo in basso a destra della figura c’è una tabella che elenca tutte le chiavi con il loro rispettivo valore. Dalla radice possiamo passare ad ‘a’ -> ‘i’ -> ‘r’. Questa sequenza forma la parola “air” con valore (per esempio il numero di volte che questa parola appare in un testo) di 4 (i valori sono dentro un riquadro verde). Per convenzione, una parola esistente in una tabella di simboli ha un valore non nullo. Così, mentre la parola “airway” esiste in inglese, non è in questo trie, perché, seguendo la convenzione, il valore del nodo con chiave ‘y’ è nullo.
La parola “air” è anche il prefisso delle parole: “aereo”; e “vie aeree”. La sequenza ‘h’ -> ‘a’ forma il prefisso di “ha” e “cappello” come la sequenza ‘h’ -> ‘o’ forma il prefisso di “casa” e “cavallo”.
Oltre a servire bene come struttura dati per, diciamo, un dizionario, serve anche come elemento importante di un’applicazione di completamento. Un sistema potrebbe proporre le parole “aereo” e “vie aeree” dopo che un utente ha digitato la parola “aria”, attraversando il sottoalbero la cui radice è il nodo con chiave “r”. Un ulteriore vantaggio di questa struttura è che si può ottenere una lista ordinata delle chiavi esistenti, senza alcun costo aggiuntivo, attraversando il trie in modo ordinato, impilando i figli di un nodo e attraversandoli da sinistra a destra.
Nella figura 2 è esemplificata l’anatomia del nodo.
La parte interessante in questa anatomia è che i puntatori ai figli sono solitamente memorizzati in un array. Se stiamo rappresentando un set di caratteri S, per esempio, ASCII esteso, abbiamo bisogno che l’array sia di dimensione 256. Avendo un carattere figlio possiamo accedere al suo puntatore nel suo genitore in tempo O(1). Considerando che la dimensione del vocabolario è N e che la dimensione della parola più lunga è M, la profondità del trie è anche M e ci vuole O(MN) per attraversare la struttura. Se consideriamo che normalmente N è molto più grande di M (il numero di parole in un corpus è molto più grande della dimensione della parola più lunga), possiamo considerare O(MN) come lineare. In pratica questo array è rado, cioè la maggior parte dei nodi ha molti meno figli di S. Inoltre la dimensione media delle parole è anche più piccola di M, quindi, in pratica, in media, ci vuole O(logₛ N) per cercare una chiave e O(N logₛ N) per attraversare l’intera struttura.
Ci sono due problemi principali con questa struttura dati. Uno è il consumo di spazio. Ad ogni livello L, a partire da zero, ci sono, assimptoticamente, Sᴸ puntatori. Se con l’ASCII esteso, cioè 256 caratteri, questo è un problema in dispositivi di memoria limitata, la situazione peggiora se si considerano tutti i caratteri Unicode (65.000). Il secondo è la quantità di nodi con un solo figlio, degradando le prestazioni di input e di ricerca. Nell’esempio della figura 1 solo la radice, la chiave ‘h’ al livello 1, le chiavi ‘a’ e ‘o’ al livello 2 e la chiave ‘r’ al livello 3 hanno più di un figlio. In altre parole, solo 5 nodi su 27 hanno due o più figli. Le alternative a questa struttura affrontano principalmente questi problemi.
Prima di descrivere l’alternativa (PATRICIA Trie), risponderò alla seguente domanda: perché le alternative se possiamo usare una tabella hash invece di un array? La tabella hash aiuta davvero a risparmiare memoria con poco degrado delle prestazioni, ma non risolve il problema. Più in alto si è nel trie (più vicino al livello zero) è più probabile che quasi tutti i caratteri di qualsiasi charset debbano essere indirizzati. Le tabelle hash ben implementate, come quella in Java (sicuramente tra le migliori, probabilmente la migliore), iniziano con una dimensione predefinita e si ridimensionano automaticamente secondo una soglia. In Java la dimensione predefinita è 16 con una soglia di 0,75 e raddoppia la sua dimensione in ciascuna di queste operazioni. Considerando l’ASCII esteso, quando il numero di bambini in un nodo raggiunge 192, la tabella hash sarà ridimensionata a 512, anche se alla fine ne servono solo 256.
Radix Trie
Poiché PATRICIA Trie è un caso speciale di Radix Trie, inizierò con quest’ultimo. In Wikipedia un Radix Trie è definito come “una struttura di dati che rappresenta un trie ottimizzato nello spazio… in cui ogni nodo che è l’unico figlio è fuso con il suo genitore… A differenza degli alberi regolari (dove intere chiavi sono confrontate in massa dal loro inizio fino al punto di disuguaglianza), la chiave di ogni nodo è confrontata pezzo di bit per pezzo di bit, dove la quantità di bit in quel pezzo in quel nodo è il radix r del radix trie”. Questa definizione avrà più senso quando esemplificheremo questo trio nella figura 3.
Il numero di figli r di un nodo in una trie radix è determinato dalla seguente formula dove x è il numero di bit richiesto per confrontare il chunk-of-bits di cui sopra:
La formula sopra stabilisce che il numero di figli in una Trie Radix sia sempre un multiplo di 2. Per esempio, per una 4-Radix Trie, x = 2. Questo significa che dovremmo confrontare pezzi di due bit, una coppia di bit, perché con questi due bit rappresentiamo tutti i numeri decimali da 0 a 3, per un totale di 4 numeri diversi, uno per ogni figlio.
Per capire meglio le definizioni precedenti, consideriamo l’esempio in figura 3. In questo esempio abbiamo un ipotetico set di caratteri composto da 5 caratteri (NULL, A, B, C e D). Ho scelto di rappresentarli usando solo 4 bit (tenete presente che questo non ha niente a che fare con r, si ha bisogno solo di 3 bit per rappresentare 5 caratteri, ma ha a che fare con la facilità di disegnare l’esempio). Nell’angolo in alto a sinistra della figura c’è una tabella che mappa ogni carattere alla sua rappresentazione in bit.
Per inserire il carattere ‘A’, lo confronterò bit per bit con quello NULL. Nell’angolo in alto a destra della figura 3, all’interno della casella etichettata con 1 in un diamante giallo, possiamo vedere che questi due caratteri differiscono alla prima coppia. Questa prima coppia è rappresentata in rosso nella rappresentazione a 4 bit di A. Quindi DIFF contiene la posizione del bit dove finisce la coppia diversa.
Per inserire il carattere ‘B’ dobbiamo confrontarlo bitwise con il carattere ‘A’. Questo confronto è nella casella di diamante etichettata con 2. La differenza cade questa volta sulla seconda coppia di bit, in rosso, che termina alla posizione 4. Poiché la seconda coppia è 01, creiamo il nodo con la chiave B e puntiamo l’indirizzo 01 del nodo A ad esso. Questo collegamento può essere visto nella parte inferiore della figura 3. La stessa procedura si applica per inserire i caratteri ‘C’ e ‘D’.
Finora stiamo inserendo stringhe composte da un solo carattere. Cosa succederebbe se provassimo a inserire le stringhe AB e BA? Il lettore interessato può prendere questa proposizione come esercizio. Due suggerimenti: entrambe le stringhe differiscono dal nodo B nella posizione 9; e il trie finale è illustrato nella figura 15.
Pensando all’ultima figura, si può porre la seguente domanda: Per trovare, ad esempio, la stringa BA sono necessari 5 confronti di bit, mentre per calcolare il codice hash di questa stringa sono necessarie solo due iterazioni, quindi quando PATRICIA Trie diventa competitivo? Immaginate se inseriamo in questa struttura la stringa ABBABABBBBABABBA (presa dall’esempio del documento originale PATRICIA del 1968) che è lunga 16 caratteri? Questa stringa sarebbe posta come figlio sinistro del nodo AB con diff alla posizione 17. Per trovarla, avremmo bisogno di 5 confronti di bit, mentre per calcolare il codice hash ci vorrebbero 16 iterazioni, una per ogni carattere, questa è la ragione per cui PATRICIA Trie è così competitivo nella ricerca di lunghe stringhe.
PATRICIA Trie vs Hash Map
In questa ultima sezione confronterò Hash Map e PATRICIA Trie in due applicazioni. La prima è un conteggio di parole su leipzig1m.txt contenente 1 milione di frasi casuali e la seconda è il popolamento di una tabella di simboli con più di 2,3 milioni di URL dal progetto DMOZ (chiuso nel 2017) scaricato da qui.
Il codice che ho sviluppato e utilizzato sia per implementare lo pseudocodice sopra che per confrontare le prestazioni di PATRICIA Trie con Hash Table può essere trovato sul mio GitHub.
Questi confronti sono stati effettuati nella mia macchina Ubuntu 18.04. La configurazione può essere trovata nella figura 16
.