单模式字符串匹配的5种常用算法(js实现)

1,482 阅读20分钟

前言

什么是字符串匹配?

在一个字符串(记为主串/string)里面查找另一个或多个特定字符串(记为模式串/pattern)的行为。

什么是单模式串匹配?

单模式串匹配,是在一个主串中查找一个模式串。

多模式串匹配,是在一个主串中查找多个模式串。

因篇幅原因,本篇内容只涉及单模式串匹配,多模式串匹配的方法以后有机会再写。

本文用到的名词

  1. 主串:被查找的字符串(上文提到的string)
  2. 子串:主串中任意连续的字符组成的字符串(本文一般指正在和模式串进行对比的子串)
  3. 模式串:需要从主串中找到的特定字符串(上文提到的pattern)
  4. 移动:当遇到模式串和子串不匹配的时候,需要将模式串向后移动,与下一个子串进行匹配。假设当前与模式串对齐的是首字符 i = 0 的子串,此时把模式串向后移动一位,该操作指的是把模式串对齐首字符 i = 1 的子串。
图片名称
  1. 前缀:主串或模式串里,所有第一个字符下标为0的子串,视为它的前缀。如在字符串"abc"里,子串"a"、"ab"都是它的前缀。

  2. 后缀:主串或模式串里,所有最后一个字符下标为length - 1的子串,视为它的后缀。如在字符串"abc"里,子串"c"、"bc"都是它的后缀。

算法介绍

1. BF算法(Brutal Force/暴力搜索)

最常见的查找思路,猴子都能想到的算法。

从下标为0的位置开始,找出与模式串相同长度的子串(即下标为 0 ~ pattern.length - 1 的子串)并对二者进行遍历对比。

如果二者不匹配,就把模式串向后移动一位。重复上述行为,直到找到匹配的字符串或者遍历完整个主串。

代码实现:

function brutalForce(s,p) {
    if (s.length === 0 || p.length === 0 || s.length < s.length) {
        return -1
    }
    
    const n = s.length, m = p.length;
    for(let i = 0; i < n; i++) {
        // 遍历对比子串与模式串
        for(let j = 0; j < m; j++) {
            if(s[i + j] !== p[j]) break;
            // 如果遍历到了最后一位,并且相等,说明匹配成功
            if(j === p.length - 1) {
                // 返回已匹配子串的首字母位置
                return i;
            }
            
        }
    }

    return -1;
}

暴力算法虽然最慢,但是实际操作中还是经常用到的。因为很多场景我们并不要求那么高的性能,在性能允许的情况下,肯定还是以代码的可读性为优先。

1.1 复杂度分析

n为主串长度,m为模式串长度,这一点后面不再赘述。

时间复杂度:O(n * m)。

空间复杂度:O(1)。

2. RK算法(Rabin-Karp)

RK算法实际上是利用哈希值进行字符串比较的算法。

它的基本操作思路是:把模式串和子串,通过哈希算法转换成数字,再直接进行数字比较,如果数字相等说明二者匹配成功。如果不匹配,则仿照暴力算法把子串后移一位,继续进行哈希值比较。

2.1 如何计算哈希值

可以设计这样一种哈希算法。首先你需要知道,你的主串中可能用到多少种字符,然后针对这些字符创建一个数字映射表。

假设主串由英文字母a-z组成,我们把这26个字母分别映射到0 ~ 25这26个数字上。在计算一个字符串例如'zcgb'的时候,就可以用如下的公式计算哈希值:

// const mapping = {a:0,b:1,c:2,...z:25};
const hash = mapping['z'] * 26 * 26 * 26
  + mapping['c'] * 26 * 26
  + mapping['g'] * 26 
  + mapping['b']

第一个字符乘了26的str.length - 1次方,后面字符的依次递减,最后再把所有字符的哈希值相加。这样针对不同字符串计算出来的哈希值就是绝对不会有重复的。

实际操作中,我们可以利用循环求出任意字符串的哈希值。代码如下:

// const mapping = {a:0,b:1,c:2,...z:25};
// mapping的长度
const len = 26;
function getHashValue(str) {
    let value = 0;
    for(let i = 0; i < str.length; i++) {
        value += mapping[str[i]] * len ** (str.length - 1 - i);
    }
    return value;
}

2.2 子串移动后如何计算哈希值

根据上面的哈希算法,计算出模式串和第一个子串的哈希值。如果二者不匹配,就把子串往后移动一位。

这个移动子串的操作,可以看作删除了子串的第一个字符,然后在末尾添加了一个新的字符,中间的字符本身实际上是没有变化的,只是哈希值会因为位数的变更产生变化。

如字符串 'abc' 向后移动一位,变为 'bcd':

hash['abc'] = mapping['a'] * 26 * 26
  + mapping['b'] * 26 
  + mapping['c'];
hash['bcd'] = mapping['b'] * 26 * 26
  + mapping['c'] * 26 
  + mapping['d'];

在这个过程中,中间的两个字符"bc"的哈希值由

mapping['b'] * 26 + mapping['c'] 变为了 mapping['b'] * 26 * 26 + mapping['c'] * 26 利用简单的数学运算,可以得出':

 mapping['b'] * 26 * 26 + mapping['c'] * 26
 = (mapping['b'] * 26 + mapping['c']) * 26

所以,中间字符的哈希值变化,实际上就只是在末尾多乘了一个26。至于首尾字母的变化,只要减去首字母的哈希值,加上尾字母的哈希值就可以了。

2.3 rk算法完整代码

// 创建a-z的映射 {a:0,b:1,...z:25}
function createMapping() {
    const mapping = {};
    for(let i = 0; i < 26; i++) {
        const letter = String.fromCharCode('a'.charCodeAt() + i);
        mapping[letter] = i;
    }
    return mapping;
}

// 创建字符集的映射。有多少种字符就创建多长的映射
const mapping = createMapping();
// mapping的长度
const len = 26;

function getHashValue(str) {
    let value = 0;
    for(let i = 0; i < str.length; i++) {
        value += mapping[str[i]] * len ** (str.length - 1 - i);
    }
    return value;
}

function rk(s,p) {
    const pHash = getHashValue(p);
    let sHash = getHashValue(s.slice(0, p.length));

    for(let i = 0; i <= s.length - p.length; i++) {
    	// 哈希值相等,匹配成功
        if(sHash === pHash) {
            return i;
        } else {
            // 通过首尾字符的变化,计算哈希值
            sHash = (sHash - mapping[s[i]] * len ** (p.length - 1)) * len 
            	+ mapping[s[i + p.length]]; 
        }
    }

    return -1;
}

2.4 如何防止哈希值溢出

如果子串特别长,字符种类特别多,哈希值可能超出计算机允许的整型范围。这时候我们可以把系数变小一些,比如a-z,我们原先是乘以26的 i 次方,现在我们可以试着用小一点的数,比如25、20、10……等,甚至1。

这样设计必然会导致哈希冲突,也就是求出的哈希值相同但是字符串不同。但是没关系,当产生冲突的时候,我们只要把子串跟模式串进行一次遍历对比就行了。

// 哈希值相等,进行一次遍历对比
if(sHash === pHash) {
    // 切出子串
    const c = s.slice(i, p.length);
    for(let j = 0; j < p.length; j++) 
    	if(p[j] !== c[j]) continue;
    }
    return i;
}

不过哈希冲突越多效率就越低,在条件允许的情况下,还是尽量用比较大的数为好。

2.4 复杂度分析

时间复杂度:O(n + m),哈希冲突时极端情况下会退化为O(n * m)。

空间复杂度:O(1),主要是建立映射表的消耗。

3. KMP算法

RK算法的效率虽然高,但是限制还是比较多的,如果字符集范围比较大,就不那么好用了。后面介绍的几种匹配算法,不仅能应付所有情况,而且可以在子串和模式串匹配失败的情况下,一次性向后移动多位,不用每次都从头遍历对比子串和模式串,这就让匹配的效率大大提升。

我们先来介绍KMP算法。

3.1 KMP算法原理

KMP思路是这样的。先从前往后遍历匹配子串和模式串,如果遇到不符合的字符(为了方便描述,暂且称这个字符为坏字符),这时候就把坏字符之前已经匹配好的子串看作一个整体(暂且称这部分子串为好前缀)。再从长到短检查好前缀的后缀,看有没有能和模式串的前缀匹配上的。

也可以用集合的概念理解,就是找出好前缀的前缀集合和后缀集合的交集,我们称之为公共前后缀。

单看文字可能有点绕,这里举个例子,再参照下面的图就很好理解:

假设主串为"ababcedhijk",模式串为"ababfa"。首先用主串中的子串"ababce"和模式串进行对比,发现第五个字符c和f无法匹配上。我们把前面已经匹配好的子串前缀"abab"看作一个整体,再查找"abab"的所有后缀(即“b”,"ab","bab")和前缀(即"a","ab","aba"),最后发现两者都有的字符串是"ab",这就是公共前后缀。

如果能够匹配上,就把模式串的前缀与子串的后缀对齐;如果有多个公共前后缀匹配,选择最长的那个。

图片名称

如果所有后缀都无法跟模式串的前缀匹配,就把模式串移动到坏字符之后。

图片名称

3.2 提升模式串前缀的查找效率

根据前面的内容可知,KMP算法的核心在于,子串和模式串匹配失败的时候,找到最长公共前后缀,来决定模式串该移动多少位。

仔细观察可以发现,好前缀虽然是子串的前缀,但同样也可以看作是模式串的前缀。拿模式串为"ababfa"来说,假如第五个字符发生不匹配现象,那么在任何情况下,该模式串都一定向后移动两位。我们可以得出结论,模式串移动的位数是由模式串本身决定的。

再进一步思考。以"ababfc"为例,在第五个字符发生不匹配现象时,我们实际上关注的是前四个字符组成的前缀里面,有没有最长公共前后缀。最后发现有最长公共前后缀"ab",该公共前后缀长度为2,由此得出移动2位。

现在我们知道,模式串的移动位数是由前缀子串的长度和该前缀的最长公共前后缀决定的。所以,如果我们能事先就计算出模式串所有前缀的最长公共前后缀,自然就知道不匹配时该移动多少位了。

我们可以用一个next数组表示二者之间的关系,数组下标 i 用来表示下标0 ~ i的前缀,对应的值表示最长公共前后缀的长度。具体格式如下:

next[前缀子串的结尾字符下标] = 最长公共前后缀的长度

前缀子串的结尾字符下标 i,其实就对应匹配失败的字符(坏字符)的前一个字符。当我们匹配失败的时候,获取失败时候的字符下标,通过next[i]就可以直接获取到最长公共前后缀,从而直接计算出移动距离。

以下步骤描述了一个模式串"ababaa"的next数组:

  1. 前缀"a"里没有相等的前后缀,next[0] = 0;
  2. 前缀"ab"同上, next[1] = 0;
  3. 前缀"aba",下标为0的前缀和下标为2的后缀能匹配上("a"),next[2] = 1;
  4. 前缀"abab",下标为0 ~ 1的前缀和下标为2 ~ 3的后缀能匹配上("ab"),next[3] = 2;
  5. 前缀"ababa",下标0 ~ 2的前缀和2 ~ 4的后缀能匹配上("aba"),next[4] = 3;
  6. 前缀"ababaa",下标为0的前缀和下标为5的后缀能匹配上("a"),next[5] = 1;

得出:

next = [0,0,1,2,3,1];

表示方法有了,这个数组具体该怎么计算?用暴力方法固然可以,但这里有一种更巧妙的解决方法。该方法用到了动态规划的思想,这也是整个KMP算法最难理解的地方。

为了方便描述,我们用两个变量i和len分别代表next数组的下标和对应的值。

let len = 0; // len表示最长公共前后缀的长度
let i = 1; // i 表示前缀子串的结尾字符下标

假设有一个模式串p,我们已经计算好了next[i] = len,那么最长公共前缀的末尾就是下标为p[len - 1],最长公共后缀的末尾就是下标为 p[i]。

此时把i后移一位,新加入的字符为p[i + 1],假如引p[i + 1]与上一个公共前缀末尾的后一个字符,也就是与p[len]相同,那么next[i + 1]必然在next[i]的基础上 + 1。

下面的图描述了这个过程。

图片名称

用代码描述就是:

next[i] = len;
i++;
if(p[i] === p[len]) len++;
next[i] = len;

只要后面的字符都满足p[i + 1] === p[len],我们可以断言,最长公共前后缀的长度,必然是在前一个next[i]的基础上加1。

如果p[i + 1] 和 p[len]不相等呢?

还是以"ababaa"为例.在对比该字符串最后一位的时候,前面已经计算出了next[4] = 3,此时i = 4, len = 3。有没有办法根据之前的next快速得出答案呢?

最烧脑的地方来了。为了让大家不懵逼,我们把情况弄简单点。现在我们只知道next[i]的值,还有p[i + 1] !== p[len],其他的信息一概不知。如果要让next[i + 1]确实存在公共前后缀,请问next[i + 1]的值有几种可能?

图片名称

答案只有两种可能,即如下两种情况:

图片名称

还有一种情况之所以不可能,是因为这种排列方式根本不能形成公共前后缀,如下:

图片名称

再仔细观察可以发现,上面两种可能的答案,实际上都是next[i](蓝色部分)的公共前后缀之一(公共前后缀和最长公共前后缀不要搞混了)。这种情况其实不难理解。公共前后缀必须同时满足前缀和后缀,思考起来比较麻烦。如果你只思考其中一项,例如只思考前缀的特性,会发现较短前缀必然也是较长前缀的前缀。如字符串"abc"有两个前缀("a","ab"),"a"同时也是"ab"的前缀。后缀同样如此。

由此可推出,较短公共前后缀必然也是较长公共前后缀的公共前后缀。

再回到最初的问题。现在我们遇到了next[4] = 3(如下图)的情况:

图片名称

p[i + 1]和p[len]这两个字符不匹配,意味着next[i]这个公共前后缀的下一个字符,无法跟p[i + 1]匹配上。那么我们很容易想到一种朴素的解决思路就是,我们去找短一些的公共前后缀,把较短公共前后缀的下一个字符,再跟p[i + 1]对比。

这个较短公共前后缀如何计算?根据我们之前得到的结论,较短公共前后缀必然也是较长公共前后缀的公共前后缀。所以我们只要计算出,当前最长公共前后缀next[i]的最长公共前后缀,就可以得到较短的公共前后缀

next[i + 1]的最长公共前后缀如何计算?这里回顾下next数组的定义。

next[前缀子串的结尾字符下标] = 最长公共前后缀的长度

next[i]表示的是公共前后缀长度,那么next[i] - 1就等于前缀子串的结尾字符下标。而通过next[next[i] - 1],就可以得到next[i]的最长公共前后缀了(由于我们计算整个模式串的公共前后缀,是从短到长计算的,在计算next[i]之前,next[next[i] - 1]的值必然也已经计算出来了,所以也不用担心取不到值的问题)。

如果较短公共前后缀无法匹配,就找更短的公共前后缀。如果某个公共前后缀能匹配上,说明该公共前后缀长度 + 1就是next[i + 1]的答案;如果所有公共前后缀都无法匹配上,那么next[i + 1]就等于0

不难看出,以上思路是一个递推的过程。

最后用代码表示p[i + 1]和p[len]不相等情况下的思路:

next[i] = len;
i++;
// 当len = 0,或者 找到了匹配的公共前后缀的时候,退出循环
while(len > 0 && p[i] !== p[len]) {
    // len 在这里等于 next[i - 1]的值
    // 找到next[i]的公共前后缀
    len = next[len - 1];
}
// p[i] === p[len],表示找到了,在len的基础上 + 1就是答案
if(p[i] === p[len]) len++;
// 如果 p[i] !== p[len],表示没找到,此时len = 0,next[i] 也等于0
next[i] = len;

最后综合一下上面所有的情况:如果我们利用循环推进 i 的下标,每一步将会产生两种可能,即p[i] 和 p[len] 相等或者不相等,而这两种情况的处理方法我们都已经明白了。同时我们又可以推断出,无论我们推进到哪个步骤(除了第一步),也都只会产生这两种可能。这样一步步推断到最后,就可以得出模式串的next数组。

如果你了解动态规划,相信能更好地理解这里的思想。

现在终于可以写下next数组的代码了:

function getNext(p,next) {
    let len = 0;
    // next[0]的情况无法推出,手动初始化一下
    next[0] = 0;
    // 通过循环推进i的下标
    for(let i = 1; i < p.length; i++) {
        // 对比当前公共前后缀的下一个字符
        // 当len = 0,或者 找到了匹配的公共前后缀的时候,退出循环
        while(len > 0 && p[i] !== p[len]) {
          // 进入循环,说明当前的最长公共前后缀的下一个字符不匹配
          // 就去找较短的最长公共前后缀
          // 较短公共前后缀必然是较长公共前后缀的公共前后缀
          // 通过next[next[i - 1] - 1],找到较短为的公共前后缀
          // len = next[i - 1]
          len = next[len - 1];
        }

        // p[i] === p[len],表示找到了,在len的基础上 + 1就是next[i]的值
        if(p[i] === p[len]) len++;
        // 如果 p[i] !== p[len],表示没找到,此时len = 0,next[i] 也等于0
        next[i] = len;
    }
}

3.3 KMP完整代码

有了next数组,kmp剩下的代码,其实主要就是处理当某个字符不匹配的时候,模式串该移动到什么位置。这部分代码很简单,实现方式也有很多种,直接看注释就行。

function kmp(s,p) {
    if(!p.length) return 0;
    
    const next = getNext(p);
    const n = s.length, m = p.length;
    let i = 0, j = 0;
    while(i < s.length) {
    	// 遍历对比子串和模式串
        while(j < m && s[i] === p[j] ) {
            i++;
            j++;
        }
        // j === m,匹配完成
        if(j === m) return i - j;

        // 当j === 0的时候,i后移一位
        // 如果j > 0,那么i所在位置就是坏字符,下次循环需要重新对比,所以不用重新赋值
        if(!j) i++;
        // 有公共前后缀,把j移到公共前后缀的后一位,下次循环跟i所在的坏字符对比
        // 没有则退回到0的位置
        j = next[j - 1] || 0;
    }

    return -1;
}


// 获取next数组
function getNext(p) {
    const next = [];
    let len = 0;
    // next[0]的情况无法推出,手动初始化一下
    next[0] = 0;
    // 通过循环推进i的下标
    for(let i = 1; i < p.length; i++) {
        // 对比当前公共前后缀的下一个字符
        // 当len = 0,或者 找到了匹配的公共前后缀的时候,退出循环
        while(len > 0 && p[i] !== p[len]) {
          // 进入循环,说明当前的最长公共前后缀的下一个字符不匹配
          // 就去找较短的最长公共前后缀
          // 较短公共前后缀必然是较长公共前后缀的公共前后缀
          // 通过next[next[i - 1] - 1],找到较短为的公共前后缀
          // len = next[i - 1]
          len = next[len - 1];
        }

        // p[i] === p[len],表示找到了,在len的基础上 + 1就是next[i]的值
        if(p[i] === p[len]) len++;
        // 如果 p[i] !== p[len],表示没找到,此时len = 0,next[i] 也等于0
        next[i] = len;
    }

    return next;
}

3.4 复杂度分析

时间复杂度:O(n + m)。

空间复杂度:O(m),主要是建立next数组的消耗。

4. BM算法

BM算法是一种从后往前匹配字符的算法。它同时使用了两种匹配规则:坏字符规则和好后缀规则。BM无论概念还是代码实现都有一定难度,不过它的效率却也是公认的高,平均情况下可以比KMP快上数倍。

4.1 坏字符规则

4.1.1 什么是坏字符

从后往前遍历子串,如果遇到某个字符无法跟模式串匹配,就称其为坏字符。

图片名称

4.1.2 遇到坏字符如何处理

从后往前遍历模式串,查看是否有与坏字符相同的其他字符。如果没有,就把整个模式串移动到坏字符的后面。

图片名称

如果有,就把这个字符与坏字符对齐。

图片名称

如果有多个坏字符,选择靠后的坏字符。

图片名称

4.1.3 提升坏字符的查找效率

坏字符操作中有一个步骤,就是需要在模式串中,寻找有没有相同的坏字符。如果每次都去遍历,效率必然很低,我们可以用一个哈希表,记录下数组中每个字符最后出现的位置。

function generateBadCharHash(p) {
    // 为了简化代码这里用了对象。
    // 实际生产中为了效率,可以选用映射数组或者Map
    const hash = {};
    for(let i = 0; i < p.length; i++) {
            // 相同字符中,我们只关注靠后的字符
            // 所以重复的字符只记录最后一次出现的位置即可
            mapping[p[i]] = i;
        }
    }
    return hash
}

4.2 好后缀规则

4.2.1 什么是好后缀

从后往前遍历模式串,在遇到坏字符之前,如果子串中有字符能与模式串匹配上,则这部分字符被称为好后缀。

图片名称

4.2.2遇到好后缀如何处理

遇到一个好后缀的时候,从主串中查找是否有另一个与好后缀相同的字符串。好后缀的处理方式和坏字符是很相似的。

  1. 如果有与好后缀相同的字符串,就把该好后缀与子串中的好后缀对齐;
  2. 如果有多个,选择靠后的好后缀;

但是没有另一个好后缀的情况下,处理方法就不一样了。

在坏字符里面,没有找到的情况下,我们直接将整个模式串移动到坏字符的后面。如果好后缀却不能这样做,原因如下:

图片名称
在这个例子中,如果直接把模式串移动到好后缀的后面(模式串首字母对齐主串的c位置),会错失真正的匹配。只有把模式串的前缀d,对齐好后缀的后缀d才能匹配成功:
图片名称
所以在没有找到另一个好后缀的情况下,真正正确的处理方式是:

检查好后缀的后缀里,有没有能够跟模式串的前缀匹配上的字符。

换一种解释方式,就是把模式串的首字母对齐好后缀的第二个字符,继续检查这部分后缀能否匹配;如果不能就再后移一位,直到检查完整个好后缀。

4.2.3 利用哈希算法提升好后缀的查找效率

好后缀的处理方式,比坏字符要复杂不少。特别是我们不仅要检查好后缀,还要检查好后缀的后缀,如果暴力遍历,效率必然非常低。所以必须设计一种哈希算法来提升查找效率。

如何设计这个哈希算法,其实可以做得非常复杂,我自己也没有深究。这里只提供了比较简单的设计思路。

根据前面所述的内容,好后缀相关的操作主要有两种:

  1. 匹配与好后缀相同的模式串子串;
  2. 匹配与好后缀的后缀相同的模式串前缀。
4.2.3.1 匹配与好后缀相同的模式串子串

对于第一种情况,我们可以事先就把所有的后缀找出来,然后再找模式串中是否有另一个与该后缀相同的子串。可以用一个数组suffix存储二者的关系,格式如下:

suffix[后缀的长度] = 与该后缀相同子串的首字母下标

那么如何寻找另一个与模式串后缀相同的字符串呢? 直观的思路是从后往前,先找到后缀,然后遍历整个模式串找到与该后缀相同的子串,但这样做会导致很多不必要的重复计算。

我们可以转变下思路,从前往后遍历模式串,假设当前循环遍历到的下标是i,我们可以对比0 ~ i 子串的后缀与模式串的后缀,如果二者有相同的后缀,就可以更新suffix了。

在对比两个后缀的时候也有很巧妙的方法,应该从最短的后缀开始,即对比长度为1的后缀,再对比长度为2、长度为3的……如果某个长度的后缀不相等,其他更长的后缀必然也不相等,剩余长度的后缀也不用再对比了。

function generateGoodSuffix(p,suffix) {
    const m = p.length;

    // b表示模式串,m表示长度
    for (let i = 0; i < m; ++i) { // 初始化
        suffix[i] = -1;
    }
    
    // i 用来标记 0 ~ i子串,和模式串寻找公共后缀
    for (let i = 0; i < m - 1; ++i) {
        let j = i;
        let k = 1; // k用来标记模式串后缀长度

    	// 从短到长对比子串后缀和模式串后缀
        // p[j] 用来标记子串后缀的首字母
        // p[m - k] 用来标记模式串后缀的首字母
        while (j >= 0 && p[j] === p[m - k]) {
            suffix[k] = j; 
            j--;
            k++;
        }
    }
}
4.2.3.2 匹配与好后缀的后缀相同的模式串前缀

对于第二种情况,也就是好后缀的后缀查找。而好后缀的后缀实际上就是模式串的后缀,这个问题就转变为从模式串找一对相同的前缀和后缀。我们可以创建另一个数组prefix,事先就找好模式串里面有没有能够匹配的前后缀。

prefix[后缀的长度] = boolean; // 布尔值表示能否和前缀匹配上

4.2.3.3 好后缀哈希算法代码
function generateGoodSuffix(p,suffix,prefix) {
    const m = p.length;

    // b表示模式串,m表示长度,suffix,prefix数组事先申请好了
    for (let i = 0; i < m; ++i) { // 初始化
        suffix[i] = -1;
        prefix[i] = false;
    }
    
    for (let i = 0; i < m - 1; ++i) {
        let j = i;
        let k = 1; // k用来标记模式串后缀长度

    	// 从短到长对比子串后缀和模式串后缀
        // p[j] 用来标记子串后缀的首字母
        // p[m - k] 用来标记模式串后缀的首字母
        while (j >= 0 && p[j] === p[m - k]) {
            suffix[k] = j; 
            j--;
            k++;
        }

        // 0 ~ i子串同时也是模式串的前缀
        // j == -1,表示该前缀和模式串的后缀完全相等
        if (j == -1) prefix[k - 1] = true; 
    }
}

4.3 BM算法的运作方式

BM算法的核心,就在于同时计算出坏字符规则和好后缀规则的移动位数,比较两种方式的移动位数,然后选择较大的数进行移动。最后还剩一个问题,就是移动的位数该怎么计算?这方面的问题不难,我在最后的代码里面给出注释,参考注释理解即可。

4.4 BM算法完整代码

function bm(s,p) {
    const badChar = {};
    generateBadChar(p,badChar);
    const suffix = [], prefix = [];
    generateGoodSuffix(p,suffix,prefix);
    const n = s.length, m = p.length;
    let i = 0;
    while(i <= n - m) {
        // 从子串末尾向前遍历
        let j = m - 1;
        while(j >= 0) {
            // 找到坏字符,退出循环
            if(s[j + i] !== p[j]) break;
            j--;
        }
        // 如果j < 0,说明没有坏字符,字符串匹配完成,直接返回下标
        if(j < 0) return i;
        
        // 计算坏字符移动位数
        let bc = j;
        if(badChar[s[j + i]] !== undefined) {
            bc = j - badChar[s[j + i]];
        }


        // 计算好后缀移动位数
        let gs = 0;
        // j 比 m - 1小才会有好后缀
        if(j < m - 1) {
            gs = moveByGs(j, m, suffix, prefix);
        }

        // 从坏字符和好后缀中取较大者
        // 当较大值为0的时候,移动1位
        i += Math.max(bc,gs) || 1;
    }

    return -1;
}

// 创建badChar的映射,方面快速定位p中字符的位置
function generateBadChar(p,badChar) {
    for(let i = 0; i < p.length; i++) {
            // 相同字符中,我们只关注靠后的字符
            // 所以重复的字符只记录最后一次出现的位置即可
            badChar[p[i]] = i;
        }
}

// 生成好后缀哈希
function generateGoodSuffix(p,suffix,prefix) {
    const m = p.length;

    // b表示模式串,m表示长度,suffix,prefix数组事先申请好了
    for (let i = 0; i < m; ++i) { // 初始化
        suffix[i] = -1;
        prefix[i] = false;
    }
    
    for (let i = 0; i < m - 1; ++i) {
        let j = i;
        let k = 1; // k用来标记模式串后缀长度

    	// 从后往前对比子串和模式串后缀
        // p[j] 用来标记子串后缀的首字母
        // p[m - k] 用来标记模式串后缀的首字母
        while (j >= 0 && p[j] === p[m - k]) {
            suffix[k] = j; 
            j--;
            k++;
        }

        // 0 ~ i子串同时也是模式串的前缀
        // j == -1,表示该前缀和模式串的后缀完全相等
        if (j == -1) prefix[k - 1] = true; 
    }
    // console.log(suffix,prefix);
}

// 利用好后缀移动
// index: 坏字符对应的字符下标
// m,模式串长度
function moveByGs(index, m, suffix, prefix) {
    const len = m - 1 - index; // 好后缀长度
    // 如果suffix有值,计算出移动的距离
    if(suffix[len] !== -1) return index - suffix[len] + 1;
    for(let i = len; i > 0; i--) {
        if(prefix[i]) {
            // 把模式串头部对齐好后缀的后缀
            return m - i;
        }
    }

    return m;
}

可以看到BM算法真的很难,不仅是概念难理解,代码上实现更难。如果只是出于学习目的,建议理解其思想即可.。

4.5 复杂度分析

时间复杂度: O(n + m)(比较时间 + 预处理时间);

空间复杂度:O(m) (预处理都跟模式串长度有关);

5. Sunday算法

我第一次看到sunday算法非常震惊,不仅仅是因为它测试出来的效率比BM算法还高,还因为它是如此简单易懂。

5.1 Sunday算法原理

Sunday算法原理非常简单。从前往后遍历模式串和子串,如果发现有一个字符不匹配,直接把目标瞄到子串后面一位字符,然后查找该字符在模式串中是否存在。

图片名称

如果找到了,则将模式串中匹配到的字符与该字符对齐。

如果没找到,则将模式串中移动到该字符之后。

5.2 提前计算移动距离

实际上我们可以提前计算好移动距离。仔细观察不难发现这样一个规律:

模式串的移动距离 = 模式串长度 - 对应字符下标

根据该规律可以提前创建一个字符和移动距离的映射。

// 创建模式串中字符与位移距离的映射
function generateShift(p) {
    const shift = {};
    for(let i = 0; i < p.length; i++) {
            shift[p[i]] = p.length - i;
        }
    return shift;
}

5.2 Sunday算法代码实现

有了上面那个映射,sunday算法就很简单了,相信你直接看代码也能轻易看懂。至于sunday算法有何缺陷,由于网上相关文章不多,我并没有找到关于这方面的内容。如果您知道的话还望在评论区补充。

function sunday(s,p) {
    const n = s.length, m = p.length;

    const shift = generateShift(p)

    let i = 0;
    while(i <= n) {
        let j = 0, temp = i;
        // 遍历子串和模式串,进行匹配
        while(s[temp] === p[j] && j < m) {
            temp++;
            j++;
        }
        // j === m 说明子串和模式串匹配成功,直接返回结果
        if(j === m) return temp - j;

        // 子串后一位字符
        const c = s[m - j + temp];
        // 如果shift没有值,则移动m + 1位
        i += shift[c] || m + 1;
    }
    return -1;
}

// 创建模式串中字符与位移距离的映射
function generateShift(p) {
    const shift = {};
    for(let i = 0; i < p.length; i++) {
            // 相同字符中,我们只关注靠后的字符
            // 所以重复的字符只记录最后一次出现的位置即可
            shift[p[i]] = p.length - i;
        }
    return shift;
}

5.3 复杂度分析

时间复杂度: O(n + m)(比较时间 + 预处理时间);

空间复杂度:O(m);