web-dev-qa-db-ja.com

接尾辞配列アルゴリズム

かなり読んだ後、接尾辞配列とLCP配列が何を表すかを理解しました。

Suffix array:配列の各サフィックスの_lexicographicランクを表します。

LCP配列:2つの連続する接尾辞が辞書式にソートされた後に一致する最大長の接頭辞が含まれます。

私は数日以来、接尾辞配列とLCPアルゴリズムが正確にどのように機能するかを理解しようと懸命に努力しています

Codeforces から取得したコードは次のとおりです。

/*
Suffix array O(n lg^2 n)
LCP table O(n)
*/
#include <cstdio>
#include <algorithm>
#include <cstring>

using namespace std;

#define REP(i, n) for (int i = 0; i < (int)(n); ++i)

namespace SuffixArray
{
    const int MAXN = 1 << 21;
    char * S;
    int N, gap;
    int sa[MAXN], pos[MAXN], tmp[MAXN], lcp[MAXN];

    bool sufCmp(int i, int j)
    {
        if (pos[i] != pos[j])
            return pos[i] < pos[j];
        i += gap;
        j += gap;
        return (i < N && j < N) ? pos[i] < pos[j] : i > j;
    }

    void buildSA()
    {
        N = strlen(S);
        REP(i, N) sa[i] = i, pos[i] = S[i];
        for (gap = 1;; gap *= 2)
        {
            sort(sa, sa + N, sufCmp);
            REP(i, N - 1) tmp[i + 1] = tmp[i] + sufCmp(sa[i], sa[i + 1]);
            REP(i, N) pos[sa[i]] = tmp[i];
            if (tmp[N - 1] == N - 1) break;
        }
    }

    void buildLCP()
    {
        for (int i = 0, k = 0; i < N; ++i) if (pos[i] != N - 1)
        {
            for (int j = sa[pos[i] + 1]; S[i + k] == S[j + k];)
            ++k;
            lcp[pos[i]] = k;
            if (k)--k;
        }
    }
} // end namespace SuffixArray

私はこのアルゴリズムがどのように機能するかを理解することはできません。私は鉛筆と紙を使って例に取り組んでみて、関係する手順を経て書きましたが、少なくとも私にとっては、あまりにも複雑なので、その間のリンクを失いました。

たぶん例を使用して説明に関する助けをいただければ幸いです。

45
Spandan

概要

これは、サフィックス配列構築のためのO(n log n)アルゴリズムです(または、::sortの代わりに2パスバケットソートが使用された場合は、そうなります)。

最初に2グラムをソートすることで機能します(*)、元の文字列Sの4グラム、8グラムなどのように、i番目の反復で2をソートします-グラム。明らかにログ以上のものはありません2(n)そのような反復、およびトリックは2-i番目のステップのグラムは、2つの2-gramsはO(1)時間(O(2ではなく)時間)。

これはどのように行われますか?さて、最初の反復で2グラム(別名バイグラム)をソートしてから、いわゆる辞書編集名の変更を実行します。これは、各バイグラムに対して、そのバイグラムソートでそのrankを格納する新しい配列(長さn)を作成することを意味します。

辞書式の名前変更の例:いくつかのバイグラムのsortedリストがあるとします{'ab','ab','ca','cd','cd','ea'}。次に、左から右に移動して、ランク0から始まり、newバイグラムの変更が発生するたびにランクを増分することにより、ranks(辞書式名)を割り当てます。したがって、割り当てるランクは次のとおりです。

ab : 0
ab : 0   [no change to previous]
ca : 1   [increment because different from previous]
cd : 2   [increment because different from previous]
cd : 2   [no change to previous]
ea : 3   [increment because different from previous]

これらのランクは辞書名として知られています。

さて、次の繰り返しで、4グラムをソートします。これには、異なる4グラム間の多くの比較が含まれます。 2つの4グラムをどのように比較しますか?まあ、文字ごとに比較することができます。これは、比較ごとに最大4つの操作になります。しかし、代わりに、前の手順で生成されたランクテーブルを使用して、それらに含まれる2つのバイグラムのランクをlook upで比較します。そのランクは、前の2グラムのソートからの辞書式ランクを表すため、特定の4グラムについて、その最初の2グラムが別の4グラムの最初の2グラムよりも高いランクである場合、辞書式に大きくする必要があります最初の2文字のどこか。したがって、2つの4グラムで最初の2グラムのランクが同一である場合、最初の2文字で同一でなければなりません。言い換えると、ランク表の2回の検索は、2つの4グラムの4文字すべてを比較するのに十分です。

ソート後、今回は4グラムの新しい辞書式の名前を再度作成します。

3回目の反復では、8グラムでソートする必要があります。繰り返しますが、前のステップからの辞書式ランク表での2つのルックアップは、2つの与えられた8グラムの8文字すべてを比較するのに十分です。

などなど。各反復iには2つのステップがあります。

  1. 2で並べ替え-grams、前の反復からの辞書式名を使用して、2つのステップ(O(1) time)での比較を可能にします)

  2. 新しい辞書式名を作成する

これをすべて2まで繰り返す-グラムは異なります。それが起こったら、完了です。すべてが異なるかどうかをどのようにして知るのでしょうか?まあ、辞書式の名前は、0から始まる整数の増加シーケンスです。したがって、反復で生成される最高の辞書式の名前がn-1と同じ場合、各2-gramには、独自の明確な辞書式名が付けられている必要があります。


実装

次に、コードを見て、これらすべてを確認しましょう。使用される変数は次のとおりです。sa[]は、作成するサフィックス配列です。 pos[]はランク検索テーブル(つまり、辞書式の名前を含む)です。具体的には、pos[k]には前のステップのk- th m-gramの辞書式の名前が含まれます。 tmp[]は、pos[]の作成に役立つ補助配列です。

コード行の間でさらに説明します。

void buildSA()
{
    N = strlen(S);

    /* This is a loop that initializes sa[] and pos[].
       For sa[] we assume the order the suffixes have
       in the given string. For pos[] we set the lexicographic
       rank of each 1-gram using the characters themselves.
       That makes sense, right? */
    REP(i, N) sa[i] = i, pos[i] = S[i];

    /* Gap is the length of the m-gram in each step, divided by 2.
       We start with 2-grams, so gap is 1 initially. It then increases
       to 2, 4, 8 and so on. */
    for (gap = 1;; gap *= 2)
    {
        /* We sort by (gap*2)-grams: */
        sort(sa, sa + N, sufCmp);

        /* We compute the lexicographic rank of each m-gram
           that we have sorted above. Notice how the rank is computed
           by comparing each n-gram at position i with its
           neighbor at i+1. If they are identical, the comparison
           yields 0, so the rank does not increase. Otherwise the
           comparison yields 1, so the rank increases by 1. */
        REP(i, N - 1) tmp[i + 1] = tmp[i] + sufCmp(sa[i], sa[i + 1]);

        /* tmp contains the rank by position. Now we map this
           into pos, so that in the next step we can look it
           up per m-gram, rather than by position. */
        REP(i, N) pos[sa[i]] = tmp[i];

        /* If the largest lexicographic name generated is
           n-1, we are finished, because this means all
           m-grams must have been different. */
        if (tmp[N - 1] == N - 1) break;
    }
}

比較機能について

関数sufCmpは、2つの(2 * gap)-gramを辞書的に比較するために使用されます。したがって、最初の反復ではバイグラムを比較し、2回目の反復では4グラム、次に8グラムなどを比較します。これは、グローバル変数であるgapによって制御されます。

sufCmpの単純な実装は次のようになります。

bool sufCmp(int i, int j)
{
  int pos_i = sa[i];
  int pos_j = sa[j];

  int end_i = pos_i + 2*gap;
  int end_j = pos_j + 2*gap;
  if (end_i > N)
    end_i = N;
  if (end_j > N)
    end_j = N;

  while (i < end_i && j < end_j)
  {
    if (S[pos_i] != S[pos_j])
      return S[pos_i] < S[pos_j];
    pos_i += 1;
    pos_j += 1;
  }
  return (pos_i < N && pos_j < N) ? S[pos_i] < S[pos_j] : pos_i > pos_j;
}

これは、i番目の接尾辞pos_i:=sa[i]の先頭にある(2 * gap)-gramをj番目の接尾辞pos_j:=sa[j]の先頭にあるものと比較します。そして、それらを文字ごとに比較します。つまり、S[pos_i]S[pos_j]と比較し、次にS[pos_i+1]S[pos_j+1]と比較します。文字が同一である限り続きます。それらが異なると、i番目の接尾辞の文字がj番目の接尾辞の文字より小さい場合は1を返し、それ以外の場合は0を返します。 (intを返す関数のreturn a<bは、条件が真の場合1を返し、偽の場合0を返すことに注意してください。)

Return-statementの複雑な外観条件は、(2 * gap)-gramsの1つが文字列の末尾にある場合を扱います。この場合、pos_iまたはpos_jは、それまでのすべての文字が同一であっても、すべての(2 * gap)文字が比較される前にNに達します。次に、i番目の接尾辞が末尾にある場合は1を返し、j番目の接尾辞が末尾にある場合は0を返します。すべての文字が同一である場合、shorter oneは辞書的に小さいため、これは正しいです。 pos_iが最後に達した場合、i番目の接尾辞はj番目の接尾辞より短くなければなりません。

明らかに、この素​​朴な実装はO(gap)です。つまり、その複雑さは(2 * gap)-gramの長さで線形です。ただし、コードで使用される関数は、辞書式の名前を使用して、これをO(1)(具体的には、最大2回の比較まで)にします):

bool sufCmp(int i, int j)
{
  if (pos[i] != pos[j])
    return pos[i] < pos[j];
  i += gap;
  j += gap;
  return (i < N && j < N) ? pos[i] < pos[j] : i > j;
}

ご覧のとおり、個々の文字S[i]およびS[j]を検索する代わりに、i番目とj番目の接尾辞の辞書式ランクをチェックします。辞書のランクは、ギャップグラムの前回の反復で計算されました。したがって、pos[i] < pos[j]の場合、i番目の接尾辞sa[i]は、sa[j]の先頭のギャップグラムより辞書的に小さいギャップグラムで始まる必要があります。つまり、pos[i]pos[j]を検索して比較するだけで、2つのサフィックスの最初のgap文字を比較しました。

ランクが同一である場合は、pos[i+gap]pos[j+gap]を比較して続行します。これは、(2 * gap)-gramsの次のgap文字、つまりsecond halfを比較するのと同じです。ランクが再び同一である場合、2つの(2 *ギャップ)グラムは同一であるため、0を返します。それ以外の場合、i番目の接尾辞がj番目の接尾辞より小さい場合は1を返します。


次の例は、アルゴリズムの動作方法を示し、特にソートアルゴリズムにおける辞書式名の役割を示しています。

ソートする文字列はabcxabcdです。このためのサフィックス配列を生成するには、3回の反復が必要です。各反復で、S(文字列)、sa(接尾辞配列の現在の状態)、およびtmpposを表示します。これらは辞書式名を表します。

まず、初期化します:

S   abcxabcd
sa  01234567
pos abcxabcd

最初にユニグラムの辞書式ランクを表す辞書式名が、文字(つまり、ユニグラム)自体と単純に同一であることに注意してください。

最初の反復:

ソート基準としてバイグラムを使用して、saをソートします。

sa  04156273

最初の2つの接尾辞は0と4です。これらはバイグラム「ab」の位置だからです。次に、1と5(バイグラム「bc」の位置)、次に6(バイグラム「cd」)、次に2(バイグラム「cx」)。 7(不完全なバイグラム 'd')、3(バイグラム 'xa')。明らかに、ポジションはキャラクターのバイグラムのみに基づいた順序に対応しています。

辞書編集名の生成:

tmp 00112345

説明したように、辞書式名は増加する整数として割り当てられます。最初の2つのサフィックス(両方ともバイグラム 'ab'で始まる)は0を取得し、次の2つ(両方ともバイグラム 'bc'で始まる)は1を取得し、2、3、4、5(それぞれ異なるバイグラム)を取得します。

最後に、saの位置に従ってこれをマッピングし、posを取得します。

sa  04156273
tmp 00112345
pos 01350124

posの生成方法は次のとおりです。左から右へsaを通過し、エントリを使用してposのインデックスを定義します。tmpの対応するエントリを使用しますそのインデックスの値を定義するためにpos[0]:=0pos[4]:=0pos[1]:=1pos[5]:=1pos[6]:=2など、インデックスはsatmpの値。

2回目の反復:

saを再度ソートし、再びpos(それぞれが元の文字列のtwoバイグラムのシーケンスを表す)からバイグラムを調べます。

sa  04516273

前のバージョンのsaと比較して、1 5の位置がどのように切り替わったかに注目してください。以前は15でしたが、現在は51です。これは、以前の反復中にpos[1]のバイグラムとpos[5]のバイグラムが同一(両方bc)だったためです。現在、pos[5]のバイグラムは12ですが、pos[1]のバイグラムは13です。したがって、位置5before position 1になります。これは、辞書式名がそれぞれ元の文字列のバイグラムを表すようになったためです。pos[5]bcを表し、pos[6]は 'cd'を表します。したがって、これらは一緒にbcdを表し、pos[1]bcを表し、pos[2]cxを表します。したがって、一緒にbcxを表します。 bcdより大きい。

再び、現在のバージョンのsaを左から右にスクリーニングし、posの対応するバイグラムを比較することにより、辞書式の名前を生成します。

tmp 00123456

posの対応するバイグラムは両方とも01であるため、最初の2つのエントリは同じです(両方とも0)。残りは、pos内の他のすべてのバイグラムがそれぞれ一意であるため、厳密に増加する整数のシーケンスです。

以前のように新しいposへのマッピングを実行します(saからインデックスを取得し、tmpから値を取得します):

sa  04516273
tmp 00123456
pos 02460135

3回目の反復:

saを再度並べ替え、posのバイグラム(常に)を取得します。これはそれぞれ、元の文字列の4つのバイグラムのシーケンスを表します。

sa  40516273

最初の2つのエントリの位置が入れ替わったことがわかります。0440になりました。これは、pos[0]のバイグラムが02であるのに対し、pos[4]のバイグラムは01であり、後者は明らかに辞書編集的に小さいためです。深い理由は、これら2つがそれぞれabcxabcdを表すためです。

辞書編集名を生成すると、次のようになります。

tmp 01234567

それらはすべて異なります。つまり、最高のものは7、つまりn-1です。ソートはすべて異なるm-gramに基づいているため、これで完了です。続けても、ソート順は変わりません。


改善提案

2のソートに使用されるアルゴリズム-各反復のグラムは、組み込みのsort(またはstd::sort)のように見えます。これは、それが比較ソートであることを意味します。最悪の場合、O(n log n)時間各反復でがかかります。最悪の場合、log n回の反復があるため、これはO(n(log n)2)-時間アルゴリズム。ただし、ソート比較に使用するキー(つまり、前のステップの辞書式名)は整数シーケンスを増加させるため、ソートはバケットソートの2つのパスを使用して実行できます。したがって、これは、サフィックスのソートのための実際のO(n log n)時間アルゴリズムに改善される可能性があります。


リマーク

これは、1992年の論文でManberとMyersによって提案されたサフィックス配列構築の元のアルゴリズムであると思います( Google Scholarのリンク ;これは最初のヒットであり、 a PDF there)。これは(同時に、GonnetとBaeza-Yatesの論文とは独立して)これは、サフィックス配列(当時pat配列とも呼ばれる)を導入したさらなる研究のために興味深いデータ構造。

接尾辞配列構築のための最新のアルゴリズムはO(n)であるため、上記のアルゴリズムは利用可能な最良のアルゴリズムではなくなりました(少なくとも理論的、最悪の場合の複雑さに関して)。


脚注

(*) 2-gramでは、元の文字列の2つのシーケンス連続を意味します。たとえば、S=abcdeが文字列の場合、abbccddeSの2グラムです。同様に、abcdbcdeは4グラムです。一般的に、m-gram(正の整数mの場合)はm連続文字のシーケンスです。 1グラムはユニグラムとも呼ばれ、2グラムはバイグラムと呼ばれ、3グラムはトリグラムと呼ばれます。一部の人々は、テトラグラム、ペンタグラムなどを続けます。

Sで始まるiのサフィックスは、Sの(n-i)-gramであることに注意してください。また、すべてのm-gram(すべてのm)は、Sのサフィックスのいずれかのプレフィックスです。したがって、m-gramのソート(mができるだけ大きい場合)は、サフィックスのソートに向けた最初のステップになります。

106
jogojapan