Lyndon分解学习小记

参考资料:

https://www.luogu.com.cn/blog/wucstdio/solution-p6127

https://oi-wiki.org/string/lyndon/

因为有参考资料所以这里不写详细。


Lyndon串:如果一个串是Lyndon串,当且仅当所有后缀中字典序最小的串是这个串本身。

另一个定义:所有循环同构串中字典序最小的串是这个串本身。两者等价。

Lyndon分解:将一个字符串\(s\)分解成\(s_1s_2\dots s_k\),满足\(s_1\ge s_2\ge \dots\ge s_k\),并且\(s_i\)为Lyndon串。Lyndon分解存在且唯一


引理1:如果有两个串\(u,v\)为Lyndon串,且\(u<v\),则\(uv\)为Lyndon串。

引理2:如果存在串\(v\)和字符\(c\)满足\(vc\)为某个Lyndon串的前缀,则如果有字符\(d>c\)\(vd\)为Lyndon串。

这里有证明:https://www.luogu.com.cn/blog/wucstdio/solution-p6127


Duval 算法

现在把字符串分成三个部分:\(S_1S_2S_3\)

其中\(S_1\)已经完成Lyndon分解,\(S_2\)为Lyndon近似串,即可以分解成:\(www\dots wv\)\(w\)为Lyndon串,\(v\)\(w\)的前缀。

有三个指针\(i,j,k\),其中\(S_1=[1,i-1],S_2=[i,k-1]\)\(j=k-|w|\)

现在尝试将\(s_k\)加入\(S_2\)中,考虑指针的变化:

  1. 如果\(s_k=s_j\),则\(j\)\(k\)分别后移。
  2. 如果\(s_k>s_j\),根据引理2\(vs_k\)为Lyndon串。再根据引理1,它要和前面的若干个\(w\)合并。也就是说\(S_2s_k\)合并成一个Lyndon串,成为新的\(S_2\),即\(j\leftarrow i\)。(此时只是表明它们合并成了Lyndon串,并不表明它们已经分解好,所以没有归到\(S_1\)
  3. 如果\(s_k<s_j\),把\(S_2\)的前缀(若干个\(w\))丢入\(S_1\)中,留下\(v\)\(i\)位置变更,\(k\leftarrow i+1\)\(j\leftarrow i\)

具体见代码(洛谷模板):

using namespace std;
#include <bits/stdc++.h>
#define N 5000005
int n;
char s[N];
int q[N],cnt;
int main(){
	freopen("in.txt","r",stdin);
	scanf("%s",s+1);
	n=strlen(s+1);
	int i=1;
	while (i<=n){
		int k=i+1,j=i;
		while (k<=n && s[k]>=s[j]){
			if (s[k]==s[j])
				++j;
			else
				j=i;
			++k;
		}
		while (i<=j){
			q[++cnt]=i+k-j-1;
			i+=k-j;
		}
	}
	int ans=0;
	for (int i=1;i<=cnt;++i)
		ans^=q[i];
	printf("%d\n",ans);
	return 0;
}

时间复杂度分析:

首先\(i\)最多增加\(O(n)\)。考虑最后那个while\(i\)要变化成什么,最后一定是\(j<i\le k\)

转化成这样的问题:有一个队列,有个指针\(k\),一开始在位置\(1\)。每次指针会往后移动一位。你可以在任意时刻,把长度至少\(\frac{k}{2}\)的前缀截掉,并将\(k\)移到剩下的队列的位置\(1\)。问整个队列都被截掉为止,指针最多会移动多少次。

可以这么想:一开始势能为\(n\),截掉一个长度为\(l\)的前缀增加\(l\)的势能。于是至多只会移动\(2n\)次。

oi-wiki上说内层循环次数不超过\(4n-3\)。它说的循环和我算的大概不是一个东西,但总之就是\(O(n)\)

进一步思考:

  1. 在进行算法时,如果遇到\(s_k=s_j\),称其为2情况;如果遇到\(s_k>s_j\),称其为3情况;特殊地,如果有一个刚刚重置的\(i\),把它视作\(k\),称其为1情况。对于一个\(k\),如果经历了一次3情况,那么它不会再次经历1情况和2情况和3情况。

    解释:遇到3情况时,此时\(S_2=w=s[i,k]\)\(i\)不变的时候,这个\(k\)不可能再被遍历到;\(i\)如果变了,一定会变到大于等于\(k+1\)的位置(指这里的\(k\)而不是那时的\(k\))。

  2. \(S_2=www\dots wv\),其中\(v\)也有可能会由\(w'w'w'\dots\)组成(即存在一个循环前缀)。所以在\(i\)变化后,需要将\(k,j\)重置。

  3. Lyndon串的前缀不一定是Lyndon串。设这个Lyndon串为\(s\),取前缀\(s[1,d]\)。此时可能存在位置\(x\),满足\(s[x,d]<s[1,d]\),此时一定有\(s[x,d]\)\(s[1,d]\)的前缀。

(下面例题中的代码中有有关性质的测试)


例题

hdu6761 Minimum Index

题意:给出一个字符串,对于每个前缀求最小后缀的位置。\(n\le 10^6,\sum n\le 2*10^7\)

上一年多校的时候遇见的,当时rush了一个SAM做法,挂掉了。半年后再看,当时rush的SAM写了好多错误的细节啊……

SAM做法:考虑一个子串\(s[l,r]\)可以贡献到\(r\)的答案当中。建SAM之后,按照转移边按字典序从小到大遍历SAM(遍历过的节点不再遍历),把这个节点在fail树上的子树打标记。打标记暴力打,如果存在了标记就不打。时间\(O(n)\),有\(26\)的小常数。本地跑官方数据大概10s。

using namespace std;
#include <cstdio>
#include <cstring>
#include <algorithm>
#define N 1000010
#define mo 1000000007
#define ll long long
int n;
char s[N];
struct Node{
    Node *c[26],*fail;
    int len;
} d[N*2],*S,*T;
int cnt;
Node *newnode(){++cnt;memset(&d[cnt],0,sizeof(Node));return &d[cnt];}
void insert(int ch){
    Node *nw=newnode();
    nw->len=T->len+1;
    Node *p=T;
    for (;p && p->c[ch]==0;p=p->fail)
        p->c[ch]=nw;
    if (!p)
        nw->fail=S;
    else{
        Node *q=p->c[ch];
        if (q->len==p->len+1)
            nw->fail=q;
        else{
            Node *clone=newnode();
            memcpy(clone->c,q->c,sizeof q->c);
            clone->fail=q->fail;
            clone->len=p->len+1;
            for (;p && p->c[ch]==q;p=p->fail)
                p->c[ch]=clone;
            nw->fail=q->fail=clone;
        }
    }
    T=nw;
}
int id[N];
struct EDGE{
    int to;
    EDGE *las;
} e[N*2];
int ne;
EDGE *last[N*2];
int fa[N*2];
int cov[N*2];
bool vis[N*2];
void cover(int x,int l){
    if (cov[x])
        return;
    cov[x]=l;
    for (EDGE *ei=last[x];ei;ei=ei->las)
        cover(ei->to,l);
}
void dfs(int x,int s){
    if (vis[x])
        return;
    vis[x]=1;
    if (x!=S-d)
        cover(x,s);
    for (int i=0;i<26;++i)
        if (d[x].c[i])
            dfs(d[x].c[i]-d,s+1);
}
int main(){
	freopen("in.txt","r",stdin);
//	freopen("out.txt","w",stdout);
    int Q;
    scanf("%d",&Q);
    while (Q--){
        scanf("%s",s+1);
        n=strlen(s+1);
        cnt=0;
        S=T=newnode();
        for (int i=1;i<=n;++i)
            insert(s[i]-'a'),id[i]=T-d;
        ne=0;
        memset(last,0,sizeof(EDGE*)*(cnt+1));
        for (int i=2;i<=cnt;++i){
            fa[i]=d[i].fail-d;
            e[ne]={i,last[fa[i]]};
            last[fa[i]]=e+ne++;
        }
        memset(cov,0,sizeof(int)*(cnt+1));
        memset(vis,0,sizeof(bool)*(cnt+1));
        dfs(S-d,0);
        ll ans=0;
        for (int i=1,pw=1;i<=n;++i,pw=(ll)pw*1112%mo){
            int x=i-cov[id[i]]+1;
            printf("%d ",x);
//            (ans+=(ll)x*pw)%=mo;
        }
//        printf("\n");
        printf("%lld\n",ans);
    }
    return 0;
}

重新看这题的时候尝试想SA做法,想出个常数比较大的,而且不够优(根据后面对Lyndon分解的分析可以对此进行大幅度优化),所以不讲。

【Lyndon分解做法1】

首先进行Lyndon分解。询问前缀\(s[1,d]\)的最小后缀,首先这个后缀的开头一定在\(d\)所在Lyndon串(记为\(s[h,d]\))内。如果有后缀\(s[x,d]<s[h,d]\),那么一定有\(s[x,d]\)\(s[h,d]\)的前缀。

考虑对于每个Lyndon串分别处理。对于一个Lyndon串\(s[h,t]\)来说,\(s[x,\dots]\)可以贡献的\(d\)\([x,x+LCP(s[h,t],s[x,t])-1]\)中。求串中所有后缀和整个串的LCP,这就是exkmp中的\(next\)数组。跑个exkmp。然后扫一遍,用个单调栈来维护后面哪些区间贡献是多少。

时间\(O(n)\)。然而实测大概和上面SAM做法差不多,10s?

using namespace std;
#include <bits/stdc++.h>
#define N 1000005
#define mo 1000000007
#define ll long long
#define fi first
#define se second
#define mp(x,y) make_pair(x,y)
int n;
char s[N];
int p[N],cnt;
int a[N];
void doit(char s[],int a[],int n,int os){
	static int nxt[N];
	nxt[0]=n;
	int pos=0,mr=0;
	for (int i=1;i<n;++i){
		nxt[i]=(i<mr?min(nxt[i-pos],mr-i):0);
		while (i+nxt[i]<n && s[nxt[i]]==s[i+nxt[i]])
			++nxt[i];
		if (mr<nxt[i])
			mr=nxt[i],pos=i;
	}
	a[0]=0+os;
	static int st[N];
	int tp=1;
	st[1]=0;
	for (int i=1;i<n;++i){
		while (tp && st[tp]+nxt[st[tp]]<i+nxt[i])
			--tp;
		st[++tp]=i;
		while (tp && st[tp]+nxt[st[tp]]<=i)
			--tp;
		a[i]=st[tp]+os;			
	}
}
int main(){
	freopen("in.txt","r",stdin);
	freopen("out.txt","w",stdout);
	int T;
	scanf("%d",&T);
	while (T--){
		scanf("%s",s+1);
		n=strlen(s+1);
		cnt=0;
		int i=1;
		while (i<=n){
			int k=i+1,j=i;
			while (k<=n && s[k]>=s[j]){
				j=(s[k]==s[j]?j+1:i);
				++k;
			}
			while (i<=j){
				p[++cnt]=i;
				i+=k-j;
			}
		}
		p[cnt+1]=n+1;
		for (int i=1;i<=cnt;++i)
			doit(s+p[i],a+p[i],p[i+1]-p[i],p[i]);
		ll ans=0;
		for (int i=1,w=1;i<=n;++i,w=(ll)w*1112%mo)
			(ans+=(ll)a[i]*w)%=mo;
		printf("%lld\n",ans);			
	}
	return 0;
}

【Lyndon分解做法2】

如果暴力对\(s\)的每个前缀做Lyndon分解,那么最后一个Lyndon串显然就是答案。那么有没有增量构造的Lyndon分解算法?

至少我想不出来。但尽管不是想象中的增量构造,还是有“伪”增量构造方法:

\(a_i\)表示前缀\(s[1,i]\)的最小后缀。跑一个普通的Lyndon分解,在算法中加入几句话:

\(i\)刚刚被重置时,\(a_i\leftarrow i\)。(记作1情况)

\(s_j=s_k\)时,\(a_k\leftarrow a_j+k-j\)。(记作2情况)

\(s_j<s_k\)时,\(a_k\leftarrow i\)。(记作3情况)

解释:1情况,\(s[1,i]\)最后一个Lyndon串是\(s[i,i]\);2情况,因为\(S_2=www\dots wv\)\(v\)\(w\)的前缀,\(a_k\)应该在\(v\)中,因为\(j\)\(k\)相当于在\(w\)中同样的位置,\(a_j\)也应该在\(v\)中,两者等价,可以继承;3情况,\(s[i,k]\)成为一个Lyndon串。

时间\(O(n)\)。常数极小,可以通过。

可能存在的问题:

  1. \(a_k\)不一定是\(v\)的开头。因为如果单对\(v\)分解,有可能还是会分成多个Lyndon串,因为Lyndon串的前缀不一定是Lyndon串。
  2. 由于\(i\)重置过后\(j,k\)都需要被重置,所以某个\(a_k\)可能会被赋值多次。然而每次赋的值都是一样的。
using namespace std;
#include <bits/stdc++.h>
#define N 1000005
#define mo 1000000007
#define ll long long
int n;
char s[N];
int a[N],b[N],c[N];
int main(){
	freopen("in.txt","r",stdin);
	freopen("out.txt","w",stdout);
	int T;
	scanf("%d",&T);
	while (T--){
		scanf("%s",s+1);
		n=strlen(s+1);
		memset(a,0,sizeof(int)*(n+1));
		memset(b,0,sizeof(int)*(n+1));
		int i=1;
		while (i<=n){
			int k=i+1,j=i;
			if (a[i])
				assert(a[i]==i);
			static int tim;
			if (b[i]==3)
				printf("fuck\n");
			a[i]=i,b[k]=1;
			while (k<=n && s[k]>=s[j]){
				if (s[k]==s[j]){
					if (a[k])
						assert(a[k]==a[j]+k-j);
					if (b[k]==3)
						printf("fuck\n");
					a[k]=a[j]+k-j,b[k]=2;
					++j,++k;
				}
				else{
					if (a[k])
						assert(a[k]==i);
					++tim;
					if (b[k]==3)
						printf("fuck\n");
					a[k]=i,b[k]=3;
					j=i,++k;
				}
			}
			i=k-(j-i)%(k-j); 
//			while (i<=j)
//				i+=k-j;
		}
		ll ans=0;
		for (int i=1,w=1;i<=n;++i,w=(ll)w*1112%mo)
			(ans+=(ll)a[i]*w)%=mo;
		printf("%lld\n",ans);			
	}
	return 0;
}
posted @ 2021-03-28 07:54  jz_597  阅读(162)  评论(0编辑  收藏  举报