第四章 串

*4.1 串的定义和实现

① 本节不在统考大纲范围,仅供学习参考。

字符串简称串,计算机上非数值处理的对象基本都是字符串数据。我们常见的信息检索系统(如搜索引擎)、文本编辑程序(如Word)、问答系统、自然语言翻译系统等,都是以字符串数据作为处理对象的。本章详细介绍字符串的存储结构及相应的操作。

4.1.1 串的定义

串(string)是由零个或多个字符组成的有限序列。一般记为

\[ S = 'a_1a_2\cdots a_n' \ (n \geq 0) \]

其中,\( S \) 是串名,单引号括起来的字符序列是串的值;\( a_i \) 可以是字母、数字或其他字符;串中字符的个数 \( n \) 称为串的长度。\( n = 0 \) 时的串称为空串(用 \( \varnothing \) 表示)。

串中任意多个连续的字符组成的子序列称为该串的子串,包含子串的串称为主串。某个字符在串中的序号称为该字符在串中的位置。子串在主串中的位置以子串的第1个字符在主串中的位置来表示。当两个串的长度相等且每个对应位置的字符都相等时,称这两个串是相等的。

例如,有串 \( A = 'China Beijing' \),\( B = 'Beijing' \),\( C = 'China' \),则它们的长度分别为13、7和5。\( B \) 和 \( C \) 是 \( A \) 的子串,\( B \) 在 \( A \) 中的位置是7,\( C \) 在 \( A \) 中的位置是1。

注意,由一个或多个空格(空格是特殊字符)组成的串称为空格串(空格串不是空串),其长度为串中空格字符的个数。

串的逻辑结构和线性表极为相似,区别仅在于串的数据对象限定为字符集。在基本操作上,串和线性表有很大差别。线性表的基本操作主要以单个元素作为操作对象,如查找、插入或删除某个元素等;而串的基本操作通常以子串作为操作对象,如查找、插入或删除一个子串等。

4.1.2 串的基本操作

- `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 \) 销毁。

不同的高级语言对串的基本操作集可以有不同的定义方法。在上述定义的操作中,串赋值 `StrAssign`、串比较 `StrCompare`、求串长 `StrLength`、串联接 `Concat` 及求子串 `SubString` 五种操作构成串类型的最小操作子集,即这些操作不可能利用其他串操作来实现;反之,其他串操作(除串清除 `ClearString` 和串销毁 `DestroyString` 外)均可在该最小操作子集上实现。

4.1.3 串的存储结构

1. 定长顺序存储表示

类似于线性表的顺序存储结构,用一组地址连续的存储单元来存储串值的字符序列。在串的定长顺序存储结构中,为每个串变量分配一个固定长度的存储区,即定长数组。

```c #define MAXLEN 255 //预定义最大串长为255 typedef struct{ char ch[MAXLEN]; //每个分量存储一个字符 int length; //串的实际长度 }SString; ```

串的实际长度只能小于或等于 `MAXLEN`,超过预定义长度的串值会被舍去,称为截断。串长有两种表示方法:一是如上述定义描述的那样,用一个额外的变量 `len` 来存放串的长度;二是在串值后面加一个不计入串长的结束标记字符 `'\0'`,此时的串长为隐含值。

在一些串的操作(如插入、联接等)中,若串值序列的长度超过上界 `MAXLEN`,约定用“截断”法处理,要克服这种弊端,只能不限定串长的最大长度,即采用动态分配的方式。

2. 堆分配存储表示

堆分配存储表示仍然以一组地址连续的存储单元存放串值的字符序列,但它们的存储空间是在程序执行过程中动态分配得到的。

```c typedef struct{ char *ch; //按串长分配存储区,ch 指向串的基地址 int length; //串的长度 }HString; ```

在C语言中,存在一个称为堆的自由存储区,并用 `malloc()` 和 `free()` 函数来完成动态存储管理。利用 `malloc()` 为每个新产生的串分配一块实际串长所需的存储空间,若分配成功,则返回一个指向起始地址的指针,作为串的基地址,这个串由 `ch` 指针来指示;若分配失败,则返回 `NULL`。已分配的空间可用 `free()` 释放掉。

上述两种存储表示通常为高级程序设计语言所采用。块链存储表示仅做简单介绍。

3. 块链存储表示

类似于线性表的链式存储结构,也可采用链表方式存储串值。由于串的特殊性(每个元素只有一个字符),在具体实现时,每个结点既可以存放一个字符,又可以存放多个字符。每个结点称为块,整个链表称为块链结构。图4.1(a)是结点大小为4(每个结点存放4个字符)的链表,最后一个结点占不满时通常用“#”补上;图4.1(b)是结点大小为1的链表。

4.2 串的模式匹配

4.2.1 简单的模式匹配算法

模式匹配是指在主串中找到与模式串(想要搜索的某个字符串)相同的子串,并返回其所在的位置。这里采用定长顺序存储结构,给出一种不依赖于其他串操作的暴力匹配算法。

```c 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; } ```

在上述算法中,分别用计数指针 \( i \) 和 \( j \) 指示主串 \( S \) 和模式串 \( T \) 中当前待比较的字符位置。 算法思想是:从主串 \( S \) 的第一个字符起,与模式串 \( T \) 的第一个字符比较,若相等,则继续逐个比较后续字符;否则从主串的下一个字符起,再重新和模式串 \( T \) 的字符比较;以此类推,直至模式串 \( T \) 中的每个字符依次和主串 \( S \) 中的一个连续的字符序列相等,则称匹配成功,函数值为与模式串 \( T \) 中第一个字符相等的字符在主串 \( S \) 中的序号,否则称匹配不成功,函数值为零。

图4.2展示了模式串 \( T='abcac' \) 和主串 \( S \) 的匹配过程。

在简单模式匹配算法中,设主串和模式串的长度分别为 \( n \) 和 \( m \)(\( n \gg m \)),则最多需要进行 \( n - m + 1 \) 趟匹配,每趟最多需要进行 \( m \) 次比较,最坏时间复杂度为 \( O(nm) \)。例如,当模式串为 \( '0000001' \) 而主串为 \( '000000000000000000000000000000000000000000001' \) 时,由于模式串中的前6个字符均为 \( '0' \),主串中的前45个字符均为 \( '0' \),每趟匹配都是比较到模式串中的最后一个字符时才发现不等,整个匹配过程中指针 \( i \) 需要回溯39次,总比较次数为 \( 40×7 = 280 \) 次。

4.2.2 串的模式匹配算法——KMP算法

在图4.2的第三趟匹配过程中,\( i = 7 \)、\( j = 5 \) 的字符比较,结果不等,于是又从 \( i = 4 \)、\( j = 1 \) 重新开始比较。然而,仔细观察会发现,\( i = 4 \) 和 \( j = 1 \)、\( i = 5 \) 和 \( j = 1 \) 以及 \( i = 6 \) 和 \( j = 1 \) 这三次比较都是不必进行的。从第三趟部分匹配的结果可知,主串的第4个、第5个、第6个字符是 \( 'b' \)、\( 'c' \)、\( 'a' \)(模式串的第2、第3、第4个字符),因为模式串的第1个字符是 \( 'a' \),所以再和这三个字符进行比较纯属多余,而只需将模式串向右滑动三个字符的位置,再进行 \( i = 7 \)、\( j = 2 \) 的比较即可。

在简单模式匹配算法中,每趟匹配失败都是模式串向右滑动一位后从头开始比较的。而某趟已匹配相等的字符序列是模式串的某个前缀,因此可从分析模式串本身的结构着手,若已匹配相等的前缀序列中有某个后缀正好是模式串的前缀,则可将模式串向右滑动到与这些相等字符对齐的位置(也是后面手算next数组的依据),主串指针 \( i \) 无须回溯,并从该位置开始继续比较。而模式串向右滑动位数的计算仅与模式串本身的结构有关,与主串无关。

1. KMP算法的原理

要了解模式串的结构,首先要弄清楚几个概念:前缀、后缀和部分匹配值。前缀是指除最后一个字符外,字符串的所有头部子串;后缀是指除第一个字符外,字符串的所有尾部子串;部分匹配值则是指字符串的前缀和后缀的最长相等前后缀长度。下面以 \( 'ababa' \) 为例进行说明:

- \( 'a' \) 的前缀和后缀都为空集,最长相等前后缀长度为0。

- \( 'ab' \) 的前缀为 \( \{a\} \),后缀为 \( \{b\} \),\( \{a\} \cap \{b\} = \varnothing \),最长相等前后缀长度为0。

- \( 'aba' \) 的前缀为 \( \{a, ab\} \),后缀为 \( \{a, ba\} \),\( \{a, ab\} \cap \{a, ba\} = \{a\} \),最长相等前后缀长度为1。

- \( 'abab' \) 的前缀 \( \{a, ab, aba\} \cap \) 后缀 \( \{b, ab, bab\} = \{ab\} \),最长相等前后缀长度为2。

- \( 'ababa' \) 的前缀 \( \{a, ab, aba, abab\} \cap \) 后缀 \( \{a, ba, aba, baba\} = \{a, aba\} \),公共元素有两个,最长相等前后缀长度为3。

因此,模式串 \( 'ababa' \) 的部分匹配值为00123。

这个部分匹配值有什么作用呢?

回到最初的问题,主串为 \( 'ababcabcacbab' \),模式串为 \( 'abcac' \)。

利用上述方法容易求出模式串 \( 'abcac' \) 的部分匹配值为 00010,将部分匹配值写成数组形式,就得到了部分匹配值(Partial Match,PM)的表。

下面用PM表来进行字符串匹配:

第一趟匹配过程:

发现c与a不匹配,前面的2个字符 \( 'ab' \) 是匹配的,查表可知,最后一个匹配字符b对应的部分匹配值为0,因此按照下面的公式算出模式串需要的右滑位数:

右滑位数 = 已匹配的字符数 - 对应的部分匹配值

因为 \( 2 - 0 = 2 \),所以将模式串向右滑动2位如下,进行第二趟匹配:

第二趟匹配过程: 发现c与b不匹配,前面的4个字符 \( 'abca' \) 是匹配的,查表可知,最后一个匹配字符a对应的部分匹配值为1,\( 4 - 1 = 3 \),将模式串向右滑动3位如下,进行第三趟匹配:

第三趟匹配过程:

模式串全部比较完成,匹配成功。整个匹配过程中,主串始终没有回退,所以KMP算法可在 \( O(n + m) \) 的时间数量级上完成串的模式匹配操作,大大提高了匹配效率。

某趟发生失配时,若已匹配相等的序列中没有相等的前后缀,则对应的部分匹配值为0,此时滑动的位数最大,直接将模式串首字符向右滑动到主串当前位置进行下一趟比较;若已匹配相等的序列中存在最大相等前后缀(可理解为首尾重合),则将模式串向右滑动到和主串中该相等后缀对齐(这些重合的字符下一趟显然无需再比较),然后从主串当前位置进行下一趟比较。两种情况的模式串右滑位数都等于“已匹配的字符数 - 对应的部分匹配值”。

还有一种特例,在上述举例中并未出现,当某趟第一个字符比较就失配时,应如何处理呢?此时,应让模式串向右滑动一位,再从主串当前位置的下一位开始比较。

2. next数组的手算方法

在实际的匹配过程中,模式串在内存中是不会滑动的,发生变化的是指针,前面的举例只是手动模拟KMP算法的过程,也是为了让读者更为形象地进行理解。

**命题追踪** KMP算法中指针变化、比较次数的分析(2015、2019)

每趟匹配失败时,只有模式串指针 \( i \) 在变化,主串指针 \( j \) 不会回溯,为此可以定义一个next数组,next\[j\]的含义是当模式串的第 \( j \) 个字符失配时,跳到next\[j\]位置继续比较。

下面给出一种求next数组的手算方法,仍以模式串 \( 'abcac' \) 为例。

第1个字符失配时,令next\[1\] = 0,然后指针 \( i \) 和 \( j \) 同时加1,即下次将模式串的第1个位置与主串当前位置的下一位置进行比较(注意,图中的下标为模式串编号)。

第2个字符失配时,令next\[2\] = 1,模式串的下次比较位置为1,相当于向右滑动1位。 注,模式串的next\[1\] = 0、next\[2\] = 1都是固定不变的。

在后面的手算过程中,在不匹配的位置前画一条分界线,模式串一步一步往后退,直到分界线之前能对上(首尾重合),或模式串完全跨过分界线为止。

第3个字符失配时,模式串的下次比较位置为1,即next\[3\] = 1,相当于向右滑动2位。

第4个字符失配时,模式串的下次比较位置为1,即next\[4\] = 1,相当于向右滑动3位。

第5个字符失配时,模式串的下次比较位置为2,即next\[5\] = 2,相当于向右滑动3位。

next数组和PM表的关系是怎样的?

通过上述举例,可以推理出next数组和PM表之间的关系:

\[ \begin{align*} \text{next}[j] &= j - \text{右滑位数} \\ &= j - (j - \text{已匹配的字符数}^{\text{①}} - \text{对应的部分匹配值}) \\ &= j - [(j - 1) - \text{PM}[j - 1]] \\ &= \text{PM}[j - 1] + 1 \end{align*} \]

根据上述结论,将模式串 \( 'abcac' \) 的PM表右移一位,并整体加1,就得到了模式串 \( 'abcac' \) 对应的next数组,通过与前面手算的结果比较,可以验证上述结论。

我们注意到:

1)第一个元素右滑以后空缺的用0来填充,因为若是第一个元素匹配失败,则需要将主串指针和模式串指针同步右移一位,从而不需要计算模式串指针移动的位数。

2)最后一个元素在右滑的过程中溢出,因为原来的模式串中,最后一个元素的部分匹配值是其下一个元素使用的,但显然已没有下一个元素,所以可以舍去。

**注意** 上述KMP算法的举例中,都假设串的编号是从1开始的;若串的编号是从0开始的,则next数组需要整体减1。

*3. next数组的推理公式

如何推理next数组的一般公式?设主串为 \( 's_1s_2\cdots s_n' \),模式串为 \( 'p_1p_2\cdots p_m' \),当主串的第 \( i \) 个字符与模式串的第 \( j \) 个字符失配时,应让主串当前位置与模式串的哪个字符进行比较?

假设此时应与模式串的第 \( k \)(\( k < j \))个字符进行比较,则模式串的前 \( k - 1 \) 个字符的子串必须满足下列条件,且不可能存在 \( k' > k \) 满足下列条件:

\[ 'p_1p_2\cdots p_{k - 1}' = 'p_{j - k + 1}p_{j - k + 2}\cdots p_{j - 1}' \]

若存在满足如上条件的子串,则发生失配时,仅需将模式串的第 \( k \) 个字符和主串的第 \( i \) 个字符对齐,此时模式串的前 \( k - 1 \) 个字符的子串必定与主串的第 \( i \) 个字符之前长度为 \( k - 1 \) 的子串相等,因此,只需从模式串的第 \( k \) 个字符与主串的第 \( i \) 个字符进行比较即可,如图4.3所示。

当模式串已匹配相等序列中不存在满足上述条件的子串时(可视为 \( k = 1 \)),显然应让主串的第 \( i \) 个字符和模式串的第1个字符进行比较。

当模式串的第1个字符(\( j = 1 \))与主串的第 \( i \) 个字符发生失配时,规定next\[1\] = 0。

通过上述分析可以得出next函数的公式:

\[ \text{next}[j] = \begin{cases} 0, & j = 1 \\ \max\{k \mid 1 < k < j \text{ 且 } 'p_1\cdots p_{k - 1}' = 'p_{j - k + 1}\cdots p_{j - 1}'\}, & \text{当此集合不为空时} \\ 1, & \text{其他情况} \end{cases} \]

要用代码来实现,难度貌似还不小,下面来尝试推理求解的科学步骤。

首先由公式可知 \[ \text{next}[1] = 0 \] 设next\[j\] = \( k \),此时 \( k \) 应满足的条件在上文中已描述。

时next\[j + 1\] =?可能有两种情况:

(1)若 \( p_k = p_j \),则表明在模式串中

\[ 'p_1\cdots p_{k - 1}p_k' = 'p_{j - k + 1}\cdots p_{j - 1}p_j' \]

且不可能存在 \( k' > k \) 满足上述条件,此时next\[j + 1\] = \( k + 1 \),即

\[ \text{next}[j + 1] = \text{next}[j] + 1 \]

(2)若 \( p_k \neq p_j \),则表明在模式串中

\[ 'p_1\cdots p_{k - 1}p_k' \neq 'p_{j - k + 1}\cdots p_{j - 1}p_j' \]

此时可将求next函数值的问题视为一个模式匹配问题。用前缀 \( p_1\cdots p_k \) 去与后缀 \( p_{j - k + 1}\cdots p_j \) 匹配,当 \( p_k \neq p_j \) 时,应将 \( p_1\cdots p_k \) 向右滑动至用第next\[k\]个字符与 \( p_j \) 进行比较,若 \( p_{\text{next}[k]} \) 与 \( p_j \) 仍不匹配,则需要寻找长度更短的相等前后缀,下一步继续用 \( p_{\text{next}[\text{next}[k]]} \) 与 \( p_j \) 进行较,以此类推,直到找到某个更小的 \( k' = \text{next}[\text{next}[\cdots [k]]] \)(\( 1 < k' < k < j \)),满足条件

\[ 'p_1\cdots p_{k'}' = 'p_{j - k' + 1}\cdots p_j' \]

则next\[j + 1\] = \( k' + 1 \)。

也可能不存在任何 \( k' \) 满足上述条件,即不存在长度更短的相等前后缀,令next\[j + 1\] = 1。

理解起来有点儿费劲?下面举一个简单的例子。

图4.4的模式串中已求得6个字符的next值,现求next[7],因为next[6]=3,又\( p_6 \neq p_3 \),所以需要比较\( p_6 \)和\( p_1 \)(因next[3]=1),\( p_6 \neq p_1 \),而next[1]=0,因此next[7]=1;求next[8],因为\( p_7 = p_1 \),所以next[8]=next[7]+1=2;求next[9],因为\( p_8 = p_2 \),所以next[9]=3。

*4. KMP算法的实现

通过上述分析写出求next值的程序如下:

```c 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; //若\( p_i = p_j \),则 next[j+1]=next[j]+1 } ``` ```c else j=next[j]; //否则令j=next[j],循环继续 } } ```

计算机执行起来效率很高,但需要手工计算时,仍然采用前面的方法。

与next数组的求解相比,KMP的匹配算法相对要简单很多,它在形式上与简单的模式匹配算法很相似。不同之处仅在于当匹配过程产生失配时,指针\( i \)不变,指针\( j \)退回到next[j]的位置并重新进行比较,且当指针\( j \)为0时,指针\( i \)和\( j \)同时加1。也就是说,若主串的第\( i \)个位置和模式串的第1个字符不等,则应从主串的第\( i+1 \)个位置开始匹配。具体代码如下:

```c 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; } ```

尽管普通模式匹配的时间复杂度是\( O(mn) \),KMP算法的时间复杂度是\( O(m + n) \),但在一般情况下,普通模式匹配的实际执行时间复杂度近似为\( O(m + n) \),因此至今仍被采用。KMP算法仅在主串与子串有很多“部分匹配”时才显得比普通算法快,其主要优点是主串不回溯。

4.2.3 KMP算法的进一步优化

**命题追踪** nextval数组的计算(2024)

前面定义的next数组在某些情况下尚有缺陷,还可以进一步优化。如图4.5所示,模式串`'aaaab'`在和主串`'aaabaaaab'`进行匹配时。

当 \( i = 4 \)、\( j = 4 \) 时,\( s_4 \) 跟 \( p_4 \)(\( b \neq a \))失配,若用之前的 next 数组,则还需要进行 \( s_4 \) 与 \( p_3 \)、\( s_4 \) 与 \( p_2 \)、\( s_4 \) 与 \( p_1 \) 这 3 次比较。事实上,因为 \( p_{\text{next}[4]} = p_3 = a \)、\( p_{\text{next}[3]} = p_2 = a \)、\( p_{\text{next}[2]} = p_1 = a \),显然后面 3 次用一个和 \( p_4 \) 相同的字符跟 \( s_4 \) 比较毫无意义,必然失配。那么问题出在哪里呢?

问题在于不应该出现 \( p_j = p_{\text{next}[j]} \)。理由是:当 \( p_j \neq s_i \) 时,下次匹配必然是 \( p_{\text{next}[j]} \) 跟 \( s_i \) 比较,若 \( p_j = p_{\text{next}[j]} \),则相当于拿一个和 \( p_j \) 相等的字符跟 \( s_i \) 比较,这必然导致继续失配,这样的比较毫无意义。若出现 \( p_j = p_{\text{next}[j]} \),则如何处理呢?

若出现 \( p_j = p_{\text{next}[j]} \),则需要再次递归,将 next\[j\] 修正为 next\[next\[j\]\],直至两者不相等为止,更新后的数组命名为 nextval。计算 next 数组修正值的算法如下,此时匹配算法不变。

```c void get_nextval(SString 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]; } } ```

KMP 算法对于初学者来说可能不太容易掌握,建议读者结合王道课程来理解。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值