Suffix Array と LCP と 文字列検索の実装をした
Last updated
Dec 16, 2019
この土日のメモです. SAとLCPのお気持ちをまとめたくなっただけ. 間違ってたらごめん
文字列アルゴの勉強する気が起きないたった一つの理由: Rolling Hash— νιυεζ (@xiuez) 2019年12月13日
これをやめたいので, 手始めにSuffix Arrayを使った文字列検索をやってみようかなというのが今回の主題
# 概要
- SA-ISでSuffix Arrayを構築$ O(|S|)$
- LCP配列の構築$ O(|S|)$
- LCPによるSuffix同士のLCPをSparse Tableで構築$ O(|S| \log{|S|})$, クエリ$ O(1)$
- Suffix Arrayの二分探索で文字列検索を$ O(|T| log{|S|})$
- Suffix ArrayとLCPの二分探索で文字列検索を構築$ O(|S|)$, クエリ$ O(|T| + \log{|S|})$
の実装をやってみました. このときのメモを残しておきたいと思います.
各用語の説明はここではしません… 他の記事や, 蟻本 - Amazonを参考に.
# SA-IS
Suffix Arrayの実装は蟻本にも載っていますが, そこまで早くありません… SA-ISというアルゴリズムが早いらしいのでこれを実装します.
SA-ISの理解には, この記事がとても参考になりました. とてもわかりやすい記事です.
SA-IS 法のメモ - まめめも
SA-ISの実装はyosupoさんのコードを見ました.
僕が書いたSA-ISのコードはこれです.
Submitted
- メモリを使い回す(resizeの回数を減らしてメモリを使いまわしても, assignが割と早くてこれが非自明)
push_back
をなくす- 入出力の早いライブラリを使うともっと早くなります
実は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, 5
のlcp
の最小値である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で求めることができます. 次にLlcp
とnlcp
を比較します.
以下の例で考えてみます. (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なので
|
Tと
suf[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なので
|
このときは, T
とsuf[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<int, int> get_lcp(const std::vector<T>& t, int si, int offset) {
int i = offset;
si += offset;
while(i < t.size() && si < N) {
if(t[i] != str[si]) {
return { i, t[i] - str[si] };
}
i++;
si++;
}
return { i, 0 };
}
std::pair<int, int> search(const std::vector<T>& 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 < 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 < 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 < N + 1) seg_n <<= 1;
seg.resize(seg_n * 2, 1e9);
for(int i = 0;i + 1 < 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 << 1) + 1], seg[(i << 1) + 2]);
}
}
std::pair<int, int> get_lcp(const std::vector<T>& t, int sa_i, int offset) {
if(sa_i > N) return { offset, -1 };
int i = offset;
int si = sa[sa_i] + offset;
while(i < t.size() && si < N) {
if(t[i] != str[si]) {
return { i, t[i] - str[si] };
}
i++;
si++;
}
return { i, 1 };
}
std::pair<int, int> search(const std::vector<T>& 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 << 1) + 1];
if(nlcp == 1e9) {
j = (j << 1) + 1;
R = M;
}
else if(Llcp < nlcp) {
j = (j << 1) + 2;
L = M;
}
else if(Llcp > nlcp) {
j = (j << 1) + 1;
R = M;
}
else {
auto p = get_lcp(t, M, Llcp);
if(p.second >= 0) {
j = (j << 1) + 2;
L = M;
Llcp = p.first;
}
else if(p.second < 0) {
j = (j << 1) + 1;
R = M;
}
}
}
return { Llcp, L };
}
|
Aizu Online Judge
これでも最初の二分探索に勝てませんでした… なんでだろう でもこれでもかなり速いです.
# しめ
FM-indexとかやってみたくなりました