字符串简陋入门
字符串笔记
前言
我的字符串也不是很好,如果有错欢迎指出。
一些约定和定义
如无特殊说明,都参考这里。
\(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_{1,len}\) 就是 \(1\rightarrow len\) 的字符串hash值
我们需要得到任意 \(x\rightarrow y\) 的hash值,即以 \(x\) 为起点进行一次hash计算,然而这样预处理复杂度过高,需要考虑差分。
显然只需要预处理出
就可以得出
之后我们认为两个字符串相同当且仅当它们的hash值相同。
注意到 \(k\) 是常数,于是复杂度就显然了。
考虑口胡冲突概率。
显然我们至多有 \(p\) 个hash值,假如我们进行了 \(c\) 次比较,最优秀的情况是我们的hash均匀分布(远优于上文的构造方法)
那么不产生冲突的概率是
进行放缩
于是不冲突的概率就是
进行了 \(k\) 次hash,最后的不冲突概率就是
一般取 \(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])\),考虑如下过程:
- \(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)\)
证明也很简单,就是直接展开:
结合等式中每一步的边界就可以得出上式
如果 \(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,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\) 中的所有结束位置,例如
引理1
显然,如果 \(endpos(u)\) 和 \(endpos(v)\) 中存在一个公共元素,那么 \(u=v[i:]\),于是引理成立
Lyndon唯一分解
定理
存在性和唯一性
定义一个串为Lyndon串当且仅当它自己是自己最小的后缀,也就是
Lyndon分解需要的就是把 \(s\) 划分为 \(m\) 个Lyndon子串,满足
这样的分解是唯一的,证明如下
一开始不妨令 \(a_i=s[i]\),显然此时所有 \(a_i\) 都是Lyndon串
考虑
则有Lyndon串的定义知
于是只需要把所有 \(a_i>a_{i+1}\) 合并成新的Lyndon串 \(a_ia_{i+1}\) 即可
接着证明这样的分解是唯一的
考虑有不同的划分
不妨设
于是有
矛盾,故这样的分解是唯一的
引理
联系定义,我们有
又有
于是可知 \(vd\in Lyndon\)
算法
理论
按照存在性的证明中所说,显然可以做到 \(\Theta(n\log n)\),但存在线性的做法
我们考虑已经维护了 \(s[:i-1]\) 的Lyndon分解,正在扫描 \(s[k]\)
此时有
令 \(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;
}
}
}

浙公网安备 33010602011771号