数据结构与算法之字符串
字符串是多个字符连接起来的串,其中的字符是选自特定的字符集,比如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进行匹配过程中,前三个元素已经是匹配成功的,分别是a
、b
、c
(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
由此可见,相等前后缀有两个a
与abaa
,按照取最长的话,应该取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转移下标)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)