String
1. Definition of String
串(string)是由零个或多个字符组成的有限序列,又叫字符串
- 记法:s = “a1a2···an”(n≥0)
- s 是串的名称
- 双引号(或单引号)括起来的字符序列是串的值
- ai(1≤i≤n) 是一个字符(字母、数字或其他字符),i 是该字符在串中的位置
- 串中的字符数目n称为串的长度
- 零个字符的串称为空串(null string),其长度为零可以由两个双引号“”表示,也可以用希腊字母 Φ 表示
- 序列:即说明串的相邻字符之间具有前驱和后继的关系
关于串的其他概念
- 空格串:只包含空格的串,可以包含不止一个空格
- 子串与主串:串中任意个数的连续字符组成的子序列称为该串的子串,相应的,包含子串的串称为主串
2. Comparison of Strings
串的比较是通过对组成串的字符之间的编码来进行的,而字符的编码指的是字符在对应字符集中的序号,如 ASCII、Unicode 等
串相等
- 通俗理解:两个串相等 <==> 两个串的长度以及它们对应位置的字符都相等
- 数学表达:给定两个串 s=“a1a2···an”,t=“b1b2···bn”,当且仅当 n=m,且a1=b1,a2=b2,···,an=bm 时,认为 s=t
串比较(两个串大小的判定)
给定两个串 s=“a1a2···an”,t=“b1b2···bn”,当满足下列条件之一,则可判定 s<t
- n<m,且 ai=bi(i=1,2,···,n)
- 存在某个 k≤min(m,n),使得 ai=bi(i=1,2,···,k-1),ak<bk
eg.
“hap” < “happy” (由 1 知)
“happen” < “happy” (由 2 知)
3. The Abstract Data Type of String
串和线性表对比
- 相同:逻辑结构相似
- 相异
- 线性表关注对单个元素的操作,如查找、插入、删除一个元素等
- 串关注对字符集的操作,如查找子串位置、得到指定位置子串、替换子串等
ADT string
- DATA 串的元素仅由一个字符组成,相邻元素具有前驱和后继的关系
- OPERATION
- StrAssign(T, *chars) 生成一个其值等于字符串常量 chars 的串 T
- StrCopy(T,S) 串 S 存在,由串 S 复制得串 T
- ClearString(S) 串 S 存在,将串清空
- StringEmpty(S) 串 S 为空,则返回 true,否则返回 false
- StrLength(S) 返回串 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 个字符开始的长度为 len 的子串
- Index(S,T,pos) 串 S,T 存在,且 T 非空串,1≤pos≤StrLength(S)。 若串 S 中存在与串 T 值相同的子串,则返回它在主串中第 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 的子串。
- 代码实现见 strlib.c
4. The Storage Structure of String
串的顺序存储结构
- 串的顺序存储结构是用一组地址连续的存储单元来存储串中的字符序列的。
- 存储方式:按照预定义的大小,为每个定义的串变量分配一个固定长度的存储区,一般用定长数组定义。有如下存储方法,
- 将实际串长度值存储在数组的 0 下标位置
|length|f|r|i|e|n|d|||||
- 在串值后边加一个不计入串长度的结束标记字符‘\0’,来表示串值的终结,此时需要通过遍历得到串的长度
|f|r|i|e|n|d|\0|||||
- 关于串的顺序存储
- 串值的存储空间可在程序执行过程中动态分配得到
- 计算机中的自由存储区(‘堆’)可以由 C 语言的动态分配函数 malloc() 和 free() 来管理
串的链式存储结构
- 串的链式存储结构与线性表类似,但是为了不造成过大的空间浪费,因此可以考虑一个节点存储多个字符,当最后一个结点未被占满时,可以用‘#’或其他非串值字符补全
两种存储结构的对比
- 链式存储结构在连接串与串操作更方便
- 但是链式存储结构不如顺序存储结构灵活,性能也次之
5. Naive Pattern Matching Algorithm (BF,Brute-Force)
模式匹配:子串的定位问题称为串的模式匹配
朴素模式匹配:对主串的每一个字符作为子串的开头,与要匹配的字符串进行匹配。对主串做大循环,每个字符开头做子串长度的小循环,直到匹配成功或全部遍历完成位置。
朴素模式匹配举例 主串 S=‘goodgoogle’ T=‘google’
轮次 图示 结果 1th 'g’oodgoogle 第四个字符匹配失败 2nd g’o’odgoogle 第一个字符匹配失败 3rd go’o’dgoogle 第一个字符匹配失败 4th goo’d’google 第一个字符匹配失败 5th good’g’oogle 六个字母全匹配成功 算法结束
//算法实现 BF.c(基于自定义的字符串数据结构,定长数组、第一个元素存储字符串长度)
/* 返回子串 T 在主串 S 中第 pos 个字符之后的位置,若不存在则返回 0 */
/* 其中要求,T 非空,且 1≤pos≤StrLength(S) */
int index(String S, String T, int pos)
{
int i = pos; // i 表示主串中当前匹配字符的下标,初始为 pos,表示从 pos 位置开始匹配
int j = 1; // j 表示子串中当前匹配字符的下标,初始为 1
/*
算法终止的条件
1. 匹配成功 => j 遍历到子串的最后一个字符且成功匹配(T[j] == S[i]),出循环时有 j == T[0] + 1
2. 匹配失败 => i 遍历到最后一个字符开启最后一个大循环,此时 j 开始的小循环遍历也没有全部成功,
故而遍历失败,出循环时有 j <= T[0] and i == S[0]
*/
while (i <= S[0] && j <= T[0])
{
if (S[i] == T[j]) // 字符匹配相等则继续匹配
{
i++;
j++;
}
else // 字符匹配不相等,指针后退宠信匹配
{
i = i - j + 2; // i 指针后退到上次匹配首位的下一位
j = 1; // j 退回到子串首位
}
}
if (j > T[0]) // 匹配成功,返回子串在主串 S 第 pos 个字符之后出现的位置
return i - T[0];
else
return 0;
}
朴素模式匹配算法分析:主串长度为 n,子串长度为 m
- 最好的情况:一开始就匹配成功,则时间复杂度为 O(m)
- 平均时间复杂度(存疑):O(n+m)
- 最坏时间复杂度(时间复杂度一般指最坏时间复杂度):即最后一次才匹配成功,且之前每次匹配,内循环都遍历 n 次,因此时间复杂度为 O((n-m+1)*m) or O(mn)
6. KMP Pattern Matching Algorithm
KMP 算法的设计思路
- 当匹配过程中主串指针和子串指针指向的字符不相同时,我们不是像 BF 算法一样去让主、子串指针后退,对主串自本轮遍历开始的字符的下一位重新进行遍历。
- 当匹配失败,由于已知之前遍历的字符,那么使用这些已知信息来避免 BF 算法中回退(backup)指针的操作步骤,从而使得算法时间复杂度线性化,这就是 KMP 算法的实现思路
- 举例如下(注意字符串第一个元素位置对应 1,这里我们使用的是自定义的字符串类型)
|A|B|A|B|A|B|C|A|A| 主串 S
|A|B|A|B|C| 子串 T
- S[5] != T[5] 匹配失败
- 按照 BF 算法的思路,需要将 S 指针 i 后退到 2,T 的指针 j 后退到 1,然后对应位置重新开始遍历比较
- 按照 KMP 算法的思路,则不移动 S 的指针 i,将 T 的指针 j 移动到 3,即如下
|A|B|A|B|A|B|C|A|A| 主串 S
-----|A|B|A|B|C| 子串 T
- 此时 i 从 5,j 从 3 开始遍历
- 从感觉上来体会,这是合乎清理的,因为主串3~4的元素和子串1~2的元素是等同的,也因此可以省略前两个多余的比较了,但是具体原理呢?
- 这是因为,我们我们第一次匹配失败的时候,T 1~4 和 S 1~4 是相匹配的,我们又发现,它们共同的串是 “ABAB”,这个串的前缀 AB 和 后缀 AB 是相同的,那么因此,我们是否就可以移动子串 T,使得 T (相对于ABAB)的前缀对应 S (相对于ABAB)的后缀呢?当然可以,此时因为二者相同,因此省去了 S 3~4 和 T 1~2 的 比较麻烦啦,直接从 S[5] 和 T[3] 开始比较就可以了。那么现在关键是,我们如何可以得知子串 T 可以将其指针 j 移动到哪一位呢?这就涉及到字符串的前后缀相似度了。
- 此外,如果是如下状况
|A|B|C|D|X|B|C|A|A| 主串 S
|A|B|C|D|E| 子串 T
- 因为子串每个元素都和首位元素不相同,且前四个元素匹配成功,那么一定可以断定,T[0] 和 S 1~4 都匹配不成功,因此情况可以变化如下
|A|B|C|D|X|B|C|A|A| 主串 S
--------|A|B|C|D|E| 子串 T
- 显然我们知道这是合乎情理与逻辑的,对应的 j 移动为 1,i 不变,那么我们是否可以同样通过前后缀相似度来解决这个 j 的变化呢?因为 S 1~4 和 T 1~4 元素各不相同,因此相似度为 0,因此 j 变化为 0 + 1 = 1
字符串的前后缀相似度及 next 数组
- 字符串的前后缀相似度
- 前后缀相似度就是字符串前缀和后缀相同时最大长度
- 函数定义
n e x t [ j ] = { 0 , j = 1 M A X { k ∣ 1 < k < j , 且 ′ P 1 ⋅ ⋅ ⋅ P k − 1 ′ = ′ P j − k + 1 ⋅ ⋅ ⋅ P j − 1 ′ } , 当集合不为空 1 , e l s e next[j] = \begin{cases} 0,& j = 1\\\\ MAX\{k|1<k<j, 且'P_1···P_{k-1}'='P_{j-k+1}···P_{j-1}'\}, & 当集合不为空\\\\ 1, & else \end{cases} next[j]=⎩ ⎨ ⎧0,MAX{k∣1<k<j,且′P1⋅⋅⋅Pk−1′=′Pj−k+1⋅⋅⋅Pj−1′},1,j=1当集合不为空else- 函数解释
- next[j] 表明 j 要更新的位置,不等同于前后缀相似度
- 函数规定的相似度计算是基于 1~j-1 个字符组成的字符串的前后缀相似度(注意 j 从 1 开始),又从上述例子我们知道,理论上,j 更新的位置应该是 相似度 + 1
- j = 1,next[j] = 0 的原因:第一个字符比较特殊(与相似度无关),这里的 0 主要起到了一个标记的作用,即此时主串的指针指向的字符,和子串的第一个字符不匹配 =》 那么可以知道,此时需要的操作(j 已经更新为 0)是主串指针移动,而子串指针指向第一个元素,即 i++,j++(j是0+1=1)
- else,其他情况即,当前字串指针 j 指向的不是子串的第一个字符,且对于子串 T 而言,1~j-1 个字符组成的字符串没有相同的前后缀子串,因此可以显然知道,我们要做的就是让主串指针递增,子串指针更新为 1(这和我们上述举的第二个例子很相似)。也可以通过 相似度 + 1 = 0 + 1 的角度来理解 next[j] = 1。
- 对于一般情形,符合我们上述讨论 next[j] = 相似度 + 1
算法实现
- next 数组的实现
// 注意,第一个字符对应的索引时 1! /* 通过计算返回子串T的next数组。 */ void get_next(String T, int *next) { int i = 1; // 指向模板字符串字符的指针 int k = 0; // 当前字符前的字符串对应的前后缀相似度 next[1] = k; /* next 数组算法思路(注意,next 算法得到的值,是要更新的下标的位置) if j = 1 then next[j] = 0,起到标记作用 elif 1~j-1 组成的字符串最大前后缀相似度不为0 then next[j] = 该最大前后缀相似度 else then next[j] = 1 因为此时 1~j-1 对应的字符串全都不相同,因此要更新的话,也不过是需要将 j = 1 上述代码已经处理了 j = 1 时 next = 0 的情形,因此我们只需要考虑上述条件分支结构的第二第三点 */ while (i < T[0]) /* 此处T[0]表示串T的长度 */ { if (k == 0 || T[i] == T[k]) { ++i; ++k; next[i] = k; } else k = next[k]; /* 若字符不相同,则k值回溯 */ } }
- KMP 算法的实现
/* 返回子串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的长度时,循环继续 */ { 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; }
KMP 算法分析
- BMP VS. BF
- 去除了 i 值回溯部分
- KMP 算法的时间复杂度是 O(m + n) 相较于 BF 的时间复杂度 O((n-m+1)*m) 好
- 仅当模式串与主串之间存在许多部分匹配的情况下才能体现出优势,否则两者差异不明显
7. KMP Pattern Matching Algorithm IMPROVED
为了解决类似
S:a a a a b c d e f ···
T:a a a a a x
之间的匹配,发现 T 如果在 S[5]!=T[5] 后可以直接使得 T[1] 对准 S[6] 就好了,因此选择更改 next 数组为 nextVal 数组解决问题
由 next 导出 nextVal
if patt[i] == patt[next[i]]
then nextVal[i] = nextVal[next[i]]
else
then nextVal[i] = next[i]
说明
- 此笔记基于网络资源及《大话数据结构整理》
- 推荐 KMP 算法学习视频 链接: 点击进入哔哩哔哩观看