Comparing PATRICIA Trie, With It’s Nuts and Bolts, with Hash Map

私が関わっている小宇宙では、トライデータ構造のようなものはそれほど人気があるわけではありません。 この環境は主にデータ サイエンティストと、少数ですが自然言語処理 (NLP) を扱うソフトウェア エンジニアで構成されています。

最近、trie またはハッシュ テーブルをいつ使用するかについての興味深い議論に参加することになりました。 その議論では、これら 2 つのデータ構造のそれぞれの長所と短所など、ここで報告できるような新しいことは何もありませんでしたが (少しググれば、それらに言及した多くのリンクが見つかります)、私にとって新鮮だったのは、パトリシア トライと呼ばれる特殊なトライの種類と、いくつかのシナリオにおいて、このデータ構造が処理時間と空間の両方でハッシュ テーブルと同等の性能を示し、共通のプレフィックス付きのストリングを追加費用なしにリストするタスクも扱っているということでした。

このことを念頭に置いて、PATRICIA アルゴリズムがどのように動作するのか、特に 1968 年に発表された 21 ページの長い論文を読むための近道として、簡単な方法(怠惰の婉曲表現)で理解できるストーリーやブログはないかと探したのです。

この物語での私の目的は、上記のギャップを埋めること、つまり、手元にあるアルゴリズムを平易な言葉で説明する物語を持つことです。 この紹介に続いて、最も一般的なトライをリストアップし、PATRICIAトライがどのように動作し、なぜうまく動作するのかを実装して詳細に説明します。 一つは、かなり大きなテキストに基づく単語数(<WORD, FREQUENCY>タプル)、もう一つは、DNSサーバーのコア要素である200万以上のURLのリストに基づいて<URL, IP>タプルを構築するものです。 プリンストン大学の Sedgewick と Wayne が Algorithms – Fourth Edition (publisher: Addison-Wesley) で定義したように、トライ(接頭辞木)は「検索を導くために検索キーの文字から構築されるデータ構造」です。 この「データ構造は、NULLか他のノードを参照するリンクを含むノードで構成される。 各ノードは、その親と呼ばれる他の1つのノードによって指され(ルートという1つのノードを除いて、そのノードを指さない)、各ノードはS個のリンクを持ち、Sはアルファベットサイズ(または文字セット)である」。

上記の定義に従って、下の図1はトライの例を含む。

図1:トライの例

ルートノードには、ラベルがない。 これには3つの子ノードがあり、キー’a’、’h’、’s’を持つノードである。 図の右下には、すべてのキーとそれぞれの値をリストアップした表がある。 ルートから’a’ -> ‘i’ -> ‘r’へとたどることができる。 この順序は、値(例えば、この単語がテキストに現れる回数)が4の単語「air」を形成する(値は緑色のボックスの中にある)。 慣習として、シンボルテーブルの既存の単語はヌルでない値を持つ。 したがって、”airway” という単語は英語には存在するが、このトライには存在しない。なぜなら、慣習に従って、キー ‘y’ を持つノードの値は null であるからだ。 “airplane “や “airways “などの単語の接頭辞でもあります。 h’ -> ‘o’が “house “と “horse “の接頭辞を形成するように、’h’ -> ‘a’ という並びは “has “と “hat “を形成する。

それは例えば辞書用のデータ構造としてうまく機能するだけではなく、補完アプリケーションの重要な要素として機能する。 たとえば、”air” という単語を入力した後に、キー “r” を持つノードをルートとするサブツリーを走査して、”airplane” と “airways” という単語をシステムが提案することができる。 この構造のもう1つの利点は、トライを順方向でトラバースし、ノードの子を重ねて左から右にトラバースすることで、追加コストなしに既存のキーの順序付きリストを得ることができることである。

図2にノードの解剖学を例示する。

図2:トライノードの解剖学

この解剖学の興味深い点は、子のポインタが通常配列に格納される点である。 例えばExtended ASCIIの文字セットSを表現する場合、256のサイズの配列が必要です。 子文字があれば、その親のポインタにO(1)時間でアクセスすることができます。 語彙の大きさがNで,最長の単語の大きさがMであることを考えると,トライの深さもMであり,構造を走査するのにO(MN)かかることになる. 通常NはMよりずっと大きい(コーパスの単語数は最長単語のサイズよりずっと大きい)ことを考慮すると、O(MN)は線形であると考えることができる。 さらに単語の平均サイズも M より小さいので、実際には平均して、キーを検索するのに O(logₛ N)、構造全体をトラバースするのに O(N logₛ N)を要する。 1つは空間消費である。 0から始まる各レベルLには、同値でSᴸのポインタが存在する。 Extended ASCII、すなわち256文字では、これは限られたメモリ・デバイスにおける問題ですが、すべてのUnicode文字(そのうちの65,000文字)を考慮すると、状況は悪化してしまいます。 2つ目は、子ノードが1つしかないノードの多さで、入力と検索のパフォーマンスが低下することです。 図1の例では、ルート、レベル1のキー「h」、レベル2のキー「a」と「o」、レベル3のキー「r」だけが複数の子ノードを持つ。 つまり、27個のノードのうち、2個以上の子ノードを持つのは5個だけである。

代替案(PATRICIA Trie)を説明する前に、次の質問に答えます。配列の代わりにハッシュテーブルが使えるのに、なぜ代替案なのか? ハッシュテーブルは、性能の低下をほとんど伴わずにメモリを節約するのに非常に役立ちますが、問題を解決するものではありません。 トライの上位(レベルゼロに近い)ほど、どの文字セットでもほぼすべての文字に対処する必要がある可能性が高くなります。 よく実装されたハッシュテーブルは、Javaのもの(間違いなく最高のものの一つで、間違いなく最高です)のように、デフォルトのサイズから始まり、閾値に従って自動的にサイズ変更されます。 Javaでは、デフォルトのサイズは16で、閾値は0.75で、これらの操作のたびにそのサイズを2倍にします。 Extended ASCIIを考えると、1つのノードの子ノード数が192になる頃には、ハッシュテーブルは512にリサイズされ、最終的には256しか必要ないにもかかわらず、です。

Radix Trie

PATRICIAトライはRadix Trieの特殊例ですので、後者を取り上げたいと思います。 Wikipediaでは、基数トライは「空間を最適化したトライを表すデータ構造で、唯一の子である各ノードがその親にマージされる…通常の木(キー全体をその先頭から不等間隔の点まで一括比較する)と異なり、各ノードのキーはチャンクのビットごとに比較され、そのノードにおけるチャンクのビットの量が基数トライの基数rとなる」、と定義されています。 この定義は、図3でこのトライを例証すると、より理解しやすくなる。

基数トライのノードの子の数rは、xが上記のチャンクオブビットの比較に必要なビットの数である次の式で決定される:

上記の式によって基数トライにおける子の数は常に2の倍数となるよう設定されている。 例えば、4Radix Trieの場合、x=2である。 これは、2 つのビットのチャンク、ビットのペアを比較する必要があることを意味します。これらの 2 つのビットで 0 から 3 までのすべての 10 進数を表し、合計 4 つの異なる数、各子供に 1 つを表すからです。 この例では、5つの文字(NULL、A、B、C、D)からなる仮想的な文字集合があります。 4ビットで表現することにしました(これはrとは関係なく、5文字を表現するのに3ビットで済みますが、例を描きやすくするためです)。 図の左上には各文字をビット表現に対応させた表があります。

文字 ‘A’ を挿入するために、NULLの文字とビット単位で比較することにします。 図3の右上、黄色い菱形に1と書かれたボックスの中で、この2つの文字が最初のペアで異なっていることがわかる。 この最初のペアは、Aの4ビット表現では赤で表されています。 つまり、DIFFには、異なるペアが終了するビット位置が含まれています。

文字Bを挿入するには、文字Aとビット単位で比較する必要があります。 この比較は2というラベルのついた菱形のボックスで行われます。今回、差は赤で示した2番目のビットの組で発生し、それは位置4で終了します。 2番目のペアは01なので、キーBを持つノードを作成し、ノードAのアドレス01をそれに向けます。 このリンクは、図3の下側に見ることができます。

Figure 3: Radix Tree Example

上の例で、最初のペア(青で01)が挿入されるすべての文字のプレフィックスであることが分かる。

この例は、上記の Radix Trie の定義で述べた chunk-of-bits 比較、空間最適化 (文字セットのサイズに関係なく、この場合は 4 つの子のみ)、親子結合 (ルートと異なるビットのみ子ノードに格納) を説明する役割を果たします。 それでも、NULLノードを指すリンクが1つ存在する。 Sparsenessはrとtrieの深さに正比例する。

PATRICIA Trie

PATRICIA (Practical Algorithm to Retrieve Information Coded in Alphanumeric, Journal of the ACM, 15(4):514-534, October 1968) …。 そう、PATRICIAは頭字語なんです!

PATRICIA Trieは、ここでは3つのルールで定義されています。

  1. 各ノード(TWINはPATRICIAの原語)は2つの子(Radix Trieの用語ではr = 2とx = 1)を持つ;
  2. ノードは2つの単語(PROPER OCCURRENCEはPATRICIAの用語)が同じプレフィックス(PATRICIAの用語のL-PRHASE)と二つの子(それぞれの子はBRANCHとなり、PATRICIAの元の用語を表す)だけに分裂される;
  3. ノードは1つのプレフィックスと二つの子(PATRICIAの用語のP-PRHAIN)だけが分裂させられる。 そして
  4. すべての単語の終わりは、ノード内でnull(PATRICIAのオリジナルの命名法ではENDマーク)以外の値で表される

上記のルール2には、各レベルの接頭辞の長さが前のレベルの接頭辞の長さより大きいことが含まれています。 これは、要素の挿入や別の要素の検索といったPATRICIA操作をサポートするアルゴリズムで非常に重要になる。

上記のルールから、以下の図のようにノードを定義することができる。 各ノードはルートを除く1つの親と、2つの子、値(任意の型)と、キービットコードにおける分割位置を示す変数diffを持つ

Figure 4: PATRICIAノードの構造

PATRICIAアルゴリズムを使うには、2つの文字列を与えて、左から右へ、両者の異なる最初のビットの位置を返す関数が必要です。 これはすでにRadix Trieで動作しているのを見た。

PATRICIAを使ってトライを操作する、つまりキーを挿入したり見つけたりするには、2つのポインタが必要である。 一つはparentで、ルートノードを指すことで初期化される。

以下では、挿入されるキーがchild.keyと異なる位置をdiffという変数で保持する。 ルール2に従い、以下の2つの不等式を定義する。

不等式 (1)

式(2)

foundはキー検索が終了したノードを指すとする。 found.keyが挿入される文字列と異なる場合、ComputeDiff関数により、この文字列とfound.keyを使用してdiffが計算されます。 これらが等しい場合、キーは構造体に存在し、他のシンボルテーブルと同様に、その値が返されます。 BitAtPosition というヘルパーメソッドは、特定の位置 (0 または 1) のビットを取得するもので、操作中に使用されます。

検索するには Search(key) 関数を定義する必要があります。 その疑似コードを図4に示す。

Figure 5: Search for a key in a PATRICIA Trie

The second function we will use in this story is the Insert(key).この話の中で使用する2つめの関数は、Insert(key)である。 その疑似コードを図6に示します。

Figure 6: Insert a key in a PATRICIA Trie

上記の疑似コードにはCreateNodeというノード作成用のメソッドがあるのですが、これはノードを作成するためのものです。 その引数は図4から理解できる。

Figure 7: PATRICIA Trieがどのように機能するかを例示するために、私は読者を段階的な操作に導き、7文字(横の図7で指定された’A’, ‘B’, ‘C’, ‘D’, ‘E’, ‘F’, ‘G’)を自然の順序で挿入完了したいのである。 最後に、このデータ構造がどのように機能するかを理解することができれば幸いである。 そのビットコードは01000001である。 基数トライと同様に、このビットコードをルートのビットコードと比較します。この時点ではNULLなので、00000000と01000001を比較します。

Figure 8: Insert Character A

ノードを作成するには、左子と右子をそれぞれの後継者に指定する必要があります。 このノードはルートなので、定義により、その左の子は自分自身を指し、その右の子はヌルを指します。 これはトライ全体で唯一のヌルポインタとなります。 ルートは、親を持たない唯一のノードであることを忘れないでください。 この挿入操作の結果を図8に示す。

次にビットコード01000010の文字Bを挿入する。 まず、この文字がすでに構造体の中にあるかどうかをチェックする。 そのために、検索を行います。 PATRICIAトライの検索は簡単である(図5の擬似コード参照)。 rootのキーとBを比較した結果、両者は異なると結論づけられたので、 parentとchildの2つのポインタを作成することで処理を進める。 初期状態では、parentはrootを指し、childはrootの左の子を指している。 この場合、両方ともノードAを指している。 ルール 2 に従って、不等式 (1) が真である間、構造を反復します。

この時点で parent.DIFF は child.DIFF と等しく、それより小さくはありません。 これは不等式(1)を破ります。 child.VALUEがnull(1に等しい)とは異なるので、文字を比較します。 AはBと異なるので、これはBがトライにないことを意味し、挿入する必要がある。

挿入するには、再び、前のように親と子の定義、すなわち、親はルートを指し、子はその左ノードを指すことから始める。 構造体を通しての反復は不等式(1)と(2)に従わなければならない。 これは、Bとchild.KEYのビットごとの差分であり、この段階ではAと等しい(ビットコード01000001と01000010の差分)。 この差は7番目のビットにかかるので、diff = 7.

Figure 9: Insert Character B

この時点で親と子は同じノード(ルートとなるノード A )を指しています。 不等式(1)はすでに偽で、child.DIFFはdiffより小さいので、ノードBを作り、parent.LEFTをそこに向けることにします。 ノードAでやったように、ノードのBの子もどこかに向ける必要があります。 B の位置 7 のビットは 1 なので、ノードの B の右の子は自分自身を指すことになります。 ノードのBの左の子は親(この場合はルート)を指すことになります。 最終結果は図9のようになります。

ノードのBの左子をその親に向けることで、ルール番号2で述べた分割を有効にしています。 差分が2と7の間にある文字を挿入すると、このポインタは、新しい文字を挿入する正しい位置に私たちを送り返します。 これは、文字Dを挿入するときに起こります。

しかし、その前にビットコードが01000011に等しい文字Cを挿入する必要があります。 ポインターの親はノードA(ルート)を指します。

このとき、不等式(1)が成り立つ(parent.DIFFがchild.DIFFより小さい)ので、親を子に、子をノードのB右子に設定して両方のポインタを更新します(位置7でCのビットが1なので、

更新後は両方のポインタがノードBを指すので不等式(1)は破れます。) 先ほどと同様に、child.KEYのビットコードとCのビットコード(01000010と01000011)を比較し、位置8で異なると結論付けます。 位置7でCのビットが1なので、この新しいノード(ノードC)はBの右子となる。位置8でCのビットが1なので、ノードCの右子を自分自身に設定し、左子はこの時点で親であるノードBを指していることになる。

Figure 10: Insert Character C

Next character is D (bit code 01000100). 文字Cの挿入のときと同様に、最初はポインタの親をノードAに、ポインタの子をノードBに設定することになります。 このとき、parentはノードBを指し、childはノードBの左側の子を指しますが、これは位置7のDのビットが0だからです。文字Cの挿入とは異なり、childはノードAに戻り、不等式(1)を破ります。 再び、AとDのビットコードの差を計算します。 ここでも通常通りparentとchildを設定します。 ここで、不等式(1)は成立するが、不等式(2)は不成立である。 つまり、Aの左の子としてDを挿入することになります。しかし、Dの子はどうでしょうか? 位置6でDのビットは1なので、右の子は自分自身を指している。 興味深いのは、ノードDの左子がノードBを指すようになり、図11のようなトライになることだ。

図11:文字Dの挿入

次にビットコード01000101の文字Eである。 検索機能はノードDを返す。キャラクタDとEの差は位置8のビットにかかる。 Eのビットは6位で1なので、Dの右に位置することになる。

Figure 12: Insert Character E

面白いのは文字Fを挿入すると、DとFの差が7位にあることからその位置が文字DとEの間になるという点である。 しかし、位置7ではEのビットは0なので、ノードEはノードFの左の子になる。今、図13に示すような構造になっている。

Figure 13: Insert Character F

そして、最後にキャラクターGを挿入します。すべての計算の結果、新しいノードはノードFの右の子供として、差は8と同じに置かれるべきという結論がでました。

Figure 14: Insert Character G

今までは1文字からなる文字列を挿入していた。 もし、ABとBAの文字列を挿入しようとしたら、どうなるだろうか。 興味のある読者は、この命題を練習問題として取り上げることができる。

Figure 15: Insert strings AB and BA

最後の図について考えてみると、以下の疑問が浮かぶだろう。 例えば文字列BAを見つけるには5ビットの比較が必要だが、この文字列のハッシュコードを計算するには2回の反復しか必要ない。では、PATRICIA Trieはいつ競争力を発揮するのだろうか? この構造に16文字の文字列ABBABABBBBABBA(1968年のPATRICIAの原論文の例からとりました)を挿入したらどうでしょうか。 この文字列はノードABの左の子として配置され、位置17にdiffを持つことになる。

PATRICIA Trie vs Hash Map

この最後の節では、Hash MapとPATRICIA Trieを二つのアプリケーションで比較します。 1つ目は、100万個のランダムな文章を含む leipzig1m.txt のワードカウント、2つ目は、ここからダウンロードした DMOZ プロジェクト (2017 年に終了) から 230 万以上の URL を含む記号テーブルの入力です。

上記の疑似コードの実装と PATRICIA Trie のパフォーマンスを Hash Table と比較するのに私が開発し使用したコードは、私の GitHub に掲載されています。 構成は図16

の通りです。

コメントを残す

メールアドレスが公開されることはありません。