字串 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:

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\) 開頭的字串是答案。

練習題:

CSES 2107, CSES 1733

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';
    }
}

謝謝大家

Made with Slides.com