我们在平常开发过程当中,针对字符串的处理那可以说是十分常见的了,所以我们今天就看两种字符串当中的算法,不过我们在看具体算法的实现之前,我们先来了解一下字符串的概念
字符串
在计算机刚被发明的时候,主要作用是做一些科学和工程的计算工作,刚开始的计算机都是处理数值工作,直到后来引入了『字符串』的概念,这样一来计算机开始可以处理非数值的概念了(原理是用数值来模拟非数值,通过 ASCII
码表),我们先来看下『串』这样的数据结构
- 串(
string
)是由零个或多个字符组成的『有限序列』,又名叫字符串 - 一般记为
s = "a1a2a3 ... an"(n >= 0)
- 串可以是空串,即没有字符,直接由
""
表示,或者可以用希腊字母Φ
(fai
)来表示 - 子串与主串的概念,例如
"abc"
是"abcdef"
子串,反之则倒过来
字符串的存储结构
- 字符串的存储结构与线性表相同,也分顺序存储结构和链式存储结构
- 字符串的顺序存储结构是用一组地址连续的存储单元来存储串中的字符序列的
- 按照预定义的大小,为每个定义的字符串变量分配一个固定长度的存储区,一般用定长数组来定义
- 与线性表相似,既然是固定长度的存储区,就存在一个空间分配不灵活的问题,那么会考虑用链式存储结构
- 不同的是字符串我们一般都是连在一起表述的,所以习惯上我们通常还是会直接定义一个足够长度的存储区来存储的
字符串的比较
字符串比较大小跟传统的数字比较有点差别,很容易我们可以知道数字 2
比 1
要大,可要是字符串之间的比较呢?没错,也是比较大小,但是比较的是字符串里每个字符的 ASCII
码大小,因为 "a"
的 ASCII
码是 97
,"A"
的 ASCII
码是 65
,所以 'abc' > 'Abc'
是成立的,但是这样的比较并没有太大的意义,我们比较关注的还是两个字符串之间是否相等,这也是本章当中将要介绍的内容,即 BF
和 KMP
算法
BF 算法
BF
算法,即暴力(Brute Force
)算法,是普通的模式匹配算法,BF
算法的思想就是将目标串 S
的第一个字符与模式串 T
的第一个字符进行匹配
- 若相等,则继续比较
S
的第二个字符和T
的第二个字符 - 若不相等,则比较
S
的第二个字符和T
的第一个字符,依次比较下去,直到得出最后的匹配结果
BF
算法属于朴素的模式匹配算法,它的核心思想是
- 有两个字符串
S
和T
,长度为N
和M
- 首先
S[1]
和T[1]
比较,若相等,则再比较S[2]
和T[2]
,一直到T[M]
为止,若S[1]
和T[1]
不等,则T
向右移动一个字符的位置,再依次进行比较 - 该算法最坏情况下要进行
M * (N - M + 1)
次比较,所以时间复杂度为O(M * N)
C
语言版本实现如下
1 | // 返回子串 T 在主串 S 中第 pos 个字符之后的位置,若不存在,则返回 0 |
JavaScript
版本如下
1 | function indexOf(str, key) { |
KMP 算法
下面我们再来看一种比较复杂的算法,也就是 KMP
算法
KMP
,也称为Knuth-Morris-Pratt
字符串查找算法,简称为KMP
算法,常用于在一个文本串S
内查找一个模式串P
的出现位置,这个算法由Donald Knuth
、Vaughan Pratt
、James H. Morris
三人于1977
年联合发表,故取这3
人的姓氏命名此算法
相较于 BF
算法,KMP
算法的主旨是尽量的减少指针的回溯从而使得性能得到提高(主要是文本串的指针,下面可以发现),我们先来看一下 KMP
算法 的操作流程
- 假设现在文本串
S
匹配到i
位置,模式串P
匹配到j
位置 - 如果
j = -1
,或者当前字符匹配成功(即S[i] == P[j]
),都令i++
和j++
,然后继续匹配下一个字符 - 如果
j != -1
,且当前字符匹配失败(即S[i] != P[j]
),则令i
不变,j = next[j]
(此举意味着失配时,模式串P
相对于文本串S
向右移动了j - next[j]
位) - 换言之,将模式串
P
失配位置的next
数组的值对应的模式串P
的索引位置移动到失配处
是不是看上去一头雾水,没关系,下面我们来结合图片一步一步往下看,其实 KMP
算法的两个难点在于『前缀表』与『next
数组』的获取,如果搞懂了这两个概念以后,在理解起来就是非常的简单了,我们以下图文本串 S
与模式串 P
为例
我们首先可以得出模式串 P
的所有『子串』,如下图
然后在求得每一个模式串中『相等的前缀与后缀的最大长度』,所以这里我们就需要稍微提及一下前缀与后缀的概念,所谓『前缀』指除了最后一个字符以外,一个字符串的全部头部组合,而『后缀』是指除了第一个字符以外,一个字符串的全部尾部组合,可以参考下面这个例子加深印象
abcdef
的前缀 ==>a、ab、abc、abcd、abcde
(注意,abcdef
不是前缀)abcdef
的后缀 ==>f、ef、def、cdef、bcdef
(注意,abcdef
不是后缀)- 公共最大长 ==>
0
(因为其前缀与后缀没有相同的)
ababa
的前缀 ==>a、ab、aba、abab
ababa
的后缀 ==>a、ba、aba、baba
- 公共最大长 ==>
3
(因为他们的公共前缀后缀中最长的为aba
,长度3
)
我们以上图当中标注为橙色的第五行的 abaab
为例,前缀和后缀分别如下图所示
然后我们可以得出『两者相同部分』的『最大长度』,也就是图中标注绿色的部分,由图可知为 2
,然后在依据此原理,依次推算出所有子串的『相等的前缀与后缀的最大长度』,结果如下图所示
但是这里要多说一句,就是有没有什么方法可以让我们快速的获取到所有子串的『相等的前缀与后缀的最大长度』呢?方法是有的,这里我们以第四和第五行子串为例,如下图
我们假设已经求得了第四行子串的『最长公共前后缀』,它的长度为 1
,如下图所示
那么我们可以思考,如何使得它的『最长公共前后缀』变长呢?没错,只需要在它后面添加一个字母就够了,也就是字母 b
,如下图
也就是说,下一行添加的字母与当前行的『最长公共前后缀』的长度后面的那个字母一样,则『最长公共前后缀』的长度加 1
,也就如下图所示
但是如果下一行添加的字母与当前行的『最长公共前后缀』的长度后面的那个字母不一样,则可以根据我们最开始的笨方法重新计算即可
现在再让我们回到最开始的地方,因为我们已经得到了『所有子串的相等的前缀与后缀的最大长度』,我们可以将这组序列称之为 maxL
,如下图所示
然后我们便可以根据『最大长度表』来去获得我们最为重要的 next
数组了,关于 next
数组的获取,就是在我们的『最大长度表』的基础之上『整体向右移动一位』,然后初始值赋为 -1
(最后一位的 0
则被忽略掉)
有了 next
数组以后,我们就可以正式的开始我们的比对过程了,如下图
如图中所示,依次从左往右开始一一比对,直到遇到不相匹配的,也就是下图这样的情况
这时可以发现,模式串的 b
与文本串的 c
失配了,所以我们就需要找出失配处模式串的 next
数组里面对应的值,这里为 0
,所以就需要将索引为 0
的位置移动到失配处再次开始匹配(其他元素也是移动同样的距离)
然后继续执行我们的比对过程,但是此时可以发现,依然是不匹配的,所以我们还是需要按照之前的逻辑来进行执行
此时模式串的 a
与文本串的 c
失配了,所以我们就需要找出失配处模式串的 next
数组里面对应的值,这里为 -1
,所以就需要将索引为 -1
的位置移动到失配处,再次继续向右执行
直到再次出现下图这样的情况
此时也是同理,从图中我们可以发现,当前不匹配的位置位于 next
数组当中的 2
位置处(也就是红框所在位置),所以这里依然按照我们之前的逻辑,移动到索引为 2
的置处再次开始匹配,如下图
最终直到匹配完成或者没有匹配到结果,如下
这样一来,就完成了我们的整个 KMP
算法的流程,下面我们在来看看如何用代码来进行实现,总的来说分为两个步骤,首先是先获取我们的 next
数组,然后才是去进行匹配
1 | #include <stdio.h> |
但是这里还存在可以优化的空间,比如我们需要匹配的主串 S
为 aaaabcde
,而子串 T 是 aaaaax
,那么匹配的情况就成了下图所示
如果按照我们之前的逻辑,这里将会依次匹配 next
数组的 4, 3, 2, 1, 0
,但是我们可以很明显的发现,因为 T
串的前几个字母都是一致的,完全没有必要进行比较,直接将将指针赋值过去即可,所以我们可以稍微的优化一下
1 | void getNext(String T, int *next) { |
最后再来看一下 JavaScript
版本的实现,原理都是一样的,首先获取 next
数组,然后在根据 next
数组来进行回退操作
1 | function getNext(p) { |