数据结构与算法之深入解析KMP算法的核心原理和实战演练

Posted Forever_wj

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构与算法之深入解析KMP算法的核心原理和实战演练相关的知识,希望对你有一定的参考价值。

一、简介

① 概念
  • KMP 算法是一种改进的字符串匹配算法,由 D.E.Knuth,J.H.Morris 和 V.R.Pratt 提出的,因此称它为克努特—莫里斯—普拉特操作,简称 KMP 算法。
  • KMP 算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是通过一个 next() 函数实现,函数本身包含了模式串的局部匹配信息。KMP 算法的时间复杂度 O(m+n)。
  • KMP 算法是三位学者在 Brute-Force 算法的基础上同时提出的模式匹配的改进算法,Brute- Force 算法在模式串中有多个字符和主串中的若干个连续字符比较都相等,但最后一个字符比较不相等时,主串的比较位置需要回退。KMP 算法在上述情况下,主串位置不需要回退,从而可以大大提高效率。
② 字符串的模式匹配
  • 字符串的模式匹配是一种常用的运算。所谓模式匹配,可以简单地理解为在目标(字符串)中寻找一个给定的模式(也是字符串),返回目标和模式匹配的第一个子串的首字符位置。
  • 通常目标串比较大,而模式串则比较短小。
③ 模式匹配的类型
  • 精确匹配
    • 如果在目标 T 中至少一处存在模式 P,则称匹配成功,否则即使目标与模式只有一个字符不同也不能称为匹配成功,即匹配失败。
    • 给定一个字符或符号组成的字符串目标对象 T 和一个字符串模式 P,模式匹配的目的是在目标 T 中搜索与模式 P 完全相同的子串,返回 T 和 P 匹配的第一个字符串的首字母位置。
  • 近似匹配
    • 如果模式 P 与目标 T(或其子串)存在某种程度的相似,则认为匹配成功。常用的衡量字符串相似度的方法是根据一个串转换成另一个串所需的基本操作数目来确定。
    • 基本操作由字符串的插入、删除和替换来组成。
④ KMP 模式匹配算法
  • KMP 算法是一种改进的字符串匹配算法,其关键是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的明。
  • 求得模式的特征向量之后,基于特征分析的快速模式匹配算法(KMP 模式匹配算法)与朴素匹配算法类似,只是在每次匹配过程中发生某次失配时,不再单纯地把模式后移一位,而是根据当前字符的特征数来决定模式右移的位数。
⑤ 改进的 KMP 算法
  • 复旦大学朱洪教授对 KMP 串匹配算法进行了改进,他主要是修改了 next() 函数,在求 next[j] 时,不但要求 P [i]=P[j-( next[j]-i)](i=1,2,…, next [j]-1) 成立,而且要求 P[next[j]]!=p[j]。
  • 把修改后的 next 函数计作 Newnext,则计算函数 Newnext 值,算法如下:
	// 全局变量 newnext 保存模式串的匹配下标值
	void get-newnext() {
		int k,j;
		newnext[1] = 0;
		j = 2;
		while(j <= m) {
			k = next[j];
			if (k == 0 || p[k] != p[j]) {
				newnext[j] = k;
			} else {
				newnext[j] = newnext[k];
			}
			j = j + 1;
		}
	}

二、KMP 提取加速匹配的信息

  • KMP 算法主要是通过消除主串指针的回溯来提高匹配的效率的,那么它是怎样来消除回溯的呢?这是因为它提取并运用了加速匹配的信息。
  • 对于每模式串 t 的每个元素 t j,都存在一个实数 k ,使得模式串 t 开头的 k 个字符(t 0 t 1…t k-1)依次与 t j 前面的 k(t j-k t j-k+1…t j-1,这里第一个字符 t j-k 最多从 t 1 开始,所以 k < j)个字符相同,如果这样的 k 有多个,则取最大的一个。模式串 t 中每个位置 j 的字符都有这种信息,采用 next 数组表示,即 next[ j ]=MAX{ k }。
  • 加速信息,即数组 next 的提取是整个 KMP 算法中最核心的部分。
	void Getnext(int next[],String t) {
	   int j=0,k=-1;
	   next[0]=-1;
	   while(j<t.length-1) {
	      if(k == -1 || t[j] == t[k]) {
	         j++;k++;
	         next[j] = k;
	      } else k = next[k];
	   }
	}
  • 分三种情况来阐述 next 的求解过程:
    • 特殊情况:当 j 的值为 0 或 1 的时候,它们的 k 值都为 0,即 next[0] = 0、next[1] =0。但是为了后面 k 值计算的方便,将 next[0] 的值设置成 -1。
    • 当 t[j] = = t[k] 的情况:当 t[j] = = t[k] 时,必然有 t[0]…t[k-1] = = t[j-k]…t[j-1],此时的 k 即是相同子串的长度。因为有 t[0]…t[k-1] = = t[j-k]…t[j-1],且 t[j] = = t[k],则有 t[0]…t[k] == t[j-k]…t[j],也就得出了 next[j+1]=k+1。

    • 当 t[j] != t[k] 的情况:当 t[j] = = t[k] 时,t[j+1] 的最大子串的长度为 k,即 next[j+1] = k+1,但此时 t[j] != t[k] ,所以就有 next[j+1] < k,那么求 next[j+1] 就等同于求 t[j] 往前小于 k 个的字符(包括 t[j],下图蓝色部分)与 t[k] 前面的字符(下图绿色部分)的最长重合串,即 t[j-k+1] ~ t[j] 与 t[0] ~ t[k-1] 的最长重合串(这里所说“最长重合串”实不严谨,但知道是符合 k 的子串就行),那么就相当于求 next[k](只不过 t[k] 变成了 t[j],但是 next[k] 的值与 t[k] 无关),因此才有了这句 k = next[k],如果新的一轮循环(这时 k = next[k] ,j 不变)中 t[j] 依然不等于 t[k] ,则说明倒数第二大 t[0~next[k]-1] 也不行,那么 k 会继续被 next[k] 赋值(这就是所谓的 k 回退),直到找到符合重合的子串或者 k == -1。

三、KMP 算法的实现

  • 以目标串 s (指针为 i)、模式串 t (指针为 j) 为例:
    • s i-j ~ s i-1 = = t 0 ~ t j-1,s I != t j(前面都相等,但比较到 t j 时发现不相等)且next[j] == k:

    • 根据 next 数组的定义得知 t k ~ t j-1 = = t 0 ~ t k-1,所以 t 0 ~ t k-1 == s i-k~ s i-1

    • 将模式串右移,得到下图:

	int KMP(String s,String t) {
	   int next[MaxSize],i=0;j=0;
	   Getnext(t,next);
	   while(i<s.length&&j<t.length) {
	      if(j==-1 || s[i]==t[j]) {
	         i++;
	         j++;
	      } else j=next[j];     // j回退
	   }
	   if(j>=t.length)
	       return (i-t.length); // 匹配成功,返回子串的位置
	   else
	      return (-1);          // 没找到
	}

  • 先来看一下上面算法存在的缺陷,上边的算法得到的 next 数组应该是[ -1,0,0,1 ];

  • 把 j 移动到第 1 个元素:

  • 不难发现,这一步是完全没有意义的。因为后面的 B 已经不匹配了,那前面的 B 也一定是不匹配的,同样的情况其实还发生在第 2 个元素 A 上。显然,发生问题的原因在于 t[j] == t[next[j]]。因此,需要添加一个判断,如下:
	void Getnext(int next[],String t) {
	   int j=0,k=-1;
	   next[0]=-1;
	   while(j<t.length-1) {
	      if(k == -1 || t[j] == t[k]) {
	         j++;k++;
	         if(t[j]==t[k]) // 当两个字符相同时,就跳过
	            next[j] = next[k];
	         else
	            next[j] = k;
	      }
	      else k = next[k];
	   }
	}

四、KMP 与暴力法的对比

  • 暴力的字符串匹配算法很容易写,看一下它的运行逻辑:
	// 暴力匹配(伪码)
	int search(String pat, String txt) {
	    int M = pat.length;
	    int N = txt.length;
	    for (int i = 0; i <= N - M; i++) {
	        int j;
	        for (j = 0; j < M; j++) {
	            if (pat[j] != txt[i+j])
	                break;
	        }
	        // pat 全都匹配了
	        if (j == M) return i;
	    }
	    // txt 中不存在 pat 子串
	    return -1;
	}
  • 对于暴力算法,如果出现不匹配字符,同时回退 txt 和 pat 的指针,嵌套 for 循环,时间复杂度 O(MN),空间复杂度O(1)。最主要的问题是,如果字符串中重复的字符比较多,该算法就显得很蠢。
  • 比如 txt = “aaacaaab” pat = “aaab”,很明显,pat 中根本没有字符 c,根本没必要回退指针 i,暴力解法明显多做了很多不必要的操作。如下所示:

  • KMP 算法的不同之处在于,它会花费空间来记录一些信息,在上述情况中就会显得很聪明:

  • 再比如类似的 txt = “aaaaaaab” pat = “aaab”,暴力解法还会和上面那个例子一样蠢蠢地回退指针 i,而 KMP 算法又会耍聪明:

  • 因为 KMP 算法知道字符 b 之前的字符 a 都是匹配的,所以每次只需要比较字符 b 是否被匹配就行了。
  • KMP 算法永不回退 txt 的指针 i,不走回头路(不会重复扫描 txt),而是借助 dp 数组中储存的信息把 pat 移到正确的位置继续匹配,时间复杂度只需 O(N),用空间换时间,所以我认为它是一种动态规划算法。
  • KMP 算法的难点在于,如何计算 dp 数组中的信息?如何根据这些信息正确地移动 pat 的指针?这个就需要确定有限状态自动机来辅助了,别怕这种“高大上”的文学词汇,其实和动态规划的 dp 数组如出一辙,等你学会了也可以拿这个词去“吓唬”别人。
  • 还有一点需要明确的是:计算这个 dp 数组,只和 pat 串有关。意思是说,只要给我个 pat,我就能通过这个模式串计算出 dp 数组,然后你可以给我不同的 txt,我都不怕,利用这个 dp 数组我都能在 O(N) 时间完成字符串匹配。
  • 比如上文举的两个例子,txt 不同,但是 pat 是一样的,所以 KMP 算法使用的 dp 数组是同一个:
	txt1 = "aaacaaab" 
	pat = "aaab"
	txt2 = "aaaaaaab" 
	pat = "aaab"
  • 只不过对于 txt1 的下面这个即将出现的未匹配情况:
  • dp 数组指示 pat 这样移动:

  • PS:这个 j 不要理解为索引,它的含义更准确地说应该是状态(state),所以它会出现这个奇怪的位置。而对于 txt2 的下面这个即将出现的未匹配情况:

  • dp 数组指示 pat 这样移动:

  • 明白了 dp 数组只和 pat 有关,那么这样设计 KMP 算法就会比较漂亮:
	public class KMP {
	    private int[][] dp;
	    private String pat;
	
	    public KMP(String pat) {
	        this.pat = pat;
	        // 通过 pat 构建 dp 数组
	        // 需要 O(M) 时间
	    }
	
	    public int search(String txt) {
	        // 借助 dp 数组去匹配 txt
	        // 需要 O(N) 时间
	    }
	}
  • 这样,当需要用同一 pat 去匹配不同 txt 时,就不需要浪费时间构造 dp 数组:
	KMP kmp = new KMP("aaab");
	int pos1 = kmp.search("aaacaaab"); // 4
	int pos2 = kmp.search("aaaaaaab"); // 4

五、KMP 与状态机的联系

① 状态机
  • 为什么说 KMP 算法和状态机有关呢?其实,可以认为 pat 的匹配就是状态的转移。比如当 pat = “ABABC”,圆圈内的数字就是状态,状态 0 是起始状态,状态 5(pat.length)是终止状态:
  • 开始匹配时 pat 处于起始状态,一旦转移到终止状态,就说明在 txt 中找到了 pat。比如说当前处于状态 2,就说明字符 “AB” 被匹配:

  • 另外,处于不同状态时,pat 状态转移的行为也不同。比如说假设现在匹配到了状态 4,如果遇到字符 A 就应该转移到状态 3,遇到字符 C 就应该转移到状态 5,如果遇到字符 B 就应该转移到状态 0:

  • 具体什么意思呢,我们来举例看看。用变量 j 表示指向当前状态的指针,当前 pat 匹配到了状态 4:

  • 如果遇到了字符 “A”,根据箭头指示,转移到状态 3 是最聪明的:

  • 如果遇到了字符 “B”,根据箭头指示,只能转移到状态 0(一夜回到解放前):

  • 如果遇到了字符 “C”,根据箭头指示,应该转移到终止状态 5,这也就意味着匹配完成:

  • 当然,还可能遇到其他字符,比如 Z,但是显然应该转移到起始状态 0,因为 pat 中根本都没有字符 Z:

  • 为了清晰起见,画状态图时就把其它字符转移到状态 0 的箭头省略,只画 pat 中出现的字符的状态转移:

  • KMP 算法最关键的步骤就是构造这个状态转移图。要确定状态转移的行为,得明确两个变量,一个是当前的匹配状态,另一个是遇到的字符;确定了这两个变量后,就可以知道这个情况下应该转移到哪个状态。
  • 为了描述状态转移,定义一个二维 dp 数组,它的含义如下:
	dp[j][c] = next
	0 <= j < M,代表当前的状态
	0 <= c < 256,代表遇到的字符(ASCII 码)
	0 <= next <= M,代表下一个状态
	
	dp[4]['A'] = 3 表示:
	当前是状态 4,如果遇到字符 A,
	pat 应该转移到状态 3
	
	dp[1]['B'] = 2 表示:
	当前是状态 1,如果遇到字符 B,
	pat 应该转移到状态 2
  • 根据这个 dp 数组的定义和刚才状态转移的过程,就可以先写出 KMP 算法的 search 函数代码:
	public int search(String txt) {
	    int M = pat.length();
	    int N = txt.length();
	    // pat 的初始态为 0
	    int j = 0;
	    for (int i = 0; i < N; i++) {
	        // 当前是状态 j,遇到字符 txt[i],
	        // pat 应该转移到哪个状态?
	        j = dp[j][txt.charAt(i)];
	        // 如果达到终止态,返回匹配开头的索引
	        if (j == M) return i - M + 1;
	    }
	    // 没到达终止态,匹配失败
	    return -1;
	}
  • 那么如何通过 pat 构建这个 dp 数组呢?
② 构建状态转移图
  • 要确定状态转移的行为,必须明确两个变量,一个是当前的匹配状态,另一个是遇到的字符,而且根据这个逻辑确定义 dp 数组的含义,那么构造 dp 数组的框架就是这样:
	for 0 <= j < M: # 状态
    	for 0 <= c < 256: # 字符
        	dp[j][c] = next
  • 这个 next 状态应该怎么求呢?显然,如果遇到的字符 c 和 pat[j] 匹配的话,状态就应该向前推进一个,也就是说 next = j + 1,不妨称这种情况为状态推进:

  • 如果字符 c 和 pat[j] 不匹配的话,状态就要回退(或者原地不动),不妨称这种情况为状态重启:

  • 那么,如何得知在哪个状态重启呢?解答这个问题之前,再定义一个名字:影子状态,用变量 X 表示。所谓影子状态,就是和当前状态具有相同的前缀。比如下面这种情况:

  • 当前状态 j = 4,其影子状态为 X = 2,它们都有相同的前缀 “AB”。因为状态 X 和状态 j 存在相同的前缀,所以当状态 j 准备进行状态重启的时候(遇到的字符 c 和 pat[j] 不匹配),可以通过 X 的状态转移图来获得最近的重启位置。
  • 比如说刚才的情况,如果状态 j 遇到一个字符 “A”,应该转移到哪里呢?首先只有遇到 “C” 才能推进状态,遇到 “A” 显然只能进行状态重启。状态 j 会把这个字符委托给状态 X 处理,也就是 dp[j][‘A’] = dp[X][‘A’]:
  • 为什么这样可以呢?这是因为既然 j 这边已经确定字符 “A” 无法推进状态,只能回退,而且 KMP 就是要尽可能少的回退,以免多余的计算。那么 j 就可以去问问和自己具有相同前缀的 X,如果 X 遇见 “A” 可以进行「状态推进」,那就转移过去,这样回退最少。

  • 当然,如果遇到的字符是 “B”,状态 X 也不能进行「状态推进」,只能回退,j 只要跟着 X 指引的方向回退就行:
  • 你也许会问,这个 X 怎么知道遇到字符 “B” 要回退到状态 0 呢?因为 X 永远跟在 j 的身后,状态 X 如何转移,在之前就已经算出来了。动态规划算法不就是利用过去的结果解决现在的问题吗?我们就细化一下刚才的框架代码:
	int X # 影子状态
	for 0 <= j < M:
	    for 0 <= c < 256:
	        if c == pat[j]:
	            # 状态推进
	            dp[j][c] = j + 1
	        else: 
	            # 状态重启
	            # 委托 X 计算重启位置
	            dp[j][c] = dp[X][c] 
③ 代码实现
  • 现在就剩下一个问题:影子状态 X 是如何得到的呢?
	public class KMP {
	    private int[][] dp;
	    private String pat;
	
	    public KMP(String pat) {
	        this.pat = pat;
	        int M = pat.length();
	        // dp[状态][字符] = 下个状态
	        dp = new int[M][256];
	        // base case
	        dp[0][pat.charAt(0)] = 1;
	        // 影子状态 X 初始为 0
	        int X = 0;
	        // 当前状态 j 从 1 开始
	        for (int j = 1; j < M; j++) {
	            for (int c = 0; c < 256; c++) {
	                if (pat.charAt(j) == c) 
	                    dp[j][c] = j + 1;
	                else 
	                    dp[j][c] = dp[X][c];
	            }
	            // 更新影子状态
	            X = dp[X][pat.charAt(j)];
	        }
	    }
	    public int search(String txt) {...}
	}
  • dp[0][pat.charAt(0)] = 1:这行代码是 base case,只有遇到 pat[0] 这个字符才能使状态从 0 转移到 1,遇到其它字符的话还是停留在状态 0(Java 默认初始化数组全为 0)。
  • 影子状态 X 是先初始化为 0,然后随着 j 的前进而不断更新的。下面看看到底应该如何更新影子状态 X:
	int X = 0;
	for (int j = 1; j < M; j++) {
	    ...
	    // 更新影子状态
	    // 当前是状态 X,遇到字符 pat[j],
	    // pat 应该转移到哪个状态?
	    X = dp[X][pat.charAt(j)];
	}
  • 更新 X 其实和 search 函数中更新状态 j 的过程是非常相似的:
	int j = 0;
	for (int i = 0; i < N; i++) {
	    // 当前是状态 j,遇到字符 txt[i],
	    // pat 应该转移到哪个状态?
	    j = dp[j][txt.charAt(i)];
	    ...
	}
  • 其中的原理非常微妙,注意代码中 for 循环的变量初始值,可以这样理解:后者是在 txt 中匹配 pat,前者是在 pat 中匹配 pat[1…end],状态 X 总是落后状态 j 一个状态,与 j 具有最长的相同前缀,所以把 X 比喻为影子状态,似乎也有一点贴切。
  • 另外,构建 dp 数组是根据 base case dp[0][…] 向后推演。这就是我认为 KMP 算法就是一种动态规划算法的原因。
  • 下面来看一下状态转移图的完整构造过程,就能理解状态 X 作用之精妙:

  • 至此,看下 KMP 算法的完整代码:
	public class KMP {
	    private int[][] dp;
	    private String pat;
	
	    public KMP(String pat) {
	        this.pat = pat;
	        int M = pat.length();
	        // dp[状态][字符] = 下个状态
	        dp = new int[M][256];
	        // base case
	        dp[0][pat.charAt(0)] = 1;
	        // 影子状态 X 初始为 0
	        int X = 0;
	        // 构建状态转移图(稍改的更紧凑了)
	        for (int j = 1; j < M; j++) {
	            for (int c = 0; c < 256; c++)
	                dp[j][c] = dp[X][c];
	            dp[j][pat.charAt(j)] = j + 1;
	            // 更新影子状态
	            X = dp[X][pat.charAt(j)];
	        }
	    }
	
	    public int search(String txt) {
	        int M = pat.length();
	        int N = txt.length();
	        // pat 的初始态为 0
	        int j = 0;
	        for (int i = 0; i < N; i++) {
	            // 计算 pat 的下一个状态
	            j = dp[j][txt.charAt(i)];
	            // 到达终止态,返回结果
	            if (j == M) return i - M + 

以上是关于数据结构与算法之深入解析KMP算法的核心原理和实战演练的主要内容,如果未能解决你的问题,请参考以下文章

数据结构与算法之深入解析RSA加密算法的实现原理

数据结构与算法之深入解析Base64编码的实现原理

数据结构与算法之深入解析“路径总和”的求解思路与算法示例

哨兵的多个核心底层原理的深入解析(包含slave选举算法)

数据结构与算法之深入解析“一维数组的动态和”的求解思路与算法示例

数据结构与算法之深入解析“一维数组的动态和”的求解思路与算法示例