这篇文章上次修改于 243 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

算法学习笔记(9): 后缀数组

定义

后缀数组是对后缀的一个排序,每个后缀按照字典序进行排序,然后用两个数组来映射它们自己和它们的排名。

同时,为了便于记录后缀,我们记字符串s的后缀s[i:]记为i,同时i和它的排名rank相互映射。这就是后缀数组。为了便于理解,在本文将此数组称为映射,这是它实际完成的任务,只是数组恰好作为这个工具。我们通常使用sa(rankToId) 和 rk(idToRank)两个映射来记录后缀数组。

如何构造后缀数组

做法一:暴力法

复杂度:O(n^2logn)

暴力排序,枚举出所有的后缀,在对所有的后缀进行排序。

做法二:倍增法

复杂度:O(nlog^2n)

为了完成这个思想,先建设一个前提,首先要了解字典排序的特点,当被排序的两者长度不一时。

当abc和b进行排序和123和2进行排序是不同的,进行等价的转换,实际上是123和200之间进行排序。虽然后缀的长度不同,实际上比较的时候,它们的长度是相同的。

如果所有后缀都要进行排序,不妨我们让短一点的后缀在后面填充0,好了,这下子所有后缀的长度都一样了。

这个算法的第一步:

首先对字符串s的所有长度为1的子串,即每个字符进行排序,得到排序后的编号数组sa1和排名数组 rk1。(此时如果有两者一样,两者的排名相同)

你就可以发现,对于他们的排序就相当于对每个后缀的第一个字符进行预先比较,下一步,就是这些预先比较使用动态规划的思想被后面的排序利用到。

再换一个角度去想,abac变成1213实际上就是一种序号编码,通过他们的大小进行了一个编码。

第二步,对所有长度为2的子串进行排序,此时,最后一位就是以0结尾。同时,依据这个思路,把长度为2 的子串进行了编码。但又是怎么排序的呢,位置1的取1和2,位置2的取2和3。

第三步,对所有长度为4的子串进行排序,注意!为什么从2直接跳跃到4,因为原来长度为2的情况已经被编码过了,将两个组合起来在进行排序就可以获得长度为4的排序。

sa2

复杂度分析也就简单了,因为倍增所以排序logN次,每次排序消耗NlogN次。

void buildSAArray(const string& input){
	int len = input.size();
	vector<int> idToRank(2 * len);
	vector<int> RankToid(len + 1); // 由于没有第0名这个概念,于是空置第一个位置

	int currentSortLen;

    for (int i = 1; i <= len; ++i){
        RankToid[i] = i; // 塞入所有的名次编号
        idToRank[i] = input[i - 1];
        // 因为一开始未编码,所以只要保存大小关系即可,ascii能说明字典序
    }
	
	auto cmp = [&](int id1, int id2){
		if(idToRank[id1] == idToRank[id2]){
			return idToRank[id1 + currentSortLen] < idToRank[id2 + currentSortLen];
		}else{
			return idToRank[id1] < idToRank[id2];
		}
	};
	
	for(currentSortLen = 1; currentSortLen < len; currentSortLen <<= 1){
		sort(RankToid.begin() + 1, RankToid.end(), cmp);
		vector<int> temp(idToRank.begin(), idToRank.end());
		// 根据排序顺序编码化
		for (int p = 0, i = 1; i <= len; ++i) {
			if (temp[RankToid[i]] == temp[RankToid[i - 1]] and 
					temp[RankToid[i] + currentSortLen] == temp[RankToid[i - 1] + currentSortLen]) {
				idToRank[RankToid[i]] = p;
			} else {
				idToRank[RankToid[i]] = ++p;
			} 
	  }
	}
	// for (int i = 1; i <= len; ++i) printf("%d ", RankToid[i]);
  // cout << endl;
	// for (int i = 1; i <= len; ++i) printf("%d ", idToRank[i]);
  // 得到的RankToid和idToRank可自行选择方式返回获取
}

优化排序

此处可以选择优化的排序,使排序从NLogN提升到N,这样复杂度就会降低到NLogN。

通过做法2的思路,我们可以发现,我们的排序实际上被排序的元素只有两个标签值,称作双关键字。对此我们可以使用非比较排序的基数排序。

在进行基数排序之前,需要了解基数排序和计数排序的基本内容。它们都属于非比较排序。

计数排序

非比较排序就是不会进行元素之间的比较,计数排序使用了从空间角度上很暴力的手段。举一个例子就可以知道了。

对于排序序列 2 3 3 1 4 2 8

计数排序直接维护一个大数组,此处可以取长度为10。记录所有0~9的元素出现个数,然后再从小到大依次输出记录的个数。

此处记录了①有1个 ② 有2个 ③有2个④有2个⑧有1个。其余都是0个。按照顺序输出也就是

1 2 2 3 3 4 4 8

排序完成。

基数排序

基数排序适用于多关键字排序,对于自然数来说,关键字就是不同的例如百位十位个位的值,而这个双关键字的情况,根据MSD 基数排序, 也就是从小的位到大的位进行排序。再结合计数排序,先按照第二关键字进行计数排序,取出所有,再对第一关键字进行排序即可。

以下就是基于基数排序的使排序复杂度降低到O(N)

void buildSAArray(const string& input) {
	int len = input.size();
	vector<int> idToRank(2 * len);
	vector<int> rankToid(len + 1);

	int currentSortLen;
	
	vector<int> cnt(128);
	for(int i = 1; i <= len; i++){ // 此处就是计数排序
		idToRank[i] = input[i - 1];
		cnt[input[i - 1]]++;
	}
	
	for(int i = 1; i <= cnt.size() - 1; i++){
		// 计算前缀和的原因是,我们需要的并不是计数排序输出所有元素,而是获取排名。
		cnt[i] += cnt[i - 1];
	} 

	for(int i = len; i >= 1; i--){
        // 放在SA里面的排名也就是index 需要不一样,所以这里要减一
        int rank = cnt[input[i - 1]];
        cnt[input[i - 1]]--;
		rankToid[rank] = i;
    }
    vector<int> oldIdToRank(idToRank);
	
    // 排名序列化,之前说明过
    for (int p = 0, i = 1; i <= len; ++i) {
        if (oldIdToRank[rankToid[i]] == oldIdToRank[rankToid[i - 1]]) {
        idToRank[rankToid[i]] = p;
        } else {
        idToRank[rankToid[i]] = ++p;
        }
    }

    for (currentSortLen = 1; currentSortLen < len; currentSortLen <<= 1) {
        // 对第二关键字:idToRank[RankToid[i] + currentSortLen]进行计数排序
        vector<int> cnt(len + 1);
        vector<int> id(rankToid); // id保存一份儿RankToid的拷贝,实质上就相当于oldRankToid
        
        for (int i = 1; i <= len; ++i){
            int secondValue = idToRank[id[i] + currentSortLen];
            cnt[secondValue]++;
        }

        for (int i = 1; i <= cnt.size() - 1; ++i) {
            // 进行前缀和,理由和上面一样
            cnt[i] += cnt[i - 1];
        }

        for(int i = len; i >= 1; i--){
            int secondValue = idToRank[id[i] + currentSortLen];
            int rank = cnt[secondValue];
            cnt[secondValue]--;
            rankToid[rank] = id[i];
        }

        // 对第一关键字:id[i]进行计数排序
        fill(cnt.begin(), cnt.end(), 0);

        id = rankToid;

        for (int i = 1; i <= len; ++i){
            int firstValue = idToRank[id[i]];
            cnt[firstValue]++;
        }

        for(int i = 1; i <= len; i++){
            cnt[i] += cnt[i - 1];
        }

        for(int i = len; i >= 1; i--){
            int firstValue = idToRank[id[i]];
            int rank = cnt[firstValue];
            cnt[firstValue] --;
            rankToid[rank] = id[i];
        }

        // 以上的两次计数排序就完成了一次基数排序
        vector<int> oldidToRank(idToRank);
        for (int p = 0, i = 1; i <= len; ++i) {
            if (oldidToRank[rankToid[i]] == oldidToRank[rankToid[i - 1]] &&
                oldidToRank[rankToid[i] + currentSortLen] == oldidToRank[rankToid[i - 1] + currentSortLen]) {
                idToRank[rankToid[i]] = p;
            } else {
                idToRank[rankToid[i]] = ++p;
            }
        }
    }

    for (int i = 1; i <= len; ++i) printf("%d ", rankToid[i]);
}

根据以上代码的重新整理和总结,简化了许多以及使人更容易看懂。以下是一些注意点:

在进行SA构建的中途idToRank和rankToid是不一定互相映射的,因为rankToId的index作为rank,rank是唯一的,然后idToRank中,如果两者一样,排名也会一样。至于为什么最后还是相互映射的,因为所有的后缀长度不一样,所以必定不会出现相同的情况。

一开始的时候,idToRank并不是真正意义上的rank,实际上包括在过程中,rank的作用只需要是能体现两者之间的大小关系就可以。在每次排完后,会进行一次序列化,就会变成从1开始的排名。

以上代码仅供参考理解,性能方面不敢保证,理解算法本质后可使用他人模板。

常数优化

第二关键字排序优化

第二关键字实际上是偏移后的位置,因为上面的考虑,第二关键字很多时候都是空,也就是等同于0的存在。对于计数排序,在计数完后再取出进行编号就会变得不那么必要了,因为它为0,就必定先出,可以直接进行序列化编号。

优化计数排序的范围

计数排序的缺点在于空间的使用以及需要遍历空间,然而我们说过,一旦出现相同,rank值可以一样,又因为计数排序的容器大小就是被排序元素种类的个数,所以有的时候不一定为len,可以用p获取种类数进行优化。

将 相互映射 保存

因为计算过程中的映射不统一,所以计算中经常出现idToRank[rankToid[i]],可以直接保存这个关系,减少不连续内存访问

用函数替换重复判断

oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]

替换成 cmp(sa[i], sa[i - 1], w)

bool cmp(int x, int y, int w) { return oldrk[x] == oldrk[y] && oldrk[x + w] == oldrk[y + w]; }

若排名都不相同可直接生成后缀数组

如果过程中,排名已经没有出现重复,那么实际上已经排序结束了。

O(N) 参考

SA-IS

可以参考 诱导排序与 SA-IS 算法,另外它的 评论页面 也有参考价值。

DC3

可以参考[2009] 后缀数组——处理字符串的有力工具 by. 罗穗骞

后缀数组的应用

后缀数组通常和子串有关,因为子串实际上就是后缀的前缀。例如**在字符串中找子串,**因为有了后缀数组,可以直接二分去找。或者是利用好单调性,同时关注前缀关键词,有的时候,题目的前缀也就是反串的后缀。

用后缀数组构建height 数组

LCP(最长公共前缀):lcp(i, j) 后缀i和后缀j的最长公共前缀(的长度) height[i] = lcp(sa[i], sa[i - 1])

易得

height[rank[i]] ≥ height[rank[i - 1]] - 1

for (i = 1, k = 0; i <= n; ++i) {
  if (rk[i] == 0) continue;
  if (k) --k;
  while (s[i + k] == s[sa[rk[i] - 1] + k]) ++k;
  height[rk[i]] = k;
}

height 数组应用

两子串最长公共前缀

lcp(sa[i], sa[j]) = min(height[i + 1 ~ j])

区间最值问题

比较一个字符串的两个子串的大小关系

A = S[a~b] , B = S[c~d]

if lcp(a, c) ≥ min(|A|, |B|) A < B = |A| < |B|

else A < B = rk[a] < rk[c]

不同子串的数目

答案是n(n +1) / 2 - sum(height[2:])

出现至少 k 次的子串的最大长度

出现至少k次意味着后缀排序后有至少连续k个后缀以这个子串作为公共前缀。所以,求出每相邻k个的最小值,再求这些最小值的最大值就是答案。

某字符串在文本串中至少不重叠地出现了两次

可以二分目标串的长度|s|,将h数组划分成若干个连续 LCP 大于等于 |s| 的段,利用 RMQ 对每个段求其中出现的数中最大和最小的下标,若这两个下标的距离满足条件,则一定有长度为 |s| 的字符串不重叠地出现了两次。