[字串相關]字尾陣列 - 倍增演算法
我們約定,下文所提到的字串下標都從 \(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)\)
#2.2 倍增演算法
這裡採用倍增的雙關鍵字排序。大致流程如下:
- 給所有後綴的第一個字元排序;
- 給所有後綴的前兩個字元排序,實際是雙關鍵字排序;
- 列舉長度 \(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.
[3] 字尾陣列簡介 - OI Wiki