[補檔計劃] 字符串 之 知識點匯總
學習一個算法, 需要弄清一些地方
① 問題與算法的概念
② 算法以及其思維軌跡
③ 實現以及其思維軌跡
④ 復雜度分析
⑤ 應用
KMP算法
字符串匹配與KMP算法
為了方便弄清問題, 應該從特例入手.
設 A = " ababababb " , B = " ababa " , 我們要研究下面三個遞進層次的字符串匹配問題:
① 是否存在 A 的子串等於 B
② 有幾個 A 的子串等於 B
③ A 的哪些位置的子串等於 B
KMP算法可以直接在線性復雜度解決問題③, 進而更可以解決問題①和問題②.
KMP算法
我們考慮利用匹配的特性進行優化. 如下圖所示, 匹配到 S[L:R] = T[1:r] , 發現 S[R+1] != T[r+1] . 考慮另一個匹配位置 B , 如果 B 在 S 內能匹配成功, 則需要滿足一個必要條件: S[B:R] = T[1:R-B+1] , 而 S[B:R] = T[r-(R-B+1)+1 : r] , 所以 T[1:r] 的長度為 $len=R-B+1$ 的前綴要等於長度為 $len$ 的後綴. 為了不遺漏所有的情況, B 要盡可能的小, 我們要取最大的 len = ex[r] , 使得 T[1:len] = T[r-len+1 : r] .
現在問題的焦點在於如何求 ex 數組. 我們發現求 ex 數組的方法可以遞推(基底+轉移), 和上述方法的流程是一樣的.
算法實現
#define F(i,a,b) for (register int i=(a);i<=(b);i++) int nS; char s[S]; int nx[S]; int nT; char t[T]; bool KMP(void) { for (int i=2,j=0;i<=nS;i++) { while (j<=nS && s[i]!=s[j+1]) j=nx[j]; nx[i]= (s[i]==s[j+1] ? ++j : j); } for (int i=1,j=0;i<=nT;i++) { while (j<=nT && t[i]!=s[j+1]) j=nx[j]; if (s[i]==s[j+1]) { j++; if (j==nT) return true; } } return false; }
復雜度分析
由於每次 j 只會往後移一位, 所以往後移的總位數為 O(n) . 每次 j 往前跳, 至少會跳一位, 所以往前跳的總次數為 O(n) . 綜上, 均攤時間復雜度為 O(n) .
Manacher 算法
EX 數組
回文串分為兩種: 奇回文串, 偶回文串.
從特殊向一般演進, 我們先研究奇回文串的信息如何快速處理. 為了刻畫, 我們引入了 ex 數組: 給定一個字符串 S , 對它的每個位置有 ex[i] , ex[i] 為最大的使 S[i-t+1 : i+t-1] 為回文串的 t 的值. 舉個例子, S = "ababaab" ,則有: ex[1]=1, ex[2]=2, ex[3]=3, ex[4]=2, ex[5]=1, ex[6]=1, ex[7]=1, ex[8]=1.
Manacher算法, 可以在線性復雜度求出 ex 數組.
Manacher算法
與KMP類似, 首先考慮 Brute Force, 然後使用匹配的連續性進行優化. 網上資料很多, 這裏不贅述.
實現
void Manacher(void) { len=n+n+1, t[0]=‘<‘, t[1]=‘+‘, t[len+1]=‘>‘; F(i,1,n) t[2*i]=s[i], t[2*i+1]=‘+‘; for (int i=1, k=0; i<=len; i++) { int j = (k+ex[k]-1<=i ? 0 : min(k+ex[k]-i, ex[k+k-i])); while (t[i+j]==t[i-j]) j++; ex[i]=j; if (i+ex[i] > k+ex[k]) k=i; } }
復雜度分析
每次都是最遠點往後拓展, 每個點不會拓展兩次, 所以時間復雜度均攤 $O(n)$ .
偶回文串
以某個字符為對稱軸的所有回文串, 即奇回文串的信息, 可以通過 Manacher 求出來. 現在將偶回文串的坑也給填了.
我們考慮化歸, 將 S 變為 T, T 與 S 有一定的聯系, 對 T 用 Manacher, 就可以轉化回來.
具體的, T[0] = ‘<‘ , T[1] = ‘+‘ , T[2n] = S[n], T[2n+1] = ‘+‘ , T[2n+2] = ‘>‘ , 對 T 用 Manacher, 得到 ex[1..2n+1] .
位置上的轉化關系:
從 S 到 T : T[2n] = S[n].
從 T 到 S : 當 n 為偶數時, S[n/2] = T[n].
T 的 ex 數組:
當 i 為偶數時, $ex_S[i/2]=ex_T[i]/2$ ;
當 i 為奇數時, 以 S[(i-1)/2] 和 S[(i+1)/2] 的中心的 $ex = (ex_T[i]-1)/2$ ;
這個不能死記硬背, 關鍵明白位置上的轉化關系, 舉一個例子看看就好了.
回文自動機
PAM : Palindrome Automaton, 回文自動機.
對一種自動機的學習, 我們要弄清: 概念, 構建, 實現, 復雜度分析, 應用, 例題.
有兩篇寫得很好的資料.
http://blog.csdn.net/maticsl/article/details/43651169
http://blog.csdn.net/u013368721/article/details/42100363
PAM 的概念
對於一個字符串 S, 可以構建一個唯一的 PAM. PAM 可以刻畫字符串 S 的回文子串的信息.
PAM 由 點 和 邊 組成.
點 PAM 的每個點, 對應一個本質不同的回文子串, 用回文子串的長度 len 和一個虛擬的編號來刻畫.
對於一個字符串 S , 有這樣一個性質: 它的回文子串最多只有 $|S|$ 個. 來一個簡單的無字證明:
邊 邊分為兩類: 後繼邊 和 Suffix Link.
後繼邊 如果 u 通過標記字符為 c 的後繼邊連向 v , 則 v = cuc
Suffix Link 最長後綴回文串對應的編號.
PAM 的構建
我們考慮遞歸構建, 在線末端插入: 構建 S[1:1] 的 PAM, 構建 S[1:2] 的 PAM, ..., 構建 S[1:n] 的 PAM.
為了構建的順利, 我們需要引入輔助變量 last: 當前前綴的最長後綴回文串對應的虛擬編號, tot: 結點個數.
初始化 構建兩個點, len[0] = 0, len[1] = -1, suf[0] = -1 ;
末端插入字符 c 沿 last 往前跳, 直到找到第一個 s[n] = s[n-len-1] 的位置 cur, 順便把這個過程叫做 Find.
如果 cur 沒有 c 後繼, 則建立點 now.
len[now] = len[cur] + 2.
suf 怎麽處理呢? 宏觀上, 繼續 t=Find(suf[cur]) , 然後 suf[now] = next[t][c] . 微觀上, 發現 0, 1, 或者找不到等若幹情況都能滿足.
連接 next[cur][c] = now.
PAM 的實現
TIPS 對於長度為 $n$ 的字符串, PAM 需要用到的空間上限為 $n+1$ .
#include <cstdio> #include <cstring> #include <cstdlib> const int N = 100005; char s[N]; int nS; int tot, last; int suf[N], son[N][30]; int len[N]; int Find(int n, int cur) { while (s[n-len[cur]-1] != s[n]) cur = suf[cur]; return cur; } void Expand(int n, int c) { int cur = Find(n, last); if (!son[cur][c]) { int now = ++tot; len[now] = len[cur]+2; suf[now] = son[Find(n, suf[cur])][c]; son[cur][c] = now; } last = son[cur][c]; } int main(void) { #ifndef ONLINE_JUDGE freopen("A.in", "r", stdin); freopen("A.out", "w", stdout); #endif scanf("%s", s+1); nS = strlen(s+1); tot = 1, last = 0; suf[0] = 1; len[0] = 0, len[1] = -1; for (int i = 1; i <= nS; i++) Expand(i, s[i]-‘a‘); printf("%d\n", tot-1); return 0; }
PAM 的復雜度
時間復雜度: $O(n)$
空間復雜度: $O(nA)$
PAM 的應用
由此可見, PAM 確乎地可以求出回文子串的一些信息, 那具體可以處理那些問題呢?
問題1 是否存在回文串? 是否存在長度大於 x 的回文串? 是否存在偶回文串?
一個字符串一定存在回文串.
找是否有節點的 len 大於 x.
找是否有節點的 len 為偶數.
問題2 列舉所有本質不同的回文串.
在插入的時候, 對每個節點 x, 記錄 end[x] 表示 x 節點表示的回文串的末端. 這樣可以用 end[x] 和已有的 len[x], 輸出回文串 S[ end[x]-len[x]+1 : end[x] ] .
問題3 求串 S 的本質不同的回文串個數. 求串 S 的所有前綴的不同回文串個數.
前綴 S[1:i] 的不同回文串個數, 為當前 PAM 的 tot-1.
問題4 求每個不同回文串出現的次數.
首先, 每個點多記錄一個 cnt, 表示在構建的時候, 這個點作為最長後綴回文串的次數.
Suffix-Link 形成了一棵回文樹. 每個不同回文串出現的次數, 即其等價節點在回文樹上的子樹 cnt 之和. 進行 DAG 上的遞推.
問題5 求串 S 的回文串的個數. 求偶回文串的總數.
將每個不同的回文串出現的次數相加.
將每個不同的偶回文串出現的次數相加.
問題6 以下標 i 為結尾的回文串的個數.
在線構建 PAM 至 S[1:i] . 求 last 在回文樹上對應節點的深度 - 2.
後綴數組
後綴數組的實現
對於一個字符串 S , 有後綴數組 sa[1..n] , 排名數組 rk[1..n], 和輔助數組 height[1..n].
sa[i]: 在 S 的後綴中, 排名第 i 的後綴為 S[sa[i]: n] .
rk[i]: 在 S 的後綴中, S[i:n] 的排名.
height[i]: S[sa[i-1]:n] 與 S[sa[i]:n] 的 LCP.
顯然有 rk[sa[i]] = i, sa[rk[i]] = i.
使用倍增的方法快速求 sa[1..n] 和 rk[1..n] .
求 ht[1..n] 的時候, 我們依次處理 S[1:n], S[2:n], ..., S[n:n], 處理 S[i:n] 的時候求 ht[rk[i]] . Brute Force 的復雜度為 $O(n^2)$ , 但是我們可以利用一個性質: $ht[rk[i]] \ge ht[rk[i-1]]-1$ , 附一個無字證明:
#include <cstdio> #include <cstring> #include <cstdlib> #define F(i, a, b) for (register int i = (a); i <= (b); i++) #define D(i, a, b) for (register int i = (a); i >= (b); i--) const int N = 50005; char s[N]; int n; int rk[N], sa[N], ht[N]; namespace Output { const int S = 1000000; char s[S]; char *t = s; inline void Print(int x) { if (x == 0) *t++ = ‘0‘; else { static int a[65]; int n = 0; for (; x > 0; x /= 10) a[++n] = x%10; while (n > 0) *t++ = ‘0‘+a[n--]; } *t++ = ‘ ‘; } inline void Flush(void) { fwrite(s, 1, t-s, stdout); } } using Output::Print; void Prework(void) { static int sum[N], trk[N], tsa[N]; int m = 500; F(i, 1, n) sum[rk[i] = s[i]]++; F(i, 1, m) sum[i] += sum[i-1]; D(i, n, 1) sa[sum[rk[i]]--] = i; rk[sa[1]] = m = 1; F(i, 2, n) rk[sa[i]] = (s[sa[i]] != s[sa[i-1]] ? ++m : m); for (int j = 1; m != n; j <<= 1) { int p = 0; F(i, n-j+1, n) tsa[++p] = i; F(i, 1, n) if (sa[i] > j) tsa[++p] = sa[i]-j; F(i, 1, n) sum[i] = 0, trk[i] = rk[i]; F(i, 1, n) sum[rk[i]]++; F(i, 1, m) sum[i] += sum[i-1]; D(i, n, 1) sa[sum[trk[tsa[i]]]--] = tsa[i]; rk[sa[1]] = m = 1; F(i, 2, n) { if (trk[sa[i]] != trk[sa[i-1]] || trk[sa[i]+j] != trk[sa[i-1]+j]) m++; rk[sa[i]] = m; } } m = 0; F(i, 1, n) { if (m > 0) m--; while (s[i+m] == s[sa[rk[i]-1]+m]) m++; ht[rk[i]] = m; } } int main(void) { #ifndef ONLINE_JUDGE freopen("xsy1621.in", "r", stdin); freopen("xsy1621.out", "w", stdout); #endif scanf("%s", s+1); n = strlen(s+1); Prework(); F(i, 1, n) Print(rk[i]); *(Output::t++) = ‘\n‘; F(i, 1, n) Print(ht[i]); *(Output::t++) = ‘\n‘; Output::Flush(); return 0; }
後綴自動機
http://blog.sina.com.cn/s/blog_70811e1a01014dkz.html
最簡狀態SAM
SAM, Suffix Automaton, 後綴自動機.
對於字符串 S , 構建 SAM. SAM 是一張 Trie圖 , 能夠恰好識別字符串 S 的所有子串.
如下圖, 對於 S = ACADD , 構建了一個狀態數為 $O(n^2)$ 的 SAM .
我們發現這很不優秀, "可能很難接受".
考慮優化: 發現很多空間是可以共用的. 因此, 我們要構建 最簡狀態SAM , 一個節點可能有多個父親 .
具體地, SAM 由每個節點刻畫, 而節點由以下三種變量刻畫:
son[26]: 轉移函數
pre: 如果當前節點可以接受新的後綴, 那麽上一個能接受後綴的節點為 pre
step: 從根節點到當前節點的最長距離
SAM 的構建
SAM 的構建過程可以概括為 逐位插入末尾, 在線構建 . 具體地, 先構建 Prefix(1) 的 SAM, 再構建 Prefix(2) 的 SAM, 再構建 Prefix(3) 的SAM, ..., 構建 Prefix(n) 即 S 的 SAM. 因此, SAM 也可以處理 S 的任意一個前綴的信息.
為了方便闡述, 先提出 SAM 的幾個顯而易見的命題:
① 根據 SAM 的定義, 從 root 到任意節點p 的每條路徑上的字符組成的字符串, 都是 S 的子串.
② 對 "接受後綴的節點" 的定義 , 如果當前節點 p 是可以接受後綴的節點, 那麽從 root 到當前節點p 的每條路徑上的字符組成的字符串, 都是 S 的後綴.
③ 對 pre 的定義, 如果當前節點 p 是可以接受後綴的節點, 那麽 pre 也是可以接受後綴的節點.
④ 對 pre 的定義, 從 root 到 pre 的每條路徑上的字符組成的字符串, 是從 root 到任意節點 p 的每條路徑上的字符組成的字符串的後綴.
現在考慮怎樣由 t 的 SAM 構建 tx 的 SAM.
我們需要記錄構建 t 時的新增的節點 last, 這個節點一定可以接受後綴; 以及記錄當前 SAM 的節點總數 tot , 以方便新增節點.
新建節點 np , 從 p = last 沿著 pre 往前跳, 記 son[t][x] = np, 直到 q = son[t][x] 存在. 這時候分兩種情況討論:
① step[q] = step[p] + 1
這種情況下, 從 root 到 p 的路徑上全為 t 的後綴, 且 q 的唯一到達方式是通過 p.
所以從 root 到 q 的路徑上亦全為 tx 的後綴, 所以 q 可以當做 np, 將 pre[np] 連向 q.
② step[q] > step[p] + 1
說明 q 的唯一到達方式不是 p , 那麽考慮化歸到第一種情況.
新建節點 nq, 將 q 的信息復制到 nq 上, p 以及之前連向 q 的節點改連到 nq 上, pre[nq] = pre[q], pre[q] = pre[np] = nq.
SAM 的實現
#include <cstdio> #include <cstring> #include <cstdlib> #define F(i, a, b) for (register int i = (a); i <= (b); i++) #define D(i, a, b) for (register int i = (a); i >= (b); i--) const int N = 200; char s[N]; int n; int tot, last; int son[N][26], suf[N], len[N]; char t[N]; int cnt; inline int Newnode(int L) { return len[++tot] = L, tot; } void Expand(int c) { int p = last, np = Newnode(len[last]+1); for (; p > 0 && !son[p][c]; p = suf[p]) son[p][c] = np; if (p == 0) suf[np] = 1; else { int q = son[p][c]; if (len[p]+1 == len[q]) suf[np] = q; else { int nq = Newnode(len[p]+1); memcpy(son[nq], son[q], sizeof son[q]); suf[nq] = suf[q], suf[q] = suf[np] = nq; for (; p > 0 && son[p][c] == q; p = suf[p]) son[p][c] = nq; } } last = np; } void DFS(int x) { printf("%s\n", t+1); for (int i = 0; i < 26; i++) if (son[x][i] > 0) { t[++cnt] = ‘A‘+i; DFS(son[x][i]); t[cnt--] = 0; } } int main(void) { #ifndef ONLINE_JUDGE freopen("sam.in", "r", stdin); freopen("sam.out", "w", stdout); #endif scanf("%s", s+1); n = strlen(s+1); tot = last = 1; F(i, 1, n) Expand(s[i]-‘A‘); printf("The substrings of S in alphabetical order are: \n"); DFS(1); return 0; }
樣例輸入
ACADD
樣例輸出
The substrings of S in alphabetical order are:
A
AC
ACA
ACAD
ACADD
AD
ADD
C
CA
CAD
CADD
D
DD
復雜度分析
空間復雜度 由於每次插入最多只會增加2個節點, 所以空間復雜度為 $O(n|\sum|)$ , 最多有 $2n$ 個節點.
時間復雜度 $O(n|\sum|)$ .
後綴樹
利用 SAM 的構建方法構建後綴樹
我們思考 SAM 的構建過程中, 如果根據 Suffix Link 進行連邊, 那麽我們會得到一棵樹, 考慮這棵樹的含義.
由於一棵樹由點和邊構成, 所以考慮這棵樹的點, 以及邊的含義.
思考節點的含義需要按照三個層次: ①代表著的節點有那些直觀的認識; ②代表的節點的個數; ③具體代表哪些節點. 對此, 我們根據 SAM 的構建過程的理解, 給出的回答是
① 每一個節點代表著若幹前綴;
② 每一個節點代表的前綴個數為 len[x] - len[suf[x]];
③ 記從根到 suf[x] 的最長路徑的字符組成的字符串為 A, 從根到 x 的最長路徑的字符組成的字符串為 T, 則 T = s1s2s3....snA, x代表的字符串是 snA, ..., s1s2s3..snA .
邊是一個字符串 s = s1s2s3...sn.
我們發現這棵樹其實就是母串 S 的前綴樹. 我們知道, 後綴樹是很有用的, 那能不能構建後綴樹呢? 其實很簡單, 只需要將母串 S 進行反序構建 SAM 即可. 但是註意, 這時候的字符串也需要反序.
廣義後綴自動機
http://dwjshift.logdown.com/posts/304570
廣義後綴自動機的概念
給定一棵 Trie 樹, 定義 Trie 樹的後綴為從某個節點到葉子節點的路徑上的字符組成的字符串, 求能恰好識別 Trie 樹的所有後綴的自動機.
根據這個概念, 我們還可以有另外一種定義方式: 給定 n 個字符串 s1, s2, s3, ..., sn, 求能恰好識別任意 si 的後綴的自動機, 因為將 s1, s2, ..., sn 插入到一棵 Trie 樹後, 概念恰好等價.
類似的, 我們還有廣義後綴數組, 也可以由兩種定義方式.
廣義後綴自動機的構建
廣義後綴自動機的構建, 應該類比後綴自動機的構建.
但由於它是一棵樹, 而不是一條鏈, 我們不難想到兩種方法: BFS, 和 DFS.
BFS 構建 Trie 樹, 或者利用已有的 Trie 樹, 在樹上進行 BFS , 由於字符串的長度遞增, 所以直接利用 SAM 的代碼即可. 時間復雜度為 $O(n|\Sigma|)$ .
DFS 直接掃描字符串(無需構建 Trie 樹), 或者利用已有的 Trie 樹, 逐個字符串進行構建. 時間復雜度為 $O(n|\Sigma|)+G(T)$ . $G(T)$ 為 Trie 樹的葉子結點的深度之和.
廣義後綴自動機的實現
懶癌晚期, 只寫一個在線版的.
inline int Newnode(int L) { return len[++tot] = L, tot; } void Expand(int id, int c) { if (son[last][c] > 0) { int p = last, q = son[last][c]; if (len[p]+1 == len[q]) last = q; else { int nq = Newnode(len[p]+1); son[nq] = son[q]; suf[nq] = suf[q], suf[q] = nq; for (; p > 0 && son[p][c] == q; p = suf[p]) son[p][c] = nq; last = nq; } } else { int np = Newnode(len[last]+1), p = last; for (; p > 0 && !son[p][c]; p = suf[p]) son[p][c] = np; if (!p) suf[np] = 1; else { int q = son[p][c]; if (len[p]+1 == len[q]) suf[np] = q; else { int nq = Newnode(len[p]+1); son[nq] = son[q]; suf[nq] = suf[q], suf[q] = suf[np] = nq; for (; p > 0 && son[p][c] == q; p = suf[p]) son[p][c] = nq; } } last = np; } s[last].insert(id); }
[補檔計劃] 字符串 之 知識點匯總