Fork me on GitHub

数据结构与算法之字符串

字符串是多个字符连接起来的串,其中的字符是选自特定的字符集,比如ASCII、Unicode等,在Python和Java语言中使用的Unicode字符集,由多个Unicode字符连接成的串即为字符串,字符串在结构上可以理解成一个顺序表,其中的每个字符即为顺序表中的一个元素,但是字符串与顺序表不同在于,字符串多涉及到一些整体操作,比如子串的匹配与替换等。

子串

比如主串为abababcc,abab就是其中的子串,可以发现,abab在其中出现了两次,所以子串是可以多次出现且重叠位置的,如果子串位置有重叠的话,在进行替换的时候,需要指定方向。比如abababcc需要将abab替换为eeee,那么从左向右替换,得到的结果为eeeeabcc,从右向左替换为abeeeecc,得到的是不同的结果。

字符串匹配算法

朴素匹配算法

朴素匹配算法就是用最简单的逻辑进行匹配运算,比如eeeeabcc进行匹配abcc,那么从左向右进行匹配时,进行的步骤有:

此种匹配方案思路为两步:
(1)选定方向匹配每个元素
(2)发现不匹配时,转去考虑目标串里的下一个位置是否与模式串匹配

KMP算法

设pat为模式串(待匹配的串),txt为总串,m为pat的长度,n为txt的总长度。算法的核心理念:区别于朴素算法在每次匹配失败时,都会将txt的下标回溯,KMP算法永不回溯txt下标,不断调整pat的下标用以继续匹配。
对于朴素算法匹配与KMP算法匹配的区别这里不再细讲,这里解释下核心理念中几点概念,和它们的原理
1、永不回溯txt下标
下图是我用processon画的朴素法与KMP法,txt下标的变化,也就是说在KMP法中txt的下标一直在前进匹配。此外我在KMP中将一个词标红了,“合适”这个词,这也是KMP算法的关键,怎么在匹配失败时,怎么调整pat到合适的下标继续匹配。

2、不断调整pat的下标
从上面图形的描述中,你用肉眼发现,用KMP法时,是不是很聪明,它怎么就知道将pat的下标移动到b的位置继续匹配呢,如果用人的思维来思考,结合匹配过程,我们能获取到的信息有这些:
(1)当前txt中,匹配失败的元素是a
(2)在txt与pat进行匹配过程中,前三个元素已经是匹配成功的,分别是abc
(3)pat第一个元素是a,txt的第二个元素是b,将pat第一个下标与txt下标第二个元素对齐进行匹配(这就是朴素法的方式),这肯定失败,同理,第三个元素也失败
(4)pat第一个元素是a,txt第四个元素也是a,将pat下标与txt下标第四个元素下标对齐匹配,将肯定会成功
(5)结合上面的信息,我们只需要从pat的第二个元素与txt下标的第五个元素进行对比,从这里开始继续匹配
这些信息都是通过人眼,结合匹配过程分析出来的,那么KMP是怎么做到分析这些过程的呢?提炼上面的信息,关键需要做到这一步:知道匹配失败的元素后,找到pat需要移动的下标

上图是结合整个匹配过程信息而生成了一个匹配流程,在进行匹配的几步中,当遇到txt与pat中的元素不相等时,需要比较txt中已匹配串与pat中已匹配串的中有没有重叠的部分(术语称为:最长的前后缀子串),而这个比较的关键在于txt中当前比较的元素,我将txt中当前用于比较的元素定义为Ti,将pat中当前匹配元素定义为Pj,将pat应该移动的下标称作K。Ti的出现是随机的,数据范围也没有明确的限制(除了字符集的限制外),这时Ti会有两种情况:
1、如果Ti这个元素不存在于pat中(比如pat是abcd,而Ti是e),则pat与txt找不到最长的前后缀,这时pat移动到下标为0的位置。
2、如果Ti存在于pat中,需要定义当Ti出现时,pat应该移动的位置。这个结果完全依赖于pat的元素,因为Ti∈{pat}的,于是可以生成一个pat的列表,其中的每个元素Pj对应于每个Ti都有一个k。
以下是字符串匹配的代码,get_k_dict是获取k列表的函数。

# 字符串匹配
def match_string(txt, pat):
    i, j = 0, 0
    m, n = len(pat), len(txt)
    k_dict = get_k_dict(pat)
    k = 0
    while j < m and i < n:
        k = k + 1
        if txt[i] == pat[j]:
            # 相等,则txt与pat的下标都往后推移,继续匹配
            j = j + 1
        else:
            if pat.find(txt[i]) == -1:
                # 如果txt当前元素不在pat中,那么将pat设置为0下标,txt继续往后匹配
                j = 0
            if pat.find(txt[i]) >= 0:
                # 获取pat下标移动位置
                j = k_dict[j][txt[i]]
        if j == m:
            return i - m
        i = i + 1
    return -1

问题的关键在于怎么实现get_k_dict,这里需要涉及一个概念,叫做最长相等前后缀,什么意思呢,可以参见下图:

附加:
1、怎么求一个串的前缀与后缀?求前缀从第一个元素往右取,不断拼接为整串,求后缀则从最后一个元素往左取,不断拼接为整串,举例说明abacd
abacd(前缀):a、ab、aba、abac、abacd
abacd(后缀):d、cd、acd、bacd、abacd
2、为什么是最长相等前后缀?
举例:baabaa与abaaba进行取重叠串
baabaa后缀:a、aa、baa、abaa、aabaa、baabaa
abaaba前缀:a、ab、abaa、abaab、abaaba
由此可见,相等前后缀有两个aabaa,按照取最长的话,应该取abaa,为什么取最长呢?看下图

经过上图对比发现,相比于abaa如果取a的话,将会跳过更多的匹配,这可能会导致损失可能的匹配。
弄懂了最长相等前后缀之后,我们开始实现get_k_dict。尝试分析下get_k_dict函数的过程
(1):当入参Pj与Ti相等时,函数直接返回,返回值为pat的下标加1。很好理解,如果对比的字符都相等,那么往后继续对比则可
(2):当入参Pj与Ti不相等时,这时会有多种可能性,根据Ti的值而不同,假设pat的串为abacd...,我们来演示这个过程:


以上是这个过程示意图表,在图中,描述了当前pat属于某种状态后遇到Ti后应该跳转到某种状态。这部分理解可以参考KMP 算法详解
既然可以总结出了一个Ti -> K的一个表格,证明这个解是有穷的且唯一的,那么我们需要做的就是找规律(目的在于找上面链接博客中提到的影子状态的由来)。

规律1:在pat中不存在最长相同前后缀时(或者称为相同串,可参见:abcabd中的a与第二个a)时,每次匹配跟第一个元素匹配失败的回退是相同的,比如abcabd中的b在匹配时,Ti不等于b,if Ti=a,那么K=1,如果Ti!=a,k=0。再比如c在匹配时,if Ti=a,那么K=1,如果Ti!=a,k=0
规律2:具有相同串的元素进行匹配时,比如ab_1,与ab_2,1,2是我给定义的编号,决定了他们的位置,在abcabd中,第一个ab为ab_1,第二个ab为ab_2,ab_2的回退跟ab_1的选择逻辑是一致的。此逻辑也好理解,ab_2中你将pat与txt的前面已经匹配过的元素都挡起来不看,那不就是ab_1在匹配吗。那么可以忽略前面已经匹配的元素是因为这个ab_2的串其实就是pat到目前匹配过程为止的最长前缀了,只用看可以重叠的地方,其它重叠不了的就可以忽略不看。
在上图匹配中,按照我说的逻辑进行匹配过程,你会发现在不断的从txt匹配过程中,都是在生成可用的信息,我们从匹配最后一个元素d往回看,匹配d就是在匹配第三个元素c,而匹配元素c就是在匹配第一个元素a,此过程可以描述为match(d) = match(c) = match(a),有了此种规律,那么我们就知道匹配时候当我们需要去找pat的下标K的时候,我们应该一步步回溯状态,回到最初状态就会有答案。
规律3:当我们去刷新kk时,其实也就是在kk的位置跟当前的?去比较,这个结果就是k的新值

实现get_k_dict函数代码

def get_k_dict(pat):
    # 构造一个双重字典,
    # 第一重:key为pat当前的元素,value是一个ti相关字典
    # 第二重:key为ti可能出现的元素,value为当ti出现时,k的值,初始默认为0
    dp = {i: {i: 0 for i in pat} for i in range(len(pat))}
    # 初始化首元素的k,pat首元素只要遇到它本身时,k=1,其它都等于0
    first_ele = pat[0]
    dp[0][first_ele] = 1
    # 设置一个最长相等前后缀的后置位变量,初始时设置为0
    longest_str_after = 0
    # 从pat的第二个元素下标元素开始找k
    m = len(pat)
    for j in range(1, m):
        for ti in pat:
            # 两种情况,
            # 如果ti正好等于pj,那么将k值等于 j + 1
            # 如果ti不等于pj,那么就找longest_str_after去办
            if ti == pat[j]:
                k = j + 1
                dp[j][ti] = k
            if ti != pat[j]:
                # 交给longest_str_after去办事
                dp[j][ti] = dp[longest_str_after][ti]
        # 每次循环结束都需要检查下longest_str_after的下标需不需要更新
        # 更新的原理为:
        # 如果pj当前比较的元素跟longest_str_after下标指示的元素相等,那么最长相同前后缀长度就加1,longest_str_after的下标就要加1;如果pj当前比较的元素跟longest_str_after下标指示的元素不相等,那么就要回退到之前最长的前后缀的位置
        # longest_str_after的位置,就是longest_str_after遇到pat[j]字符时的值
        longest_str_after = dp[longest_str_after][pat[j]]
    return dp

传统的KMP匹配步骤

以上是以一个我们人眼去匹配时应该想到的一个过程,然后产出了一个代码逻辑,与传统的KMP算法逻辑有所不同,可以看下图

我们直接来看传统的KMP的代码逻辑,通过代码来比对与上述的逻辑有何不同?我们来实现一下传统的KMP(Python)代码

def get_table(pat):
    i, k, m = 0, -1, len(pat)
    dp = [-1] * m
    # 思路就是判断当前pat[i]的元素,设置pat[i+1]位置元素的k
    while i < m - 1:
        if k == -1:
            i = i + 1
            k = k + 1   # k加1设置为0
            if pat[i] == pat[k]:
                # 注意这时的i,k都加1了
                dp[i] = dp[k]
            else:
                dp[i] = k  # 设置i+1的k
        else:
            if pat[i] == pat[k]:
                i = i + 1
                k = k + 1
                if pat[i] == pat[k]:
                    # 注意这时的i,k都加1了,
(备注一)           dp[i] = dp[k]
                else:
                    dp[i] = k   # 设置i+1的k
            else:
                # 回退k
                k = dp[k]
    return dp

下图是上面代码中的备注一处的代码的说明

下图是起始状态为什么是-1的说明

两种方案的对比

1、执行结果的对比
定义模式串为aa,KMP非传统版与KMP传统版的结果分别如下:
非传统版:{0: {'a': 1}, 1: {'a': 2}} ——> 代表0下标的元素只有遇到'a'字符时k=1,遇到其它字符k都是0,1下标遇到'a'字符k=1,其它k=0
传统版:[-1, -1] ——>代表0下标的元素,k=-1(-1的含义是txt下标加1,pat下标归0),1下标的元素,k=-1
非传统版的txt的下标是默认往后推进一位,而传统版是不会的。就aa串的两种执行结果,匹配效率都是一样的,在没有遇到可匹配的字符时,都是将txt下标往后推进一位,且pat下标归0。
2、匹配逻辑的对比
非传统版:将pat当前匹配元素Pj遇到的多种Ti(txt当前匹配元素)进行汇总,不同的Ti有不同的k值
传统版:遍历pat的元素,pat中的每个元素都对应有一个k,这个k就是pat的另一元素的下标
两种匹配逻辑的核心都在于:pat与txt中已匹配串中,寻找最长相等前后缀
3、执行效率的对比
非传统版:pat中可能存在多种字符,受限于字符集的字符个数,但总归是常量。时间复杂度可记做O((字符集字符个数)M)
传统版:时间复杂度为O(M)
M为模式串长度,复杂度上面来说,传统版更优一点。

总结

1、字符串匹配算法有两种:朴素匹配法,KMP匹配法。
朴素匹配法是暴力破解的方式,通过回溯下标的方式,忽略了已有匹配串提供的信息。
KMP匹配法,使用上了已有匹配串提供的信息,不回溯下标,通过调整pat的下标继续匹配
2、一种非传统版的KMP算法
非传统的KMP算法,是一种穷尽txt当前匹配元素的可能性,然后汇总这些可能性,而得出的K值,K值就是pat的转移下标。
与传统的KMP算法比较来看,非传统的KMP逻辑理解更简单,但是效率偏低,核心的实现理念都是一致的。
3、KMP匹配算法的关键
第一:最长相等前后缀。需要理解为什么只需要pat的元素就可以构成一个用来作为匹配的参考表。关键在于匹配过程中是有一个前缀与后缀的一个匹配。
第二:k是什么?或者说,dp表中里面的值是什么?
是pat中当前匹配元素在匹配失败后,所转移的位置,这个位置充分利用了已有的匹配信息而得出。比如dp[2]=1,它的含义就是pat下标为2的匹配元素在匹配失败后,可以将下标转移到1的位置继续匹配。
第三:dp[i] = dp[k]与k=dp[k]
这两步都是在往前找k的位置,dp[k]就是pat下标为k时匹配失败后,可以将下标转移的位置。
dp[i] = dp[k] ——> 当pat下标为i匹配失败时,可将下标转移到kk(kk=pat下标为k时匹配失败后,可以将下标转移的位置)位置
k=dp[k] ——> 当前k等于kk(kk=pat下标为k时匹配失败后,可以将下标转移的位置)
将kk...k...i按照顺序进行排列,赋值的过程,其实就是在往前找更合理的k的位置(也就是更合理的pat转移下标)

posted @   三脚半猫  阅读(251)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)
点击右上角即可分享
微信分享提示