SA 学习笔记

前言

这是我发布的第一篇博客,从学习SA开始。

基本概念

一些规定

字符串的下标从1开始,n=|S|,记suf(x)表示以s[x]开头的后缀,pre(x)表示以s[x]为结尾的前缀。记suf(x)pre(x)的编号为xlcp(i,j)表示suf(i)suf(j)的最长公共前缀。

后缀数组

后缀数组储存的是关于后缀的一些信息,然后运用这些信息来解决字符串问题。

后缀数组实际上是两个数组sa[],rk[]
sa[i]表示将所有后缀按照字典序排序之后排名第i位的后缀的编号;
rk[i]表示suf(i)的排名。

所以容易发现一个性质:sa[rk[i]]=rk[sa[i]]=i

后缀数组求法

后缀数组求法一般是用倍增,复杂度O(nlogn),当然也有O(n)的做法,但是目前我并没有学习,所以就只在这里写一下倍增的求法。

前置知识

计数排序:我感觉类似于桶排序,将每种元素统计出来储存在cnt[]中,然后求一个前缀和,然后cnt[x]的值就是x的排位。

基数排序:这是针对于有k(k>1)维参数的元素的排序的方法。具体方法是:先按照第k维进行排序,在按照k1维排序,一直进行到第1维。正确性可以感性理解一下:因为越靠后的参数越不重要,所以先对最不重要的排序,然后一层一层的用更重要的参数的排序方法将它覆盖,最终就是正确的排序,代码如下(来自OI-WiKi):

void counting_sort(int p) {
	memset(cnt, 0, sizeof(cnt));
	for (int i = 1; i <= n; ++i) ++cnt[a[i].key[p]];
	for (int i = 1; i <= w[p]; ++i) cnt[i] += cnt[i - 1];
	
	// 为保证排序的稳定性,此处循环i应从n到1
	// 即当两元素关键字的值相同时,原先排在后面的元素在排序后仍应排在后面
	for (int i = n; i >= 1; --i) b[cnt[a[i].key[p]]--] = a[i];
	memcpy(a, b, sizeof(a));
}

void radix_sort() {
	for(int i = k; i >= 1; --i) counting_sort(i);
}

后缀排序

字符串是由一个个字符组成的,其实也可以将这每一个字符都当做这一个字符串的其中一维参数,而将字符串按照字典序排序就可以把它当成将有多维参数的元素进行排序,所以考虑基数排序

但是一维一维的去比较明显复杂度过高,所以考虑倍增优化

先把每一个字符进行排序。然后假设说我们已经将所有w(是2的幂)位字符的子串排序完毕,那么我们考虑把编号为i和编号为i+w子串合并当成一个二元组,然后再对二元组进行基数排序即可,直到排序到所有编号的子串的排名都不相同,也就是排序完毕。具体的过程参见下图(来自OI-Wiki):

当然还可以进行一些常数级别优化。这里记id[i]表示按照第二维排序后排名第i位的后缀,lrk[i]表示只按照第一维排序后后缀i的排名。

  1. 因为在编号nw+1,nw+2,,n的后缀的第二元为空,所以说它们在进行第二位的排序中肯定在排名的前几位,所以现将它们排序放入排名,接下来再对其他排序。代码如下:
for(i = n, p = 0; i > n - w; --i) id[++p] = i;
for(int i = 1; i <= n; ++i)
	if(sa[i] > w) id[++p] = sa[i] - w;
  1. 可以新建一个数组px[],其中px[i]表示按照第二维排序后排名为i的后缀的只按照第一维排序时的排名,即px[i]=rk[id[i]]

  2. 对于完全相等的二元组排名应当是相同的,所以要查重,判断函数如下:

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

整体的板子代码放在下面:(常数优化在代码中有注释)

#include<bits/stdc++.h>
using namespace std;
const int N = 1e6 + 30;

int n, m = 300, sa[N], rk[N], px[N], id[N], cnt[N], lrk[N];
char s[N];

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

int main(){
	int i, p, w;
	scanf("%s", s + 1); n = strlen(s + 1);
	for(i = 1; i <= n; ++i) cnt[rk[i] = s[i]]++;
	for(i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
	for(i = n; i >= 1; --i) sa[cnt[rk[i]]--] = i;
	for(w = 1;; w <<= 1, m = p){
		for(i = n, p = 0; i > n - w; --i) id[++p] = i;
		for(int i = 1; i <= n; ++i)
			if(sa[i] > w) id[++p] = sa[i] - w;
		memset(cnt, 0, sizeof(cnt));
		
		//rk[id[i]]可以理解为在基数排序中的第一维元素,
		//用px[i]将rk[id[i]]存起来是为了使原来不连续的下标id[i]转化为连续的下标i,优化常数。 
		for(int i = 1; i <= n; ++i) cnt[px[i] = rk[id[i]]]++;														 
		for(int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1]; 
		
		//为保证排序的稳定性,此处循环i应从n到1
		//即当两元素关键字的值相同时,原先排在后面的元素在排序后仍应排在后面
		for(int i = n; i >= 1; --i) sa[cnt[px[i]]--] = id[i]; 
		memcpy(lrk, rk, sizeof(rk));

		//对于二元组完全相同的元素,使它们的排名变成相等的(该操作类似于离散化) 
		for(p = 0, i = 1; i <= n; ++i)
			rk[sa[i]] = cmp(sa[i], sa[i - 1], w) ? p : ++p;

		//考虑新的rk数组,若其值域为[1, n]那么每个排名都不同,此时无需再排序。
		if(p == n){
			for(int i = 1; i <= n; ++i) sa[rk[i]] = i;
			break;
		}
	}
	for(int i = 1; i <= n; ++i) printf("%d ", sa[i]);
	return 0;
}

height 数组

这个数组是我认为SA中最有用的部分,对于只用sa[]的题目其实大多数也可以用exKMP之类完成,而有些题目则只能用height求(当然也可以用SAM求,但我还没学...),而且经常结合一些数据结构。

定义height[i]表示suf(sa[i])suf(sa[i1])LCP(最长公共前缀)。

性质: height[rk[i]]height[rk[i1]]1。这个性质我还不太会证明,只能硬记啦。

LCP 求解

lcp(i,j)=mink=rk[i]+1rk[j]height[k]

正确性证明:首先对于排名越远的两个后缀,它们的lcp一定越小(因为是按字典序排序,所以这很显然),而且lcp(sa[i],sa[i+2])一定是lcp(sa[i],sa[i+1])的前缀(因为sa[i+1]sa[i]相似的程度一定比sa[i+2]sa[i]的相似程度高),所以结合height的定义感性理解一下就能得到这个式子。

求法:利用性质求解就行:

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

SA 的基本应用

这里有一个技巧:对于处理多个字符串之间的问题,一般可以将它们接在一起,中间用没有出现过的字符连接起来,当然连接字符也要互不相同。

sa 数组的运用

1、P4051 [JSOI2007]字符加密最小表示法

本题让循环排序使字典序最小,其实就是求最小表示法,用sa数组也可以做,方法就是把S倍长变成SS然后进行后缀排序,从sa[1]开始输出就行。

2、匹配

判断S在模式串T是否出现、求出现的位置,可以先对T进行后缀排序,然后在排序后的后缀之后二分查找即可。

height 数组的基本应用

这一类题主要是询问某个子串出现的次数、相同的子串的最长长度、在多个字符串中找相同的子串等等。下面给出几道题目:

1、UVA1223 Editor(最长相同子串)

height数组的板子题,因为排名越远的两个后缀的lcp肯定越小,所以求完height数组之后取个最大值即可。代码把 SA 模板和height数组模板拼一块儿然后加一行mx=max(mx,k);就行。

2、P2852 [USACO06DEC]Milk Patterns G(多次出现的子串)

因为出现k次,排名距离越近的后缀lcp越近,所以就是求任意排名连续的k个后缀的lcp的最大值。用一个st表或者单调队列维护就行。我用的multiset维护。代码除了模板内容如下:

for(i = 1; i <= n; ++i){
	q.insert(height[i]);
	if(i > k) q.erase(q.find(height[i - k]));
	ans = max(ans, *q.begin());
}

3、P2408 不同子串个数

不同子串的个数就是所有子串的个数减去重复的子串的个数。而子串就是
字符串后缀的前缀,因此可以想到用sa数组,而子串相等,也就是后缀的前缀相等,而两个相等的字符串的子串也一定相等,所以说我们只要找到lcp并把它减掉就行了,因此想到height数组,因为排名越近相同前缀越长,所以只用减去对于后缀sa[i]|suf(sa[i])|height[i]就是它新增加的前面没有出现过的子串的个数,因此答案就是:

ans=i=1nnsa[i]+1height[i]=n(n+1)2i=1nheight[i]

把板子敲上去,然后统计答案即可。代码就不放了。

4、P1117 [NOI2016] 优秀的拆分

因为一个优秀拆分由两部分组成, AA 和 BB ,这两部分结构都是两个连续的相等的子串,因此可以先预处理出出现结构AA(表示两个相等的连续子串)的位置,存在数组a[],b[]中,其中a[i]表示在i处开始的结构AA的数量,b[i]表示在i处结束的AA的数量,那么最后的答案很显然就是:

i=1n1b[i]a[i+1]

所以接下来关键的问题就是如何处理出数组a[],b[]

法一

之前说过字符串后缀的前缀是子串,现在要找到两个连续且相等的子串,其实就是找到两个子串suf(i),suf(j),使lcp(i,j)ij。因此计算height数组枚举即可,复杂度O(n2)

法二

上述方法显然会超时,因此我么考虑对这个算法进行优化。

因为我们只关心在哪里会有AA结构,而不是关心这样的结构有多长,所以说没有必要去对每一个AA去枚举它的位置然后用lcp去判断,这样的话超出lcp长度的子串就在以后还要再枚举判断,因此我们现在考虑在什么样的情况之下会出现AA。因为AA出现是要两个完全相等的子串连接在一起,所以长度至关重要,考虑枚举|A|然后判断。不难发现,如果|A|=len,那么结构AA一定经过两个点,满足这两个点的下标i,j满足len|i,len|j,并且这两个点一定位于两个不同位置的A中。所以说可以让s[i],s[j]向两边扩展,使在扩展的子串中pre(i1)=pre(j1)suf(i)=suf(j)并且让该子串尽可能的长。这时候如果这两个子串在原字符串中相交说明出现AA结构。下图是扩展时的情形:

注意:在本图中LCS(i1,j1)表示最长的满足pre(i1)=pre(j1)的字符串。

siLCS(i1,j1),,si1LCS(i1,j1),si,LCP(i,j),?,,sj1LCS(i1,j1),sj,,sj+LCP(i,j)1LCP(i,j)

如果 |?|>0, 就说明以i,j为关键点无法构成结构AA

|?|0就说明可以构成结构AA,并且容易发现:当第二个A的开头在[LCS(i1,j1),LCP(i,j)]时,就都可以构成结构AA

LCP可以利用height数组套ST表求解,LCS其实也是类似,把字符串翻转后接在原字符串处理一下即可。

接下来剩余最后一个问题:如何统计答案。

因为合法的临界点在一段连续的区间,所以说a[],b[]更改的元素的下标也是一段连续的区间,可以用查分进行统计,但还有一些细节要注意

因为我们现在只要A的长度是len,所以说如果LCP(i,j)或者LCS(i1,j1)大于它应该处于的范围,这一段超出范围的长度我们目前应该先不算,否则会与之后计算重复。因此为了避免重复,我们进行以下操作:

LCP(i,j)=min(LCP(i,j),ji)=min(LCP(i,j),len)

LCS(i1,j1)=min(LCS(i1,j1),(j1)i)=min(LCS(i1,j1),len1)

然后就可以正常进行差分了!

a[ilcs(i1,j1)]++,a[ilen+lcp(i,j)+1];

b[j+lenlcs(i1,j1)1]++,b[j+lcp(i,j)];
本题除去模板部分如下:

scanf("%s", s + 1);
ans = 0ll; p = 0; k = 0; m = 300; n = strlen(s + 1);
s[n + 1] = '#';
for(i = n + 2; i <= 2 * n + 1; ++i) s[i] = s[(n + 1) * 2 - i];
n = n * 2 + 1;
                    
//SA模板
                    
n /= 2;
for(len = 1; len * 2 <= n; ++len){
	for(i = len, j = i + len; j <= n; i += len, j += len){
		lcp = min(LCP(i, j), len); lcs = min(LCP(2 * n + 3 - i, 2 * n + 3 - j), len - 1);
		if(lcs + lcp < len) continue;
		a[i - lcs]++; a[i - len + lcp + 1]--;
		b[j + len - lcs - 1]++; b[j + lcp]--;
	}
}
for(i = 1; i <= n; ++i) a[i] += a[i - 1], b[i] += b[i - 1];
for(i = 1; i < n; ++i) ans += 1ll * b[i] * a[i + 1];
printf("%lld\n", ans); 

5、P3975 [TJOI2015]弦论

考虑 t=0 的时候,跟P2408 不同子串个数 一样,但要多记录一个数组 sum[] ,其中 sum[i]表示以所有排名前 i 的后缀的本质不同的子串的总数,然后找到第一个比 k 大的 sum[p] ,输出 suf(sa[p]) 的前 ksum[p1]+height[p] 位即可。

t=1 的时候自然就不能这么做了,因为相同子串算多个,并且后缀排序是按照字典序排序,所以算排名第 k 位的子串应该一位一位去确定,这样前 p 位相同的字符串在一个连续的区间内,就可以二分 p+1 位应该是什么。还是应该处理出来 sum[] ,但含义变成前 i 个后缀所有子串的数量。先确定第一位,在 [1,n] 二分找到第一位字母应该是 ch,然后令 k=k 以小于 ch 开头的子串的个数,然后在第一位为 ch 的子串的区间内继续二分确定第二位,知道 k 某一位之前相同的子串的数量时结束。具体细节看代码:

if(!opt){
	for(i = 1; i <= n; ++i) sum[i] = sum[i - 1] + n - sa[i] + 1 - ht[i];
	if(pos > sum[n]) cout << -1;
	else{
		p = lower_bound(sum + 1, sum + 1 + n, pos) - sum;
		for(i = sa[p]; i <= n - sum[p] + pos; ++i) cout << s[i];
	}
}else{
	for(i = 1; i <= n; i++) sum[i] = sum[i - 1] + n - sa[i] + 1;
	if(sum[n] < k){
		cout << -1;
		return 0;
	}
	int L = 1, R = n;
	for(i = 1; i <= n; i++){
		tmp = L;
		for(j = 'a'; j <= 'z'; j++){
			l = tmp, r = R;
			while(l <= r){
				mid = l + r >> 1;
				if(s[sa[mid] + i - 1] > j) r = mid - 1;
				else l = mid + 1;
			}
			k = sum[r] - sum[tmp - 1] - (r - tmp + 1) * (i - 1);	//求出区间内不算长度小于i个字符的不同子串数
			if(pos <= r - tmp + 1){									//k <= 前i位相等的数量,说明到这一位结束
				for(j = sa[tmp]; j <= sa[tmp] + i - 1; j++) cout << s[j];
				return 0;
			}
			if(k >= pos){											//如果pos刚好在这位字符为j的时候小于等于k,说明这一位是j
				L = tmp, R = r;										//更改区间范围,确定下一位
				pos -= r - tmp + 1;
				break;
			}
			tmp = r + 1, pos -= k;									//这一位比j大,尝试更大的j
		}
		if(n - sa[L] + 1 == i) L++;									//如果发现排名为L的后缀长度不够了,那么这个后缀就没有用了,因此把它去除
	}
}

6、P6640 [BJOI2020] 封印

题目其实就是多次询问我们一个字符串 s 的一个子串的所有子串与一个固定的字符串 t 的子串的 lcp

lcp 肯定用 height 数组,而且是对一个固定的字符串求 lcp 所以说可以预处理出来每个后缀与 tlcp 的最大值 ,储存在 val[] , 其中 val[i]=maxp=1|t|lcp(i,p) ,而这个最大值一定在 rk[i] 的前驱、后继,所以说把 t 的所有后缀的排名插入到 set 中找前驱后继求最大值即可,这个方法具体可以看下一大块中的第七题。现在进行多次询问,询问一个子串的子串与 tlcp 的最大值,其实就类似于区间查询 val[lr] 的最大值,但其实此时的 val[i] 的值应该会有所改动,变为了 min(val[i],ri+1) ,因此难以用ST表维护,可是如果我们可以让 val[i] 的值小于 rl+1 就可以了,即我们要消除 rl+1 的影响,而这个影响只与它的位置有关,并且答案具有单调性,所以考虑二分答案 lcp=mid ,然后判断 maxi=lrmid+1val[i] 是否 mid ,不断二分即可。

复杂度 O(qlogn+nlogn) 。二分、预处理部分代码如下:

S.insert(0); S.insert(n + 1);
for(i = mid + 2; i <= n; ++i) S.insert(rk[i]);
for(i = 1; i <= mid; ++i){
	pre = *(--S.lower_bound(rk[i])); nxt = *S.lower_bound(rk[i]);
	mx[i][0] = max(lcp(pre, rk[i]), lcp(rk[i], nxt));
}
for(j = 1; j <= 18; ++j)
	for(i = 1; i + (1 << j) - 1 <= mid; ++i)
		mx[i][j] = max(mx[i][j - 1], mx[i + (1 << (j - 1))][j - 1]);
scanf("%d", &q);
while(q--){
	scanf("%d%d", &L, &R);
	l = 0, r = R - L + 1;
	while(l + 1 < r){
		pos = (l + r) >> 1;
	if(get(L, R - pos + 1) >= pos) l = pos;
		else r = pos;
	}
	if(get(L, R - r + 1) >= r) printf("%d\n", r);
	else printf("%d\n", l);
}

height 数组与数据结构结合应用

1、P2178 [NOI2015] 品酒大会,height 数组与并查集结合

k相似,其实就是找lcpk 的后缀,容易想到先预处理出height数组。接下来,还容易观察发现:满足条件的后缀一定是一个个连续的区间,并且随着k的减小,满足条件的后缀单调不降,即将k从大到小枚举时只会从满足条件的区间向外扩展,这种操作就类似于并查集操作。所以维护并查集,将k从大到小枚举,每一次加入满足条件的后缀,然后计算新的对数、最大值即可。

注意:

因为有的美味程度是负数,所以有可能最后的最大值是两个负数相乘得到,所以并查集之内要维护最大值、次大值,最小值、次小值。然后再对没有满足条件的后缀的情况特判一下。

代码如下:

struct node{
  int fa, val, mx1, mx2, mn1, mn2, sz;
}a[N];
priority_queue< pair<int, int> > q;

int getfa(int x){
  if(a[x].fa == x) return x;
  return a[x].fa = getfa(a[x].fa);
}

void merge(int x, int y, int t){
  int fx = getfa(x), fy = getfa(y);
  ans[t] += a[fx].sz * a[fy].sz;
  a[fy].sz += a[fx].sz;
  if(a[fx].mx1 >= a[fy].mx1){
  	a[fy].mx2 = max(a[fy].mx1, a[fx].mx2);
  	a[fy].mx1 = a[fx].mx1;
  }else if(a[fx].mx1 > a[fy].mx2) a[fy].mx2 = a[fx].mx1;
  if(a[fx].mn1 <= a[fy].mn1){
  	a[fy].mn2 = min(a[fy].mn1, a[fx].mn2);
  	a[fy].mn1 = a[fx].mn1;
  }else if(a[fx].mn1 < a[fy].mn2) a[fy].mn2 = a[fx].mn1;
  mx[t] = max(mx[t], max(a[fy].mx1 * a[fy].mx2, a[fy].mn1 * a[fy].mn2));
  a[fx].fa = fy;
}

int main(){

//SA板子

  for(i = 1, k = 0; i <= n; ++i){
  	if(k) k--;
  	while(s[i + k] == s[sa[rk[i] - 1] + k]) k++;
  	ht[rk[i]] = k;
  	q.push(make_pair(k, i));
  }
  ans[n] = 0; mx[n] = -INF;
  for(i = n - 1; i >= 0; --i){
  	ans[i] = ans[i + 1]; mx[i] = mx[i + 1];
  	while(!q.empty() && q.top().first >= i){
  		w = q.top().first, k = q.top().second;
  		q.pop();
  		merge(k, sa[rk[k] - 1], i);
  	}
  }
  for(i = 0; i <= n - 1; ++i) printf("%lld %lld\n", ans[i], mx[i] == -INF ? 0 : mx[i]);
}

2、P4094 [HEOI2016/TJOI2016]字符串,height 数组与主席树结合

首先注意一个关键词:s[ab]一个 子串和s[cd] 这个子串。

这道题让我们对一个区间内部的子串和指定的一个子串去匹配最长子串,我跟之前说过,一般匹配子串的问题都可以转化成求lcp的问题。并且这道题是一系列的子串去匹配一个固定的子串,也就是求在后缀排序后一堆点到定点的lcp的最大值。而我们知道。排名差距越远的后缀的lcp一定越小,所以说,到定点的lcp的最大值一定在后缀排序中它的前驱和后继中。

但是这样并不一定能够保证正确性。因为我们是要求在一个指定区间内的最大值,所以说ansi=min(lcp(i,c),bi+1.dc+1),因此,前驱和后缀的lcp最大,但如果它太靠近b,最终的答案可能比不上其他答案。

因此我么考虑限制i的取值范围,使他不会出现越界的情况,即使每一个可以选择的i的最终答案一定就是lcp(i,c)

可是对于不同的lcp长度,i的取值范围也是不一样的,所以说我们考虑二分答案,对于长度len(0lenmin(dc+1,ba+1)),i的取值范围就是[a,blen+1]

范围有了,但如何在不超时的前提下多次访问不同区间对应的c的前驱和后继呢?

因为要求固定字符串的一个区间内部对于c的前驱和后继,其实也就是求在这个区间中排名第k大和第k+1的后缀的编号(k=区间内部排名在suf(c)之前的后缀的个数)。

求区间第k大,自然就想到了主席树。所以说这道题就是维护一个rk[]值域的主席树,然后二分答案即可。

复杂度O(n+qlog2n)

下面是主席树和二分答案部分的代码:

int build(int l, int r){
	int rt = ++tot;
	if(l < r){
		int mid = (l + r) >> 1;
		ls[rt] = build(l, mid);
		rs[rt] = build(mid + 1, r);
	}
	return rt;
}

int update(int lrt, int l, int r, int p){
	int rt = ++tot;
	sum[rt] = sum[lrt] + 1;
	ls[rt] = ls[lrt]; rs[rt] = rs[lrt];
	if(l < r){
		int mid = (l + r) >> 1;
		if(p <= mid) ls[rt] = update(ls[lrt], l, mid, p);
		else rs[rt] = update(rs[lrt], mid + 1, r, p);
	}
	return rt;
}

int query_sum(int rt, int lrt, int l, int r, int ql, int qr){
	if(ql <= l && r <= qr) return sum[rt] - sum[lrt];
	int mid = (l + r) >> 1, res = 0;
	if(ql <= mid) res += query_sum(ls[rt], ls[lrt], l, mid, ql, qr);
	if(qr > mid) res += query_sum(rs[rt], rs[lrt], mid + 1, r, ql, qr);
	return res;
}

int query_pos(int rt, int lrt, int l, int r, int k){
	if(k > sum[rt] - sum[lrt]) return n + 1;
	if(l == r) return l;
	int mid = (l + r) >> 1, tmp = sum[ls[rt]] - sum[ls[lrt]];
	if(k <= tmp) return query_pos(ls[rt], ls[lrt], l, mid, k);
	else return query_pos(rs[rt], rs[lrt], mid + 1, r, k - tmp);
}

bool check(int val, int l, int r, int p){
	int pre = query_pos(rt[r], rt[l - 1], 1, n, query_sum(rt[r], rt[l - 1], 1, n, 1, rk[p]));
	int nxt = query_pos(rt[r], rt[l - 1], 1, n, query_sum(rt[r], rt[l - 1], 1, n, 1, rk[p]) + 1);
	return max(lcp(pre, rk[p]), lcp(rk[p], nxt)) >= val;
}

int main(){
  
	//SA模板,lg、st预处理
  
  	rt[0] = build(1, n);
	for(i = 1; i <= n; ++i) rt[i] = update(rt[i - 1], 1, n, rk[i]);
	while(T--){
		scanf("%d%d%d%d", &a, &b, &c, &d);
		l = 0, r = min(d - c + 1, b - a + 1);
		while(l + 1 < r){
			mid = (l + r) >> 1;
			if(check(mid, a, b - mid + 1, c)) l = mid;
			else r = mid;
		}
		if(check(r, a, b - r + 1, c)) printf("%d\n", r);
		else printf("%d\n", l);
	}
}

3、 [AHOI2013]差异 ( SA 与单调栈结合)

题意:给出一个字符串,求: i=1n1j=i+1n(ni+1)+(nj+1)2lcp(i,j)

这个式子可以拆成两部分:i=1n1j=i+1n(ni+1)+(nj+1)2i=1n1j=i+1nlcp(i,j) 。先考虑第一个式子,将它化简后变成 n(n1)(n+1)2 。现在主要说一下第二个式子:

要求解 lcp 自然就可以想到用 height 数组。因为枚举 i,j 复杂度太高,所以说我们可以换一个角度去想:去看每一个 height 的值可以是多少对 i,jlcp ,其实也就是在后缀数组中对于 i ,找到两个集合 S,T, 使得 pS,qT 都有 lcp(p,q)=height[i],pi,qi ,并让这两个集合的大小尽可能大,此时这个 height[i] 的贡献就是 2height[i]l[i]r[i] (为了方便记 l[i]=|S|,r[i]=|T| )。因为 lcp(sa[p],sa[q])=minw=p+1qheight[w] ,所以容易发现这两个集合都是后缀排序中连续的一段,而要满足这个条件其实就是让集合内部与 suf(sa[i])lcp 都不小于 height[i] ,可以跑两边单调栈去维护,当然也可以跑一遍,但思维量较大,具体见CF802I Fake News (hard) 中的方法。

综上所述,式子可以变成:

n(n1)(n+1)22i=1nheight[i]l[i]r[i]

时间复杂度 O(nlogn) 。单调栈代码如下:

st.push(n + 1);
for(i = n; i > 1; --i){
	while(st.size() > 1 && ht[i] <= ht[st.top()]) st.pop();
	r[i] = st.top() - i;
	st.push(i);
}
while(!st.empty()) st.pop();
st.push(1);
for(i = 2; i <= n; ++i){
	while(st.size() > 1 && ht[i] < ht[st.top()]) st.pop();
	l[i] = i - st.top();
	st.push(i);
}
for(i = 1; i <= n; ++i) ans -= 2 * ht[i] * l[i] * r[i];

4、P318suf [HAOI2016]找相同字符( SA 与单调栈结合)

本题要对两个字符串进行匹配,可以对两个字符串的每个后缀的每个前缀进行对比判断。容易发现,对于s1的后缀is2的后缀j,只要是lcp(i,j)的子串都可以匹配成功,即ans=i=1|s1|j=1|s2|lcp(i,j) 因此想到height数组,将s2连在s1后边,中间用一个没出现过的字符隔开,求解height数组然后枚举判断。 但是枚举ij复杂度O(n2)会TLE,因此考虑优化。

因为lcp(i,j)min(height[rk[i]+1],,height[rk[j]]),所以求每个后缀为匹配做的贡献就是求在多大的范围之内它对应的height值为最小值,因此容易想到用单调栈去维护。先用s1匹配s2,再用s2匹配s1。现在具体说一下s1匹配s2的具体方法,另一部分类似:

对于值height[i],它可以更新j(满足height[j]<height[i],height[j+1i1]height[i]))之后、i之前的元素。而且j可以匹配到的数量i也可以匹配到(可以把height数组当成一个台阶,height[i],height[j] 是两层台阶的高度,可以匹配到的数量就是台阶的体积,ij高,所以说j以下台阶的体积一定也在i以下),所以ans[i]=ans[j]+height[i](i,js2j)。其中i,j之间属于s2的字符的数量可以用前缀和计算。最后将属于s1的后缀的答案加起来即可,记为ress1。用s2匹配s1的方法相同,只是要用属于s2的后缀去更新答案。求属于s1的后缀的数量的前缀和就行,然后将属于s2的后缀的答案加起来记为ress2。最终答案就是ress1+ress2

单调栈匹配部分代码如下:

for(i = 1; i <= n; ++i){
	f[i] = f[i - 1]; g[i] = g[i - 1];
	if(sa[i] <= mid) f[i]++;
	if(sa[i] > mid + 1) g[i]++;
}
st.push(make_pair(1, 0)); 
for(i = 2; i <= n; ++i){													//s1匹配s2 
	while(st.size() > 1 && ht[i] <= ht[st.top().first]) st.pop();
	k = (g[i - 1] - g[st.top().first - 1]) * ht[i] + st.top().second;		//first是height的下标,second是ans[] 
	st.push(make_pair(i, k));
	if(sa[i] <= mid) ans += 1ull * k;
}
while(!st.empty()) st.pop(); st.push(make_pair(1, 0));
for(i = 2; i <= n; ++i){													//s2匹配s1 
	while(st.size() > 1 && ht[i] <= ht[st.top().first]) st.pop();
	k = (f[i - 1] - f[st.top().first - 1]) * ht[i] + st.top().second;
	st.push(make_pair(i, k));
	if(sa[i] > mid + 1) ans += 1ull * k;
}
printf("%llu", ans);

5、CF802I Fake News (hard)( SA 与单调栈结合——新感悟)

题意:所有本质不同的子串出现次数的平方和。

因为要统计出现次数,容易想到 height 数组,维护一个单调栈统计出来 height[i] 这个长度的贡献,即分别从左往右、从右往左跑两边单调栈找到最大的区间[li,ri]使得这个区间内部所有后缀的 lcp 长度至少为 height[i] ,然后贡献 val=heigh[i](rili+1)2,最终的答案就是:

ans=i=1nheight[i](r[i]l[i]+1)2

但很可惜,这样写答案错的十分彻底。 但为什么会错呢?有两个原因:

1、本题是要求本质不同的子串出现的次数的平方的和,但如果按照上面的式子去计算,从左往右跑单调栈当遇到height[i]height[st.top()],我们会进行st.pop() 操作,这样对于以 suf(sa[i]) 来说它出现的次数+1,贡献计算没有任何问题,而对于 height[st.top()] 来说,在子串长度为 [height[i]+1,height[st.top()]] 时也没有问题,它无法再向右扩展,可是这个时候我们计算的是 height[st.top()](r[st.top()]l[st.top()]+1)2 而并没有把已经统计过的字串长度在 [1,height[i]] 这一部分重复计算的贡献减去,即重复计算了长度较短的子串的贡献。

2、height 表示的是两个字符串之间的关系,也就是说只要是在这里统计上贡献的子串出现的次数一定至少为2次,换句话说,我们少统计了出现次数是1的子串的贡献,所以答案就统计少了。

综上所述:上面的式子统计多了一部分,统计少了一部分,错的不能再错了。

那么如何去把这个式子改对呢?

先去考虑第一个问题:首先要明确一点这道题和一般的题的区别:我们要求的是本质相同的子串出现的个数,也就是说我们是要把每个本质不同的子串当做一部分去考虑,而不能再去将 height 当做一个整体去处理,否则就会有多余计算的情况出现。这道题与 [AHOI2013]差异 这道题是很不一样的,《差异》是要求出每个 height[i] 能够对于多少对 lcp(i,j) 做出贡献,强调的是位置,而不是像本题强调的是本质不同子串出现的次数。也因此我们就不是用 height 数组求贡献,而是以本质不同的子串为一个单位去计算贡献,采取另一种策略:不去考虑每一个 height 这个整体对于答案的贡献,而是去考虑每一种子串对于答案的贡献,即换句话说为了避免重复计算的情况我们可以先找到一个固定的长度,然后把等于这个长度的所有本质相同的子串出现的次数计算出来,计算出所有这个长度的本质不同的子串的贡献,再把各种长度的贡献加起来,这样就不会出现上面那种重复计算短子串贡献的情况。

可是去枚举子串长度相当于把 height 数组的作用完全忽略了,复杂度也肯定不过关,所以应该去考虑优化。

我们想要去利用 height 数组,其实就是想去利用已知的 lcp 的长度,那么如何去利用呢?其实仔细考虑一下我们之前重复计算的部分,发现只要能够判断数来哪一部分是要计算的、哪一部分是它新贡献的就行,而这其实就是当前的 height[i] 减去比左右两边第一个比它低 height[j] 的大小 mx[i] 即可(因为 height[j] 一定会将 height[i] 中的短子串的贡献计算过一遍)。此时出现超过一次的子串的总贡献为:

i=1n(height[i]mx[i])(r[i]l[i]+1)2

如何找到左右两边第一个比它小的 height[j] 呢? 在这里有两种方法:

1、还是跑两边单调栈,在记录区间范围的同时分别记录左、右两边第一个 height 小于它的位置的 height 值然后比较一下大小就行。当然,就像一般的单调栈套路一样,因为会有相等的 height 值出现,但我们既不能两边遍历都将对方 pop 掉,这样仍然会出现问题一;也不能都保留,否则这种子串的出现次数会少算。因此随便定一个方向,让这个方向的遍历取等,另一边不取等就行。

2、考虑单调栈在 pop 时的条件是什么:当 height[i]<height[st.top()] 时进行 pop ,而当我们从左往右扫时这个操作其实也就是让我们找到了 st.top() 右边第一个比它小的 height 值,这个值的位置就是 i ,因此我们可以在进行 pop 操作时再统计当时的 st.top() 的贡献(st.top() 左边第一个比他小的是此时 st.top() 的上一个元素,右边第一个比它小的是 i )。这样只需要进行一次单调栈即可。但还有一点要注意,因为在进行到 height[n] 之后栈内依然有元素,并且这些元素的贡献没有计算,所有要增加一个模拟结束点 n+1 ,并且 height[n+1]=0 ,这样可以保证使所有元素的贡献都计算完毕,并且考虑实际意义:右边没有元素,所以可以到达0长度。

现在来讨论第二个问题:如何处理只出现过一次的子串。我们想要把每个位置开头的只出现一次的子串从中剥离出来其实就是用后缀总长度-最长的出现两次及以上的子串的长度(这个长度只会从在后缀排序中与它相邻的元素中产生),即以 i 开头的子串中只出现一次的子串的个数为: sumi=ni+1max(ht[rk[i]],ht[rk[i]+1]) 。(以上方法为口胡,没有代码验证)。

接下来我们说另一种方法:既然无法剥离,那我们其实就可以先把所有子串都当成只出现过一次,然后再减去重复计算的次数即可。换句话说,就是让 ans 的初始值为 n(n+1)2 ,然后跑单调栈计算贡献。但要注意此时如果一种子串出现了 p 次,新增加贡献就不是 p2,而是 p2p ,这样才能将初始值多增加的这 p 个子串的贡献抵消掉。

到这里这个问题就结束啦,下面给出两种统计贡献的代码(注意: 代码中的 li,ri 不表示可以做贡献的区间范围,而只是为了代码间接,计算方便而定义,真正的区间的范围用代码中 li,ri 的含义表示应该是 [li,ri1]):

//跑两遍单调栈的方法
ht[0] = ht[n + 1] = 0; ans = (n + 1) * n / 2;
while(!st.empty()) st.pop(); st.push(0);
for(i = 1; i <= n; ++i){
	while(st.size() > 1 && ht[i] < ht[st.top()]) st.pop();
	mx[i] = ht[st.top()]; l[i] = st.top();
	st.push(i);
}
while(!st.empty()) st.pop(); st.push(n + 1);
for(i = n; i >= 1; --i){
	while(st.size() > 1 && ht[i] <= ht[st.top()]) st.pop();
	mx[i] = max(mx[i], ht[st.top()]); r[i] = st.top();
	st.push(i);
}
for(i = 1; i <= n; ++i) ans += (ht[i] - mx[i]) * (r[i] - l[i]) * (r[i] - l[i] - 1);
//跑一遍单调栈的方法
while(!st.empty()) st.pop();
st.push(1); ans = n * (n + 1) / 2;
for(i = 2; i <= n + 1; ++i){
	while(st.size() > 1 && ht[i] < ht[st.top()]){
		h = ht[st.top()];
		st.pop();
		lst = ht[st.top()]; len = i - st.top();
		ans += (len * len - len) * (h - max(lst, ht[i]));
	}
	st.push(i);
}

有一道及其类似的题目 CF123D String,思路与本道题完全一样,只是把计算贡献的式子换一下即可。

6、CF204E Little Elephant and Strings( SA 与单调队列结合、多字符串公共子串匹配问题)

多个字符串匹配的问题,按照固定的套路,把所有字符串连接在一起,中间用没有出现过的字符间隔开(注意:这里有一个细节,因为这道题最多会有1e5个字符串,所以最好把字符型转成整型,同时要注意定义个间隔符不要越界!!!!(之前就因为这个问题调了一周...)。然后题目让找相同的字符串,很自然就能想到用height数组,找lcp。如果lcp满足条件那么lcp的子串一定也满足条件,即找出lcp之后它会产生的贡献就是|lcp|。所以说先把板子敲上去。

现在只剩一个问题,就是如何计算每个后缀的贡献:

因为只有当后缀suf(i)出现在k个不同的字符串的时候才能产生贡献,而从lcp求解的方法就能看出来,要求覆盖的区间越大,区间内部的 lcp 就一定越小。因此就可以得出:suf(i)的贡献就是在后缀排序中:所有包含rk[i]的,含有来自k个不同字符串的后缀的,以不同后缀为结尾,长度尽可能小区间的lcp的最大值。其中以不同后缀为结尾的,长度尽可能小的区间的含义是:1、所有满足条件区间[li,ri],ri互不相同;2、任何区间[li,ri]满足[li,ri]中有来自k个不同字符串的后缀(记为条件1)并且[li+1,ri]没有有来自至少k个不同字符串的后缀。通俗一点说就是首先对于后缀排序中的每一个元素,求出来以它结尾的最短的满足条件1的区间并保存,然后在这些区间中找出所有包含rk[i]的区间,找出这些区间中lcp的最大值就是它的贡献。

所有的区间很容易就可以预处理出来,把后缀排序上每个后缀打上一个标记 pos 表示它是哪个字符串的后缀,然后存一个cnt数组记录当前每个字符串的后缀出现了几次。然后把区间的 r 从1到 n 枚举,再不断增大l的数值知道刚好再+1就不满足条件1为止。

但如何求包含后缀 suf(i) 的所有区间的lcp的最大值呢?

因为区间的 l,r 大小肯定是单调不降的,而我们从后缀排序中的排名1的后缀遍历到排名n的后缀的时候下标也是单调递增的,也就是说如果区间 i 不包含排名 p 的后缀,那也一定不包含排名 p+1 的后缀,即要靠后的区间对于以后的后缀作用就越大,这就很像单调队列可以解决的问题,单调队列队首维护 lcp 最大的区间,队列内部 lcp 单调递减,如果队首区间无法覆盖当前后缀就一直 pop 队首,如果队尾 lcp 当前 lcp 就一直 pop 队尾,最后所有覆盖 suf(sa[i]) 的区间的 lcp 的最大值就是队首的 lcp 。这道题到这里就结束了。

这里给出合并字符串,预处理区间和单调队列部分的代码:

//主函数中:

for(i = 1; i <= T; ++i){
	scanf("%s", tmp + 1); p = strlen(tmp + 1);
	for(j = 1; j <= p; ++j) s[++n] = tmp[j], pos[n] = i, len[n] = p - j + 1;
	s[++n] = ('z' + i);
}
s[n--] = ('z' + T);

//模板...

//pos[i]表示suf(i)属于的字符串的编号,T是字符串数量,n是合并所有字符串之后的总长度
for(i = 1; i <= T; ++i) cnt[i] = 0;
//这里T只枚举到n-T+2是因为其余部分都是间隔字符,没有贡献,没必要去算。
for(l = 1, r = 1, p = 0; r <= n - T + 2; ++r){
	if(!cnt[pos[sa[r]]]) p++;
	cnt[pos[sa[r]]]++;
	while(p >= k){
		if(p == k) v.push_back(node(l, r, lcp(l, r))), mx = max(mx, lcp(l, r));
		if(cnt[pos[sa[l]]] == 1){
			if(p == k) break;
			p--;
		}
		cnt[pos[sa[l]]]--;
		l++;
	}
}
p = 0;
for(i = 1; i <= n - T + 2; ++i){
	while(!q.empty() && v[q.front()].r < i) q.pop_front();
	while(p < v.size() && v[p].l <= i){
		while(!q.empty() && v[p].val >= v[q.back()].val) q.pop_back();
		q.push_back(p);
		p++;
	}
	ans[pos[sa[i]]] += v[q.front()].val;
}

7、CF427D Match & Catch SA 与 set 结合

我也不知道 set 是不是数据结构,但就先放到这一块儿

题意:给出两个字符串 s1,s2,要找到这两个字符串的一个公共子串 ss ,使得 i[1,|s1||ss|+1],j[1,|s2||ss|+1] ,满足 s1[ii+|ss|1]ss,s2[jj+|ss|1]ss 并且使得 |ss| 最小。

先看前提条件:是公共子串,因此可以求 height 数组,然后就可以 O(n2) 枚举子串的开头,用ST表算一下也就知道了所有 lcslcs 的前缀就是公共子串。

接下来考虑第二个条件:在同一个字符串内不能出现多次。越长越不容易出现多次,所以先判断每种 lcs 是否合法,如果 lcs(i,j) 不合法,那么以 i,j 开头的所有公共子串肯定都不合法。其实就是说对于以 i 开头的 lcs 不能有 lcp(i,j)lcs ( i,j 在为同一个字符串的下标) 。而我们知道排名越近的两个后缀的 lcp 一定越大,因此只要找到 i 在排名中的前驱后继,比教一下它们与 ilcp 是否比 lcs 就行,这个操作可以用 set 进行。建立两个 set:S1,S2 ,分别插入 s1,s2 的后缀,按照排名排序即可找到前驱后继。

如何找到最短?其实也很简单,只要满足以上两个条件的 ss 中求最小的。可以先考虑让以 i,j 开头的 ss 最小,容易想到 ss 最小就是让它的长度刚好满足要求,即让最大的 lcp+1 即是答案。然后 O(n2) 找每个位置的最小的是 ss 的最小值即可。

因为 set 带一个 log ,最终复杂度为 O(n2logn) 。判断、统计部分代码如下:

S.insert(0); S.insert(n + 1); T.insert(0); T.insert(n + 1);
for(i = 1; i <= mid; ++i) S.insert(rk[i]);
for(i = mid + 2; i <= n; ++i) T.insert(rk[i]);
for(i = 1; i <= mid; ++i){
	for(j = mid + 2; j <= n; ++j){
		w = lcp(rk[i], rk[j]);
		pre = *(--S.lower_bound(rk[i])); nxt = *(++S.lower_bound(rk[i]));
		p = max(lcp(pre, rk[i]), lcp(rk[i], nxt));
		if(p < w){
			pre = *(--T.lower_bound(rk[j])); nxt = *(++T.lower_bound(rk[j]));
			k = max(lcp(pre, rk[j]), lcp(rk[j], nxt));
			if(k < w) ans = min(ans, max(p, k) + 1);
		}
	}
}
if(ans == INF) puts("-1");
else printf("%d\n", ans);

height 数组与其他字符串算法结合应用

1、P3649 [APIO2014]回文串 (SA 与 manacher 结合)

先不考虑回文串这个限制,十分明显可以用 SA 做,求出现次数可以在后缀排序之后二分左右边界,用ST表去判断。求出来这些之后求个存在值的最大值即可,复杂度O(nlogn)

现在要求这个字符串应该是个回文串,自然就想到用 manacher 算法。但如果是每个回文串都算一下复杂度有可能退化到 O(n2logn) ,是会超时的,因此我们应该思考一下 manacher 是如何将寻找回文串复杂度降到 O(n) 的。其实就是对于相同的回文串它会跳过,那我们要找贡献,而相同的回文串贡献也一定是相同的,所以说对于相同的回文串我们也可以跳过,只对于使 r 增大了的回文串进行计算,这样复杂度就变成了 O(nlogn)

但是还有一些细节是需要处理的:因为我们在做 manacher 算法时增加了#字符,而回文串的实际有效长度为 d[i] ,并且这个 d[i] 指的是边界到达 # 时的 d[i] ,而**不是任何时候回文串长度都是 d[i] **,所以只要在边界到达#并且超过 r 时才去更新。具体内容看代码:

void work(int x, int val){
	if((x & 1) == 0) return;		//细节要注意,如果边界不是#就不要更新答案,否则会WA
	x = (x + 1) >> 1;
	int l, r, mid, L, R;
	l = 1, r = rk[x];
	while(l + 1 < r){
		mid = (l + r) >> 1;
		if(lcp(mid, rk[x]) >= val) r = mid;
		else l = mid;
	}
	if(lcp(l, rk[x]) >= val) L = l;
	else L = r;
	l = rk[x], r = n;
	while(l + 1 < r){
		mid = (l + r) >> 1;
		if(lcp(rk[x], mid) >= val) l = mid;
		else r = mid;
	}
	if(lcp(rk[x], r) >= val) R = r;
	else R = l;
	mx = max(mx, 1ll * val * (R - L + 1));
}
int main(){

	//SA板子
	
		for(i = 1; i <= n; ++i){
		tmp[i << 1] = s[i];
		tmp[i << 1 | 1] = '#';
	}
	tmp[1] = '#'; N = (n << 1) | 1;
	for(i = 1, mid = 0, r = 0; i <= N; ++i){
		if(i <= r) d[i] = min(r - i, d[(mid << 1) - i]);
		while(i - d[i] > 1 && i + d[i] < N && tmp[i - d[i] - 1] == tmp[i + d[i] + 1]){
			d[i]++; work(i - d[i], d[i]);			//能进入这个循环一定都超过r
		}
		if(i + d[i] > r){
			work(i - d[i], d[i]);
			r = i + d[i];
			mid = i;
		}
	}
	printf("%lld\n", mx);
}
posted @   _zyc  阅读(182)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
点击右上角即可分享
微信分享提示