KMP 算法详解及代码实现

KMP 有什么用

KMP(Knuth,Morris和Pratt这三位发明者的首字母)算法,用于模式匹配(字符串匹配)

给定字符串 S 0 S 1 . . . S n − 1 S_0S_1...S_{n-1} S0S1...Sn1,和模式串 T 0 T 1 . . . T m − 1 T_0T_1...T_{m-1} T0T1...Tm1,在字符串 S S S 中寻找模式串 T T T 的过程称为模式匹配。如果匹配成功,返回 T T T S S S 中的位置;否则匹配失败,返回 -1.

朴素模式匹配算法—Brute Force 算法

在介绍 KMP 算法之间,先看一个暴力求解的 Brute Force 算法,基本思想:

  • 从字符串 S S S(主串) 的第一个字符开始和模式 T T T 的第一个字符进行比较,若相等,则继续依次比较后续的字符;否则,从字符串 S S S 的第二个字符重新开始 T T T 的第一个字符进行比较。
  • 重复上面的过程,直到 T T T 中字符全都比较完毕,说明匹配成功;或 S S S 中字符全部比较完,说明匹配失败。
int strMatch_BF(string S, string T, int pos=0) {
    int i = pos, j = 0;
    while(i < S.size() && j < T.size()) {
        if(S[i] == T[j]) {
            ++i;
            ++j;
        } else {
            i = i - j + 1;   // 从本趟匹配开始位置的下一个字符重新开始匹配。
            j = 0;           // 模式也从第一个字符开始重新匹配。
        }
    }

    if(j == T.size()) {
        return i - T-size();   // 返回本趟匹配的起始位置
    } else {
        return -1;
    }
}

BF 算法的时间性能较低,因为在每趟匹配不成功时存在大量回溯,没有利用已经部分匹配的结果。

那么可不可以在匹配失败时,让主串不回溯?

  • 如果主串不回溯,那么就需要模式串向右滑动一段距离
  • 这就是 KMP 算法的主要思想,下面介绍如何确定模式串的滑动距离,也就是前缀表(或next数组)。

KMP 算法主要思想

经过前面的分析,在匹配失败时,我们 不 去 回 溯 主 串 , 而 是 将 模 式 串 尽 可 能 地 向 右 “ 滑 动 ” \color{red}{不去回溯主串,而是将模式串尽可能地向右“滑动”} ,这就是 KMP 算法的主要思想。

思考的开始:

  • 假定主串 S 0 S 1 . . . S n − 1 S_0S_1...S_{n-1} S0S1...Sn1,和模式串 T 0 T 1 . . . T m − 1 T_0T_1...T_{m-1} T0T1...Tm1
  • 无回溯匹配问题变成:当主串的字符 S i S_i Si 与模式中的字符 T j T_j Tj 不匹配时,主串的 S i S_i Si 应与模式中的哪个字符对齐再进行比较呢
  • 这个信息我们将其存在 next 数组,也就是前缀表当中。所以前缀表的作用就是:

    前缀表中记录了模式串与主串不匹配的时候,模式串应该从哪里开始重新匹配。

进一步思考:

  • 假设主串中的 S i S_i Si 与模式中的 T k T_k Tk k < j k < j k<j)继续比较(即 n e x t [ j ] = k next[j]=k next[j]=k),则应有:
    • T 0 T 1 . . . T k − 1 = S i − k S i − k + 1 . . . S i − 1 T_0T_1...T_{k-1} = S_{i-k}S_{i-k+1}...S_{i-1} T0T1...Tk1=SikSik+1...Si1
    • 可能有多个 k k k,那么选哪一个
    • 注意这里的 k < j \color{red}{k < j} k<j 是必须的,因为我们要将模式串向后移,如果 k = j k=j k=j 的时候,这不就相当于没移了吗?这是计算前缀表时必须注意的一点。
  • 而根据已有的匹配,有:
    • T j − k T j − k + 1 . . . T j − 1 = S i − k S i − k + 1 . . . S i − 1 T_{j-k}T_{j-k+1}...T_{j-1}=S_{i-k}S_{i-k+1}...S_{i-1} TjkTjk+1...Tj1=SikSik+1...Si1
  • 所以:
    • T 0 T 1 . . . T k − 1 = T j − k T j − k + 1 . . . T j − 1 T_0T_1...T_{k-1}=T_{j-k}T_{j-k+1}...T_{j-1} T0T1...Tk1=TjkTjk+1...Tj1
    • 如果 k < j k < j k<j的话, T j − k T j − k + 1 . . . T j − 1 T_{j-k}T_{j-k+1}...T_{j-1} TjkTjk+1...Tj1 就不可能是前缀。

那么 T 0 T 1 . . . T k − 1 = T j − k T j − k + 1 . . . T j − 1 T_0T_1...T_{k-1}=T_{j-k}T_{j-k+1}...T_{j-1} T0T1...Tk1=TjkTjk+1...Tj1 说明了什么?

  • 模式串从第 0 位向右数 k k k 位,和从第 j − 1 j-1 j1 位向前数 k k k 位,是相同的。
  • k k k j j j 具有函数关系,由当前匹配失败的位置 j j j( T j T_j Tj),可以计算出滑动位置 k k k(即新的比较位置 T k T_k Tk
  • 滑动位置 k k k(新的比较位置 T k T_k Tk)仅与模式串 T T T 自身相关,与主串无关

k k k 必须满足 k < j k<j k<j,且应该取所有可能值中的最大值,因为取最大值就意味着向右移动的距离最小(可以意会一下),这也就避免错过成功匹配的机会,即:
k = m a x { k ∣ 0 < k < j , T 0 T 1 . . . T k − 1 = T j − k T j − k + 1 . . . T j − 1 } k = max\{k| 0<k<j,T_0T_1...T_{k-1}=T_{j-k}T_{j-k+1}...T_{j-1}\} k=max{k0<k<j,T0T1...Tk1=TjkTjk+1...Tj1}

前缀表(next数组)的计算方法

前缀表中存储的就是模式串 T T T 中的每一位 T j T_j Tj 匹配失败时,模式串的哪一位 T k T_k Tk 要和当前主串失配的位置对齐,令 k = n e x t [ j ] k=next[j] k=next[j]

next ⁡ [ j ] = { − 1 j = 0 m a x { k ∣ 0 < k < j , T 0 T 1 . . . T k − 1 = T j − k T j − k + 1 . . . T j − 1 } 0  other case  \operatorname{next}[j]=\left\{\begin{array}{lc} -1 & j=0 \\ max\{k| 0<k<j,T_0T_1...T_{k-1}=T_{j-k}T_{j-k+1}...T_{j-1}\} \\ 0 & \text { other case } \end{array}\right. next[j]=1max{k0<k<j,T0T1...Tk1=TjkTjk+1...Tj1}0j=0 other case 

对于模式串的任一位置 j j j k = n e x t [ j ] k=next[j] k=next[j] 实质上是找模式串的字串 T 0 T 1 . . . T j − 1 T_0T_1...T_{j-1} T0T1...Tj1中的最长的相同前缀( T 0 T 1 . . . T k − 1 T_0T_1...T_{k-1} T0T1...Tk1)和后缀( T j − k T j − k + 1 . . . T j − 1 T_{j-k}T_{j-k+1}...T_{j-1} TjkTjk+1...Tj1

n e x t next next 数组的涵义详解:

  • j = 0 j=0 j=0 时, n e x t [ j ] next[j] next[j] 初始化为 − 1 -1 1
    • n e x t [ j ] = − 1 next[j]=-1 next[j]=1 表示不将当前失配位置 S i S_i Si 和模式串中的字符进行比较,而是把模式头部与主串的下一个字符进行比较。
  • j > 0 j>0 j>0 时, n e x t [ j ] next[j] next[j] 的值为:模式串前 j j j 的字符构成的字串 T 0 T 1 . . . T j − 1 T_0T_1...T_{j-1} T0T1...Tj1 中所出现的首尾相同的字串最大长度
    • 模式从 n e x t [ j ] next[j] next[j] 位和主串的失配位置进行比较。
    • 注意 n e x t [ j ] < j next[j]<j next[j]<j,即 k < j k<j k<j
  • j > 0 j > 0 j>0 且无首尾相同字串时, n e x t [ j ] = 0 next[j]=0 next[j]=0
    • n e x t [ j ] = 0 next[j]=0 next[j]=0表示,将模式串的 T 0 T_0 T0 与主串失配位置开始进行比较。
void GetNext(string T, vector<int>& next) {
    next.resize(T.size());

    int j=0, k=-1;
    next[0] = -1;       // 初始化
    while(j < T.size()) {
        if(k == -1 || T[j] == T[k]) {
            j++; k++;
            next[j] = k;
        } else {
            k = next[k];
        }
    }

}

算法时间复杂度:O(m)

说白了,就是从模式串当中的每一个字符 T j T_j Tj之前的 j j j个字符中,找最长的相同前缀和后缀的长度 k k k

KMP 算法实现

KMP 算法实现步骤:

  • 在主串 S S S 和模式 T T T 分别设比较的起始下标 i , j i,j i,j
  • 循环直到 S S S 中所剩的字符长度小于 T T T 的长度或 T T T 中所有字符均比较完毕
    • 如果 S [ i ] = = T [ j ] S[i] == T[j] S[i]==T[j],继续比较 S S S T T T 的下一个字符;
    • 否则将模式串“向右滑动”, j = n e x t [ j ] j=next[j] j=next[j],也就是将 k = n e x t [ j ] k=next[j] k=next[j] 处字符与当前主串失配位置 S i S_i Si 对齐;
      • 如果 j = = 0 j==0 j==0,那么将 i i i j j j 分别加 1,准备下一趟从头开始的比较。
      • 这里注意,如果 j = = 0 j==0 j==0,我们先是把 n e x t [ 0 ] = − 1 next[0]=-1 next[0]=1 给了 j j j j + + j++ j++ 之后相当于回到了模式串的初始字符。
  • 如果 T T T 中所有字符比较完毕,则返回主串中的起始下标;否则返回 0.
int StrMatch_KMP(string S, string T, int pos=0) {
    int i=pos, j=0;
    while(i < S.size() && j < T.size()) {
        if(S[i] == T[j]) {
            i++; j++;
        } else {
            j = next[j];
            if(j == 0) {    // 模式串的初始字符就匹配失败了
                i++;        // 此时就要略过主串的失配位置,向后移
                j++;        // 模式串位置也要从 -1 变成 0
            }
        }
    }
    if(j == T.size()) {
        return i-T.size();
    } else {
        return -1;
    }
}

本文 next 数组的实现和网上其他教程不太一样,但个人认为这种 next 数组的表示形式更容易理解一些,就是将每个位置 T j T_j Tj 之前的 T 0 T 1 . . . T j − 1 T_0T_1...T_{j-1} T0T1...Tj1最长的相同前缀和后缀的长度 k k k 存到数组当中,而且注意 1) k < j ;2) next[0]=-1这两点就好了。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值