字符串
序言
字符串说实话我不算是很擅长,但是我还是想写一点东西。
字符串是一种存储字符的数据结构,本身来说这个并不难,但是因此也拓展出了非常非常多的算法。
很多人学习字符串的基本算法时就被劝退了,但殊不知这只是字符串的起点。
所以,坚持地学习下去吧,等你有一天层次高了后,你会发现:我以前怎么连这么弱智的算法都不会。
本人也是刚接触字符串不久,所以并不会涉及太难的知识点。
KMP
KMP 是字符串匹配算法中的经典,它同时由三位作者提出,故它的名字就是这三个人的名字首字母。
KMP 的精髓就是一个失配数组 \(fail[i]\),但很多人一开始一直搞不懂这个数组的意义,所以我们不妨假设我们现在已经得到了这么一个数组,怎么去匹配呢?
我们先正常遍历,若发现一个不匹配的,我们就回退到上一个匹配的位置,直到找到一个匹配的位置。
但这样做有一个问题,就是回退的位置太远了,我们可以用失配数组来优化这个过程。
我们可以发现,失配数组 \(fail[i]\) 记录的是前缀的最长匹配长度,也就是说,如果我们已经匹配到位置 \(i\),而后面还有一段字符串 \(s[j:j+m]\),且 \(s[j:j+m]\) 与 \(s[i:i+m]\) 前缀相同,那么 \(fail[i]\) 就记录了 \(s[j:j+m]\) 的最长匹配长度。
那么,我们就可以根据失配数组来优化回退的位置。
这里因为笔者觉得 KMP 其实在省选难度并不会出现(一般都是 AC自动机或者马拉车等),所以就不展开讲解了。
这里放个模板
#include<bits/stdc++.h>
#define int long long
#define ull unsigned long long
#define PII pair<int,int>
#define mk(a,b) make_pair(a,b)
using namespace std;
template<typename P>
inline void read(P &x){
P res=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9'){
res=res*10+ch-'0';
ch=getchar();
}
x=res*f;
}
int T=1;
char a[1000010],b[1000010];
int nxt[1000010];
signed main(){
cin>>a+1;
cin>>b+1;
int j=0;
int la=strlen(a+1),lb=strlen(b+1);
for(int i=2;i<=lb;++i){
while(j && b[j+1]!=b[i]) j=nxt[j];
if(b[j+1]==b[i]) j++;
nxt[i]=j;
}
j=0;
for(int i=1;i<=la;++i){
while(j && b[j+1]!=a[i]) j=nxt[j];
if(b[j+1]==a[i]) j++;
if(j==lb){
cout<<i-lb+1<<endl;
j=nxt[j];
}
}
for(int i=1;i<=lb;++i) cout<<nxt[i]<<' ';
cout<<endl;
return 0;
}
trie树
trie树是一种树形结构,用来存储字符串,它是一种非常经典的字符串匹配算法。
trie树的每个节点都是一个字符,如果当前字符不存在,则创建一个新的节点,如果存在,则进入这个节点。
具体来说,我们先判断当前节点 \(u\) 是否有儿子 \(ch\),即新插入的字符,如果有,则令 \(u=t[u].son[ch]\),代表当前节点沿着树边向下移,如果没有,则新创一个节点 \(t[u].son[ch]=++cnt\),然后还是 \(u=t[u].son[ch]\),代表当前节点沿着树边向下移。这样就能实现插入的操作。
然后,我们要查询一个字符串是否在trie树中,我们从根节点开始,沿着树边向下,如果当前节点的儿子中有某个儿子的字符与查询字符串的当前字符相同,则继续沿着这个儿子的边向下,如果没有,则说明这个字符不在trie树中,返回false。
如果查询到最后一个字符,则说明这个字符串在trie树中,返回true。
trie树的插入以及查询时间复杂度均为 \(O(m)\),其中 \(m\) 为字符串的长度。
#include<bits/stdc++.h>
#define int long long
#define ull unsigned long long
#define PII pair<int,int>
#define mk(a,b) make_pair(a,b)
using namespace std;
template<typename P>
inline void read(P &x){
P res=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9'){
res=res*10+ch-'0';
ch=getchar();
}
x=res*f;
}
int T=1;
struct node{
int a[72];
int flag;
}t[3000010];
string a;
int n,m;
int u,cnt=0;
int get(char a){
if(a>='A' && a<='Z') return (int)(a-'A');
else if(a>='a' && a<='z') return (int)(a-'a'+26);
else return (int)(a-'0'+52);
}
void insert(string s){
int len=s.size();
u=0;
s='0'+s;
for(int i=1;i<=len;++i){
int k=get(s[i]);
if(!t[u].a[k]) t[u].a[k]=++cnt;
u=t[u].a[k];
t[u].flag++;
}
}
int query(string s){
int len=s.size();
u=0;
s='0'+s;
for(int i=1;i<=len;++i){
int k=get(s[i]);
if(!t[u].a[k]) return 0;
u=t[u].a[k];
}
return t[u].flag;
}
signed main(){
auto solve=[&](){
read(n),read(m);
for(int i=0;i<=cnt;++i) for(int j=0;j<=70;++j) t[i].a[j]=0;
for(int i=0;i<=cnt;++i) t[i].flag=0;
cnt=0;
for(int i=1;i<=n;++i){
cin>>a;
insert(a);
}
for(int i=1;i<=m;++i){
cin>>a;
cout<<query(a)<<endl;
}
};
//freopen(.in,'r',stdin);
//freopen(.out,'w',stdout);
read(T);
while(T--) solve();
return 0;
}
AC自动机
AC自动机是一种字符串匹配算法,它是基于trie树的改进,它可以解决一些trie树无法解决的问题。
AC自动机可以说是 KMP 与 trie树 的结合体,它在trie树的基础上,增加了一些状态转移的规则,使得它可以更好地匹配字符串。
具体来说,KMP 只能处理匹配单个字符串的问题,一旦有 \(n\) 个字符串需要查找,复杂度上升为 \(O(nm)\),而 AC自动机 就可以解决这个问题。
我们还是对每个字符串建 trie树,然后我们需要一个链接边,类似 KMP 的,它的目标我们设为 \(fail[u]\) 表示的是从根节点到 \(u\) 所形成的字符串的最长公共前后缀的节点(就是 KMP 中的 \(fail\) 的定义),这样我们就可以根据这个链接边来优化回退的位置。
AC自动机的状态转移规则如下:
- 如果当前字符与当前节点的字符相同,则进入当前节点的儿子,并将当前节点设为 \(fail[u]\);
- 如果当前字符与当前节点的字符不同,则沿着当前节点的链接边,找到第一个与当前字符相同的节点,并将当前节点设为 \(fail[u]\);
- 如果当前字符与当前节点的字符相同,但当前节点的儿子中没有与当前字符相同的儿子,则说明当前字符不在当前节点的子树中,则沿着当前节点的链接边,找到第一个与当前字符相同的节点,并将当前节点设为 \(fail[u]\);
这样,我们就可以根据 \(fail\) 数组来优化回退的位置。
AC自动机的插入与查询时间复杂度均为 \(O(26n+m)\),其中 \(m\) 为字符串的长度。
代码(二次加强版)如下:
#include<bits/stdc++.h>
#define ull unsigned long long
#define PII pair<int,int>
#define mk(a,b) make_pair(a,b)
using namespace std;
template<typename P>
inline void read(P &x){
P res=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9'){
res=res*10+ch-'0';
ch=getchar();
}
x=res*f;
}
int T=1;
struct node{
int a[27];
int flag;
int ans;
node(){
memset(a,0,sizeof(a));
flag=0;
ans=0;
}
}trie[2000010];
int n;
string t;
string s;
int u,cnt=0;
int fail[2000010],vis[200010],in[20000010],pp[2000001];
int get(char a) {return (int)(a-'a');}
queue<int> q;
void insert(string s,int id){
int len=s.size();
s='0'+s;
u=0;
for(int i=1;i<=len;++i){
int k=get(s[i]);
if(!trie[u].a[k]) trie[u].a[k]=++cnt;
u=trie[u].a[k];
}
if(!trie[u].flag) trie[u].flag=id;
pp[id]=trie[u].flag;
}
void build(){
for(int i=0;i<26;++i) if(trie[0].a[i]) q.push(trie[0].a[i]);
while(!q.empty()){
int u=q.front();
q.pop();
for(int i=0;i<26;++i){
int v=trie[u].a[i];
if(v) fail[v]=trie[fail[u]].a[i],in[fail[v]]++,q.push(v);
else trie[u].a[i]=trie[fail[u]].a[i];
}
}
}
void topo(){
for(int i=1;i<=cnt;++i) if(in[i]==0) q.push(i);
while(!q.empty()){
int u=q.front();
q.pop();
vis[trie[u].flag]=trie[u].ans;
int v=fail[u];in[v]--;
trie[v].ans+=trie[u].ans;
if(in[v]==0) q.push(v);
}
}
void query(string s){
int len=s.size();
s='0'+s;
u=0;
for(int i=1;i<=len;++i){
int p=get(s[i]);
u=trie[u].a[p];
trie[u].ans++;
}
}
signed main(){
auto solve=[&](){
read(n);
for(int i=1;i<=n;++i) cin>>t,insert(t,i);
build();
cin>>s;
query(s);
topo();
for(int i=1;i<=n;++i) cout<<vis[pp[i]]<<endl;
};
//freopen(.in,'r',stdin);
//freopen(.out,'w',stdout);
//read(T);
while(T--) solve();
return 0;
}
拓展KMP(Z函数)
其实这个我不是很理解与 KMP 有什么关系,但是这个我印象比较深。
Z函数的精髓是维护一个盒子,\(z_i\) 表示的是从 \(i\) 这个位置出发,设能拓展到的右端点为 \(r\) ,左端点为 \(l\),\([l,r]\) 之间最长公共前缀的长度。
简单点理解就是:当前 \(i\) 能往后拓展到的盒子的最大长度,使得整个盒子中有最长公共前缀。
说以来比较绕口,举个例子:
我们先不管 \(nxt\) 函数有什么用,先看看它怎么求出来的。
我们假设 \(z[1]\) 至 \(z[i-1]\) 的函数值我们已知,现在求 \(z[i]\),有个明显的结论:
\([1,z[i]-1],[l,l+z[i]-1]\) 是相同的,这里的 \(l\) 指的是盒子 \(z[i]\) 的左端点。如下图:
那么也有:
显然可以观察到,我们拓展 \(z[i]\) 时。若它当前在一个前面的 \(z[k]\) 所表示的盒子中,那么 \(z[i]\) 的大小就是 \(z[i-l]\)。
但是我们要考虑一个问题,有可能 \(z[i]\) 的大小会超出之前的 \(l,r\) 的范围,比如:
所以我们先不考虑超出的部分,那么 \(z[i]\) 的大小必须得限制在剩余的长度中,即 \(z[i]=min(z[i-l],r-i+1)\)。
现在考虑超出的部分或者本身 \(i\) 就不在 \([l,r]\) 范围内的情况,那么 \(z[i]\) 的大小就没有前继可以转移了,直接暴力:
while(a[z[i]]==a[i+z[i]] && i+z[i]<n) z[i]++;
最后我们还要移动 \(l\) 和 \(r\) 的位置来更新后面的 \(z\) 函数。
所以当当前的 \(z[i]\) 超出范围时,我们就要更新 \(l\) 和 \(r\) 的位置,
即:
if(i+z[i]>r) l=i,r=i+z[i];
这样就得到了 \(z\) 函数。
但是其实我到现在都没有遇到需要用 \(z\) 函数解决的题(不如后缀自动机)。
代码如下:
void getz(string a){
n=a.size();
for(int i=1,l=0,r=0;i<n;++i){
if(i<r) z[i]=min(z[i-l],r-i);
while(a[z[i]]==a[i+z[i]] && i+z[i]<n) z[i]++;
if(i+z[i]>r) l=i,r=i+z[i];
}
}
Manacher马拉车
求解回文子串的一种算法,用的不多,一般都用回文自动机(PAM)或者后缀数组啥的。
其实跟 \(z\) 函数几乎一模一样,只需要把 \(z[i]\) 所代表的意义变成:以 \(i\) 为回文中心的最长回文半径(包含自身)。
代码也是高度相似:
void manacher(char *s,int n){
d[1]=1;
for(int i=2,l,r=1;i<=n;++i){
if(i<=r) d[i]=min(d[r-i+l],r-i+1);
while(s[i-d[i]]==s[i+d[i]]) d[i]++;
if(i+d[i]-1>r) l=i-d[i]+1,r=i+d[i]-1;
}
}
建议读者自己理解一下,理解不了的话建议去 Bilibili找董晓算法学习。
后缀数组(SA)
现在我只学习了后缀数组排序算法,实际运用还没学。
后缀排序可以在 \(O(n\log n)\) 的时间内求出后缀数组关联的两个数组 \(rk[i]\) 和 \(sa[i]\),其中 \(rk[i]\) 表示的是 \(i\) 位置的后缀的排名,\(sa[i]\) 表示的是排名为 \(rk[i]\) 的后缀在原串中的位置。二者相互映射,即 \(sa[rk[i]]=rk[sa[i]]\)。
具体思路就是倍增一个长度 \(k\),然后用 \(k\) 作为步长,对原串进行分块,对每个块进行排序,得到的结果就是后缀数组。
排序的过程就是基数排序,没学的自己去学,我不讲基础的。
同时,还可以求出 LCP 数组,即 \(lcp[i]\) 表示的是 \(i\) 位置的后缀与 \(i+1\) 位置的后缀的最长公共前缀的长度。
但是在模板中并没有对 \(lcp\) 数组的运用:
#include<bits/stdc++.h>
#define ull unsigned long long
#define ll long long
#define PII pair<int,int>
#define mk(a,b) make_pair(a,b)
using namespace std;
template<typename P>
inline void read(P &x){
P res=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9'){
res=res*10+ch-'0';
ch=getchar();
}
x=res*f;
}
int T=1;
const int N=2e6+10;
char s[N];
int n,m;
int x[N],y[N],sa[N],rk[N],h[N];
int c[N];
void getsa(){
int i,k;
for(i=1;i<=n;++i) c[x[i]=s[i]]++;
for(i=1;i<=m;++i) c[i]+=c[i-1];
for(i=n;i>=1;--i) sa[c[x[i]]--]=i;
for(k=1;k<=n;k<<=1){
//第二关键字排序
memset(c,0,sizeof(c));
for(i=1;i<=n;++i) y[i]=sa[i];
for(i=1;i<=n;++i) c[x[y[i]+k]]++;
for(i=1;i<=m;++i) c[i]+=c[i-1];
for(i=n;i>=1;--i) sa[c[x[y[i]+k]]--]=y[i];
//第一关键字排序
memset(c,0,sizeof(c));
for(i=1;i<=n;++i) y[i]=sa[i];
for(i=1;i<=n;++i) c[x[y[i]]]++;
for(i=1;i<=m;++i) c[i]+=c[i-1];
for(i=n;i>=1;--i) sa[c[x[y[i]]]--]=y[i];
//重排
for(i=1;i<=n;++i) y[i]=x[i];
for(m=0,i=1;i<=n;++i){
if(y[sa[i]]==y[sa[i-1]] && y[sa[i]+k]==y[sa[i-1]+k]) x[sa[i]]=m;
else x[sa[i]]=++m;
if(m==n) break;
}
}
}
void geth(){
for(int i=1;i<=n;++i) rk[sa[i]]=i;
for(int i=1,k=0;i<=n;++i){
if(rk[i]==1) continue;
if(k) k--;
int j=sa[rk[i]-1];
while(i+k<=n && j+k<=n && s[i+k]==s[j+k]) k++;
h[rk[i]]=k;
}
}
signed main(){
scanf("%s",s+1);
n=strlen(s+1);m=122;
getsa();
// geth();
for(int i=1;i<=n;++i) printf("%d ",sa[i]);
printf("\n");
return 0;
}
标签:长期,ch,int,long,字符串,节点,define
From: https://www.cnblogs.com/lizihan00787/p/18361413