目录
串
【知识框架】
一、串的定义
串( string)是由零个或多个字符组成的有限序列,又名叫字符串。
一般记为:
S
=
′
a
1
a
2
.
.
.
a
n
′
(
n
>
=
0
)
S='a_1a_2...a_n '(n>=0)
S=′a1a2...an′(n>=0)。
其中,
S
S
S是串名,单引号括起来的字符序列是串的值;
a
n
a_n
an 可以是字母、数字或其他字符;串中字符的个数
n
n
n称为串的长度。
另外还有一些其它概念:
- 空串: n = 0 n=0 n=0时的串称为空串。
- 空格串:是只包含空格的串。注意它与空串的区别,空格串是有内容有长度的,而且可以不止一个空格。
- 子串与主串:串中任意个数的连续字符组成的子序列称为该串的子串,相应地,包含子串的串称为主串。
- 子串在主串中的位置就是子串的第一个字符在主串中的序号。
串的逻辑结构和线性表极为相似,区别仅在于串的数据对象限定为字符集。在基本操作上,串和线性表有很大差别。线性表的基本操作主要以单个元素作为操作对象,如查找、插入或删除某个元素等;而串的基本操作通常以子串作为操作对象,如查找、插入或删除一个子串等。
二、串的存储结构
1、定长顺序存储表示
类似于线性表的顺序存储结构,用一组地址连续的存储单元存储串值的字符序列。在串的定长顺序存储结构中,为每个串变量分配一个固定长度的存储区,即定长数组。
#define MAXLEN 255 //预定义最大串长为255
typedef struct{
char ch[MAXLEN]; //每个分量存储一个字符
int length; //串的实际长度
}SString;
串的实际长度只能小于等于MAXLEN,超过预定义长度的串值会被舍去,称为截断。串长有两种表示方法: 一是如上述定义描述的那样,用一个额外的变量len来存放串的长度;二是在串值后面加一一个不计入串长的结束标记字符“\0”,此时的串长为隐含值。
在一些串的操作(如插入、联接等)中,若串值序列的长度超过上界MAXLEN,约定用“截断”法处理,要克服这种弊端,只能不限定串长的最大长度,即采用动态分配的方式。
2、堆分配存储表示
堆分配存储表示仍然以一组地址连续的存储单元存放串值的字符序列,但它们的存储空间是在程序执行过程中动态分配得到的。
typedef struct{
char *ch; //按串长分配存储区,ch指向串的基地址
int length; //串的长度
}HString;
在C语言中,存在一一个称之为“堆”的自由存储区,并用malloc()和free()函数来完成动则返回一个指向起始地址的指针,作为串的基地址,这个串由ch指针来指示;若分配失败,则返回NULL。已分配的空间可用free()释放掉。
上述两种存储表示通常为高级程序设计语言所采用。块链存储表示仅做简单介绍。
3、块链存储表示
类似于线性表的链式存储结构,也可采用链表方式存储串值。由于串的特殊性(每个元素只有一个字符),在具体实现时,每个结点既可以存放一个字符, 也可以存放多个字符。每个结点称为块,整个链表称为块链结构。图(a)是结点大小为4 (即每个结点存放4个字符)的链表,最后一个结点占不满时通常用“#”补上;图(b)是结点大小为1的链表。
三、串的基本操作
- 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。
- StrEngth(S): 求串长。返回串S的元素个数
- Substring(&Sub,S,pos,1en):求子串。用Sub返回串S的第pos个字符起长度为len的子串。
- Concat(&T,S1,S2): 串联接。用T返回由S1和S2联接而成的新串。
- Index(S,T): 定位操作。若主串S中存在与串T值相同的子串,则返回它在主串S中第一次出现的位置;否则函数值为0
- Clearstring(&S): 清空操作。将S清为空串
- Destroystring(&S): 销毁串。将串S销毁
不同的高级语言对串的基本操作集可以有不同的定义方法。在上述定义的操作中,串赋值StrAssign、串比较 StrCompare、求串长 Strength、串联接 Concat及求子串 Substring五种操作构成串类型的最小操作子集,即这些操作不可能利用其他串操作来实现;反之,其他串操作(除串清除 Clearstring和串销毁 Destroystring外)均可在该最小操作子集上实现。
例如,可利用判等、求串长和求子串等操作实现定位函数 Index(S,T)。
int Index(Sring S, String T){
int i = 1, n = StrLength(S), m = StrLength(T);
String sub;
while(i <= n-m+1){
SubString(sub, S, i, m); //取主串第i个位置,长度为m的串给sub
if(StrCompare(sub, T) != 0){
++i;
}else{
return i; //返回子串在主串中的位置
}
}
return 0; //S中不存在与T相等的子串
}
四、串的模式匹配(重点)
1、简单的模式匹配算法
子串的定位操作通常称为串的模式匹配,它求的是子串(常称模式串)在主串中的位置。这里采用定长顺序存储结构,给出一种不依赖于其他串操作的暴力匹配算法。
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;
}
}
下图展示了模式串
T
=
′
a
b
c
a
c
′
T = 'abcac'
T=′abcac′和主串
S
S
S的匹配过程
简单的模式匹配算法的最坏时间复杂度为 O ( n m ) O(nm) O(nm),其中 n n n和 m m m分别为主串和模式串的长度。
2、KMP算法
在上面的简单匹配中,每趟匹配失败都是模式后移一位再从头开始比较。而某趟已匹配相等的字符序列是模式的某个前缀,这种频繁的重复比较相当于模式串在不断地进行自我比较,这就是其低效率的根源。
因此,可以从分析模式本身的结构着手,如果已匹配相等的前缀序列中有某个后缀正好是模式的前缀,那么就可以将模式向后滑动到与这些相等字符对齐的位置,主串i指针无须回溯,并继续从该位置开始进行比较。而模式向后滑动位数的计算仅与模式本身的结构有关,与主串无关。
KMP算法的特点就是:仅仅后移模式串,比较指针不回溯。很是牛掰。
(1)字符串的前缀、后缀和最大公共前后缀长度
要了解子串的结构,首先要弄清楚几个概念:前缀、后缀和部分匹配值。前缀指除最后一个字符以外,字符串的所有头部子串;后缀指除第一个字符外,字符串的所有尾部子串;部分匹配值则为字符串的前缀和后缀的最大公共前后缀长度。下面以 ′ a b a b a ′ ' ababa' ′ababa′为例进行说明
- ′ a ′ 'a' ′a′ 的前缀和后缀都为空集最大公共前后缀长度长度为0。
- ′ a b ′ 'ab' ′ab′ 的前缀为 { a } \{a\} {a} ,后缀为 { b } , { a } ∩ { b } = N U L L \{b\}, \{a\}∩\{b\}=NULL {b},{a}∩{b}=NULL,最大公共前后缀长度长度为0。
- ′ a b a ′ 'aba' ′aba′ 的前缀为 { a , a b } \{a,ab\} {a,ab}, 后缀为 { a , b a } , { a , a b } ∩ { a , b a } = { a } \{a,ba\}, \{a,ab\}∩\{a,ba\} =\{a\} {a,ba},{a,ab}∩{a,ba}={a}, 最大公共前后缀长度长度为1
- ′ a b a b ′ 'abab' ′abab′ ,前缀∩后缀, { a , a b , a b a } ∩ { b , a b , b a b } = { a b } \{a, ab, aba\} ∩\{b, ab ,bab\}=\{ab\} {a,ab,aba}∩{b,ab,bab}={ab},最大公共前后缀长度长度为2。
- ′ a b a b a ′ 'ababa' ′ababa′ ,前缀∩后缀, { a , a b , a b a , a b a b } ∩ { a , b a , a b a , b a b a } = { a , a b a } \{a, ab, aba, abab\} ∩ \{ a, ba, aba, baba\}=\{ a, aba\} {a,ab,aba,abab}∩{a,ba,aba,baba}={a,aba}, 公共元素有两个,最大公共前后缀长度长度为3。
故字符串
′
a
b
a
b
a
′
'ababa'
′ababa′的最大公共前后缀长度为00123。
这个值有什么作用呢?
回到最初的问题,主串为
′
a
b
a
c
a
b
c
a
c
b
a
b
′
'abacabcacbab'
′abacabcacbab′,子串为
′
a
b
c
a
c
′
'abcac'
′abcac′。
利用上述方法容易写出子串
′
a
b
c
a
c
′
'abcac'
′abcac′的最大公共前后缀长度为00010,将最大公共前后缀长度值写成数组形式,就得到了最大公共前后缀长度(Partial match,PM)的表。
下面用PM表来进行字符串匹配:
第一趟匹配过程:
发现
c
c
c与
a
a
a不匹配,前面的2个字符
′
a
b
′
'ab'
′ab′是匹配的,查表可知,最后一个匹配字符
b
b
b对应的部分匹配值为0,因此按照下面的公式算出子串需要向后移动的位数:
移动位数
=
已匹配的字符数
−
对应最大公共前后缀长度
移动位数=已匹配的字符数 - 对应最大公共前后缀长度
移动位数=已匹配的字符数−对应最大公共前后缀长度
因为
2
−
0
=
2
2-0=2
2−0=2,所以将子串向后移动2位,如下进行第二趟匹配:
第二趟匹配过程:
发现
c
c
c与
b
b
b不匹配,前面4个字符
′
a
b
c
a
′
'abca'
′abca′是匹配的,最后一个匹配字符a对应的部分匹配值为1,
4
−
1
=
3
4-1=3
4−1=3,将子串向后移动3位,如下进行第三趟匹配:
第三趟匹配过程:
子串全部比较完成,匹配成功。整个匹配过程中,主串始终没有回退,故KMP算法可以在
O
(
n
+
m
)
O(n + m)
O(n+m)的时间数量级上完成串的模式匹配操作,大大提高了匹配效率。
(2)对算法的改进方法
使用部分匹配值时,每当匹配失败,就去找它前一个元素的部分匹配值,这样使用起来有些不方便,所以将PM表右移一位,这样哪个元素匹配失败,直接看它自己的部分匹配值即可。将上例中字符串
′
a
b
a
c
′
' abac'
′abac′的PM表右移一位,就得到了next数组:
有时为了使公式更加简洁、计算简单,将next数组整体+1。因此,next数组就变成:
最终得到子串指针变化公式
j
=
n
e
x
t
[
j
]
j=next[ j ]
j=next[j]。
next[ j ]的含义是:在子串的第j个字符与主串发生失配时,则跳到子串的next[ j ]位置重新与主串当前位置进行比较。
通过分析,可以知道,除第一个字符外,模式串中其余的字符对应的next数组的值等于其最大公共前后缀长度加上1
n e x t [ j ] = 最大公共前后缀长度 + 1 next[ j ] = 最大公共前后缀长度 + 1 next[j]=最大公共前后缀长度+1
科学的推导得出以下公式:
通过科学推论,我们可以写出求next数组的程序如下:
void get_next(String T, int *next){
int i = 1, j = 0;
next[1] = 0;
while (i < T.length){
if(j==0 || T.ch[i]==T.ch[j]){ //ch[i]表示后缀的单个字符,ch[j]表示前缀的单个字符
++i; ++j;
next[i] = j; //若pi = pj, 则next[j+1] = next[j] + 1
}else{
j = next[j]; //否则令j = next[j],j值回溯,循环继续
}
}
}
与next数组的求解相比,KMP算法就简单许多,和简单模式匹配算法很相似:
int Index_KMP(String S, String T){
int i=1, j=1;
int next[255]; //定义next数组
get_next(T, next); //得到next数组
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; //匹配成功
}else{
return 0;
}
}
(3)KMP算法的进一步优化
前面定义的next数组在某些情况下尚有缺陷,还可以进-步优化。如图4.7 所示,模式
′
a
a
a
a
b
′
' aaaab '
′aaaab′在和主串
′
a
a
a
b
a
a
a
a
a
b
′
' aaabaaaaab '
′aaabaaaaab′进行匹配时:
显然后面3次用一个和p4相同的字符跟S4比较毫无意义,必然失配。
比较毫无意义。那么如果出现了这种类型的应该如何处理呢?
如果出现了,则需要再次递归,将
n
e
x
t
[
j
]
next[j]
next[j]修正为
n
e
x
t
[
n
e
x
t
[
j
]
]
next [next[j]]
next[next[j]],直至两者不相等为止,更新后的数组命名为nextval。计算next数组修正值的算法如下,此时匹配算法不变。
void get_nextval(String T, int *nextval){
int i = 1, j = 0;
nextval[1] = 0;
while (i < T.length){
if(j==0 || T.ch[i]==T.ch[j]){ //ch[i]表示后缀的单个字符,ch[j]表示前缀的单个字符
++i; ++j;
if(T.ch[i] != T.ch[j]){ //若当前字符与前缀字符不同
nextval[i] = j; //则当前的j为nextval在i位置的值
}else{
//如果与前缀字符相同
//则将前缀字符的nextval值给nextval在i位置上的值
nextval[i] = nextval[j];
}
}else{
j = nextval[j]; //否则令j = next[j],j值回溯,循环继续
}
}
}
总结改进过的KMP算法,它是在计算出next值的同时,如果a位字符与它next值指向的b位字符相等,则该a位的nextval就指向b位的nextval值,如果不等,则该a位的nextval值就是它自己a位的next的值。
这块逻辑很简单,有了next数组,我们很容易就能推导出它的nextval数组了。
附录
上文链接
下文链接
专栏
参考资料
1、严蔚敏、吴伟民:《数据结构(C语言版)》
2、程杰:《大话数据结构》
3、王道论坛:《数据结构考研复习指导》
4、托马斯·科尔曼等人:《算法导论》