概述、定义
串(字符串,String)是由零个或多个字符组成的序列,也是一种特殊线性表,一般记为
S
=
′
a
1
a
2
…
a
n
′
(
n
≥
0
)
S = 'a_1 a_2 \dots a_n' (n \geq 0)
S=′a1a2…an′(n≥0)
其中,
S
S
S为串名,单引号括起来的字符序列就是串的值;其中,串里面可以是字母、数字或其他字符;串中字符数目
n
n
n称为串的长度。零个字符的串称为空串(Null String)。
串中任意个连续的字符组成的子序列称为该串的子串,包含子串的串称为主串;字符在序列中的序号为该字符在串中的位置。子串在主串中的位置为子串的第一个字符在主串中的位置。
仅当两个串的值完全相等的情况下才称两个串是相等的。
串值必须用单引号括起来(在编程语言中也有使用双引号的,这里仅关注其数学描述),但单引号本身不是串的一部分,其作用是为了避免混淆。
串的逻辑结构与线性表极为相似,区别是串对值的约束为字符集。不过,串的基本操作和线性表仍有区别,线性表的基本操作大多以“单个元素”作为操作对象;而串的基本操作主要以“串为整体”作为操作对象。串同样可以使用顺序结构或链式结构的方式存储。
由于绝大部分现代编程语言都将串作为基本类型对待,并提供充足的库函数方便操作,它的基础操作又过多,因此就不展开它的实现了。但为了有更好的整体认知,摘抄一份ADT以便形成基本整体认知,也可以试着实现。主要重点提及一下串的模式匹配。
抽象数据类型(ADT, Abstract Data Type)
其中,"&"为前缀的代表该参数会被修改。
StrAssign(&D, chars)
生成一个值为chars
的串D
StrCopy(&D, S)
将串S
拷贝到串D
StrEmpty(s)
判断串S
是否为空StrCompare(S1, S2)
比较串S1
及S2
,若S1>S2
则返回值大于0。StrLength(S)
返回串S
的长度ClearString(&D)
将串D
清空Concat(&D, S1, S2)
连接串S1
及S2
,结果置于D
中SubString(&Sub, S, pos, len)
返回串S
第pos
个字符起长度为len
的字串,结果置于Sub
中Index(S, T, pos)
返回串T
在串S
中第pos
个字符之后第一次出现的位置(如有)Replace(&D, S, T)
用T
替换主串D
中出现的所有与S
相等但不重叠的字串StrInsert(&S, pos, T)
在串S
的第pos
个字符之前插入串T
StrDelete(&S, pos, len)
从串S
中删除第pos
个字符起长度为len
的子串DestroyString(&S)
销毁串S
模式匹配
串中的定位操作一般称作模式匹配,其中匹配串又称为模式串。,大白话就是从一个串中寻找另一个串在其中的首次出现位置,这也是串最基本也最重要的操作之一。
一个最简单的方法就是,暴力搜索:
//最简单的模式匹配,C语言实现,C的数组下标从0开始
int Index(const char *string, const char *p, int pos)
{
int i = pos, j = 0;
while (string[i] && p[j]) //如任一串到达了'\0'则意味着搜索结束
{
if (string[i] == p[j]) //相同则将两个串的指针同时前移
{
i++;
j++;
}
else //失配要将字符串指针退回并"前移一位",而模式串指针则退回起点
{
i = i - j + 1;
j = 0;
}
}
if (p[j] == '\0') //循环结束时如模式串指针已经触尾,意味着匹配成功。
return i - j;
return 0;
}
这样的实现非常容易理解,不赘述。不过这种方式的效率十分低下,对上方程序进行一个简单分析:如果待搜索的串是'abababc'
,模式串为'abc'
。
那么搜索过程如下(为节省篇幅,匹配成功的字符过程略过):
第一轮:
i=2, j=2
时失配a b a b a b c
a b c
失配后
i
回退并前移为1(即i-j+1)
,这里加1的作用是失配时需要将字符串指针前移1位,以继续匹配。
j
回退为0,即模式串的起始,下同,模式串的回退不再赘述。第二轮:
i=1, j=0
时失配a b a b a b c
a b c
失配后
i
回退并前移为2。第三轮:
i=4, j=2
时失配a b a b a b c
a b c
失配后
i
回退并前移为3。第四轮:
i=3, j=0
时失配a b a b a b c
a b c
失配后
i
回退并前移为4。第五轮:匹配成功
a b a b a b c
a b c
由上面的过程可以看出,整个搜索过程中字符串指针需要不停的回退,部分匹配的长度越长那么回退所造成的影响就越严重。假定n, m
等于字符串及模式串长度,那么这种算法在最恶劣的情况下时间复杂度为
O
(
n
m
)
O(n m)
O(nm)。
KMP算法
该算法是暴力搜索的改进算法,它可以在 O ( n + m ) O(n+m) O(n+m)的时间完成模式匹配。它是由D.E.Knuth, J.H.Morris, V.R.Pratt共同发现的,因此叫做KMP算法。
其巨大的性能改进主要源于对回退的优化,它可以使得出现失配时字符串不回退,而利用失配过程中得到的部分匹配结果来使模式串尽可能的往前滑动。
先看一个示例,然后再来看看是怎么实现的:
为什么模式串可以这样移动呢,原因是在前面的部分匹配中就必然会得知一些中间结果。如果我们看第二段,即:
a a b a b c a b …
a b c d e f
在
i=3, j=2
时失配,因为前面部分匹配的内容,知道s[1], s[2]一定是'a','b'
,因此就没有必要再次从i=2
处开始匹配。所以直接将模式串前移即可。
当然,实际上这里的窍门在到底前移多少,看图中的f(j)
即表示当某个字符失配时应该滑动到模式串的哪个位置,这里全为0主要是因为模式串字符全不相同,所以一旦失配前面的部分匹配一定是都不可能再匹配的,因此任一字符失配直接前滑到模式串的起始即可。
MP算法
KMP实际上是基于MP算法的微改进,而MP算法是最先实现字符串不回退的方法,因此先了解这个方法更容易理解KMP算法。
发生失配滑动多长是算法的核心,而计算滑动量仅需要模式串。原理如下:
设有目标串 T ( t 0 , t 1 , … t n − 1 ) T(t_0, t_1, \dots t_{n-1}) T(t0,t1,…tn−1)和模式串 P ( p 0 , p 1 , … p m − 1 ) P(p_0, p_1, \dots p_{m-1}) P(p0,p1,…pm−1)。
使用最普通的方式比较一轮,在k处发生失配,如下图:
由于发生失配的地方位于 p k , t k p_k, t_k pk,tk处,因此我们知道 t 0 , t 1 , t 2 , … , t k − 1 = p 0 , p 1 , p 2 , … , p k − 1 t_0, t_1, t_2, \dots, t_{k-1} = p_0, p_1, p_2, \dots, p_{k-1} t0,t1,t2,…,tk−1=p0,p1,p2,…,pk−1,也即用 p 0 , p 1 , p 2 , … p k − 1 p_0, p_1, p_2, \dots p_{k-1} p0,p1,p2,…pk−1来替换掉 t 0 , t 1 , t 2 , … , t k − 1 t_0, t_1, t_2, \dots, t_{k-1} t0,t1,t2,…,tk−1不会产生任何变化。
如果按照这个规则替换,而 p 0 ≠ p 1 p_0 \neq p_1 p0̸=p1。看看这一轮比较是什么样子的:
如果继续比较一轮呢:
可以看出,实际上后面的两轮都是 P P P与自己的比较,利用这种思想,就可以仅根据 P P P字符间的关系来计算滑动量了。
用来计算
P
P
P中各字符关系的函数称作失效函数
f
f
f,采用一个例子说明,假设模式字符串为papajpa
:
定义:
- j j j 为模式串字符失配前成功匹配的字符个数
- k ∈ { x ∣ 0 ≤ x < j } k \in \{ x | 0 \leq x < j \} k∈{x∣0≤x<j},且 k k k满足条件 p 0 p 1 … p k = p j − k p j − k + 1 … p j p_0p_1 \dots p_k = p_{j-k} p_{j-k+1} \dots p_j p0p1…pk=pj−kpj−k+1…pj的最大正整数。当这样的 k k k不存在时取值为-1(如 j = 0 j=0 j=0时就不可能存在)。
上面就是失效函数的基本定义,那么计算过程如下:
-
j = 0 j=0 j=0 时,不可能存在 0 ≤ k < j ( 0 ) 0 \leq k < j(0) 0≤k<j(0)的情况,因此 f ( 0 ) f(0) f(0)取值
-1
。 -
j = 1 j=1 j=1 时,由于 0 ≤ k < j ( 1 ) 0 \leq k < j(1) 0≤k<j(1),因此 k k k取值仅有
0
,但显然 k = 0 ∣ p 0 ( ′ p ′ ) ≠ p 1 ( ′ a ′ ) k=0 |p_0('p') \neq p_1('a') k=0∣p0(′p′)̸=p1(′a′),因此仍然不满足条件, f ( 1 ) f(1) f(1)取值-1
。 -
j = 2 j=2 j=2 时, k k k的取值有
0,1
(原因同上,不再赘述)- k = 0 ∣ p 0 ( ′ p ′ ) = p 2 ( ′ p ′ ) k = 0|p_0('p') = p_2('p') k=0∣p0(′p′)=p2(′p′)
- k = 1 ∣ p 0 p 1 ( ′ p a ′ ) ≠ p 1 p 2 ( ′ a p ′ ) k=1|p_0p_1('pa') \neq p_1 p_2('ap') k=1∣p0p1(′pa′)̸=p1p2(′ap′)
满足条件的最大值为 k = 0 k=0 k=0时,因此 f ( 2 ) f(2) f(2)取值
0
。 -
j = 3 j=3 j=3 时, k k k的取值有
0,1,2
- k = 0 ∣ p 0 ( ′ p ′ ) ≠ p 3 ( ′ a ′ ) k = 0|p_0('p') \neq p_3('a') k=0∣p0(′p′)̸=p3(′a′)
- k = 1 ∣ p 0 p 1 ( ′ p a ′ ) = p 2 p 3 ( ′ p a ′ ) k=1|p_0 p_1('pa') = p_2 p_3('pa') k=1∣p0p1(′pa′)=p2p3(′pa′)
- k = 2 ∣ p 0 p 1 p 2 ( ′ p a p ′ ) ≠ p 1 p 2 p 3 ( ′ a p a ′ ) k=2|p_0 p_1 p_2('pap') \neq p_1 p_2 p_3('apa') k=2∣p0p1p2(′pap′)̸=p1p2p3(′apa′)
满足条件的最大值为 k = 1 k=1 k=1时,因此 f ( 3 ) f(3) f(3)取值
1
。 -
j = 4 j=4 j=4 时, k k k的取值有
0,1,2,3
- k = 0 ∣ p 0 ( ′ p ′ ) ≠ p 4 ( ′ j ′ ) k = 0|p_0('p') \neq p_4('j') k=0∣p0(′p′)̸=p4(′j′)
- k = 1 ∣ p 0 p 1 ( ′ p a ′ ) ≠ p 3 p 4 ( ′ a j ′ ) k=1|p_0 p_1('pa') \neq p_3 p_4('aj') k=1∣p0p1(′pa′)̸=p3p4(′aj′)
- k = 2 ∣ p 0 p 1 p 2 ( ′ p a p ′ ) ≠ p 2 p 3 p 4 ( ′ p a j ′ ) k=2|p_0 p_1 p_2('pap') \neq p_2 p_3 p_4('paj') k=2∣p0p1p2(′pap′)̸=p2p3p4(′paj′)
- k = 3 ∣ p 0 p 1 p 2 p 3 ( ′ p a p a ′ ) ≠ p 1 p 2 p 3 p 4 ( ′ a p a j ′ ) k=3|p_0 p_1 p_2 p_3('papa') \neq p_1 p_2 p_3 p_4('apaj') k=3∣p0p1p2p3(′papa′)̸=p1p2p3p4(′apaj′)
没有满足就条件的情况,因此 f ( 4 ) f(4) f(4)取值
-1
。 -
j = 5 j=5 j=5 时, k k k的取值有
0,1,2,3,4
- k = 0 ∣ p 0 ( ′ p ′ ) = p 5 ( ′ p ′ ) k = 0|p_0('p') = p_5('p') k=0∣p0(′p′)=p5(′p′)
- k = 1 ∣ p 0 p 1 ( ′ p a ′ ) ≠ p 4 p 5 ( ′ j p ′ ) k=1|p_0 p_1('pa') \neq p_4 p_5('jp') k=1∣p0p1(′pa′)̸=p4p5(′jp′)
- k = 2 ∣ p 0 p 1 p 2 ( ′ p a p ′ ) ≠ p 3 p 4 p 5 ( ′ a j p ′ ) k=2|p_0 p_1 p_2('pap') \neq p_3 p_4 p_5('ajp') k=2∣p0p1p2(′pap′)̸=p3p4p5(′ajp′)
- k = 3 ∣ p 0 p 1 p 2 p 3 ( ′ p a p a ′ ) ≠ p 2 p 3 p 4 p 5 ( ′ p a j p ′ ) k=3|p_0 p_1 p_2 p_3('papa') \neq p_2 p_3 p_4 p_5('pajp') k=3∣p0p1p2p3(′papa′)̸=p2p3p4p5(′pajp′)
- k = 4 ∣ p 0 p 1 p 2 p 3 p 4 ( ′ p a p a j ′ ) ≠ p 1 p 2 p 3 p 4 p 5 ( ′ a p a j p ′ ) k=4|p_0 p_1 p_2 p_3 p_4('papaj') \neq p_1 p_2 p_3 p_4 p_5('apajp') k=4∣p0p1p2p3p4(′papaj′)̸=p1p2p3p4p5(′apajp′)
满足条件的最大值为 k = 0 k=0 k=0时,因此 f ( 5 ) f(5) f(5)取值为
0
。 -
j = 6 j = 6 j=6 时, k k k的取值有
0,1,2,3,4,5
- k = 0 ∣ p 0 ( ′ p ′ ) ≠ p 6 ( ′ a ′ ) k = 0|p_0('p') \neq p_6('a') k=0∣p0(′p′)̸=p6(′a′)
- k = 1 ∣ p 0 p 1 ( ′ p a ′ ) = p 5 p 6 ( ′ p a ′ ) k=1|p_0 p_1('pa') = p_5 p_6('pa') k=1∣p0p1(′pa′)=p5p6(′pa′)
- k = 2 ∣ p 0 p 1 p 2 ( ′ p a p ′ ) ≠ p 4 p 5 p 6 ( ′ j p a ′ ) k=2|p_0 p_1 p_2('pap') \neq p_4 p_5 p_6('jpa') k=2∣p0p1p2(′pap′)̸=p4p5p6(′jpa′)
- k = 3 ∣ p 0 p 1 p 2 p 3 ( ′ p a p a ′ ) ≠ p 3 p 4 p 5 p 6 ( ′ a j p a ′ ) k=3|p_0 p_1 p_2 p_3('papa') \neq p_3 p_4 p_5 p_6('ajpa') k=3∣p0p1p2p3(′papa′)̸=p3p4p5p6(′ajpa′)
- k = 4 ∣ p 0 p 1 p 2 p 3 p 4 ( ′ p a p a j ′ ) ≠ p 2 p 3 p 4 p 5 p 6 ( ′ p a j p a ′ ) k=4|p_0 p_1 p_2 p_3 p_4('papaj') \neq p_2 p_3 p_4 p_5 p_6('pajpa') k=4∣p0p1p2p3p4(′papaj′)̸=p2p3p4p5p6(′pajpa′)
- k = 5 ∣ p 0 p 1 p 2 p 3 p 4 p 5 ( ′ p a p a j p ′ ) ≠ p 1 p 2 p 3 p 4 p 5 p 6 ( ′ a p a j p a ′ ) k=5|p_0 p_1 p_2 p_3 p_4 p_5('papajp') \neq p_1 p_2 p_3 p_4 p_5 p_6('apajpa') k=5∣p0p1p2p3p4p5(′papajp′)̸=p1p2p3p4p5p6(′apajpa′)
满足条件的最大值为 k = 1 k=1 k=1时,因此 f ( 6 ) f(6) f(6)取值为
1
。
最终失效函数结果如下表:
j j j | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
p j p_j pj | p | a | p | a | j | p | a |
f ( j ) f(j) f(j) | -1 | -1 | 0 | 1 | -1 | 0 | 1 |
在得到失效函数 f f f后就可以引用在MP算法中了,具体的方式如下:
- 位于模式串 P j P_j Pj发生失配时,如果 j = 0 j=0 j=0,则目标串 S S S指针前移1位。
- 位于模式串 P j P_j Pj发生失配时,如果 j ≠ 0 j \neq 0 j̸=0,则目标串 S S S指针不动,但模式串 P P P的起始位置滑动至 p f ( j − 1 ) + 1 p_{f(j-1)+1} pf(j−1)+1。
看个具体一点的例子:假定字符串 S = b p a p a j a p a p a j p a S = bpapajapapajpa S=bpapajapapajpa、模式串 P = p a p a j p a P = papajpa P=papajpa,其过程见下图
可以看到整个过程中,字符串 S S S的指针从未回退,这也就是为什么其性能较之暴力算法大幅度提升的原因,这种算法在模式串与字符串高度相似的时候效率最高。
但仍然能看到缺点,第三轮失配是没有必要的,因为在第二轮时就得知 s 6 ≠ p 5 s_6 \neq p_5 s6̸=p5,而 p 5 = p 1 p_5 = p_1 p5=p1,因此必然存在 s 6 ≠ p 1 s_6 \neq p_1 s6̸=p1。
KMP算法对MP算法的改进
先提一句,失效函数经常表示为next
表(数组),就是先计算出next
数组供匹配时使用。注意,MP的失效函数生成的表匹配规则有当
p
j
,
j
≠
0
p_j,j\neq0
pj,j̸=0发生失配时,模式串
P
P
P滑动至
p
f
(
j
−
1
)
+
1
p_{f(j-1)+1}
pf(j−1)+1处。为方便实现,在转为mpNext表时,就要实现其中的
f
(
j
−
1
)
+
1
{f(j-1)+1}
f(j−1)+1,但
m
p
N
e
x
t
[
0
]
mpNext[0]
mpNext[0]仍然保持为-1
。
前面说了,KMP对MP算法是一个改进,具体改进就在于避免了MP算法中如上例的情况。
就是说尽量避免滑动后就马上失配的情况,虽然无法知道匹配的字符串是什么,但至少可以通过模式串本身避免滑动后的字符与失配的字符是一样的情况。
通过MP失效函数算法生成的mpNext就可以建立优化的kmpNext表,其规则如下:
其中 1 ≤ j ≤ m 1 \leq j \leq m 1≤j≤m
- 如果 m p N e x t [ j ] = 0 mpNext[j] = 0 mpNext[j]=0且 p j = p 0 p_j = p_0 pj=p0,则 k m p N e x t [ j ] = − 1 kmpNext[j] = -1 kmpNext[j]=−1
- 如果 m p N e x t [ j ] = 0 mpNext[j] = 0 mpNext[j]=0且 p j ≠ p 0 p_j \neq p_0 pj̸=p0,则 k m p N e x t [ j ] = 0 kmpNext[j] = 0 kmpNext[j]=0
- 如果 m p N e x t [ j ] ≠ 0 mpNext[j] \neq 0 mpNext[j]̸=0且 p j ≠ p m p N e x t [ j ] p_j \neq p_{mpNext[j]} pj̸=pmpNext[j],则 k m p N e x t [ j ] = m p N e x t [ j ] kmpNext[j] = mpNext[j] kmpNext[j]=mpNext[j]
- 如果 m p N e x t [ j ] ≠ 0 mpNext[j] \neq 0 mpNext[j]̸=0且 p j = p m p N e x t [ j ] p_j = p_{mpNext[j]} pj=pmpNext[j],则用 m p N e x t [ j ] mpNext[j] mpNext[j]替换 m p N e x t [ j ] mpNext[j] mpNext[j]中 j j j的值,直到情况转为上述三种。
此外,与MP失效函数一样,
k
m
p
N
e
x
t
[
0
]
kmpNext[0]
kmpNext[0]也置为-1
。
用如上规则对上面MP生成的表建立mpNext及kmpNext如下:
j j j | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
p j p_j pj | p | a | p | a | j | p | a |
f ( j ) f(j) f(j) | -1 | -1 | 0 | 1 | -1 | 0 | 1 |
m p N e x t [ j ] mpNext[j] mpNext[j] | -1 | 0 | 0 | 1 | 2 | 0 | 1 |
k m p N e x t [ j ] kmpNext[j] kmpNext[j] | -1 | 0 | -1 | 0 | 2 | -1 | 0 |
顺便一提,在这个表上生成 j = 7 j=7 j=7的mpNext、kmpNext有没有意义呢?因为当 j = 7 j=7 j=7时一定代表已经匹配了,不过如果需要匹配所有的位置则是有意义的。
现在知道了怎么求出kmpNext,但对它本身仍有一些模糊,看看kmpNext的各个值分别是什么意思:
- k m p N e x t [ 0 ] = − 1 kmpNext[0] = -1 kmpNext[0]=−1 固定值
- k m p N e x t [ j ] = − 1 kmpNext[j] = -1 kmpNext[j]=−1 这种情况表示模式串中字符 p j p_j pj与首字符相同,且 p j p_j pj前 k k k个字符与模式串开头的 k k k个字符不相等,也或者相等但 p j ≠ p k p_j \neq p_k pj̸=pk,其中 j ≠ 0 , 1 ≤ k < j j \neq 0, 1 \leq k < j j̸=0,1≤k<j
- k m p N e x t [ j ] = k kmpNext[j] = k kmpNext[j]=k 表示模式串中字符 p j p_j pj见面 k k k个字符与模式串开头的 k k k个字符相等,即 p 0 p 1 … p k − 1 = p j − k p j − k + 1 … p j − 1 p_0 p_1 \dots p_{k-1} = p_{j-k} p_{j-k+1} \dots p_{j-1} p0p1…pk−1=pj−kpj−k+1…pj−1,且 p j ≠ p k p_j \neq p_k pj̸=pk,其中 1 ≤ k < j 1 \leq k < j 1≤k<j
- 除上述3种情况,其余 k m p N e x t [ j ] = 0 kmpNext[j] = 0 kmpNext[j]=0
C 实现
先来看看MP算法的实现:
//mpNext生成函数
void preMP(const char *p, int mpNext[])
{
int i = 0, j = mpNext[0] = -1;
while (p[i] != '\0')
{
while (j > -1 && p[i] != p[j])
j = mpNext[j];
mpNext[++i] = ++j;
}
}
//利用mpNext匹配字符串函数
void MP(const char *string, const char *p)
{
int i = 0, j = 0, *mpNext;
mpNext = malloc(sizeof(int) * strlen(p)+1);
preMP(p, mpNext);
while (string[i] != '\0')
{
while (j > -1 && p[j] != string[i])
j = mpNext[j];
i++;
j++;
if (p[j] == '\0')
{
printf("Found match on: %d\n", i - j);
break;
}
}
}
再看看KMP算法实现,与MP唯一不同的就是,KMP多了一个条件,也就是用来避免同字符失配后仍滑到同字符的情况,将kmp用于匹配的代码与MP完全一致:
//kmpNext生成函数
void preKMP(const char *p, int kmpNext[])
{
int i = 0, j = kmpNext[0] = -1;
while (p[i] != '\0')
{
while (j > -1 && p[i] != p[j])
j = kmpNext[j];
i++;
j++;
if (p[i] == p[j])
kmpNext[i] = kmpNext[j];
else
kmpNext[i] = j;
}
}