字符串匹配与kmp算法(部分匹配表)

1,706 阅读4分钟

(本文是个人理解+实现,留作备忘,如有问题欢迎讨论)

较之暴力匹配,kmp的优点在于对源字符串(待匹配的母串)遍历时省略掉了部分循环,在某些情景下可以带来可观的性能提升。而部分匹配表解法的核心是通过对比模式串的前缀和后缀(定义不再赘述)得到一个部分匹配表数组,从而对循环进行优化。

首先,要了解两个概念:"前缀"和"后缀"。

前缀指除了最后一个字符以外,一个字符串的全部头部组合;

后缀指除了第一个字符以外,一个字符串的全部尾部组合

例如字符串elecat,前缀有e,el,ele,elec,eleca,后缀有t,at,cat,ecat,lecat


先看一下暴力匹配

function bruteForce(source, pattern) {
    let source_len = source.length,
        pattern_len = pattern.length,
        result = [];
    for (let i = 0; i < source_len; i++) {
        let index = 0;
        while (
            (source.charCodeAt(i + index) === pattern.charCodeAt(index)) && (source_len - i > pattern_len)
        ) {
            index++;
            if (index === pattern_len) result.push(i);
        }
    }
    return result.length === 0 ? -1 : result
}

不论上一次匹配的结果如何,下一次的外部循环i总是+1

接着先上获取部分匹配表的代码,再看其作用

function getPartMatch(str) {
    let result = [0],
        len = str.length;
    for (let i = 1; i < len; i++) {
        let child_str = str.substring(0, i + 1);
        for (let j = 0, j_len = child_str.length; j < j_len; j++) {
            let prefix = child_str.substring(0, j),
                suffix = child_str.substring(j_len - j, j_len);
            if (prefix === suffix && prefix !== '') {
                result[i] = prefix.length;
            }
            if (!result[i]) result[i] = 0
        }
    }
    return result
}
getPartMatch('ABCDABD')//[0, 0, 0, 0, 1, 2, 0]

部分匹配表的作用:当模式串与源字符串分别匹配前1~n(n是源字符串的长度)位时,部分匹配表的[n-1]位记录着下一次匹配已知的有效匹配位数

这句话可能有点难理解,还是用代码说话

const sourceStr = 'BBC ABCDABABCDAB ABCDABCDABDE';//源字符串
const patternStr = 'ABCDABD';//模式串
//刚才已经得到模式串的部分匹配表是
//  [0, 0, 0, 0, 1, 2, 0]
//第1次匹配时(懒得画图了):
B|B|C| |A|B|C|D|A|B|A|B|C|D|A|B| |A|B|C|D|A|B|C|D|A|B|D|E
A|B|C|D|A|B|D
//没有1位匹配,无需查看部分匹配表
//后移1位,进入第2次匹配
B|B|C| |A|B|C|D|A|B|A|B|C|D|A|B| |A|B|C|D|A|B|C|D|A|B|D|E
 |A|B|C|D|A|B|D
//同上,继续向下匹配
//第3次匹配
B|B|C| |A|B|C|D|A|B|A|B|C|D|A|B| |A|B|C|D|A|B|C|D|A|B|D|E
 | |A|B|C|D|A|B|D
//第4次匹配
B|B|C| |A|B|C|D|A|B|A|B|C|D|A|B| |A|B|C|D|A|B|C|D|A|B|D|E
 | | |A|B|C|D|A|B|D
//第5次匹配
B|B|C| |A|B|C|D|A|B|A|B|C|D|A|B| |A|B|C|D|A|B|C|D|A|B|D|E
 | | | |A|B|C|D|A|B|D

注意这时,模式串的前6位(也就是有效匹配内容)全部匹配上了,查阅部分匹配表的[6-1]位,值是2,表明虽然没有完全匹配,但是我们可以知道:除了有效匹配内容的最后2位,其他的部分我们可以直接跳过,不进行循环。

所以我们下一次循环时,将模式串的第1位与刚才有效匹配内容的倒数第2位对齐即可

//第6次匹配
B|B|C| |A|B|C|D|A|B|A|B|C|D|A|B| |A|B|C|D|A|B|C|D|A|B|D|E
 | | | | | | | |A|B|C|D|A|B|D

在这次匹配中模式串的前2位匹配上,部分匹配表的[2-1]位值是0,即,此次的2位有效内容下次循环均可跳过。

//第7次匹配
B|B|C| |A|B|C|D|A|B|A|B|C|D|A|B| |A|B|C|D|A|B|C|D|A|B|D|E
 | | | | | | | | | |A|B|C|D|A|B|D

···

如此往复。

知道了部分匹配表的核心理念,在暴力匹配的基础上加以修改即可

function kmpPartMatch(source, pattern) {
    let partMatch = getPartMatch(pattern),
        source_len = source.length,
        pattern_len = pattern.length,
        result = [];//使用数组缓存匹配的所有结果,也可修改为类似indexOf只返回第一个匹配处的下标
    // console.log(partMatch);
    // console.log(source);
    for (let i = 0; i < source_len - pattern_len; i++) {
        let index = 0;//模式串游标
        //console.log(`检索到第${i}个字符`);
        while (
            (source.charCodeAt(i + index) === pattern.charCodeAt(index)) && (source_len - i > pattern_len)
            //源字符串与模式串对应下标的字符相等&&源字符串剩余的长度还满足一个模式串的要求,也规避掉了一些无效循环
        ) {
            index++;//游标后移
            if (index === pattern_len) result.push(i);//游标移动的长度=模式串的长度,说明满足匹配,记录结果
        }
        if (index > 0) {//index大于0说明游标移动了,即模式串与源字符串是有匹配的部分
            // console.log(`匹配上的长度:${index}`);
            let step = index - partMatch[index - 1] - 1;//考虑到for循环体的自增操作,最后再多减一
            // console.log(`步长:${step}`);
            // console.log(`将要去第${i + step}个字符`)
            i += step;
        } else {
            // console.log('匹配失败');
        }
        // console.log('--------------------');
    }
    return result.length === 0 ? -1 : result
}

console语句和注释比较多,有不明白的地方可以将console语句打开看一下log,方便理解