4.2 字符串模式匹配

概念

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

朴素(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 表示 x y的后缀

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

满足了上面的 π(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=2 t[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;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值