【串】:一看就懂的KMP算法(六千字总结,多图预警)

1,765 阅读15分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第30天,点击查看活动详情

1、写在前面

大家好,我是翼同学。今天文章的内容是:

  • KMP算法

在学完算法与数据结构中的课程后,对KMP算法有种似懂非懂的感觉。今天就系统的再对KMP算法进行梳理和归纳,

2、串的模式匹配

在数据结构中,串属于线性结构,是一种数据元素为字符的特殊线性表。而串的特殊性在于,操作的对象不再是单个元素,而是一组元素。比如在线性表中查找某个元素,比如在线性表中,我们通常会查找、插入或删除某个元素,而在串中,我们还能在某个位置查找、插入或删除某段子串。即以“一段子串”为操作对象。

举个例子,假设我们现在有一段主串S(目标串)和子串P(模式串),此时的需求是在主串S中找到一个与子串P相等的子串,如果找到了就返回子串P在主串S中第一次出现的位置。

像这种子串的定位操作,通常被我们称为模式匹配(或模型匹配)。

3、BF算法

3.1、算法思路

对于子串的模式匹配,如果采用朴素的模式匹配Brute-Force算法,简称BF算法)思路,则算法思想如下:

  • 假设现在目标串S匹配到i位置,模式串P匹配到j位置
  • 如果此时S[i] == P[j](字符匹配成功),则继续匹配下一个字符,即i++, j++
  • 如果此时S[i]! = P[j](字符匹配失败),则使i回溯,j则重置为0,即i = i - (j - 1)j = 0
  • 不断进行朴素的模式匹配
  • 如果最后匹配成功,则返回模式串的第一个字符在目标串中第一次出现的位置
  • 如果匹配失败,则返回-1

3.2、参考代码

根据朴素的模式匹配算法思路,我们很容易写出代码。

参考代码如下:

// 设定s是目标串,p是模式串
int bfFind(char* s, char* p) {  
    int sLen = strlen(s);   // 目标串的长度
    int pLen = strlen(p);   // 模式串的长度
    int i = 0;              // 指向目标串的下标
    int j = 0;              // 指向模式串的下标
    while (i < sLen && j < pLen) {
        // 当字符相等时,指针后移,匹配下一个字符 
        if (s[i] == p[j]) {  
            i++;  
            j++;  
        }
        // 如果字符匹配失败,则指针i回溯,j则重置为0
        else {    
            i = i - j + 1;  
            j = 0;  
        }
    }
    // 如果匹配成功,则返回模式串 p 的第一个字符在目标串中出现的位置  
    if (j == pLen) {
        return i - j;
    }
    // 如果匹配失败,则返回-1 
    else{
        return -1;
    }
}  

3.3、示意图

首先我们设定:

  • 目标串为:PFABCDR GABCDE
  • 模式串为:ABCDE
  • 最后通过模式匹配后,结果返回9
  • 也就是说,模式串中的第一个字符(A)在目标串中出现的位置为s[9]

下面是朴素的模式匹配过程示意:

(1) 模式串不断往后移动

image.png

可以看到:

  • s[0]p
  • p[0]A
  • 此时s[0] != p[0],很明显字符不匹配,因此:
  • i回溯:i = i-(j-1),即0-(0-1)i变成了1
  • j重置为零:即j = 0
  • 这种效果其实就是将模式串s要往后移动一位

image.png

可以看到,将模式串往右边移动后:

  • s[1]F
  • p[0]A
  • 此时s[1] != p[0],字符不匹配,因此:
  • i回溯:i = i-(j-1),即1-(0-1),此时i变成了2
  • j重置为零:即j = 0
  • 模式串s不断往后移动。

(2) 暂时匹配成功

image.png

可以看到:

  • s[2]A
  • p[0]A
  • 此时s[2] == p[0],对应字符成功匹配,因此:
  • 两指针同时后移,即i++; j++;
  • 此时i=3, j=1,继续往后匹配。

image.png

可以看到:

  • s[3]B
  • p[1]B
  • 此时s[3] == p[1],对应字符成功匹配,因此:
  • 两指针同时后移,即i++; j++;
  • 此时i=4, j=2,继续往后匹配,不断重复这个过程...

(3) 再次匹配失败

image.png

可以看到:

  • s[6]R
  • p[4]E
  • 此时s[6] != p[4],对应字符再次不匹配,因此:
  • i回溯:i = i-(j-1),即6-(4-1)i变成了3
  • j重置为零:即j = 0
  • 此时模式串s又再次往后移动一位。

image.png

(4) 最后匹配成功

image.png

可以看到,到最后:

  • s[13]E
  • p[4]E
  • 此时s[14] != p[4],对应字符匹配,因此:
  • 两指针同时后移,即i++; j++;
  • 此时i=14, j=5
  • 因为不满足i < sLen && j < pLen,所以退出循环
  • 最后return i - j;,也就是返回10(目标串中A的下标就为10

(5) 小结

到这里,我们可以发现,对于朴素的模式匹配算法来说,我们很容易理解,代码简单易懂。但缺点也很大,就是i需要多次回溯。如果对于数据量较大的文件,朴素的模式匹配其实是效率很低的。为了提高模式匹配的算法效率,我们需要减少字符匹配的次数以及回溯的次数。

比如在前文给出的匹配过程中:

  • 虽然模式串和目标串已经成功匹配了四个字符ABCD
  • s[6] != p[4],即'R' != 'E'
  • 所以目标串就回溯到s[3],模式串则被重置为p[0]
  • 也就是将模式串向后移动一位,然后继续匹配(让s[3]p[0]进行字符匹配)

image.png

为了提高模式匹配的算法效率,下面我们来学习KMP算法

4、KMP算法

4.1、简介

KMP算法的简介如下:

  • KMP算法是一种改进的字符串匹配算法
  • D.E.KnuthJ.H.MorrisV.R.Pratt提出的
  • 因此人们称它为Knuth-Morris-Pratt算法(简称KMP算法)
  • KMP算法的核心是利用匹配失败后的信息
  • 尽量减少模式串与主串的匹配次数以达到快速匹配的目的
  • 具体实现就是通过一个next()函数实现
  • next()函数本身包含了模式串的局部匹配信息
  • KMP算法的时间复杂度O(m+n)O(m+n)

对于KMP算法来说,主要特点就是主串(目标串)不用回溯,主串指针i一直往后面移动,只有子串(模式串)的指针j在回溯。这就大大减少了模式匹配算法的比较次数以及回溯次数。KMP算法可以在O(m+n)O(m+n)的时间复杂度量级上完成串的模式匹配。

4.2、引入

回顾前文的匹配过程:

image.png

  • 这趟匹配中,在i=6, j=4下标处,对应字符不匹配,
  • 在朴素的模式匹配下,i, j都将回溯
  • 也就是又从i=3, j=0下标处重新开始进行匹配。

如下所示:

image.png

image.png

image.png

image.png

但实际上可以发现,i=3j=0i=4j=0i=5j=0这三趟字符匹配,完全没必要进行,也就是说可以省略掉这几次字符匹配的过程,将子串向右滑动3个字符,继续从i=6, j=0下标处开始匹配即可。

如下所示:

image.png

也就是说,在KMP算法下,当目标串下标为i的字符与目标串中下标为j的字符不匹配时,目标串指针i不回溯,模式串指针j回溯到一个合适的位置,重新进行比较(模式串相对于目标串,是向右移动的)。

明白了这个思路后,我们再来看看,模式串指针j应该滑动到哪个合适的位置才合适呢?

4.3、KMP算法思想

KMP算法采用以空间换时间的方式,申请一个与子串长度相等的整型数组next,令next[j] = k,则next[j]表示当子串中p[j]与主串中s[i]匹配失败时,在子串中需要重新和主串中s[i]进行匹配的字符的位置k。也就是说,当模式匹配时出现失配时,主串下标i不变,利用next数组求出子串下标为j的位置失配时需要滑动到的新位置k

这就是KMP算法的基本思想。

我们先不管next数组是怎么计算得到的,先看看KMP算法的具体流程。

如下所示:

  1. 设定主串匹配到s[i]处,子串匹配到p[j]
  2. 如果当前j = -1或者s[i] == p[j],则代表当前字符匹配成功,此时让指针变量都往后移动一位,继续匹配下一个字符
  3. 如果j != -1并且s[i] != p[j],则代表当前字符匹配失败,此时令i不变,而将j赋值为next[j],其实就是子串相对于主串向后移动了j - next[j]位。

因此在KMP算法中,当模式串的某个字符跟目标串中的某个字符不匹配时,next数组会告诉模式串下一步匹配时应当在哪个位置,而不是傻乎乎的将模式串往后移一位。

4.4、KMP算法代码

根据KMP算法思路,我们很容易写出代码。

参考代码如下:

// 设定 s 是目标串, p 是模式串
int KMPFind(char* s, char* p) {  
    int sLen = strlen(s);   // 目标串的长度
    int pLen = strlen(p);   // 模式串的长度
    int i = 0;              // 指向目标串的下标
    int j = 0;              // 指向模式串的下标
    // 定义next数组
    int *next = new int [pLen];
    getNext(next, p);
    while (i < sLen && j < pLen) {
        // 当字符相等时,指针后移,匹配下一个字符 
        if (j == -1 || s[i] == p[j]) {  
            i++;  
            j++;  
        }
        // 如果 j!=-1 并且s[i] != p[j],则表示字符匹配失败
        // 此时指针 i 不回溯,j 则重置为next[j]
        else {    
            j = next[j];
        }
    }
    // 如果匹配成功,则返回模式串 p 的第一个字符在目标串中出现的位置  
    if (j == pLen) {
        return i - j;
    }
    // 如果匹配失败,则返回-1 
    else{
        return -1;
    }
}

4.5、next 数组

(1) 求解 k 的值

现在我们假设:

  • 主串SS = "S0S_0 S1S_1 S2S_2 ... Sn1S_{n-1}"
  • 子串PP = "P0P_0 P1P_1 P2P_2 ... Pm1P_{m-1}"

则在KMP算法中,当 SiS_iPjP_j 匹配失败时,主串不回溯,而子串中的第 kk 个字符 PkP_k 将与主串 SiS_i 继续进行匹配。

示意图如下:

  • 主串:

image.png

  • 子串:

image.png

  • 如果子串中"P0P_0 P1P_1 ... Pj1P_{j-1}"已经完成匹配
  • 但此时 PjP_j 不等于 SiS_i 时,匹配失败后
  • 此时主串指针i不用动
  • 子串也不用重置为0,不用从头开始匹配
  • 而是将子串回溯到下标为k的位置与主串继续进行匹配:
  • 示意图如下:

image.png

也就是说,当发生字符匹配失败时,仅需将子串向右滑动至子串中下标为k的字符,与主串中下标为i的字符对齐,继续进行匹配即可。这是因为子串前面的k个字符必定与主串中下标为i的字符前长度为k的子串相等。

到这里,你就会发现,KMP算法的关键问题已经变成了如何求解 k 的值。

(2) 最长相等前后缀

首先记录一下什么是前后缀子串:

  • 前缀:指的是不包含最后一个字符的所有以第一个字符开头的连续子串
  • 后缀:指的是不包含第一个字符的所有以最后一个字符结尾的连续子串

正确理解前后缀子串的含义,对于后续理解next数组来说有很大的帮助。


前文简述了k值的重要性,可能看起来不太直观。现在我们来看一道例题:

  • 主串 SS 为:"abc123abc123abcd"
  • 子串 PP 为:"123abcd"
  • 在串的模式匹配中,会出现这样的情况,如下图:

image.png

可以看到,s[9] != p[9],此时如果是朴素的模式匹配算法,则i会回溯到1,j则会重置为0。但是在KMP算法中,主串指针i不动,子串指针j会回溯到k的位置(实际上k的值就是next[j]),如下图所示:

image.png

因此,为了确定k的值:

  • 我们会在子串P中寻找最长相同的
  • 前缀子串 "P0P_0 P1P_1 ... Pk1P_{k-1}"
  • 以及后缀子串 "PjkP_{j-k} Pjk+1P_{j-k+1} ... Pj1P_{j-1}"。
  • 如果找到了最长的相同的前后缀子串,则k的值就可以确定了。

(3) 什么是next数组?

无论是k值,还是最长相等前后缀子串,都是为理解next数组而服务。

那么next数组到底是什么?

其实,next数组就是用来让模式串指针j进行合理地回溯,其记录了模式串与目标串不匹配时,模式串应该从哪里开始重新匹配。

而前文提到的k值,就是next[j]

  • KMP算法中,我们申请一个整型数组next
  • 并且令next[j] = k
  • next[j]表示当子串中 PjP_j 和主串中 SiS_i 失配时,
  • 在子串中需要重新和主串中的 SiS_i 进行匹配的字符下标为k

也就是说,next数组要求的就是最长相同前后缀的长度。

因此,每个字符对应的next数组值,其实就是该字符之前的子串中包含最大长度的相同前后缀子串。注意,理解这一句话很重要。

(4) 如何获取next数组?

下面给出获取next数组的参考代码:

void getNext(char* p, int next[]) {
    int pLen = strlen(p);   // 取到子串的长度 pLen 
    next[0] = -1;           // next[0]初始化为-1, 表示子串已经滑动到头 
    int k = -1;             // p[k]表示前缀子串
    int j = 0;              // p[j]表示后缀子串
    // 遍历子串
    while (j < pLen - 1) { 
        if (k == -1 || p[j] == p[k]) {  
            ++k;  
            ++j;  
            next[j] = k;  
        }  
        else {  
            k = next[k];  
        }  
    }  
}  

备注:

  • 提问:为什么next[0]要初始化为-1
  • 回答:这是为了表示当子串指针j指向下标为0的元素在匹配失败后进行下一轮匹配时,子串指针j仍然可以指向下标为0的元素。

(5) 例子

现在给出几张next数组求解的示意图,帮助理解next数组的求解过程。

  • 假设现在有一段模式串 P = abc123abcd",任务是求出其next数组:

image.png

# 初始化

  • k = -1j = 0,并且next[0] = -1

image.png

# 第1轮循环

  • 初始化工作完成后,就开始第一轮循环:
  • 此时k = -1刚好满足if语句的判断条件
  • 则直接执行if语句块,jk先自增,即k = 0, j = 1
  • 接着就是next数组元素的赋值:next[j] = k
  • 因此next[1]就等于0
  • 过程如下:

image.png

# 第2轮循环

  • 进入第二轮循环后,由于k = 0并且p[j] = "b"p[k] = "a",二者并不相等
  • 因此不满足if判断语句,此时执行else语句块:
  • k = next[k], 即k = next[0]。因此k就又变成了-1
  • 过程如下:

image.png

# 第3轮循环

  • 进入第三轮循环后,由于k = -1,满足if语句的判断条件
  • 因此执行if语句块,jk先自增,即j = 2k = 0
  • 接着就是next数组元素的赋值:next[j] = k,即next[2] = 0
  • 过程如下:

image.png

# 第4轮循环

  • 进入第四轮循环后
  • 由于j = 2, k = 0并且p[j] = "c"p[k] = "a",二者并不相等
  • 因此不满足if判断语句,此时执行else语句块:
  • k = next[k], 即k = next[0]。因此k就又变成了-1
  • 过程如下:

image.png

# 第5轮循环

  • 进入第五轮循环后,由于k = -1,满足if语句的判断条件
  • 因此执行if语句块,jk先自增,即j = 3k = 0
  • 接着就是next数组元素的赋值:next[j] = k,即next[3] = 0
  • 过程如下:

image.png

# 第6轮循环

  • 进入第六轮循环后
  • 由于j = 3, k = 0并且p[j] = "1"p[k] = "a",二者并不相等
  • 因此不满足if判断语句,此时执行else语句块:
  • k = next[k], 即k = next[0]。因此k就又变成了-1
  • 过程如下:

image.png

# 第7轮循环

  • 进入第七轮循环后,由于k = -1,满足if语句的判断条件
  • 因此执行if语句块,jk先自增,即j = 4k = 0
  • 接着就是next数组元素的赋值:next[j] = k,即next[4] = 0
  • 过程如下:

image.png

# 第8轮循环

  • 进入第八轮循环后
  • 由于j = 4, k = 0并且p[j] = "2"p[k] = "a",二者并不相等
  • 因此不满足if判断语句,此时执行else语句块:
  • k = next[k], 即k = next[0]。因此k就又变成了-1
  • 过程如下:

image.png

# 第9轮循环

  • 进入第九轮循环后,由于k = -1,满足if语句的判断条件
  • 因此执行if语句块,jk先自增,即j = 5k = 0
  • 接着就是next数组元素的赋值:next[j] = k,即next[5] = 0
  • 过程如下:

image.png

# 第10轮循环

  • 进入第十轮循环后
  • 由于j = 5, k = 0并且p[j] = "3"p[k] = "a",二者并不相等
  • 因此不满足if判断语句,此时执行else语句块:
  • k = next[k], 即k = next[0]。因此k就又变成了-1
  • 过程如下:

image.png

# 第11轮循环

  • 进入第十一轮循环后,由于k = -1,满足if语句的判断条件
  • 因此执行if语句块,jk先自增,即j = 6k = 0
  • 接着就是next数组元素的赋值:next[j] = k,即next[6] = 0
  • 过程如下:

image.png

# 第12轮循环

  • 进入第十二轮循环后
  • 由于j = 6, k = 0
  • 并且此时p[j] = "a"p[k] = "a":二者相等!字符匹配成功!
  • 因此满足if判断语句,此时执行if语句块:
  • jk先自增,即j = 7k = 1
  • 接着就是next数组元素的赋值:next[j] = k,即next[7] = 1
  • 过程如下:

image.png

# 第13轮循环

  • 进入第十三轮循环后
  • 由于j = 7, k = 1
  • 并且此时p[j] = "b"p[k] = "b":二者相等!字符匹配成功!
  • 因此满足if判断语句,此时执行if语句块:
  • jk先自增,即j = 8k = 2
  • 接着就是next数组元素的赋值:next[j] = k,即next[8] = 2
  • 过程如下:

image.png

# 第14轮循环

  • 进入第十四轮循环后
  • 由于j = 8, k = 2
  • 并且此时p[j] = "c"p[k] = "c":二者相等!字符匹配成功!
  • 因此满足if判断语句,此时执行if语句块:
  • jk先自增,即j = 9k = 3
  • 接着就是next数组元素的赋值:next[j] = k,即next[9] = 3
  • 过程如下:

image.png

# 退出循环

至此,由于j = 9 不满足循环条件while(j < pLen-1),因此退出循环。

所以最后,我们也得到了模式串Pnext数组,如下所示:

image.png

5、总结

总结一下,对于串的模式匹配中:

  • BF算法(朴素的模式匹配,简称暴力检索)中,当主串和子串发生失配时,主串和子串的指针都需要回溯,然后再进行匹配。所以该算法的时间复杂度较高,达到O(nm)O(nm),空间复杂度为O(1)O(1)
  • KMP算法中,我们设计了一个next数组,用于达到主串不回溯,让子串指针有规律回溯的目的,这样就减少了回溯和匹配的次数,大大提升了模式匹配的效率。简单来说,KMP的算法思想是当出现对应字符不匹配时,可以先记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。但这是建立在牺牲了存储空间的基础上进行的。KMP算法的时间复杂度为O(n+m)O(n+m),空间复杂度为O(n)O(n)

6、刷题巩固

6.1、找出字符串中第一个匹配项的下标

学完KMP算法后,可以尝试刷这道题:找出字符串中第一个匹配项的下标,我们可以用KMP算法去解这道题。

(1) 描述

image.png

(2) 举例

image.png

image.png

(3) 提示

image.png

6.2、利用KMP算法解题

参考代码(C++版)如下:

class Solution {
public:
    int strStr(string haystack, string needle) {
        int sLen = haystack.length(); // 获取主串的长度
        int pLen = needle.length();   // 获取子串的长度
        // 如果模式串的长度为0,则返回0
        if(pLen == 0) return 0;
        int next[pLen];         // 定义next数组,用模式串的长度去赋值数组的大小
        getNext(next, needle);  // 为next数组的数据元素赋值
        int i = 0;   // 主串指针
        int j = 0;   // 子串指针
        // 循环遍历
        while(i < sLen && j < pLen) {
            // 当字符相等时,指针后移,匹配下一个字符 
            if (j == -1 || haystack[i] == needle[j]) {  
                i++;  
                j++;  
            }
            // 如果 j!=-1 并且s[i] != p[j],则表示字符匹配失败
            // 此时指针 i 不回溯,j 则重置为 next[j]
            else {    
                j = next[j];
            }
        }
        // 如果匹配成功,则返回模式串 p 的第一个字符在目标串中出现的位置  
        if (j == pLen) {
            return i - j;
        }
        // 如果匹配失败,则返回-1 
        else{
            return -1;
        }
    }
    // 自定义函数,用于获取 next 数组
    void getNext(int* next, string& p) {
        int pLen = p.length();
        int k = -1;
        int j = 0;
        next[0] = -1;
        while(j < pLen - 1) {
            if(k == -1 || p[j] == p[k]) {
                k++;
                j++;
                next[j] = k;
            } else {
                k = next[k];
            }
        }
    }
};

image.png

7、写在最后

好了,关于串的模式匹配,就先记录到这里,如果文章有出错的地方,请大佬们指出。今天文章的内容就写到这里,感谢观看。