以下は、最初に文字列が単純である(つまり、繰り返される文字が含まれていない)場合の動作を示し、次に完全なアルゴリズムに拡張することによって、Ukkonenアルゴリズムを説明する試みです。
まず、いくつかの予備的な声明。
私たちが構築しているものは、基本的には検索トライのようなものです。したがって、ルートノードがあり、そのエッジから出て新しいノードにつながり、さらにそれらから出るエッジが続きます。
ただし、検索トライとは異なり、エッジラベルは単一の文字ではありません。代わりに、各エッジには整数のペアを使用してラベルが付けられます
[from,to]
。これらはテキストへのポインタです。この意味で、各エッジは任意の長さの文字列ラベルを保持しますが、O(1)スペース(2つのポインター)のみを使用します。
基本的な原則
最初に、特に単純な文字列(繰り返し文字のない文字列)のサフィックスツリーを作成する方法を示します。
abc
アルゴリズムは、左から右へと段階的に機能します。文字列の文字ごとに1つのステップがあります。各ステップには複数の個別の操作が含まれる場合がありますが、操作の総数がO(n)であることがわかります(最後の最後の観察を参照)。
したがって、左から開始して、最初にa
ルートノード(左側)からリーフにエッジを作成し、ラベルを付けて1文字だけを挿入し
ます[0,#]
。で、現在のエンド。Iは、記号を使用#
意味する現在の終了位置1(右の後です、a
)。
したがって、次のような初期ツリーがあります。
そしてそれが意味することはこれです:
次に、位置2(の直後b
)に進みます。各ステップの目標は、現在の位置までのすべてのサフィックス
を挿入することです。これを行う
- 既存の
a
エッジを拡張するab
- 新しいエッジを1つ挿入する
b
私たちの表現ではこれは次のようになります
そしてそれが意味することは:
2つのことを観察します。
- 以下のためのエッジ表現は、
ab
ある同じ、それが最初の木であることが使用されます:[0,#]
。現在の位置#
を1から2に更新したため、その意味は自動的に変更されました。
- 各エッジは、それが表す文字数に関係なく、テキストへの2つのポインタのみで構成されるため、O(1)スペースを消費します。
次に、位置を再度インクリメントし、c
既存のすべてのエッジにa を追加して新しいサフィックスに新しいエッジを1つ挿入することにより、ツリーを更新しますc
。
私たちの表現ではこれは次のようになります
そしてそれが意味することは:
私たちは観察します:
- ツリーは、
各ステップの後の現在の位置までの正しいサフィックスツリーです。
- テキストの文字数と同じ数のステップがあります
- 各ステップの作業量はO(1)です。これは、既存のすべてのエッジがを増分
#
することによって自動的に更新され、最後の文字の1つの新しいエッジの挿入はO(1)時間で実行できるためです。したがって、長さがnの文字列の場合、必要な時間はO(n)のみです。
最初の拡張:単純な繰り返し
もちろん、これは文字列に繰り返しが含まれていないためだけにうまく機能します。次に、より現実的な文字列を見てみましょう。
abcabxabcd
abc
前の例と同じように始まり、ab
が繰り返され、が続き、が繰り返されx
、abc
が続きd
ます。
ステップ1から3:最初の3つのステップの後に、前の例のツリーがあります。
ステップ4:#
位置4に移動します。これにより、既存のすべてのエッジが暗黙的に更新されます。
そして、現在のステップの最後のサフィックスをa
ルートに挿入する必要があります。
これを行う前に、(に加えて)さらに
2つの変数を導入します。#
もちろん、これらは常に存在しますが、これまでのところ使用していません。
- アクティブポイント三重であり、
(active_node,active_edge,active_length)
remainder
我々は挿入する必要がありますどのように多くの新しい接尾辞を表す整数です、
これら2つの正確な意味はすぐに明らかになりますが、今のところは言っておきましょう。
- シンプルでは
abc
例として、アクティブポイントは常にして
(root,'\0x',0)
、つまりはactive_node
ルートノードだった、active_edge
ヌル文字として指定されました'\0x'
、そしてactive_length
ゼロでした。この結果、すべてのステップで挿入した1つの新しいエッジが、新しく作成されたエッジとしてルートノードに挿入されました。この情報を表すためにトリプルが必要な理由はすぐにわかります。
remainder
常に各ステップの開始時に1に設定しました。これの意味は、各ステップの最後に積極的に挿入しなければならないサフィックスの数が1(常に最後の文字のみ)であることでした。
今、これは変わるでしょう。現在の最後の文字a
をルートに挿入するとa
、特にで始まる出力エッジが既にあることがわかりますabca
。このような場合の処理を次に示します。
- 私たちはしていない新鮮なエッジを挿入し
[4,#]
、ルートノードで。代わりに、サフィックスa
が既にツリーにあることに気づきます。それは長いエッジの真ん中で終わりますが、私たちはそれで気になりません。私たちは物事をそのままの状態にしています。
- アクティブポイントをに設定し
(root,'a',1)
ます。つまり、アクティブポイントは、ルートノードの出力エッジの中央、つまりで始まりa
、特にそのエッジの位置1の後にあることを意味します。エッジは最初の文字だけで指定されていることがわかりますa
。特定の文字で始まるエッジは1つだけなので、これで十分です(説明全体を読んだ後、これが正しいことを確認してください)。
- また、をインクリメントします
remainder
。そのため、次のステップの開始時には2になります。
観察:挿入する必要がある最後のサフィックスがツリーにすでに存在することが判明した場合、ツリー自体はまったく変更されません(アクティブポイントとのみを更新しますremainder
)。その場合、ツリーは現在の位置までのサフィックスツリーの正確な表現ではなくなりますが、すべてのサフィックスが含まれます(最後のサフィックスa
が暗黙的に含まれているため)。したがって、変数の更新(すべて固定長であるため、これはO(1)です)を除いて、この手順では
何も行われませんでした。
ステップ5:現在の位置#
を5に更新します。これにより、ツリーが次のように自動的に更新されます。
また、remainder
is 2 なので、現在の位置の最後の2つのサフィックスを挿入する必要があります:ab
とb
。これは基本的に次の理由によります:
a
前のステップのサフィックスが正しく挿入されたことはありません。したがって、それは残っており、1つのステップを進めて以来、現在はからa
に成長していab
ます。
- そして、新しい最終エッジを挿入する必要があります
b
。
実際には、これはアクティブポイント(a
現在はabcab
エッジの後ろのポイント)に移動し、現在の最終文字を挿入することを意味しb
ます。しかし、再び、b
同じエッジにすでに存在していることがわかります。
したがって、ここでもツリーを変更しません。私たちは単に:
- アクティブな点を更新します
(root,'a',2)
(以前と同じノードとエッジですが、今はの後ろを指していますb
)
remainder
前のステップの最終エッジをまだ適切に挿入しておらず、現在の最終エッジも挿入していないため、を3に増やします。
明確にするために、現在のステップでab
とを挿入する必要がありb
ましたが、ab
既に見つかったため、アクティブポイントを更新し、を挿入することさえしませんでしたb
。どうして?ab
がツリー内にある場合、その
すべてのサフィックス(を含むb
)もツリー内にある必要があるためです。おそらく暗黙的にだけかもしれませんが、これまでにツリーを構築した方法のため、そこにある必要があります。
ステップ6に進みます#
。ツリーは自動的に次のように更新されます。
のでremainder
3である、我々は挿入する必要がabx
、bx
と
x
。アクティブポイントはどこでab
終了するかを示しているので、そこにジャンプしてを挿入するだけで済みx
ます。実際、x
まだそこにはないので、abcabx
エッジを分割して内部ノードを挿入します。
エッジ表現は依然としてテキストへのポインタであるため、内部ノードの分割と挿入はO(1)時間で実行できます。
それで、2に対処しabx
、remainder
2にデクリメントしました。次に、残りの次のサフィックスを挿入する必要がありbx
ます。ただし、その前に、アクティブポイントを更新する必要があります。エッジを分割して挿入した後のこのルールは、以下のルール1と呼ばれ、active_node
がルートである場合は常に適用されます
(他のケースについては、以下でルール3を学習します)。これがルール1です。
ルートから挿入した後、
active_node
ルートのまま
active_edge
挿入する必要がある新しいサフィックスの最初の文字、つまり b
active_length
1減る
したがって、新しいアクティブポイントトリプル(root,'b',1)
は、次の挿入がbcabx
1文字の後ろ、つまり後ろで行われる必要があることを示しますb
。O(1)時間で挿入ポイントを識別し、x
すでに存在するかどうかを確認できます。存在する場合は、現在のステップを終了し、すべてをそのままにします。しかしx
、存在しないため、エッジを分割して挿入します。
繰り返しますが、これにはO(1)の時間がかかり、1に更新remainder
し(root,'x',0)
、ルール1の状態としてアクティブポイントを更新します。
しかし、やらなければならないことがもう1つあります。これをルール2と呼びます。
エッジを分割して新しいノードを挿入し、それが現在のステップで作成された最初のノードでない場合は、以前に挿入されたノードと新しいノードを特別なポインターである接尾辞リンクを介して接続します。なぜそれが役立つのかは後で説明します。これが私たちが得るものです、サフィックスリンクは点線のエッジとして表されます:
現在のステップの最後のサフィックスを挿入する必要があり
x
ます。以来、active_length
アクティブノードの成分が0まで低下した、最終的なインサートは直接ルートで行われます。で始まるルートノードには発信エッジがないためx
、新しいエッジを挿入します。
ご覧のとおり、現在のステップでは、残りのすべての挿入が行われました。
= 7を設定してステップ7に進みます。これにより、常に、すべてのリーフエッジに#
次の文字が自動的に追加さ
a
れます。次に、新しい最後の文字をアクティブポイント(ルート)に挿入しようとしましたが、すでにそこにあることがわかります。したがって、何も挿入せずに現在のステップを終了し、アクティブポイントをに更新し(root,'a',1)
ます。
ではステップ8、#
= 8、我々は追加しb
て、前に見たように、この唯一の手段は、我々は積極的にポイントの更新(root,'a',2)
と増分remainder
ので、他に何もせずにb
、既に存在しています。ただし、(O(1)時間で)アクティブポイントがエッジの終わりにあることがわかります。これをにリセットして反映し
(node1,'\0x',0)
ます。ここではnode1
、ab
エッジが終了する内部ノードを参照するために使用します。
次に、ステップ#
= 9で、「c」を挿入する必要があります。これは、最終的なトリックを理解するのに役立ちます。
2番目の拡張:サフィックスリンクの使用
いつものように、#
更新はc
リーフエッジに自動的に追加され、アクティブポイントに移動して「c」を挿入できるかどうかを確認します。'c'がそのエッジに既に存在することが判明したため、アクティブポイントをに設定し
(node1,'c',1)
、インクリメントしてremainder
、他には何もしません。
今では、ステップ#
= 10、remainder
4であり、インサートに我々最初必要があるので、
abcd
挿入することにより(3つのステップ前から残っている)d
アクティブポイントで。
d
アクティブなポイントに挿入しようとすると、O(1)時間でエッジが分割されます。
active_node
、分割が開始された、赤、上記に記されています。これが最後のルール、ルール3です。
active_node
ルートノードではないからエッジを分割した後、そのノードから出るサフィックスリンクがあればそれをたどり、が指すノードにをリセットactive_node
します。サフィックスリンクがない場合はactive_node
、ルートに設定します。active_edge
そして、active_length
変わりません。
したがって、アクティブポイントはになり(node2,'c',1)
、node2
下に赤でマークされます。
の挿入abcd
が完了したので、remainder
3 にデクリメントし、現在のステップの次の残りのサフィックスを検討し
bcd
ます。ルール3では、アクティブポイントを正しいノードとエッジに設定しているので、アクティブポイントにbcd
最後の文字d
を挿入するだけで挿入できます
。
これを行うと、別のエッジ分割が発生します。ルール2のため、以前に挿入されたノードから新しいノードへのサフィックスリンクを作成する必要があります。
観察:サフィックスリンクを使用すると、アクティブポイントをリセットできるため、O(1)で次の残りの挿入を行うことができます。実際にノードラベルで確認するために、上のグラフを見てab
のノードにリンクされているb
(その接尾辞)、およびのノードabc
にリンクされています
bc
。
現在のステップはまだ完了していません。remainder
これで2になり、アクティブポイントを再度リセットするには、ルール3に従う必要があります。現在active_node
(上記の赤)にはサフィックスリンクがないため、ルートにリセットします。現在アクティブなポイントは(root,'c',1)
です。
したがって、次の挿入は、最初の文字の後ろ、つまりの後ろで、ラベルがc
:cabxabcd
で始まるルートノードの1つの発信エッジで行われc
ます。これにより、別の分割が発生します。
これには新しい内部ノードの作成が含まれるため、ルール2に従い、以前に作成された内部ノードから新しいサフィックスリンクを設定します。
(私はこれらの小さなグラフにGraphviz Dotを使用しています。新しいサフィックスリンクはドットが既存のエッジを再配置する原因となったので、上に挿入された唯一のものが新しいサフィックスリンクであることを確認するために注意深くチェックしてください。)
これにより、remainder
を1に設定できます。これactive_node
はルートであるため、ルール1を使用してアクティブポイントをに更新し(root,'d',0)
ます。つまり、現在のステップの最後の挿入は、d
ルートにシングルを挿入することです。
これが最後のステップで、これで完了です。ただし、いくつかの最終的な観察があります。
各ステップで#
1ポジション進みます。これにより、すべてのリーフノードがO(1)時間で自動的に更新されます。
ただし、a)前のステップから残っているサフィックス、およびb)現在のステップの最後の1文字を処理しません。
remainder
追加の挿入をいくつ行う必要があるかを示します。これらの挿入は、現在の位置で終了する文字列の最後のサフィックスに1対1で対応し#
ます。次々と考えてインサートを作ります。重要:各挿入はO(1)時間で行われます。これは、アクティブポイントがどこに行くべきかを正確に指示し、アクティブポイントに1文字だけ追加する必要があるためです。どうして?他の文字は暗黙的に含まれているためです
(そうでない場合、アクティブポイントはその場所にありません)。
このような挿入が行われるたびremainder
に、サフィックスリンクがある場合はそれを減らして移動します。そうでない場合は、ルートに移動します(ルール3)。すでにルートにいる場合は、ルール1を使用してアクティブポイントを変更します。いずれの場合も、O(1)時間しかかかりません。
これらの挿入の1つで、挿入したい文字が既にそこにあることがわかった場合、remainder
> 0 であっても、何もせずに現在のステップを終了します。その理由は、残っている挿入は、先ほど作成した挿入のサフィックスになるためです。したがって、それらはすべて現在のツリーで暗黙的です。remainder
> 0であるという事実は、残りのサフィックスを後で処理することを確実にします。
アルゴリズムの最後でremainder
> 0の場合はどうなりますか?これは、テキストの最後がどこか以前に発生した部分文字列である場合に当てはまります。その場合、これまでにない文字列の最後に1文字追加する必要があります。文献では、通常、ドル記号$
がその記号として使用されています。なぜそれが重要なのですか?->完成したサフィックスツリーを後で使用してサフィックスを検索する場合、リーフで終了する場合にのみ一致を受け入れる必要があります。そうしないと、ツリーに暗黙的に含まれる多くの文字列がメイン文字列の実際のサフィックスではないため、多くの疑似一致が発生します。強制remainder
末尾が0になることは、基本的にすべてのサフィックスがリーフノードで終了することを保証する方法です。ただし、ツリーを使用してメイン文字列のサフィックスだけでなく一般的な部分文字列を検索する場合は、下のOPのコメントで示唆されているように、この最後の手順は実際には必要ありません。
では、アルゴリズム全体の複雑さはどのくらいですか?テキストの長さがn文字の場合、明らかにnステップ(またはドル記号を追加した場合はn + 1)になります。各ステップで(変数の更新以外に)何もしないか、またはremainder
O(1)時間をかけて挿入を行います。以来remainder
、私たちは前の手順で何もしなかった、と私たちは今、作ること、すべての挿入のために減少される回数を示し、私たちが何かをする回数の合計は、ちょうどn(またはN + 1)です。したがって、全体の複雑度はO(n)です。
ただし、適切に説明しなかった小さなことが1つあります。サフィックスリンクをたどり、アクティブポイントを更新した後、そのactive_length
コンポーネントが新しいでうまく機能しない場合がありactive_node
ます。たとえば、次のような状況を考えます。
(破線はツリーの残りの部分を示します。点線はサフィックスリンクです。)
次に、アクティブポイントをと(red,'d',3)
するf
と、defg
エッジの後ろの場所を指します。次に、必要な更新を行ったと想定し、サフィックスリンクをたどって、ルール3に従ってアクティブポイントを更新します。新しいアクティブポイントは(green,'d',3)
です。ただし、d
緑色のノードから出ている-edgeはde
なので、2文字しかありません。正しいアクティブポイントを見つけるには、そのエッジを青いノードまでたどり、にリセットする必要があります(blue,'f',1)
。
特に悪いケースでは、active_length
はと同じくらい大きく
remainder
なる可能性があり、nと同じくらい大きくなる可能性があります。そして、正しいアクティブポイントを見つけるために、1つの内部ノードをジャンプするだけでなく、最悪の場合は最大nまでジャンプする必要があることもよくあります。各ステップでは一般にO(n)であり、サフィックスリンクをたどった後のアクティブノードへの後調整もO(n)になる可能性があるため、アルゴリズムには隠されたO(n 2)の複雑さがあるということremainder
ですか?
いいえ。理由は、実際にアクティブポイントを調整する必要がある場合(たとえば、上記のように緑から青に)、独自のサフィックスリンクを持つ新しいノードに移動し、active_length
削減されるためです。サフィックスリンクのチェーンをたどると、残りの挿入が行われ、active_length
減少するだけであり、途中で行うことができるアクティブポイント調整の数はactive_length
、任意の時点より大きくなることはできません。ためには、
active_length
より大きくなることはありませんremainder
、とremainder
だけでなく、すべての単一工程でO(n)があるが、これまでに行われた増分の総和remainder
プロセス全体にわたってはO(n)があまりにも、活性点調整の数でありますまた、O(n)によって制限されます。