算法日记day 11(KMP算法)

一、KMP算法

基本原理:

KMP算法(Knuth-Morris-Pratt算法)是一种用于在一个文本串(字符串)中查找一个模式串(子串)的高效算法。它的主要优点是在匹配过程中避免了回溯(backtracking),从而提高了匹配的效率。在字符串匹配算法中,通过求解最长相等前后缀来解决不匹配的情况,利用前缀表记录每个位置的最长相等前后缀,这种方法称为KMP算法。

KMP算法的核心思想:

  1. 部分匹配表(Partial Match Table)

    • KMP算法的关键在于构建部分匹配表,也称为最长前缀匹配表(Longest Prefix which is also Suffix, LPS)。这张表记录了模式串中每个前缀的最长匹配前缀长度。
    • 通过部分匹配表,可以根据已经匹配的部分信息,快速调整模式串的位置,跳过不可能匹配的情况,从而避免不必要的比较。
  2. 匹配过程

    • 在匹配过程中,KMP算法会根据当前匹配的位置和部分匹配表来决定模式串的移动位置,以达到尽量减少匹配次数的目的。
    • 当匹配失败时,不会简单地将模式串向后移动一个位置,而是利用部分匹配表中的信息,将模式串移动到一个合适的位置再进行匹配,从而提高效率。

算法步骤概述:

  1. 构建部分匹配表

    • 根据模式串,构建部分匹配表,计算每个位置对应的最长匹配前缀长度。
  2. 匹配过程

    • 遍历文本串,同时根据部分匹配表调整模式串的位置,进行匹配。
    • 如果当前字符匹配成功,继续匹配下一个字符;如果匹配失败,根据部分匹配表移动模式串的位置,直到找到匹配或者完全匹配失败。

优点:

  • 避免回溯:KMP算法通过利用部分匹配表的信息,能够在模式串与文本串匹配过程中,减少不必要的比较,避免回溯到之前已经比较过的位置,从而提高了算法的效率。
  • 时间复杂度:KMP算法的时间复杂度为O(m + n),其中m为模式串长度,n为文本串长度,主要由构建部分匹配表和匹配过程决定。

那么如何用前缀表来进行匹配?什么是前缀表?前缀表如何求?

前缀表记录了模式串中每个位置的最长相等前后缀长度,使得在匹配失败时可以跳转到之前已匹配的位置继续匹配。这种方法相比于暴力匹配算法,时间复杂度从O(m*n)降低到了O(m+n)。

首先要理解什么是前缀,什么是后缀,前缀是包含首字母不包含尾字母的子串,后缀是包含尾字母不包含首字母的子串,以例子说明:

文本串:    "aabaabaaf"

模式串: "aabaaf"

KMP算法在匹配时,当第一次匹配后,文本串的"b"匹配模式串的"f"匹配失败,这是KMP算法的第二轮匹配会直接从模式串的"b"开始,匹配文本串的第二个"b",那么为什么是这样匹配的呢?为什么会直接从b开始匹配?这就是前缀表的功劳了。

对于模式串来说,它的前缀可以是 a,aa,aab,aaba,aabaa, 后缀可以是f,af,aaf,baaf,abaaf,正是遵循"最长相等前后缀的原则" ,以例子说明:

a的相等前后缀为0

aa的相等前后缀为1    (前缀a,后缀a)

aab的相等前后缀为0

aaba的相等前后缀为1   (前缀a,后缀a)

aabaa的相等前后缀为2     (前缀a,aa,后缀a,aa)

aabaaf的相等前后缀为0

最终得到的模式串前缀表为

(0,1,0,1,2,0)

 再我们第一轮找到不匹配的元素后,这时应该找前一位元素的最长相等前后缀是多少

文本串:    "aabaabaaf"

模式串: "aabaaf"

 前缀表: 010120

模式串中"f"的前一位是a,它的最长相等前后缀是2,那么这个2是什么意思?

它代表着子串"aabaa"中的一个两位后缀aa,同时也有着一个相等的两位前缀aa,由于匹配的后缀不相等造成冲突,因此需要去寻找与其相等的前缀,而前缀表中的这个2,就是下一次匹配时模式串的起始位置(下标), 2是相等前后缀的长度,现在已知了前缀,因此要从该相等前缀的下一个元素开始下一次串匹配。

 

这时第二次匹配为

 

文本串:    "aabaa  baaf"

模式串:       "aa  baaf"

 

这时匹配完成,KMP算法结束

总结:

KMP算法通过利用部分匹配表,避免了回溯,提高了模式串匹配的效率,特别适合于需要多次匹配模式串的场景,如字符串匹配、编辑距离计算等。

具体题目:

1、KMP算法经典应用

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

示例 1:

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

示例 2:

输入:haystack = "leetcode", needle = "leeto"
输出:-1
解释:"leeto" 没有在 "leetcode" 中出现,所以返回 -1 。

代码:

首先创建next数组作为字符串的前缀表(前缀表的值统一减一)

public void getNext(int[] next, String s){
    int j = -1;             // 初始化 j 为 -1,表示当前没有匹配的前缀
    next[0] = j;            // 第一个位置的部分匹配值为 -1
    for (int i = 1; i < s.length(); i++){  // 从第二个字符开始遍历 s 字符串
        while(j >= 0 && s.charAt(i) != s.charAt(j + 1)){
            // 如果当前字符不匹配,并且 j 大于等于 0,则通过 next[j] 回溯 j 的值
            j = next[j];
        }

        if(s.charAt(i) == s.charAt(j + 1)){
            // 如果当前字符匹配,则 j 向前移动一位
            j++;
        }
        next[i] = j;         // 记录当前位置 i 处的最长匹配前缀的末尾字符在模式串中的位置
    }
}
  1. 初始化

    • j = -1:初始化 j 为 -1,表示当前没有匹配的前缀。
    • next[0] = j:设置 next 数组的第一个位置为 -1,因为第一个字符没有前缀。
  2. 循环计算 next 数组

    • for (int i = 1; i < s.length(); i++):从字符串 s 的第二个字符开始遍历到末尾。
    • while(j >= 0 && s.charAt(i) != s.charAt(j + 1))
      • 如果当前字符不匹配,并且 j 大于等于 0,则通过 next[j] 回溯 j 的值,直到找到一个可以继续匹配的位置或者 j 回溯到 -1
    • if(s.charAt(i) == s.charAt(j + 1))
      • 如果当前字符匹配,则将 j 向前移动一位 j++
    • next[i] = j
      • 将 j 的值赋给 next[i],即记录当前位置 i 处的最长匹配前缀的末尾字符在模式串中的位置。

 

 KMP算法

public int strStr(String haystack, String needle) {
    if (needle.length() == 0) {
        return 0;   // 如果 needle 为空字符串,则返回 0
    }

    int[] next = new int[needle.length()];  // 创建 next 数组,用于存储部分匹配表
    getNext(next, needle);  // 调用 getNext 方法,计算 needle 的部分匹配表

    int j = -1;  // 初始化 j 为 -1,表示当前没有匹配的前缀
    for (int i = 0; i < haystack.length(); i++) {
        while (j >= 0 && haystack.charAt(i) != needle.charAt(j + 1)) {
            // 如果当前字符不匹配,并且 j 大于等于 0,则通过 next[j] 回溯 j 的值
            j = next[j];
        }
        if (haystack.charAt(i) == needle.charAt(j + 1)) {
            // 如果当前字符匹配,则 j 向前移动一位
            j++;
        }
        if (j == needle.length() - 1) {
            return (i - needle.length() + 1);  // 如果 j 移动到 needle 的末尾,则找到匹配的起始位置
        }
    }
    return -1;  // 没有找到匹配的子串,返回 -1
}
  1. 参数和边界情况处理

    • strStr 方法接受两个字符串参数 haystack 和 needle,其中 haystack 是待搜索的主字符串,needle 是要搜索的子字符串。
    • 如果 needle 的长度为 0,直接返回 0,因为一个空的 needle 在任何位置都是匹配的起始位置。
  2. 初始化 next 数组

    • 创建一个 next 数组,用于存储 needle 字符串的部分匹配表。
  3. 计算部分匹配表

    • 调用 getNext(next, needle) 方法,这个方法计算并填充 next 数组,用于在匹配失败时快速确定下一次比较的位置。
  4. 主匹配循环

    • 使用两个指针 i 和 j 分别在 haystack 和 needle 中进行遍历和比较。
    • j 初始为 -1,表示当前没有匹配的前缀。
    • 遍历 haystack 中的每个字符,如果当前字符不匹配,并且 j 大于等于 0,则通过 next[j] 回溯 j 的值,直到找到一个可以继续匹配的位置或者 j 回溯到 -1
    • 如果当前字符匹配,则将 j 向前移动一位。
    • 如果 j 移动到 needle 的末尾 needle.length() - 1,则说明找到了完整的匹配子串,返回起始位置 (i - needle.length() + 1)
  5. 返回结果

    • 如果循环结束都没有找到匹配的子串,则返回 -1 表示未找到。

在java中提供有直接寻找子字符串的方法indexOf,如

public int strStr(String haystack, String needle) {
    return haystack.indexOf(needle);
}
  • indexOf 方法是 Java 字符串类 String 的方法,用于查找一个子字符串在原字符串中的位置。它从原字符串的开头开始,逐个位置尝试匹配子字符串,直到找到匹配或者遍历完整个字符串。
  • 如果找到了匹配的子字符串 needle,则返回第一次出现的索引位置。
  • 如果没有找到匹配的子字符串,则返回 -1

这段代码简单而有效地利用了 Java 提供的字符串方法来实现字符串搜索功能。它适用于大多数基本的字符串搜索需求,但需要注意的是,它并没有利用更高级的字符串匹配算法(如 KMP 算法)来提高效率,因此在大数据量或者复杂匹配模式的情况下,可能性能会受到影响。

 

2、重复的子字符串

题目:

给定一个非空的字符串 s ,检查是否可以通过由它的一个子串重复多次构成。

示例 1:

输入: s = "abab"
输出: true
解释: 可由子串 "ab" 重复两次构成。

示例 2:

输入: s = "aba"
输出: false

示例 3:

输入: s = "abcabcabcabc"
输出: true
解释: 可由子串 "abc" 重复四次构成。 (或子串 "abcabc" 重复两次构成。)

思路:

这里仍采用KMP算法,在最长相等前后缀中,由重复子串组成的字符串中,最长相等前后缀不包含的子串就是最小重复子串

 这是为什么呢?如何寻找是关键

 

步骤一:因为 这是相等的前缀和后缀,t[0] 与 k[0]相同, t[1] 与 k[1]相同,所以 s[0] 一定和 s[2]相同,s[1] 一定和 s[3]相同,即:,s[0]s[1]与s[2]s[3]相同 。

步骤二: 因为在同一个字符串位置,所以 t[2] 与 k[0]相同,t[3] 与 k[1]相同。

步骤三: 因为 这是相等的前缀和后缀,t[2] 与 k[2]相同 ,t[3]与k[3] 相同,所以,s[2]一定和s[4]相同,s[3]一定和s[5]相同,即:s[2]s[3] 与 s[4]s[5]相同。

步骤四:循环往复。

所以字符串s,s[0]s[1]与s[2]s[3]相同, s[2]s[3] 与 s[4]s[5]相同,s[4]s[5] 与 s[6]s[7] 相同。

正是因为 最长相等前后缀的规则,当一个字符串由重复子串组成的,最长相等前后缀不包含的子串就是最小重复子串。

此时数组对应的next数组的值为:

数组:a b a b a b a b

next[]: 0 0 1 2 3 4 5 6

 再比如:

判断子字符串是否重复,只需要用数组长度减去最长相同前后缀的长度相当于是第一个周期的长度,也就是一个周期的长度,如果这个周期可以被整除,就说明整个数组就是这个周期的循环

//创建next数组
for (int i = 2, j = 0; i <= len; i++) {
    // 匹配不成功,j回到前一位置 next 数组所对应的值
    while (j > 0 && chars[i] != chars[j + 1]) j = next[j];
    // 匹配成功,j往后移
    if (chars[i] == chars[j + 1]) j++;
    // 更新 next 数组的值
    next[i] = j;
}

 判断是否为重复子字符串

if (next[len] > 0 && len % (len - next[len]) == 0) {
    return true;
}

 完整代码如下:

public boolean repeatedSubstringPattern(String s) {
    if (s.equals("")) return false;

    int len = s.length();
    // 原串加个空格(哨兵),使下标从1开始,这样j从0开始,也不用初始化了
    s = " " + s;
    char[] chars = s.toCharArray();
    int[] next = new int[len + 1];

    // 构造 next 数组过程,j从0开始(空格),i从2开始
    for (int i = 2, j = 0; i <= len; i++) {
        // 匹配不成功,j回到前一位置 next 数组所对应的值
        while (j > 0 && chars[i] != chars[j + 1]) j = next[j];
        // 匹配成功,j往后移
        if (chars[i] == chars[j + 1]) j++;
        // 更新 next 数组的值
        next[i] = j;
    }

    // 最后判断是否是重复的子字符串,这里 next[len] 即代表next数组末尾的值
    if (next[len] > 0 && len % (len - next[len]) == 0) {
        return true;
    }
    return false;
}

 

  1. 边界条件

    • if (s.equals("")) return false; 如果输入的字符串 s 是空字符串,则直接返回 false,因为空字符串不可能由重复的子字符串构成。
  2. 初始化

    • int len = s.length(); 获取字符串 s 的长度。
    • s = " " + s; 在字符串 s 的开头加上一个空格作为哨兵,使得字符串的索引从 1 开始,这样方便后续算法中的处理。
  3. 构造 next 数组

    • char[] chars = s.toCharArray(); 将字符串 s 转换为字符数组 chars,便于随后的字符操作。
    • int[] next = new int[len + 1]; 创建 next 数组,用于存储部分匹配表。
  4. KMP 算法核心部分

    • for (int i = 2, j = 0; i <= len; i++) { ... } 使用 i 和 j 两个指针,其中 i 表示当前正在匹配的位置,j 表示前缀的匹配长度。
    • while (j > 0 && chars[i] != chars[j + 1]) j = next[j]; 如果当前字符不匹配,并且 j 大于 0,则通过 next[j] 回溯 j 的值,直到找到一个可以继续匹配的位置。
    • if (chars[i] == chars[j + 1]) j++; 如果当前字符匹配,则将 j 向后移动一位。
    • next[i] = j; 更新 next 数组的值,表示当前位置的最长相同前缀后缀长度。
  5. 判断重复子字符串

    • if (next[len] > 0 && len % (len - next[len]) == 0) { return true; } 
      • next[len] 表示整个字符串的最长相同前缀后缀长度。
      • len % (len - next[len]) == 0 表示字符串长度能整除最长前缀后缀的长度,即可以由子字符串重复构成。
      • 如果上述条件满足,则返回 true,否则返回 false

 相关资料来源:

https://www.programmercarl.com/0459.%E9%87%8D%E5%A4%8D%E7%9A%84%E5%AD%90%E5%AD%97%E7%AC%A6%E4%B8%B2.html#%E6%80%9D%E8%B7%AF

https://leetcode.cn/problems/repeated-substring-pattern/

 今天的学习就到这里了

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值