串
一. 定义(了解)
串,即字符串,是计算机系统和网络传输中最常用的数据类型,任何非数值型的处理都会以字符串的形式存储和使用。
串(String)是由零个或多个字符组成的有限序列,一般记为:
S = ′ a 1 a 2 … a n ′ ( n ≥ 0 ) S='a_1a_2 \dots a_n' \quad (n \geq 0) S=′a1a2…an′(n≥0)
-
S
:串名 -
a_i
:字符(任意一种可被编码的符号,即为字符) -
n
:串的长度(n=0
时S=空
) -
子串:串的子序列(如
abc
即为dabcd
的子串) -
主串:子串的原始串
-
位置:字符在串中的序号(子串的位置由首字符标识)
-
串相等:长度相等且每个字符相等。
-
串的大小比较规则:
-
从第一个字符依次往后比较,以第一个不相等的字符的大小标识串的大小
-
若每个字符都相等,则短串小于长串。
-
若串长度也相等,则串相等。
-
注: 字符的大小比较规则,以编码值的大小为准.
如按ASCALL编码,字符
A
编码63,字符a
编码97,则可认为A<a
对于串的正确理解,需要注意以下几点:
- 空格串与空串并不等价,如串
' '
是长度为2的空格串(实际上,除了空格还有许多不可见字符如'\n'
等,对于这些字符在字符串里的地位和可见字符完全等价)。 - 串的逻辑结构和线性表非常相似,实际上,将线性表中的元素类型设为字符型就是串结构的一种实现方式。区别在于线性表主要关注的是元素"crud"等操作,而其中元素类型是具有抽象性的;而串则限定了其中的元素类型为字符型,其关注的重点主要是查找删除或者插入一个子串这种独属于字符串的操作。
-
串同样具有顺序和链式两种存储结构,这里不过多赘述,它的基本操作如下:
StrAssign(&T, chars)
: 赋值操作。把串T
赋值为chars
。StrCopy(&T, S)
: 复制操作。由串S
复制得到串T
。StrEmpty(S)
: 判空操作。若S
为空串,则返回TRUE
,否则返回FALSE
。StrCompare(S, T)
: 比较操作。若S > T
,则返回值 > 0;若S = T
,则返回值 = 0;若S < T
,则返回值 < 0。StrLength(S)
: 求串长。返回串S
的元素个数。SubString(&Sub, S, pos, len)
: 求子串。用Sub
返回串S
的第pos
个字符起长度为len
的子串。Concat(&T, S1, S2)
: 串联接。用T
返回由S1
和S2
联接而成的新串。Index(S, T)
: 定位操作。若主串S
中存在与串T
值相同的子串,则返回它在主串S
中第一次出现的位置;否则函数值为0
。ClearString(&S)
: 清空操作。将S
清为空串。DestroyString(&S)
: 销毁操作。将串S
销毁。
二. 串的模式匹配(重要)
以下讨论中的串结构我们均采用顺序存储的模式,其声明如下:
typedef struct {char ch[MAXLEN]; //注意,这里我们统一从下标为1的位置开始使用数据int length;
}String; //string(首字母小写)为C++语言内置的数据类型,这里使用首字母大写以示区别
1. 朴素算法
所谓串的模式匹配,即是串的基本操作中的Index(S, T)
的实现。
如对于串S="google", T="ogl"
的测试用例,函数应该返回T
在S
中的位置,即应返回2
。
而对于串S="google", T="ogld"
的测试用例来说,由于T
并不是S
的子串,所以应该返回-1
。
这里暴力匹配的方法应该很容易理解:
int Index(String S, String T) {int i = 1, j = 1; //字符串的下标从1开始while (i <= S.length && j <= T.length) {if (S.ch[i] == T.ch[j]) {++i, ++j;}else {i = i - j + 2;j = 1;}}if (j > T.length) {return i - T.length;}return 0; //匹配失败
}
这种模式匹配的时间复杂度为 O(mn)
其中n和m分别是串S和T的长度,因为在最坏的情况下(每次都是T字符串的最后一位不匹配),针对S中的每一个字符,T都需要遍历整个字符串。
// 朴素模式匹配最坏情况
S = "000000000000000000000000000001";
T = "000001";
2. KMP算法
显然,朴素匹配的思想太过”朴素“,无法满足我们对Index
函数的性能要求,那么,我们该如何优化呢?
试想上面提到的朴素算法最坏情况的那个例子:
S = "000000000000000000000000000001";
T = "000001";
朴素算法耗时之处就在于,我们在比对子串时需要从T
字符串开头逐字符进行对比,一旦匹配失败,则主串i
指针需要回溯到当前对比子串的下一个子串开头,正是这个回溯的过程,才导致了朴素匹配算法的效率低下。
00000'0'000000000000000000000001
00000'1'
//匹配失败后,需要进行回溯
0'0'0000000000000000000000000001'0'00001 //需要从这里开始重新对比
但 Knuth Morris Pratt (KMP
算法的三位创始人,KMP
算法因此得名) 认为,这种回溯操作不是必要的:
00000'0'000000000000000000000001
00000'1'
/*
当最后一个字符匹配失败后,实际上前面的字符信息我们已经已知
即,当T字符串中第j个字符不匹配时,实际上就代表着,S和T的前j-1个字符是匹配的!
*/
那么,将i
指针回溯到下一个子串的初始位置就不必要的,因为在上次匹配中,我们已经知道了:
- 子串的第二位和主串的第二位匹配。
- 根据子串本身的特点(KMP算法的核心所在),子串的第一位与子串第二位相同。
- 所以,我们得出主串的第二位和子串的第一位一定匹配!那么,
i
指针回溯到下一个子串的位置(这里指第二位)就没有必要。
那么,i
指针到底应该回溯到哪呢?事实上,i
指针并不需要回溯!
理由是,既然子串匹配失败,那就代表着假如可以匹配成功,那么子串的结尾字符一定会在i
之后(否则不会匹配失败)。
所以,我们只需要将j
(指向子串)指针回溯到一个合理的位置继续对比即可。
00000'0'000000000000000000000001
00000'1'
//只需要将j指针回溯到第一位即可
00000'0'000000000000000000000001'0'00001 //i指针不回溯,继续对比
其实i
不回溯本质上的原因就在于在扫描的过程中,位置i
之前串的信息我们已经掌握,没有回溯的必要。
所以现在的问题就变成了,当不匹配发生时,j
指针应该回溯到什么位置?
所以到这就可以看出,解决问题的关键实际上不在于主串,而在于子串,所以KMP算法的核心就是利用子串本身的特点简化匹配过程。
这里我们引入next数组,next[j]
的含义是当子串第j
位发生不匹配时,j
指针应该回退到next[j]
的位置。
我们先介绍其手算的方式,这也是考研数据结构的核心考点:
假设子串T="abaabc";
-
当
T[1]
不匹配时由于是子串的首字符不匹配,所以此时应当进行的操作实际上是将主串指针
i
后移一位继续对比,但是为了程序书写的一致性,我们让j
回退到0,然后让i
和j
同时后移一位,即**next[1]=0;
**。 -
当
T[2]
不匹配时需要将
j
回溯到首位继续对比,故**next[2]=1
**。 -
当
T[3]
不匹配时***ab'*'********ab'a' //发生不匹配***ab'*'********'a'ba //你应该让j指针回到第一位进行对比
所以,
next[3]=1
。 -
当
T[4]
不匹配时***aba'*'*******aba'a' //发生不匹配***aba'*'*******a'b'aa //你应该让j指针回到第2位进行对比
所以,
next[4]=2
。 -
当
T[5]
不匹配时***abaa'*'******abaa'b' //发生不匹配***abaa'*'******a'b'aab //你应该让j指针回到第2位进行对比
所以,
next[5]=2
。 -
当
T[6]
不匹配时***abaab'*'******abaab'c' //发生不匹配***abaab'*'******ab'a'abc //你应该让j指针回到第3位进行对比
所以,
next[6]=3
。
所以对于子串"abaabc"
来说,其next
数组情况如下:
j | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
next[j] | 0 | 1 | 1 | 2 | 2 | 3 |
那么,我们使用next
数组来模拟一下匹配过程:
假设主串S="abaccabaacabaabca"
,子串T="abaabc"
,那么,匹配流程为:
1.
aba'c'cabaacabaabca
aba'a'bc //T[4]不匹配,j=next[4]=22.
aba'c'cabaacabaabcaa'b'aabc //T[2]不匹配,j=next[2]=13.
aba'c'cabaacabaabca'a'baabc //T[1]不匹配,j=next[1]=0, 然后i和j同时++4.
abac'c'abaacabaabca'a'baabc //T[1]不匹配,j=next[1]=0, 然后i和j同时++5.
abaccabaa'c'abaabcaabaa'b'c //T[5]不匹配,j=next[5]=26.
abaccabaa'c'abaabcaa'b'aabc //T[5]不匹配,j=next[5]=27.
abaccabaa'c'abaabca'a'baabc //T[2]不匹配,j=next[2]=18.
abaccabaac'abaabc'a'abaabc' //完全匹配,算法结束
所以得到了next
数组以后,模式匹配的代码如下:
int Index(String S, String T, int next[]) {int i = 1, j = 1; //字符串的下标从1开始while (i <= S.length && j <= T.length) {if (j = 0 || S.ch[i] == T.ch[j]) {++i, ++j;}else {j = next[j]; //发生匹配失败的情况,i指针不回溯}}if (j > T.length) {return i - T.length;}return 0; //匹配失败
}
接下来,我们总结一下next
数组的手算方式:
对于任意一个子串:
next[1]=0
:首字符不匹配,等于0之后两个指针同时++。next[2]=1
:第二个字符不匹配,则回到第一个字符开始比较。
从第三个字符开始,我们可以采用手动模拟的形式计算,即自己观察若第j (j > 2)
个字符不匹配,则j
指针应该回溯到哪里才能让前j-1
个字符与主串对应位置的字符相等。
例:求"aabaac"
next数组。
0 1 2 1 2 3
下面我们分析一下如何编程求出next
数组(考研了解即可)
我们假设子串 T = T 1 T 2 … T m T=T_1T_2 \dots T_m T=T1T2…Tm
我们已知 next[1]=0
,我们假设 next[j]=k (j>1)
,那么就代表着,当第j
个元素匹配失败时,我们应该从第k
个元素开始继续比较,那就意味着:
T 1 T 2 … T k − 1 = T j − k + 1 T j − k + 2 … T j − 1 T_1T_2 \dots T_{k-1}=T_{j-k+1}T_{j-k+2} \dots T_{j-1} T1T2…Tk−1=Tj−k+1Tj−k+2…Tj−1
且不存在更长的子序列满足这个条件。
就像,T=abaabc
,其next[6]=3 (next[c]=a)
的真正原因在于,失配的c
的前两个字符组成的子串ab
,与T[3]
的前缀ab
相同,所以当最后一个c
失配时,只需要从T[3]
开始匹配即可。
那么,我们现在来讨论next[j+1]
的情况。
-
当 T k = T j T_k = T_j Tk=Tj 时,
next[j+1] = k+1
,即next[j+1]=next[j]+1
-
当 T k ≠ T j T_k \neq T_j Tk=Tj 时,
代表着此时
j+1
位置的前k
个字符,和字符串的前k
个字符就不相等了,那么我们怎么办呢?答案是显然的,找更短的子串使其相等。即让
k1=next[k]
,接着判断前k1
个元素和j+1
位置的前k1
个元素组成的子序列是否相等,若相等,则next[j+1]=k1+1
;若还不相等,则继续缩短子序列进行判断(k2=next[k1]
)。若一直不相等,则子序列的长度会被缩短为0,因为
next[1]=0
,此时,next[j+1]=1
,即从头开始比较。
将以上思路,转换为代码,就可以得到next
数组的编程求法了:
void get_next(String T, int next[])
{int i = 1, k = 0;next[1] = 0;while (i < T.length) {if (k == 0 || T.ch[i] == T.ch[k]) {i++, k++;next[i] = k;}else {k = next[k];}}
}
到这里为止,我们就可以分析KMP算法的时间复杂度了(假设主串长为n
,子串长为m
):
- 对于匹配的过程,由于主串指针
i
不回溯,故时间复杂度 O ( n ) O(n) O(n) - 对于求解
next
数组的过程,由于程序主体是从第二个字符开始依次求解,而next
数组的回溯速度较快(近乎常数),所以时间复杂度近似 O ( m ) O(m) O(m)
故整体的时间复杂度为: O ( m + n ) O(m+n) O(m+n)
这里时间复杂度的分析仅为了便于理解,本篇文章不详细解析KMP算法时间复杂度的计算,感兴趣可自行查阅资料。
3. KMP算法进一步优化
这里让我们回到朴素的那个例子:
S = "000000000000000000000000000001";
T = "000001";
试着求解一下这里子串T
的next
数组,其值如下:
j | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
T[j] | ‘0’ | ‘0’ | ‘0’ | ‘0’ | ‘0’ | ‘1’ |
next[j] | 0 | 1 | 2 | 3 | 4 | 5 |
假设当T[5]
发生失配时,按照next
数组,我们应该将j
指针回溯到4
的位置:
***0000'*'********* 0000'0'1 //T[5]发生失配, '*'必然不是0***0000'*'*********000'0'01 //回溯到4的位置, 必然失配
但是这样就会产生一个问题,当T[5]
发生失配时,我们可以知道主串对应的位置的值必然不等于T[5]
,而T[4]==T[5]
,所以j
指针回溯到4
的位置必然失配。
事实上,观察子串"000001"
子串可知,当T[5]
发生失配时,回溯到除0
以外的任何位置都会失配。
那么问题出在哪了呢?我们观察为什么会出现这种现象,很容易看出这里的 T[5]==T[next[5]]
,即出现了T[j]==T[next[j]]
的现象,这其实是不应该出现的,因为如果T[j]
发生了失配,且T[j]==T[next[j]]
,那么T[next[j]]
就一定会发生失配。
所以在这里,我们就可以对next
数组的生成进行优化,如果T[j]==T[next[j]]
,那么我们应该递归似的将next[j]
赋值为next[next[j]]
,直到二者不相等为止。优化以后的数组,我们习惯上称为nextVal
数组,其生成代码如下:
void get_nextVal(String T, int nextVal[])
{int i = 1, k = 0;nextVal[1] = 0;while (i < T.length) {if (k == 0 || T.ch[i] == T.ch[k]) {i++, k++;if (T.ch[i] != T.ch[k]) {nextVal[i] = k;} else {//实际计算时是从前往后遍历,这样可以保证这条语句最多只需要执行一次nextVal[i] = nextVal[k];}}else {k = nextVal[k];}}
}
考研的同学需要注意题目需要求解的是
next
还是nextVal
。