文章目录
- 完全理解
- 粗略的实现
- 匹配矩阵的优化
完全理解
在字符串匹配算法中,KMP算法之所以差不多可以做到O(N)的复杂度,关键就在于消除了主指针回溯,从而可以节省大量的时间。
例如想要对abcdabce
和abce
进行匹配,那么暴力算法如下表所示,每次需要对比4个字符,总共对比5次。
a | b | c | d | a | b | c | e | |
1 | a | b | c | e | ||||
2 | a | b | c | e | ||||
3 | a | b | c | e | ||||
4 | a | b | c | e | ||||
√ | a | b | c | e |
然而我们一眼就能看出,这个d
根本就不在abce
里面,故若能存储一些其他信息,或许可以一下子跳过这个d
。
a | b | c | d | a | b | c | e | |
1 | a | b | c | e | ||||
√ | a | b | c | e |
但有的时候也不能多跳,比如想要从abcabcabc
中匹配cab
,比较好的方案大致如下
a | b | c | a | b | c | a | b | c | |
1 | c | a | b | ||||||
√ | c | a | b |
所以问题的关键在于,凭什么上面那个案例,可以直接跳过d
,而下面这个案例,就只能不偏不倚地正好跳过两个。
小结一下发现两条规律,设txt为一个长文本,需要在txt中找到一个str,设当前比对的字符是ch,则有两条简单的规则
ch不在str中 | str跳过这个ch |
ch刚好是str[0] | str调到这个ch的位置 |
那么接下来,如果ch在str中,但不是str[0],应该怎么考虑?
当然不能直接跳过,因为str中可能存在重复序列,比如从abababc
中匹配ababc
,那么最好的方案应该是
a | b | a | b | a | b | c | |
1 | a | b | a | b | c | ||
√ | a | b | a | b | c |
也就是说,对于ababc
这样的串,由于a
的位置不同,当我们得到一个新的a
的时候,将要采取不同的决策。
在下图中,圆圈中表示当前的匹配情况,箭头表示一个新来的字符,箭头指向表示接下来将要跳转的位置。感叹号表示不是某个字符。
看到这里是不是有种恍然大明白的感觉,这就是所谓的状态机吧,而且这个状态机的构建过程也和txt毫无关系。换句话说,只要在匹配txt之前,对str做一下自我匹配,就可以得到一个这样的状态机。
而状态机中的状态,其实代表着将要匹配的字符串中的指针位置,退回到a意味着指针指向0;前进的时候指针加1;当abab接收一个a退回到aba这步时,意味着指针从5退回到3,如下表所示。
a | ab | aba | abab | ababc | |
a | 0 | 0 | 0 | 3 | |
b | 0 | 0 | 0 | 0 | |
c | 0 | 0 | 0 | 0 | 成功 |
其他 | 0 | 0 | 0 | 0 |
是不是又恍然大悟了,所谓的状态机其实就是个矩阵。
接下来我们要做的就是生成这个状态矩阵。
粗略的实现
还是考虑从txt中找str,第一步建立str的状态矩阵
test = "ababcdabadc" #python中str是关键字,所以改个名
length = len(test)
#创建用于存储状态的字典
status = {s:[0 for _ in range(length)] for s in set(test)}
for ch in status:
for i in range(length):
for j in range(i+1):
if test[i-j:i]+ch == test[0:j+1]:
status[ch][i] = j+1
得到
b [0, 2, 0, 4, 0, 0, 0, 8, 0, 4, 0]
d [0, 0, 0, 0, 0, 6, 0, 0, 0, 10, 0]
c [0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 11]
a [1, 1, 3, 1, 3, 1, 7, 1, 9, 1, 1]
对于字符串ababcdabadc
而言,最开始指针为0,这个时候如果来了一个a
,则指针跳转到a[0]=1
,说明此时处于a
的状态;这个时候再来一个b
,则指针跳转到b[1]=2
,说明此时处于ab
的状态,以此类推。
在制作了str的状态矩阵之后,就可以非常方便地进行字符串比对了。
txt = "ababcdabasdcdasdfababcdabadc"
test = "ababcdabadc"
KMP(txt,test)
def KMP(txt,test):
status = setStatus(test)
length = len(test)
keySet = set(status.keys())
match = []
j = 0
for i in range(len(txt)):
s = txt[i]
j = status[s][j] if s in keySet else 0
if j==length:
match.append(i-length+1)
return match
def setStatus(test):
length = len(test)
#创建用于存储状态的字典
status = {s:[0 for _ in range(length)] for s in set(test)}
for ch in status:
for i in range(length):
for j in range(i+1):
if test[i-j:i]+ch == test[0:j+1]:
status[ch][i] = j+1
return status
匹配矩阵的优化
一般来说,除了丧心病狂的aaaaaaa
这种字符串,其他字符串的匹配矩阵一般都很稀疏,这意味着我们进行了大量的无用比对。所以其匹配矩阵的求解过程可以粗略地优化一番,至少可以拿掉最外层的循环。
def setStatus(test):
length = len(test)
#创建用于存储状态的字典
status = {s:[0 for _ in range(length)] for s in set(test)}
for i in range(length):
for j in range(i+1):
if test[i-j:i] == test[0:j]:
ch = test[j]
status[ch][i] = j+1
return status
现在就只剩下两层循环,看上去清爽一些,但不明真相的吃瓜群众还是很介意的复杂度。为了更加清爽,再来考察一下匹配矩阵的特点
b [0, 2, 0, 4, 0, 0, 0, 8, 0, 4, 0]
d [0, 0, 0, 0, 0, 6, 0, 0, 0, 10, 0]
c [0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 11]
a [1, 1, 3, 1, 3, 1, 7, 1, 9, 1, 1]
首先,大部分非零值都是递增的。比如d
中的非零值6,11
;c
中的非零值5,11
。而一旦出现了一个变小的值,那么这个值一定曾经出现过,比如b[9]=4
,这个4就曾经出现过。
如果把索引降低,则索引必然重复
作为一条原则,那么显然0
和1
也可以纳入这条原则,b,c,d
中的0都在第一位出现过;而a
中不存在0,因为a[0]=1
,其最小值就只能是1。
或者说,a[i]
要么为i+1
,要么为曾经出现过的值。
既然如此,对于一个长度为N
的字符串str
,我可以从str
中截取出前M
个字符进行后向的匹配。例如"ababcdabadc"
,先匹配a
,得到匹配成功的一组位置;然后再对这些位置匹配ab
,以此类推,直到匹配位置为0位置。