Nuartz

notes

Search

Search IconIcon to open search

Suffix Array と LCP と 文字列検索の実装をした

Last updated Dec 16, 2019

この土日のメモです. SAとLCPのお気持ちをまとめたくなっただけ. 間違ってたらごめん

文字列アルゴの勉強する気が起きないたった一つの理由: Rolling Hash— νιυεζ (@xiuez) 2019年12月13日

これをやめたいので, 手始めにSuffix Arrayを使った文字列検索をやってみようかなというのが今回の主題

# 概要

の実装をやってみました. このときのメモを残しておきたいと思います.

各用語の説明はここではしません… 他の記事や, 蟻本 - Amazonを参考に.

# SA-IS

Suffix Arrayの実装は蟻本にも載っていますが, そこまで早くありません… SA-ISというアルゴリズムが早いらしいのでこれを実装します.

SA-ISの理解には, この記事がとても参考になりました. とてもわかりやすい記事です.

SA-IS 法のメモ - まめめも

SA-ISの実装はyosupoさんのコードを見ました.

僕が書いたSA-ISのコードはこれです.

Submitted

実はSA-ISの論文に実装が載っていてそれがとても速いです. ぜひ参考にしてみてください.

以下, 文字列SのSuffix ArrayをSAとします. Sの辞書順でi番目に小さいsuffixをSuf[i] := S[SA[i]…]とします.

# LCP(Longest Common Prefix)

LCP配列は, Suffix Arrayで隣り合ったSuffix(つまり, Suf[i]とSuf[i + 1])の最長共通接頭辞を求めた配列です. Kasai's Algorithmを用いて$ O(|S|)$で構築できます.

LCPの理解は以下の記事がわかりやすいです. 蟻本にもあるはず.

LCP配列 (Kasai’s algorithm)

例は, 接尾辞配列(Suffix-Array) | Luzhiled’s memo がわかりやすいです.

僕の実装は先頭に無(空配列)があるので, 以下のようになります.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
i :lcp
0 : 0 
1 : 0 a
2 : 1 abra
3 : 4 abracadabra
4 : 1 acadabra
5 : 1 adabra
6 : 0 bra
7 : 3 bracadabra
8 : 0 cadabra
9 : 0 dabra
10: 0 ra
11: 2 racadabra

# 任意のsuffix同士のLCP

上の例で, i = 2, abraと, j = 5, adabraのLCPを求めるとすると, 3, 4, 5lcpの最小値である1がその答えになります.

Suffix Arrayで, indexが $ i$ のsuffixと $ j$ のsuffixのLCPは, $ [i + 1, j + 1)$ 間のlcpの最小値になります.

なので, lcpをSparse Tableに載せると構築 $ O(|S| \log{|S|})$, クエリ$ O(1)$で処理できます.

# Suffix Arrayで文字列検索

文字列SのSuffix Array SAを使って, Sの中に文字列Tがあるかどうかを二分探索で処理できます. これは, Suffix Arrayによって各suffixがソートされているのを利用しています.

計算量は$ O(|T| \log{|S|})$です. AOJの提出コード

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
cin >> t;
int L = 0;
int R = sa.size();
while(R - L > 1) {
  int M = (L + R) >> 1;
  if(s.substr(sa[M], t.size()) <= t) {
    L = M;
  }
  else {
    R = M;
  }
}
cout << (s.substr(sa[L], t.size()) == t) << endl;

これがかなりはやい なんでだろう

# SAとLCPで文字列検索

この二分探索はさらに高速化できます. suffixとTの比較を最小限にすることで, $ O(|T| + \log{|S|})$を達成します.

具体的には, suf[L]とTのLCPを常に持ちながら二分探索をします. このLCPをLlcpとします. M = (L + R) / 2として, suf[L]suf[M]のLCPを求めて, nlcpとします. nlcpは先に書いたとおり, Sparse Tableで求めることができます. 次にLlcpnlcpを比較します.

以下の例で考えてみます. (Suffix Arrayではありませんが, 複数の文字列を辞書順にソートしたという意味で同じです)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
T = ad

L : aaa
    aaab
    aaac
M : aac
    aacc
    ba
R : 

Llcp = LCP(aaa, ad) = 1  // "a"aa, "a"dなので
nlcp = LCP(aaa, aac) = 2 // "aa"a, "aa"cなので

Tは辞書順でsuf[T]以上ということがわかっているので, Llcp < nlcpより, Tとsuf[M]のLCPはLlcpであり, Tは辞書順でsuf[M]以上です. なので, Llcpはそのままで, L = Mとします.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
T = aaac

L : aaa
    aaab
    aaac
M : aac
    aacc
    ba
R : 

Llcp = LCP(aaa, aaac) = 3  // "aaa", "aaa"cなので
nlcp = LCP(aaa, aac) = 2 // "aa"a, "aa"cなので

Tsuf[M]のLCPはnlcpであり,Tは辞書順でsuf[M]未満です. なので, Llcpはそのままで,R = M`とします.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
T = aacc

L : aaa
    aaab
    aaac
M : aac
    aacc
    ba
R : 

Llcp = LCP(aaa, aacc) = 2  // "aa"a, "aa"ccなので
nlcp = LCP(aaa, aac) = 2 // "aa"a, "aa"cなので

このときは, Tsuf[M]の辞書順の関係がわからないので比較をします. このとき, LCPの部分は一致していることがわかっているので比較をしなくてよいです. 比較をした後, Llcpを比較をした時の計算結果を利用して更新します.

Llcpは探索中, 単調増加します. なので, 文字列の比較が全体で$ O(|T|)$しかされません. これにより, 計算量が改善されます.

実際にコードを示します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
std::pair&lt;int, int> get_lcp(const std::vector&lt;T>&amp; t, int si, int offset) {
  int i = offset;
  si += offset;
  while(i &lt; t.size() &amp;&amp; si &lt; N) {
    if(t[i] != str[si]) {
      return { i, t[i] - str[si] };
    }
    i++;
    si++;
  }
  return { i, 0 };
}

std::pair&lt;int, int> search(const std::vector&lt;T>&amp; t) {
  int L = 0;
  int R = N + 1;
  int Llcp = 0;

  while(R - L > 1) {
    int M = (L + R) >> 1;
    int nlcp = st.query(L + 1, M + 1);
    if(Llcp &lt; nlcp) {
      L = M;
    }
    else if(Llcp > nlcp) {
      R = M;
    }
    else {
      auto p = get_lcp(t, sa[M], Llcp);
      if(p.second >= 0) {
        L = M;
        Llcp = p.first;
      }
      else if(p.second &lt; 0) {
        R = M;
      }
    }
  }

  return { Llcp, L };
}

これで早くなるはず…!

Aizu Online Judge

5倍遅くなった…

# Sparse Tableの構築が重すぎる

$ O(|S| \log{|S|})$ 流石に重い… 改善したい

# Sparse Tableを使わない方法で改善

二分探索だけならSparse Tableである必要はありません. Segment Treeを使います.
二分探索で最小値を求めたい区間は必ず[L, (L + R) / 2)に対応できます. なので, 二分探索するときに, Segment Treeのノードを降りていくようにすると 構築$ O(|S|)$で二分探索ができるようになります.

コードはこんな感じ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
...
...
  seg_n = 1;
  while(seg_n &lt; N + 1) seg_n &lt;&lt;= 1;
  seg.resize(seg_n * 2, 1e9);
  for(int i = 0;i + 1 &lt; N + 1;i++) {
    seg[i + seg_n - 1] = lcp[i + 1];
  }
  for(int i = seg_n - 1; i --> 0;) {
    seg[i] = std::min(seg[(i &lt;&lt; 1) + 1], seg[(i &lt;&lt; 1) + 2]);
  }
}

std::pair&lt;int, int> get_lcp(const std::vector&lt;T>&amp; t, int sa_i, int offset) {
  if(sa_i > N) return { offset, -1 };
  int i = offset;
  int si = sa[sa_i] + offset;
  while(i &lt; t.size() &amp;&amp; si &lt; N) {
    if(t[i] != str[si]) {
      return { i, t[i] - str[si] };
    }
    i++;
    si++;
  }
  return { i, 1 };
}

std::pair&lt;int, int> search(const std::vector&lt;T>&amp; t) {
  int L = 0;
  int R = seg_n;
  int Llcp = 0;
  int j = 0;

  while(R - L > 1) {
    int M = (L + R) >> 1;
    int nlcp = seg[(j &lt;&lt; 1) + 1];
    if(nlcp == 1e9) {
      j = (j &lt;&lt; 1) + 1;
      R = M;
    }
    else if(Llcp &lt; nlcp) {
      j = (j &lt;&lt; 1) + 2;
      L = M;
    }
    else if(Llcp > nlcp) {
      j = (j &lt;&lt; 1) + 1;
      R = M;
    }
    else {
      auto p = get_lcp(t, M, Llcp);
      if(p.second >= 0) {
        j = (j &lt;&lt; 1) + 2;
        L = M;
        Llcp = p.first;
      }
      else if(p.second &lt; 0) {
        j = (j &lt;&lt; 1) + 1;
        R = M;
      }
    }
  }

  return { Llcp, L };
}

Aizu Online Judge

これでも最初の二分探索に勝てませんでした… なんでだろう でもこれでもかなり速いです.

# しめ

FM-indexとかやってみたくなりました