串之KMP模式匹配算法笔记
这应该算是《大话数据结构》这本书看到现在第一个需要想想的算法,准备认真的整理整理思路,不能一开始就掉队……
KMP的目标
先借用《大话数据结构》书中图片:
首先定义长度长的字符串为主串 S[ ],需匹配的为 T[ ]。图中1-6是朴素模式匹配的步骤,但是可以发现,第1步之后第2步中的比较其实是没有必要的,因为在第1步后可以得出$$T[0]\ne T[1] and S[1] = T[1] \Rightarrow T[0] \ne S[1] $$,也就是说第2步其实没有必要进行。同理,第3、4、5步也是如此,这也就是说假设在匹配前对 T[] 进行处理,则可以避免朴素模式匹配的2-5步。
这也是书中所说的”KMP模式匹配算法就是为了让这没有必要的回溯不发生“。这时候问题便集中在如何告诉在 T[] 匹配不成功后应该以哪一个元素再和 S[] 进行比较,也就是 T[] 指针下一位该指到哪里?
NEXT数组
\(T[j]\rightarrow Next\) 和 S[] 没有关系,因为这是当一元素匹配错误时T指针之后的指向,下图是几种不同的T串形式。
可以看出NEXT取决于当前字符之前的串的前后串之间的关系和当前字符无关,可以得到:
书中之后举例来归纳规律,其实和我的灵魂画相类似,由此便可以得出next数组代码:
def get_next(T:str) -> list:
i,j = 1, 0 # 双指针,i > j
L = len(T)
Next = [0] * L
while i < L - 1:
if T[i] == T[j]: # 相等的时候i+1 的next就是 j+1,一定注意0-j此时该条件都满足
i += 1
j += 1
Next[i] = j # 因为是当前字符之前串的关系,所以在判断字符间关系后两个指针同时移动然后+1
elif j != 0:
j = Next[j] # 如果字符不同,则j进行回溯(好好想想为什么**)
else:
i += 1 # 假如j回溯到初始位置字符仍不相等,则i移动
return Next
为什么回溯呢?
假设 \(T[i] \ne T[j]\) 时有\(T[i-k] = T[l-k] (l <j,k=0,1,...,l)\)
因为\(j \ne 0\) ,且$ j += 1$ 执行条件为 \(T[i] = T[j]\) ,\(T[ i-k] = T[j - k](k = 1,2,...,j)\)
必然有 \(T[i-k]=T[j-k](k=1,2,...,l)\)
由此可得:\(T[l-k]=T[j-k](k = 1,2,...,l)\)
所以 \(l=Next(j)\)
模式匹配主函数代码:
def index_KMT(S:str,T:str)->int:
Next = get_next(T)
i_max, j_max = len(Next), len(S)
i, j = 0, 0
while j < j_max and i < i_max: # i == i_max说明匹配成功,j == j_max说明S串中没有T串
if T[i] == S[j]:
i += 1
j += 1
elif i == 0: # 两字符串不相等且T串指针指向首位,移动S串
j += 1
else:
i = Next[i] # 两字符串不相等,T串回溯
if i == i_max:
return j - i_max
else:
return -1
从上面的代码中可以看出来,KMP只有当模式和主串之间存在许多“部分匹配”时候才会具有优势,否则返回结果和朴素模式匹配没有差距。
KMP的改进
KMP算法实际上也是有缺陷的,从书中图片中可以明显看出来:
然后书上讲的改良对我来说太难了……然后就自己想想,为什么会出现还有没有必要的步骤呢?其实是步骤1中 \(j=5\) 时的判断结果没有利用,之前的KMP都不会去利用当前字符的判断结果,所以说改进也应该是在这个当中做文章。
def get_next(T:str) -> list:
i,j = 1, 0
L = len(T)
Next = [0] * L
while i < L - 1:
if T[i] == T[j]:
i += 1
j += 1
##########改良############
if T[i] == T[j]:
Next[i] = Next[j]
else:
Next[i] = j
##########################
elif j != 0:
j = Next[j]
else:
i += 1
return Next
在上述代码中,在 \(T[i] = T[j]\) 满足后看 \(T[i+1] = T[j+1]\) 是否满足,满足的话说明在当前字符判断不匹配后随之 \(next[i]\) 也不匹配,此时便赋值 \(Next[i] = Next[j]\) ,因为该循环为不断迭代,故也就不需要进行类似$ Next[i] = Next[Next[j]]?$ 这样的判断了。以 \(T = 'ababaaaba'\) 为例,原版输出结果为 [0, 0, 0, 1, 2, 3, 1, 1, 2] 改良输出结果为 [0, 0, 0, 0, 0, 3, 1, 0, 0] ,从中可以明显看出二者区别。
总结
这个算法为什么会这样思考呢?
朴素模式匹配中没有利用任何之前进行的匹配,所以计算过程很繁琐,如何避免呢?一定是想办法避免重复匹配,这里有点像最小栈,需要一个辅助数列来避免重复的比较。
而为什么只和T串有关呢?因为在整个匹配过程中,我们只能知道的是等于或者不等于,我们是无法获得S串的具体值的。
此时当知道我们需要为T串加入辅助列表时,后面很多就可以观察到了。这里面比较有意思的是实际上next数组的寻找用到了双指针,而在链表中像链表倒数第k个节点、环形链表等等也都用到了双指针,他们有什么共同点吗?
单链链表中由于无法获得前节点,所以利用双指针来获得链表节点的位置关系;同样,在next数组构建中假如直接对某一字符进行判断,获取向前多少字符是重复的很低效,在迭代过程中判断时双指针可以获得串尾和串头之间关系也就能对字符串重复进行判断了。这或许就是一个比较牵强的解释吧?