$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

文字列マッチングのためのLCP Arrayを構築する

前回のブログ記事で、文字列マッチングをするためのSuffix Arrayという構造を構築した。このSuffix Arrayという構造だけでも、テキスト長をn、パターン長をmとして、{\mathcal{O} ( m \log n ) }の計算量で文字列マッチングできるようになった。

しかし、前処理としてSuffix ArrayからLCP Array(Longest Common Prefix Array)という構造をさらに作っておくと、{\mathcal{O} ( m + \log n ) }という計算量で文字列マッチングが出来るようになるらしい。そこで、今回はLCP Array(Longest Common Prefix Array)の構築を実装してみる。今回はまだ構造構築までなので、文字列マッチングの実装はしていない。

実装は https://github.com/shibayu36/algorithm-study/tree/master/algorithms-on-string/src/main/java においてあるので、参考にどうぞ。

LCP Arrayとは

あたりの説明が分かりやすい。「LCP ArrayとはSuffix Arrayにおいて、隣同士のSuffixの最長共通接頭辞の長さを格納した配列」とのこと。これを使うことでSuffix Arrayを構築するときよりも、さらに文字列マッチングの効率を上げることが出来る。

これだけだとよくわからないので、例を挙げてみる。例えばbananaというテキストがあったとすると、Suffixをソートしたものは以下のようになる。(Suffix Arrayはpositionを並べたものなので、[ 5, 3, 1, 0, 4, 2 ]になる)

- a      (position=5)
- ana    (position=3)
- anana  (position=1)
- banana (position=0)
- na     (position=4)
- nana   (position=2)

すると、この並びで隣同士のSuffixで、最長共通接頭辞の長さを調べると

- aとanaなら1文字
- anaとananaなら3文字
- ananaとbananaなら0文字
- bananaとnaなら0文字
- naとnanaなら2文字

となるため、LCP Arrayは[ 1, 3, 0, 0, 2 ]となる。

単純なアルゴリズムを考える

では実際にLCP Arrayを構築してみる。単純に考えればSuffixをソートしたもの(suffixes)を使うと、以下のような順で構築できる。suffixesの配列長をnとしている。

  • suffixes[0]とsuffixes[1]で、先頭から1文字ずつ比較し、一致した文字列長をlcpArray[0]に入れる
  • suffixes[1]とsuffixes[2]で、先頭から1文字ずつ比較し、一致した文字列長をlcpArray[1]に入れる
  • ...
  • suffixes[n-2]とsuffixes[n-1]で、先頭から1文字ずつ比較し、一致した文字列長をlcpArray[n-2]に入れる

n-1番目のSuffixはsuffixes配列の最後なので、その次の比較対象がない。そのため、n-2番目とn-1番目の比較までで終了となる。


これをJavaで実装したものは以下のようになる。これで、検索したいテキスト(text)のSuffix Array(suffixArray)を先に作っておいて、LCPArray1.make(text, suffixArray)とすれば、LCP Arrayを取得することが出来る。

public class LCPArray1 {
  public static Integer[] make(String text, Integer[] suffixArray) {
    text += "\0"; // 番兵を入れておく

    int size = suffixArray.length;
    Integer[] lcpArray = new Integer[size];
    Arrays.fill(lcpArray, 0);
    for (int i = 0; i < size - 1; i++) {
      lcpArray[i] = calcLCP(text, suffixArray[i], suffixArray[i + 1]);
    }

    return lcpArray;
  }

  private static int calcLCP(String text, int pos1, int pos2) {
    int lcp = 0;
    while (text.charAt(pos1 + lcp) == text.charAt(pos2 + lcp)) {
      lcp++;
    }
    return lcp;
  }
}

これでひとまずLCP Arrayの生成はできた。しかし、この実装は、textの長さをnとすると、n回ループを回していて、かつcalcLCPでn文字分の比較があり得るので、時間計算量は{\mathcal{O} ( n ^ 2 ) }である。そのため、この実装では遅くなる可能性があり、もっと速いアルゴリズムが求められる。

kasai's アルゴリズムで構築する

もっと速いアルゴリズムが必要であると書いたが、LCP Arrayの構築はkasai's アルゴリズムという、{\mathcal{O} ( n ) }で計算できるアルゴリズムがある。そこでこちらも実装してみる。

この論文アルゴリズムが説明されていて、また http://www.geocities.jp/m_hiroi/light/pyalgo60.html の記事が日本語の説明として詳しい。

このアルゴリズムの基本としては、Suffixの長い順にループして計算していけば、前のループで計算したLCPを使って文字の比較回数を減らせるというものだ。

具体的に考える

しかし、論文や記事の説明だけを見てもよくわからないので、ひとまず具体的に先程のbananaの例を考えてみる。

まずbananaのSuffixをソートしたものは

- a      (position=5)
- ana    (position=3)
- anana  (position=1)
- banana (position=0)
- na     (position=4)
- nana   (position=2)

だが、これを長い順に計算したい。つまり以下が計算順である。

- banana (position=0)
- anana  (position=1)
- nana   (position=2)
- ana    (position=3)
- na     (position=4)
- a      (position=5)

計算は以下のようになり、先程と同様LCP Arrayは[ 1, 3, 0, 0, 2 ]となる。

- banana : na     (lcpArray[3]=0)
- anana  : banana (lcpArray[2]=0)
- nana   : null   (lcpArray[5]=なし)
- ana    : anana  (lcpArray[1]=3)
- na     : nana   (lcpArray[4]=2)
- a      : ana    (lcpArray[0]=1)

さて、よくよく見てみると、4行目の計算で比較しているanaとanana(LCP=3)から、それぞれ先頭1文字ずつ削ったものが次の比較であるnaとnanaとなっている。そのため、2文字目までは一致しているとみなして、3文字目から文字比較することで、文字の比較回数を減らせるということだ。ここまでが具体例を使った考え方である。

汎用的に考える

しかし、どんな場合でもこのように考えられるのか疑問に思えるので、もう少し汎用的に考えてみる。

まず、Suffix Arrayとして以下のように並んでいるとする。...はそれ以外のSuffixがたくさん並んでいるという意味で読んで欲しい。

- ...
- abcfh   (lcp=3)
- abcgdef
- ...

まずabcfhとabcgdefのそれぞれにおいて先頭一文字を削ったもの、つまりbcfhとbcgdefは、Suffix Arrayの性質上、もちろんSuffix Arrayのどこかに含まれる。

また、先頭が2文字以上マッチしていれば、先頭を1文字削っても辞書順は変わっていないはずである。つまり先頭一文字削ったものは以下のように並んでいるはずである。

- ...
- bcfh
- ...
- bcgdef
- ...

あとは、bcfhの隣も必ず先頭2文字は一致しているのかを考えたら良い。これもそこまで難しくなくて、Suffixが辞書順に並んでいるなら、bcfhとbcgdef間のSuffixは、少なくとも先頭はbcから始まっているよねということだ(それ以外から始まっていたらどうなるか試してみると理解できると思う)。

- ...
- bcfh
- bc????
- ...
- bcgdef
- ...

これらから、abcfhのLCPを計算したあと、それを一文字削ったbcfhのLCPを計算するときは、少なくとも2文字はマッチしていると考えて、3文字目から比較してもよいということになる。


ここまでを理解したら、この論文 も大体理解できるようになると思う。

実装する

ここまでをまとめると、次のように計算していけば良い。

  • LCPはSuffixの長い順に計算する
  • 前のループステップでLCPが2以上(1文字削ってもLCPが1以上)であれば、前のLCP-1分の文字比較はスキップしてその次から比較をスタートすれば良い
  • 前のループステップでLCPが0か1なら、もう一度1文字目から比較していく

これをJavaで実装してみると以下のようになった。検索したいテキスト(text)のSuffix Array(suffixArray)を先に作っておいて、LCPArray2.make(text, suffixArray)とすれば、LCP Arrayを取得することが出来る。

public class LCPArray2 {
  public static Integer[] make(String text, Integer[] suffixArray) {
    text += "\0"; // 番兵を入れておく

    // 文字の長さ順にLCPを計算できるように、
    // suffixArray内のどこに位置するか、
    // 文字列長さ順に並べた配列を用意する
    int size = suffixArray.length;
    Integer[] rank = new Integer[size];
    for (int i = 0; i < size; i++) {
      rank[suffixArray[i]] = i;
    }

    Integer[] lcpArray = new Integer[size];
    int lcp = 0;
    for (int i = 0; i < size; i++) {
      // suffixArray中のindex番目のLCPを計算する
      int index = rank[i];
      int pos1 = suffixArray[index];
      // indexが最後なら、次の比較するものはないので、lcpは0で終わり
      if (index == size - 1) {
        lcpArray[index] = lcp = 0;
        continue;
      }

      int pos2 = suffixArray[index + 1];
      lcpArray[index] = lcp = calcLCP(text, pos1, pos2, lcp);

      // 次は一文字削ったものなので、lcpは1減らす
      lcp--;
      // lcpがもともと0か1なら、-1か0なので、そのときは1文字目から再開
      if (lcp <= 0) lcp = 0;
    }

    return lcpArray;
  }

  private static int calcLCP(String text, int pos1, int pos2, int lcp) {
    while (text.charAt(pos1 + lcp) == text.charAt(pos2 + lcp)) {
      lcp++;
    }
    return lcp;
  }
}

速度検証と考察

アルゴリズムの速度検証をやってみると、さらに理解が深まると思ったので、検証をしてみた。テストケースとしては

調べることは、「文字の比較回数」と「全体の速度」とする。


ベンチマーク用のコードは次のとおり*1

https://github.com/shibayu36/algorithm-study/blob/master/algorithms-on-string/src/test/java/LCPArrayBenchmark.java

public class LCPArrayBenchmark {
  @Test
  public void make() throws Exception {
    long begin;
    Integer[] suffixArray, lcpArray;
    String text = StringUtils.repeat("bananapanama", 1000);
    suffixArray = SuffixArray2.make(text);

    // warmup
    LCPArray1.make(text, suffixArray);
    LCPArray2.make(text, suffixArray);

    System.out.println("---- bananapanama x 1000 ----");
    begin = currentTimeMillis();
    lcpArray = LCPArray1.make(text, suffixArray);
    System.out.println("lcp array1 compareNum: " + LCPArray1.compareNum);
    System.out.println("lcp array1: " + (currentTimeMillis() - begin) + "ms");

    begin = currentTimeMillis();
    lcpArray = LCPArray2.make(text, suffixArray);
    System.out.println("lcp array2 compareNum: " + LCPArray2.compareNum);
    System.out.println("lcp array2: " + (currentTimeMillis() - begin) + "ms");

    System.out.println("---- random 100000 chars ----");
    text = RandomStringUtils.randomAlphabetic(100000);
    suffixArray = SuffixArray2.make(text);

    begin = currentTimeMillis();
    lcpArray = LCPArray1.make(text, suffixArray);
    System.out.println("lcp array1 compareNum: " + LCPArray1.compareNum);
    System.out.println("lcp array1: " + (currentTimeMillis() - begin) + "ms");

    begin = currentTimeMillis();
    lcpArray = LCPArray2.make(text, suffixArray);
    System.out.println("lcp array2 compareNum: " + LCPArray2.compareNum);
    System.out.println("lcp array2: " + (currentTimeMillis() - begin) + "ms");

    // 指定のファイル URL のファイルをバイト列として読み込む
    // sample.txtにhttps://ja.wikipedia.org/wiki/%E6%94%BF%E6%95%99%E5%88%86%E9%9B%A2%E3%81%AE%E6%AD%B4%E5%8F%B2 がコピペされているとする
    try {
      byte[] fileContentBytes = Files.readAllBytes(Paths.get("./sample.txt"));
// 読み込んだバイト列を UTF-8 でデコードして文字列にする
      text = new String(fileContentBytes, StandardCharsets.UTF_8);
    }
    catch (Exception e) {}
    suffixArray = SuffixArray2.make(text);

    System.out.println("---- Wikipedia ----");
    begin = currentTimeMillis();
    lcpArray = LCPArray1.make(text, suffixArray);
    System.out.println("lcp array1 compareNum: " + LCPArray1.compareNum);
    System.out.println("lcp array1: " + (currentTimeMillis() - begin) + "ms");

    begin = currentTimeMillis();
    lcpArray = LCPArray2.make(text, suffixArray);
    System.out.println("lcp array2 compareNum: " + LCPArray2.compareNum);
    System.out.println("lcp array2: " + (currentTimeMillis() - begin) + "ms");

    text = text + text;
    suffixArray = SuffixArray2.make(text);

    System.out.println("---- Wikipedia x 2 ----");
    begin = currentTimeMillis();
    lcpArray = LCPArray1.make(text, suffixArray);
    System.out.println("lcp array1 compareNum: " + LCPArray1.compareNum);
    System.out.println("lcp array1: " + (currentTimeMillis() - begin) + "ms");

    begin = currentTimeMillis();
    lcpArray = LCPArray2.make(text, suffixArray);
    System.out.println("lcp array2 compareNum: " + LCPArray2.compareNum);
    System.out.println("lcp array2: " + (currentTimeMillis() - begin) + "ms");
  }
}

実行結果は以下のようになった。

---- bananapanama x 1000 ----
lcp array1 compareNum: 71874078
lcp array1: 389ms
lcp array2 compareNum: 23994
lcp array2: 6ms
---- random 100000 chars ----
lcp array1 compareNum: 326219
lcp array1: 11ms
lcp array2 compareNum: 199947
lcp array2: 25ms
---- Wikipedia ----
lcp array1 compareNum: 785373
lcp array1: 32ms
lcp array2 compareNum: 311735
lcp array2: 49ms
---- Wikipedia x 2 ----
lcp array1 compareNum: -592799649
lcp array1: 29132ms
lcp array2 compareNum: 625309
lcp array2: 58ms

「banamapanama x 1000」と「Wikipedia x 2」はkasai'sアルゴリズムのほうが圧倒的に比較回数も少なく実行時間も短い。「Wikipedia x 2」の比較回数に関しては、longでもオーバーフローして負数になるくらいである。

一方で、「random 100000 chars」と「Wikipedia」に関しては、むしろkasai'sアルゴリズムのほうが若干遅くなってしまっている。


この結果から、kasai'sアルゴリズムの仕組みをもう一度考えてみると、kasai'sアルゴリズムはLCPの値が大きくなればなるほど、次ステップの比較を多くスキップ出来るものである。そのため、「banamapanama x 1000」と「Wikipedia x 2」のようなLCPが非常に大きくなるようなテキストでは非常に効率的になる。逆にLCPが大きくなりにくい「random 100000 chars」と「Wikipedia」に関しては、比較回数の差がそこまでつかないので、他の処理を行っている分、愚直なアルゴリズムのほうが速くなりうる。

この特性を考えると、例えば遺伝子構造のような、ほとんど同じような文字列が並んでいるけど、たまに違うといったようなテキストには、莫大な効果を上げられるのだろう。

*1:正確に測定するには何度も動かして平均を取る必要があるが、今回は大体把握できたら良いので、この程度に留めた