系列文章导引
开源项目
本系列所有文章都将会收录到GitHub
中统一收藏与管理,欢迎ISSUE
和Star
。
GitHub传送门:Kiner算法算题记
前言
字符串匹配算法相较于我们之前学习过的其他算法而言,需要极强的观察能力,不然,看似简单的问题,可能会被复杂化。今天我们就来一起了解一些经典的字符串匹配算法以及常用的辅助字符串匹配的数据结构,以及学习一下这些匹配算法的特性,在什么场景下应该使用哪个算法匹配会更高效。
基础概念扫盲
单模匹配问题
翻译成人话就是:“在一长串的字符串中,是否出现过某个子串,如果出现过某个子串,则匹配成功”。其中,长串的字符串,我们叫做母串(或叫文本串),而子串就被成为模式串。类似于在一个word
文档中的“单词查找”功能,我们整个word文档的字符串就是母串,而我们要查找的那个单词就是模式串。这一类问题就是“单模匹配问题”。
母串
如:aecaeaecaed
模式串
如:aecaed
经典字符串匹配算法
暴力匹配算法
概念
暴力匹配算法会用模式串和母串中的每个位置对齐,对齐以后,向后匹配,判断一下从对齐位开始,向后是否能够完整匹配模式串。
要点
用模式串对齐母串的每一位。即先用模式串的第一位与母串的第一位对齐,然后匹配一下能否匹配成功,如果不行,则让模式串的第一位与母串的第二位对齐,再匹配一下看看能否匹配成功。。。依次类推,直至匹配成功或母串长度已经小于模式串长度时停止。
# 母串
a e c a e a e c a e d
# 模式串
a e c a e d
# 首先让模式串的第一位与母串第一位对齐,尝试匹配
a e c a e a e c a e d
△
▽
a e c a e d
# 发现最后一位a与d不匹配,接着让模式串的第一位与母串的第二位对齐
a e c a e a e c a e d
△
▽
a e c a e d
# 第一位都不对齐,继续对齐母串的下一位
# ...
a e c a e a e c a e d
△
▽
a e c a e d
# 直到匹配到上面的情况,发现从a处对齐,直到最后,跟模式串完美匹配,说明能在母串中找到模式串
特点
暴力匹配算法能够不重不漏得处理每一次的匹配操作。其中:
不重指的是如果我的模式串已经跟我们母串的某一位对齐过了,那么,在匹配的过程中,不会再次与这一位重复对齐(因为在我们算法运行的过程中,重复的操作会降低算法的效率)。
不漏指的是在我们的暴力匹配算法中,不会漏掉任何一次有可能匹配成功的操作。
无论我们其他算法如何优化,至少都要能够跟暴力匹配算法一样,达到不重不漏的匹配。
代码演示
// 暴力匹配算法
/**
* 在母串中使用暴力匹配方式查找模式串,如存在模式串,则返回期起始位置索引,否则返回-1
* @param text 母串
* @param chars 模式串
* @returns
*/
function bruteForce(text: string, chars: string): number {
// i用于使文本串与模式串对齐,j用于匹配对齐后的每一个字符是否与模式串匹配
let i=0,j=0;
// 只要文本串还存在未匹配的字符就继续
while(text[i]) {
// 设置标志位默认为true
let flag = true;
j = 0;
// 从对齐位开始匹配母串中的每一个字符串,看是否与模式串的每一位字符串相同
while(chars[j]) {
// 如果相同则继续匹配下一个字符,注意,母串的字符是i+j位,因为母串是从第位开始对齐的
if(text[i+j] === chars[j]) {
j++;
continue;
}
// 如果没有进入上面的逻辑,那么说明找到了一个不相同的字符串,将标志位设置为false,
// 并退出本次对齐的匹配,因为已经有不匹配的字符了,再匹配下去也没意义了
flag = false;
break;
}
// 如果匹配结束后,标志位依然还是true,那就说明从对齐位开始的每一位斗鱼模式串匹配,直接返回true
if(flag) return i;
i++;
}
// 整个母串都匹配完了,还没匹配上,则返回false
return -1;
}
console.time("耗时:");
console.log(bruteForce('aecaeaecaed', 'aecae'));// 0
console.log(bruteForce('aecaeaecaed', 'aecaed'));// 5
console.log(bruteForce('aecaeaecaed', 'aecaef'));// -1
console.log(bruteForce('aecaeaecaed', 'aeaaed'));// -1
console.timeEnd("耗时:");
// 0
// 5
// -1
// -1
// 耗时:: 8.566ms
KMP算法
KMP
算法就是基于上述暴力匹配算法
进行一定优化的一种字符串匹配算法。
重点
KMP
算法的关键在于,将原本的母串
与模式串
的问题,转换成了模式串
与模式串
的问题。具体的转换逻辑如下:
那么,将母串
与模式串
的问题转换成模式串
和模式串
的问题,到底有什么意义呢?通常情况下,我们的母串
都是比较长的,我们要对母串
做一些处理比较难,而模式串
都是比较短的,我们要对模式串
做一些处理就比较简单了。将原本母串
与模式串
的问题转换成为模式串
与模式串
的问题之后,我们就可以根据模式串
做一些预处理。
# 根据上面的推导过程,我们知道:
# 第二位对齐的前提条件是:模式串的前4位等于模式串的后4位,即:
pre4 = last4
# 第三位对齐的前提条件是:模式串的前3位等于模式串的后3位,即:
pre3 = last3
# 第四位对齐的前提条件是:模式串的前2位等于模式串的后2位,即:
pre2 = last2
# 只有当上面的条件满足,我们对齐对应位才有意义,如果不满足,注定不能匹配成功,就不需要浪费资源去匹配了,这也是相较于暴力匹配算法做的一个优化。
# 在匹配的过程中,我们已经知道了pre4不等于last4,pre3不等于last3了,那么当我们第一次匹配失败后,我们可以不用再跟第二位和第三位对齐,直接跟第一个满足条件的第四位进行对齐即可。
使用上述方法优化了算法实现后,我们依然可以保证“不重不漏”的匹配到答案,之所以我们跳过了第二位和第三位对齐依然能够保证不重不漏,是因为我们使用了高效的方式已经提前判断出来了第二位和第三位对齐是不可能匹配成功的。
这就是暴力匹配算法的其中一个优化方向,就是跳过一些明确不可能匹配成功的环节,直接进入有可能匹配成功的匹配流程中。有点类似于之前动态规划优化中的剪枝操作,剪去一些无用的分支,提升算法效率。
KMP的加速
KMP算法就是基于上述的优化进行加速的一种算法,假设模式串的前缀为:Ta,模式串的后缀为:Tb,我们需要找到截止到目前匹配成功位置之前(即上面示例中带有┈
的部分)模式串中最长的Ta与Tb相等的部分。之所以要找到截止到目前匹配成功位置之前(即上面示例中带有┈
的部分)模式串中最长的部分,是为了保证匹配过程不漏。
KMP算法预处理的信息其实就是上述的模式串不断往前移动时,需要往前移动到哪一位的信息,即找到上一个可能跟i+j
位匹配的字符在模式串中的位置。即我们在上述的j
位如果匹配失败,我们就直接跳到预处理出来的j’
位置,程序实现时,我们一般将这些信息存到数组当中,在数组的第j
位存储的是j’
的位置。
那么,假如说模式串中不存在“Ta = Tb”的结构呢?那就意味着我们的模式串跟母串标记了“┈”位置上的任何一位都不可能匹配成功,那么我们就可以让模式串直接跳过这些标注了"┈"的部分,从第一个没有标注"┈"的部分开始对齐。
代码演示
function initNext(char: string, next: number[]): void{
// 第一位固定是-1,我们假设-1是万能匹配位,无论跟谁都能匹配上,假如说我们模式串的第一位都
// 没办法匹配,就会指向这个-1
next[0] = -1;
for(let i=1,j=-1;char[i];i++) {
// 如果j+1位字符与i位字符不匹配并且j不是-1时,我们需要让j跳到下一位
while(j!==-1 && char[j+1] !== char[i]) j = next[j];
// 如果j+1位于i位匹配则j向后移动一位
if(char[j+1] === char[i]) j++;
// 将当前的关键信息存入到next数组中
next[i] = j;
}
}
function kmp(text: string, char: string): number {
// 模式串的长度
const n = char.length;
// 用于存储预处理出来的信息,当某一位匹配不上时,我们应该跳到哪一位
const next: number[] = [];
// 初始化关键信息
initNext(char, next);
// i指向的是当前匹配的位置,而j则是当前位置的前一位,因此,我们要匹配的是i与j+1位是否匹配
for(let i=0,j=-1;text[i];i++) {
// 如果j不是-1并且母串的第i位与模式串的j+1位不能匹配成功,则让j向前跳
while(j!==-1 && text[i] !== char[j+1]) j = next[j];
// 如果文本串的第i位于模式串的j+1位匹配成功,那么模式串长度加1
if(text[i] === char[j+1]) j++;
// 判断是否匹配成功
// 如果模式串的j+1位不存在,说明我们已经匹配完了整个模式串了,说明匹配成功
if(!char[j+1]) return i - j;
}
// 整个匹配下来还没有匹配成功,则无法匹配,返回-1
return -1;
}
console.time("耗时:");
console.log(kmp('aecaeaecaed', 'aecae'));// 0
console.log(kmp('aecaeaecaed', 'aecaed'));// 5
console.log(kmp('aecaeaecaed', 'aecaef'));// -1
console.log(kmp('aecaeaecaed', 'aeaaed'));// -1
console.timeEnd("耗时:");
// 0
// 5
// -1
// -1
// 耗时:: 7.771ms
思维发散
从上面的代码程序实现中,我们不难看出,i
只会不断往后,把一个个字符“喂”给下面的模式串匹配逻辑,真正一直在变化的是j
,我们把j
看成一个状态的话,那么,这个过程可以理解为:我们每改变一个字符(i变化),我们的状态(j)就会随之改变,这其实就是我们计算机中很常见的状态机。也就是说,KMP算法
,本质上就是一种状态机。
KMP算法的应用场景
KMP
算法可以处理基于流数据的单模匹配问题。因为我们的KMP算法每次根据所给的一个字符改变状态,并不需要一次性将全量的数据全部提供给KMP算法
,即:“来一个字符,状态变化一下,再来一个字符,状态在变化一下”。举个例子:
Sunday算法
即星期天算法。通过对齐黄金对齐点位达到不重不漏的匹配字符串的目的。下面我们来看看星期天算法是怎么工作的。
# 母串
a e c a e a e c a e d
# 模式串
a e c a e d
# 首先,第一次匹配失败后,我们将模式串向右移动一位
# 母串
a e c a e a e c a e d
# 模式串
a e c a e d
# 此时,我们母串中与模式串对应的最后一位是e,那么,如果想要匹配成功,我们的模式串中也必须要出现e,此时我们从后往前查找模式串,找到第一个e出现的位置,然后让着两个e的位置对齐,而这两个e的位置就是黄金对齐点位
# 母串
a e c a e a e c a e d
# 模式串
a e c a e d
# 黄金对齐点对齐后,然后从第一位开始尝试与母串匹配,仍然不匹配,模式串后移一位
# 母串
a e c a e a e c a e d
# 模式串
a e c a e d
# 此时与模式串最后一位相对应的母串的字符是a,那么,相同的道理,我们需要在模式串中,从后向前找到第一个a出现的位置,然后再让这个a与母串的a对齐,这两个a的位置也是黄金对齐点位
# 母串
a e c a e a e c a e d
# 模式串
a e c a e d
# 对齐后从新从模式串开头与母串进行匹配,发现能够完全匹配,说明已经找到了。
总结一下,星期天算法,需要预处理收集每个字符在模式串中最后一个出现的位置,即从后往前第一个位置,当我们匹配失败之后,需要看一下母串当前位的下一位字符在模式串中排在倒数第几位,排在倒数第二位,我们的模式串就向右推2位,排在倒数第n位,模式串就向右推n位。假如说我们母串当前位的下一位字符在模式串中不存在,那么,我们可以假设模式串最前面有一个万能匹配位-1
,我们直接让-1
位跟这个字符对齐,就相当与我们让这个模式串调到这个字符之后的一位再开始尝试对齐,即模式串向后推整个模式串的长度。
应用场景
星期天算法最适合处理在一篇文章中查找一个单词是否出现过。假设我们在一个有10000个字母的查找一个由100个字母的段落,在最优的情况下,能够达到10000/100
= 100
,也就是说,我们只需要循环100次就能找到。这种效率算是非常高的了。在通常情况下,实际应用场景中,星期天算法的时间复杂度远优于暴力匹配算法和KMP算法。当然,由于星期天算法要求知道整个文本串的内容才能进行匹配,因此,无法像KMP算法一样处理流数据。因此,脱离实际问题场景讨论算法的优劣就是在耍流氓,我们应该结合实际问题场景选择最适合的算法。
代码实现
function sunday(text: string, char: string): number {
// 用于记录某个字符出现的最后一个的位置
const lastPosition: Record<string, number> = {};
// n是文本串长度,m时模式串的长度
let n = text.length,m;
// 预处理模式串中每个字母出现的最后一个位置
for(let i=0;text[i];i++) lastPosition[text[i]] = -1;
for(m=0;char[m];m++) lastPosition[char[m]] = m;
// 遍历文本串的字符,由于当模式串中的字符在正在匹配的文本串中没有出现时会往后推一整个模式串的长度
// 因此遍历的边界条件应该是当前匹配的位置加上模式串的长度要不大于文本串的长度。至于我们每次应该让
// i往后走几位则取决于i+m位的字符出现在模式串的倒数第几位,lastPosition[text[i+m]]代表的是i+m
// 位字符出现在模式串的倒数第几位,但我们要算的的往后推几位,因此,还要用总长度m减去它
for(let i=0;i+m<=n;i+=(m - lastPosition[text[i+m]])) {
// 跟暴力匹配算法一样,依次匹配模式串的每个字符
let flag = true;
for(let j=0;char[j];j++) {
if(text[i+j] === char[j]) continue;
flag = false;
break;
}
if(flag) return i;
}
return -1;
}
console.time("耗时:");
console.log(sunday('aecaeaecaed', 'aecae'));// 0
console.log(sunday('aecaeaecaed', 'aecaed'));// 5
console.log(sunday('aecaeaecaed', 'aecaef'));// -1
console.log(sunday('aecaeaecaed', 'aeaaed'));// -1
console.timeEnd("耗时:");
// 0
// 5
// -1
// -1
// 耗时:: 7.48ms
Shift-And算法
重点
Shift-And算法会先拿着模式串预处理出一种特定的数据信息,即编码信息,然后根据这个数据信息和文本串进行匹配
# 模式串
a e c a e d
预处理
▽
dict['a'] = [ 1, 0, 0, 1, 0, 0 ] # 二进制表示为:001001
dict['c'] = [ 0, 0, 1, 0, 0, 0 ] # 二进制表示为:000100
dict['d'] = [ 0, 0, 0, 0, 0, 1 ] # 二进制表示为:100000
dict['e'] = [ 0, 1, 0, 0, 1, 0 ] # 二进制表示为:010010
# 从上面我们可以看出,shift-and算法将模式串处理成将每个字符按照二进制的反向表示形式记录每个位上是否出现了该字符,由于字符串是从左到右的,因此,二进制表示也用从低位到高位排列。
# 预处理出来上面的编码信息之后,shift-and算法还会设置一个额外的标记P
P = [ 0, 0, 0, 0, 0, 0 ]
# 如果P的相关位置为0,说明没有匹配上,如果为1,说明以文本串当前位置作为结尾,能够匹配成功模式串的前几位,如:
P = [ 0, 1, 0, 0, 1, 0 ]
# 上面P第2位和第4位分别代表的是以文本串的当前位置作为结尾,能够匹配模式串的前2位和前4位。
# 举个例子:
# 母串
a e c a e a e c a e d
# 模式串
a e c a e d
# 假如我当前以第4位的a作为结尾,那么此时由于最后一个a与第一位的a匹配,因此P的第一位为1,又因为以a作为结尾,前四位都能匹配上,因此,第四位也为1,因此,P为:
P = [ 1, 0, 0, 1, 0, 0 ]
上面已经说明了P
的具体的含义以及0
和1
代表的意思,那么,接下来,我们再来看看P
值在我们的文本串字符发生变化时是如何转移的呢?
# 假设我们现在匹配的文本串新进来第i位的字符text[i],那么,此时,我们首先先将P的反向二进制表示统一向前移动(二进制表示为按位左移<<)一位,然后再跟1进行按位或(|)运算,最后在与我们新进来的字符编码的反向二进制表示进行按位与运算(&)来最终确定是否真的能够匹配上
P = (P << 1 | 1) & d[text[i]]
# 那么,我们要如何理解上面的这个公式呢?还是拿上面举的例子来说,由于我们新进来一个字符,那么尾门的二进制位自然也要多给一个,就像原本我们酒店预定了2间房,但现在来了三个人,如果不想两人挤一间房,是不是得再开一间房?这就是上面公式中:P<<1的含义,上面的P按位左移1位之后结果如下:
P = [ 1, 0, 0, 1, 0, 0 ]
P = P << 1 = [ 0, 1, 0, 0, 1, 0, 0 ]
# 因为原本P能匹配上前1位和前4位,那么我们新进来一个字符,就有可能(注意,这里说的是有可能,而不是一定)匹配上前2位和前5位,又因为新进来的字符能够匹配上前1位,因此对按位左移之后的P进行按位或1处理匹配上新的前一位的问题
P = 0b0100100 | 0b0000001 = 0b0100101 # 二进制按位或运算
# 反向二进制表示
P = (P << 1 | 1) = [ 0, 1, 0, 0, 1, 0, 1]
# 假设新进来的数字是第五位e
P = 0b0001001 & 0b010010
P = P & d[text[i]] = [ 0, 1, 0, 0, 1, 0, 0 ]
# 即新加入一个字符e后,前二位和前5位能够匹配上
现在,我们已经借助P
完成了匹配的过程,那么我们要如何判断模式串已经被找到了呢?这也很简单,只要判断P
的最后一位是否为1就可以了,公式为:P & (1 << (n-1)) !== 0
,也就是让P
跟1
按位左移n-1
位后的结构进行按位与运算进行判断,如果结不为0
,则说明匹配成功。其中,n
为模式串的长度。
代码实现
// 算法时间复杂度:O(n)
function shiftAnd(text: string, char: string): number {
// 首先预处理字符编码信息
const code: Record<string, number> = {};
let m;
for(m=0;char[m];m++) {
if(code[char[m]] === undefined) {
code[char[m]] = 0;
}
// 如果第m位存在该字符,就将该字符的对应位置为1
code[char[m]] |= (1 << m);
}
let p = 0;
for(let i=0;text[i];i++) {
p = (p << 1 | 1) & code[text[i]];
// 由于i是代表以i下标字符作为结尾,因此,我们匹配模式串的开始位置的下标 应该是:i - m + 1
if(p & (1 << m - 1)) return i - m + 1;
}
return -1;
}
console.time("耗时:");
console.log(shiftAnd('aecaeaecaed', 'aecae'));// 0
console.log(shiftAnd('aecaeaecaed', 'aecaed'));// 5
console.log(shiftAnd('aecaeaecaed', 'aecaef'));// -1
console.log(shiftAnd('aecaeaecaed', 'aeaaed'));// -1
console.timeEnd("耗时:");
// 0
// 5
// -1
// -1
// 耗时:: 8.003ms
思维发撒
与KMP算法
类似,我们这个Shift-And
算法也是一个状态机,我们给一个字符,状态就改变一下,因此,这个算法也是适合流数据单模匹配问题的。并且其时间复杂度是O(n)
,因此,相较而言,Shift-And
算法会更高效。
应用场景
出了上面说的,Shift-And
算法适合流数据处理,并且相较于KMP
算法更加高效之外,在一个文章中,匹配一段正则表达式的场景也适合使用Shift-And
算法。
# 如:在一篇文章中按照如下正则表达式匹配字符串
[a|c][d|e][f][g|g|i]
# 那么,为什么shift-and算法擅长处理这种匹配场景呢?
# 这就跟我们预处理出来的字符编码有关了,我们还是拿下面这个例子看一下:
# 下面的例子与上面我们的例子唯一的不同就是,第0位既可以是字符a,也可以是字符c,这是不是就符合正则表达式中,第一位既可以是a,也可以是c的情况了呢?
# 模式串
a e c a e d
预处理
▽
dict['a'] = [ 1, 0, 0, 1, 0, 0 ] # 二进制表示为:001001
dict['c'] = [ 1, 0, 1, 0, 0, 0 ] # 二进制表示为:100100
dict['d'] = [ 0, 0, 0, 0, 0, 1 ] # 二进制表示为:100000
dict['e'] = [ 0, 1, 0, 0, 1, 0 ] # 二进制表示为:010010
总结一下:Shift-And算法天生适合处理每个位置上允许出现不同字符的匹配问题,如正则匹配问题。
结语
我们今天一起讨论了四种经典的字符串匹配算法,每种算法各有其奇妙之处,即使是最笨的暴力匹配算法,也让我们学习到了字符串匹配最关键的一个要求:不重不漏。我们再实际的应用场景中,根据不同的具体情况,选用不同的算法,能够极大得提升字符串的匹配效率。如在处理流数据时选用KMP算法或Shift-And算法,遇到已知全文的单个文章中查找单个单词的算法时,可以选用sunday算法,遇到每个位置上的字符允许出现不同字符时选用Shift-And算法等等。