掰开来揉碎了细细讲解KMP算法,没学过数据结构的人也能懂

字符串匹配是指在一个主串中寻找子串的算法。比如在字符串"gheafxbbdaiafc"中从左往右查找子串"afx"的结果就是3。这个算法看似简单实则不然,在 D.E.Knuth、J,H,Morris 和 V.R.Pratt三人提出KMP算法前,字符串匹配采用的是暴力(Brute-Force)匹配算法,时间耗费与两个字符串的长度成正比。所以使用暴力匹配算法进行长字符串匹配要花掉很长时间。而进行更复杂的长文本对比时(比如在SVN或者Git中对比两个程序文本的异同)要花掉天文数字的时间。

KMP算法就是为解决这个问题而提出的,它的时间耗费仅仅与两个字符串的长度之和成正比。KMP算法十分简洁,但是它提出的kmp值的概念及其算法本身都是十分难懂的。本文从零基础出发,一点一点细细讲解KMP算法的来龙去脉,通过实例和图表重点演示kmp值的概念及其在字符串匹配中的作用,即使没有学过数据结构的读者也能看懂。

目录

1. 字符串的暴力匹配算法

2. KMP算法

2.1 什么是kmp值

2.2 使用kmp值的匹配算法

2.3 主子串和副子串

2.4 KMP算法

3. 结束语


1. 字符串的暴力匹配算法

在 D.E.Knuth、J,H,Morris 和 V.R.Pratt三人提出KMP算法前,字符串匹配采用的是暴力(Brute-Force)匹配。该算法比较简单,就是先对齐主串和子串,然后顺序对比两个字符串上的对应字符,如果不匹配,就把子串往右移动1位,然后从子串的头部开重新对比字符,以此类推。以下是用Python写的暴力匹配算法:

代码1 字符串暴力匹配算法

def find(main, sub):
    sub_len = len(sub)
    main_len = len(main)
    for main_i in range(main_len - sub_len + 1):
        main_j = main_i  # 复制主串指针
        sub_i = 0        # 子串指针
        while main_j < main_len and sub_i < sub_len and main[main_j] == sub[sub_i]:
            main_j += 1
            sub_i += 1
        if sub_i == sub_len:
            return main_i
    return -1

算法主要由一个双重循环构成,外、内两个循环分别对主、子串中的每个字符循环。该算法的问题主要是,如果主串和子串的当前字符不等,则主串指针main_j要回到main_i+1位置处,子串指针sub_i则要回到0位置处。也就是说,一开始,从第0个字符开始对齐主串和子串,然后依序比较每一对字符。如果发现某个位置处对应的两个字符不等,就停止比较,把整个子串往右移动一位,然后再从主串的第1个字符和子串的第0个字符开始比较。以此类推。

这样做造成的结果就是,暴力匹配算法的复杂度是O(mn),即算法运行所花的时间是mn数量级,其中m和n分别是主串main和子串sub的长度。

2. KMP算法

2.1 什么是kmp值

KMP算法则不同,其时间复杂度为O(m+n),在m>>n即m远大于n的情况下,复杂度只有O(m),数量级要比O(mn)低很多,因为通常n>1。KMP算法是如何做到的?

KMP算法也是依序对比主串和子串的每个字符,不同点在于当发现不等字符之后该怎么做。KMP算法的基本核心思想是,如果主串和子串的当前字符不等时,主串和子串已经比较过的字符一定是相等的,主串中与这些字符相关的匹配都是可以省略的。比如

下标:0123456789101112...
主串:ababbaabbbaba...
子串:abbbbaa

main_j=10, sub_i=4, main_i=6

图1 当前字符比较不相等

其中红色字符是当前正在比较的且不相等的两个字符。根据暴力匹配算法,此时应该把主串指针main_j从10改为main_i+1即7,而子串指针sub_i则应该从4改为0。由于频繁地回指,暴力算法的时间复杂度才会达到O(mn)。

但是,KMP算法发现,虽然主串和子串当前的两个字符不等,但它们前面已经比较过的4个字符一定相等,都是abbb。这意味着,接下来主串在与子串的匹配中,凡是与这4个字符相关的,结果事先都能够预测,见下图:

下标:0123456789101112...
主串:ababbaabbbaba...
子串:abbbbaa
右移子串:abbbbaa

图2 子串右移1位的结果
由于紫色的两个字符不等,所以子串右移1位后与主串的匹配注定是不会成功的

图2是把子串右移一位然后再从头比较的结果。由于紫色的两个字符是不等的,这就意味着主串和右移1位的子串比较的结果注定是不匹配的。接下来,子串右移2位或者3位后与主串的匹配结果都是注定不成功的:

下标:0123456789101112...
主串:ababbaabbbaba...
子串:abbbbaa
右移2位:abbbb...

图3 子串右移2位的结果
子串右移2位后与主串的匹配注定不成功


下标:0123456789101112...
主串:ababbaabbbaba...
子串:abbbbaa
右移3位:abbb...

图4 子串右移3位的结果
子串右移3位后与主串的匹配注定不成功


下标:0123456789101112...
主串:ababbaabbbaba...
子串:abbbbaa
右移4位:abb...

图5 子串右移4位的结果
​​​​​​​此时才有必要比较子串的第0个字符与主串的第10个字符

图5中子串右移了4位,此时才有必要比较子串和主串的当前字符。所以,如果主串与子串的当前字符不等,则没有必要把主串指针回指。因为回指后的所有与已经比较过的字符相关的匹配的结果都是可以预先知道的。这就提示我们,完全可以为子串的每一个字符设置一个kmp值,该值的含义就是当当前字符与主串的当前字符A不等时,子串应该拿哪个字符与A对齐。比如,对上面例子中的子串abbbbaa来说,从0开始数的第4个字符b的kmp值就是0,计作kmp[4]=0,表示当第4个字符b与主串当前字符不相等时,应该拿子串的第0个字符a与当前主串字符对齐。这个结论十分令人吃惊,但却是逻辑推理的合理结果。

是不是子串的每个字符的kmp值都是0?不是,下面就是一个例子:

子串下标:012345
子串:ababaa
子串右移2位:abab

kmp[5]=3

图6 kmp值不等于0的例子

在这个例子中,假设子串的第5个字符与主串不匹配,这意味着子串前5个字符与主串是匹配的,则子串应该右移2位,即主串的当前字符应该对齐子串的第kmp[5]=3个字符。这是因为子串右移2位后,子串和主串有3个字符即aba是相等的。

还有一种情况要注意,那就是子串某个字符的kmp值不存在:

子串下标:0123
子串:abba
子串右移2位:a

kmp[3]=-1

图7 kmp值不合法的例子

这个例子中,如果子串的第3个字符与主串不匹配,显然子串必须右移3位。但是此时,子串的当前字符仍然是a,注定仍然不能与主串匹配。此时,我们令kmp[3]=-1,表示应该把子串继续右移一位,而主串指针应指向下一个字符。

2.2 使用kmp值的匹配算法

假设子串已经有kmp值了,则相应的匹配算法是:

代码2 使用kmp值的字符串匹配算法

def find(main, sub):
    """
    在字符串s中从左到右寻找子串p
    :param main: 主串
    :param sub: 被寻找的子串
    :return: 子串sub在main中第一次出现的位置,-1表示找不到
    """
    kmp = get_kmp(sub)  # 获取kmp值
    main_len = len(main)
    sub_len = len(sub)

    main_i = 0   # 主串上的指针
    sub_i = 0    # 子串上的指针
    while main_i < main_len and sub_i < sub_len:
        if main[main_i] == sub[sub_i]:  # 如果相等则比较下一对字符
            main_i += 1
            sub_i += 1
        else:
            sub_i = kmp[sub_i]  # 不等时,利用kmp值重新定位子串
            if sub_i < 0:       # 如果kmp<0则
                sub_i = 0       # 子串从头开始
                main_i += 1     # 主串指向下一个字符
    return -1 if sub_i < sub_len else main_i - sub_i

2.3 主子串和副子串

现在我们只剩下一个问题,就是如何计算子串每个字符的kmp值,这正是KMP算法的核心。

计算kmp值的算法与代码2十分相似,这是因为kmp值的本质是,尽管主串和子串的当前字符不相等,但它们前面的字符一定相等。所以为了计算子串当前位置处的kmp值,可以并排放置两个子串,其中一个称为主子串,另一个称为副子串,一开始两者错开1个字符:

主子串下标:012345
主子串:ababaa
副子串:ababaa
kmp值:-1

图8 主子串和副子串初始位置以及kmp的初值

主子串用来在计算kmp值时模拟主串上的字符,副子串则用来模拟在主串上移动的子串。注意,kmp的值是针对主子串上的每个字符进行记录的,所以图7中主子串第0个字符的kmp值是-1。这是因为主串和子串匹配时,如果子串的第0个字符就与主串对应字符不等,显然应该把整个子串往右移动1位从而让子串的第0个字符与主串的下一个字符对齐,所以不论子串的内容是什么,kmp[0] = -1永远成立,而主副子串的初始位置也应该错开1个字符,算法也应该从比较主子串的第1个字符和副子串的第0个字符开始。如下图所示:

主子串下标:012345
主子串:ababaa
副子串:ababa
kmp值:-10

图9 当前字符不等,则kmp[1]=0

此时发现主子串的当前字符b与副子串的当前字符a不等,这满足kmp值的特征,所以副子串字符a的下标0就是主子串当前字符的kmp值,即kmp[1]=0,见图9。

接着,应该移动副子串,使得主子串和副子串的当前字符相等,这样才能为计算主子串下个字符的kmp值创造条件。而kmp值本来就是用来右移子串和副子串的,所以,既然kmp[0]=-1,这意味着应该把副子串右移一位,见下图。算法的巧妙之处就在于一边计算kmp的值,一边又利用了已经计算出来的kmp值。

主子串下标:012345
主子串:ababaa
副子串:abab
kmp值:-10-1

图10  两个字符相等,则主子串当前字符的kmp值就等于副子串当前字符的kmp值​​​​​​​

此时,当前的两个字符相等,这意味着副子串的当前字符必定跟主串(注意,是主串,不是主子串)的当前字符不等。这时,子串应该右移,而kmp值恰恰决定了右移之后的对齐位置,所以,主子串当前位置的kmp值应该等于副子串当前位置的kmp值,即kmp[2]=kmp[0]=-1。

接下来,既然当前字符相等,主副子串都应该保持不动,而把各自的指针右移:

主子串下标:012345
主子串:ababaa
副子串:abab
kmp值:-10-10

图11 ​​​​​​​当前字符相等,则主子串当前字符的kmp值就等于副子串当前字符的kmp值​​​​​​​

当前字符相等,根据前面的方法,我们可以确定kmp[3]=bmp[1]=0。然后再次保持主副子串不动,仅移动主副指针:

主子串下标:012345
主子串:ababaa
副子串:abab
kmp值:-10-10-1

图12 ​​​​​​​当前字符相等,则kmp[4]=kmp[2]​​​​​​​

再次发现当前字符相等,于是得到kmp[4]=kmp[2]​​​​​​​=-1,并且仅移动主副指针:

主子串下标:012345
主子串:ababaa
副子串:abab
kmp值:-10-10-13

图13 ​​​​​​​当前字符不等,则kmp[5]=3

此时,当前字符不等,根据前面的叙述,有kmp[5]=3。至此,主子串中所有字符的kmp值都计算出来了。

2.4 KMP算法

确定了主副两个子串的作用以及它们的初始对齐位置以及kmp[0]的初值之后,下面可以给出KMP算法:

代码3 KMP算法

def get_kmp(s):
    """
    KMP算法
    :param s: 字符串
    :return: 字符串的KMP值
    """
    main_i = 1   # 主串指针
    sub_i = 0    # 子串指针
    length = len(s)
    result = [-1] * length

    while main_i < length and sub_i < length:
        if s[main_i] == s[sub_i]:
            if result[main_i] >= 0:
                result[main_i] = result[sub_i]
            main_i += 1
            sub_i += 1
        else:
            result[main_i] = sub_i
            sub_i = result[sub_i]
            if sub_i < 0:
                sub_i = 0
                main_i += 1
    return result

其中main_i和sub_i分别指向主副子串的当前字符。如果两字符相等,这意味着包括当前这一对字符在内,主副子串到目前为止的字符都是相等的。这就为计算下一个字符的kmp值创造了条件。所以应该把主副指针分别指向下一个字符。

否则,对应字符不等,满足我们对kmp值性质的定义,所以,当前主子串在当前位置处的kmp值就是副子串当前字符的下标sub_i。

比较上述“如果”、“否则”两段文字,你会发现,如果当前位置处的两个字符不等,那么为了计算下一个字符的kmp值,副子串应该右移到一个位置,使得主副子串在当前位置处的字符相等。而右移副子串,正是kmp值的目的,所以应该执行sub_i = result[sub_i]

第二个发现是,如果当前位置处的两个字符相等,当然,副子串就不用右移了,而主子串在当前位置处的kmp值必然等于副子串在当前位置处的kmp值。这是因为主副两个子串的内容完全相同,只是副子串比主子串靠右,所以副子串在当前位置处的kmp值必然是已知的。更重要的是,我们正在计算主子串在当前位置处的kmp值,也就是说,假定主串(不必关心主串的具体内容)和主子串在当前位置处的字符是不相等的,这意味着主串和副子串在当前位置处的字符必然不等。既然如此,主子串在当前位置处的kmp值必然等于副子串在当前位置处的kmp值,以便在真正进行匹配时一次性把子串右移到位。

3. 结束语

KMP算法除了用于字符串或者类似数据的查找之外,还可以用在文本对比(比如SVN和Git比较两段代码文本的异同)、基因序列对比中。KMP算法是《数据结构》中最重要的算法之一,其算法简洁,构思巧妙,算法逻辑既令人吃惊又合情合理。弄懂KMP算法的每一个细节及其本质是一次难得的思维训练过程,为读者学习和思考其他精妙算法开拓了眼界,拓宽了视角。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

方林博士

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值