4.2 字符串模式匹配

来源:互联网 发布:淘宝活动报名入口 编辑:程序博客网 时间:2024/06/07 00:34

概念

  • 模式匹配的定义:对于一个长度为n的文本字符串s[1..n],且长度为m的模式字符串t[1..m]。其中满足mnst都来自有限字符集合Σ。(例如小写英文字母集Σ={a,b,c,d,...,z})如果s[r+1..r+m]=t[1..m],则ts中出现,其中偏移为r称为有效偏移。模式匹配问题需要求解到所有的rk,使tsrk的有效偏移出现。

朴素(Brute-Force)算法

既然模式匹配的目标是得到这样的集合{r|s[r..r+m]=t[1..m]r[1,n]}

因此朴素算法的核心思想是,穷举从[1,n]所有的r的可能取值,判断s[r..r+m]t[1..m]是否相等。
因此可以得到如下的算法(为了通用起见和隐藏实际的地址指针,这里选择标准库的std::string类型来描述字符串。):

/*** @brief 字符串模式匹配暴力算法* @param const std::string & source 文本串* @param const std::string & pattern 模式串* @return std::vector<int> 找到的模式串位于文本串的起始位置,没找到则为空*/std::vector<int> naive_string_match(const std::string & source, const std::string & pattern){    std::vector<int> result;    int i, j;    int end = source.length() - pattern.length() + 1;    //确保i + j不会越过source的末尾    for(i = 0; i < end; i++){        for(j = 0; j < pattern.length(); j++)               if(source[i + j] != pattern[j])                break;        if(j == pattern.length())            result.push_back(i);    }    return result;}

注意外层循环条件end的取值是nm+1,因为如果是取nm,则最后一趟循环时i=nm1,故source[i + j]的取值的最大值为(nm1)+m=n1,无法对齐最后一个字符,可能出现漏判的情况。

对其进行时间复杂度分析,最内层循环执行m次,外层循环执行nm1次,因此利用乘法法则,总的时间复杂度O(m(nm1))。通常情况下mn,近似的也可以认为总时间复杂度为O(mn)。空间复杂度O(1)

KMP算法

Knuth, Morris, Pratt算法,简称KMP算法,是一种高效的模式匹配算法,其核心在于先对子串进行预处理,得到一个辅助的前缀函数π(x),从而充分利用已知信息,避免主串指针的回溯,以提高效率。其时间复杂度:Θ(m)的预处理和Θ(n)的匹配。

有限自动机(DFA)

对于KMP算法的理解,介绍一下有限自动机(deterministic finite-state automaton, DFA)是很有帮助的。

一个有限自动机M=(Q,q0,A,Σ,δ)
其中Q是所有可能的状态集合,q0Q表示自动机的初始状态,AQ表示自动机的终态集合(也就是自动机被接受的所有状态),Σ是所有输入的字符集合,δ是一个状态转移函数,是一个由QΣ的笛卡儿积(Q×Σ)到Q的函数。
自动机从q0状态开始。对于任意的状态q,每读取一个字符a,其状态就由q变为δ(q,a)

自动机的状态转换类似于时序电路中的状态,下一个状态依赖于当前状态和输入。

DFA的模式匹配

对于一个任意的模式串t[1..m],都可以构造一个有限自动机M
假设模式串t= ababaca。 字符集 Σ={a,b,c}
则由该模式串产生的自动机M共有8个状态,记为0,1,2,3,4,5,6,7,分别对应 ε (空字符串),aababaababababaababacababaca
因此
状态0,如果输入为a,则进入状态1,反之依然为状态0;
状态1,如果输入为b,则进入状态2,输入为a,进入状态1,输入为c,进入状态0。
状态2,如果输入为a,则进入状态3,如果输入为bc,则进入状态0.
…(以此类推。)
"abababc"自动机示意图
上图对上例的字符集Σ={a,b}的字符串abababc进行模式匹配构造的自动机的状态图。7为接受状态。

这样在匹配的过程中,只需要自动机进行到第7个状态,则完成匹配。
利用自动机进行匹配的核心在于:状态q满足方程q=δ(q,a),aΣ。因此使用自动机时,需要提前根据模式串t计算出状态转移函数δ,然后在遍历主串,根据状态转移函数修改状态。
计算转移函数,设i处状态为q,则对t[i+1]使用xΣ
δ(q,x)=σ(t[1..q]x)σ是后缀函数,满足σ(y)y的后缀的t的最长前缀的长度。
t[i+1]=x时,很明显δ(q,x)=q+1。反之,当t[i+1]x时,则取最长的子串p的长度,满足p既是t[1..q]的前缀又是t[1..q]x的后缀(注意两边有区别)。后缀相当于是目前走过的部分,而t[1..q]的前缀,则保证了走到当前位置时,已经与模式串的前些位相匹配。

以上面例子的状态5为例,i=5
(1)x=c时,与x=t[6]匹配,则δ(5,a)=6
(2)x=b时,t[6]=a不匹配,则状态需要回退,考虑新加进的字符b以后,能回退的最大长度,则需要找到前缀与新加进字符b以后的后缀相等的字符串的长度,很明显未加入b时,ababa,而键入b后得到ababab,因此取的字符串为abab,长度为4,即δ(5,b)=4
(3)x=a时,同(2)相类似,可得δ(5,c)=1

由此可见,计算δ是一个模式串自我(部分)匹配的过程。

由DFA到KMP

对于自动机来说,因为δ函数的输入是一个二元笛卡儿积,故存储预处理生成状态转移函数需要O(|Σ|m)的额外空间,且最好的时间为O(|Σ|m)。利用KMP算法能够将空间复杂度和时间复杂度显著改善为O(m)

在KMP算法中,引入了辅助的前缀函数π代替存储状态转移函数δ,利用π的值,可以在常数时间(摊还意义)计算出δ的值。

定义π(q)=max{k:k<qt[1..k]t[1..q]}
其中xy表示xy的后缀

需要注意的是,与δ不同的地方在于后缀部分并不包含当前读取的字符,因为π函数本身不包括当前字符的信息(为了减少|Σ|项必然不能包括字符信息。)。

满足了上面的π(q)的定义,可以得出,对于s[i]t[q]时,可以将s[i]t[π(q)+1]进行比较(定义保证了前后缀相同且长度最大,因此比较下一个字符),如此往复,直到能匹配上(自动机回退到某一状态)或q=0为止(自动机回退到初始状态)。实际上这是一个动态计算δ函数的过程。因此不难得出KMP的主函数。

下面的问题在于如何求取ππ是预先计算出来存放在数组中的。求取π实际也是自我匹配过程。

π满足π(1)=0的初始条件。可以设k用来指示满足要求的最长前缀的长度,于是在遍历t[1..m]的过程中,q是遍历指针,当t[k+1]t[q]时,说明不满足要求,于是应该进行回退,寻找t[1..k]的前缀子串t,看看能否满足t[k+1]=t[q]。在回退过程中,令k=π[k]0<k<p),则得到的前缀子串和未加入t[q]的后缀子串必然是相等的。这时候再对这两个子串分别追加一个字符,判断能否继续满足相等的条件。如果不能满足,则继续循环操作,直到两个串都为空串ε时循环终止,自动满足上述条件。整个过程来看因为是求k的最大值,因此使用了假设法,让k依次在可能的取值中递减,直到求出满足条件的k值。

同样以t= ababaca为例,已经求得π(1)=π(2)=0,π(3)=1,π(4)=2,于是求π(5)时,k=2t[5]=t[3],于是π(5)=k+1=3。求π(6)时,t[6]t[4],也就是说加入c字符后得到的后缀abac与同样长度的前缀abab不等,于是应该回退,k=π(k)=1。此时也就是说,在未加入字符c之前,在长度为k=3的情况不满足时,依然有次小的k=1满足,这时候依然需要判断当前字符c加入后能否满足。显然ab ac。再次重复上面的操作,直到k=0,也就是他们的前缀子串与后缀子串都为空串ε。此时分别在前缀子串和后缀子串末尾加入字符ac,产生的新串并不等,于是依然有k=0。所以π(6)=0

求前缀函数的方法也明确以后,就不难写出KMP的匹配算法了。

/*** @brief 计算KMP的前缀函数数组* @param const std::string & pattern 模式字符串* @return std::vector<int> 前缀函数数组*/std::vector<int> kmp_prefix_func(const std::string & pattern){    std::vector<int> result;    int k = 0;                  //满足条件的最大前缀数组长度    result.push_back(0);        //初始条件    for(int i = 1; i < pattern.length(); i++){        while(k > 0 && pattern[k] != pattern[i]){               //C/C++数组下标以0开始,pattern[k]就是前缀数组要新加入的字符            k = result[k - 1];         }        if(pattern[k] == pattern[i]){            k++;        }        result.push_back(k);    }    return result;}/*** @brief KMP字符串模式匹配主函数* @param const std::string & text 文本字符串* @param const std::string & pattern 模式字符串* @return std::vector<int> 所有找到的下标(以0开始)*/std::vector<int> kmp_matcher(const std::string & text, const std::string & pattern){    std::vector<int> prefix = kmp_prefix_func(pattern); //计算前缀函数    std::vector<int> result;    int q = 0;      //设置状态初始值    for(int i = 0; i < text.length(); i++){        while(q > 0 && text[i] != pattern[q]){            //C/C++数组下标以0开始,pattern[q]就是前缀数组要新加入的字符               q = prefix[q - 1];        }        if(pattern[q] == text[i]){            q++;        }        if(q == pattern.length()){            result.push_back(i - (q - 1));  //找到了,加入进返回值数组            q = prefix[q - 1];              //更新状态,查找下一个        }    }    return result;}