字符串简称串,计算机上非数值处理的对象基本都是字符串数据。我们常见的信息检索系统 (如搜索引擎)、文本编辑程序(如Word)、问答系统、自然语言翻译系统等,都是以字符串数据作为处理对象的。本章详细介绍字符串的存储结构及相应的操作。
串(string)是由零个或多个字符组成的有限序列。一般记为:
S = ′ a 1 a 2 ⋯ a n ′ ( n ≥ 0 ) S='a_1 a_2 \cdots a_n' \quad(n \ge 0) S=′a1a2⋯an′(n≥0)
其中, S S S 是串名,单引号括起来的字符序列是串的值; a i a_i ai可以是字母、数字或其他字符;串中字符的个数 n n n称为串的长度。 n = 0 n=0 n=0时的串称为空串(用$\emptyset $表示)
串中任意多个连续的字符组成的子序列称为该串的子串,包含子串的串称为主串。某个字符在串中的序号称为该字符在串中的位置。子串在主串中的位置以子串的第一个字符在主串中的位置来表示。当两个串的长度相等且每个对应位置的字符都相等时,称这两个串是相等的。
例如,有串 A = ′ C h i n a B e i j i n g ′ A='China \quad Beijing' A=′ChinaBeijing′, B = ′ B e i j i n g ′ B='Beijing' B=′Beijing′, C = ′ C h i n a ′ C='China' C=′China′,则它们的长度分别为13,7 和 5。 B B B和 C C C是 A A A的子串, B B B在 A A A中的位置是 7, C C C在 A A A中的位置是 1。
需要注意的是,由一个或多个空格(空格是特殊字符)组成的串称为空格串(注意,空格串不是空串),其长度为串中空格字符的个数。
串的逻辑结构和线性表极为相似,区别仅在于串的数据对象限定为字符集。在基本操作上, 串和线性表有很大差别。线性表的基本操作主要以单个元素作为操作对象,如查找、插入或删除某个元素等;而串的基本操作通常以子串作为操作对象,如查找、插入或删除一个子串等。
1.定长顺序存储表示
类似于线性表的顺序存储结构,用一组地址连续的存储单元存储串值的字符序列。在串的定长顺序存储结构中,为每个串变量分配一个固定长度的存储区,即定长数组
#define MAXLEN 255 //预定义最大串长为255
struct SString {
char ch[MAXLEN]; //每个分量存储一个字符
int length; //串的实际长度
};
串的实际长度只能小于等于MAXLEN,超过预定义长度的串值会被舍去,称为截断。串长有两种表示方法:
2.堆分配存储表示
在一些串的操作(如插入、联接等)中,若串值序列的长度超过上界 MAXLEN,约定用"截断"法处理,要克服这种弊端,只能不限定串长的最大长度,即采用动态分配的方式。
堆分配存储表示仍然以一组地址连续的存储单元存放串值的字符序列,但它们的存储空间是在程序执行过程中动态分配得到的。
struct HString {
char *ch; //按串长分配存储区,ch指向串的基地址
int length; //串长度
};
3.块链存储表示
类似于线性表的链式存储结构,也可采用链表方式存储串值。由于串的特殊性(每个元素只有一个字符),在具体实现时,每个结点既可以存放一个字符,也可以存放多个字符。每个结点称为块,整个链表称为块链结构。图(a)是结点大小为4 (即每个结点存放4个字符)的链表,最后一个结点占不满时通常用"#"补上;图(b)是结点大小为1的链表。
子串的定位操作通常称为串的模式匹配,它求的是子串(常称模式串)在主串中的位置。这里采用定长顺序存储结构,给出一种不依赖于其他串操作的暴力匹配算法。
下图展示了模式T='abcac'
和主串S
的匹配过程,每次匹配失败后,都把模式T
后移一位。算法思想为:从主串S
的第一个字符起,与模式T
的第一个字符比较, 若相等,则继续逐个比较后续字符;否则从主串的下一个字符起,重新和模式的字符比较;以此类推,直至模式T
中的每个字符依次和主串S
中的一个连续的字符序列相等,则称匹配成功,函数值为与模式T
中第一个字符相等的字符在主串S
中的序号,否则称匹配不成功,函数值为零。
简单模式匹配算法的最坏时间复杂度为 O ( n m ) O(nm) O(nm),其中 n n n 和 m m m 分别为主串和模式串的长度。例如,当模式串为 0000001
,而主串为0000000000000000000000000000000000000000000001
时,由于模式中前6个字符均为"0", 主串中前45个字符均为"0",每趟匹配都是比较到模式的最后一个字符时才发现不等,指针 i i i 需回溯 40 40 40 次,总比较次数为 40 × 7 = 280 40×7=280 40×7=280次。
简单模式匹配代码实现如下:
#include
#include
using namespace std;
#define MAXLEN 255 //预定义最大串长为255
struct SString {
char ch[MAXLEN]; //每个分量存储一个字符
int length; //串的实际长度
};
//S为主串 T为子串
int Index(SString S, SString T) {
int i = 1, j = 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; //匹配成功
else
return 0; //匹配失败
}
int main() {
SString S, T;
strcpy(S.ch, "#ababcabcacdab");
S.length = 13;
strcpy(T.ch, "#abcac");
T.length = 5;
cout << Index(S, T) << endl;
return 0;
}
运行结果:
6
上一节简单模式匹配过程中,在第三趟匹配中, i = 7 、 j = 5 i=7、j=5 i=7、j=5 的字符比较不等,于是又从 i = 4 、 j = 1 i=4、j=1 i=4、j=1 重新开始比较。然而,仔细观察会发现, i = 4 i=4 i=4 和 j = 1 j=1 j=1, i = 5 i=5 i=5 和 j = 1 j=1 j=1 及 i = 6 i=6 i=6 和 j = 1 j=1 j=1 这三次比较都是不必进行的。从第三趟部分匹配的结果可知,主串中第4、5和6个字符'b'
是'c'
和'a'
,(即模式中第2、3和4个字符),因为模式中第一个字符是'a'
,因此它无须再和这3个字符进行比较,而仅需将模式向右滑动3个字符的位置,继续进行 i = 7 i=7 i=7、 j = 2 j=2 j=2 时的比较即可。
在暴力匹配中,每趟匹配失败都是模式后移一位再从头开始比较。而某趟已匹配相等的字符序列是模式的某个前缀,这种频繁的重复比较相当于模式串在不断地进行自我比较,这就是其低效率的根源。因此,可以从分析模式本身的结构着手,如果已匹配相等的前缀序列中有某个后缀正好是模式的前缀,那么就可以将模式向后滑动到与这些相等字符对齐的位置,主串 i i i 指针无须回溯,并从该位置开始继续比较。而模式向后滑动位数的计算仅与模式本身的结构有关,与主串无关(这里理解起来会比较困难,没关系,带着这个问题继续往后看)。
1.字符串的前缀、后缀和部分匹配值
要了解子串的结构,首先要弄清楚几个概念:前缀、后缀和部分匹配值。
下面以’ababa’为例进行说明:
故字符串'ababa'
,的部分匹配值为00123
这个部分匹配值有什么作用呢?
回到最初的问题,主串为a b a b c a b c a c b a b
,子串为a b c a c
。
利用上述方法容易写出子串'abcac'
的部分匹配值为00010
,将部分匹配值写成数组形式, 就得到了部分匹配值 ( P a r t i a l M a t c h , P M ) {\rm(Partial Match, PM)} (PartialMatch,PM)的表。
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | b | c | a | c |
PM | 0 | 0 | 0 | 1 | 0 |
下面用 P M {\rm PM} PM表来进行字符串匹配:
(1)第一趟匹配过程∶
发现c
与a
不匹配,前面的2个字符'ab'
是匹配的,查表可知,最后一个匹配字符b
对应的部分匹配值为 0,因此按照下面的公式算出子串需要向后移动的位数:
移动位数 = 已匹配的字符数 − 对应的部分匹配值 {\bf 移动位数=已匹配的字符数-对应的部分匹配值} 移动位数=已匹配的字符数−对应的部分匹配值
因为2 - 0 = 2
,所以将子串向后移动 2 位,然后进行第二趟匹配。
(2)第二趟匹配过程:
发现c
与b
不匹配,前面4 个字符'abca'
是匹配的,最后一个匹配字符a
对应的部分匹配值为 1,4 - 1 = 3
,将字串向后移动3位,然后进行第三趟匹配:
(3)第三趟匹配过程:
子串全部比较完成,匹配成功。整个匹配过程中,主串始终没有回退,故 K M P {\rm KMP} KMP算法可以在 O ( n + m ) O(n+m) O(n+m)的时间数量级上完成串的模式匹配操作,大大提高了匹配效率。
某趟发生失配时,如果对应的部分匹配值为0,那么表示已匹配相等序列中没有相等的前后缀,此时移动的位数最大,直接将子串首字符后移到主串 i i i 位置进行下一趟比较;如果已匹配相等序列中存在最大相等前后缀(可理解为首尾重合),那么将子串向右滑动到和该相等前后缀对齐(这部分字符下一趟显然不需要比较),然后从主串 i i i 位置进行下一趟比较。
2.KMP算法的原理是什么:
我们刚刚学会了怎样计算字符串的部分匹配值、怎样利用子串的部分匹配值快速地进行字符串匹配操作,但公式 “移动位数=已匹配的字符数-对应的部分匹配值” 的意义是什么呢
如图(a)所示,当c
与b
不匹配时,已匹配'abca'
的前缀a
和后缀a
为最长公共元素。已知前缀a
与b、c
均不同,与后缀a
相同,故无须比较,直接将子串移动**“已匹配的字符数-对应的部分匹配值”**,用子串前缀后面的元素与主串匹配失败的元素开始比较即可,如图(b)所示。
对算法的改进方法:
已知:右移位数 = 已匹配字符数 - 对应的部分匹配值
写成: M o v e = ( j − 1 ) − P M [ j − 1 ] {\rm Move=(j-1)-PM[j-1]} Move=(j−1)−PM[j−1]。
使用部分匹配值时,每当匹配失败,就去找它前一个元素的部分匹配值,这样使用起来有些不方便,所以将 P M {\rm PM} PM表右移一位,这样哪个元素匹配失败,直接看它自己的部分匹配值即可。
将上例中字符串'abcac'
的 P M {\rm PM} PM表右移一位,就得到了 n e x t {\rm next} next数组:
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | b | c | a | c |
next | -1 | 0 | 0 | 0 | 1 |
我们注意到:
这样,上式就改写为:
M o v e = ( j − 1 ) − n e x t [ j ] {\rm Move=(j-1)-next[j]} Move=(j−1)−next[j]
相当于将子串的比较指针j
回退到
j = j − M o v e = j − ( ( j − 1 ) − n e x t [ j ] ) = n e x t [ j ] + 1 {\rm j=j-Move=j-((j-1)-next[j])=next[j]+1 } j=j−Move=j−((j−1)−next[j])=next[j]+1
有时为了使公式更加简洁、计算简单,将 n e x t {\rm next} next数组整体 +1 。
因此,上述子串的 n e x t {\rm next} next数组也可以写成
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | b | c | a | c |
next | 0 | 1 | 1 | 1 | 2 |
最终得到子串指针变化公式j = next[j]
。在实际匹配过程中,子串在内存里是不会移动的, 而是指针在变化,书中画图举例只是为了让问题描述得更加形象。next[j]
的含义是∶在子串的第j
个字符与主串发生失配时,则跳到子串的next[j]
位置重新与主串当前位置进行比较。
推理next数组的一般公式:
主串为 S 1 S 2 … S n S_1 S_2 \dots S_n S1S2…Sn,模式串为 P 1 P 2 … P m P_1 P_2 \dots P_m P1P2…Pm,当主串中第i
个字符与模式串中第j
个字符失配时,假设此时应与模式中第 k ( k < j ) { \rm k (k
′ P 1 P 2 … P k − 1 ′ = ′ P j − k + 1 P j − k + 2 … P j − 1 ′ {\rm 'P_1 P_2 \dots P_{k-1}'='P_{j-k+1} P_{j-k+2} \dots P_{j-1}'} ′P1P2…Pk−1′=′Pj−k+1Pj−k+2…Pj−1′
若存在满足如上条件的子串,则发生失配时,仅需将模式向右滑动至模式中第k
个字符和主串第i
个字符对齐,此时模式中前k-1
个字符的子串必定与主串中第i
个字符之前长度为k-1
的子串相等。由此,只需从模式第k
个字符与主串第i
个字符继续比较即可,如下图所示:
当模式串已匹配相等序列中不存在满足上述条件的子串时(可以看成k=1
),显然应该将模式串右移j-1
位,让主串第i
个字符和模式第一个字符进行比较,此时右移位数最大。
当模式串第一个字符(j=1
)与主串第i
个字符发生失配时,规定next[1]=0
将模式串右移一位,从主串的下一个位置(i+1
)和模式串的第一个字符继续比较。
通过上述分析可以得出next函数的公式:
n e x t [ j ] = { 0 j = 1 m a x { k ∣ 1 < k < j 且 ′ P 1 … P k − 1 ′ = ′ P j − k + 1 … P j − 1 ′ } 当此集合不空时 1 其他情况 {\rm next[j]= } \begin{cases} 0 & {\rm j=1 } \\ {\rm max \{ k|1
首先由公式可知:
n e x t [ 1 ] = 0 {\rm next[1]=0} next[1]=0
设 n e x t [ j ] = k {\rm next[j]=k} next[j]=k,此时 k {\rm k} k 应该满足的条件在上文已经描述过了。
此时 n e x t [ j + 1 ] = ? {\rm next[j+1]=?} next[j+1]=? 可能有两种情况:
(1) 若 P k = P j {\rm P_k=P_j} Pk=Pj ,则表明在模式串中:
′ P 1 … P k − 1 P k ′ = ′ P j − k + 1 … P j − 1 P j ′ {\rm 'P_1 \dots P_{k-1} P_k'='P_{j-k+1} \dots P_{j-1} P_j'} ′P1…Pk−1Pk′=′Pj−k+1…Pj−1Pj′
并且不可能存在 k ′ > k {\rm k'>k} k′>k满足上述条件,此时 n e x t [ j + 1 ] = k + 1 {\rm next[j+1]=k+1} next[j+1]=k+1 即:
n e x t [ j + 1 ] = n e x t [ j ] + 1 {\rm next[j+1]=next[j]+1} next[j+1]=next[j]+1
(2)若 P k ≠ P j {\rm P_k \ne P_j} Pk=Pj,则表明在模式串中:
′ P 1 … P k − 1 P k ′ ≠ ′ P j − k + 1 … P j − 1 P j ′ {\rm 'P_1 \dots P_{k-1} P_k' \ne 'P_{j-k+1} \dots P_{j-1} P_j'} ′P1…Pk−1Pk′=′Pj−k+1…Pj−1Pj′
此时可以把求 n e x t {\rm next} next 函数值的问题视为一个模式匹配的问题。用前缀 P 1 … P k {\rm P_1 \dots P_k} P1…Pk去跟后缀 P j − k + 1 … P j {\rm P_{j-k+1} \dots P_j} Pj−k+1…Pj匹配,则当 P k ≠ P j {\rm P_k \ne P_j} Pk=Pj时应将 P 1 … P k {\rm P_1 \dots P_k} P1…Pk 向右滑动至以第 n e x t [ k ] {\rm next [k]} next[k] 个字符与 P j {\rm P_j} Pj 比较,如果 P n e x t [ k ] {\rm P_{next[k]}} Pnext[k] 与 P j {\rm P_j} Pj 还是不匹配,那么需要寻找长度更短的相等前后缀,下一步继续用 P n e x t [ n e x t [ k ] ] {\rm P_{next[next[k]]}} Pnext[next[k]] 与 P j {\rm P_j} Pj 比较, 以此类推,直到找到某个更小的 k ′ = n e x t [ n e x t … [ k ] ] ( 1 < k ′ < k < j ) {\rm k'= next[next\dots[k]] \quad (1
′ P 1 … P k ’ ′ = ′ P j − k ′ + 1 … P j ′ {\rm 'P_1 \dots P_{k’}' = 'P_{j-k'+1} \dots P_j'} ′P1…Pk’′=′Pj−k′+1…Pj′
则: n e x t [ j + 1 ] = k ′ + 1 {\rm next[j+1]=k'+1} next[j+1]=k′+1。
也可能不存在任何 k ′ {\rm k'} k′ 满足上述条件,即不存在长度更短的相等前缀后缀,令 n e x t [ j + 1 ] = 1 {\rm next[j+1]=1} next[j+1]=1
下面举一个简单的next数组推理例子:
j | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
模式 | a | b | a | a | b | c | a | b | a |
next[j] | 0 | 1 | 1 | 2 | 2 | 3 | ? | ? | ? |
模式串中已经求得6个字符的next
值:
next[7]
,因为next[6]=3
,所以比较 P 6 {\rm P_6} P6和 P 3 {\rm P_3} P3('c'!='a'
),得到比较结果: P 6 ≠ P 3 {\rm P_6 \ne P_3} P6=P3;又因为next[3]=1
,所以比较 P 6 {\rm P_6} P6和 P 1 {\rm P_1} P1('c'!='a'
),得到比较结果: P 6 ≠ P 1 {\rm P_6 \ne P_1} P6=P1;又因为next[1]=0
,即不存在长度更短的相等前缀后缀,所以next[7]=1
。next[8]
,因为next[7]=1
,所以比较 P 7 {\rm P_7} P7和 P 1 {\rm P_1} P1,得到比较结果: P 7 = P 1 {\rm P_7 = P_1} P7=P1,所以next[8]=next[7]+1=2
。next[9]
,因为next[8]=2
,所以比较 P 8 {\rm P_8} P8和 P 2 {\rm P_2} P2,得到比较结果: P 8 = P 2 {\rm P_8 = P_2} P8=P2,所以next[9]=next[8]+1=3
。根据上述分析写出求next值的程序如下:
#define MAXLEN 255 //预定义最大串长为255
struct SString {
char ch[MAXLEN]; //每个分量存储一个字符
int length; //串的实际长度
};
//构造模式的next数组
void get_next(SString T, int next[]) {
int i = 1, j = 0;
next[1] = 0;
while (i < T.length)
if (j == 0 || T.ch[i] == T.ch[j]) {
++i;
++j;
next[i] = j; //若Pi=Pj 则next[j+1]=next[j]+1
} else
j = next[j]; //否则令j=next[j],循环继续
}
与next
数组的求解相比, K M P {\rm KMP} KMP的匹配算法相对要简单很多,它在形式上与简单的模式匹配算法很相似。不同之处仅在于当匹配过程产生失配时,指针i
不变,指针j
退回到 next[j]
的位置并重新进行比较,并且当指针j
为0时,指针i
和j
同时加1。即若主串的第i
个位置和模式串的第一个字符不等,则应从主串的第i+1
个位置开始匹配。具体代码如下:
//S为主串 T为子串
int Index_KMP(SString S, SString T, int next[]) {
int i = 1, j = 1;
while (i <= S.length && j <= T.length)
if (j == 0 || S.ch[i] == T.ch[j]) {
++i;
++j; //继续比较后续字符
} else {
j = next[j]; //模式串向右移动
}
if (j > T.length)
return i - T.length; //匹配成功
else
return 0;
}
完整代码如下:
#include
#include
using namespace std;
#define MAXLEN 255 //预定义最大串长为255
struct SString {
char ch[MAXLEN]; //每个分量存储一个字符
int length; //串的实际长度
};
//构造模式的next数组
void get_next(SString T, int next[]) {
int i = 1, j = 0;
next[1] = 0;
while (i < T.length)
if (j == 0 || T.ch[i] == T.ch[j]) {
++i;
++j;
next[i] = j; //若Pi=Pj 则next[j+1]=next[j]+1
} else
j = next[j]; //否则令j=next[j],循环继续
}
//S为主串 T为子串
int Index_KMP(SString S, SString T, int next[]) {
int i = 1, j = 1;
while (i <= S.length && j <= T.length)
if (j == 0 || S.ch[i] == T.ch[j]) {
++i;
++j; //继续比较后续字符
} else {
j = next[j]; //模式串向右移动
}
if (j > T.length)
return i - T.length; //匹配成功
else
return 0;
}
int main() {
SString S, T;
int next[MAXLEN];
strcpy(S.ch, "#ababcabcacdab");
S.length = 13;
strcpy(T.ch, "#abcac");
T.length = 5;
get_next(T, next);
cout << Index_KMP(S, T, next) << endl;
return 0;
}
运行结果
6
小结:
尽管普通模式匹配的时间复杂度是 O ( m n ) {\rm O(mn)} O(mn), K M P {\rm KMP} KMP算法的时间复杂度是 O ( m + n ) { \rm O(m+n) } O(m+n),但在一般情况下,普通模式匹配的实际执行时间近似为 O ( m + n ) { \rm O(m+n) } O(m+n),因此至今仍被采用。 K M P {\rm KMP} KMP算法仅在主串与子串有很多 “部分匹配” 时才显得比普通算法快得多,其主要优点是主串不回溯。