字串

cjtsai

\(INDEX\)

  • Hash
  • Trie
  • KMP
  • Z
  • Manacher

這我

  • 無學籍 蔡嘉晉
  • 失蹤的世宗

 

  • 北市賽建中墊底小丑
  • TOI 2025 1!

 

這我高一的照片...

{前言}

何謂字串演算法

我們要解決甚麼問題

  • 字串匹配
    • 匹配是對於兩個字串 我們好奇前者出現在後者的那些地方(index)
    • 並可擴展至多個字串是否相同/多字串匹配/回文檢測(?
  • 後面的各種演算法如果沒有特別說他要解決甚麼問題
    • 請預設他要解決字串匹配問題

{Rolling Hash}

滾來滾去的雜湊

  • 對於兩個長度為 \(N\) 的字串
  • 要比較他們是否相同
    • \(O(N)\) 比對
  • 有 \(M\) 個字串要互相比較呢
    • \(O(M^2N)\)
    • 超慢

字串的元素數量太多了

  • 我們只有 \(M\) 個字串
    • \(M\) 不會太大 (大概<1e6)
  • 長度為 \(N\) 的字串卻有 \(26^N\) 種可能
    • 蠻沒必要的
  • 感性的理解一下 宇集越大你要比較兩個元素是否相同的消耗就越大
  • 我們可以把這個狀態壓縮一下 把它用更小的集合來表示各個字串

映射

  • 大家應該還記得函數的定義吧(部分)
    • 一對一 -> OK
    • 多對一 -> OK
    • 一對多 ->  X
  • 我們可以構建一個把字串映射成一個數字的函數
    • 數字在 \(ll\) 的大小內比較速度可以忽略不計

定義雜湊函數

  • \(def\ H^p(S)=\sum_{i=0}^{n-1}S_i*p^{n-1-i}\)
    • \(p\) 是一個進位數 \(p=10\) 即為十進位
    • \(p>|\sum|\) 即字元集數量 數字為10/小寫拉丁字母為26
  • 範例 字串為 \(ckefgisc\)  \(p=27\)
i 0 1 2 3 4 5 6 7 sum
char c k e f g i s c X
toi 3 11 5 6 7 9 19 3 X
p的冪次 10460353203 387420489 14348907 531441 19683 729 27 1 X
乘起來 31381059609 4261625379 71744535 3188646 137781 6561 513 3 35717763027
  • 如此便可以數字的方式來表示字串 且不會重複
    • \(H^{27}(ckefgisc)=35717763027\)

問題

  • 不難發現
    • 不久之後 雜湊函數回傳的值便會超出 long long 的範圍
    • \(27^{15}\approx 3e21\)
  • 沒有完美的解方 只好犧牲一下
    • 對雜湊值取模
    • \(def\ H^p(S)=(\sum_{i=0}^{n-1}S_i*p^{n-1-i})\ \% \ M\)
  • ​\(M\) 要多大 會犧牲多少正確性?
    • 所謂被犧牲掉的正確性即是碰撞機率​
    • 當兩個字串元素不同但在取模後有相同的雜湊值
    • 你便會誤認他們為相同的字串
    • ​算數學囉 知道你們不愛看所以給你們看

碰撞機率計算

  • 二維彈性碰撞
  • 任兩個字串碰撞的機率 在 \(mod\ M\) 的情況下 為 \(\frac{1}{M}\)
  • N個字串
    • \(\approx 1-\frac{1}{e^{\frac{n^2}{2M}}}\) by 生日問題
    • \(M=10^{9}+7\ N=10^{5}\ =>\ 5*10^{-4}\)
    • \(M=2^{64}\ N=1e6\ => 2.7*10^{-7}\)
  •  

這個好像才是重點

  • 上面介紹了如果計算整個字串的 hash 值
  • 但如果我們只要一個子字串的 hash 值呢

 

  • 假設有 ckefgisc 要從中間拿出 kefg
    • 此時 k 的index由1變成0 所要乘的冪次倍率從6變成3
    • e index 2->1 power 5->2
    • f index 3->2 power 4->1
    • g index 4->3 power 3->0
  • 雖然每個人的冪次都變了 但整體相對的關係不變!
  • 透過前綴或後綴和取出代表這四個字元的hash值
  • 將hash值乘上 \(27^{-3}\)就拿到kefg的hash值ㄌ

圖示

i 0 1 2 3 4 5 6 7 sum
char c k e f g i s c X
toi 3 11 5 6 7 9 19 3 X
p的冪次 10460353203 387420489 14348907 531441 19683 729 27 1 X
乘起來 31381059609 4261625379 71744535 3188646 137781 6561 513 3 35717763027
i 0 1 2 3 sum
char k e f g X
toi 11 5 6 7 X
p的冪次 19683 729 27 1 X
乘起來 216513 3645 162 7 220327
原本的hash值 4261625379 71744535 3188646 137781 4336696341
除19683 216513 3645 162 7 220327

挖 是對的欸 廢話 不然我來搞笑的嗎

應用

  • 各種字串匹配
  • 把字串前半跟後半用相反的順序hash可以檢測回文
    • 尤其是你有多個字串可以連接的時候可以O(1)接續互通

{Trie}

畢竟算是一棵樹對吧

字典樹

  • 你有一個字典 裡面有很多字
  • 要怎麼找一個字有沒有出現過

 

  • 顯然是不可能全部跑一遍

 

  • 要怎麼在 \(O(|S|)\) 的時間跑完(?

剪枝(?

  • 如果正在比較的這兩個字串前綴不一樣
  • 就沒必要繼續比了
    • fdkjs & fdkew
    • 比完fdk都有可能是相同的
    • 但走道第四個字元的時候就不一樣了
    • 這樣就可以跳掉了

 

  • 所以要對每個字串逐字元比對
  • 還是太慢了

剪枝(?

  • 如果兩個字元前綴依樣
  • 他們甚至可以合併成一個節點一起比較欸
  • 等到出現不一樣的時候再分岔就好啦

TRIE

  • 每個節點代表一個前綴
  • 往下走一層代表為這個前綴
  • 多加一個字元
1
2 a
3 b
4 c
5 aa
6 ab
7 ba
8 ca
9 cb
10 cc
11 aba
12 caa
13 cab
14 cba
15 caaa

如何存節點

  • 對Node開一個struct

 

  • 用指標指向他的子傑點們
  • 因為你不知道你到底會有幾個節點
  • 所以這時候幫每個點存一個陣列的指標就很好往下走了

 

  • 複習一下struct跟指標一起出現的時候
struct Trie{
	Trie* c[26];//看你下面有幾種字元,也可以是不定長度的
    int cnt=0; //為了儲存這個節點有幾個字是結尾在這裡的
	Trie():cnt(0){
		memset(c, 0, sizeof(c));
	}
};
trie* root = new trie();

insert

  • 如果有下一個節點
  • 就走下去
  • 沒有就新創他
  • 如果走到底了就把那個節點cnt+=1
void insert(string s){
	trie* tmp = root;
	for(int i=s.size()-1; i>=0; i--){
		if(!tmp->c[(s[i]-'a')]){
			tmp->c[(s[i]-'a')] = new trie();
		}
		tmp=tmp->c[(s[i]-'a')];
	}tmp->cnt++;
}

query

  • 照這要找的字元走下去就好
bool query(string s){
	trie* tmp =root;
	for(int i=0; i<s.size(); i++){
		int eee=s[i]-'a';
		if(tmp->c[eee]){
			tmp=tmp->c[eee];
		}else{
			return false;
		}if(tmp->cnt&&i==s.size()-1){
        	return true;
		}
	}
    return false;
}

欸不是

  • 我用set存就好了啊

 

  • 但其實trie還有很多功能
  • 像是需要對每個字串長度進行比較時
    • 對sfkdlaj的所有前綴字串進行查詢
    • Trie可以做到\(O(N)\)
    • set需要\(O(N\ logN)\)

Trie+DP

  • CSES 1731
  • 給定一個字典
  • 裡面有很多字

 

  • 一個長字串可以有幾種用字典裡面組成的方法
  • 用推的
  • 當你算到第i個字元的時候
  • 把i當root開始跑字典樹
  • 如果跑到某個地方有這個字
  • 就把dp[i+k]+=dp[i]

CODE

  • TEXT
#include <bits/stdc++.h>
using namespace std;
#define int long long
#define modd 1000000007
vector<int> dp(100007, 0);
struct trie{
	trie* c[26];
	int cnt=0;
	trie(){
		memset(c, 0, sizeof(c));
	}
};
trie* root = new trie();
 
void insert(string s){
	trie* tmp = root;
	for(int i=s.size()-1; i>=0; i--){
		if(!tmp->c[(s[i]-'a')]){
			tmp->c[(s[i]-'a')] = new trie();
		}
		tmp=tmp->c[(s[i]-'a')];
	}tmp->cnt++;
}
string ans;
void query(int e){
	trie* tmp =root;
	int tpe=e;
	while(tpe>=0){
		int eee=ans[tpe]-'a';
		if(tmp->c[eee]){
			tmp=tmp->c[eee];
		}else{
			break;
		}if(tmp->cnt){
			dp[tpe]+=dp[e+1];
			dp[tpe]%=modd;
		}tpe--;
	}
 
}
 
signed main(){
	cin>>ans;
	int n;cin>>n; 
	for(int i=0; i<n; i++){
		string t;cin>>t;
		insert(t);
	}
	dp[ans.size()]=1;
	for(int i=ans.size()-1; i>=0; i--){
		query(i);
	}cout<<dp[0];
}
SHARE CODE TO OTHERS

Trie ^ bitwise op

  • 字串匹配其實也不一定要匹配到完全一樣的
  • Trie就可以做到在一定程度下允許不同的字串
  • 或是找完全不同的東西
  • 如:
    • 給定一堆數字
    • 接下來有一連串的數字問他跟上面那堆數字哪個xor起來最大
    • 用數字的二進位表示建trie
    • 從頭往下走
    • 如果要問的數字這個位元是1就優先往0走 沒有就只能走1
    • 如果要問的數字這個位元是0就優先往1走 沒有就只能走0

今天時間蠻多的

大家現在去想辦法AC他

{KMP}

Knuth–Morris–Pratt 就三個人名

前綴函數 \(\pi\)

  • 對於一個字串
  • 定義其 \(\pi[i]\) 為 \(s[0...i]\) 中 前後綴相同的子字串長度
    • 真前後綴為不包含正個字串的前後綴們
    • \(\pi[i]=k\)  if  \(s[0...k]==s[i-k+1...i]\)
    • \(\pi[0]=0\) 無真前後綴
    • 對於字串abcabcd
      • \(\pi[1]=0\)   ab無共同前後綴
      • \(\pi[2]=0\)   abc無共同前後綴
      • \(\pi[3]=1\)   abca中a=a
      • \(\pi[4]=2\)   abcab中ab=ab
      • \(\pi[5]=3\)   abcabc中abc=abc
      • \(\pi[6]=0\)   abcabcd無共同前後綴

怎麼算\(\pi\)函數?

  • Brute force
  • 對於\(pi[i]\)算每個字字串對他們的前後綴進行比較
  • \(O(N^3)\)

怎麼算\(\pi\)函數?

  • 優化
  • 現在算完了\(\pi[i]\) 要怎麼算\(\pi[i+1]\)?
  • \(max(\pi[i+1])=\pi[i]+1\)
    • iff \(s[i+1] == s[\pi[i]]\)
  • 但如果不同呢
  • 我要怎麼選下一個要比較的?

 

  • \(\pi[i]-1\)    !
  • why?
  • 動畫燒雞

visualization

  • TEXT

實作

vector<int> prefix_function(string s) {
  int n = (int)s.length();
  vector<int> pi(n);
  for (int i = 1; i < n; i++) {
    int j = pi[i - 1];
    while (j > 0 && s[i] != s[j]) j = pi[j - 1];
    if (s[i] == s[j]) j++;
    pi[i] = j;
  }
  return pi;
}

字串匹配

  • 現在有兩個字串 要計算S在T中的哪些位置出現
  • 要怎麼用 \(\pi\) 函數算?
  • 對 \( S\#T \) 做 \(\pi\) 函數
  • # 代表一個不出現在T跟S中的字元
  • \(\pi\)函數中如果有任何一個值是S的長度
  • 代表T中出現了一次S!

{Z-Algorithm}

aka Gusfield Algorithm

另外一種字串匹配工具

  • KMP算了最長共同真前後綴
  • Z要算的是最長共同前綴
    • 對於每個index 自其開始之子字串與原字串的最長共同前綴
    • 稱為Z value
  • 例: S=aabaabaab
    • \(Z[0]=S.size()=9\)
    • \(Z[1]=1\) (\(a=a\))
    • \(Z[2]=0\) (\(b\neq a\))
    • \(Z[3]=6\) (\(aabaab=aabaab\))
    • \(Z[4]=1\) (\(a=a\))
  • Z value的意義本身應該不難理解 接下來看要怎麼快速的算他
  • \(Z[5]=0\) (\(b\neq a\))
  • \(Z[6]=3\) (\(aab=aab\))
  • \(Z[7]=1\) (\(a=a\))
  • \(Z[8]=0\) (\(b\neq a\))

Z value

  • 要怎麼找每個index的Z值呢
  • 我們先定義爆搜的算法 \(O(n^2)\)

 

  • 對於 \(Z[i]\)
    • 我們直接火爆跑迴圈
    • \(for\ j\ in\ [i, n)\)
      • \(if\ Z[j]!=Z[j-i]\)
        • \(Z[i]=j-i\)
        • \(break\)
    • 反正就是一直跟字串的頭配對 對的話就兩個index一起加
    • 不然就跳出來說我找到最長共同前綴了

Optimization

  • 觀察一下
  • 有甚麼性質可以用
  • 假設我們配到一半 格子裡面是Z值
X 2 1 0 3 ?
  • 現在要算 ? 的 Z 
  • 有甚麼東西已經算過的可以用?
X 2 1 0 3 ?
  • 不難發現 根據定義 既然第五格的Z值為3
  • 這兩個顏色的字串是一樣的

Optimization

X 2 1 0 3 ?
X 2 1 0 3 ?
  • 此時我們發現 第六位跟第二位是一樣的
  • 並且第七位也跟第三位一樣
  • 因此原本第二位的Z值是2
  • 所以我們可以宣稱他的Z值至少是2
    • 因為這些人的東西都是一樣的!
    • 要注意並不是因為Z[1]=2 而是min(Z[1], 5+Z[5]-6)是2
      • Why? 搞不好Z[1]的值很大
      • 大到超過Z[5]可以保證的範圍了
      • 但後面的東西我們根本還沒看過長怎樣

Optimization

X 2 1 0 3 ?
X 2 1 0 3 ?
  • 所以我們可以宣稱他的Z值至少是2
  • since
X 2 1 0 3 ?
  • and
X 1 0 3 ?

2

  • 可得
X 2 1 0 3 ?

Optimization

X 2 1 0 3 ?
  • 所以我們可以宣稱他的Z值至少是2
  • 然後呢
    • 第三位是否跟第八位相同
    • 第四位是否跟第九位相同?
    • 直接爆搜即可 一直搜到匹配失敗
    • 這邊假設全部都是一樣的
X 2 1 0 3 ?
X 2 1 0 3 4
  • 有人知道這個字串長怎樣了嗎

Conclusion

  • 在前面的格子所匹配出來的共同前綴有覆蓋到的地方
  • 我們都可以這樣加速運行
  • 而如果沒有被任何一個位置覆蓋
  • 直接爆搜即可

 

  • 乍看之下有可能每一個位置都要爆搜
  • 為甚麼複雜度會比較好?

 

  • 每次的爆搜都是在為了推進覆蓋區域(越來越後面)做準備
  • 並且每次成功的爆搜一格便會推進一格 減少往後爆搜的次數
  • 所以均攤下可以在\(O(N)\)的時間結束整個字串Z值的計算

Z總整理 配扣

  • 能夠摸到最遠的那個人為bst
vector<int> z_build(string S) {
        int n=S.size();
    vector<int> Z(n);
        Z[0] = 0;
    int bst = 0;
    for(int i = 1; i<n; i++) {
        if(Z[bst] + bst < i) Z[i] = 0;
        else Z[i] = min(Z[bst]+bst-i, Z[i-bst]);
        while(S[Z[i]] == S[i+Z[i]]) Z[i]++;
        if(Z[i] + i > Z[bst] + bst) bst = i;
    }
        return Z;
}
  • 若目前沒人能摸到他 先設為0
  • 否則設為相對位置之值或與最末端位置之距離
  • 如果下一個是一樣的 一直往後跑
  • 更新bst

Why Z

  • TEXT

{Manacher}

做...不...完...

{還有很多很多}

有興趣的自己去學

雖然你可能用不到

  • Aho-Corasick Automaton (AC自動機)
    • 這名字超讚
  • suffix array (SA)
  • suffix automaton (SAM)

記得報幹訓

我會去講課 如果沒有被取消的話

我做完發現這頁幾乎沒用

因為坐在這邊的建北電資的只剩一個沒報 不急 就是你

for CKEFGISC

字串 ver. 一六

By cjtsai

字串 ver. 一六

  • 46