KMP算法

KMP算法

简介

一种由Knuth(D.E.Knuth)、Morris(J.H.Morris)和Pratt(V.R.Pratt)三人设计的线性时间字符串匹配算法。该算法主要解决的就是字符串匹配的问题。
本文参考前缀函数与KMP算法-oi.wiki

例题

LeetCode 28:找出字符串种第一个匹配项的下标
给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。
如果 needle 不是 haystack 的一部分,则返回  -1 。

1 <= haystack.length, needle.length <= 104

haystack 和 needle 仅由小写英文字符组成

输入:haystack = "sadbutsad", needle = "sad"
输出:0
解释:"sad" 在下标 0 和 6 处匹配。
第一个匹配项的下标是 0 ,所以返回 0 。

前置知识

  • 字符串前缀
    假设有一个字符串 s[0...i] (下标为0 - i)
    它的前缀就定义为 s[0...j] (j <= i),当 j < i 时,就定义为该字符串的真前缀
  • 字符串后缀
    同理
    后缀定义为 s[j...i] (j >= 0), 当 j > 0 时,就定义为该字符串的真后缀

前缀函数 next[] 数组

next数组是KMP的核心

定义

给定一个长度为 n 的字符串 s,其 前缀函数 被定义为一个长度为 n 的数组 next。 其中 next[i] 的定义是:
如果子串 s[0...i] 有一对相等的真前缀与真后缀:s[ 0...k-1 ] 和 s[ i-(k-1)...i ] ( k-1 < i-(k-1) ),那么 next[i] 的值就是这个字符串最长的真前后缀(因为相等的真前后缀可能不止一对,所以需要的是最长的真前后缀)的长度,也就是 next[i] = k;
next[i] = max(k,max) //条件:([0...k-1] == s[i-(k-1)...i])
初始化 next[0] = 0

举例

对于字符串: a b a b a b a c
next 数组:[ 0,0,1,2,1,2,3,0 ] => "","","a","ab","a","ab","aba",""

暴力求解 next 数组

给定字符串 s
暴力求解:
双指针遍历 s,设双指针 i 为当前 s[0...i] 的前缀和的下标(截至下标),j 为遍历 0 - i 的下标(遍历匹配 s[j] 和 s[i-j])
假设字符串 s[0...i] => j 遍历 比较 s[0] == s[i] ... s[1] == s[i-1] ... s[j] == s[i-j] (需要满足要求 j < i-j )
代码如下(Java):

public static int[] prefixFunction(String str){
    int[] next = new int[str.length()];
    // next[0] = 0; // 可以省略,默认为 0
    for(int i = 1; i < str.length(); i++){
        for(int j = 0; j < i; j++){
            // 截取 str[0...j] 和 str[i-j...i] 比较 
            if(i-j > j && str.substring(0,j+1).equals(str.substring(i-j,i+1))){
                next[i] = j+1;
            }
        }
    }
    return next;
}
时间复杂度 O(n^3) 不推荐使用
优化思想
  • 优化 1
    对于 next[i] ,当 s[i] == s[next[i-1]] 时 可以有 next[i] = next[i-1] + 1: (为什么呢😶,别急看下面)
    假设字符串:a b c a b c 现在有 i = 5
    已求 next :[0,0,0,1,2,?] next[5] = 2,现在 s[5] == s[2] , 有 next[5] == 3
    仔细想 对于 next[i] 来说,需要求的是 s[0...k] == s[i-k...i]
    而已经有的 next[i-1] = m,所以已经知道的信息是 s[0...m-1] == s[i-m...i-1] ,而现在又有 s[m] = s[i],那自然能推出 s[0...m] == s[i-m...i] ,所以 m + 1一定是 满足next[i] 的一个前后缀相等的一个长度,那么我们如何确定是最大的呢
    假设他现在不是最大,存在一个更大的 next[i] == k + 1 ,那就有 s[0...k] == s[i-k...i] (k > m ),
    那么对于 next[i-1] 而言他也能找到 s[0...k-1] == s[i-k...i-1] (k > m) 那么 next[i-1] 就应该是 k 了,而不是 m ,这显然是矛盾的,所以 k 并不存在,m + 1 就是next[i] 的最大前后缀相等长度
    总结 if( s[i] == s[next[i-1]] ) next[i] = next[i-1] + 1
  • 优化 2 (最难理解的部分来了,如果感觉看不懂可以考虑结合 oi.wiki 的一些图解增强理解)
    对于 next[i] ,当 s[i] != s[next[i-1]] 时, 考虑 next[i-1]的第二长前缀和长度开始 ,假设 第二长度 为 j 那么同理的 我们可以比较 s[i] == s[j],再不行就第三长...
    例如 : a b c a b d a b c a b c 此时 i == 11
    next[]:[0,0,0,1,2,0,1,2,3,4,5,?]
 a b c a b 是i-1最长 => s[0...4] == s[10 - 4...10]  
 a b 是第二长 => s[0...1] == s[10 - 1...10]
 现在的问题是如何求 第二长 j (这里 j == 2) i == 11,next[i-1] == 5)
 已知: next[i-1] = 5
 因为有 s[0...4] == s[6...10] ,所以有 s[0...1] == s[6...7] (s[0...j-1] == s[i - next[i - 1]...i - next[i - 1] + j)
 而 又因为 s[0...1] == s[10 - 1...10] == s[4 - 1...4] (s[0...j-1] == s[i-j...i-1] == s[next[i-1] - j...next[i-1]-1])
 所以有 s[0...j-1] == s[next[i-1] - j ... next[i-1]-1] ,仔细观察会发现这其实就是 下标为 next[i-1] - 1 的最长前后缀相等长度
 所以有 j = next[next[i-1] - 1]
 同理 第三长就是 next[next[next[i-1]- 1] - 1]

优化求解 next 数组

结合前面两个优化

public static int[] prefixFunctionAdvance(String str){
    int[] next = new int[str.length()];
    for(int i = 1; i < next.length; i++){
        int j = next[i-1];
        while(j > 0 && str.charAt(i) != str.charAt(j))  j = next[j-1];
        if(j < i-j && str.charAt(i) == str.charAt(j)) j++;
        next[i] = j;
    }
    return next;
}

解例题

现在我们知道了怎么求 next 数组,想要解这个例题其实很简单
尝试将两个字符串合并,使用 '#' 字符区别开('#' 可以替换,只需要满足两个字符串中都不会出现该字符):
合并前: haystack = "sadbutsad", needle = "sad"
合并后: s = "sad#sadbutsad"
然后对 s 求 next 数组
next数组中第一次出现 next[i] == needle.lenght() 时,那么这就是 needle 第一次出现的位置
【注】:因为前缀式中有个字符'#'是不可能出现相等的,所以不可能存在前后缀最大和 > needle.length()

public int solution(String haystack,String needle){
    String s = needle + "#" + haystack;
    int[] ints = prefixFunctionAdvance(s);
    for(int i = 0; i < ints.length; i++){
        if(ints[i] == needle.length()){
            // i - needle.len + 1为 next中出现重复的开始下标,需要减去 needle + "#" 的长度才是 haystack 的下标
            // return i - needle.length() + 1 - (needle.length() + 1); 
            // 化简后
            return i - needle.length() * 2;
        }
    }
    return -1;
}
posted @ 2023-03-20 15:48  PupilXIao  阅读(22)  评论(0编辑  收藏  举报