字符串匹配问题 —— KMP算法详解(Java版)

引入

字符串应用十分广泛,计算机上非数值处理的对象大部分都是字符串数据,所以字符串匹配技术也是十分重要的一项技术,匹配字符串的索引有许多方法,本文主要讲诉常见的KMP算法,使用 Java 语言实现,其他语言的实现方式大致相同(如果需要直接查看源码,请滑动至文章末尾)。

什么是字符串匹配问题呢?如源字符串source=“helloworld”,目标字符串target=“world”,我们需要匹配这个目标字符串在源字符串中出现的位置,若存在,则返回索引;不存在,则返回-1。此处的结果应为5。

好的,下面开始正式进入算法的学习 >>>


暴力破解法(了解)

在学习KMP之前,我们先简单了解以下传统的字符串匹配格式,即暴力破解法,由于此算法不是本文的主要内容,所以这里仅仅作为引入,我们通过一段代码快速了解一下(已了解跳过此处即可):

public class StringIndex {

	@Test
    public void BFTest() {
        String source = "aaacdaaaxb";
        String target = "aaax";
        System.out.println("查找到字符串的起始位置:" + indexOfToBF(source, target, 0));
        // 查找到字符串的起始位置:5
    }
    
    /**
     * 采用BF算法(暴力破解),查找字符串子串的位置
     *
     * @param source 源字符串
     * @param target 目标(子)字符串
     * @param pos    源字符串的起始索引
     * @return 目标字符串在源字符串中第一次出现的索引位置
     */
    public int indexOfToBF(String source, String target, int pos) {
        // 初始化索引
        int i = pos, j = 0;
        // 判断i和j的索引都不能大于等于字符串长度,否则说明比较完成
        while (i < source.length() && j < target.length()) {
            // 判断两个字符串的各个子字符是否相等
            if (source.charAt(i) == target.charAt(j)) {
                i++;
                j++;
            } else {
                // 如果有不相等的,则回溯。i回溯到上次的位置+1,j重新开始
                i = i - j + 1;
                j = 0;
            }
        }

        // 判断子字符的索引是否大于等于子字符串,如果为true说明查找成功,返回源字符串当前位置-子字符串长度
        return j >= target.length() ? i - target.length() : -1;
    }
}

上面的代码其实不难理解,就是先从源字符串的第一个字符开始于目标字符串一个一个进行比较,如果匹配到不相等的字符,就将i回溯,从源字符串的第二个字符串开始于目标字符串再次一一比较,如此循环,直到目标字符串的所以字符都能和源字符串的某子串相符,就返回索引;如果查找失败,则返回-1。

算法图解

image-20211211201054896

从上图可以看到,暴力破解法是非常低效的。执行第一步时,从源字符串的第一个字符开始和目标字符串匹配,然后前三个字符匹配成功,但是第四个字符匹配失败了。

这导致原本我们已经匹配到第4个字符了,但是由于回溯效果,我们第二步开始是从原字符串的第2个字符开始匹配,然后匹配到目标字符串第三个字符匹配失败又需要回溯。可以看到,回溯的次数是非常多的,如果源字符串和目标字符串中有非常多重复字符片段,就会导致程序的效率进一步降低,这在大型的字符串匹配中将是致命的。


KMP 算法

基本介绍

KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。 —— 源自百度百科

看上面的概念过于抽象,我们还是通过图像来解释这些概念,还是使用上方同样的字符串进行演示。

源字符串:aaacdaaaxb,目标字符串:aaax

第一步还是和之前一样,从起始位置开始对字符一一进行比较。

image-20211211212724385

但是第二步开始和之前不同,在KMP算法中,我们通常不用将字符串位置全部回溯,因为很明显,目标字符串的第2、3个字符和源字符串的2、3个字符已经比较过是相等的,既然比较过是相等的,后面如果采用暴力破解法中的方式全部回溯显然是没有必要的,如果全部回溯必然某个位置还是会不等。

所以我们可以跳过这两个已经比较过的字符,也就是 i 不用进行回溯,我们回溯的是指向目标字符串的指针 j 。故第二步为:

image-20211212104121928

再次进行比较后直接就不相等,那么我们继续回溯指向目标字符串的指针 j,i 仍然不用回溯。故第三步

image-20211211213916059

还是不相等,目标字符串已经无法回溯了,就保持当前指向的位置,且i仍不需要回溯,最后再次进行比较,可以看到,就可以得到结果了。

image-20211211214113099

可以看到在上面的算法中,我们只有四步就完成了我们的需求,而使用暴力破解法,由于每次指向源字符串的指针 i 和指向目标字符串的指针 j 都需要回溯,所以效率很低。使用KMP算法只回溯了 j ,大幅提高效率。

在上图第一步到第二步时我们说过,因为有些部分已经比较过了,所以不需要再进行比较,虽然人很容易发现这一点,但是怎么让计算机实现我们的想法,就是我们需要解决的问题。

前缀和后缀

既然 i 值不进行回溯,所以我们可以不考虑 i 的问题,考虑的是 j 的问题,j 是指向目标字符串的每一个字符的,所以这个问题也可以转换为对目标字符串的处理。

这个 j 值其实和源字符串没什么关系,关键是取决于目标字符串中字符的重复问题。如下图,目标字符串 target="abcdf" ,没有一个重复元素,所以当下一次比较时,由4转换为0,从字符串索引 0 处重新开始比较:

image-20211211220322728

而像前面的字符串就如 target="aaax" 时,显然就不需要全部回溯,也就是说,我们需要在查找字符串之前,对目标字符串进行处理,就可以减少查找的难度,大幅提高查找效率。而KMP算法中,就是推到出如下图中的一个辅助数组

image

通过上面的数组,我们可以得到这么一个函数公式,用于定义辅助数组next:

image-20211211225039383

很多人看到这个公式可能就懵逼了,什么是公共后缀??前面没说说过呀,别急,下面进行讲解,我们先了解一下字符串的前缀和后缀。我们还是以图的方式先简单了解一下前后缀的方式

image

可以发现,前缀和后缀的区别就是:

  • 前缀就是除了不包含最后一个字符外,其他的所有从索引为0开始向后偏移的子字符串。
  • 后缀就是除了不包含第一个字符串外,其他所有从索引为length() - 1 开始向前偏移的子字符串。

而公共后缀,就是前缀集合和后缀集合中相交部分的最大长度。

next 辅助数组推导

知道了上面的知识,我们应该就更能吗明白,如何通过上上图中的函数公式,求得next数组了,毕竟如果我们人都不会,如何写的出算法让计算机"理解"呢?

例1:设目标字符串 target="ababd",则其next数组如下:

image-20211211224824085

计算步骤

  1. 当 k=0 时,next[0] = -1。

  2. 当 k = 1 时,其前面只有一个a,无前后缀,故 next[1] = 0。

  3. 当 k = 2 时,其前面的字符串为ab,前缀为a,后缀为b,无公共元素。故 next[2] = 0。

  4. 当 k = 3 时,其前面的字符串为aba,前缀为{ a,ab },后缀为{ a, ba },公共元素为 a,长度为1,故 next[3] = 1。

  5. 当 k = 4 时,其前面的字符串为abab,前缀为{ a,ab,aba },后缀为{ b,ab,bab },公共元素为 ab,长度为2,故 next[4] = 2。

例2:设目标字符串 target="abcdf",则其next数组如下:

image-20211211225655440

计算步骤

  1. 当 k = 0 时,next[0] = -1。
  2. 当 k = 1 时,其前面的字符串为 a,无前后缀,故 next[1] = 0。
  3. 当 k = 2 时,其前面的字符串为 ab,前缀为 a,后缀为 b,无公共元素。故 next[2] = 0。
  4. 当 k = 3 时,其前面的字符串为 abc,前缀为{ a,ab },后缀为{ c,bc },无公共元素,故 next[3] = 0。
  5. 当 k = 4 时,其前面的字符串为 abcd,前缀为{ a,ab,abc },后缀为{ d,cd,bcd },无公共元素,故 next[3] = 0。

通过上面两个例子,应该可以了解到next数组的生成方式了,那么我们就可以着手开始编写代码了。

KMP 算法代码实现( Java 版)

首先我们应该先涉及求 next 数组的方法,因为没有next数组,KMP算法是不知道如何回溯的,由于使用的 Java语言,我们需要先创建一个类,用于字符串匹配查找。

在函数内部,我设计了一个函数式接口,这和其他的教程可能不一样,其他教程通常是直接设计一个方法,调用方法返回一个next数组,但是我觉得这样不够好,对于获取next数组方法的设计,我们可以灵活的,由调用者来决定如何获取到这个next数组,毕竟一千个人里由一千个哈姆雷特,谁敢说自己的算法就是最好的呢?

public class StringIndex {

    // 函数式接口,获取next数组
    @FunctionalInterface
    public interface NextArray {
        int[] getNext(String target);
    }
}

当然,上面都是题外话,其他语言按照下面的方式,直接设置一个方法/函数即可:

// 使用方法获取接口实例,接口中的getNext方法可以获取next数组
public NextArray getNextInstance() {
    return target -> {
        // 初始化数组大小
        int[] next = new int[target.length()];
        // 0 位置默认为-1
        next[0] = -1;
        // 遍历字符串每一位,计算每个位置上的值
        for (int i = 0, k = -1; i < target.length() - 1; ) {
            // k还不可比较说明是第一次进入或者回溯后可能产生的结果,k++,此位置的next[i]应为0
            // 如果前缀和后缀相等,说明可以计算权值
            if (k == -1 || target.charAt(i) == target.charAt(k)) {
                i++;
                k++;
                next[i] = k;
            } else {
                // 回溯
                k = next[k];
            }
        }
        return next;
    };
}

以上使用的是lambda表达式,关于函数式接口和lambda的内容属于Java8的新特性,这里不做展开,有兴趣的参考我的另一篇文章:Java8新特性。至于其他语言,只需要将target -> {},花括号中的内容按照相同的方式实现即可。

解决完上面的小家伙,就可以正式进入KMP算法的编写了:

/**
* 采用KMP算法,查找字符串子串的位置
* @param source    源字符串
* @param target    目标(子)字符串
* @param pos       源字符串的起始索引
* @param nextArray 函数式接口,传入获取next数组的方法(因为next数组上面定义了两种获取方式)
* @return 目标字符串在源字符串中第一次出现的索引位置
*/
public int indexOfToKMP(String source, String target, int pos, NextArray nextArray) {
    int i, j;
    // 调用接口中的方法,获取next数组
    int[] next = nextArray.getNext(target);
    // 初始化遍历
    i = pos;
    j = 0;
    // 判断i和j的索引都不能大于等于字符串长度,否则说明比较完成
    while (i < source.length() && j < target.length()) {
        // 如果j==-1,说明需要从头开始比较;或者由公共元素相等,继续比较
        if (j == -1 || source.charAt(i) == target.charAt(j)) {
            i++;
            j++;
        } else {
            // 需要按照数组回溯j
            j = next[j];
        }
    }
    // 判断子字符的索引是否大于等于子字符串,如果为true说明查找成功,返回源字符串当前位置-子字符串长度
    return j >= target.length() ? i - target.length() : -1;
}

可以看到,KMP算法中的大部分内容和暴力破解法相同,主要的就是修改了 j 的索引值,代码逻辑并不复杂,当然,KMP算法仅当模式与主串之间存在许多"部分匹配"的情况,否则效率的差异并不会特别明显。

next 数组的改进

在前面我将求next数组的方式修改成了一个接口,就是为了后面我们只需要修改求next数组的实现类,就可以实现我们的需求。

难道说next数组还可以改进吗,没错,其实上方的next数组并不是最优解,也就是说当前的算法还是有缺陷的。

设源字符串source=“aaaabcaaaaaca”,目标字符串target=“aaaaac”。那么依据之前的方式可以求得next数组应为:

image-20211212105444166

然后开始字符串匹配,如果按照之前的步骤,应该是如下图的方式进行匹配:

image-20211212212022738

虽然这上面的效率也不错,但是是不是我们还可以在进一步提高效率呢?如图第①步到第⑤步,是否也是可以省略的呢?

答案是当然的,我们可以发现,当字符串的子串大量重复之后,很多步骤仍然是多余的步骤,所以也可以省略,至于省略的方式就是对子串进一步的进行解析。

前面的next数组可以升级为nextVal数组,目标字符串target="aaaac"的结果如:

image-20211212212757484

计算步骤

  1. 当 k = 0 时,next[0] = -1,且nextVal[k] = -1,延续next数组的设定即可。
  2. 当 k = 1 时,前面的字符串为a,无前后缀,故next[1] = 0。但是此时nextVal数组不能顺延next的设定,而是需要找到 nextVal[0] 的索引中的字符是否和当前字符相同,此处相同(索引1的a和索引0的a相同),故nextVal[1] = -1,-1 为索引0位置存储的数值。
  3. 当 k = 2 时,前面的字符串为aa,前缀为 a,后缀为 a,故next[2] = 1。查找nextVal[1] 处的字符是否和当前字符相同,此处仍然相同(索引2的a和索引1的a相同),故nextVal[2] = -1,-1为索引1位置存储的数值。
  4. 之后的 k = 3, k = 4 依然同理,先按照求next数组的方式求一个索引值,然后判断求出的索引值位置的字符是否和当前索引字符相同,若相同,则继承求出索引值位置存储的内容,否则按照原来的next数组存储。
  5. 当 k = 5 时,前面的字符串为aaaaa,前缀为{a,aa,aaa,aaaa},后缀为{a,aa,aaa,aaaa},故next[5] = 4,比较nextVal[4] 处的字符是否和当前字符相等,此处不相等(索引5的c和索引4的a不同),则仍然使用next数组的内容,故nextVal[5] = 4。

通过上面的一道例题应该可以了解nextVal数组的求法,其实就是先判断最长公共元素,再判断当前元素和之前的元素。使用这次优化后的nextVal数组,同时不用修改之前KMP算法的主题,就可以将字符串的匹配优化为:

源字符串source=“aaaabcaaaaaca”,目标字符串target="aaaaac"

image-20211212214842487

可以看到,神奇的事情发生了,原来多的7步的步骤被优化成了3步,这就是优化后强大的nextVal数组。

那么下面就是求nextVal数组的源码,可以看到,仅仅变动了中间的部分代码,其他基本和原来的next数组算法一致。

public NextArray getNextValInstance() {
    return target -> {
        // 初始化数组大小
        int[] nextVal = new int[target.length()];
        // 0 位置默认为-1
        nextVal[0] = -1;
        // 遍历字符串每一位,计算每个位置上的值
        for (int i = 0, k = -1; i < target.length() - 1; ) {
            if (k == -1 || target.charAt(i) == target.charAt(k)) {
                i++;
                k++;
                // 判断当前字符和求的k位置的字符是否相等
                if (target.charAt(i) == target.charAt(k)) {
                    // 若相等,则继承k位置的值
                    nextVal[i] = nextVal[k];
                } else {
                    // 不等,则还是使用原来next数组的值
                    nextVal[i] = k;
                }
            } else {
                k = nextVal[k];
            }
        }
        return nextVal;
    };
}

至于KMP算法主体,则不需要我们改变,通过我前面的方法,我们将这个获取实例的方法传入,就可以动态求next和nextVal数组对我们的字符串匹配问题求解。


总结

OK,以上就是对KMP算法我理解的全部内容,最后奉上我本次的全部源码:

public class StringIndex {

    // 函数式接口,获取next数组
    @FunctionalInterface
    public interface NextArray {
        int[] getNext(String target);
    }

    @Test
    public void BFTest() {
        String source = "aaaabcaaaaaca";
        String target = "aaaaac";
        System.out.println("查找到字符串的起始位置:" + indexOfToBF(source, target, 0));
        // 查找到字符串的起始位置:6
    }

    @Test
    public void KMPTest() {
        int i;
        String source = "aaaabcaaaaaca";
        String target = "aaaaac";

        // 未改进next算法
        i = indexOfToKMP(source, target, 0, getNextInstance());
        System.out.println("使用KMP算法和未改进的next数组获取下标:" + i);

        // 改进next算法
        i = indexOfToKMP(source, target, 0, getNextValInstance());
        System.out.println("使用KMP算法和改进后的next数组获取下标:" + i);

        // 使用KMP算法和未改进的next数组获取下标:6
        // 使用KMP算法和改进后的next数组获取下标:6
    }

    /**
     * 采用BF算法(暴力破解),查找字符串子串的位置
     *
     * @param source 源字符串
     * @param target 目标(子)字符串
     * @param pos    源字符串的起始索引
     * @return 目标字符串在源字符串中第一次出现的索引位置
     */
    public int indexOfToBF(String source, String target, int pos) {
        // 初始化索引
        int i = pos, j = 0;
        // 判断i和j的索引都不能大于等于字符串长度,否则说明比较完成
        while (i < source.length() && j < target.length()) {
            // 判断两个字符串的各个子字符是否相等
            if (source.charAt(i) == target.charAt(j)) {
                i++;
                j++;
            } else {
                // 如果有不相等的,则回溯。i回溯到上次的位置+1,j重新开始
                i = i - j + 1;
                j = 0;
            }
        }

        // 判断子字符的索引是否大于等于子字符串,如果为true说明查找成功,返回源字符串当前位置-子字符串长度
        return j >= target.length() ? i - target.length() : -1;
    }

    // 使用方法获取接口实例,接口中的getNext方法可以获取next数组
    public NextArray getNextInstance() {
        return target -> {
            // 初始化数组大小
            int[] next = new int[target.length()];
            // 0 位置默认为-1
            next[0] = -1;
            // 遍历字符串每一位,计算每个位置上的值
            for (int i = 0, k = -1; i < target.length() - 1; ) {
                // k还不可比较说明是第一次进入或者回溯后可能产生的结果,k++,此位置的next[i]应为0
                // 如果前缀和后缀相等,说明可以计算权值
                if (k == -1 || target.charAt(i) == target.charAt(k)) {
                    i++;
                    k++;
                    next[i] = k;
                } else {
                    // 回溯
                    k = next[k];
                }
            }
            return next;
        };
    }

    // 改进版的nextVal数组
    public NextArray getNextValInstance() {
        return target -> {
            // 初始化数组大小
            int[] nextVal = new int[target.length()];
            // 0 位置默认为-1
            nextVal[0] = -1;
            // 遍历字符串每一位,计算每个位置上的值
            for (int i = 0, k = -1; i < target.length() - 1; ) {
                if (k == -1 || target.charAt(i) == target.charAt(k)) {
                    i++;
                    k++;
                    // 判断当前字符和求的k位置的字符是否相等
                    if (target.charAt(i) == target.charAt(k)) {
                        // 若相等,则继承k位置的值
                        nextVal[i] = nextVal[k];
                    } else {
                        // 不等,则还是使用原来next数组的值
                        nextVal[i] = k;
                    }
                } else {
                    k = nextVal[k];
                }
            }
            return nextVal;
        };
    }

    /**
     * 采用KMP算法,查找字符串子串的位置
     *
     * @param source    源字符串
     * @param target    目标(子)字符串
     * @param pos       源字符串的起始索引
     * @param nextArray 函数式接口,传入获取next数组的方法(因为next数组上面定义了两种获取方式)
     * @return 目标字符串在源字符串中第一次出现的索引位置
     */
    public int indexOfToKMP(String source, String target, int pos, NextArray nextArray) {
        int i, j;
        // 调用接口中的方法,获取next数组
        int[] next = nextArray.getNext(target);
        // 初始化遍历
        i = pos;
        j = 0;
        // 判断i和j的索引都不能大于等于字符串长度,否则说明比较完成
        while (i < source.length() && j < target.length()) {
            // 如果j==-1,说明需要从头开始比较;或者由公共元素相等,继续比较
            if (j == -1 || source.charAt(i) == target.charAt(j)) {
                i++;
                j++;
            } else {
                // 需要按照数组回溯j
                j = next[j];
            }
        }
        // 判断子字符的索引是否大于等于子字符串,如果为true说明查找成功,返回源字符串当前位置-子字符串长度
        return j >= target.length() ? i - target.length() : -1;
    }
}

好了,本文到此正式结束,虽然说KMP算法已经十分高效了,但是还有需要其他的字符串匹配算法,或许更加优秀,也或许不如KMP算法,这里就不再赘述了。如果您喜欢这篇文章,或者这篇文章对您有所帮助的话,欢迎您常来我的频道。

如果想更快的了解我的更新,欢迎关注我的语雀文档CSDN博客,最后,原创不易,转载请标明出处!


本章完。

  • 5
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值