字符串专题讲解

最近教练叫我去讲字符串专题,于是来写一写这方面的内容

主要就讲以下几个吧:

1.Kmp 

2.Extended Kmp

3.Trie

4*.AC Automation (Trie Graph)

5*.String Hash

6.Suffix Array

7*.Suffix Automation

8*.Suffix Tree

9.manacher

10*.Palindrome Automation

11*.Suffix Balanced Tree


前面三个会讲得比较简单因为两个kmp不常用(基本可以用hash代替),Trie基本人人都会了

正题开始:

KMP

当时刚刚学的时候觉得它是一种十分神奇的算法,不过自从学了hash以后就再也没怎么打过

kmp基本思想就是重复利用以前处理过的信息

我们需要对c求一个next数组,next[i]表示最大的j使得c[0..j]=c[i-j+1..i]且j<i

假设我们已经有了这个next数组,那么匹配过程就会非常简单

当我们文本串匹配到i,模板串匹配到j时如果失配了,我们就可以将j和next[j]对齐重新开始匹配

为什么?因为我们匹配到了第j位,所以显然有s[i-next[j]..i]=c[j-next[j]+1,j],而又有c[0..next[j]]=c[j-next[j]+1,j]

所以c[0..next[j]]这一段我们不需要重新匹配一次就可以知道它们肯定是配对的

如何求next,这个过程相当于和自己做匹配一样,复杂度可以证明O(n),此处略,下面直接上code了

int nt[N];
void KMP(char* s,char* c,int& t,int* ans){
	int n=strlen(c),m=strlen(s);
	for(int i=1,j;i<n;++i)
		for(j=i;j;)
			if(c[j=nt[j]]==c[i]) { nt[i+1]=j+1; break; }
	for(int i=0,j=0;i<m;++i){
		if(j<n && s[i]==c[j]) ++j;
		else while(j) if(c[j=nt[j]]==s[i]){ ++j; break; }
		if(j==n) ans[++t]=i-n+1;
	}
}

ok,下面讲讲扩展KMP

Extended Kmp

这个东西呢比较鸡肋,所以这里随便说说思路,有兴趣的同学自己去网上搜来学

算法作用:在线性时间内求出一个串对另一个串的每个后缀的最长公共前缀

假设两个串为s和p,要求p和每个s的后缀的最长公共前缀,我们可以先求出p和自己每个后缀的最长公共前缀(记为A)

类似kmp算法,利用前面信息,假设我们正在计算A[i],我们可以找到一个k∈[1..i-1]使得k+A[k]尽可能大

显然有p[1..A[k]]=p[k..k+A[k]-1],于是我们就可以得到p[i..k+A[k]-1]=p[i-k+1..A[k]]

分两种情况

1.若i+A[i-k+1]-1<k+A[k]-1那么A[i]=A[i-k+1]

2.否则,暴力匹配A[i]

复杂度可以证明O(n),此处略,code略(可以自行百度)


Trie

这基本是所有人字符串入门的必修课

Trie非常好理解,其实就是将一堆字符串按位拆开而已

Trie的基本操作应该大家都很熟悉了,这里只是随便讲讲

一开始整颗trie只有一个根,每次插入字符串的时候,都向对应的儿子走,如果儿子为空就新建节点

在字符串结束的那个节点打上标记,查询和删除和插入过程基本一样,就是查询是否有标记和清除标记而已

这里给一个模板(随手打的不要吐槽,这里假设全部字符都是大写字母)

int cnt,c[1000010],ch[1000010][26];
inline void insert(char* s,int x=1){
    for(;*s;++s){
        *s-='A';
        if(!ch[x][*s]) ch[x][*s]=++cnt;
        x=ch[x][*s];
    }
    ++c[x];
}
inline bool find(char* s,int x=1){
    for(;*s;++s){
        *s-='A';
        if(!ch[x][*s]) return 0;
        x=ch[x][*s];
    }
    return c[x];
}
inline void remove(char* s,int x=1){
    for(;*s;++s){
        *s-='A';
        if(!ch[x][*s]) return;
        x=ch[x][*s];
    }
    if(c[x]) --c[x];
}

ok,开始进入真正的正题了


Ac自动机

这是干什么的?当然不是用来自动切题的机器

我们考虑,如果做要对k个模板串做匹配,那么kmp的效率就会低下(O((n+m)k))

但是Ac自动机可以在有k个模板串的情况下保证复杂度为O(n+m)

那么他是如何完成的呢?这里我们就要将上面讲的两个东西结合起来了

首先,我们有很多个字符串,我们考虑将它们插入到一个trie里面

让后拿文本串在这个trie上面查询,我们还是要考虑失配情况

不同的是,这里我们没有办法直接求next数组,所以我们考虑失配时如何操作

我们对于每个节点x,建立一个fail指针,这个fail指针指向trie树的一个节点y,满足Str(y)是Str(x)的后缀(没有满足的就指向根)

这里Str(x)表示从根到x的路径所组成的字符串

那么当我们匹配到x时,如果下一位失配,我们可以立即跳到fail[x]继续匹配(因为str(y)和str(x)的后半段相等)

是不是和kmp很像?

现在我们需要考虑如何建立fail指针

我们发现,对于一个节点x,他的fail指针如果不是根,那么一定是他的父亲节点father[x]的fail的一个子节点,或者是fail[fail[father[x]]的一个子节点,或者是。。。一直到root为止

于是我们考虑用bfs:

对于一个节点x,枚举每个儿子son[x][c],我们看fail[x]是否有儿子c

如果有那么fail[son[x][c]]=son[fail[x]][c],否则令x=fail[x]直到x=root,这时fail[son[x][c]]=root

好的到这里,我们发现一个问题:我们每次去求fail[son[x][c]]的时候呢,我们都要跑一条‘fail’链

这样很显然是有些浪费的(虽然这样写,最后复杂度也是O(n)的,但是常数稍大)

所以我们考虑做一件这样的事(其实就是将Ac自动机建成Trie图)

因为我们失配的条件是son[x][str[i]]=NULL,那么这样很显然是比较浪费的

我们不如将其直接指向son[fail[x]][str[i]],这样就可以简化x=fail[x]的过程,直接继续匹配就好了

注意如果son[root][c]=NULL那么我们要将其变成root,即s[root][c]=root(显然,因为fail[root]=root)

下面放一张图给大家体会一下两者的区别


看到了吗,如果我们匹配00,那么走到左边那个蓝色节点时,按照fail指针会先走去根再走下来

但是如果是trie图就直接不用走上去了,直接省略了这一步,这样大大简化了代码也提高了匹配效率

这里再说一下匹配的具体过程:

每次从根开始,匹配到一个节点x后,我们沿着它的fail指针检查这些节点是否有被标记为结尾节点(这一步不可省略,因为如果节点x被匹配成功,那么x沿fail一直走的路径上的所有节点也会匹配成功,所以都要统计)让后令x=son[x][str[i]](这里因为我们建立了Trie图,所以不会有任何节点的son指向NULL,最多只会指向root)

这里应该给一些题目了,经典题目就有HDU2222 和HDU2896都是模板题

这里讲一讲hdu2896,题意就是给你n个模板串和m个文本串,要求出每个文本出现了哪些模板串

非常的简单,建立Trie图直接跑就好了,非常推荐用数组板Trie图的写法,因为比ac自动机还简单

#include<queue>
#include<stdio.h>
#include<string.h>
#include<algorithm>
#define N 100010
using namespace std;
char s[N];
int fail[N],son[N][128],c[N],cnt=1,n,T=0;
inline void insert(char* s,int pos){
    int x=1;
    for(;*s;++s)
        x=son[x][*s]?son[x][*s]:son[x][*s]=++cnt;
    c[x]=pos;
}
inline void build(){
    queue<int> q;
    for(int i=0;i<128;++i)
        if(!son[1][i]) son[1][i]=1;
        else q.push(son[1][i]),fail[son[1][i]]=1;
    for(int x;!q.empty();q.pop()){
        x=q.front();
        for(int i=0;i<128;++i)
            if(!son[x][i]) son[x][i]=son[fail[x]][i];
        else q.push(son[x][i]),fail[son[x][i]]=son[fail[x]][i];
    }
}
inline bool query(char* s){
    bool vis[510]={0},v=0;
    for(int x=1;*s;++s){
        x=son[x][*s];
        for(int j=x;j!=1;j=fail[j])
            if(c[j]) vis[c[j]]=v=1;
    }
    if(!v) return 0;
    printf("web %d:",T);
    for(int i=1;i<=n;++i) 
        if(vis[i]) printf(" %d",i);
    puts(""); return 1;
}
int __18520(){
    if(scanf("%d",&n)<0) return 0; 
    for(int i=1;i<=n;++i){
        scanf("%s",s); insert(s,i);
    }
    build(); int ans=0,m; scanf("%d",&m); 
    for(T=1;T<=m;++T){
        scanf("%s",s); ans+=query(s);
    }
    printf("total: %d\n",ans); return cnt=1;
}
int main(){ while(__18520()); }

Hash
为什么这么迟才讲?因为这个东西非常重要!比上面那些加起来都有用!

这个东西是字符串中的三大神器之一(另外两个是后缀自动机和后缀平衡树)

是这样的,我们回到一个最简单的问题,单模板串匹配

不是已经讲了KMP了吗,为什么要讲这个呢?当然是这个东西好写啊

我们考虑暴力匹配,复杂度是O(nm),为什么这么慢呢,主要问题就是在比较上,要O(m)的时间

假如我们可以O(1)比较不就好了吗,对!我们可以使用哈希

我们将一段字符串看成一个k进制数,这里k是字符集的大小

那么我们现在考虑如何快速求出一段字串对应的这个数,我们可以用前缀和

我们令hash(l,r)表示字串[l,r]的哈希值,那么显然hash(l,r)=hash(1,r)-hash(1,l-1)*k^(r-l+1)

而hash(1,r)和k^(r-l+1)可以用前缀和预处理,我们令h[i]=hash(1,i),显然h[i]=h[i-1]*k+str[i]

注意这里我们是不能存下这个数的,所以要取模或者让其自然溢出,当然是自然溢出好啦

让后我们就可以得到一个非常简单的模板

#define BASE 27
#define LL long long
char s[N]; LL h[N],bas[N];
LL gH(int l,int r){ return h[r]-h[l-1]*bas[r-l+1]; }
void init_hash(){
	for(int i=1;i<=n;++i){ 
		bas[i]=bas[i-1]*BASE;
		h[i]=h[i-1]*BASE+s[i]-'a';
	}
}
是不是可以和你的kmp比短了,我们可以立即写一个匹配的code,只需要一个循环,每次比较hash(i,i+n)和模板串的hash值是否相等就好了,这里略

好了我们马上就可以将其实现扩展kmp的功能

因为我们要的是求最长公共前缀,所以我们对两个字符串都预处理出hash值,每次枚举开头,让后二分就好了,复杂度n lg n

你可能会说这个效率不如扩展kmp啊,但是如果你喜欢打扩展kmp那我也没办法咯

附上一个二分求lcp的完整代码:

#include<stdio.h>
#include<string.h>
#include<algorithm>
#define N 1000010
#define L long long
using namespace std;
char s[N]; int n;
L ans=0,b[N]={1},h[N];
inline L gH(int l,int r){ return h[r]-h[l-1]*b[r-l+1]; }
int dijk(int x){
	int l=-1,r=n-x;
	for(int m;l<r;){
		m=l+r+1>>1;
		if(gH(1,m+1)==gH(x,x+m)) l=m;
			else r=m-1;
	}
	return l+1;
}
int main(){
	freopen("gene.in","r",stdin);
	freopen("gene.out","w",stdout);
	scanf("%s",s+1); n=strlen(s+1);
	for(int i=1;i<=n;++i){
		b[i]=b[i-1]*27;
		h[i]=h[i-1]*27+s[i]-'a';
	}
	for(int i=1;i<=n;++i) ans+=dijk(i);
	printf("%lld\n",ans);
}
好了我们来讲讲比较难的题目吧

spoj1811:LCS

这个题我们讲SA和SAM时还会用到,不过这里先讲讲hash的做法

题意:给你两个字符串A,B求最长公共子串

注意这个不是那个dp题,O(n^2)是肯定过不了的

我们需要考虑如何在O(n lg n)的时间内完成

考虑二分答案:我们每次二分出一个Len

对于每个i∈[1,n-Len],对于A求出hash(i,i+Len)并存在一个数组里面,记为A',用类似的方法求出B'

让后我们看A'和B'有没有相同的数,如果有说明LCS>=Len 否则LCS<Len

这样就是O(n lg^2 n)的

诶,因为spoj太慢了上不去,网上也没找到用这个方法来写的code,这里就不放code了,下一个专题


后缀数组 Suffix Array

这个东西嘛本来是一个神器,不过当后缀自动机出来以后就没那么神了

感觉也是比较鸡肋,和下面的sam比起来嘛,复杂度高,功能还不如它强大,而且还容易写错,呃。。。

网上流传的方法,一般就是两种复杂度,倍增算法O(n lg n),dc3算法O(n)

但是我还会三种其他复杂度的算法,分别是:直接快排O(n^2 lg n) 直接基排O(n^2) 和带hash的快排O(n lg^2 n)

个人最推荐带hash的快排,好写,常数小,下面口胡一下

我们发现直接快排的问题就在于比较的代价最坏是O(n)的,所以我们要加速比较,这时候就可以用哈希

还记得我们用哈希求两个字符串的lcp吗,我们对于两个后缀x和y,用二分求出它们lcp的长度,让后比较下一位的大小就可以得出两个后缀的大小了

因为二分做一次复杂度是O(lg n)的,快排本身要n lg n所以复杂度就是O(n lg^2 n),给个code吧

#define BASE 27
#define LL long long
char s[N]; LL h[N],bas[N]; int sa[N],r[N],H[N];
LL gH(int l,int r){ return h[r]-h[l-1]*bas[r-l+1]; }
void init_hash(){
	for(int i=1;i<=n;++i){ 
		bas[i]=bas[i-1]*BASE;
		h[i]=h[i-1]*BASE+s[i]-'a';
	}
}
inline bool cmp(int x,int y){
	int l=-1,r=n-max(x,y);
	for(int m;l<r;){
		m=l+r+1>>1;
		if(gH(x,x+m)==gH(y,y+m)) l=m;
		else r=m-1;
	}
	return s[x]<s[y];
}
void buildSa(){
	init_hash();
	for(int i=1;i<=n;++i) sa[i]=i;
	sort(sa+1,sa+1+n,cmp);
	for(int i=1;i<=n;++i) r[sa[i]]=i;
	for(int i=1,j,k=0;i<=n;H[r[i++]]=k){
		if(k) --k;
		if(r[i]>1) for(j=sa[r[i]-1];s[i+k]==s[j+k];++k);
	}
}
这里说一下那个H数组,这个就是大名鼎鼎的height数组

height[i]表示的是Sa[i]和Sa[i-1]的最长公共前缀(即两个排名相邻的后缀的lcp)

这里我们说一下如何计算height[i],首先有一点是显然的,height[r[1]]=0且height[rank[i]]>=height[rank[i-1]]-1

所以我们只需要按照height[rank[1]],height[rank[2]]...的顺序计算即可

目前我见过的题目里面,似乎只有一道是必须要用SA来做的(而且我还没切掉),其他都可以用SAM,所以这里先不放题目,如果想练习一下,可以试试做上面那个LCS,这个题本来就是应该用SA做的

下面准备开始飙车了,Sam就要出场了!

Suffix Automation 后缀自动机

这是个啥?我们要先说说自动机

有限状态自动机(FSM "finite state machine" 或者FSA "finite state automaton" )是为研究有限内存的计算过程和某些语言类而抽象出的一种计算模型。有限状态自动机拥有有限数量的状态,每个状态可以迁移到零个或多个状态,输入字串决定执行哪个状态的迁移。有限状态自动机可以表示为一个有向图。有限状态自动机是自动机理论的研究对象。

有限状态自动机是具有离散输入和输出的系统的一种数学模型。

其主要特点有以下几个方面:

– (1)系统具有有限个状态,不同的状态代表不同的意义。按照实际的需要,系统可以在不同的状态下完成规定的任务。

– (2)我们可以将输入字符串中出现的字符汇集在一起构成一个字母表。系统处理的所有字符串都是这个字母表上的字符串。

– (3)系统在任何一个状态下,从输入字符串中读入一个字符,根据当前状态和读入的这个字符转到新的状态。

– (4)系统中有一个状态,它是系统的开始状态。

– (5)系统中还有一些状态表示它到目前为止所读入的字符构成的字符串是语言的一个句子。

形式定义

· 定义:有限状态自动机(FA—finite automaton)是一个五元组:
– M=(Q, Σ, δ, q0, F)
· 其中,
– Q——状态的非空有穷集合。∀q∈Q,q称为M的一个状态。
– Σ——输入字母表。
– δ——状态转移函数,有时又叫作状态转换函数或者移动函数,δ:Q×Σ→Q,δ(q,a)=p。
– q0——M的开始状态,也可叫作初始状态或启动状态。q0∈Q。
– F——M的终止状态集合。F被Q包含。任给q∈F,q称为M的终止状态。

上面其实是一堆废话,我们引用CLJ的大白话来解释一下

有限状态自动机的功能是识别字符串,令一个自动机A,若它能识别字符串S,就记为A(S)=True,否A(S)=False。

自动机由五个部分组成,alpha:字符集,state:状态集合,init:初始状态,end:结束状态集合,trans:状态转移函数。

如果trans(s,ch)这个转移不存在,为了方便,不妨设其为null,同时null只能转移到null。

那么自动机A能识别的字符串就是所有使得trans(init,x) ⊂end的字符串x,令其为Reg(A)。
从状态s开始能识别的字符串,就是所有使得trans(s,x) ⊂end的字符串x,令其为Reg(S)

好了,这里就可以给出后缀自动机的定义了:

字符串S的后缀自动机suffix automaton(以后简记为SAM)是一个能够识别S的所有后缀的自动机

所以有SAM(x)=true 当且仅当x为S的后缀,同时也可以看出后缀自动机也可以用来识别S的所有子串

那我们要开始考虑如何实现Sam了,首先最暴力的方法,肯定是将整个字符串的后缀插入到一个Trie里面,这样复杂度是O(n^2)的

所以我们要建立一个最简形式的自动机,使得其复杂度是O(n)的

我们的构造算法是Online的,也就是从左到右逐个添加字符串中的字符。依次构造SAM。

这个算法实现相比后缀树来说要简单很多,尽管可能不是非常好理解。

让我们先看一下性质:

状态s,转移trans,初始状态init,结束状态集合end。
 母串S,S的后缀自动机SAM(Suffix Automaton的缩写)。
 Right(str)表示str在母串S中所有出现的结束位置集合。
 一个状态s表示的所有子串Right集合相同,为Right(s)。
 Parent(s)表示使得Right(s)是Right(x)的真子集,并且Right(x)的大小最小的状态x。
 Parent函数可以表示一个树形结构。不妨叫它Parent树

 一个Right集合和一个长度定义了一个子串。

 对于状态s,使得Right(s)合法的子串长度是一个区间,为[Min(s),Max(s)]
 Max(Parent(s)) = Min(s)-1。
 SAM的状态数量和边的数量,都是O(N)的。
 那么如果s出发有标号为x的边,那么Parent(s)出发必然也有。
 同时,对于令f=Parent(s),
 Right(trans(s,c)) ⊆ Right(trans(f,c))。
 有一个很显然的推论是Max(t)>Max(s)

复制不动了,我们直接来看code理解一下吧qwq

char str[N];  
int s[N][26],mx[N],f[N],sz[N];  
int last=1,cnt=1,n,v[N],r[N],ans=0;   
inline int extend(char c){  
    int p=last,np=last=++cnt,q,nq;  
    c-='a'; mx[np]=mx[p]+1; sz[np]=1;  
    for(;p&&!s[p][c];p=f[p]) s[p][c]=np;  
    if(!p) return f[np]=1;  
    q=s[p][c];  
    if(mx[p]+1==mx[q]) f[np]=q;  
    else {  
        nq=++cnt;  
        mx[nq]=mx[p]+1;  
        f[nq]=f[q]; f[q]=f[np]=nq;  
        memcpy(s[nq],s[q],26<<2);  
        for(;p&&s[p][c]==q;p=f[p]) s[p][c]=nq;  
    }  
}  
int build(){  
    scanf("%d%s",&n,str);  
    for(int i=0;i<n;++i) extend(str[i]);  
    for(int i=1;i<=cnt;++i) ++v[mx[i]];  
    for(int i=1;i<=n;++i) v[i]+=v[i-1];  
    for(int i=cnt;i;--i) r[v[mx[i]]--]=i;  
    for(int p,i=cnt;i;--i) sz[f[r[i]]]+=sz[r[i]];   
}  
学习SAM的话,如果不想花很多时间去看懂那个建立的过程证明,还是将模板记下来,学学具体怎么使用

假设我们已经建好了SAM,现在要考虑一下如何使用

首先我们可以对文本串建立SAM来跑字符串匹配,当然这方面,SAM远不如Ac自动机

好的,我们先来看一道难(shui)题  (注意这里的题大部分都可以用SA解决,方法可以自行思考)

tyvj1515:给出一个字符串:求出它不同子串的个数

首先说一下SA的做法吧:Answer=n*(n-1)/2-Σheight[i]

显然子串总数为n*(n-1)/2,而height[i]表示了有多少个子串是重复的,所以直接减去就好了

让后自动机呢?也很简单,Answer=Σmx[i]-mx[f[i]]

其实如果是后缀自动机呢,只需要记下来每个节点的属性是什么,就可以灵活运用了

对于一个节点,有一下几个基本节点:

1.mx表示这个节点的深度,也就是代表这个节点所表示的字符串的长度

2.size表示这个节点所代表的字符串的出现次数

3.parent表示这个节点的父亲节点,但由于Sam不是严格的树形结构,所以这个parent是“上一个可以接受后缀的节点,而不是真正的父亲节点”

让后就很简单了,比如我们可以考虑如何统计一下一个字符串中,出现次数*长度最大的子串

很简单啊,先预处理出size和mx,让后答案就是max{mx[i]*size[i]}了

比如我们还可以计算一个指定的子串的出现次数

这里用倍增算法,我们对parent所组成的一棵“树”做倍增就好了,一直向上跳,直到一个节点满足mx[x]>=len而且mx[f[x]]<len,这个x的size就是答案

配合manacher,后缀自动机还可以完成回文串的相关问题,一会讲了manacher再说

现在来考虑一下上面提到的,LCS的问题,怎么用后缀自动机完成?

其实也非常简单,我们对A做出自动机,让后用B上面跑,跑到一个节点x跑不动了就跳到f[x]那里,让后答案就是最大的mx[x]

显然这样是对的,因为f[x]包含了x的right集合

那如果要求n个字符串的LCS呢?

也很简单,直接记录每个节点最多匹配到几个串,取最大就好了

------------------------------------------------分割线------------------------------------------------------------

好了先休息一下吧

至于例题,多得数不胜数,去看看我的后面几篇文章吧,这里不列举了

------------------------------------------------分割线------------------------------------------------------------

后缀树 Suffix Tree

终于到这里了,SAM的坑就当是填上了吧233333

这里先说一下后缀Trie,很容易理解,就是将字符串的每个后缀插入到一个Trie里面,让后就可以搞很多东西了

然而这东西是O(n^2)的,非常不吼

我们画个图或者随便想想都知道,有很多的路径都是只有一条分支的,这些部分如果能压缩起来就非常好了

于是我们有了Ukkonen算法:点这里

但是这里我们是肯定不会去花时间讲它的,因为比较复杂而且比较慢,不如SAM优秀

接下来这里就讲讲如何用SAM构建后缀树

我们构建SAM后,每个节点都有一个f,那么这就可以构成一棵树,不妨叫他parent树

同时,一个非常惊人的结论:SAM的parent树,和反串的后缀树,是一个东西

具体证明?没有,不过这里可以大概解说一下为什么这样大概是对的

我们考虑自动机上一个节点x,它到根的路径上,一定是原串的一个子串

而他的f节点所代表的那个串,一定是x代表串的后缀,所以如果反过来,那么f[x]在后缀树里面的节点就是x的前缀了,所以这样是可以的(看不懂就跳过去,这里不是很重要。。。)

好了,有了这个性质,我们就可以O(n)构建后缀树了,这要怎么用呢?

其实用处还挺多的,比如我们看一道题:

给出字符串x求它第k大的子串(重复的算一次)

SAM要做肯定很简单吧,建出SAM,求出size[x]=Σsize[son[x][i]] (注意这个size和上面提到的size并不是同一个东西),让后类似于平衡树那样去找就好了

好了现在将题目升级,多次查询第k大的子串?

哈哈哈哈上面的方法不管用了!因为SAM的深度最多为O(n)

那怎么办呢?

我们将反串parent树跑出来,构成后缀树

让后对这个树求dfs序,让后就可以二分了,为什么?

因为dfs序就是SA啊!height就是dfs序里面两个点的LCA啊!

剩下的就可以自己意会了吧,这三者的关系已经非常明显了

回文专题开始!


Manacher

这个东西。。。。。。。

好像有点。。。。。。。

不管了先说说回文串,从名字易得它的定义

现在来考虑如何乱搞

最简单的方法,直接枚举中间点和长度,让后乱搞。。。

当然还有奇怪的方法,正反一个hash让后二分,nlgn 已经很优秀了23333

回到正题,我们还是考虑如何正常求回文串

(可能有些同学以前接触到的manacher是将字符串长度加倍让后每个字符之间插入#的做法,这里不采用这种方法)

假设我们已经求出了前i-1个位置作为回文中心的回文串长度,不妨记为len[1..i-1],那么我们要如何快速求len[i]呢

一个很重要的思想:利用以前的信息

我们可以找到一个len[x]+x最大的x,让后分情况讨论

如果len[x]+x>i,那么len[i]至少为min(len[x]+x-i,len[2*x-i]),否则至少为0

因为如果x作为对称中心,且len[x]+x>i,那么显然len[i]和len[i关于x的对称点]应该是相同的,当然这里还要考虑len[x]+x的限制,所以len[i]=min(len[x]+x-i,len[2*x-i])

当然上面说的是至少,求出答案那就必须要继续匹配一下了,当然这样匹配效率就很高了,最坏也是O(n)的,这也是任何字符串题目的时间下限(因为读入至少就要O(n)了)

证明?没有,但是各位可以手玩一下一个样例:aaaaaaaa让后你就能体会到为什么他是O(n)的了

当然,manacher的好处不仅仅是比较快,它还有一个重要的性质:可以说明一个串中,本质不同的回文串也是O(n)的,这点哈希就没法做到,当然具体要求出有多少还要靠下面的PAM

有了manacher,配合自动机我们就可以做这道题了:APIO2014回文串(做不出来可以看我的博客有题解)

当然解决回文串最好的方法还是,PAM

回文自动机 Palindrome Automation

非常好,终于快结束了

回文自动机其实比后缀自动机还要简单,我们和后缀自动机一样也跳过它的构建过程,而说明一下它的性质和作用

每个节点x有以下属性:
1.len[x]表示x所代表的回文串长度,
注意,一个节点到根的路径上的字符串并不是这个回文串而是这个回文串的一半

2.son[x]表示x的儿子

3.fail[x],也可以叫做father[x]表示x的最小扩充集

4.size[x]表示x的回文串出现次数

而且为了便于分析,我们的PAM有两个根,0和1,一个代表长度为偶数的回文串,一个奇数的回文串,且f[0]=1

下面给出code:【Tsinsen】A1280. 最长双回文串

#include<stdio.h>
#include<string.h>
#include<algorithm>
#define N 300010
using namespace std;
char S[N];
int n,m=0,cnt=1,lst=0,g[N],ans=0;
int s[N][26],mx[N]={0,-1},sz[N],f[N]={1};
inline int extend(int c){
	int p=lst,np;
	for(++m;S[m-mx[p]-1]!=S[m];p=f[p]);
	if(!s[lst=p][c]){
		np=++cnt; mx[np]=mx[p]+2;
		for(p=f[p];S[m-mx[p]-1]!=S[m];p=f[p]);
		f[np]=s[p][c]; lst=s[lst][c]=np;
	} else lst=s[p][c]; ++sz[lst];
}
int main(){
	scanf("%s",S+1); n=strlen(S+1);
	for(int i=1;i<=n;++i) extend(S[i]-'a'),g[i]=mx[lst];
	memset(s,0,sizeof s); cnt=1; lst=0; m=0; reverse(S+1,S+1+n);
	for(int i=1;i<=n;++i) extend(S[i]-'a'),ans=max(ans,mx[lst]+g[n-i]);
	printf("%d\n",ans);
}
是的!代码非常简短,比SAM少了一半!

让后上面那个APIO2014的题目也可以这样做了

CODE:

#include<stdio.h>
#include<string.h>
#include<algorithm>
#define N 300010
#define LL long long 
using namespace std;
char S[N]; LL ans=0;
int n,m=0,cnt=1,lst=0;
int s[N][26],f[N]={1},sz[N],mx[N]={0,-1};
inline int extend(int c){
	int p=lst,np; 
	for(++m;S[m-mx[p]-1]!=S[m];p=f[p]);
	if(!s[lst=p][c]){
		np=++cnt; mx[np]=mx[p]+2;
		for(p=f[p];S[m-mx[p]-1]!=S[m];p=f[p]);
		f[np]=s[p][c]; lst=s[lst][c]=np;
	} else lst=s[p][c]; ++sz[lst];
}
int main(){
	freopen("palindrome.in","r",stdin);
	freopen("palindrome.out","w",stdout);
	scanf("%s",S+1); n=strlen(S+1);
	for(int i=1;i<=n;++i) extend(S[i]-'a');
	for(int i=cnt;i;--i){
		sz[f[i]]+=sz[i];
		ans=max(ans,(LL)sz[i]*mx[i]);
	}
	printf("%lld\n",ans);
}
ok,剩下的,后缀平衡树,最后一个啦
后缀平衡树 Suffix Balanced Tree

应该是所有后缀数据结构中最强大的吧,但是其实非常好理解

简单的话,一句话就可以概括:用平衡树动态维护后缀数组,支持在开头插入或者删除

当然怎么正确实现就是非常讲究的了

先来讲一个非常好的实现方法

我们上文提到了用哈希比较两个字符串让后排序构建后缀数组的方法

在这里,我们依然可以沿用这个方法,比如我们可以配合STL让后非常巧妙地完成一些简单的题目

你有一个字符串S,开始为空,现在有两种操作: 
1. 在S后面加入一个字符c 
2. 删除S最后一个字符(保证进行该操作时S不为空) 
每次操作后输出当前S中有多少个不同的连续子串。

这个如果没有插入和删除就是非常简单的SA模板题,答案就是Σi-height[i]

让后考虑加上插入的情况,因为set可以支持求前继和后续,所以可以用哈希实现快速维护height数组

让后因为是在末尾插入,而后缀平衡树不支持在结尾插入,所以要将整个串反过来

code点这里

好了,现在我们发现一个问题:比如有人将这道题数据范围改成了50W

而50W的话,我们用哈希实现的后缀平衡树是O(n lg^2 n)的,显然就卡不过了

现在考虑怎么优化

我们发现,插入一个后缀,比较的无非就是第一个字符,或者是已经在树中的两个后缀

如果我们能做到O(1)比较,就可以避免浪费时间

所以问题变成了这样:如何做到在O(1)的时间内比较两个节点的先后顺序?

一个非常好的方法就是:我们给每个节点一个排名值,是一个实数,每次插入之后,新的节点的排名值为前继+后续的排名值/2

这样,每次比较排名值,就可以快速得到答案

但是这样是有巨大问题的,精度不够!

我们可以按顺序插入,如果这样,每个节点的排名值会非常小,很快就精度爆炸

所以我们需要利用平衡树的性质,每个节点的排名值变为一个区间[l,r],而且必须严格维护,这样精度就不会爆炸

但是就有另一个问题了,平衡树需要旋转,而一次旋转会导致整颗子树的排名值改变,最坏情况就是O(n)的

所以我们需要用到一类叫做重量平衡树的平衡树,这个东西的准确定义是,每次旋转,收到影响的子树,大小是严格/期望/均摊O(lg n)的(这个东西保证了复杂度不会太坏),或者这种结构本身并不需要旋转来保持平衡

重量平衡树常用的有以下几种:
旋转类:

1*.Treap

2.红黑树(极不推荐因为我也只是听说他是重量平衡的,OI没什么人打)

Treap可谓平衡树中的神器,好写,常数小,支持分裂合并,还可以可持久化

关于treap的重量平衡:

当一个节点x旋转到k的位置后,发生重构的代价为O(size[k])

但是x可以旋转到k的概率是O(1/size[k]),所以期望代价是O(1)

非旋转类:

1.跳表

2*.替罪羊树

第二个东西非常重要,因为这个东西应该是所有平衡树里面速度最快的(我也不知道为什么)

而且非常适合用来维护一些奇奇怪怪的信息,因为它用重构来保持平衡,维护信息完全不需要任何额外代价,其效率是其他任何平衡树都不可以比的

当然这个东西不好写,所以一般都写treap,实在不行再用它来卡时间

关于上面那道题,用正版的后缀平衡树写的,确实比我的山寨版快,大概只要1/3的时间(才快3倍还是set给力啊)


好了,所有内容到这里就讲完了,完结撒花

大家可以随便翻翻后面几篇文章,大都是字符串的例题

有什么问题欢迎指出,可以在下方评论区参与讨论!

                                                                                               ——————扩展の灰

猜你喜欢

转载自blog.csdn.net/jacajava/article/details/78636487