LCS のワードサイズ高速化
概要
[1] で紹介されている LCS のワードサイズ高速化について、自分の理解を記します。 特に新しい主張はないです。
内容は [2] を大きく参考にしています。
dp 配列とその差分
を二つの文字列 の LCS の長さ、と定義します。 次が成り立ちます。
についての差分 を考えます。 なので は bit 列として表現できます。
遷移の観察
から への遷移を考えます。
便宜上、 の末尾に を、 の末尾に を追加しています。
また、 なる添字列 について、 に分割して、二重線で囲んでいます。
各区間について、 の位置の変化に注目しましょう。 同じ場所にあるか、左側に移動していることがわかります。 移動しているのは
- の場所
- の場所
のうち、最も左にある場所です。
自然な解釈
この の移動は の意味を考えるとより自然かもしれません。 なる添字 を一つ取ります。 このとき は (つまり と の LCS の長さ) に等しいです。
これは逆に LCS の長さが 初めて になるような添字 の列を が管理していると考えることができます。 文字 が追加されると、各添字は (前の添字を追い越さないように) 左側に進んでいきます。 番兵として の末尾に を追加しましたが、これが左側に移動することが、全体の LCS の長さが だけ長くなることに対応しています。
ビット演算
各区間における最も左の の場所に を立てたいです。
ここから先は整数に対する演算を考えるので、左右を反転して考えることにします。
2 種類の計算式を説明します。
D = (D | m) ^ ((D | m) & ((D | m) - (D << 1 | 1)))
[2]
右隣の区間の左端には 1 が立っているので、
(D << 1 | 1)
で各区間の右端に 1 がある状態になります(最も右の区間だけ 1 を追加する必要がある)。(D | m) - x
で(D | m)
の最下位ビットが 0 に、それ以下のビットが 1 になったものが手に入ります。(D | m) & x
で(D | m)
の最下位ビットだけが 0 になります。(D | m) ^ x
で(D | m)
の最下位ビットだけが 1 になります。
D --- 1000000 m --- 0110100 D | m --- 1110100 D << 1 | 1 --- 0000001 (D | m) - (D << 1 | 1) --- 1110011 (D | m) & ((D | m) - (D << 1 | 1)) --- 1110000 (D | m) ^ ((D | m) & ((D | m) - (D << 1 | 1))) --- 0000100
D = (D | m) & (D - (m & ~D))
[1]
[1] では [2] で紹介されたビット演算を式変形によって簡略化しました。 この演算を直接理解することもできます。
一つの区間で考えます。m
が 0 のときは D
はそのままです。
m
が 0 でないとき、
m & ~D
はm
の中でD
に含まれない 1 が立ちます。D - (m & ~D)
は、m
を右から見ていって、最下位ビットから〜次に 1 が立つ場所の手前まで 1 が連続します。(D | m) & (D - (m & ~D))
はm
の最下位ビットだけが残ります。
D --- 100000 m --- 111010 D | m --- 111010 m & ~D --- 011010 D - (m & ~D) --- 000110 (D | m) & (D - (m & ~D)) --- 000010
終わりに
実装例
#include <bits/stdc++.h> using namespace std; struct simple_bitset { int n; vector<unsigned long long> data; simple_bitset(int n_) : n((n_ + 64 - 1) / 64), data(n, 0) {} using bs = simple_bitset; void set_one(const int i) { data[i / 64] |= 1ULL << (i % 64); } bs& operator&=(const bs& r) { for(int i = 0; i < n; i++) data[i] &= r.data[i]; return *this; } bs& operator|=(const bs& r) { for(int i = 0; i < n; i++) data[i] |= r.data[i]; return *this; } bs& operator-=(const bs& r) { for(int i = 0, c = 0, nc = 0; i < n; i++) { nc = data[i] < r.data[i] || (data[i] == r.data[i] && c); data[i] -= r.data[i] + c; c = nc; } return *this; } bs operator&(const bs& r) const { return bs(*this) &= r; } bs operator|(const bs& r) const { return bs(*this) |= r; } bs operator-(const bs& r) const { return bs(*this) -= r; } bs operator~() const { bs x = *this; for(int i = 0; i < n; i++) x.data[i] = ~x.data[i]; return x; } int count() const { int cnt = 0; for(int i = 0; i < n; i++) cnt += __builtin_popcountll(data[i]); return cnt; } }; int lcs(const string& s, const string& t) { const int n = s.size(), m = t.size(); vector M(26, simple_bitset(m)); for(int j = 0; j < m; j++) M[t[j] - 'a'].set_one(j); simple_bitset D(m); for(int i = 0; i < n; i++) { const simple_bitset& Ms = M[s[i] - 'a']; D = (D | Ms) & (D - (Ms & ~D)); } return D.count(); } int main() { string s, t; cin >> s >> t; cout << lcs(s, t) << endl; }
「暫定 LCS の添字列を更新していく」方針で LCS を眺めるのは初めてで新鮮でした。
[1] では本記事で紹介したワードサイズ高速化に加えて、空間計算量の削減を行なっています。 また、verify 場所も記載されています。 ぜひ参考にしてみてください。
参考文献
[1] LCS を時間 O(|S| |T| / w)、空間 O(|S| + |T|) で復元までやる rian.hatenablog.jp
[2] 비트 연산을 활용하여 두 문자열의 LCS 빠르게 구하기
元 URL: http://www.secmem.org/blog/2019/09/12/lcs-with-bitset/