串的模式匹配算法

串也叫字符串。子串的定位操作通常称作串的模式匹配。比如在一篇文章中查找某个关键词。

假如现在我们要从主串S="goodgoogle"中,找到T="google"这个子串的位置,那该如何操作呢?

朴素的模式匹配算法

思路

串其实可以看做是一个字符数组,数组是从0角标开始计数,有些资料在讲解算法思路的时候串的字符是从第1位开始的,这样就导致写代码时要给位置标识减1才能保持一致。我这里为了推导过程和写代码保持一致,避免造成混淆,统一从第0位开始。

  1. 主串从第0位开始,S与T前三个字母都匹配成功,但S第3个字母是“d”,而T的是“g”,匹配失败。如图所示,竖直连线表示相等,闪电状弯折连线表示不等。
    在这里插入图片描述

  2. 主串S从第1位开始,主串S首字母是“o”,要匹配的T首字母是“g”,匹配失败。
    在这里插入图片描述

  3. 主串从第2、3位开始都是匹配失败。
    在这里插入图片描述

在这里插入图片描述

  1. 主串S从第4位开始,S与T,6个字母全匹配,匹配成功。
    在这里插入图片描述

简单来说,就是以主串S的每一个字符作为子串开头,与要匹配的字符串T进行匹配。对主串S做大循环,对T做小循环,直到匹配成功或全部遍历完成为止。当主串S剩下字符的个数少于T串字符个数时 ,就不必再进行下去了。

代码示例

串的模式匹配算法可以用来判断主串S是否包含串T,返回true或false;也可以返回T在S中首次出现的下标;甚至可以返回T在S中的所有下标。

假设我现在是想返回T在S中首次出现的下标,代码实现如下:

/**
 * 用朴素的模式匹配算法查询字符串的起始位置
 * @param content 目标字符串
 * @param key 要查找的字符串
 * @return -1:不包含; 大于等于0:起始位置下标(包含key)
 */
public static int beginning(String content, String key) {
    for (int i = 0; i <= content.length() - key.length(); i++) {
        for (int j = 0, pos = i, beginning = -1; j < key.length(); j++) {
            if (content.charAt(pos) == key.charAt(j)) {
                pos++;
                if (j == 0)
                    beginning = i;
                if (j == key.length() - 1)
                    return beginning;
            } else
                break;
        }
    }
    return -1;
}

时间复杂度

分析一下,最好的情况是什么?那就是一开始就匹配成功了,比如在"googlegood"中去找"google",假设主串S长度为m,要匹配的串T的长度为n,那只需要对T进行一次遍历即可,所以时间复杂度是O(n)(有的资料说是O(1),我觉得不合适,遍历T的时间复杂度怎么会是常量呢)。

稍微差一些,如果像刚才例子中第1、2、3位一样,每次都是首字母就不匹配,那么对T的循环就不必进行了,比如在"abcdefgoogle"中去找"google",那么时间复杂度为O(m-n+n)=O(m)。

那么最坏的情况又是什么?就是每次不成功的匹配都发生在串T的最后一个字符。举一个很极端的例子,主串S=“00000000000000000000000000000000000000000000000001”,而要匹配的子串为T=“0000000001”。在匹配时,每次都得将T中字符遍历到最后一位才发现:哦,原来它们是不匹配的。这等于说在S串的前40个位置都需要判断10次才能得出不匹配的结论。

在这里插入图片描述

直到第41个位置(角标从40开始)才全部匹配相等。

在这里插入图片描述

如果最终没有匹配上,比如T=“0000000002”,到了第40位发现没有匹配上,也不需要进行下去了,因为S串剩余字符个数少于T串字符个数。因此最坏的情况是O((m-n+1)*n)。

在实际运用中,对于计算机来说,处理的都是二进制位的0和1的串,一个字符的ASCII码也可以看成是8位的二进制01串,当然,汉字等所有的字符也都可以看成是多个0和1串。再比如像计算机图形也可以理解为是由许许多多个0和1的串组成。所以在计算机的运算当中,模式匹配操作可说是随处可见,而刚才这个算法,就显得太低效了。

KMP模式匹配算法

思路

由于不能忍受朴素的模式匹配算法的低效,于是有三位前辈,D.E.Knuth、J.H.Morris和V.R.Pratt(其中Knuth和Pratt共同研究,Morris独立研究)发表了一个模式匹配算法,可以大大避免重复遍历的情况,我们把它称之为Knuth-Morris-Pratt算法,简称KMP算法。

如果主串S=“abcdefgab”,其实还可以更长一些,我们就省略掉只保留前9位,我们要匹配的T=“abcdex”,那么如果用前面的朴素算法的话,前5个字母,两个串完全相等,直到第6个字母,"f"与"x"不等。

在这里插入图片描述

接下来,按照朴素的模式匹配算法,应该是如下流程:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

可仔细观察发现,对于串T来说,"abcdex"首字母"a"与后面的串"bcdex"中任意一个字符都不相等,既然"a"不与自己后面的子串中任何一个字符相等,而S和T两个串的前五位分别相等,那么就意味着T的首字母"a"不可能与S串的第2到第5位的字符相等。所以,上图中②③④⑤的判断都是多余的,只保留①⑥即可。

之所以要保留⑥,是因为T[5]≠S[5],我们仅通过T[0]≠T[5]不能推断出T[0]不等于S[5],因此要保留这一步。

这意味着什么?是不是意味着主串的角标不用回溯了?在朴素的模式匹配算法中,内循环完了之后主串角标需要回溯,以保证每次只增加1位。而KMP模式匹配算法中主串角标不需要回溯,只需回溯T串的角标即可。那么问题来了,T串的角标回溯到哪里呢?是不是每次都是从第0位开始,就如同上述例子那样?

再看一个例子。假设S=“abcabcabc”,T=“abcabx”。对于开始的判断,前5个字符完全相等,第6个字符不等。此时,根据刚才的经验,T的首字符"a"与T的第1位"b"、第2位"c"均不等,而T的第1、2位与S的第1、2位相等,所以T的首字符"a"不需要再和S的1、2位比较就可以判定为不等了,因此下图中②③步可以省略了。

在这里插入图片描述
因为T的首位"a"与T的第3位"a"相等,第1位的"b"与第4位的"b"相等。而第3位的"a"与第4位的"b"已经与主串S中的相应位置比较过了,是相等的,因此T的首字符"a"、第1位的字符"b"与S的第3位和第4位字符也不需要比较了,肯定也是相等的。所以上图中④⑤这两步也可以省略。

经过一番推理,我们知道了上图第①步判断完之后直接可以进行第⑥步了,⑥中T是从第2位开始的,这就说明T并不是每次都要回溯到第0位。那这其中有什么规律吗?

仔细观察发现,T回溯到的位置j与主串没什么关系,关键就取决于T中有没有重复的值。比如说T=“abcdex”,当j=5时(此时遍历到了字符"x")发现不匹配,“x"前面的串"abcde"前后缀不相等,此时规定j回溯到0,也就是从头开始遍历;再比如T=“abcabx”,当j=5时(此时遍历到了字符"x”)发现不匹配,由于"x"前面的串"abcab"的前缀和后缀都是"ab",此时规定从前缀"ab"的后一个字符"c"开始遍历,也即j回溯到2。

再看一种特殊情况T=“aaaaaaaab”,当j=8时,如果要回溯,该回溯到哪个位置呢?要搞明白这一点,就需要知道前缀是从哪一位到哪一位,后缀又是从哪一位到哪一位。答案如下图:

在这里插入图片描述

可以看到,前缀串和后缀串是可以有重叠的地方的,但是不能完全重合,我们只需记住这个规律就行。

next数组值推导

实际上,串中每个位置都对应一个回溯的位置,这些回溯的位置可以组成一个跟串长度一样的数组。当在某个位置发现不匹配时,对串的角标进行回溯。回溯位置的求导遵从如下规律:

  1. j=0时,回溯值为-1,j=-1时,意味着i需要加1了,否者匹配无法进行下去,这个需要对照代码理解。-1只是一个特殊标识,并不是非得是-1,只要不是数组的合法角标就都行。
  2. j=1时,回溯值为0。
  3. j>1时,从0角标到j-1角标组成一个子串,这个子串的前缀和后缀如果相等,那回溯的位置等于前缀最后一个字符的角标加1,否则回溯值为0。这里需要注意,前缀串和后缀串可以有重叠的地方的,但是不能完全重合。

我们把T串各个位置回溯值的集合定义为一个数组nextArr,下面来看几个串的nextArr示例:

串Ta b c d e x
j0 1 2 3 4 5
nextArr[j]-1 0 0 0 0 0
串Ta b c a b x
j0 1 2 3 4 5
nextArr[j]-1 0 0 0 1 2
串Ta b a b a a a b a
j0 1 2 3 4 5 6 7 8
nextArr[j]-1 0 0 1 2 3 1 1 2
串Ta a a a a a a a b
j0 1 2 3 4 5 6 7 8
nextArr[j]-1 0 1 2 3 4 5 6 7

代码示例

说了这么多,nextArr改如何求呢?直接看代码:

public static int[] getNextArr(String key) {
    if (key.isEmpty())
        return null;
    int[] arr = new int[key.length()];
    int i = 0, j = -1; // -1是特殊标识
    arr[0] = -1;
    while (i < key.length() - 1) { // 求i+1处的next值
        if (j == -1 || key.charAt(i) == key.charAt(j))
            arr[++i] = ++j;
        else
            j = arr[j];
    }
    
    return arr;
}

上面这段求nextArr的代码很巧妙,但却有点难懂,就算你理解了nextArr的求导思路,也不一定能看懂这段代码。简单分析下这段代码,我们求的是i+1处的next值,两个指针i和j,i在前,j在后,当i处的字符和j处的字符相等时,就说明i+1前面的串T1的前后缀相等,你肯定想问凭什么,关键就在于i和j处的字符相等时i和j才会自增,这就说明0到j-1这段串和i前面的某段串是相等的。所以i处的字符和j处的字符相等后,j是T1的前缀串的最后一个字符,i是T1的后缀串的最后一个字符,因此i+1的next值就是j+1了。

当i和j处的字符不相等时,j回溯到自己的next,然后再和i处的字符相比较。j的next处的字符和i处的字符相等时,也能立马求出i+1处的next值。这是因为i前面的某段串和j前面的串(从0角标开始到j-1)相等,而j前面的某段串又和j的next前面的串(从0角标开始到j的next减1)相等,所以j的next前面的串和i前面的某段串相等。

如果i和j处的字符一直都不相等,j最终会回溯到-1,-1就是一个特殊标识啦,遇到-1,不管i处字符和j处字符相不相等,i和j都会自增,不然像那种没有字符相等的情况,i和j岂不是一直停滞不前。

以上就是关于求nextArr代码的分析了,的确是很抽象,实在不懂就只能亲自debug了。

KMP模式匹配算法中,i值不回溯,j值回溯,j值回溯的位置有个nextArr数组与之对应,我们对朴素的模式匹配算法稍加改动就能得到KMP模式匹配算法了,代码如下:

public static int beginning(String content, String key) {
    int[] arr = getNext(key);
    if (arr == null)
        return -1;
    for (int i = 0, j = 0; i < content.length();) {
        if (j == -1) {
            if (++i >= content.length())
                break;
            j++;
        }
        for (; j < key.length(); j++) {
            if (content.charAt(i) == key.charAt(j)) {
                if (j == key.length() - 1)
                    return i - j;
                i++;
            } else {
                j = arr[j];
                break;
            }
        }
    }
    return -1;
}

时间复杂度

对于getNextArr函数来说,只涉及到简单的单循环,若T的长度为n,其时间复杂度为O(n),若S的长度为m,由于i值不回溯,所以整个算法的时间复杂度为O(m+n),比朴素的模式匹配算法还是要好一些。

这里也需要强调,KMP算法仅当模式串与主串之间存在许多“部分匹配”的情况下才能体现出它的优势,否则两者差距不明显。

算法的改进

后来有人发现,KMP算法还是有缺陷的,比如主串S=“aaaabcde”,模式串T=“aaaaax”,T的nextArr={-1, 0, 1, 2, 3, 4}。

在这里插入图片描述
当i=4和j=4时,发现不匹配,如上图①。然后j回溯到3,发现又不匹配,如上图②。接着j回溯到2,发现还不匹配。j继续回溯,一直回溯到0,此时仍然不匹配。j继续回溯到-1,当j=-1时,i和j自增1,于是i=5,j=0,如上图⑥所示。

仔细观察发现,②③④⑤步骤中的判断都是多余的。由于T串的第1、2、3、4位的字符与第0位的字符相等,因此就可以用第0位的next值去取代第1、2、3、4位的next值。

规律是这样的,j回溯到next位置,如果发现j处的字符和next处的字符相等,那么就再回溯一次,实际上此时j的真正next应该是j的next的next。

就拿上面的T="aaaaax"来说,nextArr[0]=-1,本来nextArr[1]=0,然后发现T[1]=T[0],于是nextArr[1]=nextArr[0]=-1。同理,本来nextArr[2]=1,然后发现T[2]=T[1],于是nextArr[2]=nextArr[1]=-1。

假设改良后的next数组为nextArrNew,请看几个模式串的nextArrNew示例:

串Ta b a b a a a b a
j0 1 2 3 4 5 6 7 8
nextArr[j]-1 0 0 1 2 3 1 1 2
nextArrNew[j]-1 0 -1 0 -1 3 1 0 -1
串Ta a a a a a a a b
j0 1 2 3 4 5 6 7 8
nextArr[j]-1 0 1 2 3 4 5 6 7
nextArrNew[j]-1 -1 -1 -1 -1 -1 -1 -1 7

代码实现如下:

public static int[] getNextArrNew(String key) {
    if (key.isEmpty())
        return null;
    int[] arr = new int[key.length()];
    int i = 0, j = -1; // -1是特殊标识
    arr[0] = -1;
    while (i < key.length() - 1) { // 求i+1处的next值
        if (j == -1 || key.charAt(i) == key.charAt(j)) {
            ++i;
            ++j;
            if (key.charAt(i) != key.charAt(j))
                arr[i] = j;
            else
                arr[i] = arr[j];
        } else
            j = arr[j];
    }
    return arr;
}
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值