《大话数据结构》笔记——第5章 串

文章目录

  • 5.1 开场白
  • 5.2 串的定义
  • 5.3 串的比较
  • 5.4 串的抽象数据结构
  • 5.5 串的存储结构
    • 5.5.1 串的顺序存储结构
    • 5.5.2 串的链式存储结构
  • 5.6 朴素的模式匹配算法
  • 5.7 KMP 模式匹配算法
    • 5.7.1 KMP 模式匹配算法原理
    • 5.7.2 next 数组推导
    • 5.7.3 KMP 模式匹配算法实现
    • 5.7.4 KMP 算法的改进
    • 5.7.5 nextval 数组值推导
  • 5.8 总结回顾
  • 5.9 结尾语

5.1 开场白

5.2 串的定义

串(String)是由零个或多个字符组成的有限序列,又名叫字符串

一般记为 s=“a1a2…an”(n>0),其中,s 是串的名称,用双引号括起来的字符序列是串的值,注意引号不属于串的内容。ai(1<=i<=n) 可以是字母、数字或其他字符,i 就是该字符在串中的位置。串中的字符数目 n 称为串的长度,定义中谈到"有限"是指长度 n 是一个有限的数值。零个字符的串称为空串(null string),它的长度为零,可以直接用两双引号 “” 表示,也可以用希腊字母"ø"来表示。所谓的序列,说明串的相邻字符之间具有前驱和后继的关系。

还有一些概念需要解释:

  • 空格串,是只包含空格的串。注意它与空串的区别,空格串是有内容有长度的,而且可以不止一个空格。
  • 子串与主串,串中任意个数的连续字符组成的子序列称为该串的子串,相应地,包含子串的串称为主串。
  • 子串在主串中的位置就是子串的第一个字符在主串中的序号。

5.3 串的比较

两个数字,很容易比较大小。2 比 1 大,这完全正确,可是两个字符串如何比较?比如 “silly”、“stupid” 这样的同样表达"愚蠢的"的单词字符串,它们在计算机中的大小其实取决于它们挨个字母的前后顺序。它们的第一个字母都是 “s”,我们认为不存在大小差异,而第二个字母,由于 “i” 字母比 “t” 字母要靠前,所以 “i”<“t”,于是我们说 “silly”<“stupid”。

事实上,串的比较是通过组成串的字符之间的编码来进行的,而字符的编码指的是字符在对应字符集中的序号

计算机中的常用字符是使用标准的 ASCII 编码,更准确一点,ASCII 码由 8 位二进制数表示一个字符,总共可以表示256个字符,这已经足够满足以英语为主的语言和特殊符号进行输入、存储、输出等操作的字符需要了。可是,单我们国家就有除汉族外的满、回、藏、蒙古、维吾尔等多个少数民族文字,换作全世界估计要有成百上千种语言与文字,显然这 256 个字符是不够的,因此后来就有了 Unicode 编码,比较常用的是由 16 位的二进制数表示一个字符 ,这样总共就可以表示 2^16 个字符,约是 65 万多个字符,足够表示世界上所有语言的所有字符了。当然,为了和 ASCII 码兼容,Unicode 的前 256 个字符与 ASCIl 码完全相同。

所以如果我们要在 C 语言中比较两个串是否相等,必须是它们串的长度以及它们各个对应位置的字符都相等时,才算是相等。即给定两个串:s=“a1a2…an”,t=“b1b2…bm”,当且仅当 n=m,且 a1=b1,a2=b2,…,an=bm时,我们认为 s=t。

那么对于两个串不相等时,如何判定它们的大小呢。我们这样定义:

  1. n

    例如:当 s=“hap”,t=“happy”,就有 s

  2. 存在某个 k<=min(m,n),使得 ai=bi(i=1,2,…,k-1),ak

    例如:当 s=“happen”,t=“happy”,因为两串的前 4 个字母均相同,而两串第 5 个字母(k值),字母 e 的 ASCII 码是 101,而字母 y 的 ASCII 码是 121,显然 e

5.4 串的抽象数据结构

串的逻辑结构和线性表很相似,不同之处在于串针对的是字符集,也就是串中的元素都是字符

因此,对于串的基本操作与线性表是有很大差别的。线性表更关注的是单个元素的操作,比如查找一个元素,插入或删除一个元素,但串中更多的是查找子串位置、得到指定位置子串、替换子串等操作。

ADT 串(string)
Data
    串中元素仅由一个字符组成,相邻元素具有前驱和后继关系。
Opration
    StrAssign(T, *chars):生成一个其值等于字符串常量chars的串T。
    StrCopy(T, S):串S存在,由串S复制得串T。
    ClearString(S):若串S存在,将串清空。 
    StringEmpty(S):若串S为空,返回true,否则返回false。
    StrLength(S):返回串的元素个数,即串的长度。
    StrCompare(S, T):若S > T,返回值 > 0,若S = T,返回0,若S < T,返回值 < 0。 
    Concat(T, S1, S2):用T返回由S1和S2联接而成的新串。
    SubString(Sub, S, pos, len):串S存在,1≤pos≤StrLength(S), 
            且0≤len≤StrLength(S) - pos + 1,
            用Sub返回串S的第pos个字符,用Sub返回串S的第pos个字符起长度为len的字串。
    Index(S, T, pos):串S和T存在,T是非空串,1≤pos≤StrLength(S)。
            若主串S中存在和串T相同的子串,则返回它在主串S中第pos个字符之后第一个出现的位置,则返回0。
    Replace(S, T, V):串S、T和V存在,T是非空串。用V替换主串S中出现的所有与T相等的不重叠的子串。
    StrInsert(S, pos, T):串S和T存在,1≤pos≤StrLength(S) + 1。在串S的第pos个字符之前插入串T。
    StrDelete(S, pos, len):串S存在,1≤pos≤StrLength(S) - len + 1。
    		从串S中删除第pos个字符起长度为len的子串。
endADT

我们来看一个操作 Index 的实现算法。

/*
 * T为非空串。若主串S中第pos个字符串之后存在与T相等的子串,
 * 则返回第一个这样的子串在S中的位置,否则返回0
 */
int Index(string  S, string  T, int pos)
{
	int n, m, i;
	string sub;
	if (pos > 0)
	{
		n = StrLength(S); //得到主串S的长度
		m = StrLenhth(T); //得到子串T的长度
		i = pos;
		while (i <= n - m + 1)
		{
			SubString(sub, S, i, m); //取主串第i个位置,长度与T相等子串给sub
			if (StrCompare(sub, T) != 0) //如果两串不相等
			{
				++i;
			}
			else //如果两串相等
			{
				return i; //返回i值
			}
		}
	}
	return 0; //若无子串与T相等,返回0
}

5.5 串的存储结构

5.5.1 串的顺序存储结构

串的顺序存储结构是用一组地址连续的存储单元来存储串中的字符序列的。按照预定义的大小,为每个定义的串变量分配一个固定长度的存储区。一般是用定长数组来定义

既然是定长数组,就存在一个预定义的最大串长度,一般可以将实际的串长度值保存在数组的 0 下标位置,有的书中也会定义存储在数组的最后一个下标位置。

刚才讲的串的顺序存储方式其实是有问题的,因为字符串的操作,比如两串的连接 Concat、新串的插入 StrInsert,以及字符串的替换 Replace。都有可能使得串序列的长度超过了数组的长度 MaxSize

于是对于串的顺序存储,有一些变化,串值的存储空间可在程序执行过程中动态分配而得。比如在计算机中存在一个自由存储区,叫做"堆"。这个堆可由 C 语言的动态分配函数 malloc() 和 free() 来管理。

5.5.2 串的链式存储结构

对于串的链式存储结构,与线性表是相似的,但由于串结构的特殊性,结构中的每个元素数据是一个字符,如果也简单的应用链表存储串值,一个结点对于一个字符,就会存在很大的空间浪费。因此,一个结点可以存放一个字符,也可以考虑存放多个字符最后一个结点若是未被占满时,可以用 “#” 或其他非串值字符补全,如图 5-5-3 所示。

在这里插入图片描述

当然,这里一个结点存多少个字符才合适就变得很重要,这会直接影响着串处理的效率,需要根据实际情况作出选择。

但串的链式存储结构除了在连接串与串操作时有一定方便之外,总的来说不如顺序存储灵活,性能也不如顺序存储结构好。

5.6 朴素的模式匹配算法

写一个程序,只要输入一些英文的文档,就可以计算出这当中所用频率最高的词汇是哪些。

要实现这一需求,当中会有很多困难。不过,这里面最重要其实就是去找一个单词在一篇文章(相当于一个大字符串)中的定位问题。这种子串的定位操作通常称做串的模式匹配,应该算是串中最重要的操作之一。

假设我们要从下面的主串 S=“goodgoogle” 中,找到 T=“google” 这个子串的位置。我们通常需要下面的步骤。

  1. 主串 S 第一位开始,S 与 T 前三个字母都匹配成功,但 S 第四个字母是 d 而 T 的是 g。第一位匹配失败。如图 5-6-1 所示,其中竖直连线表示相等,闪电状弯折连线表示不等。

    《大话数据结构》笔记——第5章 串_第1张图片

  2. 主串 S 第二位开始,主串 S 首字母是 o,要匹配的 T 首字母是 g,匹配失败,如图5-6-2所示。

    《大话数据结构》笔记——第5章 串_第2张图片

  3. 主串 S 第三位开始,主串 S 首字母是 o,要匹配的 T 首字母是 g,匹配失败,如图 5-6-3 所示。

    《大话数据结构》笔记——第5章 串_第3张图片

  4. 主串 S 第四位开始,主串 S 首字母是 d,要匹配的 T 首字母是 g,匹配失败,如图 5-6-4 所示。

    《大话数据结构》笔记——第5章 串_第4张图片

  5. 主串 S 第五位开始,S 与 T,6 个字母全匹配,匹配成功,如图 5-6-5 所示。

    《大话数据结构》笔记——第5章 串_第5张图片

简单的说,就是对主串的每一个字符作为子串开头,与要匹配的字符串进行匹配。对主串做大循环,每个字符开头做 T 的长度的小循环,直到匹配成功或全部遍历完成为止

前面我们已经用串的其他操作实现了模式匹配的算法 Index。现在考虑不用串的其他操作,而是只用基本的数组来实现同样的算法。注意我们假设主串 S 和要匹配的子串 T 的长度存在 S[0] 与 T[0] 中。实现代码如下:

/*
 * 返回子串T在主串S中第pos个字符之后的位置。若不存在,则函数返回值为0。
 * T 非空,1≤pos≤StrLength(S)。
 */
int Index(string S, string T, int pos)
{
	int i = pos; //i用于主串S中当前位置下标,若pos不为1,则从pos位置开始匹配
	int j = 1; //j用于子串T中当前位置下标值
	while (i <= S[0] && j <= T[0]) //若i小于S长度且j小于T的长度时循环
	{
		if (S[i] == T[j]) //两字母相等则继续
		{
			++i;
			++j;
		}
		else //指针后退重新开始匹配
		{
			i = i - j + 2; //i退回到上次匹配首位的下一位
			j = 0; //j退回到子串T的首位
		}
	}
	if (j > T[0])
	{
		return i - T[0];
	}
	else
	{
		return 0;
	}
}

分析一下,最好的情况是什么?那就是一开始就匹配成功,比如 “googlegood” 中去找 “google”,时间复杂度为 O(m)。稍差一些,如果像刚才例子中第二、三、四位一样,每次都是首字符就不匹配,那么对 T 串的循环就不必进行了,比如 “abcdefgoogle” 中去找 “google”。那么时间复杂度为 O(n+m),其中 n 为主串长度,m 为要匹配的子串长度。根据等概率原则,平均是 (n+m)/2 次查找,时间复杂度为 O(n+m)。

那么最坏的情况又是什么?就是每次不成功的匹配都发生在串 T 的最后一个字符。举一个很极端的例子。主串为 S=“000000000000000000000000000000001”,而要匹配的子串为 T=“0000000001”。在匹配时,每次都得将 T 中字符循环到最后一位才发现:哦,原来它们是不匹配的。这样等于 T 串需要在 S 串的前 40 个位置都需要判断 10 次,并得出不匹配的结论,如图 5-6-6 所示。

《大话数据结构》笔记——第5章 串_第6张图片

直到最后第 41 个位置,因为全部匹配相等,所以不需要再继续进行下去,如图 5-6-7 所示。如果最终没有可匹配的子串,比如是 T=“0000000002”,到了第 41 位置判断不匹配后同样不需要继续比对下去。因此最坏情况的时间复杂度为 O((n-m+1)*m)

《大话数据结构》笔记——第5章 串_第7张图片

在计算机的运算当中,模式匹配操作可说是随处可见,而刚才的这个算法,就显得太低效了

5.7 KMP 模式匹配算法

你们可以忍受朴素模式匹配算法的低效吗?也许不可以、也许无所谓。但在很多年前我们的科学家们,觉得像这种有多个 0 和 1 重复字符的字符串,却需要挨个遍历的算法是非常糟糕的事情。于是有三位前辈,D.E.Knuth、J.H.Morris 和 V.R.Pratt(其中 Knuth 和 Pratt 共同研究,Morris 独立研究)发表一个模式匹配算法,可以大大避免重复遍历的情况,我们把它称之为克努特-莫里斯-普拉特算法,简称 KMP 算法

5.7.1 KMP 模式匹配算法原理

为了能讲清楚 KMP 算法,我们不直接讲代码,那样很容易造成理解困难,还是从这个算法的研究角度来理解为什么它比朴素算法要好。

如果主串 S=“abcdefgab”,其实还可以更长一些,我们就省略掉只保留前 9 位,我们要匹配的 T=“abcdex”,那么如果用前面的朴素算法的话,前 5 个字母,两个串完全相等,直到第 6 个字母,“f” 与 “x” 不等,如图 5-7-1 的 (1) 所示。

《大话数据结构》笔记——第5章 串_第8张图片

接下来,按照朴素模式匹配算法,应该是如图 5-7-1 的流程 (2)(3)(4)(5)(6)。即主串中当 i=2,3,4,5,6 时,首字符与子串 T 的首字符均不等。

似乎这也是理所当然,原来的算法就是这样设计的。可仔细观察发现。对于要匹配的子串 T 来说,“abcdex” 首字母 “a” 与后面的串 “bcdex” 中任意一个字符都不相等。也就是说,既然 “a” 不与自己后面的子串中任何一字符相等,那么对于图 5-7-1 的 (1) 来说,前五位字符分别相等,意味着子串 T 的首字符 “a” 不可能与 S 串的第 2 位到第 5 位的字符相等。在图 5-7-1 中,(2)(3)(4)(5) 的判断都是多余。

注意这里是理解 KMP 算法的关键。如果我们知道 T 串中首字符 “a” 与 T 中后面的字符均不相等。而 T 串的第二位的 “b” 与 S 串中第二位的 “b” 在图 5-7-1 的 (1) 中已经判断是相等的,那么也就意味着,T 串中首字符 “a” 与 S 串中的第二位 “b” 是不需要判断也知道它们是不可能相等了,这样图 5-7-1 的 (2) 这一步判断是可以省略的,如图 5-7-2 所示。

《大话数据结构》笔记——第5章 串_第9张图片

同样道理,在我们知道 T 串中首字符 “a” 与 T 中后面的字符均不相等的前提下,T 串的 “a” 与 S 串后面的字符均不相等的前提下,T 串的 “a” 与 S 串后面的 “c”,“d”,“e” 也都可以在 (1) 之后就可以确定是不相等的,所以这个算法当中 (2)(3)(4)(5) 没有必要,只保留 (1)(6) 即可,如图 5-7-3 所示。

《大话数据结构》笔记——第5章 串_第10张图片

之所以保留 (6) 中的判断是因为在 (1) 中 T[6]!=S[6],尽管我们已经知道 T[1]!=T[6],但也不能断定 T[1] 一定不等于 S[6],因此需要保留 (6) 这一步。

有人就会问,如果 T 串后面也含有首字符 “a” 的字符怎么办呢

我们来看下面一个例子,假设 S=“abcabcabc”,T=“abcabx”。对于开始的判断,前 5 个字符完全相等,第 6 个字符不等,如图 5-7-4 的 (1)。此时,根据刚才的经验,T 的首字符 “a” 与 T 的第二位字符 “b”,第三位字符 “c” 均不等,所以不需要做判断,图 5-7-4 的朴素算法步骤 (2)(3) 都是多余

《大话数据结构》笔记——第5章 串_第11张图片

因为 T 的首位 “a” 与 T 第四位的 “a” 相等,第二位的 “b” 与第五位的 “b” 相等。而在 (1) 时。第四位的 “a” 与第五位的 “b” 已经与主串 S 中的相应位置比较过了,是相等的,因此可以断定,T 的首字符 “a”、第二位的字符 “b” 与 S 的第四位字符和第五位字符也不需要比较了,肯定也是相等的——之前比较过了,还判断什么,所以 (4)(5) 这两个比较得出字符相等的步骤也可以省略

也就是说,对于在子串中有与首字符相等的字符,也是可以省略一部分不必要的判断步骤。如图 5-7-5 所示,省略掉右图的 T 串前两位 “a” 与 “b” 同 S 串中的 4、5 位置字符匹配操作。

《大话数据结构》笔记——第5章 串_第12张图片

对比这两个例子,我们会发现在 (1) 时,我们的 i 值,也就是主串当前位置的下标是 6,(2)(3)(4)(5),i 值是 2,3,4,5,到了 (6),i 值才又回到了 6。即我们在朴素的模式匹配算法中,主串的 i 值是不断地回溯来完成的。而我们的分析发现,这种回溯其实是可以不需要的——正所谓好马不吃回头草,我们的 KMP 模式匹配算法就是为了让这没必要的回溯不发生

既然 i 值不回溯,也就是不可以变小,那么要考虑的变化就是 j 值了。通过观察也可发现,我们屡屡提到了 T 串的首字符与自身后面字符的比较,发现如果有相等字符,j 值的变化就会不相同。也就是说,这个 j 值的变化与主串其实没什么关系,关键就取决于 T 串的结构中是否有重复的问题

比如图 5-7-3 中,由于 T=“abcdex”,当中没有任何重复的字符,所以j就由 6 变成了 1。而图 5-7-4 中,由于 T=“abcabx”,前缀的 “ab” 与最后 “x” 之前串的后缀 “ab” 是相等的。因此 j 就由 6 变成了 3。因此,我们可以得出规律,j 值的多少取决于当前字符之间的串的前后缀的相似度

5.7.2 next 数组推导

我们把 T 串各个位置的 j 值的变化定义为一个数组 next,那么 next 的长度就是 T 串的长度。于是我们可以得到下面的函数定义:

《大话数据结构》笔记——第5章 串_第13张图片

具体如何推导出一个串的 next 数组值呢,我们来看一些例子。

  1. T=“abcdex”(如表 5-7-1 所示)

    《大话数据结构》笔记——第5章 串_第14张图片

    • 当 j=1 时,next[1]=0;
    • 当 j=2 时,j 由 1 到 j-1 就只有字符 “a”,属于其他情况 next[2]=1;
    • 当 j=3 时,j 由 1 到 j-1 串是 “ab”,显然 “a” 与 “b” 不相等,属其他情况,next[3]=1;
    • 以后同理,所以最终此T串的 next[j] 为 011111。
  2. T=“abcabx”(如图 5-7-2 所示)

    《大话数据结构》笔记——第5章 串_第15张图片

    • 当 j=1 时,next[1]=0;
    • 当 j=2 时,同上例说明,next[2]=1;
    • 当 j=3 时,同上,next[3]=1;
    • 当 j=4 时,同上,next[4]=1;
    • 当 j=5 时,此时 j 由 1 到 j-1 的串是 “abca”,前缀字符 “a” 与后缀字符 “a” 相等,因此可推算出 k 值为 2(由 ‘p1…p(k-1)’=‘p(j-k+1)…p(j-1)’,得到 p1=p4),因此 next[5]=2;
    • 当 j=6 时,j 由 1 到 j-1 的串是 “abcab”,由于前缀字符 “ab” 与后缀 “ab” 相等,所以next[6]=3。

    我们可以根据经验得到如果前后缀一个字符相等,k 值是 2,两个字符 k 值是 3,n 个字符相等 k 值就是 n+1

  3. T=“ababaaaba”(如表 5-7-3 所示)

    《大话数据结构》笔记——第5章 串_第16张图片

    • 当 j=1 时,next[1]=0;
    • 当 j=2 时,同上 next[2]=1;
    • 当 j=3 时,同上 next[3]=1;
    • 当 j=4 时,j 由 1 到 j-1 的串是 “aba”,前缀字符 “a” 与后缀字符 “a” 相等,next[4]=2;
    • 当 j=5 时,j 由 1 到 j-1 的串是 “abab”,由于前缀字符 “ab” 与后缀 “ab” 相等,所以 next[5]=3;
    • 当 j=6 时,j 由 1 到 j-1 的串是 “ababa”,由于前缀字符 “aba” 与后缀 “aba” 相等,所以 next[6]=4;
    • 当 j=7 时,j 由 1 到 j-1 的串是 “ababaa”,由于前缀字符 “ab” 与后缀 “aa” 并不相等,只有 “a” 相等,所以 next[7]=2;
    • 当 j=8 时,j 由 1 到 j-1 的串是 “ababaaa”,只有 “a” 相等,所以 next[8]=2;
    • 当 j=9 时,j 由 1 到 j-1 的串是 “ababaaab”,由于前缀字符 “ab” 与后缀 “ab” 相等,所以 next[9]=3。
  4. T=“aaaaaaaab”(如表 5-7-4 所示)

    《大话数据结构》笔记——第5章 串_第17张图片

    • 当 j=1 时,next[1]=0;
    • 当 j=2 时,同上 next[2]=1;
    • 当 j=3 时,j 由 1 到 j-1 的串是 “aa”,前缀字符 “a” 与后缀字符 “a” 相等,next[3]=2;
    • 当 j=4 时,j 由 1 到 j-1 的串是 “aaa”,由于前缀字符 “aa” 与后缀 “aa” 相等,所以 next[4]=3;
    • 当 j=9 时,j 由 1 到 j-1 的串是 “aaaaaaaa”,由于前缀字符 “aaaaaaa” 与后缀 “aaaaaaa” 相等,所以 next[9]=8。

5.7.3 KMP 模式匹配算法实现

说了这么多,我们可以来看看代码了。

/* 通过计算返回子串 T 的 next 数组 */
void get_next(String T, int *next)
{
	int i, j;
	i = 1;
	j = 0;
	next[1] = 0;
	while (i < T[0]) //此处 T[0] 表示串 T 的长度
	{
		if (j == 0 || T[i] == T[j]) // T[i] 表示后缀的单个字符,T[j] 表示前缀的单个字符 
		{
			++i;
			++j;
			next[i] = j;
		}
		else
		{
			j = next[j]; //若字符不相同,则 j 值回溯
		}
	}
}
/* 返回子串 T 在主串 S 中第 pos 个字符之后的位置。若不存在,则函数返回为 0
   T 非空,1≤pos≤StrLength(S)
*/
int Index_KMP(String S, String T, int pos)
{
	int i = pos; // i 用于主串 S 当前位置下标值,若 pos 不为 1,则从 pos 位置开始匹配
	int j = 1; // j 用于子串 T 中当前位置下标值
	/* 加粗部分开始 */
	int next[255]; //定义一个 next 数组
	get_next(T, next); //对串 T 作分析,得到 next 数组
	/* 加粗部分结束 */
	while (i <= S[0] && j <= T[0]) //若 i 小于 S 的长度且 j 小于 T 的长度时,继续循环 
	{
		/* j==0 加粗 */
		if (j == 0 || S[i] == T[j]) //两字母相等则继续,比朴素算法增加了 j=0 判断
		{
			++i;
			++j;
		}
		else //指针后退重新开始匹配
		{
			/* 加粗部分开始 */
			j = next[j]; // j 退回合适的位置,i 值不变
			/* 加粗部分结束 */
		}
	}
	if (j > T[0])
	{
		return i - T[0];
	}
	else
	{
		return 0;
	}
}

加粗的为相对于朴素匹配算法增加的代码,改动不算大,关键就是去掉了 i 值回溯的部分。对于 get_next 函数来说,若 T 的长度为 m,因只涉及到简单的单循环,其时间复杂度为 O(m),而由于 i 值的不回溯,使得 index_KMP 算法效率得到了提高,while 循环的时间复杂度为 O(n)。因此,整个算法的时间复杂度为 O(n+m)。相较于朴素模式匹配算法的 O((n-m+1)*m) 来说,是要好一些。

这里也需要强调,KMP 算法仅当模式与主串之间存在许多"部分匹配"的情况下才体现出它的优势,否则两者差异并不明显

5.7.4 KMP 算法的改进

后来有人发现,KMP 还是有缺陷的。比如,如果我们的主串 S=“aaaabcde”,子串 T=“aaaaax”,其 next 数组值分别为 012345,在开始时,当 i=5、j=5 时,我们发现 “b” 与 “a” 不相等,如图 5-7-6 的 (1),因此 j=next[5]=4,如图中的 (2),此时 “b” 与第 4 位置的 “a” 依然不等,j=next[4]=3,如图中的 (3),后依次是 (4)(5),直到 j=next[1]=0 时,根据算法,此时 i++,j++,得到 i=6,j=1,如图中的 (6)。

《大话数据结构》笔记——第5章 串_第18张图片

我们发现,当中的 (2)(3)(4)(5) 步骤,其实是多余的判断。由于T串的第二、三、四、五位置的字符都与首位的 “a” 相等,那么可以用首位 next[1] 的值去取代与它相等的字符后续 next[j] 的值,这是个很好的办法。因此我们对求 next 函数进行了改良。

假设取代的数组为 nextval,增加了加粗部分,代码如下:

/* 求模式串 T 的 next 函数修正值并存入数组 nextval */
void get_nextval(String T, int *nextval)
{
	int i, j;
	i = 1;
	j = 0;
	nextval[1] = 0;
	while (i < T[0]) //此处 T[0] 表示串 T 的长度
	{
		if (j == 0 || T[i] == T[j]) // T[i] 表示后缀的单个字符 T[j] 表示前缀的单个字符
		{
			++i;
			++j;
            /* 加粗开始 */
			if (T[i] != T[j]) //若当前字符与前缀字符不同
			{
				nextval[i] = j; //则当前的 j 为 nextval 在 i 位置的值
			}
			else
			{
				//如果与前缀字符相同,则将前缀字符的 nextval 值赋值给 nextval 在 i 位置的值
				nextval[i] = nextval[j]; 
			}
            /* 加粗结束 */
		}
		else
		{
			j = nextval[j]; //若字符不相同,则 j 值回溯
		}
	}
}

5.7.5 nextval 数组值推导

改良后,我们之前的例子 nextval 值就与 next 值不完全相同了。比如:

  1. T=“ababaaaba”(如表 5-7-5 所示)

    《大话数据结构》笔记——第5章 串_第19张图片

    先算出 next 数组的值分别是 001234223,然后再分别判断。

    • 当 j=1 时。nextval[1]=0;
    • 当 j=2 时,因第二位字符 “b” 的 next 值是 1,而第一位就是 “a”,它们不相等,所以 nextval[2]=next[2]=1
    • 当 j=3 时,因为第三位字符 “a” 的 next 值为 1,所以与第一位的 “a” 比较得知它们相等,所以 nextval[3]=nextval[1]=0
    • 当 j=4 时,第四位的字符 “b”,next 值为 2,所以与第二位的 “b” 相比较得到结果是相等,因此 nextval[4]=nextval[2]=1。
    • 当 j=5 时,next 值为 3,第五个字符 “a” 与第三个字符 “a” 相等,因此 nextval[5]=nextval[3]=0;
    • 当 j=6 时,next 值为 4,第六个字符 “a” 与第四个字符 “b” 不相等,因此 nextval[6]=4;
    • 当 j=7 时,next 值为 2,第七个字符 “a” 与第二个字符 “b” 不相等,因此 nextval[7]=2;
    • 当 j=8 时,next 值为 2,第八个字符 “b” 与第二个字符 “b” 相等,因此 nextval[8]=nextval[2]=1;
    • 当 j=9 时,next 值为 3,第九个字符 “a” 与第三个字符 “a” 相等,因此 nextval[9]=nextval[3]=1。
  2. T=“aaaaaaaab”(如表 5-7-6)

    《大话数据结构》笔记——第5章 串_第20张图片

    先算出 next 数组的值分别为 012345678,然后再分别判断。

    • 当 j=1 时,nextval[1]=0;
    • 当 j=2 时,next 值为 1,第二个字符与第一个字符相等,所以 nextval[2]=nextval[1]=0;
    • 同样的道理,其后都为 0…;
    • 当 j=9 时,next 值为 8,第九个字符 “b” 与第八个字符 “a” 不相等,所以 nextval[9]=9。

总结改进过的 KMP 算法,它是在计算出 next 值的同时,如果 a 位字符与它 next 值指向的 b 位字符相等,则该 a 位的 nextval 就指向 b 位的 nextval 值,如果不等,则该 a 位的 nextval 值就是它自己 a 位的 next 的值

5.8 总结回顾

这一章节我们重点讲了"串"这样的数据结构,串(string)是由零个或多个字符组成的有限序列,又名字符串。本质上,它是一种线性表的扩展,但相对于线性表关注一个个元素来说,我们对串这种结构更多的是关注它子串的应用问题,如查找、替换等操作。现在的高级语言都有针对串的函数可以调用。我们在使用这些函数的时候,同时也应该要理解它当中的原理,以便于在碰到复杂的问题时,可以更加灵活的使用,比如 KMP 模式匹配算法的学习,就是更有效地去理解 index 函数当中的实现细节。

5.9 结尾语

你可能感兴趣的:(#,《大话数据结构》笔记)