四大字符串匹配算法总结

字符串匹配问题

  首先简单介绍一下字符串匹配问题,字符串匹配问题里面包含一个文本串和一个模式串。我们的目标是找到文本串中与模式串相同的子字符串,该问题就称之为字符串匹配问题。

朴素字符串匹配算法

  朴素字符串匹配算法其实就是暴力对比的原理,因为模式字符串所有可能的开头只有文本串中每一个字符的位置,所以我们只需要判断以文本串中每一个字符打头时,模式串是否可以匹配文本串。

code

//朴素算法
bool ncmp(string str,string p){
	int n=str.length();
	int m=p.length();
	for(int i=0;i<n-m+1;i++){
		bool tag=true;
		for(int j=0;j<m;j++){
			if(str[i+j]!=p[j]){
				tag=false;
				break;
			}
		}
		if(tag)return true;
	}
	return false;
} 

复杂度分析

  朴素算法的时间复杂度是(n-m-1)m的,其中n是文本串长度,m是模式串长度,当m长度接近n时,算法的时间复杂度就是O(n^2)的,所以针对大规模的字符串来说,该算法的性能并不高,之所以造成这种情况,是因为他没有有效的利用文本串每一位与模式串比较时的结果,也即假如每一位从开头就比较失败,那么算法的时间复杂度是O(n)的,此时我们可以认为是利用了每一步文本串S与模式串P比较时的信息,而当P与S比较成功时,此时已经比较过的匹配成功字符的信息我们并没有在后续比较过程中加以利用,例如在S=abcabcd和P=abcd这种情况下,P首先从S的第0位尝试比较,比较失败;接下来朴素算法会选择S的第二位重复操作,而没有利用第一步时P[1]=S[1],P[2]=S[2]的信息,如果我们有效利用该信息,并知晓P[0]!=P[1] ,P[0]!=p[2],那么下一步时我们可以直接从S[3]开始匹配,而跳过S[1],与S[2]的匹配过程,这显然会很大程度地降低时间复杂度。

Rabin-Karp算法

  考虑到朴素算法的缺陷,后面三种算法都是对模式串进行预处理,来保存之前比较时可利用的信息。
  RK算法的预处理时间复杂度是O(n),而最坏的情况下,时间复杂度是O((n-m+1)*m)的,但是基于一些假设,该算法的平均时间复杂度是较好的。
  该算法运用了初等数论的概念,假设字符集只包含{0,1,2…,9},那么字符串"12345",对应数字就是12345,对于一个字符集只包含0~9的文本串而言,对于模式串P来说,我们可以计算出P对应的值,然后我们用这个唯一的数字代替该字符串,现在我们的比较过程就是用P对应的数,和文本串S中每一个P将要匹配的子串所对应的整数进行比较,如果两个相等就证明原始字符串相等。
  可以看出该方法实际上就是将字符串映射到了一个数,而将字符串比较与数挂钩,实际上的思想与哈希函数的思想是类似的。
  现在的问题是如何快速计算出模式串对应的数,以及文本串S中每一个与P长度相同的子串对应的值,我们以S=“1231321”,p="313"为例,我们可以在O(m)的时间复杂度内求得P对应的值,而如何在O(n)的时间复杂度内求出S所有长度与P相等的子串对应的值呢?首先我们可以求得S[0]打头的字符串对应的数num[0]是123,我们如何由num[0]求解出num[1]呢?这实际上就是一个简单的差分数组的问题,我们可以由num[1]=(num[0]-S[0] * 10^m)+S[1+p];快速求出S中每一位打头的长度等于P的子串对应的nums[i],此时我们只需要比较对应的P的值和nums[i]的值是否相等即可;
  另一个问题是当m较大时,由于数也会变得很大,这样数的比较就不是O(1)时间复杂度了,所以这里我们利用模相等的思想来判断是否匹配,具体来说就是(P=nums[i])mod q,时P可能与S中i打头的子串匹配,之所以是可能只因为,对于大数来说模相等,不意味原值相等,但是模不相等,原值一定不相等。
  这样我们就可以利用模等式来排除不可能相等的,而只比较模相等的情况,这也是我们为什么说该算法最坏条件下的时间复杂度是O((n-m+1)*m)的原因。

code

#include
using namespace std;
bool check(int k,string &str,string &s){
	int n=str.length();
	int m=s.length();
	for(int i=k;i<n;i++){
		bool tag=true;
		for(int j=0;j<m&&i+j<n;j++){
			if(str[i+j]!=s[j]){
				tag=false;
				break;
			}
			return true;
		}
	}
	return false;
}
int main(){
	string str;
	string s;
	cin>>str>>s;
	int n=str.length();
	int m=s.length();
	long long nump=0;
	int mod=1000000007;
	for(int i=0;i<m;i++){
		nump=(nump*10+s[i]-'0')%mod;
	}
	cout<<nump<<endl;
	// 先处理num[0] 
	long long num[n];
	num[0]=0;
	for(int i=0;i<m;i++){
		num[0]=(num[0]*10+str[i]-'0')%mod;
	}
	if(num[0]==nump){
		cout<<"true"<<endl;
		return 0;
	}
 	long long e=1;
	for(int i=0;i<m-1;i++){
		e=e*10%mod;
	}
	cout<<e<<endl;
	for(int i=1;i<n-m+1;i++){
		num[i]=(num[i-1]-(long long)(str[i-1]-'0')*e)*10+str[i+m-1]-'0';
		num[i]=(num[i]%mod+mod)%mod;
		if(num[i]==nump&&check(i,str,s)){
			cout<<"true"<<endl;
			return 0;
		}
	}
	cout<<"false"<<endl;
	return 0;
}

  上述代码可以处理字符集为数字的所有字符串,而对于不同的字符集,可以设置不同的基数。

利用有限自动机处理字符串

  这种方法其实是编译原理里面的重点内容,每一个有限自动机都对应一个正则表达式,每个有限自动机代表的是复合正则表达式的一类字符串,他可以匹配的范围更大,当然也可以匹配单个字符串。
  缺点是这种方式下的有限自动机可能会很大,这导致预处理时间会比较复杂。
  有限状态自动机是一个五元组(Q,q0,A,∑,σ),其中

  1. Q是状态的所有集合。
  2. q0∈Q是初始状态。
  3. A⊆Q是一个特殊的接受状态集合。
  4. ∑是一个有限输入字母表
  5. σ是一个Q×∑到Q的函数,称之为转移函数。
      有限自动机从q0开始,每次读入输入字符串的一个字符,如果有限自动机在状态q时读入了字符a,则它从状态q变成了σ(q,a)(进行了一次转移)每当其当前状态q属于A时,就说自动机接受了字符串a。
      具体如何构造自动机,其实本质上的思想与KMP的思想是一致的,它要最大程度地利用已经比较过的字符串的状况来确定当某个字符串匹配不成功时需要转移到的状态,区别是DFA采用的是一个一个匹配的状态来表示这个中间过程,而KMP算法则是用一维将状态转移转化成成了模式串位置的转移,这里以后有机会在详细介绍。

KMP算法

  与前面DFA的思想一致,但是KMP算法不显式计算状态转移函数,而是使用一个辅助函数π,它在O(m)的时间复杂度内计算出来,并存储到π[1~m]数组中,数组π可以按照需要及时有效的计算,在摊还的意义上与DFA中的状态转移函数一致。
  粗略的说DFA方法包含了很多无用的,但是构建DFA需要的状态转换函数,所以预处理时,需要一个字符集的系数。
  现在详细介绍KMP算法的原理,KMP算法中的数组π保存的是模式与其自身偏移量的匹配信息,换句话说对于一个模式串ababaca来说,当模式串与文本串S在模式串第5个字符匹配失败(字符下标从0开始),也即此时P的前缀ababa与S匹配,而c与字符串S对应位置的字符不匹配,那么此时我们想选择ababa的可以与其真后缀匹配的最大前缀作为已经与S匹配成功的前缀,也即我们应该选择aba作为ababaca在字符c匹配不成功时选择的新的与S匹配的前缀,来继续之后的匹配过程。
  换句话说已经匹配成功的字符串我们可以任务是S中的,那么我们在某一位匹配失败时,肯定要选择P的某个前缀,使得该前缀与已经匹配成功的字符串的后缀匹配,我们可以不断迭代这个过程,知道S未与P匹配成功的字符串与P中的某个字符匹配,或者P以该字符的下一个位置为开头进行匹配。
  现在的问题是我们怎么构造π数组,由于π数组中应该保存的是最大真前缀的长度,所以我们可以保存P的头部字符对应的文本串S中的下标所对应的偏移量,也即对于模式串第i位匹配失败时,π应当满足π[i]+真前缀的长度=i;
  具体的计算π数组的方式如下:

code

#include
using namespace std;
int main(){
	string str;
	string s;
	cin>>str>>s;
	int n=str.length();
	int m=s.length();
	int pi[m];
	pi[0]=-1;
	int k=-1;
	//pi[k]保存的是[0,1]的与模式串某个前缀相同的最大真后缀的长度-1(下标) 
	//pi[k]的含义是模式串与文本串匹配到模式串第k位时,最大真后缀的长度。 
	//pi[k]=m; 
	for(int i=1;i<m;i++){
		while(k!=-1&&s[k+1]!=s[i])k=pi[k];
		if(s[k+1]==s[i])k=k+1;//这里是尝试每个真前缀下一个字符是否可以与s[i]匹配 
		pi[i]=k;//匹配成功则k>=0,否则为-1; 
	} 
	for(int i=0;i<m;i++)cout<<pi[i]<<" ";cout<<endl;
	int q=-1;//模式串当前下标 
	//匹配过程与预处理过程其实是一致的。 
	for(int i=0;i<n;i++){//如何使用预处理的pi数组呢? 
	    cout<<q<<endl;
	    while(q!=-1&&s[q+1]!=str[i]){//当前已经匹配的字符到了模式串的第q位置,当模式串下一位匹配不成功时 
	    	q=pi[q];               //取下一个模式进行匹配  
		} 
		if(s[q+1]==str[i])q++;     //能匹配成功时模式串匹配长度加1 
		if(q==m-1){cout<<"true";return 0;}     //匹配到了m-1位时输出true 
	}
	cout<<"false"<<endl;                   
	return 0;
} 

你可能感兴趣的:(算法,字符串,算法)