接尾辞木
接尾辞木(せつびじき)またはサフィックス木(Suffix tree)は、与えられた文字列の接尾部を木構造(基数木)で表すデータ構造であり、多くの文字列操作の高速な実装に利用されている。
文字列 S の接尾辞木は木構造であり、その枝には文字列が対応し、木構造の根から葉までの経路ごとにそれぞれ S の接尾部の1つが対応している。従って、これは S の接尾部に関する基数木である。
文字列 S からそのような木構造を構築するには、S の長さに対して線形な時間と空間を要する。構築できれば、いくつかの操作が高速化される(S の部分文字列を探す、誤字をある程度許容した上での部分文字列特定、正規表現パターンとのマッチングなど)。接尾辞木は最長共通部分文字列問題の線形な解法の1つでもある。これらの高速化の代償として、接尾辞木に要するメモリ空間は文字列そのものを格納するのに要するメモリ空間よりもかなり大きくなる。
== 歴史 ==
この概念は position tree として 1973年、Weiner が初めて紹介した。ドナルド・クヌースはその論文を "Algorithm of the Year 1973" と評した。1976年、McCreight が構築法を大幅に単純化し、1995年には Ukkonen がさらに洗練させた。Ukkonen のアルゴリズムは接尾辞木を線形時間かつオンラインで構築する最初のアルゴリズム(文字列全体を入力する前から構築を開始できるアルゴリズム)であった。
ある文字列の接尾部とは、その文字列の先頭から1文字ずつ文字を除いていった残りの部分文字列全体を指す。例えば、"BANANA" の接尾部は次のようになる。
従って、長さ n の文字列の接尾部としては、元の文字列も含めると n 個の文字列が存在することになる。
長さ n の文字列 S に関する接尾辞木は、木構造として次のように定義される ( page 90):
根から葉までのそれぞれの経路に S の接尾辞(接尾部)が一対一に対応する。
各枝には空でない文字列が対応する。
根と葉以外のノードには少なくとも2つの子ノードがある。
どのような文字列にもこういった構成の木構造を構築できるわけではないので、S には本来含まれない終端記号(一般に $ で表される)を補うことがある。これにより、ある接尾部が別の接尾部の接頭部とならないようにし、n 個の葉が必ず存在して、それぞれが S の n 個の接尾部のいずれかに対応するようにする。根以外の中間ノードは全て分岐を伴うので、中間ノードの最大個数は n-1 個となり、全体としては最大 n+(n-1)+1=2n 個のノードが存在しうる。
接尾辞木の線形時間構築の鍵となるのは「接尾辞リンク(サフィックスリンク)」である。完全な接尾辞木では、根以外の内部ノードは全て他の内部ノードへの接尾辞リンクを持つ。根からあるノードまでの経路に対応する文字列が \chi\alpha で \chi が1つの文字、\alpha が(空文字列を含む)文字列であるとき、そのノードから \alpha を経路とするノードへの接尾辞リンクが存在する。図示された接尾辞木では、ANA に対応するノードから NA に対応するノードへ接尾辞リンクがある。接尾辞リンクは、接尾辞木を使ったアルゴリズムでも使われる場合がある。
文字のサイズが一定または整数の場合、長さ n の文字列 S に関する接尾辞木の構築には \Theta(n) の時間がかかる。文字サイズが一定でない場合、構築時間は実装に依存する。以下では文字サイズが一定という前提でコストを表示している。そうでない場合、コストは実装に依存する。
長さ n の文字列 S に関する接尾辞木があるとする。あるいは、長さの総和が n=|n_1|+|n_2|+...+|n_K| の文字列集合 D=\{S_1,S_2,...,S_K\} の汎用接尾辞木があるとする。これについて、以下のような機能がある。
文字列検索:
長さ m の文字列 P が部分文字列かどうかを O(m) 時間で判定する( page 92)。
長さの総和が m のパターン群 P_1,...,P_q が最初に出現する位置を O(m) 時間で検出する(接尾辞木を Ukkonen のアルゴリズムで構築した場合)。
長さの総和が m の部分文字列群が z 回出現する全位置を O(m + z) 時間で検出する( page 123)。
正規表現 P の検索を n の準線形時間で行うことが期待できる()。
パターン P の各接尾部について、P[i...m] で表される接頭部と D の部分文字列との最長一致を \Theta(m) 時間で探す( page 132)。これを P の matching statistics と呼ぶ。
文字列の属性検出:
文字列 S_i と S_j の最長共通部分文字列を \Theta(n_i + n_j) 時間で探す( page 125)。
全ての繰り返し出現する部分文字列(maximal repeats/supermaximal repeats)を \Theta(n + z) 時間で探す( page 144)。
LZWなどのLempel-Ziv法の解凍を \Theta(n) 時間で探す( page 166)。
最長繰り返し部分文字列を \Theta(n) 時間で探す。
最短でかつ最頻出する部分文字列を \Theta(n) 時間で探す。
D に現れない \Sigma 内の最短文字列が z 個あるとき、O(n + z) 時間でそれらを探す。
一度だけ出現する最短部分文字列を \Theta(n) 時間で探す。
各 i について S_i の部分文字列で D 内で他に出現しない最短なものを \Theta(n) 時間で探す。
接尾辞木はノード間の最も近い共通先祖 (LCA) の検索を \Theta(n) 時間でできる( chapter 8)。また、以下のようなことも可能である。
接尾部 S_i[p..n_i] と S_j[q..n_j] の最長共通接頭部 を \Theta(1) 時間で探す( page 196)、
長さ m のパターン P を最大 k 文字の不一致を許容した上で探すとき、z 個見つかる場合の時間が O(k n + z) となる( page 200)。
最長の回文を \Theta(n) 時間で探す( page 198)。長さ g のギャップを含む場合は \Theta(g n)、k 文字の不一致を許す場合は \Theta(k n) となる( page 201)。
z個の連続の繰り返し(tandem repeats)を O(n \log n + z) 時間で探す。k文字の不一致を許容する場合 O(k n \log (n/k) + z) となる( page 204)。
D 内の少なくとも k 個(k=2..K)の文字列にある最長共通部分文字列を \Theta(n) 時間で探す( page 205)。
接尾辞木はバイオインフォマティクスで、DNAや蛋白質を長い文字列に見立てたパターン検索によく使われる。接尾辞木の最大の利点は、ミスマッチを許容した効率的な検索能力である。また、繰り返しのデータを検出できることからデータ圧縮にも使われ、ブロックソートのソート段階でも使われる。データ圧縮法のLZWの一種であるLZSSでも使われている。接尾辞木を使ったデータ・クラスタリングのアルゴリズムが一部の検索エンジンに使われている。
各ノードまたは枝に要するメモリ空間を \Theta(1) で表すと、接尾辞木全体には \Theta(n) の空間が必要である。枝の長さ(対応する文字数)の総和は O(n^2) だが、各枝に対応する情報は S の部分文字列の位置と長さであり(部分文字列のコピーを枝ごとに持つ必要はない)、全体として必要なメモリ空間は \Theta(n) ワードとなる。最悪の場合の例として、フィボナッチ列を接尾辞木で表すと、2n 個のノードを要する。
接尾辞木を実装する際の重要な問題として、親ノードと子ノードの関係の表し方がある。最も典型的な実装は「兄弟リスト; sibling list」と呼ばれる線形リストを使う方法である。各ノードが最初の子ノードへのポインタを持ち、子ノード同士を線形リストでつなぐ(子供同士のリストなので兄弟リスト)。ハッシュテーブル、ソート済み/未ソート配列(動的配列)、平衡2分探索木なども使われ、それぞれ実行時間特性が異なる。ここでは以下に注目する。
あるノードの全子ノードをリストアップするコスト(下表では子ノード数で割った値)
文字のサイズ(種類)を \sigma としたとき、コストは以下のようになる。
なお、挿入コストは償却計算量(amortized complexity)であり、ハッシュ操作のコストは衝突が発生しない完全ハッシュを前提としている。
各ノードや枝に情報が分散するため、接尾辞木は効率が悪く、よい実装であっても元の文字列の約10倍から20倍のメモリを消費する。接尾辞配列はこれを4倍程度に削減でき、研究者はさらなるメモリ使用量削減方法を模索している。