1. 程式人生 > >[補檔計劃] 字符串 之 知識點匯總

[補檔計劃] 字符串 之 知識點匯總

簡單的 space 復雜度分析 dfs clas .net flush min return

  學習一個算法, 需要弄清一些地方

    ① 問題與算法的概念

    ② 算法以及其思維軌跡

    ③ 實現以及其思維軌跡

    ④ 復雜度分析

    ⑤ 應用

KMP算法

字符串匹配與KMP算法

  為了方便弄清問題, 應該從特例入手.

  設 A = " ababababb " , B = " ababa " , 我們要研究下面三個遞進層次的字符串匹配問題:

    ① 是否存在 A 的子串等於 B

    ② 有幾個 A 的子串等於 B

    ③ A 的哪些位置的子串等於 B

  KMP算法可以直接在線性復雜度解決問題③, 進而更可以解決問題①和問題②.

KMP算法

  

最先的想法必然是 Brute Force: 枚舉 S 的匹配的開頭位置, 將 S 與 T 逐位進行匹配. 雖然在隨機數據下優秀, 但是遇上極端數據很不理想.

  我們考慮利用匹配的特性進行優化. 如下圖所示, 匹配到 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);
}

[補檔計劃] 字符串 之 知識點匯總