字符串算法大整理!你能想到的都能找到(补全中)

字符串算法大整理!你能想到的都能找到(吧)。

2018.7.16 Chengdu

今天学习了字符串相关的一些算法,种类挺多的,特来整理一波。

字符串哈希(Hash)

简介

哈希( Hash H a s h )是一种神奇的查找算法,广泛运用于计算机领域,它的强大在于设计得好的哈希算法可以使对一个对象的查找时间复杂度降为 O(1) O ( 1 ) ,这是朴素查找的 O(n) O ( n ) 和二分查找的 O(logn) O ( log ⁡ n ) 所远不能及的。

第一次了解到哈希这一种技术是在吴军 dalao d a l a o 的《数学之美》一书上(感谢 MasterYin M a s t e r Y i n 的借阅!)讲解网络爬虫的数学原理的一章。有兴趣的话可以去读一读。

原理

哈希查找

为什么哈希查找的时间复杂度可以这么低呢?考虑下面一个问题:

设计一个程序,以完成以下输入输出:
第一行,输入两个数字 n,m n , m
第二行,输入 n n 个数字。
第三行,输入 m 个数字。对于每一个数字,询问其是否存在于第二行输入 n n 个数中,若是则输出”YES”,若不是则输出”NO”。
数据说明:输入的数字的范围在 [1,105]

对于朴素算法,我们把第二行输入的数字储存在一个数组中。对于每一次询问,我们从左到右遍历整个数组来查询,每次询问的时间复杂度为 O(n) O ( n )

对于二分查找算法,我们把第二行输入的数字进行排序。对于每一次询问,我们用二分查找的方法找到这个数字处在的位置,排序的时间复杂度为 O(logn) O ( log ⁡ n ) ,每次询问的时间复杂度也为 O(logn) O ( log ⁡ n )

可是这一题真的有这么复杂吗?我们可以使用一个大小为 105 10 5 的辅助数组 vis v i s 来表示一个数字是否出现过。对于第二行中输入的每一个数字 a a ,我们在 vis 数组中进行记录: vis(a)=true v i s ( a ) = t r u e 。这样,对于每一次询问,我们只用 O(1) O ( 1 ) 的时间访问 vis v i s 数组,就可以得到询问的结果了。而哈希,就是运用了这样的思想:我们多开一个数组,用这些多申请的空间去解决时间上的复杂。这也正是应了那句名言:

以空间换时间。 —-蒋介石

字符串哈希

那么如果我们把题改一下呢?

设计一个程序,以完成以下输入输出:
第一行,输入两个数字 n,m n , m
第二行,输入 n n 个字符串。
第三行,输入 m 个字符串。对于每一个字符串,询问其是否存在于第二行输入 n n 个字符串中,若是则输出”YES”,若不是则输出”NO”。
数据说明:输入的字符串的长度范围在 [1,105]

如果输入的是字符串的话,我们不就不能用 vis v i s 数组来储存了吗?这可怎么办?当然,如果你很熟悉 STL S T L 这一神奇的模板库,那么你一定会毫不犹豫地打出一行这样的代码:

map<string,bool>vis;

是的, map m a p 可以完美的解决这个问题。不过,我们可以试试用自己的力量来完成对字符串的处理:将字符串转换为数字。

怎样转换呢?我们知道,每一个字符都有它对应的 ASCII A S C I I 码,也就是说,一个字符可以表示为一个数字。那么,如果把字符串看作是字符的连续,不就可以把每个字符的 ASCII A S C I I 值连续起来表示字符串吗?我们可以运用这一点特性,将输入的字符串转换为一个 P P 进制数。在这里, P 是一个大数。处理完后得到的 P P 进制数,就叫做这个字符串的哈希值。按照我的习惯,我会取 P=131 。当然,如果你乐意,取点什么 P=233 P = 233 P=666 P = 666 也是可以的,不过很显然,计算难度也会随着 P P 的增大而逐步增大。

接下来给出一个函数 Hash ,来获取一个字符串的哈希值:

const int P=131;

int Hash(string tmp)
{
    int hash=0;
    for(int i=0;i<tmp.length();i++)
        hash=hash*P+tmp[i];
    return hash;
}

可是我们又面临了一个问题,这是因为题目中写到:

数据说明:输入的字符串的长度范围在 [1,105] [ 1 , 10 5 ]

字符串的最大长度是 105 10 5 !那 int i n t 不就爆炸了吗?

所以我们还要定义一个大数 M M ,在 Hash 函数的运算过程中令运算结果对它取模,作为最后的字符串哈希值。依照我的习惯,我会取 M=99991 M = 99991 。当然,如果你乐意,取其它的值也是可以的。那么这个函数的运算就变成了:

const int P=131;
const int M=99991;

int Hash(string tmp)
{
    int hash=0;
    for(int i=0;i<tmp.length();i++)
        hash=(hash*P+tmp[i])%M;
    return hash;
}

这样我们就得到了一个字符串的哈希值,不过问题又出现了:怎样保证两个字符串的哈希各不相同?

这确实是哈希的一个大问题,具体来说有三种解决方法:

  1. 通过增加哈希池大小来降低两个字符串的哈希值冲突的概率。哈希池就是储存字符哈希值的地方,对应上述问题中的 vis v i s 数组。我们可以通过多模哈希或者增大 M M 的方式来达到这一结果,不过这样就会使得哈希计算过于复杂而且难以储存。
  2. 对于每个字符串的哈希值记录原有字符串,在冲突的情况下对原有字符串进行逐次比较。

第二种方法是我的常用方法,不过怎么实现呢?这里我们可以不使用 vis 数组,而是转而使用神奇的 vector v e c t o r 来达到这一点。不过这样的话,每次查找要调用一个函数 Add A d d ,每次询问要调用一个函数 Query Q u e r y ,具体来说这样写:

vector<string>v[M];

bool Query(string tmp)
{
    int pos=Hash(tmp);
    for(int i=0;i<v[pos].size();i++)
        if(v[pos][i]==tmp) return true;
    return false;
}
void Add(string tmp)
{
    if(Query(tmp)) return ;
    int pos=Hash(tmp);
    G[pos].push_back(tmp);
}

这样就达到了我们的目的。这里给出一道字符串哈希的模板题:

Luogu P3370 【模板】字符串哈希

哈希的弊端 & 如何卡哈希

可是还有的人为了图代码书写方便而拒绝使用强大的 vector v e c t o r 来进行储存,这样就会被毒瘤出题人卡,因为会有人思考出卡掉各种字符串哈希的方法,具体可见几道神题:

BZOJ 3097 Hash Killer I
BZOJ 3098 Hash Killer II
BZOJ 3099 Hash Killer III
BZOJ 4917 [Lydsy1706月赛]Hash Killer IV (付费警告!)

在这些题目里,你被要扮演一个毒瘤出题人。因为你的后缀自动机神题被人用字符串哈希水掉了,所以你很不开心,决定用一组自造数据来卡掉他的代码。在这些题目中,给出水题的人的C++代码,要求你输出一组 hack h a c k 数据。

题目挺有意思,但是我们如何来操作呢?即,我们如何构造出两个相同的字符串,使它们的哈希值相同呢?

先拿第一题做例子。下面是题目大意:

Hash killer 1:
本来的神题:给你一个长度为 N N 的字符串S ,求有多少个不同的长度为 L L 的子串?
水过的人:给出一份 cpp ,其采用进制哈希配合 unsigned long long 的自然溢出来求出每一个长度为 L 的子串的哈希值,然后使用排序去重得到不同的子串个数。
你的输出:你现在需要给出一组可以卡掉这个算法的数据。
数据大小: 1N105 1 ≤ N ≤ 10 5

自然溢出是什么意思呢?就是不去取 M M 的模,而是任由运算结果爆出数据范围,让储存范围自动地帮你取模。这也就相当于令 M=264 。他的代码如下:

typedef unsigned long long u64;
const int MaxN = 100000;
inline int hash_handle(const char *s, const int &n, const int &l, const int &base)
{
    u64 hash_pow_l = 1;
    for(int i=1;i<=l;i++)
        hash_pow_l *= base;
    int li_n = 0;
    static u64 li[MaxN];
    u64 val = 0;
    for(int i=0;i<l;i++)
        val=val*base+s[i]-'a';
    li[li_n++]=val;
    for(int i=l;i<n;i++)
    {
        val=val*base+s[i]-'a';
        val-=(s[i-l]-'a')*hash_pow_l;
        li[li_n++]=val;
    }
    sort(li,li+li_n);
    li_n=unique(li,li+li_n)-li;
    return li_n;
}

需要注意的事,这一代码中的基数 base b a s e 值(也就是我代码中的 P P 值)是随机的。那么我们就需要分类讨论:

  • 如果基数 base 是偶数,那么 base b a s e 就可以写作一个整数与 2 2 相乘,而我们知道 M=264 。那么字符串长度超过 64 64 即可卡掉。例如这两个字符串的哈希值相同:

    • "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    • "baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    • (别数了,两个字符串长度都是 65 65
  • 如果基数 base b a s e 是奇数::
    • 我们可以构造两个字符串 str1str2 ,我们队最终目的就是使它们满足 hash(str1)mod264=hash(str2)mod264 h a s h ( s t r 1 ) mod 2 64 = h a s h ( s t r 2 ) mod 2 64
    • (hash(str1)hash(str2))mod264=0 ( h a s h ( s t r 1 ) – h a s h ( s t r 2 ) ) mod 2 64 = 0 ,就能把代码卡掉。
    • 01 01 字符串 S[i]=S[i1]+not(S[i1]),S[0]=0 S [ i ] = S [ i − 1 ] + n o t ( S [ i − 1 ] ) , S [ 0 ] = ′ 0 ′ 。其中,中括号中的 i i 仅表示字符串长度为 2i not(S[i1]) n o t ( S [ i − 1 ] ) 表示对 01 01 字符串 S[i1] S [ i − 1 ] 的每一位取反。
    • 接下来定义 h(x)=hash(S[x]),hn(x)=hash(not(S[x])) h ( x ) = h a s h ( S [ x ] ) , h n ( x ) = h a s h ( n o t ( S [ x ] ) )
    • 根据之前的定义以及进制哈希的特性,显然有:
      h(i)hn(i)=h(i1)×base2i1+hn(i1)hn(i1)×base2i1h(i1)=(h(i1)hn(i1))×(base2i11) h ( i ) – h n ( i ) = h ( i − 1 ) × b a s e 2 i − 1 + h n ( i − 1 ) − h n ( i − 1 ) × b a s e 2 i − 1 – h ( i − 1 ) = ( h ( i − 1 ) – h n ( i − 1 ) ) × ( b a s e 2 i − 1 − 1 )
    • 对于 base2i11 b a s e 2 i − 1 − 1 ,我们可以用神奇的欧拉定理得到:
      ==(base2i11)mod2i(base01)mod2i0 ( b a s e 2 i − 1 − 1 ) mod 2 i = ( b a s e 0 − 1 ) mod 2 i = 0
    • 所以我们就可以把上面的式子进行转换:
      (h(i1)hn(i1))×(base2i11)=(h(i1)hn(i1))×2ik ( h ( i − 1 ) – h n ( i − 1 ) ) × ( b a s e 2 i − 1 − 1 ) = ( h ( i − 1 ) – h n ( i − 1 ) ) × 2 i k
    • 其中的k是个没什么用的常数。然后我们用相同的方法拆啊拆啊拆,便可以得到:
      ==h(i)hn(i)21+2+3++ik2(1+i)i2k h ( i ) – h n ( i ) = 2 1 + 2 + 3 + ⋯ + i k = 2 ( 1 + i ) i 2 k
    • 我们接下来只需要让 2(1+i)i2264 2 ( 1 + i ) i 2 ≥ 2 6 4 就好啦。经过简单的计算,我们要让 i11 i ≥ 11
  • 恭喜你,经过了层层计算,终于卡掉了他的代码!(体会到毒瘤出题人的艰辛)

    接下来我们再来看看相对与计算无关的第二题吧。神题还是一模一样,只不过水题的人不打算使用自然溢出了,而是打算用模大质数的方法(这是剽窃我的代码啊喂)。而且喜欢大数字的他打算加大哈希池,令 M=109+7 M = 10 9 + 7 。他的代码是这样的:

    typedef unsigned long long u64;
    const int MaxN = 100000;
    
    inline int hash_handle(const char *s, const int &n, const int &l, const int &base)
    {
        const int Mod=1000000007;
        u64 hash_pow_l=1;
        for(int i=1;i<=l;i++)
            hash_pow_l=(hash_pow_l*base)%Mod;
        int li_n=0;
        static int li[MaxN];
        u64 val=0;
        for(int i=0;i<l;i++)
            val=(val*base+s[i]-'a')%Mod;
        li[li_n++]=val;
        for(int i=l;i<n;i++)
        {
            val=(val*base+s[i]-'a')%Mod;
            val=(val+Mod-((s[i-l]-'a')*hash_pow_l)%Mod)%Mod;
            li[li_n++]=val;
        }
        sort(li,li+li_n);
        li_n =unique(li,li+li_n)-li;
        return li_n;
    }

    这道题的做法也不难,但是要考验你的欧气了,先来了解一个神奇的悖论:

    生日悖论_百度百科

    那么同样的,如果你用大量的数据去卡他,他不就废了吗?不过如果你足够非,你还是可以WA到飞起的。

    KMP算法

    简介

    三名算法大佬克努特( D.E.Knuth D . E . K n u t h )、莫里斯( J.H.Morris J . H . M o r r i s )以及普拉特( V.R.Pratt V . R . P r a t t )改进了字符串匹配算法,所以我们合称这个算法为 KMP K M P 算法。网上对于这种算法有很多的讲解,而讲的最好的非这一篇莫属了。感谢这位大佬详尽的讲解。摘录于此,方便大家学习:

    原文地址:v_JULY_v 的博客

    1. 引言

    KMP K M P 原文最初写于 2 2 年多前的 2011 12 12 月,因当时初次接触 KMP K M P ,思路混乱导致写也写得混乱。所以一直想找机会重新写下 KMP K M P ,但苦于一直以来对 KMP K M P 的理解始终不够,故才迟迟没有修改本文。

    然近期因开了个算法班,班上专门讲解数据结构、面试、算法,才再次仔细回顾了这个 KMP K M P ,在综合了一些网友的理解、以及算法班的两位讲师朋友曹博、邹博的理解之后,写了 9 9 PPT ,发在微博上。随后,一不做二不休,索性将 PPT P P T 上的内容整理到了本文之中(后来文章越写越完整,所含内容早已不再是九张 PPT P P T 那样简单了)。

    KMP K M P 本身不复杂,但网上绝大部分的文章(包括本文的 2011 2011 年版本)把它讲混乱了。下面,咱们从暴力匹配算法讲起,随后阐述 KMP K M P 的流程、步骤、 next n e x t 数组的简单求解、递推原理、代码求解,接着基于 next n e x t 数组匹配,谈到有限状态自动机, next n e x t 数组的优化, KMP K M P 的时间复杂度分析,最后简要介绍两个 KMP K M P 的扩展算法。

    全文力图给你一个最为完整最为清晰的 KMP K M P ,希望更多的人不再被 KMP K M P 折磨或纠缠,不再被一些混乱的文章所混乱。有何疑问,欢迎随时留言评论, thanks t h a n k s

    2. 暴力匹配算法

    假设现在我们面临这样一个问题:有一个文本串 S S ,和一个模式串 P ,现在要查找 P P S 中的位置,怎么查找呢?

    • 如果用暴力匹配的思路,并假设现在文本串 S S 匹配到 i 位置,模式串 P P 匹配到 j 位置,则有:

    • 如果当前字符匹配成功(即 S[i] == P[j] ),则 i++,j++ ,继续匹配下一个字符;

    如果失配(即 S[i] != P[j] ),令 i = i - (j - 1),j = 0 。相当于每次匹配失败时, i i 回溯, j 被置为 0 0

    理清楚了暴力匹配算法的流程及内在的逻辑,咱们可以写出暴力匹配的代码,如下:

    int ViolentMatch(char* s, char* p)
    {
        int sLen = strlen(s);
        int pLen = strlen(p);
    
        int i = 0;
        int j = 0;
        while (i < sLen && j < pLen)
        {
            if (s[i] == p[j])
            {
                //如果当前字符匹配成功(即S[i] == P[j]),则i++,j++    
                i++;
                j++;
            }
            else
            {
                //如果失配(即S[i]! = P[j]),令i = i - (j - 1),j = 0    
                i = i - j + 1;
                j = 0;
            }
        }
        //匹配成功,返回模式串p在文本串s中的位置,否则返回-1
        if (j == pLen)
            return i - j;
        else
            return -1;
    }

    举个例子,如果给定文本串 S "BBC ABCDAB ABCDABCDABDE" ,和模式串 P P "ABCDABD" ,现在要拿模式串 P 去跟文本串 S S 匹配,整个过程如下所示:

    1. S[0]BP[0]A ,不匹配,执行第②条指令:“如果失配(即 S[i] != P[j] ),令 i = i - (j - 1),j = 0S[1]P[0] 匹配,相当于模式串要往右移动一位( i=1,j=0 )。

    1. S[1]P[0] 还是不匹配,继续执行第②条指令:如果失配(即 S[i] != P[j] ),令 i = i - (j - 1),j = 0S[2]P[0] 匹配( i=2,j=0 ),从而模式串不断的向右移动一位(不断的执行令 i = i - (j - 1),j = 0i 2 2 变到 4 j j 一直为 0 )。

    1. 直到 S[4]P[0] 匹配成功(i=4,j=0),此时按照上面的暴力匹配算法的思路,转而执行第①条指令:如果当前字符匹配成功(即 S[i] == P[j] ),则 i++,j++ ,可得 S[i]S[5]P[j]P[1] ,即接下来 S[5]P[1]匹配( i=5,j=1 )。

    1. S[5]P[1] 匹配成功,继续执行第①条指令:如果当前字符匹配成功(即 S[i] == P[j] ),则 i++,j++ ,得到 S[6]P[2] 匹配( i=6,j=2 ),如此进行下去。

    1. 直到 S[10] 为空格字符, P[6] 为字符 Di=10,j=6 ),因为不匹配,重新执行第②条指令:“如果失配(即 S[i] != P[j] ),令 i = i - (j - 1),j = 0 ,相当于 S[5]P[0] 匹配( i=5,j=0 )。

    1. 至此,我们可以看到,如果按照暴力匹配算法的思路,尽管之前文本串和模式串已经分别匹配到了 S[9]P[5] ,但因为 S[10]P[6] 不匹配,所以文本串回溯到 S[5] ,模式串回溯到 P[0] ,从而让 S[5]P[0] 匹配。

    S[5] 肯定跟 P[0] 失配。为什么呢?因为在之前第 4 4 步匹配中,我们已经得知 S[5] = P[1] = B ,而 P[0] = A ,即 P[1] != P[0] ,故 S[5] 必定不等于 P[0] ,所以回溯过去必然会导致失配。那有没有一种算法,让 i 不往回退,只需要移动 j 即可呢?

    答案是肯定的。这种算法就是本文的主旨 KMP 算法,它利用之前已经部分匹配这个有效信息,保持 i i 不回溯,通过修改 j 的位置,让模式串尽量地移动到有效的位置。

    3. KMP算法

    3.1 定义

    KnuthMorrisPratt K n u t h − M o r r i s − P r a t t 字符串查找算法,简称为 “ KMP K M P 算法”,常用于在一个文本串 S S 内查找一个模式串P的出现位置,这个算法由 DonaldKnuth D o n a l d K n u t h VaughanPratt V a u g h a n P r a t t JamesH.Morris J a m e s H . M o r r i s 三人于 1977 1977 年联合发表,故取这 3 3 人的姓氏命名此算法。

    下面先直接给出KMP的算法流程(如果感到一点点不适,没关系,坚持下,稍后会有具体步骤及解释,越往后看越会柳暗花明):

    • 假设现在文本串 S S 匹配到i位置,模式串 P P 匹配到j位置。

      • 如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符;

      • 如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令i不变,j = next[j]。此举意味着失配时,模式串$P$相对于文本串$S$向右移动了j - next[j]` 位。

        • 换言之,当匹配失败时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应的 next n e x t 值( next n e x t 数组的求解会在下文的 3.3.3 3.3.3 节中详细阐述),即移动的实际位数为: jnext[j] j − n e x t [ j ] ,且此值大于等于 1 1

    很快,你也会意识到next数组各值的含义:代表当前字符之前的字符串中,有多大长度的相同前缀后缀。例如如果 next[j]=k n e x t [ j ] = k ,代表 j j 之前的字符串中有最大长度为k的相同前缀后缀。

    此也意味着在某个字符失配时,该字符对应的 next n e x t 值会告诉你下一步匹配中,模式串应该跳到哪个位置(跳到 next[j] n e x t [ j ] 的位置)。如果next [j] 等于0或-1,则跳到模式串的开头字符,若 next[j]=k n e x t [ j ] = k k>0 k > 0 ,代表下次匹配跳到 j j 之前的某个字符,而不是跳到开头,且具体跳过了k个字符。

    转换成代码表示,则是:

    int KmpSearch(char* s, char* p)
    {
        int i = 0;
        int j = 0;
        int sLen = strlen(s);
        int pLen = strlen(p);
        while (i < sLen && j < pLen)
        {
            //如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++    
            if (j == -1 || s[i] == p[j])
            {
                i++;
                j++;
            }
            else
            {
                //如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]    
                //next[j]即为j所对应的next值      
                j = next[j];
            }
        }
        if (j == pLen)
            return i - j;
        else
            return -1;
    }

    继续拿之前的例子来说,当S[10]P[6]匹配失败时, KMP K M P 不是跟暴力匹配那样简单的把模式串右移一位,而是执行第②条指令:“如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令i不变,j = next[j]”,即j6变到2(后面我们将求得P[6],即字符D对应的next值为2),所以相当于模式串向右移动的位数为j - next[j]j - next[j] = 6-2 = 4)。

    向右移动4位后,S[10]P[2]继续匹配。为什么要向右移动4位呢,因为移动4位后,模式串中又有个“AB”可以继续跟S[8]S[9]对应着,从而不用让i回溯。相当于在除去字符D的模式串子串中寻找相同的前缀和后缀,然后根据前缀后缀求出next数组,最后基于next数组进行匹配(不关心next数组是怎么求来的,只想看匹配过程是咋样的,可直接跳到下文 3.3.4 3.3.4 节)。

    3.2 步骤
    • ①寻找前缀后缀最长公共元素长度

      • 对于 P=p[0,j] P = p [ 0 , j ] ,寻找模式串 P P 中长度最大且相等的前缀和后缀。如果存在p[0,k]=p[jk,j],那么在包含 pj p j 的模式串中有最大长度为 k+1 k + 1 的相同前缀后缀。举个例子,如果给定的模式串为“abab”,那么它的各个子串的前缀后缀的公共元素的最大长度如下表格所示:


    比如对于字符串"aba"来说,它有长度为 1 1 的相同前缀后缀a;而对于字符串abab来说,它有长度为2的相同前缀后缀ab(相同前缀后缀的长度为 k+1 k + 1 k+1=2 k + 1 = 2 )。

    • ②求 next n e x t 数组

      • next n e x t 数组考虑的是除当前字符外的最长相同前缀后缀,所以通过第①步骤求得各个前缀后缀的公共元素的最大长度后,只要稍作变形即可:将第①步骤中求得的值整体右移一位,然后初值赋为 1 − 1 ,如下表格所示:


    比如对于aba来说,第 3 3 个字符a之前的字符串ab中有长度为0的相同前缀后缀,所以第 3 3 个字符a对应的next值为 0 0 ;而对于abab来说,第4个字符b之前的字符串aba中有长度为 1 1 的相同前缀后缀a,所以第4个字符b对应的 next n e x t 值为 1 1 (相同前缀后缀的长度为k k=1 k = 1 )。

    • ③根据 next n e x t 数组进行匹配

      • 匹配失配,j = next[j],模式串向右移动的位数为:j - next[j]。换言之,当模式串的后缀 p[jk,j1] p [ j − k , j − 1 ] 跟文本串 s[ik,i1] s [ i − k , i − 1 ] 匹配成功,但 pj p j si s i 匹配失败时,因为next[j] = k,相当于在不包含 pj p j 的模式串中有最大长度为 k k 的相同前缀后缀,即p[0,k1]=p[jk,j1],故令j = next[j],从而让模式串右移j - next[j]位,使得模式串的前缀 p[0,k1] p [ 0 , k − 1 ] 对应着文本串 s[ik,i1] s [ i − k , i − 1 ] ,而后让 pk p k si s i 继续匹配。如下图所示:

    综上, KMP K M P next n e x t 数组相当于告诉我们:当模式串中的某个字符跟文本串中的某个字符匹配失配时,模式串下一步应该跳到哪个位置。如模式串中在 j j 处的字符跟文本串在i处的字符匹配失配时,下一步用 next[j] n e x t [ j ] 处的字符继续跟文本串 i i 处的字符匹配,相当于模式串向右移动j - next[j]位。

    接下来,分别具体解释上述3个步骤。

    3.3 解释
    3.3.1 寻找最长前缀后缀

    如果给定的模式串是:“ABCDABD”,从左至右遍历整个模式串,其各个子串的前缀后缀分别如下表格所示:

    也就是说,原模式串子串对应的各个前缀后缀的公共元素的最大长度表为(下简称《最大长度表》):

    3.3.2 基于《最大长度表》匹配

    因为模式串中首尾可能会有重复的字符,故可得出下述结论:

    失配时,模式串向右移动的位数为:已匹配字符数 - 失配字符的上一位字符所对应的最大长度值。

    下面,咱们就结合之前的《最大长度表》和上述结论,进行字符串的匹配。如果给定文本串“BBC ABCDAB ABCDABCDABDE”,和模式串“ABCDABD”,现在要拿模式串去跟文本串匹配,如下图所示:

    1. 因为模式串中的字符A跟文本串中的字符BBC、空格一开始就不匹配,所以不必考虑结论,直接将模式串不断的右移一位即可,直到模式串中的字符A跟文本串的第 5 5 个字符A匹配成功:

    1. 继续往后匹配,当模式串最后一个字符D跟文本串匹配时失配,显而易见,模式串需要向右移动。但向右移动多少位呢?因为此时已经匹配的字符数为6个(ABCDAB),然后根据《最大长度表》可得失配字符D的上一位字符B对应的长度值为 2 2 ,所以根据之前的结论,可知需要向右移动62=4位。

    1. 模式串向右移动 4 4 位后,发现C处再度失配,因为此时已经匹配了2个字符(AB),且上一位字符B对应的最大长度值为 0 0 ,所以向右移动:20=2位。

    1. A与空格失配,向右移动 1 1 位。

    1. 继续比较,发现DC失配,故向右移动的位数为:已匹配的字符数6减去上一位字符B对应的最大长度 2 2 ,即向右移动62=4位。

    1. 经历第 5 5 步后,发现匹配成功,过程结束。

    通过上述匹配过程可以看出,问题的关键就是寻找模式串中最大长度的相同前缀和后缀,找到了模式串中每个字符之前的前缀和后缀公共部分的最大长度后,便可基于此匹配。而这个最大长度便正是next数组要表达的含义。

    3.3.3 根据《最大长度表》求next 数组

    由上文,我们已经知道,字符串“ABCDABD”各个前缀后缀的最大公共元素长度分别为:

    而且,根据这个表可以得出下述结论

    • 失配时,模式串向右移动的位数为:已匹配字符数 - 失配字符的上一位字符所对应的最大长度值

    上文利用这个表和结论进行匹配时,我们发现,当匹配到一个字符失配时,其实没必要考虑当前失配的字符,更何况我们每次失配时,都是看的失配字符的上一位字符对应的最大长度值。如此,便引出了 next n e x t 数组。

    给定字符串“ABCDABD”,可求得它的 next n e x t 数组如下:

    next n e x t 数组跟之前求得的最大长度表对比后,不难发现, next n e x t 数组相当于“最大长度值” 整体向右移动一位,然后初始值赋为 1 − 1 。意识到了这一点,你会惊呼原来next 数组的求解竟然如此简单:就是找最大对称长度的前缀后缀,然后整体右移一位,初值赋为 1 − 1 (当然,你也可以直接计算某个字符对应的 next n e x t 值,就是看这个字符之前的字符串中有多大长度的相同前缀后缀)。

    换言之,对于给定的模式串:ABCDABD,它的最大长度表及 next n e x t 数组分别如下:

    根据最大长度表求出了 next n e x t 数组后,从而有

    • 失配时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应的 next n e x t

    而后,你会发现,无论是基于《最大长度表》的匹配,还是基于 next n e x t 数组的匹配,两者得出来的向右移动的位数是一样的。为什么呢?因为:

    • 根据《最大长度表》,失配时,模式串向右移动的位数 = 已经匹配的字符数 - 失配字符的上一位字符的最大长度值。

    • 而根据《 next n e x t 数组》,失配时,模式串向右移动的位数 = 失配字符的位置 - 失配字符对应的 next n e x t 值。

      • 其中,从 0 0 开始计数时,失配字符的位置 = 已经匹配的字符数(失配字符不计数),而失配字符对应的next值 = 失配字符的上一位字符的最大长度值,两相比较,结果必然完全一致。

    所以,你可以把《最大长度表》看做是 next n e x t 数组的雏形,甚至就把它当做 next n e x t 数组也是可以的,区别不过是怎么用的问题。

    3.3.4 通过代码递推计算next 数组

    接下来,咱们来写代码求下 next n e x t 数组。

    基于之前的理解,可知计算 next n e x t 数组的方法可以采用递推:

    • 1.如果对于值 k k ,已有p[0,k1]=p[jk,j1],相当于 next[j]=k n e x t [ j ] = k

      • 此意味着什么呢?究其本质,next[j] = k代表p[j]之前的模式串子串中,有长度为 k k 的相同前缀和后缀。有了这个next数组,在 KMP K M P 匹配中,当模式串中j处的字符失配时,下一步用next[j]处的字符继续跟文本串匹配,相当于模式串向右移动j - next[j]位。

    举个例子,如下图,根据模式串“ABCDABD” next n e x t 数组可知失配位置的字符D对应的 next n e x t 值为 2 2 ,代表字符D前有长度为2的相同前缀和后缀(这个相同的前缀后缀即为“AB”),失配后,模式串需要向右移动j - next [j] = 6 - 2 =4位。

    向右移动 4 4 位后,模式串中的字符C继续跟文本串匹配。

    • 2.下面的问题是:已知next[0,j],如何求出 next[j+1] n e x t [ j + 1 ] 呢?

    对于 P P 的前j+1个序列字符:

    • p[k] == p[j],则next[j + 1] = next[j] + 1 = k + 1

    • p[k] != p[j],如果此时p[next[k]] == p[j],则next[ j + 1 ] = next[k] + 1,否则继续递归前缀索引k = next[k],而后重复此过程。 相当于在字符p[j+1]之前不存在长度为 k+1 k + 1 的前缀 p[0,k] p [ 0 , k ] 跟后缀 p[jk,j] p [ j − k , j ] 相等,那么是否可能存在另一个值 t+1<k+1 t + 1 < k + 1 ,使得长度更小的前缀 p[0,t] p [ 0 , t ] 等于长度更小的后缀 p[jt,j] p [ j − t , j ] 呢?如果存在,那么这个 t+1 t + 1 便是 next[j+1] n e x t [ j + 1 ] 的值,此相当于利用已经求得的 next n e x t 数组( next[0,,k,,j] n e x t [ 0 , … , k , … , j ] )进行 P P 串前缀跟P串后缀的匹配。

    一般的文章或教材可能就此一笔带过,但大部分的初学者可能还是不能很好的理解上述求解 next n e x t 数组的原理,故接下来,我再来着重说明下。

    如下图所示,假定给定模式串ABCDABCE,且已知 next[j]=k n e x t [ j ] = k (相当于 p[0,k1]=p[jk,j1]=AB p [ 0 , k − 1 ] = p [ j − k , j − 1 ] = A B ,可以看出 k k 2),现要求 next[j+1] n e x t [ j + 1 ] 等于多少?因为 pk=pj=C p k = p j = ′ C ′ ,所以 next[j+1]=next[j]+1=k+1 n e x t [ j + 1 ] = n e x t [ j ] + 1 = k + 1 (可以看出 next[j+1]=3 n e x t [ j + 1 ] = 3 )。代表字符E前的模式串中,有长度 k+1 k + 1 的相同前缀后缀。

    但如果p[k] != p[j]呢?说明 p[0,k]p[jk,j] p [ 0 , k ] ≠ p [ j − k , j ] 。换言之,当p[k] != p[j]后,字符E前有多大长度的相同前缀后缀呢?很明显,因为C不同于D,所以ABCABD不相同,即字符E前的模式串没有长度为k+1的相同前缀后缀,也就不能再简单地令:next[j + 1] = next[j] + 1。所以,咱们只能去寻找长度更短一点的相同前缀后缀。

    结合上图来讲,若能在前缀 p[0,k] p [ 0 , k ] 中不断的递归前缀索引 k=next[k] k = n e x t [ k ] ,找到一个字符 pk p k ′ 也为D,代表 pk=pj p k ′ = p j ,且满足 p[0,k]=p[jk,j] p [ 0 , k ′ ] = p [ j − k ′ , j ] ,则最大相同的前缀后缀长度为 k+1 k ′ + 1 ,从而 next[j+1]=k+1=next[k]+1 n e x t [ j + 1 ] = k ′ + 1 = n e x t [ k ′ ] + 1 。否则前缀中没有D,则代表没有相同的前缀后缀, next[j+1]=0 n e x t [ j + 1 ] = 0

    为何递归前缀索引 k=next[k] k = n e x t [ k ] ,就能找到长度更短的相同前缀后缀呢?这又归根到 next n e x t 数组的含义。我们拿前缀 p[0,k] p [ 0 , k ] 去跟后缀 p[jk,j] p [ j − k , j ] 匹配,如果 pk p k pj p j 失配,下一步就是用 p[next[k]] p [ n e x t [ k ] ] 去跟 pj p j 继续匹配,如果 p[next[k]] p [ n e x t [ k ] ] pj p j 还是不匹配,则需要寻找长度更短的相同前缀后缀,即下一步用 p[next[next[k]]] p [ n e x t [ n e x t [ k ] ] ] 去跟 pj p j 匹配。此过程相当于模式串的自我匹配,所以不断的递归 k=next[k] k = n e x t [ k ] ,直到要么找到长度更短的相同前缀后缀,要么没有长度更短的相同前缀后缀。如下图所示:

    所以,因最终在前缀ABC中没有找到D,故E next n e x t 值为 0 0

    模式串的后缀:ABDE
    模式串的前缀:ABC
    前缀右移两位:     ABC

    读到此,有的读者可能又有疑问了,那能否举一个能在前缀中找到字符D的例子呢?OK,咱们便来看一个能在前缀中找到字符D的例子,如下图所示:

    给定模式串DABCDABDE,我们很顺利的求得字符D之前的“DABCDAB”的各个子串的最长相同前缀后缀的长度分别为0 0 0 0 1 2 3,但当遍历到字符D,要求包括D在内的“DABCDABD”最长相同前缀后缀时,我们发现pj处的字符D pk p k 处的字符C不一样,换言之,前缀DABC的最后一个字符C跟后缀DABD的最后一个字符D不相同,所以不存在长度为 4 4 的相同前缀后缀。

    怎么办呢?既然没有长度为4的相同前缀后缀,咱们可以寻找长度短点的相同前缀后缀,最终,因在 p0 p 0 处发现也有个字符D p0=pj p 0 = p j ,所以p[j]对应的长度值为 1 1 ,相当于E对应的next值为 1 1 (即字符E之前的字符串“DABCDABD”中有长度为1的相同前缀和后缀)。

    综上,可以通过递推求得 next n e x t 数组,代码如下所示:

    void GetNext(char* p,int next[])
    {
        int pLen = strlen(p);
        next[0] = -1;
        int k = -1;
        int j = 0;
        while (j < pLen - 1)
        {
            //p[k]表示前缀,p[j]表示后缀
            if (k == -1 || p[j] == p[k]) 
            {
                ++k;
                ++j;
                next[j] = k;
            }
            else 
            {
                k = next[k];
            }
        }
    }

    用代码重新计算下“ABCDABD” next n e x t 数组,以验证之前通过“最长相同前缀后缀长度值右移一位,然后初值赋为 1 − 1 ”得到的 next n e x t 数组是否正确,计算结果如下表格所示:

    从上述表格可以看出,无论是之前通过“最长相同前缀后缀长度值右移一位,然后初值赋为 1 − 1 ”得到的 next n e x t 数组,还是之后通过代码递推计算求得的 next n e x t 数组,结果是完全一致的。

    3.3.5 基于《next 数组》匹配

    下面,我们来基于 next n e x t 数组进行匹配。

    还是给定文本串“BBC ABCDAB ABCDABCDABDE”,和模式串“ABCDABD”,现在要拿模式串去跟文本串匹配,如下图所示:

    在正式匹配之前,让我们来再次回顾下上文 2.1 2.1 节所述的 KMP K M P 算法的匹配流程:

    • “假设现在文本串 S S 匹配到i位置,模式串 P P 匹配到j位置

      • 如果j == -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符;
      • 如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令i不变,j = next[j]。此举意味着失配时,模式串 P P 相对于文本串S向右移动了j - next[j]位。
        • 换言之,当匹配失败时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应的 next n e x t 值,即移动的实际位数为:j - next[j],且此值大于等于 1 1 。”
    • 1.最开始匹配时

      • P[0] S[0] S [ 0 ] 匹配失败
        • 所以执行“如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令i不变,j = next[j]”,所以j = -1,故转而执行“如果j = -1,或者当前字符匹配成功(即 S[i]==P[j] S [ i ] == P [ j ] ),都令i++,j++”,得到i = 1,j = 0,即 P[0] P [ 0 ] 继续跟 S[1] S [ 1 ] 匹配。
      • P[0] P [ 0 ] S[1] S [ 1 ] 又失配, j j 再次等于1 i i j继续自增,从而 P[0] P [ 0 ] S[2] S [ 2 ] 匹配。
      • P[0] P [ 0 ] S[2] S [ 2 ] 失配后, P[0] P [ 0 ] 又跟 S[3] S [ 3 ] 匹配。
      • P[0] P [ 0 ] S[3] S [ 3 ] 再失配,直到 P[0] P [ 0 ] S[4] S [ 4 ] 匹配成功,开始执行此条指令的后半段:“如果j == -1,或者当前字符匹配成功(即 S[i]==P[j] S [ i ] == P [ j ] ),都令i++,j++”。

    • 2. P[1] P [ 1 ] S[5] S [ 5 ] 匹配成功, P[2] P [ 2 ] S[6] S [ 6 ] 也匹配成功, …,直到当匹配到 P[6] P [ 6 ] 处的字符D时失配(即 S[10]!=P[6] S [ 10 ] ! = P [ 6 ] ),由于 P[6] P [ 6 ] 处的D对应的 next n e x t 值为 2 2 ,所以下一步用P[2]处的字符C继续跟 S[10] S [ 10 ] 匹配,相当于向右移动: jnext[j]=62=4 j − n e x t [ j ] = 6 − 2 = 4 位。

    3.向右移动 4 4 位后,P[2]处的C再次失配,由于C对应的 next n e x t 值为 0 0 ,所以下一步用P[0]处的字符继续跟 S[10] S [ 10 ] 匹配,相当于向右移动: jnext[j]=20=2 j − n e x t [ j ] = 2 − 0 = 2 位。

    4.移动两位之后, A A 跟空格不匹配,模式串后移1位。

    5. P[6] P [ 6 ] 处的D再次失配,因为 P[6] P [ 6 ] 对应的 next n e x t 值为 2 2 ,故下一步用P[2]继续跟文本串匹配,相当于模式串向右移动j - next[j] = 6 - 2 = 4位。

    6.匹配成功,过程结束。

    匹配过程一模一样。也从侧面佐证了, next n e x t 数组确实是只要将各个最大前缀后缀的公共元素的长度值右移一位,且把初值赋为 1 − 1 即可。

    3.3.6 基于《最大长度表》与基于《next 数组》等价

    我们已经知道,利用 next n e x t 数组进行匹配失配时,模式串向右移动j - next[j]位,等价于已匹配字符数 - 失配字符的上一位字符所对应的最大长度值。原因是:

    1. j j 0开始计数,那么当数到失配字符时, j j 的数值就是已匹配的字符数;
    2. 由于next数组是由最大长度值表整体向右移动一位(且初值赋为 1 − 1 )得到的,那么失配字符的上一位字符所对应的最大长度值,即为当前失配字符的 next n e x t 值。

    但为何本文不直接利用 next n e x t 数组进行匹配呢?因为 next n e x t 数组不好求,而一个字符串的前缀后缀的公共元素的最大长度值很容易求。例如若给定模式串“ababa”,要你快速口算出其 next n e x t 数组,乍一看,每次求对应字符的 next n e x t 值时,还得把该字符排除之外,然后看该字符之前的字符串中有最大长度为多大的相同前缀后缀,此过程不够直接。而如果让你求其前缀后缀公共元素的最大长度,则很容易直接得出结果:0 0 1 2 3,如下表格所示:

    然后这 5 5 个数字 全部整体右移一位,且初值赋为1,即得到其 next n e x t 数组:-1 0 0 1 2

    3.3.7 Next 数组与有限状态自动机

    next n e x t 负责把模式串向前移动,且当第j位不匹配的时候,用第 next[j] n e x t [ j ] 位和主串匹配,就像打了张“表”。此外, next n e x t 也可以看作有限状态自动机的状态,在已经读了多少字符的情况下,失配后,前面读的若干个字符是有用的。

    3.3.8 Next 数组的优化

    行文至此,咱们全面了解了暴力匹配的思路、 KMP K M P 算法的原理、流程、流程之间的内在逻辑联系,以及 next n e x t 数组的简单求解(《最大长度表》整体右移一位,然后初值赋为 1 − 1 )和代码求解,最后基于《 next n e x t 数组》的匹配,看似洋洋洒洒,清晰透彻,但以上忽略了一个小问题。

    比如,如果用之前的next 数组方法求模式串“abab”的next 数组,可得其next 数组为-1 0 0 10 0 1 2整体右移一位,初值赋为 1 − 1 ),当它跟下图中的文本串去匹配的时候,发现bc失配,于是模式串右移j - next[j] = 3 - 1 = 2位。

    右移 2 2 位后,b又跟c失配。事实上,因为在上一步的匹配中,已经得知p[3]=b,与 s[3]=c s [ 3 ] = c 失配,而右移两位之后,让 p[next[3]]=p[1]=b p [ n e x t [ 3 ] ] = p [ 1 ] = b 再跟 s[3] s [ 3 ] 匹配时,必然失配。问题出在哪呢?

    问题出在不该出现 p[j]=p[next[j]] p [ j ] = p [ n e x t [ j ] ] 。为什么呢?理由是:当 p[j]s[i] p [ j ] ≠ s [ i ] 时,下次匹配必然是 p[next[j]] p [ n e x t [ j ] ] s[i] s [ i ] 匹配,如果 p[j]=p[next[j]] p [ j ] = p [ n e x t [ j ] ] ,必然导致后一步匹配失败(因为 p[j] p [ j ] 已经跟 s[i] s [ i ] 失配,然后你还用跟 p[j] p [ j ] 等同的值 p[next[j]] p [ n e x t [ j ] ] 去跟 s[i] s [ i ] 匹配,很显然,必然失配),所以不能允许 p[j]=p[next[j]] p [ j ] = p [ n e x t [ j ] ] 。如果出现了 p[j]=p[next[j]] p [ j ] = p [ n e x t [ j ] ] 咋办呢?如果出现了,则需要再次递归,即令 next[j]=next[next[j]] n e x t [ j ] = n e x t [ n e x t [ j ] ]

    所以,咱们得修改下求 next n e x t 数组的代码。

    //优化过后的next 数组求法
    void GetNextval(char* p, int next[])
    {
        int pLen = strlen(p);
        next[0] = -1;
        int k = -1;
        int j = 0;
        while (j < pLen - 1)
        {
            //p[k]表示前缀,p[j]表示后缀  
            if (k == -1 || p[j] == p[k])
            {
                ++j;
                ++k;
                //较之前next数组求法,改动在下面4行
                if (p[j] != p[k])
                    next[j] = k;   //之前只有这一行
                else
                    //因为不能出现p[j] = p[ next[j ]],所以当出现时需要继续递归,k = next[k] = next[next[k]]
                    next[j] = next[k];
            }
            else
            {
                k = next[k];
            }
        }
    }

    利用优化过后的 next n e x t 数组求法,可知模式串“abab”的新 next n e x t 数组为:-1 0 -1 0。可能有些读者会问:原始 next n e x t 数组是前缀后缀最长公共元素长度值右移一位, 然后初值赋为 1 − 1 而得,那么优化后的 next n e x t 数组如何快速心算出呢?实际上,只要求出了原始 next n e x t 数组,便可以根据原始 next n e x t 数组快速求出优化后的 next n e x t 数组。还是以abab为例,如下表格所示:

    只要出现了 p[next[j]]=p[j] p [ n e x t [ j ] ] = p [ j ] 的情况,则把 next[j] n e x t [ j ] 的值再次递归。例如在求模式串“abab”的第 2 2 anext值时,如果是未优化的 next n e x t 值的话,第 2 2 a对应的next值为 0 0 ,相当于第2a失配时,下一步匹配模式串会用 p[0] p [ 0 ] 处的a再次跟文本串匹配,必然失配。所以求第 2 2 anext值时,需要再次递归:next[2] = next[ next[2] ] = next[0] = -1(此后,根据优化后的新 next n e x t 值可知,第 2 2 a失配时,执行“如果j == -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符”),同理,第2b对应的 next n e x t 值为 0 0

    对于优化后的next数组可以发现一点:如果模式串的后缀跟前缀相同,那么它们的 next n e x t 值也是相同的,例如模式串abcabc,它的前缀后缀都是abc,其优化后的 next n e x t 数组为:-1 0 0 -1 0 0,前缀后缀abc next n e x t 值都为-1 0 0

    然后引用下之前 3.1 3.1 节的 KMP K M P 代码:

    int KmpSearch(char* s, char* p)
    {
        int i = 0;
        int j = 0;
        int sLen = strlen(s);
        int pLen = strlen(p);
        while (i < sLen && j < pLen)
        {
            //①如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++    
            if (j == -1 || s[i] == p[j])
            {
                i++;
                j++;
            }
            else
            {
                //②如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]    
                //next[j]即为j所对应的next值      
                j = next[j];
            }
        }
        if (j == pLen)
            return i - j;
        else
            return -1;
    }

    接下来,咱们继续拿之前的例子说明,整个匹配过程如下:

    1. S[3] S [ 3 ] P[3] P [ 3 ] 匹配失败。

    1. S[3] S [ 3 ] 保持不变, P P 的下一个匹配位置是P[next[3]],而 next[3]=0 n e x t [ 3 ] = 0 ,所以 P[next[3]]=P[0] P [ n e x t [ 3 ] ] = P [ 0 ] S[3] S [ 3 ] 匹配。

    1. 由于上一步骤中 P[0] P [ 0 ] S[3] S [ 3 ] 还是不匹配。此时 i=3 i = 3 j=next[0]=1 j = n e x t [ 0 ] = − 1 ,由于满足条件 j==1 j == − 1 ,所以执行“++i,++j”,即主串指针下移一个位置, P[0] P [ 0 ] S[4] S [ 4 ] 开始匹配。最后j==pLen,跳出循环,输出结果i - j = 4(即模式串第一次在文本串中出现的位置),匹配成功,算法结束。

    3.4 KMP的时间复杂度分析

    相信大部分读者读完上文之后,已经发觉其实理解KMP非常容易,无非是循序渐进把握好下面几点:

    1. 如果模式串中存在相同前缀和后缀,即p[jk,j1]=p[0,k1],那么在pjsi失配后,让模式串的前缀p[0,k1]对应着文本串s[ik,i1],而后让pksi继续匹配。

    2. 之前本应是pjsi匹配,结果失配了,失配后,令pksi匹配,相当于j变成了k,模式串向右移动jk位。

    3. 因为k 的值是可变的,所以我们用next[j]表示j处字符失配后,下一次匹配模式串应该跳到的位置。换言之,失配前是jpjsi失配时,用p[next[j]]继续跟si匹配,相当于j变成了next[j],所以,j=next[j],等价于把模式串向右移动jnext[j]位。

    4. next[j]应该等于多少呢?next[j]的值由j之前的模式串子串中有多大长度的相同前缀后缀所决定,如果j之前的模式串子串中(不含j)有最大长度为k的相同前缀后缀,那么next[j]=k

    如之前的图所示:

    接下来,咱们来分析下KMP的时间复杂度。分析之前,先来回顾下KMP匹配算法的流程:

    • KMP的算法流程:
      • 假设现在文本串S匹配到i位置,模式串P匹配到j位置
        • 如果j == -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符;
        • 如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令i不变,j = next[j]。此举意味着失配时,模式串P相对于文本串S向右移动了j - next [j]位。

    我们发现如果某个字符匹配成功,模式串首字符的位置保持不动,仅仅是i++,j++;如果匹配失配,i不变(即i不回溯),模式串会跳过匹配过的next[j]个字符。整个算法最坏的情况是,当模式串首字符位于i - j的位置时才匹配成功,算法结束。

    所以,如果文本串的长度为n,模式串的长度为m,那么匹配过程的时间复杂度为O(n),算上计算nextO(m)时间,KMP的整体时间复杂度为O(m+n)

    4. 扩展1:BM算法

    KMP K M P 的匹配是从模式串的开头开始匹配的,而 1977 1977 年,德克萨斯大学的 RobertS.Boyer R o b e r t S . B o y e r 教授和 JStrotherMoore J S t r o t h e r M o o r e 教授发明了一种新的字符串匹配算法: BoyerMoore B o y e r − M o o r e 算法,简称 BM B M 算法。该算法从模式串的尾部开始匹配,且拥有在最坏情况下 O(N) O ( N ) 的时间复杂度。在实践中,比 KMP K M P 算法的实际效能高。

    BM B M 算法定义了两个规则:

    • 坏字符规则:当文本串中的某个字符跟模式串的某个字符不匹配时,我们称文本串中的这个失配字符为坏字符,此时模式串需要向右移动,移动的位数 = 坏字符在模式串中的位置 - 坏字符在模式串中最右出现的位置。此外,如果”坏字符”不包含在模式串之中,则最右出现位置为 1 − 1

    • 好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为 1 − 1

    下面举例说明BM算法。例如,给定文本串 “HERE IS A SIMPLE EXAMPLE”,和模式串 “EXAMPLE” ,现要查找模式串是否在文本串中,如果存在,返回模式串在文本串中的位置。

    1.首先,”文本串”与”模式串”头部对齐,从尾部开始比较。 'S''E' 不匹配。这时, 'S' 就被称为”坏字符”( badcharacter b a d c h a r a c t e r ),即不匹配的字符,它对应着模式串的第6位。且 'S' 不包含在模式串 "EXAMPLE" 之中(相当于最右出现位置是 1 − 1 ),这意味着可以把模式串后移6 - (-1) = 7位,从而直接移到 'S' 的后一位。

    2.依然从尾部开始比较,发现 'P''E' 不匹配,所以 'P' 是”坏字符”。但是, 'P' 包含在模式串 "EXAMPLE" 之中。因为 'P' 这个“坏字符”对应着模式串的第 6 6 位(从 0 开始编号),且在模式串中的最右出现位置为 4 4 ,所以,将模式串后移6 - 4 = 2位,两个 'P' 对齐。


    3.依次比较,得到 “MPLE” 匹配,称为”好后缀”( goodsuffix ),即所有尾部匹配的字符串。注意, "MPLE""PLE""LE""E" 都是好后缀。

    4.发现 'I''A' 不匹配: 'I' 是坏字符。如果是根据坏字符规则,此时模式串应该后移2 - (-1) = 3位。问题是,有没有更优的移法?


    5.更优的移法是利用好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串中上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为 1 − 1

    所有的“好后缀”( "MPLE""PLE""LE""E" )之中,只有 'E'“EXAMPLE” 的头部出现,所以后移6 - 0 = 6位。

    可以看出,“坏字符规则”只能移 3 3 位,“好后缀规则”可以移 6 位。每次后移这两个规则之中的较大值。这两个规则的移动位数,只与模式串有关,与原文本串无关。

    6.继续从尾部开始比较, 'P''E' 不匹配,因此 'P' 是“坏字符”,根据“坏字符规则”,后移 6 - 4 = 2位。因为是最后一位就失配,尚未获得好后缀。

    由上可知, BM B M 算法不仅效率高,而且构思巧妙,容易理解。

    5. 扩展2:Sunday算法

    上文中,我们已经介绍了 KMP K M P 算法和 BM B M 算法,这两个算法在最坏情况下均具有线性的查找时间。但实际上, KMP K M P 算法并不比最简单的 C C 库函数 strstr() 快多少,而 BM 算法虽然通常比 KMP K M P 算法快,但 BM B M 算法也还不是现有字符串查找算法中最快的算法,本文最后再介绍一种比 BM B M 算法更快的查找算法即 Sunday S u n d a y 算法。

    Sunday S u n d a y 算法由 DanielM.Sunday D a n i e l M . S u n d a y 1990 1990 年提出,它的思想跟 BM B M 算法很相似:

    只不过 Sunday S u n d a y 算法是从前往后匹配,在匹配失败时关注的是文本串中参加匹配的最末位字符的下一位字符。

    如果该字符没有在模式串中出现则直接跳过,即移动位数 = 匹配串长度 + 1;

    否则,其移动位数 = 模式串中最右端的该字符到末尾的距离 + 1。

    下面举个例子说明下 Sunday S u n d a y 算法。假定现在要在文本串 "substring searching algorithm" 中查找模式串 "search"

    1.刚开始时,把模式串与文本串左边对齐:

    substr i ng searching algorithm
    search
    ^

    2.结果发现在第 2 2 个字符处发现不匹配,不匹配时关注文本串中参加匹配的最末位字符的下一位字符,即标粗的字符 i ,因为模式串 "search" 中并不存在 i ,所以模式串直接跳过一大片,向右移动位数 = 匹配串长度 + 1 = 6 + 1 = 7,从 i 之后的那个字符(即字符 n )开始下一步的匹配,如下图:

    substring searching algorithm
        search
        ^

    3.结果第一个字符就不匹配,再看文本串中参加匹配的最末位字符的下一位字符,是 'r' ,它出现在模式串中的倒数第 3 位,于是把模式串向右移动 3 3 位( 'r' 到模式串末尾的距离 + 1 = 2 + 1 =3),使两个 'r' 对齐,如下:

    substring searching algorithm
         search
           ^

    4.匹配成功。

    回顾整个过程,我们只移动了两次模式串就找到了匹配位置,缘于 Sunday 算法每一步的移动量都比较大,效率很高。完。

    6. 参考文献

    《算法导论》的第十二章:字符串匹配;

    本文中模式串“ABCDABD”的部分图来自于此文:
    http://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html

    本文3.3.7节中有限状态自动机的图由微博网友 @龚陆安 绘制:
    http://d.pr/i/NEiz

    北京7月暑假班邹博半小时KMP视频:
    http://www.julyedu.com/video/play/id/5

    北京7月暑假班邹博第二次课的PPT:
    http://yun.baidu.com/s/1mgFmw7u

    理解KMP 的9张PPT:
    http://weibo.com/1580904460/BeCCYrKz3#_rnd1405957424876

    详解KMP算法(多图):
    http://www.cnblogs.com/yjiyjige/p/3263858.html

    本文第4部分的BM算法参考自此文:
    http://www.ruanyifeng.com/blog/2013/05/boyer-moore_string_search_algorithm.html
    http://youlvconglin.blog.163.com/blog/static/5232042010530101020857

    《数据结构 第二版》,严蔚敏 & 吴伟民编著;
    http://blog.csdn.net/v_JULY_v/article/details/6545192
    http://blog.csdn.net/v_JULY_v/article/details/6111565

    Sunday算法的原理与实现:
    http://blog.chinaunix.net/uid-22237530-id-1781825.html

    模式匹配之Sunday算法:
    http://blog.csdn.net/sunnianzhong/article/details/8820123

    一篇KMP的英文介绍:
    http://www.inf.fh-flensburg.de/lang/algorithmen/pattern/kmpen.htm

    我2014年9月3日在西安电子科技大学的面试&算法讲座视频(第36分钟~第94分钟讲KMP):
    http://www.julyedu.com/video/play/21

    一幅图理解KMP next数组的求法:
    http://v.atob.site/kmp-next.html

    7.后记

    对之前混乱的文章给广大读者带来的困扰表示致歉,对重新写就后的本文即将给读者带来的清晰表示欣慰。希望大部分的初学者,甚至少部分的非计算机专业读者也能看懂此文。有任何问题,欢迎随时批评指正, thanks t h a n k s

    July J u l y 、二零一四年八月二十二日晚九点。

    8.重编者(也就是我)记

    这是一篇长文章,是一篇好文章。原网页写于 2011 2011 年,使用 HTML H T M L 写成的,如今我用 Markdown M a r k d o w n 语法将其重新编辑,难度很大,但是编完令人心情舒畅。读到这篇文章令我感到幸运,编辑它更令我感到荣幸。

    反观网络上不少的 KMP K M P 算法讲解文章,不配图、讲解不到位、只贴上了模板代码的现象屡见不鲜。那么这样,博客是作为了我们的学习的工具,还是简单的代码保存器呢?答案不得而知。

    摘录此篇之前我看过不少的 KMP K M P 讲解文章,而它们的 next n e x t 数组的定义以及求解过程大相径庭。很多人在洛谷的讨论区吐槽文章太杂,根本看不懂。在这里借用一句话:

    技术分为术和道两种,具体的做事方法是术,做事的原理和原则是道。 —-吴军

    背懂了模板代码,是会了术。而道,才是算法学习中的重点。共勉。

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页