【图解算法】KMP算法及其应用

1,128 阅读4分钟

KMP这算法大家上数据结构课的时候应该都有学过,彼时痛苦的记忆应当记忆犹新,这里我们再来重温一下,力求以最清晰的思路解决它,并且掌握其变形题目。

文章结构

  1. 详解 KMP 算法
  2. KMP变形题(2018年京东校招)
  3. KMP变形题(树的子树)

问题描述

我们都知道 KMP 算法是为了解决这样一个问题:有字符串 str1(主串),求字符串 str2(子串) 在str1 中相同的子串的起始位置,如果没有,则返回 -1。具体而言,我们可以来看几个例子:

str1 = "abacde";
str2 = "acd";
// 那么,KMP(str1, str2) = 2,也就是 str1 中第二个 a 的位置

来看另一个例子:

str1 = "abcdefg";
str2 = "cat";
// 那么,KMP(str1, str2) = -1,也就是 str1 中没有 str2,返回 -1

暴力解法

在介绍KMP算法之前,我们有必要了解暴力的求解方法。

如下图所示,x指向主串的第一个字符,y指向目标子串的第一个字符,假设目标子串长度为len(下图中为3),记录此时x的位置loc_x,如果x==y,那么xy就一同向后移动,直到二者不同或者y达到目标子串的末尾。 当x不等于y时,说明从当前loc_x位置开始的len个字符与目标子串不完全匹配,那么我们将loc_x往后移动一位,同时再更新xloc_x,再将y复原到目标子串的起始位置,进行第二轮比较。

暴力求解 如下图所示,x不等于yx和y一同后移

我们要更新loc_x = loc_x + 1,同时x = loc_xy = 0

第二轮比较

可以看到,上图的xy依然不同,那么目标子串继续往右推:

第三轮比较

那么接下来xy不断地比较->相等->往后移,直到y达到目标子串的最后,说明已经在主串中找到了和目标子串一模一样的子串,此时返回loc_x即可。

如果当loc_x达到主串的末尾,还是不能找到的话,那么就说明主串中不可能存在目标子串,此时返回-1

代码实现

暴力法的代码非常好写,毕竟暴力美嘛。

这里的i实际上就是前面讲的loc_x,而xy我们则分别用i+jj来代替了。

int getSubStr(string &str1, string &str2) {
    int n = str1.length();
    int m = str2.length();
    for (size_t i = 0; i < n-m; ++i) {
        size_t j;
        for (j = 0; j < m; ++j) {
            if (str1[i + j] != str2[j]) // x != y,说明当前的loc_x是错的,直接break进入下一轮
                break;
        }
        if (j == str2.length()) // 说明 y 达到目标子串末尾了,代表找到了
            return i;
    }
    // 遍历完还没找到,说明找不到了
    return -1;
}

很显然,这种算法的时间复杂度是O(M * N)MN分别是主串和目标子串的长度。

KMP 算法

在此,我们需要将KMP算法划分为几个部分,好让大家由整体到局部的深入掌握。

最长公共前后缀

首先我们需要掌握这么一个概念:最长公共前后缀,即给定一个字符串,该字符串的前缀与后缀相同的最长部分,前缀与后缀可以相交。

假如有一个字符串abcdcab,那么它的最长公共前后缀就是ab,长度为2,如下图所示:

公共前后缀为ab

这里有一个小限制,前缀不包含最后一个字符,后缀不包含第一个字符,如例子:aaaaa,它的最长公共前后缀就是aaaa,长度为4

公共前后缀为aaaa

有这两个例子,大家应该对这个概念有一个感性的认识了,我们来看一个例子:

假设主串为:aaaaaab,目标子串为aab,如果我们使用前面讲述的暴力法,如下图所示:

暴力法

我们要注意这里第二轮进行比较时,是完全抛弃了第一轮比较获取的信息的,第二轮的第一步比较,实际上在第一轮中的第二步已经比较过了,这实质上是由于 x 的回滚导致的;而KMP算法则是使用我们前面所说的公共前后缀来进行加速,保证 x 不进行回滚,在了解具体实现之前,我们不得不再看一个由公共前后缀组成的next数组:

Next 数组

next数组存放的是子串中对应的每一个字符,其前面所有字符组成的字符串的最长公共前后缀的长度

以我们上面这个例子为例,子串为aab,那么其next数组,如下图所示,第一个字符前面没有字符,所以我们人为规定其对应的值为-1,第二个字符的前面只有一个字符a ,显然前后缀都是空,因此长度为0,在任何next数组中,其前两位我们都规定为-10

next数组的默认部分

接下来第三个字符前的字符串是aa,显然公共前后缀长度为1

完整的next数组

在这里大家不用深究next怎么求的,后面会详细讲解,这里先继续看:

我们已经获取了子串的next数组,在第一轮比较后,我们并不把x回滚,而是将y进行回滚到next[y],或者可以认为将子串向右滑动,我们前面已经求得了子串的next数组,当x指向的字符ay指向的字符b不同时,我们知道bnext数组中的值为1,因此将y回滚到子串的1字符(从0开始)。

这里实际上我们已经不需要loc_x的概念了,这里依然标红是为了方便比较,可以看到,此处加速省去了暴力法中第二轮的第一步比较,也就是前面所说的重复的那一步。

使用Next数组加速

那么这样做的依据是什么呢?

我们前面说过,next数组存的是其对应字符前的所有字符组成的字符串的最长公共前后缀长度,也就是说,bnext数组对应值1,说明了其前面的字符串aa中,公共前后缀长度是1,因此,如下图所示:

图中子串的前缀和后缀是相同的,此外,在前面的比较中我们知道了主串和子串中各自的前缀与各自的后缀也是相同的,因此所有前后缀都是相同的。而我们滑动子串的操作,实际上是把子串的前缀去对应主串中的后缀,因此二者一定是相同的。

本质是前后缀相同

我们换个例子加深印象:

next值为2的例子

事实上,KMP算法除了省略了在某一轮中子串前缀与主串后缀的比较外,还省略了许多轮,如上图,我们可以发现第二轮的loc_x的位置实际上在第三个,说明第二个位置的可能性已经被排除,至于为什么第二个位置的可能性可以被排除,这里我画了几张图进行解释:

首先是被忽略的起始点如下图所示:

KMP算法优化的部分

接下来我们采用反证法,假设红点中的部分作为起点,有可能存在与子串完全匹配的情况:

那么图中就会产生新的两个部分相同:

反证法,使得存在新的两个相同的部分

注意,这两个部分实际上可以视为前缀和后缀,而且新前缀比之前的前缀还要长,而在我们一开始求解最长公共前后缀是正确的前提下,那么这种情况必然不可能存在,因此以k为起点是不可能找到匹配的情况的,所以我们直接跳过它们。

至于图中新前缀和新后缀看起来不是等长的,实际上,...所代表的字符是不定长的,二者实际上是等长的。

得到谬误

此时,我们已经了解了next数组,也对KMP算法的核心优化点进行了证明,在假设拥有了next数组的情况下,我们来看看,KMP算法应当如何进行。

KMP算法的核心步骤

实际上核心部分相当容易,首先,x指向主串的第一个字符,y指向子串的第一个字符,如果二者相等,那么xy均向前移动;如果二者不等, 那么y就回滚到next[y]的位置,直到next[y] < 0,已经无法向前回滚了,说明主串和子串的第一个字符就不一样,那么就要x向前移动,进入下一轮。

我们的x指针只向前,不后退,而y指针在二者所指向的字符不匹配时,会向前回滚,

在前面的例子中,我们实际上已经演示过了这个过程,这里我们着重看一下代码:

int KMP(string &str1, string &str2) {
    if (str1.empty() || str2.empty() || str1.length() < str2.length()) {
        return -1;
    }
    int x = 0, y = 0;
    vector<int> next = getNextArr(str2); // 获取 next 数组,这里假设我们已经完成了
    while (x < str1.length() && y < str2.length()) {
        if (str1[x] == str2[y]) { // 字符相同,一起向前推进
            x++;
            y++;
        } else if (next[y] < 0) { // y 回滚到了最前面,只能进入下一轮
            x++;
        } else { // y 向前回滚
            y = next[y];
        }
    }
    // 判断是否遍历了 y
    return y == str2.length() ? x - y : -1;
}

理解了这个过程,KMP算法就只差最后一步了,如何求解next数组?

Next 数组的求解

前两位固定值:-1、0,从第三个开始,我们要比较当前位置的前一个字符,与上一个前缀的下一个字符是否匹配,如果匹配,说明前后缀长度+1,否则需要回溯到上上一个前缀,如果还是不匹配,就继续回溯,直到无法回溯,说明不存在公共前后缀,则长度为0

我们以一个长一点的例子来进行演示:子串:ababcabak

从第三个字符开始

如上图所示,我们每次比较黄框和绿框中的字符,绿框表示所求字符的前一个字符,黄框表示绿框字符所对应的next值指向的字符,比如第三轮中,我们要求cnext值,其前一个字符就是绿框b,其对应的next值为1,也指向黄框字符b。两者相等,则停止比较,cnext值等于绿框字符的next+1,如果不相等,则需要判断黄框是否到达首位,达到则停止比较。

接下来我们继续看黄框未到达首位的情况:

黄框未到达首位的情况

可以看到,比较过程中绿框位置不变,而黄框会根据比较情况,决定是否向前回溯继续比较,而回溯的位置的依据则是当前黄框所对应的next值。

这个例子的完整结果如下,大家可以自己算算比对一下:

完整结果

接下来我们看看代码:

vector<int> getNextArr(string &str1) {
    vector<int> next(str1.length()); // 初始化 next 数组
    next[0] = -1;
  	if (str1.length() == 1)
    		return next;
    next[1] = 0;
    int i = 2;
    int cn = 0; // 即黄框
    while (i < str1.length()) {
        if (str1[i - 1] == str1[cn]) { // 黄框与绿框的值相同
            next[i++] = ++cn;
        } else if (cn > 0) { // 黄框向前回溯
            cn = next[cn];
        } else { // 黄框已经无法向前回溯,那么当前的 next 值就是 0 
            next[i++] = 0;
        }
    }
    return next;
}

KMP变形题(2018年京东校招)

题目描述:

给定一个字符串s,请计算输出含有连续两个s作为子串的最短字符串。注意两个s可能有重叠部分。例如,ababa含有两个aba

输入描述:

输入一个字符串s,字符串长度length(1 ≤ length ≤ 50)s中每个字符都是小写字母。

输出描述:

输出一个字符串,即含有连续两个s作为子串的最短字符串

示例:

输入

aba

输出

ababa


这道题跟KMP有什么关系?一开始我也有点懵,显然不能直接套KMP。其实这道题的关键是KMP算法中的next数组,我们都知道next数组求解的是该字符之前的字符串的公共前后缀长度,如果我们能求出原字符串的公共前后缀长度,那么就只需要以这个长度为起点,复制剩下的部分到末尾即可。

如下图所示:

多一位的next数组

那么next数组的最后一位就是原字符串的公共前后缀的长度,这里为1,那么我们只要把1到末尾的原字符串复制到原字符串最后即可,如下图红线所示:

以公共前后缀长度作为起始位置

代码:

string jd2018(string &str1) { // 稍微改造 getNextArr 方法
    vector<int> next(str1.length() + 1); // 增加 next 数组长度
    next[0] = -1;
    next[1] = 0;
    int i = 2;
    int cn = 0;
    while (i <= str1.length()) { // 多存放一个
        if (str1[i - 1] == str1[cn]) {
            next[i++] = ++cn;
        } else if (cn > 0) {
            cn = next[cn];
        } else {
            next[i++] = 0;
        }
    }
  	// 返回原字符串 + 新复制的部分
    return str1 + str1.substr(next[str1.length()]);
}

KMP变形题(树的子树)

题目描述:

给定两个非空二叉树st,检验s中是否包含和t具有相同结构和节点值的子树。s的一个子树包括 s 的一个节点和这个节点的所有子孙。s也可以看做它自身的一棵子树。

示例:

下图的t树为s树的子树,返回True

返回True

下图的t树不为s树的子树,返回False

返回False


这道题实际上我们只需要将二叉树序列化,再使用KMP算法判定即可,比如上图的s树可以序列化为1_2_4_#_#_5_#_#_3_6_#_#_#_,而t树序列化为2_4_#_#_#_,显然不满足,而第一个例子中的t序列化为2_4_#_#_5_#_#_,显然满足。

序列化在二叉树文章中已讲过,这里就不写代码了。