本文参考《训练指南》P219 、 《IOI2009 国家集训队论文by罗穗骞》
后缀数组是对文本串进行处理,而非模板串(在文本串中查找模板串),例如搜索引擎。
而AC自动机是对模板串进行预处理。两者区别。
后缀trie树:对于字符串banana,可以把它的所有后缀(banana$,anana$,nana$,ana$,na$,a$)插入一颗trie树中。这样查询起来只需要对trie树进行一次遍历就行了。
后缀树(Suffix tree):在实际应用中,会把后缀trie中没有分支的链合并到一起,得到所谓的后缀树,但由于后缀树构造算法复杂难懂,且容易写错(虽然代码并不长),在算法竞赛中很少使用。
后缀数组
定义:为叙述方便,我们直接把"以下标k开头的后缀"叫做后缀k。把所有后缀进行字典序排序,然后将排好序的后缀转化成对应的k,得到的数组就是后缀数组。
比如banana:a(5) ,ana(3), anana(1), banana(0), na(4), nana(2)
那么banana的后缀数组就是 5,3,1,0,4,2
显然,直接对所有后缀进行排序效率是很低的,那么我们可以使用倍增算法(double_algorithm)与三分算法(Difference Cover modulo 3)。
倍增算法(da):
对于banana这个字符串:(以下表格体现的是rank数组的值,即当前后缀的排名。表格变化过程体现了da算法的过程)
1、先对每个后缀的第一个字母进行排序:
b | a | n | a | n | a |
2 | 1 | 3 | 1 | 3 | 1 |
2、对每个后缀的前2个字符进行排序
b | a | n | a | n | a |
21 | 13 | 31 | 13 | 31 | 10 |
3 | 2 | 4 | 2 | 4 | 1 |
3、对每个后缀的前4个字符进行排序
b | a | n | a | n | a |
32 | 24 | 42 | 24 | 41 | 10 |
3 | 2 | 5 | 2 | 4 | 1 |
4、对每个后缀的前8个字符进行排序
b | a | n | a | n | a |
32 | 25 | 52 | 24 | 41 | 10 |
4 | 3 | 6 | 2 | 5 | 1 |
这时候所有名次已经两两不同了,分别是1~6,下面就不用进行排序了。
显然,如果继续对前16个字符进行排序,那么结果和上面是一样的
b | a | n | a | n | a |
43 | 36 | 62 | 25 | 51 | 10 |
4 | 3 | 6 | 2 | 5 | 1 |
由于每次比较的字符数都翻倍,所以比较次数是log(n),而每次比较的复杂度是多少呢?
如果使用快速排序,那么每次需要O(nlogn),总共的复杂度就是O(n*logn*logn)。然而这里可以使用基数排序来使每次排序达到O(n)。
基数排序是很容易理解的。
//倍增算法--《后缀数组——处理字符串的有力工具》IOI2009 国家集训队论文 int wa[maxn],wb[maxn],wv[maxn],ws[maxn]; int cmp(int *r,int a,int b,int l) { return r[a]==r[b]&&r[a+l]==r[b+l]; } /* 待排序的字符串放在 r 数组中,从 r[0]到 r[n-1],长度为 n,且最大值小 于 m。为了函数操作的方便,约定除 r[n-1]外所有的 r[i]都大于 0, r[n-1]=0。 函数结束后,结果放在 sa 数组中,从 sa[0]到 sa[n-1]。 */ void da(int *r, int *sa, int n, int m) { int i,j,p,*x=wa,*y=wb,*t; //第一步:对长度为1的字符串进行排序 for(i=0; i<m; i++) ws[i]=0; for(i=0; i<n; i++) ws[ x[i]=r[i] ]++; for(i=1; i<m; i++) ws[i]+=ws[i-1]; for(i=n-1; i>=0; i--) sa[--ws[x[i]]]=i; for(j=1,p=1; p<n; j*=2,m=p) { for(p=0,i=n-j; i<n; i++) y[p++]=i; for(i=0; i<n; i++) if(sa[i]>=j) y[p++]=sa[i]-j; for(i=0; i<n; i++) wv[i]=x[ y[i] ]; for(i=0; i<m; i++) ws[i]=0; for(i=0; i<n; i++) ws[wv[i]]++; for(i=1; i<m; i++) ws[i]+=ws[i-1]; for(i=n-1; i>=0; i--) sa[--ws[wv[i]]]=y[i]; for(t=x,x=y,y=t,p=1,x[sa[0]]=0,i=1; i<n; i++) x[sa[i]]=cmp(y,sa[i-1],sa[i],j)?p-1:p++; } } 代码理解: 函数的第一步,要对长度为 1 的字符串进行排序。一般来说,在字符串的题目中,r 的最大值不会很大,所以这里使用了基数排序。如果 r 的最大值很大(也就是m的值很大),那么把这段代码改成快速排序。 代码:
这里 x 数组保存的值相当于是 rank 值。下面的操作只是用 x 数组来比较字符的大小,所以没有必要求出当前真实的 rank 值。在初始化时,x数组和r数组一样,但这并不意味着x数组的意义和r一样,x数组保存的当前长度为j的子串的排名。因为这些"排名"可能会相同,也可能会断号(只在第一步可能断号),所以这不是严格意义的排名,我觉得更应该理解为优先级,x数值越大,优先级小,排名越靠后。初始化时可以理解为是对长度为1的子串进行排序,所以x可以和r一样,可以把r想象成对每一个长为1的子串的优先级定义。 接下来进行若干次基数排序,在实现的时候,这里有一个小优化。基数排序要分两次,第一次是对第二关键字排序,第二次是对第一关键字排序。对第二关键字排序的结果实际上可以利用上一次求得的 sa 直接算出, 没有必要再算一次。 代码:
其中变量j是上一轮排序的子串长度,数组y保存的是对第二关键字排序的结果 。 上述代码不太好理解,其实,在这段代码执行之前,sa数组存的是上一轮的排序结果,上一轮已经对长度为j的子串排好序了(这也就是为什么j从1开始循环而不从2开始,因为在for循环之前已经初始化求出排序长度为1的sa数组了),这一轮实际上是对长度为2*j的子串进行排序得到新的sa数组。而上述代码的功能就是利用上一轮的sa数组生成y数组,这里的y保存的是对长度为2*j的子串进行第二关键字排序的结果。 y数组详细解释:y数组保存的是字符串下标,y[i],y[i+1]分别表示排在第i位和排在第i+1位的长度为2*j的子串首下标,之前说过,这里的排序规则是第二关键字的字典序,也就是说它是根据这个2*j子串的后半部分的字典序进行排序的,而前半部分的排序还没有完成。 假设y[i]表示的字符串是 ????abcd 假设y[i+1]表示的字符串是 ????bcde 其中问号字符表示未知字符(不是问号本身),显然这里的j是4,y[i]之所以在y[i+1]的前面,是因为abcd< bcde,而y[i]保存的是????abcd这个子串的首下标,y[i+1]保存的是????bcde这个子串的首下标。 然后要对第一关键字进行排序,代码:
这样便求出了新的 sa 值。在求出 sa 后,下一步是计算 rank 值(优先级),也就是下一轮的x数组。这里要注意的是,可能有多个字符串的 x值是相同的,所以必须比较两个字符串是否完全相同, y 数组的值已经没有必要保存, 为了节省空间, 这里用 y 数组保存 rank值。 我们之前已经说过,y已经保存了第二关键字的排序结果,y[i]表示第二关键排名第i的子串首下标,那么如何计算下一轮的x数组呢?首先将x和y指针互换,x[sa[0]]=0的作用是根据已经计算好的sa数组初始化x数组的其中一个值,这个位置的rank是0毫无疑问,接下来不断更新x的其他值,p从1开始,如果当前子串和上一个子串完全相同,那么rank是p-1,否则,rank是p,且p++。这个也比较好理解。在比较排名相邻的两个子串是否完全相等的过程中,用到了y数组(原来的x数组),也就是比较这两个子串前后两部分的y值(原来的x,也就是原来的rank值)是否相同。 这一步完成之后,x数组保存的就是rank值,两个串相等的话,其rank值是相同的。 这里又有一个小优化,将 x 和 y 定义为指针类型,复制整个数组的操作可以用交换指针的值代替,不必将数组中值一个一个的复制。代码:
这里的cmp函数是: int cmp(int *r,int a,int b,int l) { return r[a]==r[b]&&r[a+l]==r[b+l]; } 这里可以看到规定 r[n-1]=0 的好处,如果 r[a]=r[b],说明以 r[a]或 r[b]开头的长度为l的字符串肯定不包括字符r[n-1] , 所以调用变量 r[a+l]和r[b+l]不会导致数组下标越界,这样就不需要做特殊判断。执行完上面的代码后,rank值保存在 x 数组中,而变量 p 的结果实际上就是不同的字符串的个数。这里可以加一个小优化, 如果 p 等于 n, 那么函数可以结束。因为在当前长度的字符串中 ,已经没有相同的字符串,接下来的排序不会改变 rank 值。例如图 1 中的第四次排序,实际上是没有必要的。对上面的两段代码,循环的初始赋值和终止条件可以这样写: for(j=1,p=1;p<n;j*=2,m=p) {…………} 在第一次排序以后,rank 数组中的最大值小于 p,所以让 m=p。整个倍增算法基本写好,代码大约 25 行。 算法分析: 倍增算法的时间复杂度比较容易分析。每次基数排序的时间复杂度为 O(n) ,排序的次数决定于最长公共子串的长度,最坏情况下,排序次数为 logn 次,所以总的时间复杂度为 O(nlogn)。 |
注:关于dc3算法,其具体原理具体请参阅《IOI2009 国家集训队论文by罗穗骞》,该论文还包含两种算法的详细比较,内容太多,暂时不去研究了。