字串 I
12/29
becaido
講師介紹
- 建國中學 陳柏凱
- OJ handle:becaido
- 因為想學習字串演算法所以當字串 I 講師
什麼是字串
- 字串:由一個字元集裡的字元所組成的序列
-
字元集:一些字元所組成的集合,可以定義各個字元比較時的大小
-
子字串:字串裡一段連續區間所組成的字串
- 子序列:刪除字串裡的一些字元,在不改變相對順序下所組成的字串
-
字元集 {'h', 'e', 'l', 'o', ' ', 'w', 'r', 'l', 'd'} 可以組出字串 "hello world"
- 字元集 {'1', '2', '3', '4', '5', '6', '7'} 可以組出字串 "7122"
- "", "a", "aa", "ab", "aab" 是 "aab" 的子字串
- "", "b", "ac" 是 "abc" 的子序列
現在有一個字串 \(S\):
- \(|S|\):\(S\) 的長度 (字元個數)
- \(S_i\):\(S\) 的第 \(i\) 個字元
- \(S[l\dots r]\):\(S\) 的第 \(l\) 個字元到第 \(r\) 個字元所組成的子字串
- \(S\) 的前綴:\(S[1\dots i]\),\(S\) 開頭 \(i\) 個字元所組成的子字串 (這裡用的是 1-base)
- \(S\) 的後綴:\(S[i\dots |S|]\),\(S\) 結尾的 \(|S| - i + 1\) 個字元所組成的子字串
- |"kenorz"| = 6
- "h", "he", "heh", "hehe" 是 "hehe" 的前綴
- "d", "dd", "cdd", "bcdd", "abcdd" 是 "abcdd" 的後綴
Rolling Hash
Hash
- 把一個東西對應到一個數字
- 兩個相同的東西會對應到相同的數字,\(A = B\),假設 \(A\to n\),那 \(B\to n\)
- 兩個不同的東西有可能會對到相同的數字,\(A\neq C\),\(A\to n\),\(C\) 有可能 \(\to n\),我們稱作碰撞
Hash
- 用於快速判斷兩個東西是否相等
- 為了避免碰撞,可以讓值域變大,或是用不同的方式 hash 多次
對字串 Hash
選擇兩個數字 \(\text{base}\) 和 \(M\)
\(\text{base}\) 是字元要乘的數字,\(M\) 是要模的數字,兩個最好要是質數
例如可以讓 \(\text{base}=137, M = 10^9+7\)
兩種方式:
1. \(\text{Hash}(S)=S_1\times\text{base}^1+S_2\times\text{base}^2+\dots+S_n\times\text{base}^n\ (\text{mod }M)\)
2. \(\text{Hash}(S)=S_1\times\text{base}^{|S|-1}+S_2\times\text{base}^{|S|-2}+\dots+S_n\times\text{base}^0\ (\text{mod }M)\)
第一種方式
\(\text{Hash}(S)=S_1\times\text{base}^1+S_2\times\text{base}^2+\dots+S_n\times\text{base}^n\ (\text{mod }M)\)
#include <bits/stdc++.h>
using namespace std;
const int base = 137;
const int MOD = 1e9 + 7;
const int N = 1e5 + 5;
int n, q;
string s;
int pro[N], inv[N], pre[N];
int power(int d, int up) {
int re = 1;
while (up) {
if (up & 1) re = 1ll * re * d % MOD;
d = 1ll * d * d % MOD;
up >>= 1;
}
return re;
}
int hs(int l, int r) {
int re = 1ll * (pre[r] - pre[l - 1]) * inv[l - 1] % MOD;
return re < 0 ? re + MOD : re;
}
int main() {
cin >> n >> s;
s = " " + s;
pro[0] = inv[0] = 1;
inv[1] = power(base, MOD - 2);
for (int i = 1; i <= n; i++) {
pro[i] = 1ll * pro[i - 1] * base % MOD; // pro[i] = base 的 i 次方
inv[i] = 1ll * inv[i - 1] * inv[1] % MOD; // inv[i] = (1/base) 的 i 次方
pre[i] = (pre[i - 1] + 1ll * s[i] * pro[i]) % MOD; // pre[i] = Hash(S[1...i])
}
cin >> q;
while (q--) {
// 詢問 Hash(S[l...r])
int l, r;
cin >> l >> r;
cout << hs(l, r) << '\n';
}
}
Code:
優點:可以動態修改
缺點:詢問子字串 \(\text{Hash}\) 值需要算模逆元
第二種方式
\(\text{Hash}(S)=S_1\times\text{base}^{|S|-1}+S_2\times\text{base}^{|S|-2}+\dots+S_n\times\text{base}^0\ (\text{mod }M)\)
Code:
#include <bits/stdc++.h>
using namespace std;
const int base = 137;
const int MOD = 1e9 + 7;
const int N = 1e5 + 5;
int n, q;
string s;
int pro[N], pre[N];
int hs(int l, int r) {
int re = (pre[r] - 1ll * pre[l - 1] * pro[r - l + 1]) % MOD;
return re < 0 ? re + MOD : re;
}
int main() {
cin >> n >> s;
s = " " + s;
pro[0] = 1;
for (int i = 1; i <= n; i++) {
pro[i] = 1ll * pro[i - 1] * base % MOD;
pre[i] = (1ll * pre[i - 1] * base + s[i]) % MOD;
}
cin >> q;
while (q--) {
// 詢問 Hash(S[l...r])
int l, r;
cin >> l >> r;
cout << hs(l, r) << '\n';
}
}
優點:不用算模逆元
缺點:不能動態修改
有了 \(\text{Hash}\) 就可以快速知道兩個字串相不相等了!
有一個字串 \(A\),令其倒序後的字串為 \(A'\),我們稱 \(AA'A\) 為迴迴奇文。
給你一個字串 \(S\),每次可以交換 \(S_i, S_j\) 或是問你 \(S[l\dots r]\) 是否為迴迴奇文。
如果暴力的話就太慢了(其實可以過)
如何判斷 \(A\) 與 \(A'\)
動態修改怎麼辦?
Code:
#include <bits/stdc++.h>
using namespace std;
const int base = 137;
const int MOD = 1e9 + 7;
const int N = 2e5 + 5;
int n, q;
string s;
int pro[N], inv[N];
int power(int d, int up) {
int re = 1;
while (up) {
if (up & 1) re = 1ll * re * d % MOD;
d = 1ll * d * d % MOD;
up >>= 1;
}
return re;
}
struct BIT {
int bit[N];
void upd(int pos, int x) {
for (; pos <= n; pos += pos & -pos) bit[pos] = (bit[pos] + x) % MOD;
}
int que(int pos) {
int re = 0;
for (; pos; pos -= pos & -pos) re = (re + bit[pos]) % MOD;
return re;
}
int hs(int l, int r) {
int re = 1ll * (que(r) - que(l - 1)) * inv[l - 1] % MOD;
return re < 0 ? re + MOD : re;
}
} bit1, bit2;
void change(int i, int delta) {
if (delta < 0) delta += MOD;
bit1.upd(i, 1ll * delta * pro[i] % MOD);
bit2.upd(n - i + 1, 1ll * delta * pro[n - i + 1] % MOD);
}
int main() {
ios::sync_with_stdio(false), cin.tie(0);
cin >> n >> q >> s;
s = " " + s;
pro[0] = inv[0] = 1;
inv[1] = power(base, MOD - 2);
for (int i = 1; i <= n; i++) {
pro[i] = 1ll * pro[i - 1] * base % MOD;
inv[i] = 1ll * inv[i - 1] * inv[1] % MOD;
}
for (int i = 1; i <= n; i++) {
bit1.upd(i, 1ll * s[i] * pro[i] % MOD);
bit2.upd(n - i + 1, 1ll * pro[n - i + 1] * s[i] % MOD);
}
while (q--) {
int t, l, r;
cin >> t >> l >> r;
if (t == 1) {
int len = r - l + 1;
if (len % 3 != 0) {
cout << "0\n";
continue;
}
len /= 3;
int val = bit1.hs(l, l + len - 1);
cout << (val == bit2.hs(n - (r - len) + 1, n - (l + len) + 1) && val == bit1.hs(r - len + 1, r)) << '\n';
} else {
change(l, s[r] - s[l]);
change(r, s[l] - s[r]);
swap(s[l], s[r]);
}
}
}
可以做兩個 \(\text{Hash}\),一個是正著的,一個是反著的
用 \(\text{BIT}\) 維護
Trie
中文叫字典樹,由很多節點和邊組成
不一定要由字元組成,也可以由 \(0,1\) 組成
每條邊表示要加的字元,每個節點代表從根開始走形成的字串。
h
he
hel
hell
hello
k
ke
key
ken
+h
+e
+l
+l
+o
+k
+e
+y
+n
假設今天要加入 "bee", "yee", "yes", "yuhung", "zap", "zaps"
b
be
bee
y
ye
yee
yes
yu
yuh
yuhu
yuhun
yuhung
z
za
zap
zaps
+b
+e
+e
+y
+e
+e
+s
+u
+h
+u
+n
+g
+z
+a
+p
+s
寫成 code
偽指標型:
指標型:
#include <bits/stdc++.h>
using namespace std;
const int M = 1e6 + 5;
int n;
int cnt, trie[M][26];
void ins(string s) {
int pos = 0;
for (char c : s) {
if (!trie[pos][c - 'a']) trie[pos][c - 'a'] = ++cnt;
pos = trie[pos][c - 'a'];
}
}
int main() {
cin >> n;
while (n--) {
string s;
cin >> s;
ins(s);
}
}
#include <bits/stdc++.h>
using namespace std;
const int M = 1e6 + 5;
int n;
struct Trie {
Trie *ch[26] = {};
Trie() {}
} root;
void ins(string s) {
Trie *pos = &root;
for (char c : s) {
if (!pos->ch[c - 'a']) pos->ch[c - 'a'] = new Trie();
pos = pos->ch[c - 'a'];
}
}
int main() {
cin >> n;
while (n--) {
string s;
cin >> s;
ins(s);
}
}
給 \(n\) 個點,每個點有點權 \(a_i\),根節點為 \(1\),問點 \(1\) 到 \(i\) 路徑上子路徑 \(\text{xor}\) 的最大值。
如何快速計算一段區間的 \(\text{xor}\)?
對每個點記錄從根開始的 \(\text{xor}\) 值 \(=A_i\),那 \(u\) 到 \(v\) 路徑的 \(\text{xor}\) 值 (\(u\) 為 \(v\) 祖先) 就會是 \(A_v\text{ xor }A_{\text{fa}(u)}\)
跟前綴和的概念很像,對前綴 \(\text{xor}\) 後,如果想求得 \([l, r]\) 的值,那就把 \([1, r]\ \text{xor }[1, l - 1]\) 就好了
如果有 \(n\) 個數字 \(b_1\sim b_n\),給你 \(x\),你想找出 \(\max(x\text{ xor }b_i)\),要如何快速找呢?
把 \(n\) 個數字按照二進位插入 \(\text{trie}\) 中後,由高的位元開始往下走
因為要最大,所以想要這個位元 \(\text{xor}\) 的結果為 \(1\),如果可以組成 \(1\),那就往使用到的那個子樹走,否則只好走另一個子樹。
維護一個可以加入數字、刪除數字的 \(\text{trie}\)
\(\text{dfs}\) 時進入點就加入前綴點權,離開時就刪除。
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
const int M = 32;
int n;
int a[N], ans[N];
vector<int> adj[N];
int id;
int cnt[M * N], trie[M * N][2];
void ins(int x) {
int pos = 0;
for (int i = M - 1; i >= 0; i--) {
if (!trie[pos][x >> i & 1]) trie[pos][x >> i & 1] = ++id;
pos = trie[pos][x >> i & 1];
cnt[pos]++;
}
}
void del(int x) {
int pos = 0;
for (int i = M - 1; i >= 0; i--) {
pos = trie[pos][x >> i & 1];
cnt[pos]--;
}
}
int que(int x) {
int pos = 0, re = 0;
for (int i = M - 1; i >= 0; i--) {
int val = (x >> i & 1) ^ 1;
if (!trie[pos][val] || !cnt[trie[pos][val]]) val ^= 1;
re |= ((x >> i & 1) ^ val) << i;
pos = trie[pos][val];
}
return re;
}
void dfs(int pos, int fa) {
a[pos] ^= a[fa];
ans[pos] = max(ans[fa], que(a[pos]));
ins(a[pos]);
for (int np : adj[pos]) if (np != fa) dfs(np, pos);
del(a[pos]);
}
int main() {
ios::sync_with_stdio(false), cin.tie(0);
int tt;
cin >> tt;
while (tt--) {
cin >> n;
for (int i = 1; i <= n; i++) {
adj[i].clear();
cin >> a[i];
}
for (int i = 1; i < n; i++) {
int a, b;
cin >> a >> b;
adj[a].emplace_back(b);
adj[b].emplace_back(a);
}
for (int i = 0; i <= id; i++) trie[i][0] = trie[i][1] = cnt[i] = 0;
id = 0, ins(0);
dfs(1, 0);
for (int i = 1; i <= n; i++) cout << ans[i] << '\n';
}
}
Code:
練習題 (You can trie trie see):
CF 1732 D2, ZJ i629,
CSA Prefix Free Subset,
KMP
一些人名的縮寫
用來做字串匹配
寫成中文叫
「次長共同前後綴」
給一個字串 "kenkenken"
它的最長共同前後綴就是它自己
次長共同前後綴則長這樣
kenkenken
orandxor
babbabba
hello
令 \(\text{len}(S)\) 為 \(S\) 次長共同前後綴長度
現在給你一個字串 \(S\)
令 \(n = |S|\),想要請你求出 \(n\) 個數字:
\(\text{len}(S[1\dots 1]), \text{len}(S[1\dots 2]),\dots,\text{len}(S[1\dots i]),\dots,\text{len}(S[1\dots n])\)
\(O(n^3)\) 的作法:直接暴力看
太慢了
小觀察:\(\text{kmp}(i)\) 到 \(\text{kmp}(i+1)\) 最多只會增加 \(1\)
原本從長度 \(i+1\) 開始看,現在從長度 \(\text{kmp}(i)+1\) 開始看
第一次就匹配成功:\(\text{len}\) 增加 \(1\),否則會不變或是減少
長度的變化量總和是 \(O(n)\) 的,每次 \(O(n)\) 比對,總共 \(O(n^2)\)
令 \(\text{kmp}(i) = \text{len}(S[1\dots i])\)
但還是太慢了
有沒有更有效利用前面計算結果的方法?
string
現在要在一個已經算好所有 \(\text{len}\) 的字串加入一個字元 \(c\)
\(\text{kmp(i)}\)
我們想讓 紅色\(+c=\) 藍色\(+x\)
如果 \(c=x\),代表匹配成功了!
否則只好\(\dots\)
\(\text{kmp(i)}\)
\(\text{kmp}(\text{kmp}(i))\)
我們去找 \(\text{kmp}\) 的 \(\text{kmp}\),看看 \(c\) 能不能等於新的 \(x'\)
如果又不行,就繼續做下去,直到匹配成功,或是 \(\text{len}=0\) 了
Code:
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
int n;
string s;
int kmp[N];
int main() {
ios::sync_with_stdio(false), cin.tie(0);
cin >> s;
n = s.size(), s = " " + s;
for (int i = 2; i <= n; i++) {
int len = kmp[i - 1];
while (len && s[len + 1] != s[i]) len = kmp[len];
kmp[i] = len + (s[len + 1] == s[i]);
}
for (int i = 1; i <= n; i++) cout << kmp[i] << " \n"[i == n];
}
輸入:ababbaababa
輸出:0 0 1 2 0 1 1 2 3 4 3
輸入:kenkenken
輸出:0 0 0 1 2 3 4 5 6
複雜度:每次 \(\text{kmp}\) 最多只可能增加 \(1\),能減少的量只有 \(O(n)\),於是最後複雜度為 \(O(n)\)。
應用:尋找字串 \(t\) 在字串 \(S\) 出現幾次
可以做一個新字串 \(t\#S\),去做 \(\text{kmp}\),只要 \(\text{kmp}(i)=|t|\),就可以將次數 \(+1\)
實際上可以先建好 \(t\) 的 \(\text{kmp}\),再拿 \(S\) 的字元去匹配就好了
Code:
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
int n, m, cnt;
int kmp[N];
string s, t;
int main() {
ios::sync_with_stdio(false), cin.tie(0);
cin >> t >> s;
n = t.size(), t = " " + t;
m = s.size(), s = " " + s;
for (int i = 2; i <= n; i++) {
int len = kmp[i - 1];
while (len && t[len + 1] != t[i]) len = kmp[len];
kmp[i] = len + (t[len + 1] == t[i]);
}
for (int i = 1, len = 0; i <= m; i++) {
while (len && t[len + 1] != s[i]) len = kmp[len];
len += (t[len + 1] == s[i]);
if (len == n) {
cnt++;
len = kmp[len];
}
}
cout << cnt << '\n';
}
2022 北市賽 pD:
給你兩個字串 \(T,s\),每次要把 \(T\) 裡出現在最左邊的 \(s\) 刪掉,直到 \(T\) 裡沒有 \(s\),請你求出最後 \(T\) 的長相。(\(|s|\leq 1000, |t|\leq 6\times 10^5\))
把 \(s\) 的 \(\text{kmp}\) 建好,每次加入 \(T\) 的字元,如果匹配長度 \(=|s|\),那就把 \(T\) 的後 \(|s|\) 個字元刪掉。
\(\text{kmp}\) 還可以做許多事,像是可以做 \(\text{AC}\) 自動機 (今天不會教)
Z algorithm
給一個字串 \(S\),定義 \(Z(i)\) 為 \(S[i\dots |S|]\) 與 \(S\) 的最長共同前綴長度(特別定義 \(Z_1=0\))。
\(S\) = "abbabc", \(Z(4)=2\)
abbabc
\(S\):abbabc
\(S[4\dots |S|]\):abc
想快速求出 \(Z(1)\sim Z(|S|)\)
\(O(n^2)\),好慢喔
假設我們已經計算過 \(1\sim l\) 的 \(Z\) 了,且 \(r = l + Z(l)-1\) 為最大的 \(r\),使得 \(S[l\dots r]=S[1\dots Z(l)]\)
現在要算到 \(Z(p)\) 的值
\(S[p\dots r]=S[p-l+1\dots Z(l)]\)
兩種情況:
1. \(Z(p) = Z(p-l+1)\)
且 \(p+Z(p)-1\leq r\)
2. \(p+Z(p)-1>r\)
第一種情況:長度直接變 \(Z(p-l+1)\)
合併,讓長度變 \(\min(Z(p-l+1),\max(r-p+1,0))\),然後繼續匹配
第二種情況:長度變 \(r-p+1\),然後繼續匹配
時間複雜度:
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
int n;
string s;
int z[N];
int main() {
ios::sync_with_stdio(false), cin.tie(0);
cin >> s;
n = s.size(), s = " " + s;
int l = 2, r = 1;
for (int i = 2; i <= n; i++) {
int len = min(z[i - l + 1], max(r - i + 1, 0));
while (i + len <= n && s[i + len] == s[len + 1]) len++;
z[i] = len;
if (i + z[i] - 1 > r) {
l = i;
r = i + z[i] - 1;
}
}
for (int i = 1; i <= n; i++) {
cout << z[i] << " \n"[i == n];
}
}
Code:
第一種情況:直接結束
第二種情況:會讓 \(r\) 遞增
結果:\(O(n)\)
給一個字串 \(S\),請輸出所有長度 \(\text{len}\),使得 \(S[1\dots \text{len}]=S[|S|-\text{len}+1\dots |S|]\)
(前綴等於後綴)
解:只要 \(Z(i) = |S|-i+1\) 就代表 \(i\) 開頭的字串是答案。
manacher's algorithm
給一個字串 \(S\),請你求出 \(S\) 裡最長回文長度。
暴搜:\(O(n^2)\)
\(\text{hash}+\)二分搜:\(O(n\times\log(n))\)
有沒有更快的方法啊?
回文可能有兩種情況
1. 長度為奇數,中心為一個字元
2. 長度為偶數,中心沒有東西
OuuO
如果現在有一個演算法需要用到回文的中心點,那要怎麼調整這個字串才能讓長度奇偶都有中心點?
可以在字元間插入 #
IOI
↓
#I#O#I#
OuuO
↓
#O#u#u#O#
現在已經找好中心點為 \(1\sim \text{mid}\) 的回文長度了,且 \(\text{mid}\) 所對應到的回文區間為 \([l, r]\) 滿足 \(r\) 最大。
現在想計算中心點為 \(p\) 的回文長度,要怎麼利用之前算過的答案呢?
如果 \(p + \text{len} - 1 \leq r\),那
\(S[p-\text{len}+1\dots p+\text{len}-1]\)
\(=\text{rev}(S[2\times \text{mid}-p-\text{len}+1\dots 2\times \text{mid}-p+\text{len}-1])\)
\(2\times\text{mid}-p\) 為 \(p\) 以 \(\text{mid}\) 為中心的對稱點
\(\text{rev}(S)\) 代表字串反過來
兩個東西會一樣是因為他們在 \([l, r]\) 裡,以 \(\text{mid}\) 對稱,而且 \(S[l\dots r]\) 是回文
我們令 \(Z(i)\) 代表以 \(i\) 為中心點的回文向右延伸多少長度
\(Z(2\times\text{mid}-p)\) 已經計算過了
利用跟 \(\text{Z algorithm}\) 很像的方式,先讓 \(Z(p)=\min(Z(2\times mid - p), r - p)\)
再去做匹配
觀察匹配好的字串
#I#O#I#
\(Z(4)=3\)
#O#u#u#O#
\(Z(5)=4\)
\(Z(i)\) 剛好等於回文長度!
時間複雜度:
跟 \(\text{Z algorithm}\) 很像,可以看 \(r\) 的變化推出 \(O(n)\)。
Code for TIOJ 2133
#include <bits/stdc++.h>
using namespace std;
const int N = 1e4 + 5;
int n, ans;
string s;
int z[N];
int main() {
ios::sync_with_stdio(false), cin.tie(0);
int tt;
cin >> tt;
while (tt--) {
cin >> s;
string tmp = "#";
for (char c : s) {
tmp += string(1, c) + "#";
}
s = tmp;
n = s.size();
s = " " + s;
ans = 0;
for (int i = 1, mid = 0, r = 0; i <= n; i++) {
int len = min(i - 1, n - i);
z[i] = 0;
if (i <= r) {
z[i] = min(z[2 * mid - i], r - i);
}
for (int j = z[i] + 1; j <= len; j++) {
if (s[i - j] != s[i + j]) {
break;
}
z[i]++;
}
if (i + z[i] > r) {
mid = i;
r = i + z[i];
}
}
cout << *max_element(z + 1, z + n + 1) << '\n';
}
}
謝謝大家!
字串 I
By becaido
字串 I
- 207