V mikrokosmech, ve kterých se pohybuji, není datová struktura trie příliš populární. Toto prostředí tvoří především datoví vědci a v menším počtu softwaroví inženýři, kteří pracují se zpracováním přirozeného jazyka (NLP).
Nedávno jsem se ocitl v zajímavé diskusi o tom, kdy použít trie nebo hashovací tabulku. Ačkoli v této diskusi nebylo nic nového, o čem bych zde mohl informovat, stejně jako o výhodách a nevýhodách každé z těchto dvou datových struktur (pokud trochu zagooglujete, najdete mnoho odkazů, které se jimi zabývají), nové pro mě bylo, že existuje speciální typ trie zvaný Patricia Trie a že v některých scénářích tato datová struktura vykazuje srovnatelný výkon jak v čase zpracování, tak v prostoru s hashovací tabulkou, přičemž úlohy jako výpis řetězců se společným prefixem řeší bez dalších nákladů.
S tímto vědomím jsem hledal příběh nebo blog, který by mi pomohl jednoduchým způsobem (eufemismus pro lenost) pochopit, jak algoritmus PATRICIA funguje, speciálně sloužící jako zkratka k přečtení 21stránkového článku publikovaného v roce 1968. Bohužel jsem v tomto hledání neuspěl, takže mi nezbyla žádná alternativa k jeho přečtení.
Mým cílem je tímto příběhem zaplnit výše uvedenou mezeru, tj. mít k dispozici příběh popisující laicky řečeno daný algoritmus.
Abych dosáhl svého cíle, nejprve a stručně představím, pro ty, kteří se nevyznají v pokusech, tuto datovou strukturu. Po tomto úvodu uvedu nejoblíbenější tria, která existují, což mi umožní implementovat a podrobně popsat, jak tria PATRICIA fungují a proč mají tak dobré výsledky. Na závěr tohoto povídání porovnám výsledky (výkon a spotřebu místa) ve dvou různých aplikacích: první z nich je počet slov (<Slovo, frekvence> tria) založený na poměrně rozsáhlém textu; a druhou je sestavení <URL, IP> tria založeného na seznamu více než 2 milionů URL, který spočívá v základním prvku DNS serveru. Obě aplikace byly sestaveny na základě Hash Map a Patricia Trie.
Jak definují Sedgewick a Wayne z Princetonské univerzity ve své knize Algorithms – Fourth Edition (vydavatel: Addison-Wesley) trie neboli prefixový strom je „datová struktura sestavená ze znaků vyhledávacího klíče, která slouží jako vodítko pro vyhledávání“. Tato „datová struktura se skládá z uzlů, které obsahují odkazy, jež jsou buď nulové, nebo odkazují na jiné uzly. Na každý uzel ukazuje právě jeden jiný uzel, který se nazývá jeho rodič (s výjimkou jednoho uzlu, kořene, na který neukazují žádné uzly), a každý uzel má S odkazů, kde S je velikost abecedy (nebo znakové sady)“.
Následující obrázek 1 podle výše uvedené definice obsahuje příklad trie.
Kořenový uzel nemá žádný popisek. Má tři potomky: uzly s klíčem ‚a‘, ‚h‘ a ‚s‘. V pravém dolním rohu obrázku je tabulka se seznamem všech klíčů s jejich příslušnou hodnotou. Z kořene můžeme přejít na ‚a‘ -> ‚i‘ -> ‚r‘. Tato posloupnost tvoří slovo „air“ s hodnotou (například počet výskytů tohoto slova v textu) 4 (hodnoty jsou uvnitř zeleného rámečku). Podle konvence má existující slovo v tabulce symbolů nenulovou hodnotu. Ačkoli tedy slovo „airway“ v angličtině existuje, v této trojici není, protože podle konvence je hodnota uzlu s klíčem „y“ nulová.
Slovo „air“ je zároveň předponou pro slova: „airplane“; a „airways“. Posloupnost ‚h‘ -> ‚a‘ tvoří prefix slov „has“ a „hat“, stejně jako posloupnost ‚h‘ -> ‚o‘ tvoří prefix slov „house“ a „horse“.
Kromě toho, že dobře slouží jako datová struktura řekněme slovníku, slouží také jako důležitý prvek kompletační aplikace. Systém by mohl po zadání slova „air“ uživatelem navrhnout slova „air“ a „airways“ procházením podstromu, jehož kořenem je uzel s klíčem „r“. Další výhodou této struktury je, že lze získat uspořádaný seznam existujících klíčů, a to bez dalších nákladů, procházením tria v orientaci in-order, skládáním dětí uzlu a jejich procházením zleva doprava.
Na obrázku 2 je ukázka anatomie uzlu.
Zajímavé na této anatomii je, že ukazatele na děti jsou obvykle uloženy v poli. Pokud reprezentujeme znakovou sadu S například Extended ASCII, potřebujeme, aby pole mělo velikost 256 znaků. Máme-li znak potomka, můžeme k jeho ukazateli v rodiči přistupovat v čase O(1). Uvážíme-li, že velikost slovníku je N a že velikost nejdelšího slova je M, je hloubka tria také M a procházení struktury trvá O(MN). Uvážíme-li, že obvykle je N mnohem větší než M (počet slov v korpusu je mnohem větší než velikost nejdelšího slova), můžeme O(MN) považovat za lineární. V praxi je toto pole řídké, tj. většina uzlů má mnohem méně dětí než S. Navíc průměrná velikost slov je také menší než M, takže v praxi trvá v průměru O(logₛ N) vyhledání klíče a O(N logₛ N) projití celé struktury.
U této datové struktury existují dva hlavní problémy. Jedním z nich je spotřeba místa. Na každé úrovni L, počínaje nulou, je asimptoticky Sᴸ ukazatelů. Jestliže u Extended ASCII, tj. 256 znaků, je to v omezených paměťových zařízeních problém, situace se zhoršuje, pokud uvažujeme všechny znaky Unicode (je jich 65 000). Druhým je množství uzlů s jedním dítětem, které zhoršuje vstupní a vyhledávací výkon. V příkladu na obrázku 1 mají pouze kořen, klíč „h“ na úrovni 1, klíče „a“ a „o“ na úrovni 2 a klíč „r“ na úrovni 3 více než jednoho potomka. Jinými slovy, pouze 5 uzlů z 27 má dva nebo více potomků. Alternativy k této struktuře řeší především tyto problémy.
Před popisem alternativy (PATRICIA Trie) odpovím na následující otázku: Proč alternativy, když můžeme místo pole použít hašovací tabulku? Hašovací tabulka skutečně pomáhá šetřit paměť s malým snížením výkonu, ale problém neřeší. Čím výše se v trie nacházíte (blíže k nulové úrovni), tím je pravděpodobnější, že bude třeba řešit téměř všechny znaky v libovolné znakové sadě. Dobře implementované hashovací tabulky, jako je ta v Javě (rozhodně patří mezi nejlepší, pravděpodobně nejlepší), začínají s výchozí velikostí a automaticky mění velikost podle prahové hodnoty. V Javě je výchozí velikost 16 s prahem 0,75 a při každé z těchto operací se její velikost zdvojnásobí. Vezmeme-li v úvahu Extended ASCII, v okamžiku, kdy počet dětí v jednom uzlu dosáhne 192, bude velikost hašovací tabulky změněna na 512, i když jich nakonec bude potřeba jen 256.
Radix Trie
Jelikož je PATRICIA Trie speciálním případem Radix Trie, začnu s ní. Ve Wikipedii je Radix Trie definován jako „datová struktura, která představuje prostorově optimalizovanou trie… v níž je každý uzel, který je jediným potomkem, sloučen se svým rodičem… Na rozdíl od běžných stromů (kde se porovnávají celé klíče hromadně od jejich počátku až do bodu nerovnosti) se klíč v každém uzlu porovnává po částech, přičemž množství bitů v této části v daném uzlu je radix r radixové trie“. Tato definice bude dávat větší smysl, když si tuto trojici ukážeme na obrázku 3.
Počet dětí r uzlu v radixové trie je určen následujícím vzorcem, kde x je počet bitů potřebných k porovnání výše uvedeného chunk-of-bitu:
Výše uvedený vzorec stanovuje počet dětí v radixové trie tak, aby byl vždy násobkem 2. Například pro 4-Radix Trie platí x = 2. To znamená, že bychom museli porovnávat kousky dvou bitů, dvojici bitů, protože těmito dvěma bity reprezentujeme všechna desetinná čísla od 0 do 3, celkem tedy 4 různá čísla, pro každé dítě jedno.
Pro lepší pochopení výše uvedených definic uvažujme příklad na obrázku 3. V tomto příkladu máme hypotetickou sadu znaků, která se skládá z 5 znaků (NULL, A, B, C a D). Rozhodl jsem se je reprezentovat pouze pomocí 4 bitů (mějte na paměti, že to nemá nic společného s r, k reprezentaci 5 znaků stačí 3 bity, ale má to co do činění se snadností nakreslení příkladu). V levém horním rohu obrázku je tabulka mapující jednotlivé znaky na jejich bitovou reprezentaci:
Chci-li vložit znak ‚A‘, porovnám ho bitově s NULL. V pravém horním rohu obrázku 3, v rámečku označeném 1 ve žlutém kosočtverci, vidíme, že se tyto dva znaky liší u první dvojice. Tato první dvojice je ve čtyřbitové reprezentaci znaku A znázorněna červeně. DIFF tedy obsahuje bitovou pozici, kde končí odlišná dvojice.
Pro vložení znaku ‚B‘ jej musíme bitově porovnat se znakem ‚A‘. Toto porovnání se nachází v kosočtverci označeném číslem 2. Rozdíl tentokrát připadá na druhou dvojici bitů, červeně označenou, která končí na pozici 4. Protože druhá dvojice je 01, vytvoříme uzel s klíčem B a nasměrujeme na něj adresu 01 v uzlu A. Toto propojení je vidět ve spodní části obrázku 3. Stejný postup platí pro vložení znaků ‚C‘ a ‚D‘.
V uvedeném příkladu vidíme, že první pár (01 modře) je prefixem všech vložených znaků.
Tento příklad slouží k popisu porovnávání kusů bitů, optimalizaci prostoru (v tomto případě pouze čtyři děti, bez ohledu na velikost znakové sady) a slučování rodičů a dětí (v uzlech dětí jsou uloženy pouze bity, které se liší od kořene), které jsou zmíněny ve výše uvedené definici Radix Trie. Nicméně stále existuje jeden odkaz směřující do nulového uzlu. Řídkost je přímo úměrná r a hloubce trie.
PATRICIA Trie
PATRICIA (Practical Algorithm to Retrieve Information Coded in Alphanumeric, Journal of the ACM, 15(4):514-534, říjen 1968) . Ano, PATRICIA je zkratka!
PATRICIA Trie je zde definována třemi pravidly:
- Každý uzel (TWIN je původní termín PATRICIA) má dvě děti (r = 2 a x = 1 v termínech Radix Trie);
- Uzel se rozdělí na prefix (L-PHRASE v termínech PATRICIA) se dvěma dětmi (každé dítě tvoří BRANCH, což je původní termín PATRICIA) pouze tehdy, pokud dvě slova (PROPER OCCURRENCE v termínech PATRICIA) mají stejný prefix; a
- Každé slovo končící v uzlu bude reprezentováno hodnotou odlišnou od nuly (značka END v původním názvosloví PATRICIA)
Výše uvedené pravidlo číslo 2 znamená, že délka prefixu na každé úrovni je větší než délka prefixu z předchozí úrovně. To bude velmi důležité v algoritmech, které podporují operace PATRICIA, jako je vložení prvku nebo nalezení jiného.
Z výše uvedených pravidel lze definovat uzel podle následujícího obrázku. Každý uzel má jednoho rodiče, kromě kořene, a dva potomky, hodnotu (libovolného typu) a proměnnou diff označující pozici, na které se rozdělení nachází v bitovém kódu klíče
Pro použití algoritmu PATRICIA potřebujeme funkci, která při zadání 2 řetězců vrátí pozici prvního bitu zleva doprava, kde se liší. Již jsme viděli, že to funguje pro radix trie.
Pro práci s triem pomocí PATRICIA, tj. pro vkládání nebo hledání klíčů v něm, potřebujeme 2 ukazatele. Jeden je parent, který inicializujeme tak, že jej nasměrujeme na kořenový uzel. Druhým je child, inicializovaný tak, že jej ukážeme na levého potomka kořene.
Na co bude následovat, bude proměnná diff držet pozici, ve které se vkládaný klíč liší od klíče child.key. Podle pravidla číslo 2 definujeme následující 2 nerovnice:
Zajímavostí vložení znaku F je to, že jeho místo je mezi znaky D a E, protože rozdíl mezi D a F je na pozici 7. Vložení znaku E do tria se provede na pozici 7. Ale na pozici 7 je bit E 0, takže uzel E bude levým potomkem uzlu F. Struktura je nyní znázorněna na obrázku 13.
A nakonec vložíme znak G. Po všech výpočtech dojdeme k závěru, že nový uzel by měl být umístěn jako pravý potomek uzlu F s rozdílem rovným 8. Konečný výsledek je na obrázku 14.
Dosud jsme vkládali řetězce složené pouze z jednoho znaku. Co by se stalo, kdybychom se pokusili vložit řetězce AB a BA? Zájemce může tuto větu pojmout jako cvičení. Dvě nápovědy: oba řetězce se liší od uzlu B na pozici 9; a výsledná trojice je znázorněna na obrázku 15.
Přemýšlením nad posledním obrázkem lze položit následující otázku: K nalezení například řetězce BA je třeba 5 bitových porovnání, zatímco k výpočtu hashovacího kódu tohoto řetězce stačí pouze dvě iterace, kdy se tedy PATRICIA Trie stává konkurenceschopnou? Představme si, že do této struktury vložíme řetězec ABBABABBBBABABBA (převzatý z příkladu v původním článku PATRICIA z roku 1968), který má 16 znaků? Tento řetězec by byl umístěn jako levý potomek uzlu AB s rozdílem na pozici 17. K jeho nalezení bychom potřebovali 5 bitových porovnání, zatímco výpočet hashovacího kódu by zabral 16 iterací, jednu pro každý znak, což je důvod, proč jsou Trie PATRICIA tak konkurenceschopné při vyhledávání dlouhých řetězců.
PATRICIA Trie vs Hash Map
V této poslední části porovnám Hash Map a Trie PATRICIA ve dvou aplikacích. První je počítání slov na souboru leipzig1m.txt obsahujícím 1 milion náhodných vět a druhou je vyplňování tabulky symbolů s více než 2,3 miliony URL adres z projektu DMOZ (uzavřeného v roce 2017) staženého odsud.
Kód, který jsem vyvinul a použil jak k implementaci výše uvedeného pseudokódu, tak k porovnání výkonu PATRICIA Trie s Hash Table, naleznete na mém GitHubu.
Tato porovnání byla spuštěna v mém boxu Ubuntu 18.04. Konfiguraci naleznete na obrázku 16
.