文章目录
纸上得来终觉浅,绝知此事要躬行
1. 串的定义
串(string)是零个或多个字符组成的有限序列,又名叫字符串。 记作:S=“a1a2a3 … an”,其中 S 是串名,ai (1 ≤ i ≤ n) 可以是字母、数字或其它字符。
串值:双引号括起来的字符序列是串值
串长:串中所包含的字符个数称为该串的长度
空串(空的字符串):长度为零的串称为空串,它不包含任何字符。
空格串(空白串):构成串的所有字符都是空格的串称为空白串。
子串(substring):串中任意个连续字符组成的子序列称为该串的子串,包含子串的串相应地称为主串。
子串的序号:将子串在主串中首次出现时的该子串的首字符对应在主串中的序号,称为子串在主串中的序号(或位置)。
注意:空串和空白串的不同,例如 “ ” 和 “” 分别表示长度为1的空白串和长度为0的空串。
串的比较
串的比较是通过组成串的字符之间的编码来进行的,而字符的编码指的是字符在对应字符集中的序号。计算机中的常用字符是使用标准的 ASCII 编码。若比较两个字符串,则自左向右逐个字符相比(按ASCII值大小相比较)。
例如:s=“happen”,t=“happy”,因为两串的前 4 个字母均相同,而两串第 5 个字母,字母 e 的 ASCII 码是 101,而字母 y 的 ASCII 码是 121,显然 e<y,所以 串s<串t 。
串相等:如果两个串的串值相等(相同),称这两个串相等。换言之,只有当两个串的长度相等,且各个对应位置的字符都相同时才相等。
2. 串的抽象数据类型
串的逻辑结构和线性表很相似,不同之处在于串针对的是字符集,也就是串中的元素都是字符,哪怕串中的字符是 “123” 这样的数字组成,或者 “2019-02-10” 这样的日期组成,它们都只能理解为长度为 3 和长度为 10 的字符串,每个元素都是字符而已。
因此,对于串的基本操作与线性表是有很大差别的。线性表更关注的是单个元素的操作,比如查找一个元素,插入或删除一个元素,但串中更多的是查找子串的位置、得到指定位置的子串、替换子串等操作。
ADT 串(string)
Data
串中元素仅由一个元素组成,相邻元素具有前驱和后继关系
Operation
StrLength(S): 返回串S中的元素个数
SubString(Sub, S, pos, len): 若串S,1≤pos≤StrLength(S),
且0≤len≤StrLength(s)–pos+1,用Sub返
回串S的第pos个字符起长度为len的子串。
index(S, T, pos): 串S和T存在,T是非空串,1≤pos≤StrLength(S)。
若主串S中存在和串T值相同的子串,则返回它在主串S中
第pos个字符之后第一次出现的位置,否则返回0。
Replace(S, T, V): 串S、T和V存在,T是非空串。用V替换主串S中出现的所有
与T相等的不重叠的子串。
......
3. 串的存储结构
串是一种特殊的线性表,其存储表示和线性表类似,但又不完全相同。串在计算机中有 3 种表示方式:
3.1 顺序存储结构
串的顺序存储结构是用一组地址连续的存储单元来存储串中的字符序列。按照预定义的大小,为每个定义的串变量分配一个固定长度的存储区。一般是用定长数组来定义。
3.2 链式存储结构
串的链式存储结构和线性表的串的链式存储结构类似,采用单链表来存储串,结点的构成是:
◆ data域:存放字符,data域可存放的字符个数称为结点的大小
◆ next域:存放指向下一结点的指针
若每个结点仅存放一个字符,则结点的指针域就非常多,造成系统空间浪费,为节省存储空间,考虑串结构的特殊性,使每个结点存放若干个字符,这种结构称为块链结构。如图是块大小为3的串的块链式存储结构示意图。
在这种存储结构下,结点的分配总是完整的结点为单位,因此,为使一个串能存放在整数个结点中,在串的末尾填上不属于串值的特殊字符,以表示串的终结。
当一个块(结点)内存放多个字符时,往往会使操作过程变得较为复杂,如在串中插入或删除字符操作时通常需要在块间移动字符。
3.3 堆存储结构
仍然以一组地址连续的存储空间来存储串值字符序列,但其所需的存储空间是在程序执行过程中动态分配,故是动态的,变长的。
4. 串的模式匹配算法
模式匹配:子串在主串中的定位称为模式匹配或串匹配(字符串匹配) 。模式匹配成功是指在主串S中能够找到模式串T,否则,称模式串T在主串S中不存在。
模式匹配的应用非常广泛。例如,在文本编辑程序中,我们经常要查找某一特定单词在文本中出现的位置。
模式匹配是一个较为复杂的串操作过程。迄今为止,人们对串的模式匹配提出了许多思想和效率各不相同的算法。下面介绍两种主要的模式匹配算法。
4.1 朴素模式匹配算法
例如从主串 S=“goodgoogle” 中,找到 T=“google” 这个子串的位置。
1、主串 S 第一位开始,S 与 T 前三个字母都匹配成功,但 S 第四个字母是 d 而 T 的是 g。第一位匹配失败。
2、主串 S 第二位开始,主串 S 首字母是 o,要匹配的 T 的首字母是 g,第二位匹配失败。
……
5、主串 S 第五位开始,S 与 T,6个字母全匹配,匹配成功。
简单地说,就是对主串的每一个字符作为子串开头,与要匹配的字符串进行匹配。此过程中,对主串做大循环,每个字符开头做 T 的长度的小循环,直到匹配成功或全部遍历完成为止。
朴素模式匹配算法 Java实现:
/**
* 串的朴素模式匹配算法
* @param source 主串
* @param target 子串(模式串)
* @param pos 主串偏移量
* @return 若主串S中存在和串T值相同的子串,则返回
* 它在主串S中从pos个字符开始第一次出现的位置,否则返回-1
*/
public int BFIndex(String source, String target, int pos) {
int lenS = source.length();
int lenT = target.length();
if (lenT > lenS - pos) {
return -1;
}
// 将字符串转换成字符数组
char[] s = source.toCharArray();
char[] t = target.toCharArray();
int i = pos, j = 0;
while (i < lenS && j < lenT) { // 若i小于S长度且j小于T长度时循环
if (s[i] == t[j]) { // 若字符匹配则继续
i++;
j++;
} else { // 指针后退重新开始匹配
i = i - j + 1;
j = 0;
}
}
// 匹配成功,返回模式串T在主串S中的位置,否则返回-1
if (j >= lenT) {
return i - j;
} else {
return -1;
}
}
4.2 KMP 模式匹配算法
4.2.1 KMP 模式匹配算法原理
KMP 模式匹配算法 俗称“看毛片”算法,是一个效率非常高的字符串匹配算法。其全称是Knuth–Morris–Pratt string searching algorithm,是由 D.E.Knuth,J.H.Morris 和 V.R.Pratt 三人于1977年共同发表的。
在学习KMP算法之前需要明白什么是字符串的前缀、后缀,以及前后缀的相似度概念。
前缀:指字符串中除了最后一个字符以外,其余字符的全部头部顺序组合。
后缀:指字符串中除了第一个字符以外,其余字符的全部尾部顺序组合。
前后缀的相似度:指字符串前后缀最大相同元素的长度。
例如:
字符串 | abcab | aaaa |
---|---|---|
前缀 | a、ab、abc、abca | a、aa、aaa |
后缀 | b、ab、cab、bcab | a、aa、aaa |
前后缀最大相同元素 | ab | aaa |
前后缀的相似度 | 2 | 3 |
首先,KMP 算法是对传统 BF 算法的改善,怎么改善的呢?在BF算法中,每当主串与子串对应位置的字符匹配失败时,主串的位置指针就往前回溯,子串位置指针从头开始,然后重新匹配。实质上,每当匹配失败时可以得出两个结论:
- 本趟匹配失败
- 子串当前匹配失败字符之前的字符是匹配成功的
BF 算法正是没有利用第二条结论的信息,所以效率低。而 KMP 算法充分利用了第二条结论的信息,从而避免一些明显不合法的移位,加快匹配过程。如:
当 ‘D’ 与 ‘A’ 匹配失败,按照 BF 算法是不假思索的把子串整体右移一位,主串不动,然后再重新逐次对应比较。而 KMP 算法主要的改进是,充分利用已经比较的结果和字符串的性质,在发生不匹配时,不再单纯后移一个位置,而是尽可能跳过多个位置重新开始比较,因此效率大为提升。
例如:S=“ABDABDABA”,T=“ABDABA”。对于开始的判断,前 5 个字符完全相等,第 6 个字符不等,如图(1)。此时,T 的首字符 “A” 与 T 的第二位字符 “B”、第三位字符 “D” 均不等,所以不需要做判断,图中朴素算法步骤 2、3 都是多余。
因为 T 的首位 “A” 与 T 的第四位的 “A” 相等,第二位 “B” 与第五位 “B” 相等。而在图(1)时,第四位的 “A” 与第五位的 “B” 已经与主串 S 中的相应位置比较过了,是相等的,因此可以断定,T 的首字符 “A”、第二位的字符 “B” 与 S 的第四位字符和第五位字符也不需要比较了,肯定是相等的,所以步骤(4)、(5)也可以省略。
也就是说,对于在子串中有与首字符相等的字符,也是可以省略一部分不必要的判断步骤,如图(6)所示,省略掉 T 串前两位 “A” 与 “B” 同 S串中的 3、4 位置字符匹配操作。
从上述例子中,我们会发现在图(1)时,我们的 i 值,也就是主串当前位置的下标是 5,图(2)~(5)时,i 值是1、2、3、4,到了图(6),i 值才又回到了 5。即我们在朴素模式匹配算法中,主串的 i 值是不断地回溯来完成的。而我们的分析发现,这种回溯其实是可以不需要的一一正所谓好马不吃回头草,我们的 KMP 模式匹配算法就是为了让这没必要的回溯不发生。(KMP 算法可以省略 2~5 步,i 值是不变的,而 k 值由 5 变成了 2)
既然 i 值不回溯,也就是不可以变小,那么要考虑的变化就是 k 值了。通过观察也可发现,我们屡屡提到了 T 串的首字符与自身后面字符的比较,发现如果有相等字符,k 值的变化就会不相同。也就是说,这个 k 值的变化与主串其实没什么关系,关键就取决于 T 串的结构中是否有重复的问题。
比如图(1)中,T=“ABDABA”,由于第 6 个字符不匹配,前缀的 “AB” 与最后字符 “A” 之前的串 “ABDAB” 的后缀 “AB” 是相等的。因此 k 就由 5 变成了 2,因此,我们可以得出规律,k 值的多少取决于当前字符之前的串的前后缀的相似度。
我们把 T 串各个位置的 k 值的变化定义为一个数组 next,那么 next 的长度就是 T 串的长度。于是我们可以得到下面的函数定义:
n e x t [ k ] = { − 1 k=0 max { j ∣ 0 < j < k , 且 “ p 0 . . . p j − 1 ” = “ p k − j . . . p k − 1 ” } 当此集合不为空时 0 其它情况 next[k]= \begin{cases} -1& \text{k=0}\\ \max \{j|0<j<k,且“p_0...p_{j-1}”=“p_{k-j}...p_{k-1}”\}& \text{当此集合不为空时}\\ 0& \text{其它情况} \end{cases} next[k]=⎩⎪⎨⎪⎧−1max{j∣0<j<k,且“p0...pj−1”=“pk−j...pk−1”}0k=0当此集合不为空时其它情况
4.2.2 next 数组值推导
具体如何推导出一个串的 next 数组值呢,我们来看一些例子。
- T="abcdex"
k | 0 1 2 3 4 5 |
---|---|
模式串 T | a b c d e x |
前后缀相似度 | 0 0 0 0 0 0 |
next[k] | -1 0 0 0 0 0 |
(1)当 k=0 时,next[0]=-1;
(2)当 k=1 时,k 由 0 到 k -1 就只有字符 “a”,属于其他情况 next[1]=0;
(3)当 k=2 时,k 由 0 到 k -1 串是 “ab”,显然 “a” 与 “b” 不相等,属其他情况,next[2]=0;
(4)以后同理,所以最终此 T 串的 next[k]为000000。
- T="abcabx"
k | 0 1 2 3 4 5 |
---|---|
模式串 T | a b c a b x |
前后缀相似度 | 0 0 0 1 2 0 |
next[k] | -1 0 0 0 1 2 |
(1)当 k=0 时 , next[0]=-1;
(2)当 k=1 时,同上例说明, next[1]=0;
(3)当 k=2 时,同上,next[2]=0;
(4)当 k=3 时,同上,next[3]=0;
(5)当 k=4 时,此时 k 由 0 到 k-1 的串是 “abca”,前缀字符 “a” 与后缀字符 “a” 相等,因此可推算出 j 值为 1(由“p0…pj-1” = “pk-j…pk-1”,得到 p0 = p3 )因此next[4]=1;
(6)当 j=5 时,k 由 0 到 k - l 的串是 “abcab” ,由于前缀字符 “ab” 与后缀 “ab” 相等,所以 next[5]=2。
- T="aaaaaaaab"
k | 0 1 2 3 4 5 6 7 8 |
---|---|
模式串 T | a a a a a a a a b |
前后缀相似度 | 0 1 2 3 4 5 6 7 0 |
next[k] | -1 0 1 2 3 4 5 6 7 |
(1)当 k=0 时 , next[0]=-1;
(2)当 k=1 时,同上, next[1]=0;
(3)当 k=2 时,此时 k 由 0 到 k-1 的串是 “aa”,前缀字符 “a” 与后缀字符 “a” 相等,next[2]=1;
(4)当 k=3 时,此时 k 由 0 到 k-1 的串是 “aaa”,前缀字符 “aa” 与后缀字符 “aa” 相等,next[3]=2;
(5)…
(9)当 k=9 时,k 由 0 到 k - 1 的串是 “aaaaaaaa”,由于前缀字符 “aaaaaaa” 与后缀 “aaaaaaa” 相等,所以next[8]=7。
我们可以发现 next 数组相当于 “前后缀相似度 ” 整体向右移动一位,然后初始值赋为 -1
next 数组 Java 实现:
/**
* @param t 要生成next[]数组的字符串,在KMP算法中是子串(模式串)
* @return 给定字符串的next数组
*/
private int[] next(String t) {
int len = t.length();
int[] next = new int[len];
next[0] = -1;
int k = 0;
int j = -1;
while (k < len - 1) {
// t.charAt(j)表示前缀,t.charAt(k)表示后缀
if (j == -1 || t.charAt(j) == t.charAt(k)) {
k++;
j++;
next[k] = j;
} else {
j = next[j];
}
}
return next;
}
算法原理:
这里是用模式串自己与自己匹配来得到模式串的各个子串的相似度
用一个比较明显的字符串 ABABABAC 来做例子
由于第一位没有前后缀,故相似度设为 0;从第二位开始比较,如图,B 与 A 失配,相似度为 0,所以向后移一位开始比较;
A 与 A 匹配,相似度为 1;再比较下一位,B 与 B 匹配,相似度 +1,为 2;再比较下一位…
到第 7 位时,C 和 B 失配,这时就用到了回溯!!!先看下一次比较时,应该移动到哪里。如上图;
因为在 C 和 B 失配时,有 5 个字符匹配,而这 5 个字符就是模式串的前五个字符!!!由上图可知,这 5 个字符的相似度为 3,所以下一次匹配的位置就是模式串的第 4 个字符,也就是 index=3 的位置,再比较字符是否匹配,这里是比较 C 和 B,失配,再回溯,这次前面剩下 ABA 三个字符,相似度为 1,即前后缀还有一个字符相同,所以应该后移到下图位置。
再比较 C 和 B,还不同,再回溯,再比较 C 和 A,这样就求出了相似度,next 数组也就知道了。
4.2.3 KMP 模式匹配算法实现
/**
* KMP 模式匹配算法
* @param source 主串
* @param target 模式串
* @return 若主串S中存在和串T值相同的子串,则返回
* 它在主串S中从pos个字符开始第一次出现的位置,否则返回-1
*/
public int KMPIndex(String source, String target) {
int i = 0;
int j = 0;
int[] next = next(target);
int lenS = source.length();
int lenT = target.length();
while (i < lenS && j < lenT) {
// 如果j = -1,或者当前字符匹配成功,都令i++,j++
if (j == -1 || source.charAt(i) == target.charAt(j)) {
i++;
j++;
} else {
// 如果j != -1,且当前字符匹配失败,则令 i 不变,j = next[j]
// next[j]即为j所对应的next值
j = next[j];
}
}
if (j == lenT)
return i - j;
else
return -1;
}
4.2.4 next 数组优化
后来有人发现,KMP 还是有缺陷的。比如,如果用之前的 next 数组方法求模式串 T=“abac” 的 next 数组,可得其 next 数组为 -1 0 0 1(0 0 1 2整体右移一位,初值赋为-1),当它跟下图中的文本串去匹配的时候,发现 b 跟 c 失配,于是模式串右移 j - next[j] = 3 - 1 = 2 位。
右移 2 位后,b 又跟 c 失配。事实上,因为在上一步的匹配中,已经得知 T[3] = b,与 S[3] = c 失配,而右移两位之后,让T[ next[3] ] = T[1] = b 再跟 S[3] 匹配时,必然失配。问题出在哪呢?
问题出在不该出现 T[j] = T[ next[j] ]。为什么呢?理由是:当T[j] != S[i] 时,下次匹配必然是 T[ next [j] ] 跟 S[i] 匹配,如果 T[j] = T[ next[j] ],必然导致后一步匹配失败(因为 T[j]已经跟 S[i] 失配,然后你还用跟 T[j] 等同的值 T[ next[j] ] 去跟 S[i] 匹配,很显然,必然失配),所以不能允许 T[j] = T[ next[j] ]。如果出现了 T[j] = T[ next[j] ] 咋办呢?如果出现了,则需要再次回溯,即令next[j] = next[ next[j] ]。
/**
* next数组优化
* @param t 要生成next[]数组的字符串,在KMP算法中是子串(模式串)
* @return 给定字符串的next数组
*/
private int[] nextval(String t) {
int len = t.length();
int[] next = new int[len];
next[0] = -1;
int k = 0;
int j = -1;
while (k < len - 1) {
// t.charAt(j)表示前缀,t.charAt(k)表示后缀
if (j == -1 || t.charAt(j) == t.charAt(k)) {
k++;
j++;
// 如果t.charAt(k) == t.charAt(j),继续向前回溯
// 较之前next数组求法,改动在下面4行
if (t.charAt(k) == t.charAt(j))
next[k] = next[j];
else
next[k] = j;
} else {
j = next[j];
}
}
return next;
}
5. 总结
最后提一下两种算法的时间复杂度。设主串与子串长度分别为 n 和 m,BF 算法在最坏的情况下,每一趟不成功的匹配都是在子串的最后的一个字符与主串中相应的字符匹配失败,即在最坏情况下时间复杂度为:O(n×m)。最好的情况是,子串所有字符匹配成功之前的所有次不成功匹配都是子串的第一个字符与主串相应的字符不相同,可得 O(n+m)。对于 KMP 算法的时间复杂度为:匹配过程的时间复杂度为 O(n),算上计算 next 的 O(m) 时间,KMP 的整体时间复杂度为 O(m + n)。现在回过头来看 KMP 算法,只要抓住 主串不回溯,子串往远移 这句话,尽量减少主串与子串的匹配次数以达到快速匹配的目的,问题就能迎刃而解了。具体的实现关键在于如何实现一个 next 数组,这个数组本身包含了子串的局部匹配信息。当然还有其他的串的模式匹配算法,如BM、Sunday等字符串查找算法。