目录
1. 串的定义和实现
1.1 串的定义
1.2 串的存储结构
1.2.1 定长顺序存储表示
1.2.2 堆分配存储表示
1.2.3 块链存储表示
1.3 串的基本操作
2. 串的模式匹配
2.1 简单的模式匹配算法
2.2 串的模式匹配算法——KMP算法
2.2.1 字符串的前缀、后缀和部分匹配值
2.2.2 KMP算法的原理是什么?
2.2.3 KMP算法的进一步优化
2.3 相关练习
1. 串的定义和实现
字符串简称串,计算机上非数值处理的对象基本都是字符串数据。我们常见的信息检索系统(搜索引擎)、文本编辑程序(Word)、问答系统、自然语言编译系统等,都是以字符串数据作为处理对象的。
1.1 串的定义
串(String)是由零个或多个字符组成的有限序列。一般记为
S="a1a2a3a4……an"
其中,S是串名,单引号括起来的字符序列是串的值; 可以是字母、数字或其他字符;串中字符的个数 n 称为串的 长度。n =0 时的串 称为空串;
串中任意多个连续的字符组成的子序列称为该串的子串,包含子串的串称为主串。某个字符在串中的序号称为该字符在串中的位置。子串在主串中的位置以子串的第一个字符在主串中的位置来表示。当两个串的长度相等且每个对应位置的字符都相等时,称这两个串是相等的。
例如,有串A='China Beijing',B='Beijing',C='China',则它们的长度分别为13,7和5。B和C是A的子串,B在A中的位置是7,C在A中的位置是1。
需要注意的是,由一个或多个空格(空格是特殊字符)组成的串称为空格串(注意,空格串不是空串),其长度为串中空格字符的个数。
串的逻辑结构和线性表极为相似,区别仅在于串的数据对象限定为字符集。在基本操作上,串和线性表有很大差别。线性表的基本操作主要以单个元素作为操作对象,如查找、插入或删除某个元素等; 而串的基本操作通常以子串作为操作对象,如查找、插入或删除一个子串等。
1.2 串的存储结构
1.2.1 定长顺序存储表示
类似于线性表的顺序存储结构,用一组地址连续的存储单元存储串值的字符序列。在串的定义顺序存储结构中,为每个串变量分配一个固定长度的存储区,称为定长数组。
#define MAXLEN 255 //预定义最大串长为255
typedef struct
{
char ch[MAXLEN]; //每个分量存储一个字符
int length; //串的实际长度
}SString;
串的实际长度只能小于等于MAXLEN,超过预定义长度的串值会被舍去,称为截断。串长有两种表示方法:一是如上述定义描述的那样,用一个额外的变量 length 来存放串的长度;二是在串值后面加一个不计入串长的结束标记字符 “\0” ,此时的串长为隐含值。
在一些串的操作(如插入、联接等)中,若串值序列的长度超过上界 MAXLEN,约定用 “截断” 法处理,要克服这种弊端,只能不限定串长的最大长度,即采用动态分配的方式。
1.2.2 堆分配存储表示
堆分配存储表示仍然以一组地址连续的存储单元存放串值的字符序列,但是区别于定长顺序存储的地方是堆分配存储的存储空间是在程序执行过程中动态分配得到的。
typedef struct
{
char *ch; //按串长分配存储区,ch 指向串的基地址
int length; //串的长度
}HString;
在C语言中,存在一个称之为 “堆” 的自由存储区,并用 malloc() 和 free() 函数来完成动态存储管理。利用 malloc() 为每个新产生的串分配一块实际串长所需的存储空间,若分配成功,则返回一个指向起始地址的指针,作为串的基地址,这个串由ch指针来指示;若分配失败,则返回NULL。已分配的空间可用 free ()释放掉。
1.2.3 块链存储表示
类似于线性表的链式存储结构,也可采用链表方式存储串值。由于串的特殊性(每个元素只有一个字符),在具体实现时,每个结点既可以存放一个字符,也可以存放多个字符。每个结点
称为块,整个链表称为块链结构。
如下图 a,每个结点大小为4,每个结点存放四个字符,最后一个结点占不满时通常用 “#” 补上; 图 b,每个结点大小为1;
1.3 串的基本操作
- 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 销毁。
//求子串
#define MAXSIZE 255 //预定义最大串长为255
typedef struct
{
char ch[MAXSIZE]; //每个分量存储一个字符
int length; //串的实际长度
}SString;
bool SubString(SString &Sub,SString S,int Pos,int len)
{
//判断子串范围是否越界
if(pos+len-1 > S.length) // Sub 用于返回,S是所要找的主串,找子串就是从主串 S 中的 pos 位置开始找到长度为 len 的子串
{
return false;
}
for(int i=pos;i<pos+len;i++) // for 循环起始位置是 pos ,范围是[pos,pos+len],将这个范围内的每一个字符都赋值给Sub 用于返回;
{
Sub.ch[i-pos+1] = S.ch[i];
}
Sub.length = len; // Sub 的长度是所要定位的子串的长度
return true;
}
//比较操作
int StrCompare(SString S,SString T)
{
//比较操作。若S>T,则返回值>0;若S=T,则返回值=0;若S<T,则返回值<0
for(int i=1;i<=S.length && i<=T.length;i++)
{
if(S.ch[i]!=T.ch[i])
return S.ch[i]-T.ch[i];
}
//扫描过的所有字符都相同,则长度长的串更大
return S.length-T.length;
}
//定位操作
int Index(SString S,SString T)
{
int i=1,n=StrLength(S),m=StrLength(T); // n 表示主串 S 的长度; m 表示子串 T 的长度
SString sub; // 用于暂存子串
while(i<n-m+1) //主串中最多可以找到 n-m+1 个子串,其中n表示主串的长度,m 表示子串的长度
{
SubString(sub,S,i,m); // 从主串 S 的第 i 个位置开始找到长度为len的子串
if(StrCompare(sub,T)!=0) //从首元素开始比较,如果对应的元素不相等,则从第 (++i)个位置开始找到长度为 len 的子串
++i;
else
return i; // 如果对比子串全部相等,则返回子串在主串中的位置
}
return 0; // S 中不存在与 T 相等的子串
}
2. 串的模式匹配
2.1 简单的模式匹配算法
子串的定位操作通常称为串的模式匹配,它求的是子串 (常称为模式串) 在主串中的位置。
//首先先需要知道:主串长度为 n ,模式串长度为 m ;
主串中最多有 n-m+1 个子串
这个其实很好理解:
首先子串的长度为 m ,那么要找一定是从主串中找长度为 m 的子串,一定是从第一个元素开始依次遍历
n-m表示的意思是要给最后一个子串留有充足的空间,防止溢出;n-m+1 中的 加一 的意思是锁定最后一个子串的首元素
//这里采用定长顺序存储结构,一种不依赖于其他串操作的暴力匹配算法
//算法的思想是:
//从主串S的第一个字符起,与子串T的第一个字符比较,若相等,则继续逐个比较后续字符;否则
//从主串的下一个字符起,重新和模式串的字符比较;以此类推,直至子串 T 中的每个字符依次与主串 S 中的一
//个连续的字符序列相等,则匹配成功;函数值为与子串 T 中第一个字符相等的字符在主串 S 中的序号(也就
//是返回子串首元素在主串中对应的序号),否则称匹配不成功,函数值为零。
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; // i-j+2 的意思是:通过执行上述程序,i 和 j 会同时指向不相等的元素,i-j的意思是把 i 指向这个子串的前一个元素,记住是子串的前一个元素,不是元素的前一个元素;
//i-j+2 现在指向的是原本不相等子串的第二个元素,思想就是从主串中一个元素一个元素的依次遍历,上一个子串已经不符合要求了,现在需要去找下一个子串,上一个子串的第二个元素就是下一个子串的首元素
j=1; //指针后退重新开始匹配
}
if(j>T.length)
return i-T.length;
else
return 0;
}
}
//简单模式匹配算法的最坏时间复杂度为O(nm),其中n和m分别为主串和子串的长度。
2.2 串的模式匹配算法——KMP算法
在暴力匹配中,每趟匹配失败都是模式后移一位再从头开始比较。而某趟已匹配相等的字符序列是模式的某个前缀,这种频繁的重复比较相当于模式串在不断地进行自我比较,这也就是其低效率的根源;
2.2.1 字符串的前缀、后缀和部分匹配值
前缀、后缀和部分匹配值和子串的结构联系紧密;前缀指除最后一个字符以外,字符串的所有头部子串;后缀指除第一个字符外,字符串的所有尾部子串;部分匹配值则为字符串的前缀和后缀的最长相等前后缀长度。
例如:
‘ababa’
‘a’的前缀和后缀都是空集,最长相等前后缀长度为0;
‘ab’的前缀是a,后缀是b,a交b=空集,最长相等前后缀长度为0;
‘aba’的前缀是a、ab,后缀是a、ba,交集是a,最长相等前后缀长度为1;
‘abab’的前缀是a、ab、aba,后缀是b、ab、bab,交集是ab,最长相等前后缀长度为2;
‘ababa’的前缀是a、ab、aba、abab,后缀是a、ba、aba、baba,交集是a、aba,最长相等前后缀长度为3;
故字符串‘ababa’的部分匹配值是 00123;
那么部分匹配值到底有什么作用呢?
回到我们暴力匹配的最初问题中,主串为 ababcabcacbab,子串为 abcac;通过上述的学习我们可以计算出 ‘abcac’ 的部分匹配值为 00010 ,将该部分匹配值写成数组的形式,就得到了部分匹配表(Partial Match,PM)的表;
下面用 PM 表进行字符串匹配:
第一趟匹配过程:
发现 c 与 a 不匹配,前面的 2 个字符 ‘ab’ 是匹配的,查PM表可以知道:最后一个匹配的字符 b 对应的部分匹配值为 0 ,按照下面的公式算出子串需要向后移动的位数:
移动位数 = 已匹配的字符数 - 对应的部分匹配值
因此 移动位数 = 2 - 0 = 2;所以子串向后移动两位;
第二趟匹配过程:
第二趟匹配过程在第一趟匹配过程的基础上向后移动了两位;发现 c 与 b 不匹配,前面四个字符 “abca” 是匹配的,最后一个匹配字符 a 对应的部分匹配值为 1 ,4 - 1 = 3 ;所以将子串向后移动 3 位;
第三趟匹配过程:
第三趟子串全部比较完成,匹配成功。整个匹配过程中,主串始终没有回退,所以 KMP算法 可以在 O(n+m) 的时间数量级上完成串的模式匹配操作,大大提高了匹配效率。
2.2.2 KMP算法的原理是什么?
刚刚我们只是学习了怎么计算字符串的部分匹配值?怎样利用子串的部分匹配值快速地进行字符串匹配操作,这里着重来学习公式 “移动位数 = 已匹配的字符数 - 对应的部分匹配值” 的意义;
如图 4.3 所示,当 c 与 b 不匹配时,已匹配 ‘abca’ 的前缀 a 和后缀 a 为最长公共元素。已知前缀 a 和 b c 均不同,与后缀 a 相同,所以不需要进行比较,直接将子串移动 “已匹配的字符数 - 对应的部分匹配值” ,用子串前缀后面的元素与主串匹配失败的元素开始比较即可。
对算法的改进方法:
已知:右移位数 = 已匹配的字符数 - 对应的部分匹配值
写成:Move = ( j - 1 ) - PM [ j - 1 ]。
使用 部分匹配值 时,每当匹配失败,就去找它前一个元素的部分匹配值,这样使用起来是不方便的,因此将 PM 表右移一位,这样哪个元素匹配失败,直接看它自己的部分匹配值即可。
将上个例子字符串 ‘abcac’ 的 PM 表右移一位,就得到了 next 数组:
我们注意到:
1. 第一个元素右移以后空缺的用 -1 来填充,因为若是第一个元素匹配失败,则需要将子串向右移动一位,不需要计算子串移动的位数。
2. 最后一个元素在右移的过程中溢出,因为原来的子串中,最后一个元素的部分匹配值是其下一个元素使用的,显然最后一个元素已经没有下一个元素了,故可以舍去。
这样,上式可以改写成
Move = ( j - 1 ) - next [ j ]
相当于将子串的比较指针 j 回退到
j = j - Move = j - (( j - 1 )- next [ j ]) = next [ j ] + 1
也可以将 next 指针整体 +1
在实际的匹配过程中,子串在内存中是不会移动的,而是指针在变化。 next [ j ] 的含义是:在子串的第 j 个字符与主串发生失配时,则跳到子串的 next [ j ] 位置重新与主串当前位置进行比较。
//写 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])
{
++i;
++j;
next[i]=j;
//若pi=pj,则 next[j+1] = next[j] + 1
}
else
{
j=next[j]; //否则令 j=next[j],循环继续
}
}
}
2.2.3 KMP算法的进一步优化
这里直接给出 KMP 算法的优化代码,具体的 KMP 算法代码如何实现,参照下述相关练习题具体说明;
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])
{
++i;
++j;
if(T.ch[i]!=T.ch[j])
nextval[i]=j;
else
nextval[i]=nextval[j];
}
else
j=nextval[j];
}
}