1. 串的定义
串(或字符串)是一种重要的线性结构,计算机上的非数值处理对象基本上是字符串数据。字符串处理在文本编辑、信息检索等方面有着广泛的应用。串根据存储方式的不同可以分为顺序串、堆串和块链串。
串是由零个或多个字符串组成的有限序列。其中,含零个字符串的称为空串。串中的字符可以是字母数字或其他字符。串中任意个连续的字符组成的子序列称为串的子串。相应地,包含子串的串称为主串。串中所含字符的个数称为串长,空串的长度为0。
两个串相等当且仅当两个串中对应位置的字符相等并且长度相等。空格也是串中的一个元素。完全由空格组成的串称为空格串,注意空格串与空串是不同的。
在串的表示中,通常在串的元素序列两边加上双引号或者单引号。主要的目的是与一般的变量或者常量区别。
2. 串的基本操作
串的操作通常不是以某个元素作为操作对象,而是将一连串的字符串作为操作对象。例如,在串中查找某个子串,在串中某个位置插入或删除一个子串等。
串的基本操作有以下13种:
(1)StrAssign(&S,cstr):赋值操作。
(2)StrEmpty(S):判断串是否为空。
(3)StrLength(S):求串的长度。
(4)StrCopy(&T,S):串的复制。
(5)StrCompare(S,T):串的比较
(6)StrInsert(&S,pos,T):串的插入操作。
(7)StrDelete(&S,pos,len):串的删除操作。
(8)StrConcat(&T,S):串的连接操作。
(9)SubString(&Sub,S,pos,len):截取子串操作。
(10)StrReplace(&S,T,V):串的替换操作。
(11)StrIndex(S,pos,T):返回子串在主串的位置。
(12)StrClear(S,pos,T):清空串操作。
(13)StrDestory(&S):销毁串操作。
3. 串的顺序表示与实现
串的存储方式有两种:顺序存储和链式存储。因为串的顺序存储结构操作方便,所以更为常用。
图 串的顺序存储
采用顺序存储结构的串称为顺序串,又称为定长顺序串。在串的顺序存储结构中,确定串的长度有两种方法:一种方法就是在串的末尾加上一个结束标记,在C语言中定义串时,系统会自动在串值的最后添加'\0'作为结束标记。另一种方法是定义一个变量length,用来存放串的长度。通常在串的顺序存储结构中,设置串的长度的方法更为常用。
串的顺序存储结构类型定义描述如下:
#define MaxLength 60 typedef struct { char str[MaxLength]; int length; }SeqString;
其中,MaxLength表示串的最大长度,str是存储串的字符数组,length为串的长度。
4. 串的串的堆分配表示与实现
串的顺序存储包括静态分配的方式和动态的分配方式,在静态分配的顺序串中,串的连接、插入、替换等操作由于需要事先分配村粗空间,可能会由于事先分配的内存空间不足而出现串的一部分字符被截掉。而在顺序串中采用动态分配存储单元可以避免出现这种情况,但是,在内存单元使用完毕后,要释放这些单元。
堆串的类型定义如下:
typedef struct { char *str; int length; }HeapString;
5. 串的链式存储表示与实现
串的链式存储结构与线性表的链式存储结构类似,通过一个节点实现。节点包含两个域:数据域和指针域。采用链式存储结构的串称为链串。由于串的特殊性——每个元素只包含一个字符,因此,每个结点可以存放一个字符,也可以存放多个字符。串的链式存储结构也称为块链的存储结构,,它是采用一个“块”作为节点的数据域,存储串中的若干个字符。串的长度可能不是块大小的整数倍,因此在最后的一个节点的数据域空出的部分可以用“#”填充。
串的链式存储结构描述如下:
#define ChunkSize 10 #define stuff '#' /* 串的节点类型定义 */ typedef struct Chunk { char ch[ChunkSize]; struct Chunk *next; }Chunk; /* 链串的类型定义 */ typedef struct { Chunk *head; Chunk *tail; int length; }LinkString;
其中,ChunkSize是节点的大小,可以由用户定义。当ChunkSize等于1时,链串就变成一个普通链表。当ChunkSize大于1时,链串中的每个结点可以存放多个字符,如果最后一个节点没有被填充满,可以使用“#”填充。在算法实现中,用stuff代替‘#’。head表示头指针,指向链串的第一个节点。tail表示尾指针,指向链串的最后一个节点。length表示链串中的字符个数。
6. 串的模式匹配
串的模式匹配在串的各种操作中是经常用到的一个算法。串的模式匹配也称为子串的定位操作,即查找子串在主串中出现的位置。
(1)朴素模式匹配算法
朴素模式匹配算法又称简单匹配算法或Brute-Force算法,它是字符串模式匹配中比较简单的一种算法。它从主串的第一个字符开始进行模式匹配,依次比较主串和模式串中的每个字符,若比较全部相等(模式匹配成功),则返回模式串中第一个字符在主串中的位置,否则主串指针从比较失败的字符处回溯到第二个字符开始重新和模式串进行匹配,这样依此下去,直到和模式串匹配成功或到主串的末尾(匹配不成功)为止。
朴素模式匹配算法的C/C++代码实现:
// 定义一个字符串结构体 typedef struct { char data[MAX_SIZE]; int length; } string; int BruteForce(string s, string t) { int i = 0, j = 0; while (i < s.length && j < t.length) { if (s[i] == t[j]) { i++; j++; } else { i = i - j + 1; // 主串指针回溯 j = 0; } } if (j > t.length) return i - t.length; else return -1; }
设主串长度为n,子串的长度为m,Brute-Force匹配算法在最好情况下,即主串的前m个字符刚好与子串相等,时间复杂度为O(m)。在最坏情况下Brute-Force匹配算法的时间复杂度是O(m*n)。
算法FLASH演示如下:
http://ds.fzu.edu.cn/fine/resources/TFlash/naivematch.swf
(2)KMP算法
KMP算法是字符串匹配的一种改进算法,在1977年由D. E. Knuth,J. H. Morris和V. R. Pratt一起提出,并以他们名字的首字母命名。和朴素的模式匹配算法相比,KMP算法最大的特点就是主串指针不回溯。它利用已经得到的“部分匹配”信息来减少不必要的比较而加快字符串的匹配速度。
KMP算法本质上是实现了对自动机的模拟。它通过构造一个有限自动机来搜寻某给定的模式在正文中出现的位置。整个算法的核心就是对自动机的构建(或前缀函数的构建,KMP算法不用计算变迁函数,而是根据模式预先计算出一个辅助函数next来实现更快速的状态切换),当完成有限自动机的构建之后对主串的搜寻就显得很简单了。
KMP算法的关键在于前缀函数next的构建,模式的前缀函数包含有模式与其自身的位移进行匹配的信息。这些信息可用于避免在朴素的模式匹配算法中对无用位移的测试。比如主串和模式串在主串指针为Ti、模式串指针为Pj处匹配失败时,可以主串指针不回溯并直接取next函数的值next[j] = k将模式串向右滑动到第k个字符处重新开始比较,而不用去做无用位移的测试。只是这样的操作能成立k必须要满足一定的条件,如下所示:
首先,如果模式串能直接向右滑动到第k个字符处重新开始比较则说明模式串中的前k-1个字符必然已经和主串匹配了,也即必然已经有下面式子,且为了能最大化的向右移动则不能存在更大的k’满足下面式子:
<1>: P1 P2 … Pk-1 = Ti-k+1 Ti-k+2 … Ti-1
而由已经比较所得的“部分匹配”信息可知:
<2>: Pj-k+1 Pj-k+2 … Pj-1 = Ti-k+1 Ti-k+2 … Ti-1
因此由式子<1>和式子<2>我们可以推得:
<3>: P1 P2 … Pk-1 = Pj-k+1 Pj-k+2 … Pj-1
也就是k必须要满足式子<3>的条件!由这个式子也可以看出k就是模式中第j个字符所拥有的最长真后缀同时是模式前缀的串的长度,且k的取值和主串T无关!
那有了式子<3>之后如何去求模式的每个字符所对应的k(next[j])的值呢?
由上面结论可知,当j = 0时next[j] = -1,因为第一个字符没有真后缀同时是模式的前缀。其他情况时,next[j]则可以由它前一个位置字符的next值推出。
比如我们要求next[j+1],假设已有next[j] = k,即已有P1 P2 … Pk-1 = Pj-k+1 Pj-k+2 … Pj-1。如果此时有Pj = Pk,则表明P1 P2 … Pk = Pj-k+1 Pj-k+2 … Pj,所以next[j+1] = next[j] + 1 = k + 1。如果Pj ≠ Pk,则此时可把求next函数值的问题又看成是一个模式匹配的问题,整个模式串既是主串又是模式串。所以我们再从next[k]处开始重新进行比较,若Pj = Pnext[k],则next[j+1] = next[next[k]] + 1。否则,再从next[next[k]]开始重新比较 … 这样一直下去,直到Pj和模式中某个字符匹配成功或next[...] = -1,则next[j+1] = 0。如例子1:
例子1:
0 1 2 3 4 5 6 7
模式: a b a a b c a c
next[j] -1 0 0 1 1 2 ? ?
当求next[6]时,由于P5 ≠ P2(Pnext[5]),所以接着去比较P5和P0(Pnext[next[5]]),可P0已经等于-1,说明已经到头了,所以next[6] = 0。同理,next[7] = 1。
前缀函数的C/C++代码实现如下:
void prefix_function(char *p, int *next) { int j, k; next[0] = -1; j = 0; k = -1; int length = strlen(p) - 1; // next[0] 已不用计算 while (j < length) { if (k == -1 || p[j] == p[k]) { next[j+1] = k + 1; k++; j++; } else { k = next[k]; } } }
KMP算法的实现:当next函数求出来之后,再在主串上搜寻模式串就相对简单了,整个过程和朴素的模式匹配算法差不多,C/C++代码实现如下:
int kmp_matching(char *t, char *p) { int t_len = strlen(t); int p_len = strlen(p); // 先预先求出next函数 int *next = new int[t_len]; prefix_function(p, next); int i, j; i = 0; j = 0; while (i < t_len && j < p_len) { if (j == -1 || t[i] == p[j]) { i++; j++; } else { j = next[j]; } } if (next != NULL) { delete[] next; next = NULL; } if (j == p_len) return i - p_len; return -1; }
时间复杂度分析:由于KMP算法构造了一个自动机来匹配模式串,因此其主串中的每个字符只需比较一次,并且每个字符比较的时间为常数,所以其时间复杂度为线性。m长度的主串比较时间为O(m)。而前缀函数由于是提前构建,用平摊分析方法可知n长度的模式花费的时间为O(n),所以KMP算法总的时间复杂度为O(m+n)。
补充说明:关于next函数(前缀函数)的求法,严版《数据结构》中提到一点,即上面所给的代码还可以改善。举个例子,比如说模式P为aaaab,主串T为aaabaaaab时,用上面的算法得出next[] = {-1, 0, 1, 2, 3}。因为P3 = a和T3 = b不匹配,所以接下来还需要拿P2P1P0去和T3去比较,可这些比较不是必需的,因为P2 = P1 = P0 = P3,既然P3比较不成功,他们比较怎么又会成功呢?所以这时候就可以优化。当计算next函数时,如果比较失败的字符和他的next[]所对应的字符不等,则其next[]依旧按上面方法计算,否则其next[]等于其next[]所对应的字符的next值。反映到算法上如下:
void prefix_function(char *p, int *next) { int j, k; next[0] = -1; j = 0; k = -1; int length = strlen(p) - 1; // next[0] 已不用计算 while (j < length) { if (k == -1 || p[j] == p[k]) { if (p[j+1] != p[k+1]) next[j+1] = k + 1; else next[j+1] = next[k+1]; j++; k++; } else { k = next[k]; } } }