zoukankan      html  css  js  c++  java
  • 后缀数组(SA)

    SA 的用处(经典题型):

    • 求LCP,LCS,本质不同子串数

    • 出现 xxx 次子串,字符串中不重叠地出现至少两次的最长串长度

    • 最长AA式子串, 连续的若干相同子串(AABB式)


    首先说一下,本篇博客是本人在oi-wiki学习后缀数组是写下的笔记,更详细的证明等见oi-wiki_SA

    后缀数组主要包含两个数组:(sa[ ])(rk[ ])

    (sa[i])表示所有后缀中字典序第i小的那个后缀。

    (rk[i])表示 (i) 后缀的字典序排名。

    求后缀数组

    其中倍增求 (SA) 的代码如下(使用基数排序):

    #include <iostream>
    #include <algorithm>
    #include <cmath>
    #include <cstdio>
    #include <cstring>
    #include <string>
    #define N 2000010
    template<typename T> inline void read(T &x) {
        x = 0; char c = getchar(); bool flag = false;
        while (isdigit(c)) {if (c == '-') flag = true; c = getchar(); }
        while (isdigit(c)) {x = (x << 1) + (x << 3) + (c ^ 48); c = getchar(); }
        if (flag)   x = -x;
    }
    using namespace std;
    char s[N];
    int sa[N], rk[N], oldrk[N], id[N], p, n, w, limi;
    int bin[N];
    int main() {
        scanf("%s", s + 1);
        n = strlen(s + 1);
        for (register int i = 1; i <= n; ++i)   ++bin[rk[i] = s[i]];
        for (register int i = 1; i <= 300; ++i)   bin[i] += bin[i - 1];
        for (register int i = n; i > 0; --i)    sa[bin[rk[i]]--] = i;
        limi = 300;//注意是limi不是p,因为for一开始时不会执行limi = p
        for (w = 1; w < n; w <<= 1, limi = p) {
        	//处理id 
        	p = 0;						//注意p = 0 
        	for (register int i = n; i > n - w; --i)	id[++p] = i;//注意"i"
        	for (register int i = 1; i <= n; ++i) 
        		if (sa[i] > w)	id[++p] = sa[i] - w;//注意"sa[i] - w"   注意"sa[i]"
        	
        	//处理sa 
        	memset(bin, 0, sizeof(bin));
        	for (register int i = 1; i <= n; ++i)	++bin[rk[id[i]]];
        	for (register int i = 1; i <= limi; ++i)	bin[i] += bin[i - 1];
        	for (register int i = n; i > 0; --i) sa[bin[rk[id[i]]]--] = id[i];
        	
        	//处理rk(去重) 
        	memcpy(oldrk, rk, sizeof(rk));
        	p = 0;						//注意p = 0 
        	for (register int i = 1; i <= n; ++i)//注意"i - 1"  注意"oldrk" 
        		rk[sa[i]] = (oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) ? p : ++p;
                //注意 + w
           	if (p == n)	break;//小优化:如果已经排好序了,就不用再排了。
        }
        
        for (register int i = 1; i <= n; ++i)   printf("%d ", sa[i]);
        return 0;
    }
    

    例题:

    字符加密

    后缀数组的height数组

    (height[i]) 表示 (sa[i]) 后缀和 (sa[i - 1]) 后缀的 (lcp) (最长公共前缀)。

    求height数组

    我们发现一个规律:

    (height[rk[i]] >= height[rk[i - 1]] - 1)

    证明感性加玄学(详见oi-wiki_SA):

    假设i - 1的height为5,那么

    (O(n)) 代码如下:

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

    至于为什么是 (O(n)) 是因为可以证明 (k--) 的次数是有限的,最多 (n) 次,所以最多加 (2n) 次。

    应用

    求两子串的最长公共前缀

    • 题意简述:

    给定一小写字母组成的字符串,形如 (abababcabcabc),规定 (s[l][r])(l~r)间的子串,如 (s[2][5] = baba)

    多次询问,每次给出两组 (l),(r),求各自 (lcp)

    (len <= 100000, 1 <= l <= r <= len)

    • 分析与解

    首先要知道,(lcp(sa[i], sa[j]) = min(height[i...j])),感性证明一下,就是从 (i)(j) 都没变,那就是他们的 (lcp) 了。

    这样,我们已知 (lcp(i, j))(lcp(l...r, l'..r')) 就用子串长度和 (lcp(i, j)) 取最小者即可。

    通过ST表,可以把复杂度降为 (O(nlogn +q))

    比较子串的字典序大小

    求完后缀数组后,我们能 (O(1)) 地比较两后缀的大小,现在要比较子串大小,只需把 (lcp(A,B) >= min(|A|, |B|)) 的情况特判掉即可。

    (sa,rk,height) 数组后过于简单,故不赘述。

    求本质不同的子串数

    我们按所有后缀以字典序从大到小的顺序考虑。子串数就是后缀的前缀数,这个好求,关键是求出多算的那些相同的子串数。

    排序后第 (i) 名串与第 (i - 1) 名串的重叠部分长度为 (lcp(sa[i], sa[i - 1])),这里多算了 (lcp(sa[i], sa[i - 1])) 个子串,把他们减去即可。

    至于只考虑相邻后缀的正确性,据说可以用 (lcp(sa[i], sa[j]) = min(height[i...j])) 来证明。

    long long ans = (n * (n + 1)) >> 1;
    for (register int i = 2; i <= n; ++i) {
    	ans -= height[i];//height[i] = lcp(sa[i], sa[i - 1]);
    }
    

    求一个子串的字典序排名

    例如,我们求 ABABABDBABA 的字典序排名。

    我们可以先求出SA和height数组,然后查询 BABA 所在的后缀 BABABD。然后之前的后缀的前缀字典序一定比它小。因此直接统计一下本质不同的子串数量(len - height),再加上B,BA,BAB 比它小,就好了。

    不过有时候BABABD后缀的 height 可能比4大,这意味着前面所有 BABA... 的串的字典序都比 BABA 大。因此我们直接二分找到最靠下的第一个不包含 BABA 的后缀,这及之前的那些子串一定比 BABA 小,再加上 B,BA,BAB 即可。

    求出现至少 (k) 次的最长子串长度

    经典例题:牛奶模式

    • 题意简述

    给定一字符串,求出现至少 (k) 次的最长子串长度。

    (len <= 20000)

    • 分析与解

    把子串转换为后缀的前缀,如果有几个相同的子串,那么在排序后的后缀中应该为相邻的一部分,且它们的 (lcp) 一定大于等于子串长度。所以出现至少出现 (k) 次的最长子串长度一定是在排序后连续至少 (k) 个的 (lcp) 的最大值。然后就变成滑动窗口,用单调队列维护即可

    当然,既然求 SA 都到达了 (O(nlogn)),那么二分答案的那个 (log) 也不算什么了。

    (Code): my record

    字符串中不重叠地出现至少两次的最长串长度

    注意到答案具有单调性,故考虑二分。

    二分长度 (len),对于排序后的后缀,找到 (lcp) (>=) (len) 的连续的几段,它们一定出现了至少两次,且长度大于等于 (len)

    现在要判断有没有不重叠的两个子串。我们贪心地找出每段中最靠前的那个子串和最靠后的那个子串,如果这两个都重叠,那就不可行,缩小 (len),否则扩大 (len)

    加强版:不重叠出现至少k次的最长串长度

    对于这种不重叠问题,较为常见的方法是根据子串在原串中的位置来判断

    同样二分答案,将同一块内所有子串的编号(位置)排个序,然后又知道了串长,可以知道子串的起始位置和终止位置。然后就相当于“选择最多不交线段”的问题了,可以贪心解决。

    复杂度 (O(nlog^2n)),但是第二个 (log) 非常小。

    有个 (O(nlogn)) 的做法:我们把同一个块内的所有子串在原字符串中打上标记,然后在原串上扫描一遍,贪心选取每种串即可。这样是稳定 (O(nlogn)) 的。

    例题:4097. 【GDOI2015】短信加密

    最长AA式子串

    枚举len,每len个点放一个点,则重复子串一定跨越两个点。对于相邻点对,求lcp和lcs,判断。

    my record

    AABB式的子串个数

    看一下例题吧:优秀的拆分

    题解的图非常好:题解【P1117优秀的拆分】

    这里给出关键部分代码:

    int array[N];//array[i]:从i开始向右延伸的"AA"个数 
    int dearray[N];//dearray[i]:从i开始向左延伸的"AA"的个数
    inline void calc(int pos, int len) {
    	int lcp = A.get_lcp(pos + 1, pos + len + 1);
    	if (lcp > len - 1)	lcp = len - 1;
    	int lcs = B.get_lcp(n - (pos + len) + 1, n - pos + 1);
    	if (lcs > len)	lcs = len;
    	if (lcs + lcp < len)	return ;
    	int chonghe = lcs + lcp - len;
    	array[pos - lcs + 1]++;
    	array[pos - lcs + 2 + chonghe]--;
    	dearray[pos + len + lcp - chonghe]++;
    	dearray[pos + len + lcp + 1]--;
    }
    ...
    for (register int i = 1; i <= (n >> 1); ++i) {
    	for (register int j = i; j + i <= n; j += i) {
    		calc(j, i);
    	}
    }
    

    还是看图理解吧。

    AxxxA式(xxx的个数已知)的子串个数

    照样和前两种模型的解决方法类似:枚举A串的长度 (len),每 (len) 个字符中放一个点。我们的任务是查询第一个A(长度为len)包含某一个点的AxxxA式的串有多少个。

    设当前点为(l),则令(r = l + g + len),其中 (g) 表示给定的 (x) 的个数,并求出其lcp,lcs(与len取min)然后就要自己动手画图了。推出该AxxxA串的左端点的区间为 ([l - lcs + 1, l + lcp - 1 - len + 1]),即 ([l - lcs + 1, l + lcp - len]),这样的话合法的xxx的个数为 (lcp + lcs - len)。记得答案对0取max(非负)。

    my record

    结合各种数据结构

    • 结合并查集

    典型例题:品酒大会

    不说了,满眼都是泪。

    (Code): my record

    • 结合单调栈

    通常用来求一个字符串中相同子串对数。

    例题:差异找相同字符

    需要注意的地方

    • 注意什么时候用 (rk),什么时候用 (s)。把 (rk),(sa),(s) 分清楚,SA这块的调错就问题不大了。

    • 如果有多组数据,记得把数组清空一下。需要清空的数组有:(rk,s,sa,height,id,oldrk,bin);以及(p,w,limi)。总之都清空就问题不大了。

    • 注意求 (height) 时是 (s[i + k] == s[sa[rk[i] - 1] + k]),注意不要把-1搞到rk[]里面!!注意是 (sa[rk[i]-1]) 而不是 * * * * !!!!

    • 求height的最后是height[rk[i]] = k!!

    • 注意 (forforfor) 中处理 (rk) 时是 (oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]),是 + !!!!

    • 利用SA求lcp,用st表时,一定要在height数组上做St表,不能转化到字符上!! 因为不是求字符间最小值,是求height数组最小值,有些性质字符不符合的!!!不要耍小聪明!!!

    小结

    以下需要记忆

    • 求sa,求height

    • 在基数排序时,(sa) 一直是一对一的,但不够准确;(rk) 是多对一的,所以有 (bin[rk[id[i]]]++),但它是越来越分散的,最后变成一对一。

    • (height[rk[i]] <= height[rk[i - 1]] - 1)

    • (lcp(sa[i], sa[j]) = min(height[i...j]))

    SA 模板(调试用)

    inline void build() {
        scanf("%s", s + 1);
        n = strlen(s + 1);
        for (register int i = 1; i <= n; ++i)   ++bin[rk[i] = s[i]];
        for (register int i = 1; i <= 300; ++i)   bin[i] += bin[i - 1];
        for (register int i = n; i > 0; --i)    sa[bin[rk[i]]--] = i;
        limi = 300;//注意是limi不是p,因为for一开始时不会执行limi = p
        for (w = 1; w < n; w <<= 1, limi = p) {
            //处理id 
            p = 0;                      //注意p = 0 
            for (register int i = n; i > n - w; --i)    id[++p] = i;//注意"i"
            for (register int i = 1; i <= n; ++i) 
                if (sa[i] > w)  id[++p] = sa[i] - w;//注意"sa[i] - w"   注意"sa[i]"
    
            //处理sa 
            memset(bin, 0, sizeof(bin));
            for (register int i = 1; i <= n; ++i)   ++bin[rk[id[i]]];
            for (register int i = 1; i <= limi; ++i)    bin[i] += bin[i - 1];
            for (register int i = n; i > 0; --i) sa[bin[rk[id[i]]]--] = id[i];
    
            //处理rk(去重) 
            memcpy(oldrk, rk, sizeof(rk));
            p = 0;                      //注意p = 0 
            for (register int i = 1; i <= n; ++i)//注意"i - 1"  注意"oldrk" 
                rk[sa[i]] = (oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) ? p : ++p;
                //注意 + w
        }
    	for (register int i = 1, k = 0; i <= n; ++i) {
    	    if (k)  k--;//height[rk[i]] >= height[rk[i - 1]] - 1;
    	    while(s[i + k] == s[sa[rk[i] - 1] + k]) ++k;
    	    height[rk[i]] = k;
            //注意rk
    	}
    }
    

    st表的构建与lcp的查询 模板(调试用)

    inline void build_st() {
      for (register int i = 1; i <= n; ++i)	f[i][0] = height[i];
      for (register int j = 1; j <= 18; ++j) {
          for (register int i = 1; i <= n; ++i) {
              if (i + (1 << (j - 1)) <= n)
                  f[i][j] = min(f[i][j - 1], f[i + (1 << (j - 1))][j - 1]);
              else	f[i][j] = 0;
          }
       }
    }
    inline int query(int l, int r) {
    	l = rk[l], r = rk[r];
    	if (l > r)	swap(l, r);
    	++l;
    	int jibie = Log2[r - l + 1];
    	return min(f[l][jibie], f[r - (1 << jibie) + 1][jibie]);
    }
    

    记忆方法

    • rk 问位置返回排名, sa 问排名返回子串位置!!

    • rk 一开始不是 1~n,到最后才是 1~n!而 sa 无论何时都是 1~n 的!!

    • 每次基排都是以rk为关键字

    • for (register int i = 1; i <= n; ++i) if (sa[i] > w) id[++p] = sa[i] - w; (基排第一步) 因为每次是根据后面排前面,所以要按照后面排名的次序枚举,然后把前面加入到id数组里面!!

    • rk[sa[i]] = (oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) ? p : ++p; (去重部分,易错点) ①当然是根据 (sa) 来确定 (rk),总不能单用字符位置就确定 (rk) 吧。 ②一定要和前一名比较!不然就会出现第0名的情况。 ③ 要根据 oldrk 来比较!!不然前面就白干了。不然不觉得 (memcpy()) 那一步没用吗?不觉得整条语句太短了吗?

    • while(s[i + k] == s[sa[rk[i] - 1] + k]) ++k; 牢牢记住 (s[sa[rk[i] - 1] + k]) !即前一名往后k位的字符!!有了这个不难知道我们是在以下标(位置)的次序求 height!!因此最后为:height[rk[i]] = k; 另外,当 (k = 0) 的时候,我们其实在比较第1位。因此不用质疑那一个 (while()),也不要最后写成 k - 1!此外,虽然 (height[1] = 0) 是肯定的,但是我们是按照位置的次序求的,因此一开始不要初始化 i 为 2!要初始化为 1

    • 最后再记一下 w < n


    (Updated) (on) (Dec.9th)

  • 相关阅读:
    初步学习next.js-1-新建项目
    对象比较-深层,浅层
    制作右键菜单
    使用高德API-初级应用
    启动前后端连载方法
    使用websocket
    关于图片压缩
    归并排序(mergesort)
    冒泡排序
    递归介绍
  • 原文地址:https://www.cnblogs.com/JiaZP/p/13501432.html
Copyright © 2011-2022 走看看