4

算法介绍

  • KMP算法是一种改进的字符串匹配算法,由D.E.Kunth,J.H.Morris和V.R.Pratt提出,KMP算法的功能是在一个主文本字符串s中查找模式串t出现的位置。
  • 在KMP算法中,对于每一个模式串会先计算出模式串的内部匹配信息(即next数组),在匹配失败时主串不回溯,模式串向右移动,避免重新检查先前匹配的字符,加速了主串和模式串的匹配过程。

算法说明

设主串(下文中我们称作s)为:"a b a c a a b a c a b a b b"
模式串(下文中我们称为w)为:"a b a c a b"
用暴力匹配字符串的过程中,我们会把s[0]和t[0]匹配,如果相同则匹配下一个字符,直到出现不相同的情况,此时我们会丢弃前面的匹配信息,然后把s[1] 跟 t[0]匹配,循环进行,直到主串结束,或者出现匹配成功的情况。这种丢弃前面的匹配信息的方法,极大地降低了匹配效率。如下图所示:
图片.png
而KMP算法在匹配失败时,主串不回溯,即i值保持不变,模式串向右移动j - next[j] (next数组的求法和为啥这样移动后面会详细介绍,这里可以先混个眼熟~),j变成了next[j],下一次匹配时,s[i]和t[j]继续进行比较,直到匹配成功,具体过程如下图所示:
图片.png

next数组(重点,敲黑板!!!)

  • next数组的计算
    以t="aaabc"为例,我们先来看next[3]的含义,由t.substr(0,3)得到 的串tmp="aaa",next[3]表示tmp中最长相同的前缀和后缀的长度。tmp的前缀有"a"和"aa"两种,tmp的后缀也有"a"和"aa"两种,需要特别注意的是tmp串的前缀和后缀不能取tmp本身!显然,tmp的前缀和后缀相同的有两个,分别是"a"和"aa",但最长的是"aa",长度为2。因此,就本例而言next[3] = 2。
    默认规定所有的next[0] = -1, 针对本例t="aaabc"而言,按照上面的分析方法,很容易得出next[1] = 0, next[2] = 1, next[3] = 2, next[4] = 0
  • next数组的含义
    next数组实际上保存的是模式串自身内部匹配的信息。

KMP匹配过程

还是以图2中的s串和t串为例,当KMP第一次匹配失败时,i不变,j变成了next[j],然后第二次匹配时,s[i]继续和t[j]进行匹配,从图2可以看出,KMP第二次匹配时,是直接从t[1]开始匹配的,那如何保证t[1]之前的串和s串中的对应元素相同呢?具体问题如下图所示:
图片.png
显然,这里用到了模式串t自身的内部匹配信息,t的next数组如下:
图片.png
当KMP第一次匹配失败时,i = 5, j = 5, 此时根据t的自身内部匹配信息和与s的局部匹配信息将t的子串的前缀移动到后缀的位置,移动距离如下图所示,然后下一次匹配继续从s[i]和t[j]开始:
图片.png

代码实现

1.next数组的求法

vector<int> getNext(string t) {
// j表示当前位置的next值,具体含义是当前子串前缀和后缀相同的最大位数,初始化为-1
    int i = 0, j = -1, n = t.size();
    vector<int> next(n);
    next[0] = -1;
    while(i < n) {
    //当j = -1时表示当前子串没有相等的前缀和后缀
    //当t[i] = t[j]表示当前子串前缀的最后一位与后缀的最后一位相等,
    //此时,下一个next值为为当前next值加1(类似于dp的递推关系)
        if(j == -1 || t[i] == t[j]) {
            i++;
            j++;
            //
            next[i] = j;
        }
        // 如果匹配不成功,需要将已经匹配的前缀长度进行回退,
        // 直到直到适合的匹配长度
        else
            j = next[j];
    }
    return next;
}

2.KMP匹配过程

int KMP(string s, string t) {
    if(s.empty() || s.size() < t.size()) return -1;
    int i = 0, j = 0, m = s.size(), n = t.size();
    // 求t的next数组
    vector<int> next = getNext(t);
    while(i < m && j < n) {
        // 当j = -1说明j已经回退到模式串的起始位置,无法再次向前回退
        // 此时,需要将i移动到下一个位置,继续将s[i]和t[0]进行匹配
        // 当s[i] = t[j]时,当前位匹配成功,两个指针同时向后移动
        if(j == -1 || s[i] == t[j]) {
            i++;
            j++;
        }
        // 当两个字符不相等时,依据模式串的next数组,将指针j回退到next[j]
        // 相当于将模式串t右移j-next[j],然后继续将s[i]和t[j]进行匹配
        else
            j = next[j];
    }
    // 匹配成功,起始位置位i-j,否则返回-1,表示匹配失败
    return j == n ? i - j : -1;
}

KMP算法的改进

KMP算法的改进是由朱洪大佬完成的,他主要改进了next数组的求法,让主串和模式串匹配失败时,模式串移动更快。我们再来观察图2,当KMP第一次匹配失败时,s[i] = 'a', t[j] = 'b',模式串t向右移动后,第二次匹配时再比较s[i]和t[j],惊人地发现!!!移动后的t[j]仍然是'b',即t[j] = t[next[j]],显然,由于之前t[j]和s[i]已经匹配失败,再换一个和t[j]相同的t[next[j]]再比较,肯定失败,这就相当于多了一次毫无意义的比较。因此,再计算next值之前,先判断t[j] = t[next[j]]是否成立,如果成立,回退next值,让next[i] = next[j],此时再向右移动模式串时,就可以直接跳过这一次毫无意义的比较,代码如下:
vector<int> getNextVal(string t) {
    int i = 0, j = -1, n = t.size();
    // 一般习惯把改进后的next数组称为nextVal
    vector<int> nextVal(n);
    next[0] = -1;
    while (i < n) {
        if (j == -1 || t[i] == t[j]) {
            i++;
            j++;
            // 如果下一个元素和它的next值所在的元素相等,回退它的next值
            if (t[i] == t[j])
                nextVal[i] = next[j];
            else
                nextVal[i] = j;
        }
        else
            j = nextVal[j];
    }
    return nextVal;
}
  • 改进后的next数组
    图片.png
    注:红色表示和之前next数组相比发生了变化
  • 改进后的KMP匹配:由之前的三次匹配变成了两次匹配,如下图所示:

图片.png

KMP算法的时间复杂度分析

我们用摊还分析来看KMP算法:
关于匹配指针的位置cur
操作A: 匹配时,cur++;
操作B: 失配时, cur = next[cur],这个next[cur] <= cur是成立的。
根据势能分析(cur >= 0恒成立),我们可以证明,操作A的次数一定比操作B的次数要多,两个操作都是O(1)。而操作A的执行次数很容易分析最坏上界是O(n)。那么O(n) = T(A) >= T(B), 因此匹配的时间复杂度T(A+B) = O(n)。
对摊还分析还有疑问的可参考https://www.cnblogs.com/elpsy...

旅程到此就圆满结束了~~~

我是lioney,年轻的后端攻城狮一枚,爱钻研,爱技术,爱分享。
个人笔记,整理不易,感谢阅读、点赞和收藏。
文章有任何问题欢迎大家指出,也欢迎大家一起交流后端各种问题!

lioney
133 声望14 粉丝

引用和评论

0 条评论