字符串简陋入门

字符串笔记

前言

我的字符串也不是很好,如果有错欢迎指出。

一些约定和定义

如无特殊说明,都参考这里。

\(n\) 表示字符串长度(只有一个字符串或者存在一个字符串是所有字符串的母串)

\(s\) 表示一个字符串,具体指代需要联系上下文,\(s_i\)\(s[i]\) 表示 \(s\) 中的第 \(i\) 个字符(从 \(1\) 开始计数)

\(len(s)\)\(|s|\) 字符串 \(s\) 的长度

\(s[k:]\) 表示 \(s\) 从第 \(k\) 个字符开始的后缀

\(s[:k]\) 表示 \(s\) 到第 \(k\) 个字符的前缀

\(s[l:r]\) 表示 \(s\)\(l\)\(r\) 的字符串

\(ab\) 表示把字符串 \(b\) 接到字符串 \(a\) 后形成的字符串

\(s^x\) 表示 \(x\)\(s\) 拼在一起形成的串

\([p]\) 表示命题 \(p\) 为真时为 \(1\),为假时为 \(0\)

\(\text{lcp}(s,t)\) 表示字符串 \(s\)\(t\) 的最长公共前缀

\(|\Sigma|\) 表示字符集的大小,也就是不同的字符数

Hash

字符串hash

可以在 \(\Theta(n)\) 的时间内预处理,\(\Theta(1)\) 比较两个字符串是否相同。

显然,每一位相同的两个字符串才是相等的,但这样比较复杂度是 \(\Theta(|s|)\),不够优秀。如果我们把字符串的比较转化为数的比较就可以做到常数时间了。

而每一位之间应当相互不产生影响,于是我们可以考虑选择一个底数 \(B:B>t\),其中 \(t\) 表示字符的种数。

之后用把长为 \(|s|\) 的字符串 \(s\) 当做一个 \(|s|\) 位数就可以了。注意到很容易溢出,所以需要取模。然而,为了减小重复的概率,需要多取几组。(事实上还有挂表法,就不需要多hash了,但比较少见)

首先,选择 \(k\) 个底数 \(B_1,B_2...B_k\)\(k\) 个模数 \(p_1,p_2...p_k\)

考虑维护一个前缀

\[h_{id,len}\equiv\sum_{i=1}^{len}B_{id}^is_i\mod p_{id} \]

我们记

\[H_{1,len}= \left[\begin{array}{l} h_{1,len}\\ h_{2,len}\\ ...\\ h_{k,len}\ \end{array}\right] \]

这里的 \(H_{1,len}\) 就是 \(1\rightarrow len\) 的字符串hash值

我们需要得到任意 \(x\rightarrow y\) 的hash值,即以 \(x\) 为起点进行一次hash计算,然而这样预处理复杂度过高,需要考虑差分。

显然只需要预处理出

\[B_i^j\qquad s.t.i\in[1,k]\and j\in[1,n] \]

就可以得出

\[H_{x,y}= \left[\begin{array}{l} h_{1,y}-B_1^\Delta h_{1,x-1}\\ h_{2,y}-B_2^\Delta h_{2,x-1}\\ ...\\ h_{k,y}-B_k^\Delta h_{k,x-1}\ \end{array}\right],\Delta=y-x+1 \]

之后我们认为两个字符串相同当且仅当它们的hash值相同。

注意到 \(k\) 是常数,于是复杂度就显然了。

考虑口胡冲突概率。

显然我们至多有 \(p\) 个hash值,假如我们进行了 \(c\) 次比较,最优秀的情况是我们的hash均匀分布(远优于上文的构造方法)

那么不产生冲突的概率是

\[\prod_{i=1}^{c-1}\frac{p-i}{p} \]

进行放缩

\[\prod_{i=1}^{c-1}\frac{p-i}{p}\sim e^{-\frac{c(c-1)}{2p}} \]

于是不冲突的概率就是

\[1-e^{-\frac{c^2}{2p}} \]

进行了 \(k\) 次hash,最后的不冲突概率就是

\[1-e^{-\frac{kc^2}{2p}} \]

一般取 \(k=2\) 就可以了,实际上没有那么均匀,根据模数的不同会有一些偏差

树hash

和字符串hash类似

首先考虑有根树的情况,只需要把子树有序地当做字符插到父亲上(一般按子树大小排序),按\(dfs\)序做字符串hash即可

无根树只需要取重心作为根就可以转化为有根树了

Border理论

KMP

考虑一个经典问题,如何求 \(s\)\(t\) 中出现的次数

一个 \(\operatorname{naive}\) 的做法是枚举起点,然后判断是否相同,这样显然是 \(O(|s||t|)\)

如果使用字符串hash那么久可以做到 \(O(|s|+|t|)\),不过这个做法没有什么字符串的感觉,存在更优雅的做法

有一个显然的想法:如果 \(s=t[l:r]\),那么 \(s[:k]=t[l:r][:k]\),即相同字符串的前缀也是相同的

我们考虑维护 \((i,j)\) 表示 \((i,j)=[\,s[:i]=t[j:][:i]\,]\),所有的 \((|s|,j)\) 的和就是 \(s\)\(t\) 中出现的次数

容易发觉一些显然的性质 \((i,j)=0\Rightarrow(i+1,j+1)=0\),用人话说就是到某个位置不匹配那么之后的都不匹配

我们期望利用 \((i,j)=1\) 找到 \((k,j+1)=1\),使得 \((k+1,j+1)=0\),也就是 \(t\) 中下一位最大的和 \(s\) 相等的前缀

引入 \(Border\) 的概念,\(\operatorname{Border}(s)\) 表示一个字符串集合,\(\forall t\in \operatorname{Border}(s),\exists i\in Z,t=s[i:]=s[:i]\),也就是说,\(\text{Border}(s)\)\(s\) 那些相等的前后缀的集合,特殊地,\(s\not\in\text{Border}(s)\)

再定义 \(\operatorname{kmp}(s)=\operatorname{max}\{|t|\},t\in \operatorname{Border}(s)\),也就是说,\(\operatorname{kmp}(s)\)\(s\) 最长的相等的前后缀的长度

回到原本的问题,断言 \(k\in\{\operatorname{kmp}(s[:i+1]),i+1\}\),具体的,如果 \(s[i+1]==t[j+i+1]\),那么 \(k=i+1\),否则 \(k=\operatorname{kmp}(s[:i+1])\)

前者不难证明,后者考虑反证,显然只可能是答案比 \(\operatorname{kmp}(s[:i+1])\) 大,但如果更大而且还相等,那么 \(\operatorname{kmp}(s[:i+1])\) 也会变大,于是久证明了

现在的问题转化为了求所有的 \(\operatorname{kmp}(s[:i])\),用字符串中常见的 trick,考虑类似数学归纳法的做法,假设我们已经求出了 \(\operatorname{kmp}(s[:1]),\operatorname{kmp}(s[:2])\cdots\operatorname{kmp}(s[:i-1])\),现在要求 \(\operatorname{kmp}(s[:i])\),考虑如下过程:

  1. \(j\leftarrow i-1\)

2.比较 \(s[\operatorname{kmp}(s[:j])+1]=s[i]\)

若为真,\(\operatorname{kmp}(s[:i])\leftarrow\operatorname{kmp}(s[:j])+1\)

否则,\(j\leftarrow \operatorname{kmp}(s[:j])\)

边界是 \(\operatorname{s[:i]}=0\)

可以得出如下的实现

j=0;
for(int i = 2 ; i <= la ; ++i )
{
	while(j&&b[j+1]!=b[i]) j=kmp[j];
	if(b[i]==b[j+1]) ++j;
	kmp[i]=j;
}

这样的复杂度是均摊 \(O(|s|)\) 的,对于这种看上去不对的复杂度,常见的套路是势能分析

构造势能函数 \(\phi(i)=\operatorname{kmp}(s[:i])\),显然,最后势能的变化不超过 \(O(|s|)\),一次匹配失败将导致 \(\Delta\phi=\Delta\text{kmp}>O(1)\),于是最后的复杂度就是 \(O(|s|)\)

最后匹配就很简单了

EXKMP

也叫 \(Z\) 函数

定义 \(z(i)=|\text{lcp}(s,s[i:])|\)

考虑求一个字符串 \(s\) 所有的 \(z(i)\)

考虑 \(s[l:r]=s[1:r-l+1]\),维护 \(l,r\) 的值

\(i\le r\) 时,有 \(z(i)\ge \text{min}(z(i-l+1),r-i+1)\)

证明也很简单,就是直接展开:

\[\begin{align*} s[i+x]&=s[l-l+i+x]\\ &=s[-l+i+x]\\ &=s[x] \end{align*} \]

结合等式中每一步的边界就可以得出上式

如果 \(i>r\),那就只好暴力地设 \(z(i)=0\) 了,不过和 \(\operatorname{kmp}\) 一样,分析一下就发现这是 \(O(|s|)\)

之前推导时第二个等号的得出是没有要求 \(s[i:]\)\(s\) 的后缀的,意味着这个过程对于不同的串也是可以做的,然后就可以做匹配了

回文自动机

为什么没有 \(Manacher\) 大雾

字符串的自动机一般都是类似数学归纳法那样构造的,也就是在线的维护加一个字符的影响

\(\text{PAM}\) 由两颗树构成,分别表示长度为奇数的回文串和长度为偶数的回文串

然后建立类似 \(\text{Trie}\) 的东西,上面的路径对应字符串 \(s\)

然后偶数串对应的树上到根的路径表示回文串 \(ss\),奇数串根下面第一个字符显然只能读一次,即 \(s=as^\prime\),对应的奇数长度回文串为 \(s^\prime as^\prime\)

常用的思路是维护 \(fail\) 指针,这里表示的含义是最长的后缀回文串,然后建立的时候暴力跳 \(fail\) 就好了,复杂度也是 \(O(n)\)

不太常用,放个代码就跑:

class PAM
{
private:
	int tot,n,las;
public:
	class PAM_node
	{
	public:
		int c[26];
		int len,fail;
		int num;
	}; PAM_node d[maxn]; 
#define len(x) d[x].len
#define f(x) d[x].fail
#define ch(i,j) d[i].c[j]
	int s[maxn];
	inline int getfail(int x)
	{
		while(s[n-len(x)-1]!=s[n]) x=f(x);
		return x;
	}
	inline void insert(char c)
	{
#define k (s[n])
		s[++n]=c-'a';
		static int p,q;
		p=getfail(las);
		if(!ch(p,k))
		{
			q=++tot;
			len(q)=d[p].len+2;
			f(q)=ch(getfail(f(p)),k);
			ch(p,k)=q;//顺序很重要
		} ++d[las=ch(p,k)].num;
#undef k
	} inline int size() { return tot; }
	PAM()
	{
		tot=1,s[0]=-1;
		d[0].len=0,d[0].fail=1;
		d[1].len=-1,d[1].fail=0;
		las=0,n=0;
	}
#undef ch
#undef f
#undef len
};

后缀数组

可以用来求 \(s\) 的所有后缀的排名,可以做到 \(\Theta(|s|)\)

倍增的构造方法

我们考虑比较两个字符串是它们第一次不同的位置决定的,于是可以字符串hash,做到 \(\Theta(|s|\log^2 |s|)\)

但我们注意到

\[u=ab,v=cd \]

比较 \(u,v\) 的大小时,我们先比较 \(a,c\),只有 \(a=c\) 的情形需要比较 \(b,d\)

这启发我们可以采用倍增的思路

先把 \(s[i:][1]\) 作为 \(s[i:]\) 的第一关键字,\(s[i+1:][1]\) 作为第二关键字排序(需要注意的是这里必须用桶排否则会退化成 \(\Theta(|s|\log^2|s|)\),细节上需要先排 \(s[i:][1]\),再排 \(s[i+1:][1]\)),之后将 \(s[i:][1]\)\(s[i+1:][1]\) 合并,作为新的第一关键字

这样做正确性显然,因为桶的大小不会超过排名,所以每一次排序都是 \(\Theta(|s|)\) 的,复杂度就是 \(\Theta(|s|\log|s|)\)

给出丑陋的实现

namespace Suffix_Array
{
 string s;
 int x[maxn],y[maxn],tmp[maxn],c[maxn];
 int rk_pos[maxn],n,m;
	inline void sort()
	{
		for(int i=1;i<=m;++i) c[i]=0;
		for(int i=1;i<=n;++i) ++c[x[i]];
		for(int i=2;i<=m;++i) c[i]+=c[i-1];
		for(int i(n); i ;--i) rk_pos[c[x[y[i]]]--]=y[i];
	}
	inline void get_suffix_array()
	{
		m=127;
		for(int i(1);i<=n;++i) x[i]=s[i-1],y[i]=i;
		sort();
		for(int k=1,cnt=0,num=1;k<=n;k<<=1)
		{
			cnt=0;
			for(int i=n-k+1;i<=n;++i) y[++cnt]=i;
			for(int i=1;i<=n;++i) if(rk_pos[i]>k) y[++cnt]=rk_pos[i]-k;
			sort(),
			swap(x,tmp),
			x[rk_pos[1]]=num=1;
			for(int i=2;i<=n;++i)
				if(tmp[rk_pos[i]]==tmp[rk_pos[i-1]]&&tmp[rk_pos[i]+k]==tmp[rk_pos[i-1]+k]) x[rk_pos[i]]=num;
				else x[rk_pos[i]]=++num;
			m=num;
		}
	}
}

后缀自动机的构造方法

倒着遍历fail树即可

后缀自动机SAM

后缀自动机是建在一个字符串 \(S\) 上的DAG,点数和边数都是 \(\Theta(|S|)\)

自动机的本质是用DAG表示一个字符串的信息,后缀自动机DAG应该满足以下性质:

有一个起点和若干终点;

每条路径对应一个字符串;

\(S\) 的后缀是且仅是起点到终点的路径表示的字符串;

起点发出的不同路径对应的字符串不同,且都是 \(S\) 的子串。

为了保证复杂度,我们还期望其满足:边数和点数在满足上述条件的前提下最小

我们规定 \(endpos(s)\) 表示 \(s\)\(S\) 中的所有结束位置,例如

\[S=fftntt\Rightarrow endpos(t)=\{3,5,6\},endpos(ft)=\{3\} \]

引理1

\[|u|\leq|v|\Longrightarrow endpos(v)\subset endpos(u)\or endpos(v)\cap endpos(u)=\empty \]

显然,如果 \(endpos(u)\)\(endpos(v)\) 中存在一个公共元素,那么 \(u=v[i:]\),于是引理成立

Lyndon唯一分解

定理

存在性和唯一性

定义一个串为Lyndon串当且仅当它自己是自己最小的后缀,也就是

\[s:\forall i>1\qquad s.t.s[i:]>s \]

Lyndon分解需要的就是把 \(s\) 划分为 \(m\) 个Lyndon子串,满足

\[s=a_1a_2...a_m\\ a_i\geq a_{i+1} \]

这样的分解是唯一的,证明如下

一开始不妨令 \(a_i=s[i]\),显然此时所有 \(a_i\) 都是Lyndon串

考虑

\[u<v\and u,v\in Lyndon \]

则有Lyndon串的定义知

\[\begin{align*} &i\in[2,n]\\ \because\ &\forall u[i:]>u\\ &\forall v[i:]>v>u\\ &\forall uv[i:]=\begin{cases} &u[i:]v\\ &v[i:]\\ &v \end{cases} \\ \therefore\ &\forall uv[i:]>uv\Rightarrow uv\in Lyndon \end{align*} \]

于是只需要把所有 \(a_i>a_{i+1}\) 合并成新的Lyndon串 \(a_ia_{i+1}\) 即可

接着证明这样的分解是唯一的

考虑有不同的划分

\[s=ABC=AB^\prime C^\prime \]

不妨设

\[B=B^\prime D[:l],|B|>|B^\prime|\\ \]

于是有

\[B<D[:l]\leq D\leq B^\prime<B \]

矛盾,故这样的分解是唯一的

引理

\[s\in Lyndon,vc=s[:l],d>c,|c|=|d|=1\Longrightarrow vd\in Lyndon \]

联系定义,我们有

\[v_1<c<d \]

又有

\[v<s=vct<v[i:]ct<v[i:]c<v[i:]d \]

于是可知 \(vd\in Lyndon\)

算法

理论

按照存在性的证明中所说,显然可以做到 \(\Theta(n\log n)\),但存在线性的做法

我们考虑已经维护了 \(s[:i-1]\) 的Lyndon分解,正在扫描 \(s[k]\)

此时有

\[s[i:k-1]=a^t+b\qquad s.t.t>1\and b=\begin{cases}\empty\\a[:l]\end{cases} \]

\(j=k-|a|\)

如果 \(s[j]<s[k]\),由引理知 \(a^tbs_k\in Lyndon\),添加新的串,重新从 \(k+1\) 处开始扫描

如果 \(s[j]=s[k]\),直接继续扫描下一个数

如果 \(s[j]>s[k]\),让 \(a^t\) 作为新串,\(i\leftarrow i+t|a|\)

复杂度显然,正确性不太显然,但想一想还是可以证明,这里略过

实现

namespace Lyndon
{
vector<int> lyndon_pos;
	inline void Lyndon_decompose(string &s)
	{
		lyndon_pos.clear();
		int n=(int)s.size();
		for(int i=0;i<n;)
		{
			int j=i,k=i+1;
			for(;k<n&&s[j]<=s[k];++k)
				if(s[j]<s[k])j=i;
				else ++j;
			while(i<=j)
				lyndon_pos.emplace_back(i+k-j),
				i+=k-j;
		}
	}
}
posted @ 2022-03-12 11:37  嘉年华_efX  阅读(73)  评论(0)    收藏  举报