字符串匹配算法-暴力&KMP

目录

题目

朴素算法(暴力)

代码优化

KMP算法

1. 匹配过程

2. 前缀表

2.1 前缀与后缀定义

2.2 什么是前缀表(next)

2.3 如何生成前缀表

3 完整KMP代码


题目

给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串出现的第一个位置(下标从 0 开始)。如果不存在,则返回  -1 。当 needle 是空字符串时,返回 0。

leetcode:https://leetcode-cn.com/problems/implement-strstr/

题解前提

这道题题意为在一个字符串中,查找待匹配字符串在这个字符串中的位置,如果不存在则返回 -1。

我们称这个字符串为原串(string),待匹配字符串为模式串(pattern)。

 

朴素算法(暴力)

直观的解法的是:枚举原串 string中的每个字符作为「发起点」,每次从原串的「发起点」和匹配串的「首位」开始尝试匹配:

  • 匹配成功:返回本次匹配的原串「发起点」
  • 匹配失败:枚举原串的下一个「发起点」,重新尝试匹配。

代码:

public class Solution {

    public int strStr(String string, String pattern) {
        if (pattern == null || pattern.length() == 0) {
            return 0;
        }

        for (int i = 0; i <= string.length() - pattern.length(); i++) {
            if (string.charAt(i) != pattern.charAt(0)) {
                continue;
            }

            int pidx = 0;
            for (; pidx < pattern.length(); pidx++) {
                if (string.charAt(i + pidx) != pattern.charAt(pidx)) {
                    break;
                }
            }

            if (pidx == pattern.length()) {
                return i;
            }
        }
        return -1;
    }
    
}

代码优化

这个算法其实可以做一些优化,如:在匹配的过程中,记录所匹配的字符中下一次开始的位置。如果本次匹配失败,则进行下面的逻辑

  • 下一个开始的位置存在:直接从这个位置开始进行下一次匹配
  • 下一个开始的位置不存在:直接从原串匹配失败点的下一个「发起点」开始匹配

那如何定义这个下一次开始的位置呢?

下一次的开始位置其实就是:除去当前「发起点」,与模式串第一个字符相同的第一个出现的字符。(因为进入匹配过程的条件是,「发起点」的字符与模式串的第一个字符相同

 

产生冲突的情况

  • 「发起点」与模式串第一个字符冲突
  • 该「发起点」的字符串与模式串冲突,但是在原串所匹配的字符中,存在除去「发起点」后与模式串第一个字符相同的字符。
  • 该「发起点」的字符串与模式串冲突,但是在原串所匹配的字符中,不存在除去「发起点」后与模式串第一个字符相同的字符

第一种情况

这情况直接从sidx+1重新开始匹配即可

第二种情况

由于sidx与模式串的第一个字符相同,且sidx是除去「发起点」后第一个与模式串首字符相同的字符,所以此时next = sidx,下一次匹配从next开始

第三种情况

这种情况,此时next = -1,直接从sidx+1重新开始匹配即可

优化后的代码:

public class Solution {

    public int strStr(String string, String pattern) {
        if (pattern == null || pattern.length() == 0) {
            return 0;
        }

        int next = -1;
        int sidx = 0;
        int pidx = 0;

        // 发起点的长度
        // 如 string = "abcabd",pattern = "abd"
        // 那么发起点可以为"abca"前4个字符,所以,始发点的长度可以这样计算
        int startLen = string.length() - pattern.length() + 1;

        // sidx - pidx = 始发点索引
        while (sidx - pidx < startLen) {
            /* 
             * 判断是否为发起点,为发起点的话直接跳过next更新,否则进入一下步判断
             * 判断是否已经找到了下一个发起点,如果next == -1说明没找到发起点,否则就找到了,如果没找到则进入下一步判断
             * 判断原串的当前字符是否与模式串的第一个字符相同,如果相同,说明改点是下一个发起点
             */
            if (pidx != 0 && next == -1 && string.charAt(sidx) == pattern.charAt(0)) {
                next = sidx;
            }

            if (string.charAt(sidx) == pattern.charAt(pidx)) {
                sidx++;
                pidx++;

                if (pidx == pattern.length()) {
                    return sidx - pattern.length();
                }
            } else {
                if (next != -1) {
                    sidx = next;
                    next = -1;
                } else {
                    sidx++;
                }
                pidx = 0;
            }
        }

        return -1;
    }
}

KMP算法

KMP算法是由三个人研究得出的,KMP就是这三个人名字的首字母。

1. 匹配过程

举个例子

原串:"abcabdabcabf"

模式串:"abcabf"

这是KMP算法的匹配过程,下面我们来看看KMP原理。

2. 前缀表

要说清楚KMP算法,最主要的还是要讲清楚前缀与后缀,以及如何生成前缀表。前缀表是用来回退的,它记录了原串与模式串不匹配时,模式串应该从哪里开始继续匹配。

2.1 前缀与后缀定义

  • 前缀:对一个字符串而言,包含首字符,不包含尾字符的子串均称为前缀
  • 后缀:对一个字符串而言,包含尾字符,不包含首字符的子串均称为后缀

举个例子,求字符串 "abcdef" 的前后缀。

前缀有:"a"、"ab"、"abc"、"abcd"、"abcde"

后缀有:"f"、"ef"、"def"、"cdef"、"bcdef"

 

知道了前后缀定义后,我们再来看看上述的匹配过程。

如图,在 d 和 f 时发生冲突,我们来看看此时 pattern 模式串的前缀与后缀,以及最长匹配的前后缀。

因为已经匹配到了 'f',所以我们看 "abcab" 的前后缀

前缀有:"a"、"ab"、"abc"、"abca"

后缀有:"b"、"ab"、"cab"、"bcab"

最长匹配的前后缀:"ab"

 

为什么看最长匹配的前后缀呢?

首先,因为前缀一定是从 0 开始,而如果当前字符前面的字符串有最长匹配的前后缀,说明 f 前面有某段字符串与前缀一样,那么我们完全可以从前缀的下一个字符开始继续匹配。如图:

对于上面这个图,我们设 string 当前匹配的索引为 sidx,pattern 当前匹配的索引为 pidx。当 string[sidx] != pattern[pidx]时,我们就可以找到 pattern[0, pidx - 1] 字符串的最长匹配前后缀,将pidx回退到前缀的下一个位置继续匹配。做如下判断。

最长匹配的前缀 + pattern[pidx] == 最长匹配的后缀 + string[sidx]

 如果 pattern[pidx] == string[sidx] 那么说明上面的判断为true,继续往下匹配,否则为false,重复将pidx回退到前缀的下一个位置继续匹配的操作。

 

2.2 什么是前缀表(next)

前缀表是根据模式串 pattern 来生成的,记录了当前长度的字符串 “前缀与后缀最长匹配的长度”,所以可以通过这个前缀表快速的定位模式串应该从哪里开始继续匹配。

通过上面的前后缀与最长前后缀匹配长度的说明,不难写出上面匹配过程的pattern的前缀表:

那么当原创与模式串冲突时,就可以快速的通过前缀表找到从哪里开始继续匹配。

举个例子

当在 'd' 与 'f' 发生冲突时,我们可以快速的通过前缀表,找到 f 前面的字符串有没有相同的前后缀,如果有继续从前缀的下一个字符继续匹配,即 f 找 "abcab" 里有没有相同的最长前后缀,而这个字符串最长匹配的前后缀长度保存在最后一个字符下标的前缀表里,即 next[indexOf('f') - 1] 为 "abcab" 的最长匹配的前后缀长度,可知最长匹配的前后缀长度为2,那么继续匹配的位置为模式串的第三个,即 pattern[2]。(其实这里继续匹配的下标为最长匹配的前后缀长度是有原因的,因为pattern数组的下标是从0开始)

那么搞清楚了前缀表(next)的作用与生成原理后,我们来看看到底该如何生成前缀表。

2.3 如何生成前缀表

通过前面的描述,我们可以知道,前缀和后缀是相同的字符,如图。

那我们可以用一个前缀指针来标记前缀的最后一个字符,用一个后缀指针来标记后缀的最后一个字符。而前缀指针的值就是前缀的长度,也就是最长相等前后缀长度。

我们记前缀指针为:prefixIdx,后缀指针为:suffixIdx。然后模拟构建前缀表过程。

我们来模拟一段比较长的前缀表生成过程,模式串为:"ababaabc"

(1)首先,因为只有一个字符时,是不存在前缀与后缀的,所以能产生前后缀的最小长度是2,那我们可以省去 prefixIdx == suffixIdx 的情况,让两个索引错开,那我们就可以给prefixIdx与suffixIdx设置初始值。

prefixIdx = 0;
suffixIdx = 1;

 (2)第二步,判断 prefixIdx 所指的字符是否与 suffixIdx 所指的字符是否相同

  • pattern[prefixIdx] == pattern[suffixIdx]
  • pattern[prefixIdx] != pattern[suffixIdx]

对于pattern[prefixIdx] == pattern[suffixIdx]

说明该字符可以与之前的前后缀字符串组成新的最长相等前后缀,那我们直接将 prefixIdx 与 suffixIdx 都加一比对下一个字符即可。如图:

对于pattern[prefixIdx] != pattern[suffixIdx]

说明该字符不能与之前的前后缀字符串组成新的最长相等前后缀,这时候我们需要重新寻找新的最长相等前后缀,这种情况的构建逻辑有点难以理解,比较难解释。为方便解释,我们假设prefixIdx与suffixIdx遍历的是两个相同的字符串。如图:

当遍历到这里时,发现 pattern[prefixIdx] != pattern[suffixIdx],这时候就得重新找最长匹配的前后缀了,那我们怎么知道该从哪里重新开始呢?

首先,前缀一定是从0开始,那我们后缀一定也是从pattern[0]字符开始,我们主要的就是找到这个pattern[0]字符的位置,并且从该位置重新匹配到suffixIdx。这像不像一个字符串里的匹配过程?

其实这个过程非常的像在字符串里,匹配前缀字符串

  • 如果匹配成功,则当前的前缀字符串就是最长匹配的前后缀长度,继续匹配下一长度的前缀
  • 如果匹配失败,则需要找到suffixIdx下一个匹配的位置,继续匹配,如果匹配到了包含pattern[suffixIdx]的一段后缀,那么这段后缀的长度就是最长匹配的前后缀长度,否则没有匹配的前后缀,继续匹配下一长度的前缀。

匹配成功的不难理解,对于匹配失败的为什么可以这么做呢?

前面已经讲了相关的前后缀,与最长匹配前后缀知识。那么我们假想一下,当遇到 prefixIdx 与 suffixIdx 的字符不同时,我们是不是可以找到 suffixIdx 字符前面字符串的最长匹配前后缀,将 prefixIdx 回退到下一个匹配位置继续匹配。

(1)如果 pattern[prefixIdx] == pattern[suffixIdx],则说明此时前缀的长度就是最长匹配前后缀的长度,因为prefixIdx回退到的是前面字符串最长匹配前后缀的下一个字符。即(最长匹配的前缀 + pattern[prefixIdx] == 最长匹配的后缀 + pattern[suffixIdx])= true

(2)如果 pattern[prefixIdx] != pattern[suffixIdx],则继续将prefixIdx回退到下一个匹配的位置,继续重复操作。

此时我们就可以写出构建前缀表的代码了

public int[] buildNext(String pattern) {
    int plen = pattern.length();
    int[] next = new int[plen];
    for (int prefixIdx = 0, suffixIdx = 1; suffixIdx < plen; suffixIdx++) {
        while (prefixIdx > 0 && pattern.charAt(prefixIdx) != pattern.charAt(suffixIdx)) {
            prefixIdx = next[prefixIdx - 1];
        }

        if (pattern.charAt(prefixIdx) == pattern.charAt(suffixIdx)) {
            prefixIdx++;
        }

        next[suffixIdx] = prefixIdx;
    }
    
    return next;
}

知道了前缀表的代码后,KMP的代码就简单了。

3 完整KMP代码

// KMP 算法
// string: 原串(string)  pattern: 模式串(pattern)
public int strStr(String string, String pattern) {
    if (pattern == null || pattern.length() == 0) {
        return 0;
    }
    int slen = string.length();
    int plen = pattern.length();

    int[] next = buildNext(pattern);

    for (int sidx = 0, pidx = 0; sidx < slen; sidx++) {
        while (pidx > 0 && string.charAt(sidx) != pattern.charAt(pidx)) {
            pidx = next[pidx - 1];
        }

        if (string.charAt(sidx) == pattern.charAt(pidx)) {
            pidx++;
        }

        if (pidx == plen) {
            return sidx - plen + 1;
        }
    }

    return -1;
}

public int[] buildNext(String pattern) {
    int plen = pattern.length();
    int[] next = new int[plen];
    for (int prefixIdx = 0, suffixIdx = 1; suffixIdx < plen; suffixIdx++) {
        while (prefixIdx > 0 && pattern.charAt(prefixIdx) != pattern.charAt(suffixIdx)) {
            prefixIdx = next[prefixIdx - 1];
        }

        if (pattern.charAt(prefixIdx) == pattern.charAt(suffixIdx)) {
            prefixIdx++;
        }

        next[suffixIdx] = prefixIdx;
    }

    return next;
}

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值