1. 程式人生 > 其它 >[字串相關]字尾陣列 - 倍增演算法

[字串相關]字尾陣列 - 倍增演算法

字尾陣列倍增 $O(n\log n)$ 演算法的簡單講解

我們約定,下文所提到的字串下標都從 \(0\) 開始。下文中的“第 \(x\) 字尾”指的是從下標 \(x\) 開始的字尾。

對於字尾陣列的學習,本人建議可以自己隨便寫一個字串,然後按照相應的過程進行模擬,會很方便的理解每一步的作用以及原理。

#1.0 何為字尾陣列

#1.1 字尾樹

我們知道,像 \(\texttt{AC}\) 自動機 這樣的多模式串匹配需要預處理的是模式串,但是在很多時候,我們會先知道文字串,而非模式串,對於每個模式串的詢問要求線上回答,那怎麼辦?我們引入字尾樹這個概念。

所謂字尾樹,就是將一個字串的所有後綴建成一棵樹,設 \(S='abaca'\)\(S\) 的字尾樹為:

$ 符號是為了標記每一個字尾的結束位置,每個葉節點下面的數字表示該字尾從原字串的第幾位開始,我們已經把所有後綴按字典序從左向右擺放(預設 $ 符號的字典序最小),顯然從上面尋找模式串是非常方便的,但我們發現整個字尾樹的結構非常龐大,有沒有合適的替代品呢?

#1.2 字尾陣列

將上面的葉結點下的數字從左往右取出放在陣列中,便得到了字尾陣列\(\texttt{Suffix Array}\)),

由此便不難得到字尾陣列的定義:所有的字尾按字典序排序後的編號陣列。

#2.0 字尾陣列的構建

#2.1 樸素演算法

一個很簡單的演算法便是直接採用快排 sort(),快排的時間複雜度為 \(O(n\log n)\)

,但由於字串比較需要按位比較,所以實際時間複雜度為 \(O(n^2\log n)\),顯然不能接受。

#2.2 倍增演算法

這裡採用倍增的雙關鍵字排序。大致流程如下:

  1. 給所有後綴的第一個字元排序;
  2. 給所有後綴的前兩個字元排序,實際是雙關鍵字排序;
  3. 列舉長度 \(l=2^k\),給所有後綴的前 \(l\) 個字元排序,實際依舊是雙關鍵字排序,因為可以藉助上一次排序的結果(\(l=2^k=2^{k-1}+2^{k-1}\)),即錯開 \(l-1\) 位進行雙關鍵字排序;重複這一步直到所有後綴的排名不同。

顯然,上面的過程最多隻需要 \(\log n\) 次,如果這裡的雙關鍵字排序採用快排的話,時間複雜度為 \(O(n\log^2 n)\)

#2.3 基數排序優化

我們注意到最多隻有 \(n\) 個字尾,所以可以採用基數排序(我們常用的桶排序便是單關鍵字的基數排序),這裡需要採用雙關鍵字的基數排序。

直接講解並沒有非常好講,所以這裡先貼出程式碼,我們來一行一行的分析。

char s[N];
int sa[N], t[N], t2[N], c[N], n;

inline void build_sa(int m) {
    //m 是編號的最大值
    int i, *x = t, *y = t2;
    for (i = 0; i < m; ++ i) c[i] = 0;
    for (i = 0; i < n; ++ i) c[x[i] = s[i]] ++;
    for (i = 1; i < m; ++ i) c[i] += c[i - 1];
    for (i = n - 1; i >= 0; i --) sa[-- c[x[i]]] = i;
    for (int k = 1; k <= n; k <<= 1) {
        int p = 0;
        for (i = n - k; i < n; i ++) y[p ++] = i;
        for (i = 0; i < n; i ++) if (sa[i] >= k)
          y[p ++] = sa[i] - k;
        for (i = 0; i < m; ++ i) c[i] = 0;
        for (i = 0; i < n; ++ i) c[x[y[i]]] ++;
        for (i = 1; i < m; ++ i) c[i] += c[i - 1];
        for (i = n - 1; i >= 0; i --)
          sa[-- c[x[y[i]]]] = y[i];
        swap(x, y); p = 1; x[sa[0]] = 0;
        for (i = 1; i < n; ++ i)
          x[sa[i]] = y[sa[i-1]] == y[sa[i]] && y[sa[i - 1] + k] == y[sa[i] + k] ? p - 1 : p ++;
        if (p >= n) break;
        m = p;
    }
}

首先是第一部分

for (i = 0; i < m; ++ i) c[i] = 0;
for (i = 0; i < n; ++ i) c[x[i] = s[i]] ++;
for (i = 1; i < m; ++ i) c[i] += c[i - 1];
for (i = n - 1; i >= 0; i --) sa[-- c[x[i]]] = i;

這裡是先對每個字尾的首字母進行排序,x[i] 在這裡表示的是第 i 個字元的編號(此時的第一關鍵字),sa[i] 表示的是按第一關鍵字排序後的第 i 個字尾在原字串的起始位置。


然後是迴圈中的這一部分

int p = 0;
for (i = n - k; i < n; i ++) y[p ++] = i;
for (i = 0; i < n; i ++) if (sa[i] >= k)
  y[p ++] = sa[i] - k;

這一部分是按第二關鍵字排序,y[i] 儲存的是按第二關鍵字排序後第 i 個字尾在原字串上的開始位置。

其中第一個 for 是將沒有第二關鍵字的字尾的第二關鍵字設定為無限小,所以要放在 y[] 陣列的最前面。

考慮第二個 for 為什麼可以直接藉助 sa[] 陣列進行賦值,我們知道,sa[i] 表示的是在長度為 \(\frac k 2\) 時,按當時的雙關鍵字排序後的第 i 個字尾在原字串的起始下標,這時對應的也就是在此時長度為 \(k\) 的情況下按第一關鍵字排序後的第 i 個字尾在原字串的起始下標。

sa[i] - k 就相當於按第一關鍵字排序後的第 i 個字尾在原字串的下標的第前 k 個位置,那麼如果將 sa[i] 看作第二關鍵字,那麼 sa[i] - k 便是所對應的第一關鍵字在原字串中的下標位置,那麼陣列 y[] 中的數恰好便是按第二關鍵字排序後的第 i 個字尾在原字串的起始下標。


迴圈中的第二大部分夢幻四重迴圈

for (i = 0; i < m; ++ i) c[i] = 0;
for (i = 0; i < n; ++ i) c[x[y[i]]] ++;
for (i = 1; i < m; ++ i) c[i] += c[i - 1];
for (i = n - 1; i >= 0; i --) sa[-- c[x[y[i]]]] = y[i];

這裡 x[i] 表示的是第 \(i\) 字尾在按第一關鍵字排序後對應的 sa[] 陣列上的排名(注意這樣的排名的大小隻是相對性的,可能並不連續)。

在整個演算法的大部分時間裡,x[] 都是從字尾對映到排名,sa[] 則是從排名對映到字尾。

不難發現這裡和第一部分幾乎一模一樣。這裡是結合兩個關鍵字的分別排序,構建出當前的雙關鍵字排序後的結果,也是下一次長度為 \(2k\) 時按第一關鍵字排序後的結果。

原因不難想到:y[] 本身具有順序性,即第二關鍵字小的在前面,而本身這個排序則是將第一關鍵字小的排在前面,而不改變第一關鍵字相同的數之間的相對位置,綜合起來,得到的答案便是按雙關鍵字排序後的結果。


這是迴圈中的最後一部分

swap(x, y); p = 1; x[sa[0]] = 0;
for (i = 1; i < n; ++ i)
  x[sa[i]] = y[sa[i-1]] == y[sa[i]] && y[sa[i - 1] + k] == y[sa[i] + k] ? p - 1 : p ++;
if (p >= n) break;
m = p;

注意在開始前我們先交換了指標 x 和指標 y 所指向的地址,所以此時 x[i] 指的是按第二關鍵字排序後的第 i 個字尾在原字串的起始下標,這個資料對於我們來說已經沒有用處了;y[i] 表示的是第 \(i\) 字尾在按第一關鍵字排序後對應的 sa[] 陣列上的排名(注意這樣的排名的大小隻是相對性的,可能並不連續)。

這裡是統計按現在的結果,還會有多少個不同的排名,如果排名數達到了字尾的總數,那麼就沒有必要繼續進行下去了,如果不夠,那麼同時為各個不同的字尾重新編號(記錄按雙關鍵字排序後對應的 sa[] 陣列上的排名),這樣編號最大為 \(p-1\)

中間的三目運算子寫成 if 如下

if (y[sa[i-1]] == y[sa[i]] && y[sa[i - 1] + k] == y[sa[i] + k])
  x[sa[i]] = p - 1;
else x[sa[i]] = p ++;

這裡判等的條件分別是按長度為 \(2k\) 時的第一關鍵字相等(y[sa[i-1]] == y[sa[i]])和第二關鍵字相等(y[sa[i - 1] + k] == y[sa[i] + k]),至於為什麼 sa[] 的下標是連續的:兩個雙關鍵字都相等,而 sa[i] 中是當前雙關鍵字排序後時排名為 \(i\) 的字尾的起始位置,雙關鍵字排序的要求便是先滿足第一關鍵字從小到大,再滿足第二關鍵字從小到大,所以如果兩個都相等,那麼在 sa[] 中的排名必然是相鄰的。

筆者中午在宿舍裡思考這裡時腦子抽了,是這樣斷的句:(((x[sa[i]] = y[sa[i-1]]) == y[sa[i]]) && (y[sa[i - 1] + k)] == y[sa[i] + k]) ? p - 1/*錯誤地以為是 p --*/ : p ++,當時愣是搞不明白為啥這樣做是對的,吹起床哨了才發現我™又斷錯句了. QnQ


整體來說比較複雜,一遍不懂就需要多啃幾遍。

#3.0 字尾陣列的應用

#3.1 線上模式串匹配

採用二分查詢的方式,在文字串中尋找是否存在一個位置開始的字尾的字首是模式串。

我們可以二分所屬的字尾的排名,並與模式串比較大小,如果小了就讓排名增大,反之亦然。

設模式串長度為 \(m\),文字串長度為 \(n\),那麼單次詢問時間複雜度為 \(O(m\log n).\)

int m;
int cmp_suffix(char* pattern, int p) {
    return strncmp(pattern, s + sa[p], m);
}

int find(char* P) {
    m = strlen(P);
    if (cmp_suffix(P, 0) < 0) return -1;
    if (cmp_suffix(P, n - 1) > 0) return -1;
    int l = 0, r = n - 1;
    while (r >= l) {
        int mid = l + (r + l) / 2;
        int res = cmp_suffix(P, mid);
        if (!res) return mid;
        if (res < 0) r = mid - 1;
        else l = mid + 1;
    }
    return -1;
}

參考資料

[1] 劉汝佳, 陳鋒. 演算法競賽入門經典:訓練指南. 北京: 清華大學出版社, 2012.

[2] 字尾陣列——倍增演算法 - hyl天夢

[3] 字尾陣列簡介 - OI Wiki