21.2-字符串的经典匹配算法(手撕算法篇)

163 阅读6分钟

前言

我们已经了解了字符串单模匹配问题具体是怎样的一个问题,并且学习了暴力匹配、KMP算法、Sunday算法、Shift-And算法等经典的字符串匹配算法及每种算法的优缺点和使用场景。今天就来通过几道算法题来巩固提升一下这些算法的特性与基础吧。

前置知识

马拉车算法

  • **作用:**找出一个母串的回文子串

如果没有马拉车算法,我们要求一个母串的最长回文子串要怎么求呢?一般比较简单暴力的算法就是遍历字符串的每一位,然后以遍历到的这位作为中心点向前后扩展,只要前后字符串相同,则说明是回文串,继续往前后扩展,直到前后字符串不相等为止,不相等的两位之间的回文串就是以当前字母作为中心点的回文子串。我们只需要将整个字符串都遍历一遍,取最长的那个回文串即可。

马拉车算法要点

预处理

为了程序实现与判断时更加方便快捷,马拉车算法会将原字符串每一个字符的前后都插入一个#字符,这样,无论我们原始字符串的长度是奇数还是偶数,都会变成奇数,我们再对每一位前后扩展时,就可以更加方便,不会出现在两个字符中间前后扩展的情况

# 长度为偶数的字符
a b b a
   △
# 正常情况下,如果一个偶数长度的字符串,我们要看他是不是一个回文串,查找他的中心点比较困难,如上面的示例,中心点在两个b的中间
# 而马拉车算法的预处理就是为了干掉这种情况
# a # b # b # a #
# 这样,无论原始字符串的长度是多少,假设为n,那么预处理过后字符串的长度都是2n+1,恒定为奇数
确定范围

预处理之后,我们就可以无需考虑字符串的奇偶性了,接下来,我们需要判断一下当前遍历的节点i是不是在已知的回文串范围之内。由于回文串的特性是中心对称,我们要找到第i个点为中心的回文串,就相当于是找与其对称的点j的回文串

image-20211107103420725

如果我们上述的i点在上述的回文串内,那么我们就可以使用上述方法加速,如果以j点为中心点的回文串长度为没有超过l,即回文串的长度在已知的大回文串内部,则以i点为中心点的回文串的长度就是j点的回文串长度,如果存在超过的部分,由于我们没办法确保超过部分是否是一个回文串,因此最多只能取到r-i,即以i为中心点的回文串长度为r-i

如果不在的话,我们就只能老老实实使用暴力法,依次向前后扩展查找了。

代码实现

// LeetCode 5. 最长回文子串 (https://leetcode-cn.com/problems/longest-palindromic-substring/)
function predo(s: string) {
    let res = '#';
    for(let char of s) {
        res+=`${char}#`;
    }
    return res;
}
function longestPalindrome(s: string): string {
    // 先对原字符串进行预处理,在每一个字符的前后都插入#,可以避免奇偶性的特殊判断
    const newS = predo(s);
    // 用于存储以每一个字符作为中心点的最长回文子串的半径(注意,是半径,要求长度时需要乘以2)
    const d: number[] = [];
    // l代表回文串的左边界,r代表回文串的右边界
    let l = 0;
    let r = -1;
    // 遍历预处理后字符串的每一个字符
    for(let i=0;newS[i];i++) {
        // 如果i超过了右边界,由于我们没办法确定超出边界的字符是否能够与边界内的字符形成回文串,因此暂且认为超出边界的字符自身形成独立回文串,长度为1
        if(i > r) d[i] = 1;
        // 如果i没有超过右边界,那么我们就要再d[j]与当前位置到右边界的距离r-i之前找一个最小值
        // 对称点求解技巧 j = l + r - i,其中r-i是以i为中心点最长回文串的长度,l + r - i 便是i的对称点j的位置
        else d[i] = Math.min(r - i, d[l + r - i]);
        // 上面的步骤就是我们马拉车算法中的加速步骤,接下来,还需要用暴力匹配法对无法加速的部分进行匹配
        // 如果当前位置的索引i减去当前位置为中心点的回文半径d[i]是一个合法的索引,并且以i为中心,以d[i]为半径的两个端点的字符相等,说明目前位置还是回文串,我们需要让当前回文串的半径加1,让回文串继续向前后扩展
        while(i - d[i] >= 0 && newS[i - d[i]] === newS[i + d[i]]) d[i]++;
        // 更新回文串的边界
        // 当以i为中心点的回文长度已经超过了右边界,并且前半部分依然留在左右边界之间,我们需要更新我们的左右边界
        if(i + d[i] > r && i - d[i] > 0) {
            l = i - d[i];
            r = i + d[i];
        }
    }
    // 至此,我们就已经将整个字符串以每一位为中心点的最长回文半径存储到了d数组中,接下来,我们只需要根据这个最长回文半径,拼接出最长回文即可
    let res = '';
    // 用于记录上一次的最长半径
    let tmp = -1;
    for(let i=0;newS[i];i++) {
        // 如果上一次的回文半径比这一次长,那我们这次循环毫无意义,直接跳过
        if(tmp >= d[i]) continue;
        // 更新回文半径
        tmp = d[i];
        // 重置结果
        res = '';
        // 以i为中心点,以d[i]为半径的左边开始截取,直到右端点
        for(let j = i - d[i] + 1; j < i + d[i]; j++) {
            // 过滤掉预处理时添加的#
            if(newS[j] === "#") continue;
            res+=newS[j];
        }
    }
    return res;
};

刷题正餐

459. 重复的子字符串

解题思路

首先,我们可以观察一下,一个由重复的子字符串组成的字符串会有什么特点呢?我们拿“abab”举例,假如说我们把“abab”复制一份,变成“abababab”,如果让再在新的字符串中第二位开始,查找“abab”出现的位置,我们会发现,此时从第二位开始,首次出现“abab”子串的位置的索引是2。我们再来看一下,一个不是由重复子串组成的字符串,如:“abac”,同样复制一份得:“abacabac”,此时,如果从第二位开始查找“abac”,首次出现“abac”的子串位置的索引是4。从上面两个例子,我们可以发现一个规律:如果一个字符串是由重复子串组成的,那么复制一份后,从第二位开始查找原字符串,第一次出现原字符串的索引肯定小于原字符串长度。而如果这个字符串不是由重复子串组成,那么复制一份后,从第二位开始查找原字符串,第一次出现原字符串的索引肯定会等于原字符串长度。

自此,我们就通过观察规律找到了简单解决这个问题的方法,这也是为什么我们说字符串匹配算法是非常考验观察力的算法了。

除此之外,我们是否还能通过之前学习的字符串匹配的几种算法来解决呢?

首先,我们再来观察一下重复子串组成字符串的规律,如:“abcabcabc”,如果这个字符串是由重复子串组成的,他会有怎样的特点呢?是不是这个字符串的最长公共前缀应该等于最长公共后缀(前缀与后缀可相交),即:前缀:abcabc = 后缀abcabc,如果前缀与后缀都不相等,那么这个字符串肯定不可能是由重复子串组成的了。看到了这里的小伙伴是否觉得异常熟悉,这个最长公共前缀与后缀,不就和我们的KMP算法中的概念一样吗?那我们是否可以通过先求取next数组的方式,先找出每一个字母匹配不上时下一个指向的索引加一求得公共后缀的长度,然后与总字符串的长度相减就是重复子串的长度了,只要这个字符串总长度与重复子串长度能够整除,那是不是就说明这个字符串就是由重复子串构成的呢?

代码实现

观察+暴力匹配
function repeatedSubstringPattern(s: string): boolean {
    // 将原字符串重复叠加一次,如果这个字符串是由重复的子字符串组成的,那么从第二位开始查找第一个原字符串的位置就肯定比原字符串的长度小,因为在此之前已经有匹配上的字符串了,如:
    // 原字符串:abab
    // 重复叠加:abababab
    // 从第二位开始查找abab,查找到的索引是2,小于原字符串的索引4
    return (s+s).indexOf(s, 1) !== s.length;
};
观察+KMP-Next
function repeatedSubstringPattern(s: string): boolean {
    // 利用KMP算法中求解next数组的方法,先找到最长公共前缀和后缀的长度,只要存在这个最长公共前缀并且n与这个长度取余等于0就说明妈祖条件
    const n = s.length;
    const next: number[] = [];
    next[0] = -1;
    let j = -1;
    for(let i=1;i<n;i++) {
        while(j!==-1 && s[j+1] !== s[i]) j = next[j];
        if(s[j+1] === s[i]) j++;
        next[i] = j;
    }
    return (next[n-1] !== -1) && (n % (n - (next[n-1] + 1)) === 0)
}

1392. 最长快乐前缀

解题思路

我们不要被“快乐前缀”这个看起来骚气的名字唬住了,他实际上就是我们学习KMP算法时经常接触的一个概念最长公共前缀,我们可以利用next数组轻松的求解出最长公共前缀的长度,有了长度,直接从开始截取相应长度的字符串便是我们要找的最长快乐前缀了。

代码演示

function longestPrefix(s: string): string {
    // 依据题意,其实就是让我们找到最长的公共前缀,而我们KMP算法中的next数组就可以很方便的查找出最长公共前缀的长度,知道长度后,只需根据长度切割字符串即可
    const n = s.length;
    const next: number[] = [];
    let j = -1;
    next[0] = -1;
    for(let i=1;i<n;i++) {
        while(j!==-1 && s[j+1] !== s[i]) j = next[j];
        if(s[j+1] === s[i]) j++;
        next[i] = j;
    }
    return s.substr(0, next[n-1]+1);
};

214. 最短回文串

解题思路

首先我们一看这样的题目,相信大部分同学都能想到,直接将s反转一下然后拼接到原字符串后面,那么这肯定是一个回文串。没错,但题目要求最短回文串,我们直接翻转后拼接的串却存在一定的冗余,那么接下来,我们要想办法将这个回文串中的冗余部分去掉。我们来观察一下冗余部分的特点,发现冗余部分实际上也是一个小的回文子串,那么,我们是否可以通过求取拼接后字符串的最长公共前缀的方式,将这个回文子串找出来,然后将这个回文子串剔除呢?我们再学习KMP算法时,学习过求取next数组的技巧,而这个next数组中就存储了以每一位作为结尾的最长公共前后缀的信息。因此,我们可以借助next数组轻松的找出拼接后字符串的最长公共前缀。

代码演示

function shortestPalindrome(s: string): string {
    // 先将原字符串复制一份反转后叠加在原字符串后面,生成一个新的回文串,如:s = abcd => abcd#dcba
    // 如果不加 #,'aaa'+'aaa'得到'aaaaaa',求出的最长公共前后缀是 6,但其实想要的是 3。
    let tmp = s +'#'+ s.split('').reverse().join('');
    const n = tmp.length;
    // 使用KMP算法技巧求取next数组
    let j = -1;
    const next: number[] = [-1];
    for(let i=1;i<n;i++) {
        while(j!==-1&&tmp[j+1] !== tmp[i]) j = next[j];
        if(tmp[j+1]===tmp[i]) j++;
        next[i] = j;
    }
    // next数组n-1位存储的就是最长公共前缀的下标,我们将最长公共前缀之后的字符串截取下来,然后反转之后拼接到原字符串前面即可
    // 如上面abcd#dcba的最长公共前缀其实就是a,那么我们从a的下一位开始从原字符串中窃取剩下的字符,这个字符就是我们需要添加的,不过添加到原字符串前面之前,我们需要把这个字符串反转一下
    tmp = s.substr(next[n-1]+1, s.length);
    tmp = tmp.split('').reverse().join('');
    return tmp+s;
};