串
一、串的定义
字符串简称串(string),计算机上非数值处理的对象基本上都是字符串数据,它以由空格或多个字符组成的有限序列。
一般即为:其中,S是串名,单引号括起来的是串的值;
可以是字母、数字或者其他字符;串中字符的个数n称为串的长度。n=0时的串称为空串(用
表示)。空格串是指只包含空格的串。它与空串不同,空格串是有内容有长度的,而且可以不止一个空格。串中任意个数的连续字符组成的子序列称为该串的子串,相应地,包含子串的串称为主串。 子串在主串中的位置就是子串的第一个字符在主串中的序号。
串的逻辑结构和线性表极为相似,区别进在于串的数据对象限定为数据集,在基本操作上,串和线性表有很大区别。线性表的基本操作主要以单个元素为操作对象,如查找、插入或删除某个元素等;而串的基本操作通常以子串作为操作对象,如查找、插入或删除一个子串等。
二、串的存储结构
1、定长顺序存储表示
类似于线性表的顺序存储,用一组连续的存储单元存储串值的字符序列。在串的定长顺序存储结构中,为每个串变量分配一个固定长度的存储区,即定长数组。
#define MAXLEN 256 //预定义最大串长
typedef struct {
char ch[MAXlEN]; //每个变量存储一个字符
int length; //串的实际长度
}SString;
串的实际长度只能小于或等于MAXLEN,超过预定义长度的串值会被舍去,称为截断。串长有两种表示方法:一如上述定义描述的那样,用一个额外的变量len来存放串的长度;二是在串值后面加一个不计入串长的字符“\0”,此时的串长为隐含值。
在一些串的操作(如插入、联接等)中,若串值序列的长度超过上界MAXLEN,约定用“截断法”处理,要克服这种弊端,只能不限制串长的最大长度,即采用动态分配的方式。
2、堆分配存储表示
typedef struct {
char *ch; //按串长分配存储区,ch指向串的基地址
int length; //串的长度
} HString;
在C语言中,存在一个称之为堆的自由存储区,并用malloc()和free()函数来完成动态存储管理。利用malloc()函数为每一个新产生的串分配一块实际串长所需的存储空间,若分配成功,则返回一个指向起始地址的指针,作为串的基地址,这个串有ch指针来指示;若分配失败,则返回NULL。已分配的空间可用free()函数释放掉。
3、块链存储表示
类似于线性表的链式存储结构,也可采用链表方式存储串值。由于串的特殊性,在具体实现时,每个节点既可以存放一个字符,也可以存放多个。每个节点称为块,故整个链表也被称为块链结构。
三、串的基本操作
StrAssign(&T,chars) :赋值操作。把串T的值赋值为chars
StrCopy(&T, S):由串S复制得到串T。
StrEmpty(S): 判空操作。若S为空串,则返回true,否则返回false。
StrCompare(S, T):比较操作。若S>T,则返回值1,若S=T,则返回值0,若S<T,则返回值-1。
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销毁。
当然不同的高级语言对串的基本操作的定义不尽相同。在上述的定义操作中,串赋值StrAssign、串比较StrCompare、求串长StrLength、串联接Concat、及求子串SubString五种操作构成串类型的最小操作集,即这些操作不可能通过其他串操作来实现;反之,其他串操作(除串清除ClearString和串销毁DestroyString)都可以在该最小操作子集上来实现。
例如,可以利用比较操作,求子串和子串长的操作来实现Index(S, T)
int Index(S, T) {
int i=1,n=StrLength(S),m=StrLength(T);
while (i < n-m+1) {
SubString(Sub, S, , m);
if (StrCompare(Sub, T)!= 0) {
++i; //返回子串在主串中第一个元素的位置
} else {
return i;
}
return 0; //也就是主串中不存在这样的子串
}
四、串的模式匹配
1、简单的模式匹配
子串的定位操作通常成为串的模式匹配,它求的是子串在主串中的位置。这里采取定长顺序存储,给出一种不依赖于其他串操作的暴力匹配算法。
Index(SString S, SString T) {
int i=1, j=1;
while (j <= T.length && i <= S.length) {
if (S[i] == T[j]) {
i++; //继续比较后续字符
j++;
} else {
i = i-j+1; //指针后退重新开始匹配
j=1;
}
}
if (j > T.length) return i - T.length;
else return 0;
简单模式的思路比较简单,拿上述算法来说,就是从主串第一个元素开始与模式串T的第一个字符比较,若相等,则继续比较后续字符;否测,就从主串的下一个字符开始比较。结束条件是模式串T的最后一个元素与主串的某个元素比较完成,模式串也就是主串中的一个连续子序列;否则匹配失败,返回0。
2、KMP算法
在上述的简单匹配过程中,每次匹配失败的话,都是模式串后移一位再从头开始比较。而如果说某趟已匹配相等的字符序列是模式串的某个前缀,再从模式串后移一位再比较的话,就会造成频繁的反复比较,这也导致了简单模式匹配算法效率很低。因此,可以从分析模式串本身的结构入手,若已匹配相等的前缀序列中有某个后缀正好是模式串的前缀,则可将模式串向后滑动到与这些相等字符对齐的位置,主串的i不必回溯,并从该位置开始继续比较。而模式串向后滑动位数的计算仅与模式串本身的结构有关。而模式串向后滑动位数仅与模式串本身结构有关,而与主串无关。简而言之,就是通过寻找模式串自身结构的特点,来提高字符串匹配的效率。
(1)字符串的前缀、后缀和最大公共前后缀长度
在KMP算法中只是模式串后移,而指针不回溯,从而提高模式串匹配的效率。正如前文提到的,KMP算法与子串,也就是模式串的结构有重要关系,那么,我们该如何去了解认识我们子串呢?下面将介绍几个概念,便于我们理解子串的结构。
前缀:除了最后一个字符以外,字符串所有的头部子串
后缀:除第一个字符外,字符串所有的尾部子串
部分匹配值:字符串的前缀和后缀的最长相等前后缀长度。
下面呢,我将以为例来进行说明:
的前缀和后缀都为空集,最长相等前后缀长度为0。
的前缀为
,后缀为
,
,最长相等前后缀的长度为0
......
的前缀为
,后缀为
,最长相等前后缀长度为1
因此的部分匹配值为000121
这个部分匹配值的作用是什么呢?考察主串ababcabcacbab,子串为abcac,利用上述方法容易写出子串‘abcac’的部分匹配值为00010,将部分匹配值写成数组形式,就得到了部分匹配值(Partial Match,PM)的表
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | b | c | a | c |
PM | 0 | 0 | 0 | 1 | 0 |
下面用PM表来进行字符串匹配:
主串:a b a b c a b c a c b a b
子串:a b c
第一趟匹配过程:发现c与a不匹配,前面两个字符ab是匹配的,查表可知,最后一个匹配字符b对应的部分匹配值为0,因此按照下面的公式计算出子串需要向后移动的位数:
移动位数 = 已匹配字符数 - 对应的部分匹配值
因为2 - 0 = 2,所以将子串向后移动两位,如下进行第二次匹配:
主串:a b a b c a b c a c b a b
子串: a b c a c
第二趟匹配过程:发现c与b不匹配,前面4个字符abca是匹配的,最后一个匹配字符a对应的部分匹配值是1,因为4-1=3,因此需要将子串向后移动3位,然后进行第三次匹配:
主串:a b a b c a b c a c b a b
子串: a b c a c
第三趟匹配过程:子串全部比较完成,匹配成功。整个匹配过程中,主串始终没有回退,所以KMP算法在o(m+n)的时间数量级上完成串的模式匹配操作,大大提高了时间的效率。
某趟发生失配时,若对应的部分匹配值为0,则表示已匹配相等序列中没有相等的前后缀,此时移动的位数最大,直接将子串首字符后移到主串当前位置进行下一次比较;若已匹配相等序列中存在最大相等前后缀,则将子串向右滑动到和挂相等前后缀对齐,然后从主串当前位置进行下一次比较。
(2)算法的原理
已知: 右移位数 = 已匹配字符数 - 对应的部分匹配值
写成: Move = (j-1) - PM[j-1]
使用部分匹配值时,每当匹配失败,就去找它前一个元素的部分匹配值,这样使用起来有些不方便,所以将PM表右移一位,这样哪个元素匹配失败,直接看它自己的部分匹配值即可。将上例中字符串'abcac'的PM表向右移动一位,就得到了next数组:
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | b | c | a | c |
PM | -1 | 0 | 0 | 0 | 1 |
我们注意到:
(1)第一个元素右移以后空缺的用-1来填充,因为若是第一个元素匹配失败,则需要将子串向右移动一位,而不需要计算子串移动的位数。
(2)最后一个元素在右移的过程中溢出,因为原来的子串中,最后一个元素的部分匹配值是下一个元素使用的,但显然没有在一个元素,所以可以舍去。
这样,上式就改写为
Move = (j-1)-next[j]
相等于将子串的比较指针回退到
j = j - Move = j - ((j-1) - next[j]) = next[j] + 1
有时,为了使公式简洁,会将next数组整体加1。因此,next数组就变成:
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | b | c | a | c |
PM | 0 | 1 | 1 | 1 | 2 |
最终得到子串指针变化公式 j = next[j]。在实际匹配过程中,子串在内存中是不会移动的,而是指针发生变化。next[j]的含义是:在子串的第j个字符与主串发生失配时,则跳到子串的next[j]位置重新与主串当前位置进行比较。
如何推理出next数组的一般公式呢?设主串,模式串
,当主串中第i个字符与模式串中第j个字符失配时,子串应向右滑动多远,然后与模式中哪个字符比较?
假设此时应与模式串中第k(k<j)个字符继续比较,则模式串中前k个字符必须满足下列条件,且不可能存在满足下列条件:
=
若存在满足如上条件的子串,若发生失配时,仅需将模式串向右滑动至模式串的第k个字符为主串的第i个字符,此时模式串中的前k-1个字符的子串必定与主串中的第i个字符之前长度为k-1的子串相等,由此,只需从模式串的第k个字符与主串的第i个字符继续比较即可。
当模式串已匹配相等序列中不存在满足上述条件的子串时显然应该将模式串右移j-1位,让主串的第i个字符和模式串的第一个字符进行比较,此时右移位数最大。
当模式串的第一个字符(j=1)与主串的第i个字符发生失配时,规定next[1]=0。将模式串右移
一位,从主串的下一个位置(i+1)和模式串的第一个字符继续比较:
通过上述分析可以得出next函数的公式:
如何用代码实现呢?尝试用科学步骤来进行推理一下。
首先由公式可知
next[1] = 0
设next[j] = k,此时k应满足的条件上文已经给出,不过此时next[j+1]还是未知的,可能会有两种情况:
(1)若,则表明在模式串中
=
并且不可能存在满足上述条件,此时
即
(2)若,则表明在模式串中
!=
此时可将求next函数值的问题视为一个模式匹配的问题,当时,应将
向右滑动至以next[k]个字符与
比较,若
与
扔不匹配,则需要寻找长度更短的相等前后缀,下一步用
与
比较,以此类推,直到找到某个更小的某个
,吗、满足条件
=
则next[j+1] = k1+1
当然,也可能不存在任何的k1满足上述条件,这时令next[j+1] = 1即可
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数组的求解相比,KMP算法的效率相对要简单很多,他从形式上与简单的模式匹配很相似。不同之处进在于当匹配过程失配时,指针i不变,指针j退回到next[j]的位置并重新进行比较,并且当指针j为0时,指针i和j同时加一。即若主串的第i个位置与模式串的第一个字符不等的话,那么就从第i+1个位置进行匹配。具体实现代码如下:
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 false;
}
尽管普通模式匹配的时间复杂度是O(mn), KMP算法的时间复杂度是O(m+n),但在一般情况下,普通模式匹配的实际执行时间近似为O(m+n)。KMP算法仅在主串与子串有很多“部分匹配”时才显得比普通算法快得多,其主要优点是主串不回溯。
3、KMP算法的进一步优化
主要体现的模式串next数组的变化。对于模式串中有较多元素重复的模式串,比如“aaaab”,当第j=4个a与主串元素不匹配时,模式串会回溯到next[j]=3的位置进行匹配,我们知道模式串的前4个元素都是a,也就失去了继续比较的意义。我们做的改进是当第next[j]与j个元素相等时,寻找下一个next[next[j]]个元素,如果不相等的话,就将新的next值,这里不妨就用nextval数组来存储吧,就把不相等时对应的next值存放在nextval数组中。从而省去一些不必要的比较,提高我们算法的时间复杂度
int get_nextval(SString T, int nextval[]) {
int i = 1, j=1;
while(i <= T.length && j <= T.length) {
if (j == 0 || S.ch[i]==T.ch[j]) {
i++;j++; //继续往后比较其他字符
if (T.ch[i] != T.ch[j])
nextval[i] = j
else
nextval[i] = nextval[j]; //模式串netx[j]对应元素与j对应元素相等
}
else
j = nextval[j]; //比较不相等的话,模式串往后移
}
参考书目
2025王道考研复习指导