KMP 算法

149 阅读6分钟

KMP 算法

简介

Knuth-Morris-Pratt 字符串查找算法,简称为 “KMP算法”,常用于在一个文本串S内查找一个模式串P 的出现位置,这个算法由Donald Knuth、Vaughan Pratt、James H. Morris三人于1977年联合发表,故取这3人的姓氏命名此算法。

KMP的算法流程

  • 假设现在文本串s匹配到 i 位置,匹配串p匹配到 j 位置

    • 如果j = 0,或者当前字符匹配成功(即s[i] == p[j]),都令i++,j++,继续匹配下一个字符;

    • 如果j != 0,且当前字符匹配失败(即s[i] != p[j]),则令 i 不变,j = next[j-1]。此举意味着失配时,模式串p相对于文本串s向右移动了j - next [j-1] 位。

    • 换言之,当匹配失败时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应上一位的next 值,即移动的实际位数为:j - next[j-1] ,且此值大于等于1。

随着匹配过程的进行,原串指针的不断右移, 我们本质上是在不断地在否决一些「不可能」的方案

  • 当我们的原串指针从 i 位置后移到 j 位置,
  • 不仅仅代表着「原串」下标范围为 [i,j) 的字符与「匹配串」匹配或者不匹配,
  • 更是在否决那些以「原串」下标范围为 [i,j)为「匹配发起点」的子集。

next 数组

对于匹配串的任意一个位置而言,由该位置发起的下一个匹配点位置其实与原串无关
构建 next 数组,数组长度为匹配串的长度(next 数组是和匹配串相关的)

next 数组各值的含义

代表当前字符之前的字符串中,有多大长度的相同前缀后缀。例如如果next [j] = k,代表j 之前的字符串中有最大长度为k 的相同前缀后缀。

此也意味着在某个字符失配时,该字符对应的next 值会告诉你下一步匹配中,模式串应该跳到哪个位置(跳到next [j] 的位置)。

  • 如果next [j] = 0,则跳到模式串的开头字符,
  • next [j] = kk > 0,代表下次匹配跳到j 之前的某个字符,而不是跳到开头,且具体跳过了k 个字符。

next 的性质

主字符串中 i 指针之前的 next[j −1] 位一定与匹配字符串的第 0 位至第 next[j−1] 位是相同的。

image.png

简言之,以图中的例子来说,在 i 处失配,那么主字符串和模式字符串的前边6位就是相同的。又因为模式字符串的前6位,它的前4位前缀和后4位后缀是相同的,所以我们推知主字符串i之前的4位和模式字符串开头的4位是相同的。就是图中的灰色部分。那这部分就不用再比较了。

复杂度

时间复杂度:
n 为原串的长度,m 为匹配串的长度。复杂度为 O(m+n)

空间复杂度:
构建了 next 数组。复杂度为 O(m)

代码(java)

class Solution {
    // KMP 算法
    // 随着匹配过程的进行,原串指针的不断右移,
    // 我们本质上是在不断地在否决一些「不可能」的方案。
    // 当我们的原串指针从 i 位置后移到 j 位置,
    // 不仅仅代表着「原串」下标范围为 [i,j)的字符与「匹配串」匹配或者不匹配,
    // 更是在否决那些以「原串」下标范围为 [i,j)为「匹配发起点」的子集。
   
    public int strStr(String oriStr, String matchStr) {
        if (matchStr.isEmpty()) return 0;
        
        // 分别读取原串和匹配串的长度
        int n = oriStr.length(), m = matchStr.length();
        if(m == 0)  return 0;

        char[] original = oriStr.toCharArray();
        char[] match = matchStr.toCharArray();

        // 对于匹配串的任意一个位置而言,由该位置发起的下一个匹配点位置其实与原串无关。
        // 构建 next 数组,数组长度为匹配串的长度(next 数组是和匹配串相关的)

        // next 数组各值的含义: 【下一个匹配位置】
        // 代表当前字符之前的字符串中,有多大长度的相同前缀后缀。例如如果next [j] = k,代表j 之前的字符串中有最大长度为*k* 的相同前缀后缀。
        // 此也意味着在某个字符失配时,该字符对应的next 值会告诉你下一步匹配中,模式串应该跳到哪个位置(跳到`next [j]` 的位置)。
        // - 如果`next [j] = 0`,则跳到模式串的开头字符,
        // - 若`next [j] = k` 且 `k > 0`,代表下次匹配跳到j 之前的某个字符,而不是跳到开头,且具体跳过了k 个字符。

        // next 的性质:
        // 主字符串中 i 指针之前的 next[j −1] 位一定与匹配字符串的第 0 位至第 next[j−1] 位是相同的。

        // 注意这里我们都是操作low, high仅用于遍历字符串(一直向前: high++)  
        int[] next = new int[m];  // 初始化一个大小为 m 的整数数组 next,用于存储部分匹配表
        next[0] = 0;  // 初始化 next[0] 为 0,因为它表示模式字符串的第一个字符,没有前缀和后缀
        for (int high = 1, low = 0; high < m; high++) {  
            while (low > 0 && match[high] != match[low]) {  
                low = next[low - 1]; //将low指针指向前一位置的next数组对应值
            }
            if (match[high] == match[low]) {  
                low++; // 匹配成功, low后移
            }
            next[high] = low;  // 更新 next[i],将当前位置 i 的next(部分匹配)值设置为 j,以便在匹配失败时快速跳过一些比较,提高字符串匹配的效率。
        }


        // 匹配过程:
        // Method 1 最容易理解版本
        int i = 0, j = 0;  // 初始化 i 和 j 为 0  【匹配 i 从 0 开始】
        while (i < n) {  // 当 i 小于目标字符串的长度 n 时,执行以下操作
            if (original[i] == match[j]) {  // 如果 s[i] 和 p[j] 相等,将 i 和 j 都向后移动一位
                i++;
                j++;
            } else {
                if (j > 0) {  // 如果不匹配,且 j>0,更新 j=next[j-1]
                    j = next[j - 1]; // j跳转为上一位的匹配值next
                } else {  // 如j=0,i++,确保i一直递增(核心思想)
                    i++;
                }
            }
            if (j == m) {  // 如果 j 等于匹配字符串的长度 m,说明已经完全匹配,返回匹配的起始位置
                return i - m;
            }
        }


        // Method 2 简化版本,但是不容易理解原理 
        // 初始化 i 和 j 为 0
        // for (int i = 0, j = 0; i < n; i++) {
        //     // 如果 s[i] 和 p[j] 不相等,且 j 大于 0,更新 j 为 next[j-1]
        //     while (j > 0 && s[i] != p[j]) {
        //         j = next[j - 1];
        //     }
        //     // 如果 s[i] 和 p[j] 相等,将 j 向后移动一位
        //     if (s[i] == p[j]) {
        //         j++;
        //     }
        //     // 如果 j 等于模式字符串的长度 m,说明已经完全匹配,返回匹配的起始位置
        //     if (j == m) {
        //         return i - m + 1;
        //     }
        // }

        // Method 3 - 木鱼水心博主
        // 匹配过程,i = 1,j = 0 开始,i 小于等于原串长度 【匹配 i 从 1 开始】
        // for (int i = 1, j = 0; i <= n; i++) {
        //     // 匹配不成功 j = next(j)
        //     while (j > 0 && s[i] != p[j + 1]) j = next[j];
        //     // 匹配成功的话,先让 j++,结束本次循环后 i++
        //     if (s[i] == p[j + 1]) j++;
        //     // 整一段匹配成功,直接返回下标
        //     if (j == m) return i - m;
        // }

        return -1;
    }
}

参考链接:

  1. LeetCode原题链接
  2. 【宫水三叶】简单题学 KMP 算法
  3. 如何更好地理解和掌握 KMP 算法
  4. 最浅显易懂的 KMP 算法讲解