// 本文部分內容參照劉汝佳《算法競賽入門經典訓練指南》,特此說明。
[20190129更新!] 終於!時隔多年對這篇文章重新整理了一下,感謝大家提出的建議與意見。
1、前言
趁着這幾天上午,把后綴數組大致看完了。這個東西本身的概念可能沒太大理解問題,但是它所延伸出來的知識很復雜,很多,還有它的兩個兄弟——后綴樹,后綴自動機,編起來都不是蓋的。
2、概念
前面曾經提到過AC自動機(http://www.cnblogs.com/jinkun113/p/4682853.html),講得有點簡略,它用以解決多模板匹配問題。但是前提是事先知道所有的模板,在實際應用中,我們無法事先知道查詢內容的,比如在搜索引擎中,你的查詢是不可能直接預處理出來的。這個時候就需要預處理文本串而非每次的查詢內容。
后綴數組,說的簡單一點,就是將一個字符串的所有后綴儲存起來的數組,接下來分析它的作用。
3、構建
首先假定一個字符串BANANA,在后面添加一個非字母字符“$”,代表一個沒出現過的標識字符,然后把它的所有后綴——
插入到一棵Trie中。由於標識字符的存在,字符串每一個后綴都與一個葉節點一一對應。如圖所示:
我們發現,有了后綴Trie之后,可以O(m)查找一個單詞,如右側。
在實際應用中,會把后綴Trie中沒有分支的鏈合並在一起,得到所謂的后綴樹,但是由於后綴樹的構造算法復雜難懂,且容易寫錯,所以在競賽中很少使用,所以暫時不去研究了。相比之下,后綴數組是必備武器,時間效率高,代碼簡單,而且不易寫錯。
在繪制后綴Trie的時候,我們將字典序小的字母排在左邊。由於葉節點和后綴一一對應,我們現在在每一個葉節點上標上該后綴的首字母在原字符串中的位置,如圖:
將所有下標連在一起,構建出來的,就是所謂的后綴數組了。BANANA的后綴數組為sa[] = {5, 3, 1, 0, 4, 2},舉個例子,其中sa[1] = 3表示第3 + 1 = 4個字母開頭的后綴即"ANA"在所有后綴中字典序排名為1。這樣的話,我們就可以直接通過一次快速排序O(n log n)得到了。但是,在比較任意兩個后綴時,又需要O(n),故這是O(n^2 log n),根本扛不住。
4、倍增
下面介紹Manber和Myers發明的倍增算法,時間復雜度O(n log n)(不采用基數排序的話就是O(n log^2 n))。
首先對於所有單個字符排序(也可以理解成對於每一個后綴的第1個字符排序,這樣后面的步驟更易銜接),如圖:
對於每個字母,我們根據字典序給予其一個名次,則a->1,b->2,n->3。
而接下來,我們再給所有后綴的前兩個字符排序(之前就是前一個),將相鄰二元組合並,再次根據字典序給予一個名次,如圖:
而我們現在得到了所有后綴的前2個字符的排名,注意這種方法是倍增思想,接下來要求的就是所有后綴的前4個字符的名次,因為可知對於后綴x的前4個字符是由后綴x的前2個字符和后綴x+2的前2個字符組成的,方法同上。如圖:
我們也可以注意到,當我們試圖再去把所有后綴的前8個字符排一遍序的時候會發現,並沒有任何含義。首先,這個字符串的長度沒有達到8,其次所有名詞已經兩兩不同,已經達到了我們的目的。所以我們可以分析出,這個過程的時間復雜度穩定為O(log n)。
得到了序列a[]={4,3,6,2,5,1},a[i]表示后綴i的名次。而后我們可以得到后綴數組了:sa[]={5,3,1,0,4,2}。(你要問我怎么得到的嘛?)
個人認為,這個思路自己想想還是好些,還是比較清晰的,起碼我是先有思路再看懂網上文章的意思的。
5、基數排序
比較的復雜度為O(log n),如果這個時候再用快速排序的話,依舊需要O(n log^2 n),雖然已經小多了!但是,這個時候如果使用基數排序,可以進一步優化,達到O(n log n)。
首先先來介紹這個以前沒聽過的排序方法。設存在一序列{73,22,93,43,55,14,28,65,39,81},首先根據個位數的數值,在遍歷數據時將它們各自分配到編號0至9的桶(個位數值與桶號一一對應)中,如下圖左側所示:
得到序列{81,22,73,93,43,14,55,65,28,39}。再根據十位數排序,如右側,將他們連起來,得到序列{14,22,28,39,43,55,65,73,81,93}。
很好理解的一個排序。詳細的內容不過多闡述。它的時間復雜度取決於數的多少以及數的位數。
在構建后綴數組的過程中,我們可以發現最大位數為2(字母總共只有26個),用基數排序的復雜度明顯小於快速排序。下面給出一個臨時的后綴數組構建模板,可以發現很多地方的模板都長這個樣子的。
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 #define MAXN 1005 5 #define MAXM 30 6 7 char ch[MAXN]; 8 int sa[MAXN], a[MAXN], t[MAXN], c[MAXN], n, m = MAXM, p; 9 10 int main() {
11 scanf("%s", ch), n = strlen(ch); 12 for (int i = 0; i < n; i++) c[a[i] = (ch[i] - 'a' + 1)]++; 13 for (int i = 1; i < m; i++) c[i] += c[i - 1]; 14 for (int i = n - 1; i >= 0; i--) 15 sa[--c[a[i]]] = i; 16 for (int k = 1; k <= n; k <<= 1) { 17 int p = 0; 18 for (int i = n - k; i < n; i++) t[p++] = i; 19 for (int i = 0; i < n; i++) if (sa[i] >= k) t[p++] = sa[i] - k; 20 for (int i = 0; i < m; i++) c[i] = 0; 21 for (int i = 0; i < n; i++) c[a[t[i]]]++; 22 for (int i = 0; i < m; i++) c[i] += c[i - 1]; 23 for (int i = n - 1; i >= 0; i--) sa[--c[a[t[i]]]] = t[i]; 24 swap(a, t); 25 p = 1, a[sa[0]] = 0; 26 for (int i = 1; i < n; i++) a[sa[i]] = (t[sa[i - 1]] == t[sa[i]] && t[sa[i - 1] + k] == t[sa[i] + k]) ? p - 1 : p++; 27 if (p >= n) break; 28 m = p; 29 } 30 return 0; 31 }
【對如上代碼的注釋】
n表示串的長度,m表示字符種類數。由於m沒有直接給出,故初始賦值為30(大於可能出現的字符種類個數即可)。
6、最長公共前綴
目前我們得到的只有后綴數組一個東西。接下來就有一系列的延伸。比如說,在O(n log n)的時間內處理最長公共前綴,即LCP。求n個字符串LCP,暴力需要O(n^3),完全不是一個級別。
而利用后綴數組的話,通常需要兩個數組,rank[i]表示后綴i在SA數組中的下標;height[i]表示sa[i-1]和sa[i]的最長公共前綴長度。對於兩個前綴j和k,j<k,不妨設rank[j]<rank[k]。不難得到,后綴j和k的LCP長度等於height[rank[j+x]](x∈[1,k-j])中的最小值,舉一個例子就能明白。
好還是好理解的,但是想想,根據定義,每次計算一對的height數組,都需要O(n),則共需要O(n^2),這顯然讓人感到不可忍,畢竟構建SA數組的時候都只需要O(n log n)。
然而這個時候我們再用個輔助數組a[i]=height[rank[i]],然后按照h[1],h[2]……h[n]的順序遞推計算。遞推的關鍵在於這樣一個性質:h[i]>=h[i-1]-1.這樣就不需要從字符串開頭計算了。如下方。
代碼:
1 int rank[MAXN], height[MAXN]; 2 3 void geth() { 4 for (int i = 0; i < n; i++) rank[sa[i]] = i; 5 for (int i = 0; i < n; i++) { 6 if (k) k--; 7 int j = sa[rank[i] - 1]; 8 while (ch[i + k] == ch[j + k]) k++; 9 height[rank[i]] = k; 10 } 11 }
下面是該優化的證明:
設排在后綴i-1前一個的是后綴k。后綴k和后綴i-1分別刪除首字符之后得到后綴k+1和后綴i,因此后綴k+1一定排在后綴i的前面,並且最長公共點綴長度為h[i-1]-1,如圖所示:
這個h[i-1]-1是一系列h值的最小值,這些h值包括后綴i和排在它前一個的后綴p的LCP長度,即h[i]。因此h[i]>=h[i-1]-1。
7、總結
這是一個非常高大上的東西,也許說這些看起來還是易懂的,但是題目做起來還是能夠達到一種境界的。尤其還有后綴自動機等內容沒有提。我認為后綴數組其實是個很巧妙的東西,更何況加在上面的各種優化。