今天学习了一下后缀数组,感觉是一个较为复杂且精细的数据结构,要理解它最好只抓一些关键的部分。
首先后缀数组是建立在一个字符串上的数据结构,其存储的元素是字符串的所有后缀,譬如'abc'的后缀有'c','bc','abc',其起始下标分别为2,1,0。要存储所有的后缀显然需要花费O(n^2)级别的空间,对于超长的字符串这是不现实的。因此实现上往往用后缀的起始下标指代整个后缀,然后所有后缀可以共用源字符串,这样存储的压力就被解除了。
显然直接使用后缀是比较困难的,一般需要对所有后缀进行排序,以方便之后的查找匹配。如果利用一般的字典来做,平衡树的时间复杂度为O(n^2log2(n)),而哈希表则为O(n^2)。无论是哪一种都无法应对文本足够大的情况。而后缀数组可以用于处理后缀的排序,其能够提供O(1)时间复杂度的起始下标与排名互转的快速操作,但是必须在之前建立完整的后缀数组。下面描述利用倍增算法构建后缀数组的流程,其时间复杂度为O(nlog2(n)),空间复杂度为O(n),虽然不及隔壁的后缀树,但是胜在简单实用。
我们先对所有后缀进行第一轮排序,排序的依据仅是后缀的第一个字符。由于字符集可能会较大(甚至会超过字符串的长度),因此可以使用快速排序完成这个过程。这里的时间复杂度为O(nlog2(n))。我们之后利用排序的先后顺序对每个后缀进行排名,如果两个后缀的第一个字符相同,则获得相同排名,排名是连续的,即为0,1,1,2之类不会出现断层,显然排名被约束到了0~n之间,即我们实际上用这次排序移除了字符集带来的干扰。但是我们现在还仅仅只是对第一个字符进行排序,难道还需要对后面每个字符进行一次排序吗?答案是否定的,我们可以用ranks来记录现在每个后缀的排名,ranks[i]表示以i为起始下标的后缀的排名。那么当我们要对前两个字符进行排序,我们发现此时排序的顺序与我们为每个后缀赋予关键字ranks[i]*n+ranks[i+1]并按照关键字对后缀进行排序后的顺序是一致的。当然对于最后一个后缀n-1,我们为其赋予关键字ranks[n-1]*n+0即可。这里的原因是字符串的比较规则,不理解可以自己体会一下。在我们第二轮排序都结束后,ranks赋值为新的排名,之后我们对前4个字符进行排序,显然排序后的结果和我们为每个后缀赋予关键字ranks[i]*n+ranks[i+2]后按关键字进行排序的结果是相同的。因此我们可以发现之后可以直接对前2^3,2^4,2^5...,2^k个字符先后进行排序,因此总共排序执行了O(log2(n))次,若我们采用快速排序或归并排序,时间复杂度为O(n(log2(n))^2)。等到所有排序完成,此时有2^k>n/2,而由于后面的第k+1次排序所有后缀被赋予关键字ranks[i]*n+0,这意味着所有排名都不会变动,因此我们只需要进行前k=ceil(log2(n/2)))次排序即可。而由于最后一次排序实际上相当与对所有后缀按前n个字符进行排名(不足补0,0视作比字符集中一切字符都小),而由于不同后缀的长度均不同,因此每个后缀都将拥有不同的排名,即排名和起始下标之间建立了双射关系。排序完后我们可以将ranks进行保存,同时将排序后的后缀序列进行保存,前者用于按起始下标查找对应排名,而后者用于按照排名查找起始下标,查找的时间复杂度显然为O(1)。
由于我们每次排序后的ranks的数据范围为确定的0~n。因此我们可以使用另外一种排序技术,基数排序。我们以n为基数,这样只需要提供O(n)的空间就可以在O(n)时间复杂度内完成一趟排序。但是你可能要反问,我们排序使用的关键字是ranks[i]*n+ranks[i+2^k](其中2^k表示第k次排序)啊,其数据范围不是应该为0~n^2+n吗。是的,但是基数排序本身就是以基数r分配空间,并能在O(rlogr(n))时间复杂度内完成排序的算法,我们这里使用n为基数,而logn(n^2+n)=O(1),因此时间复杂度可以归结为O(n)。不懂的可以先百科一下基数排序。
至此,我们可以利用基数排序替换上面的快速排序(除了第一次之外),这样时间复杂度就优化为O(nlog2(n)),空间复杂度为O(n)。
1 public class SuffixArray { 2 int[] rank; 3 int[] revRank; 4 char[] data; 5 6 private SuffixArray(int[] rank, int[] revRank, char[] data) { 7 this.revRank = revRank; 8 this.rank = rank; 9 this.data = data; 10 } 11 12 public static SuffixArray makeSuffixArray(char[] s, int rangeFrom, int rangeTo) { 13 int n = s.length; 14 int range = n + 1; 15 Loop<int[]> rankLoop = new Loop(new int[3][n + 1]); 16 17 int[] orderedSuffix = new int[n + 1]; 18 int[] firstRanks = rankLoop.get(0); 19 for (int i = 0; i < n; i++) { 20 orderedSuffix[i] = i; 21 firstRanks[i] = s[i] - rangeFrom + 1; 22 } 23 orderedSuffix[n] = n; 24 firstRanks[n] = 0; 25 Loop<int[]> suffixLoop = new Loop(new int[][]{ 26 orderedSuffix, new int[n + 1] 27 }); 28 29 radixSort(suffixLoop.get(0), suffixLoop.get(1), rankLoop.get(0), rangeTo - rangeFrom + 1); 30 assignRank(suffixLoop.turn(), rankLoop.get(0), rankLoop.get(0), rankLoop.turn()); 31 32 for (int i = 1; i < n; i <<= 1) { 33 System.arraycopy(rankLoop.get(0), i + 1, rankLoop.get(1), 1, range - i - 1); 34 Arrays.fill(rankLoop.get(1), range - i + 1, range, 0); 35 radixSort(suffixLoop.get(0), suffixLoop.turn(), rankLoop.get(1), range); 36 radixSort(suffixLoop.get(0), suffixLoop.turn(), rankLoop.get(0), range); 37 assignRank(suffixLoop.get(0), rankLoop.get(0), rankLoop.get(1), rankLoop.turn(2)); 38 } 39 40 firstRanks = rankLoop.get(0); 41 return new SuffixArray(firstRanks, suffixLoop.get(), s); 42 } 43 44 public static void assignRank(int[] seq, int[] firstKeys, int[] secondKeys, int[] rankOutput) { 45 int cnt = 0; 46 rankOutput[0] = 0; 47 for (int i = 1, bound = seq.length; i < bound; i++) { 48 if (firstKeys[seq[i - 1]] != firstKeys[seq[i]] || 49 secondKeys[seq[i - 1]] != secondKeys[seq[i]]) { 50 cnt++; 51 } 52 rankOutput[seq[i]] = cnt; 53 } 54 } 55 56 public static void radixSort(int[] oldSeq, int[] newSeq, int[] seqRanks, int range) { 57 int[] counters = new int[range]; 58 for (int rank : seqRanks) { 59 counters[rank]++; 60 } 61 int[] ranks = new int[range]; 62 ranks[0] = 0; 63 for (int i = 1; i < range; i++) { 64 ranks[i] = ranks[i - 1] + (counters[i] > 0 ? 1 : 0); 65 counters[i] += counters[i - 1]; 66 } 67 68 for (int i = oldSeq.length - 1; i >= 0; i--) { 69 int newPos = --counters[seqRanks[oldSeq[i]]]; 70 newSeq[newPos] = oldSeq[i]; 71 } 72 } 73 74 public int getStartIndexByRank(int rank) { 75 return revRank[rank]; 76 } 77 78 public int getRankByStartIndex(int startIndex) { 79 return rank[startIndex]; 80 } 81 82 public static class Loop<T> { 83 T[] loops; 84 int offset; 85 86 public Loop(T[] initVal) { 87 loops = initVal; 88 } 89 90 public T get(int index) { 91 return loops[(offset + index) % loops.length]; 92 } 93 94 public T get() { 95 return get(0); 96 } 97 98 public T turn(int degree) { 99 offset += degree; 100 return get(0); 101 } 102 103 public T turn() { 104 return turn(1); 105 } 106 } 107 108 @Override 109 public String toString() { 110 StringBuilder result = new StringBuilder(); 111 for (int i = 1, bound = revRank.length; i < bound; i++) { 112 result.append(i).append(" : ").append(new String(data, revRank[i], data.length - revRank[i])).append("; "); 113 } 114 return result.toString(); 115 } 116 }
同时再介绍一种利用后缀数组以O(n+m)时间复杂度内计算两个字符串最大匹配子串的方式。首先我们用一个特殊的字符(不存在两个字符串之中)作为中间字符连接两个字符串为新的字符串S,记连接字符的位置为p。之后在合并后的字符串上建立后缀数组。我们记heights[i]表示排名为i的后缀与排名为i-1的后缀之间的最长相同前缀长度,那么我们的问题就变成了最大的heights[i],满足排名为i和i-1的后缀的起始下标一者大于p,一者小于p(由于S[p]是唯一字符,因此不会S[p]不会和任意其它字符相同,即匹配不会越过p)。heights用一般的方法建立需要付出O(n^2)的时间复杂度才行,但是我们可以利用一个简单的思路,设排名为i的后缀起始下标为i',记j'=i'-1,而j为j'后缀的排名,显然heights[i]>=heights[j]-1,因此我们按照后缀的起始下标顺序分别对每个后缀计算其对应的height值,就可以在O(n)的时间复杂度内计算完整个heights数组。这里利用了一个命题,设排名i,j,k递增, 则排名为i的后缀与排名为k的后缀的最长匹配前缀必然不可能长于排名为j与k的后缀的最长匹配前缀的长度。