经典字符串匹配算法-KMP、Sunday、shift-And基本思想、边界处理与编程技巧、代码实现

目录

前言

1、字符串匹配算法简介

2、暴力匹配算法

3、Sunday算法

1、思考是否需要逐个元素匹配

2、Sunday算法与暴力匹配的差别

3、Sunday算法流程

4、Sunday算法实现 

5、算法特点与使用场景

4、shift-And算法

1、shift-And的思想

2、 shift-And代码实现

3、 |1的处理

5、KMP算法

1、KMP算法与暴力匹配的区别

2、KMP算法的思想

3、KMP算法流程

4、KMP与shift-And的异同

总结

参考:门徒计划


前言

最近重温了一下字符串匹配算法,由于自己一直在学习一直在忘,好多学的东西,在学的时候感觉自己理解的还是比较到位的,但是疏于整理就遗忘了。从此,勤于记录便于回顾和温习。

本文主要讲解几种经典的字符串匹配算法,希望能做到言简意赅,先说明基本思想、然后梳理算法流程,对一些边界条件及编程技巧有所示意,最后附上代码实现。

1、字符串匹配算法简介

在给定的target字符串中,匹配pattern字符串是否出现在target字符串中,如果出现返回出现的位置。字符串匹配算法是极具思想性以及逻辑性的算法。是提升算法思维,编程能力很好的切入点。

按照顺序而言,应该按照以下顺序暴力匹配、KMP、Sunday、shift-And。但是考虑到KMP算法上手可能会有些麻烦,因此本人按照暴力匹配、Sunday、shift-And、KMP。希望能够由简到难,明晰思想,厘清逻辑以及熟悉一些编程的处理方式,希望能够化解算法思想缺失和编程能力不足导致的算法进阶迷茫。

2、暴力匹配算法

匹配字符串最直观也是最简单的方式,就是暴力匹配了,循环target字符串的每个位置与pattern字符串匹配,复杂度为O(m * n),代码如下:

#暴力求解
def brute_force(text, pattern): #text为target串,匹配text是否存在pattern串
    for i in range(len(text)):
        flag = 1
        for j in range(len(pattern)):
            if text[i + j] == pattern[j]: continue
            else: 
                flag = 0
                break
        if flag: return i
    return -1

基本思路:从target字符串 text的每一位匹配pattern的每一个字符串


3、Sunday算法

1、思考是否需要逐个元素匹配

仔细思索暴力匹配方式可以发现,实际情况并不需要逐个比对,希望能够通过字符串的特点,省去无效的匹配,从而加速匹配的效率。

2、Sunday算法与暴力匹配的差别

Sunday算法不再逐个位置比较,通过对pattern串的元素情况的分析,向后跳一定长度,减少了计算量。上图,为Sunday算法的核心步骤,文字为图中变量和操作的辅助说明。

通过对pattern串的分析来简化计算量是字符串匹配优化的基本思想。

3、Sunday算法流程

(1)需要一个数组记录pattern每个元素最后一次在pattern中出现的位置(用于后续跳转)

(2)从target的i位置开始匹配pattern串,如果匹配失败,直接拿target的i+m位置去找pattern出现的位置

(3)通过pos位置的换算,向前挪动i + m - pos位

4、Sunday算法实现 

(1)假设pattern为字符格式,用数组来存储每个字符最后一次在pattern串中出现的位置

#数组格式存储字符串和位置
from collections import defaultdict
def sunday_arr(text, pattern):
    n = len(text)
    m = len(pattern)
    last_pos = [-1] * 256
    for i in range(m): last_pos[ord(pattern[i])] = i
    i = 0
    while i + m <= n:
        if text[i:i + m] == pattern:
            return i
        if i + m >= n: break
        i += m - last_pos[ord(text[i + m])]
    return -1

(2)假设pattern中的元素未知,采用dict来存储每个字符最后一次在pattern串中出现的位置

#dict格式存储字符串和位置
def sunday_dict(text, pattern):
    n = len(text)
    m = len(pattern)
    last_pos = dict()
    for i in range(m): last_pos[pattern[i]] = i
    i = 0
    while i + m <= n:
        if text[i:i + m] == pattern:
            return i
        if i + m >= n: break
        i += m - last_pos.get(text[i + m], -1)
    return -1

实现中的一些细节: -1的trick,数组的实现中初始化每个位置为-1,如果target串i + m位置存在pattern串中不存在的字符,相当于直接后移;字典的实现中有同样意思的操作

while的条件判断也需注意,如果融合Sunday算法的思想,就能够简明的得到

5、算法特点与使用场景

Sunday算法用一个数组或者字典,构建了pattern的每个元素和pattern串的位置关系,当target串发生不匹配的情况,可以进行位置的跳跃由此简化匹配

如果target为长文本,pattern为一些需要关注的短语。在这种场景下使用Sunday算法较为合适,最优时间复杂度仅为O(n/m)


4、shift-And算法

1、shift-And的思想

        同样将pattern串进行编码,shift-And将pattern串的每个元素进行按位编码,如下图所示:

 pattern串中每个元素在pattern串中出现的位置赋值1,借助计算机的二进制位运算,进行计算

将匹配问题转换为高位的与运算,用p来记录当前高位的匹配情况。最终,p和pattern字符串的高位能匹配上,即成功匹配。

扫描一遍target串就能匹配成功,时间复杂度为O(n)

2、 shift-And代码实现

def shift_and(text, pattern):
    code = [0] * 256
    for i in range(len(pattern)):
        code[ord(pattern[i])] |= 1 << i 
        print(pattern[i], code[ord(pattern[i])])
        # code[ord(pattern[i])] = code[ord(pattern[i])] + (1 << i)
        ##记录每个char在二进制数的和值
    p = 0
    m = len(pattern)
    for i in range(len(text)):
        print("p:",p)
        print("p << 1:",p << 1)
        print("p << 1 | 1:",p << 1 | 1)
        print("text[i]:",text[i])
        print("code[ord(text[i])]:",code[ord(text[i])])
        p = (p << 1 | 1) & code[ord(text[i])] # 或1是为了让待匹配项由text[i]决定
        print("(p << 1 | 1) & code[ord(text[i])]", p)
        print('=====================================')
        if p & (1 << (m - 1)): return i + 1 - m
    return -1

 为了更直观的展示数值的变化,打印如下运算结果,结合shift-And的思想和实际举例计算一下就能豁然明了:

text, pattern = "aecaeaecaed", "aecaed"
print("text:",text)
print("pattern:",pattern)
print("shift_and:",shift_and(text, pattern))
text: aecaeaecaed
pattern: aecaed
a 1
e 2
c 4
a 9
e 18
d 32
p: 0
p << 1: 0
p << 1 | 1: 1
text[i]: a
code[ord(text[i])]: 9
(p << 1 | 1) & code[ord(text[i])] 1
=====================================
p: 1
p << 1: 2
p << 1 | 1: 3
text[i]: e
code[ord(text[i])]: 18
(p << 1 | 1) & code[ord(text[i])] 2
=====================================
p: 2
p << 1: 4
p << 1 | 1: 5
text[i]: c
code[ord(text[i])]: 4
(p << 1 | 1) & code[ord(text[i])] 4
=====================================
p: 4
p << 1: 8
p << 1 | 1: 9
text[i]: a
code[ord(text[i])]: 9
(p << 1 | 1) & code[ord(text[i])] 9
=====================================
p: 9
p << 1: 18
p << 1 | 1: 19
text[i]: e
code[ord(text[i])]: 18
(p << 1 | 1) & code[ord(text[i])] 18
=====================================
p: 18
p << 1: 36
p << 1 | 1: 37
text[i]: a
code[ord(text[i])]: 9
(p << 1 | 1) & code[ord(text[i])] 1
=====================================
p: 1
p << 1: 2
p << 1 | 1: 3
text[i]: e
code[ord(text[i])]: 18
(p << 1 | 1) & code[ord(text[i])] 2
=====================================
p: 2
p << 1: 4
p << 1 | 1: 5
text[i]: c
code[ord(text[i])]: 4
(p << 1 | 1) & code[ord(text[i])] 4
=====================================
p: 4
p << 1: 8
p << 1 | 1: 9
text[i]: a
code[ord(text[i])]: 9
(p << 1 | 1) & code[ord(text[i])] 9
=====================================
p: 9
p << 1: 18
p << 1 | 1: 19
text[i]: e
code[ord(text[i])]: 18
(p << 1 | 1) & code[ord(text[i])] 18
=====================================
p: 18
p << 1: 36
p << 1 | 1: 37
text[i]: d
code[ord(text[i])]: 32
(p << 1 | 1) & code[ord(text[i])] 32
=====================================
shift_and: 5

3、 |1的处理

(p << 1 | 1) & code[ord(text[i])]中|1的操作是为了兼容高位不匹配,但是低位能匹配的情况。

如下图匹配黑色的a位置,高位匹配失败,但a位置能够成为低位即pattern中第一个元素: 


5、KMP算法

1、KMP算法与暴力匹配的区别

KMP算法利用了pattern字符串的前缀和后缀信息,找到未匹配字符前最长的相同前缀和后缀信息来进行跳转,减少了计算量,如上图所示

2、KMP算法的思想

(1)发生text[i]未匹配的情况,跳转到target后缀与pattern前缀相同的位置接着匹配。

(2)问题转换为pattern串状态的转换,需要对pattern进行预处理。因为需要pattern串自身具备前缀和后缀相同的情况才能跳转。

3、KMP算法流程

(1)预处理pattern串,需要每个位置之前的字符串 最长前缀和后缀相等情况下,前缀的最后一个元素位置。即计算每个位置未成功匹配时,pattern串中跳转的位置。

(2)target串的匹配问题,转换为pattern串的状态转移问题,通过pattern串的状态转移就可知是否匹配成功

def getPosNext(pattern, pos_next):
    
    pos_next[0] = -1
    j = -1
    for i in range(1, len(pattern)):
        while j != -1 and pattern[j + 1] != pattern[i]:
            j = pos_next[j]
        if pattern[j + 1] == pattern[i]:
            j = j + 1
        pos_next[i] = j
    return

def kmp(text, pattern):
    text_len = len(text)
    pattern_len = len(pattern)
    pos_next = [0] * pattern_len
    getPosNext(pattern, pos_next)
    j = -1
    for i in range(text_len):
        while j != -1 and pattern[j + 1] != text[i]:
            j = pos_next[j]
        if pattern[j + 1] == text[i]:
            j = j + 1
        if j == pattern_len - 1:
            return i - j
    return -1

 以上过程有几点需要明晰,不然就会像陷入珍珑棋局的感觉,绕来绕去看不清楚:

(1)pattern预处理的过程 与 target和pattern的匹配过程有异曲同工之处,都是找目标字符串后缀与pattern字符串前缀的问题。只不过getPostNext函数中是pattern串自己找自己,并将结果存到数组中记录;而text匹配pattern时当target的后缀长度=pattern前缀长度即 j = n - 1时,匹配成功。

(2)回归到KMP算法思想本身思考代码的实现。这样思考就很容易。静静思索一下KMP上文所述的思想,直接急着陷入代码细节想着把getPostNext中跳转过程 与 text与pattern匹配时的跳转过程联合起来看,会有种进珍珑棋局的感觉。

(3)j=-1的trick,同初始化post_next[0] = -1联合起来,联通了text[i]字符匹配与pattern串的状态转换。既可以理解为初始状态、静默状态也可以理解为万能匹配状态。有种无极的哲学在其中。

4、KMP与shift-And的异同

KMP算法和shift-And都将字符串匹配问题,看作了与pattern字符的状态转换问题,结果只与text[i]即当前target串待匹配的字符 和 pattern串的状态有关。

不同在于KMP将匹配问题转换为待匹配字符串 和 pattern串的状态跳转问题,其状态跳转提前计算得到。计算过程中pattern串的状态是动态变化的。

而shift-And对pattern串进行位置的编码,匹配过程的结果由p动态维护,pattern串中字符串的状态固定不变。


总结

通过对pattern串进行分析可以降低字符串匹配的,将字符串的匹配问题转换为pattern串的状态跳转问题。其中蕴含着有限自动机、图灵机的思想。值得悉心思考。

参考:门徒计划

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值