一个算法大神的私人笔记之字符串匹配算法

前言

假如有一个很长的字符串 A(主串) 和一个短的字符串 B(模式串),如果想知道字符串 B 是否存在于字符串 A 中,我们会怎么做呢?

BF 算法

首先,我们想到的最简单的办法肯定是,两个字符串像物流传送带一般,主串固定,模式串一步步向前移动,一位位匹配比较,直到完全匹配找到想要的结果的位置,简单而又暴力,其匹配过程如下图所示。

假设主串 s 的长度为 m,模式串 t 的长度为 n,先拿 t 和 s (0, n-1) 开始匹配,碰到不匹配的字符再拿 t 和 s (1, n) 匹配,直到匹配到 s (m-n-1, m-1) 为止,由此我们很容易得到代码的实现。

/**
 * BF匹配算法
 *
 * @param s 主串
 * @param t 模式串
 * @return 模式串在主串的位置,不存在返回-1
 */
public static int bfMatch(String s, String t) {
    char[] sChars = s.toCharArray();
    char[] tChars = t.toCharArray();
    int i = 0;
    int j = 0;
    while (i < sChars.length && j < tChars.length) {
        if (sChars[i] == tChars[j]) { // 如果字符匹配,往后移一位继续匹配
            i++;
            j++;
        } else { // 如果字符不匹配,则回到主串的下一个字符继续匹配
            i = i - j + 1;
            j = 0;
        }
    }
    if (j == tChars.length) {
        return i - j;
    } else {
        return -1;
    }
}

这种算法在最坏的情况下需要匹配 n * (m-n) 次,也就是我们说的时间复杂度 O (n*m)。它的劣势和优势都十分明显,劣势是时间复杂度太高了,当字符串很长的时候性能会很差,优势是足够简单不容易出错,而实际场景中大部分时候字符串并不会很长,所以这种算法使用的地方蛮多的。

BM 算法

在上面的算法中,我们每碰到一个不匹配的字符,就把模式串往后移一位继续比较,而在一些场景中,如果不匹配的字符在模式串中不存在,即使是模式串往后移一位也依旧不会匹配,例如下面这种情况,只要有字符 c 在的地方,就不可能匹配,那么我们能不能把模式串往后多移几位,从而减少这种无意义的匹配呢?

BM(Boyer-Moore)算法就是通过寻找这样的一些规律让模式串往后多移几位来提高匹配效率,它是一种工程中非常常用并且匹配效率非常高的一种算法,有实验统计,它的性能是著名的KMP 算法的 3 到 4 倍,接下来我们分析一下它的具体工作原理。

坏字符规则

之前我们都是按模式串的下标从小到大的顺序,依次与主串中的字符进行匹配的,而 BM 算法的匹配顺序比较特别,它是按照模式串下标从大到小的顺序,倒着匹配的,也就是从 t (n-1) 往 t (0) 匹配。

当我们从模式串的末尾往前匹配时,发现某个主串的字符没法匹配,那么我们把主串的这个字符称之为坏字符,也就是字符 c ,然后我们再拿坏字符 c 在模式串中往前查找,如果没有找到这个字符,则可以断言有字符 c 的地方都不可能匹配,这次我们可以学聪明点,把模式串直接往后移 3 位再进行匹配。

往后移 3 位之后,发现最后一个字符依然不匹配,这一次我们还能将模式串往后移 3 位吗,很显然是不行的。因为坏字符 a 在模式串中是存在的,这种情况我们只需要将模式串往后移动两位,让两个 a 对齐即可。那么我们有没有一个公式能够概括模式串到底该往后移动几位呢?

我们把坏字符的对应的模式串中的字符下标记作 si,如果坏字符在模式串中存在,我们把这个坏字符在模式串中的下标记作 xi,如果不存在,则 xi = -1,而模式串往后移动的位数就是 si - xi,如果坏字符在模式串中存在多个,则取最靠后的那个。

利用坏字符规则,BM 算法的最好时间复杂度可以做到 O (n/m)。例如主串是 aaabaaabaaabaaab,模式串是 aaaa。每次比对,模式串都可以直接后移四位,只需要匹配四次。

不过,只有坏字符规则是不够的,因为在某些情况下,xi 可能会比 si 大。例如主串是 aaaaaaaaaaaaaaaa,模式串是 baaa,这时 si = 0,xi = 3,计算出来的移动位数是 -3,不但不往后移,还有可能出现倒退,这时候就需要请 BM 算法的另一大护法出山了。

好后缀规则

好后缀规则的思路和坏字符类似,在下面这种情况,当模式串移动到图中的位置的时候,模式串和主串有 2 个字符是匹配的,倒数第 3 个字符发生了不匹配的情况。我们把没匹配上的 c 称之为坏字符,而匹配上的字符 bc 称之为好后缀,这个时候如果我们用坏字符规则的话,可以得出后移的位数是 1,那有没有可以往后面再多移几位的办法呢,这种情况下我们就可以利用好后缀规则。

好后缀规则的基本原理是,如果在模式串往后移的过程中连好后缀都没有匹配上的话,那整个模式串就更不可能匹配上了,所以我们可以把模式串移动到直至再次匹配上好后缀的位置。

在上面的例子中,我们取好后缀的最后一个字符在模式串中的下标值 x = 6,模式串中前面一次出现好后缀的最后一个字符的下标值 y = 3,如果没有出现,则 y = -1,而往后移的位数为 x - y = 3。即后移位数 = 好后缀的位置 - 模式串中的上一次出现这个好后缀的位置。

不过,同坏字符规则一样,好字符规则会不会存在过度滑动到情况呢?答案是肯定的,下面这个例子,如果我们拿好后缀 bc 去模式串中查找,我们会发现找不到,根据上面说的,我们应该往后移动的位数是 6 - (-1) = 7,也就是把模式串移到主串的好后缀后面,尽管往后移了很多位,但由此我们也错过了匹配的机会。

合理的滑动应该是往后移动 6 位,即当模式串的前缀部分 c 和好后缀 bc 的后缀部分 c 有重合的时候,是有可能发生完全匹配的。所以针对这种情况,我们除了要考察好后缀是否在模式串存在,还需要考察好后缀的后缀部分,是否和模式串的前缀部分有重合,如果有重合,我们就移到重合对齐的位置,如果有多个重合,例如好后缀是 abb,模式串的前缀部分是 bbc,那么我们移动到能够匹配到最长子串的那个位置,也就是 bb。

在弄清楚坏字符规则和好后缀规则的基本原理之后,我们该什么时候用坏字符规则,什么时候用好后缀规则呢?答案是分别计算两种规则得出的后移位数,然后取两者的最大值,这种方式同时能避免坏字符规则中移动位数为负数的情况。

BM 代码实现

在了解了 BM 算法的基本原理后,我们该怎么实现这样一个算法呢,首先我们来实现一下坏字符规则,根据坏字符规则,当遇到坏字符时,要往后移动 si - xi 位,si 我们在匹配的过程中很容易得到,即坏字符对应模式串中的下标,而 xi 的计算才是重点,那么我们该如何求得 xi 呢?

xi 是指模式串中下一次出现坏字符的位置,当然,我们可以通过遍历的方式在模式串中查找,可是每出现一次坏字符,我们就这样查找一次效率显然很低下,我们将模式串中的每个字符及其下标都存到 Map 中,key 为字符,value 为这个字符在模式串中最后出现的 index,这样就可以快速找到坏字符在模式串的位置下标了,为什么是最后出现的 index 呢?因为是倒序查找,如果出现坏字符,我们去找最近的相同字符一定是找到 index 最大的那个,由此我们可以先得到坏字符规则的代码实现。

/**
 * BM匹配算法之坏字符规则
 *
 * @param s 主串
 * @param t 模式串
 * @return 模式串在主串的位置,不存在返回-1
 */
public static int badCharMatch(String s, String t) {
    char[] sChars = s.toCharArray();
    char[] tChars = t.toCharArray();
    // 拿到模式串字符和index的映射
    Map<Character, Integer> map = getCharIndexMap(tChars);
    int i = 0; // i表示主串与模式串对齐的第一个字符
    while (i <= sChars.length - tChars.length) {
        int j;
        for (j = tChars.length - 1; j >= 0; j--) { // 模式串从后往前匹配
            if (sChars[i + j] != tChars[j]) {
                break; // 坏字符对应模式串中的下标是j
            }
        }
        if (j < 0) {
            return i; // 匹配成功,返回主串与模式串第一个匹配的字符的位置
        }

        // 获取模式串中坏字符的最大index,如果没有找到,则返回-1
        int badCharMaxIndex = map.getOrDefault(sChars[i + j], -1);
        // 将模式串往后移动j - badCharMaxIndex位
        i = i + (j - badCharMaxIndex);
    }
    return -1;
}

private static Map<Character, Integer> getCharIndexMap(char[] chars) {
    Map<Character, Integer> map = new HashMap<>();
    for (int i = 0; i < chars.length; i++) {
        map.put(chars[i], i);
    }
    return map;
}

接下来便是更加复杂的好后缀规则实现部分了,在讲实现之前,我们先回顾一下好后缀的核心规则:

  • 在模式串中,查找跟好后缀匹配的另一个子串;

  • 在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串;

首先,在模式串和主串正式匹配之前,我们可以通过预处理模式串,预先计算好模式串的每个后缀子串,对应的另一个可匹配子串的位置。

那么我们该如何表示模式串中不同的后缀子串呢?因为后缀子串的最后一个字符的位置是固定的,下标为 m - 1,我们只需要记录长度就可以了。通过长度,我们可以确定一个唯一的后缀子串。例如 arr[1] 代表 b,arr[3] 代表 cab。

然后我们再引入最关键的变量 suffix 数组。suffix 数组的下标 k,表示后缀子串的长度,下标对应的数组值存储的是,在模式串中,除了后缀子串的部分,与该后缀子串相同的子串的起始下标,不存在就是 -1。例如后缀子串 ab,在模式串中从后往前再一次出现 ab 的 index 是 1,所以 suffix[2] = 1。

通过 suffix 数组,我们可以实现好后缀的第一条规则,但是并不能实现第二条规则,这时候我们需要再引入一个 boolean 类型的数组 prefix,用来记录模式串的后缀子串能否能匹配模式串的前缀子串。例如后缀子串 ab,模式串的前缀子串里面并没有 ab,所以 prefix[2] = false,而后缀子串 cab 刚好能和模式串的前缀子串 cab 匹配,所以 prefix[3] = true。

那么我们该如何得到这两个数组呢?答案就是不断地遍历模式串的所有子串,去和模式串的后缀子串比较,如果某个子串匹配上后缀子串并且起始 index = 0,那么对应 prefix 值设置为 true,由此便可得到初始化 suffix 数组和 prefix 数组的代码。

/**
 * 初始化模式串的suffix数组和prefix数组
 *
 * @param chars 模式串
 */
private static void initSuffixAndPrefix(char[] chars, int[] suffix, boolean[] prefix) {
    for (int i = 0; i < chars.length; i++) { // 初始化赋值
        suffix[i] = -1;
        prefix[i] = false;
    }

    // 从前往后遍历模式串中的所有子串
    for (int i = 0; i < chars.length - 1; i++) {
        int j = i; // j代表子串中和后缀子串对比的字符index
        int k = 1; // k代表后缀子串长度

        // 从后往前对比子串的字符和后缀子串的字符是否相等
        while (j >= 0 && chars[j] == chars[chars.length - k]) {
            suffix[k] = j; // 匹配成功,覆盖之前的suffix值
            if (j == 0) {
                prefix[k] = true; // 该子串为符合条件的前缀子串
            }
            j--; // 子串字符下标往前一位
            k++; // 后缀子串的长度加一位
        }
    }
}

在得到 suffix 数组和 prefix 数组后,我们该如何使用这两个数组来计算模式串往后滑动的位数呢?

假设好后缀的长度是 k。我们先拿好后缀,在 suffix 数组中查找其匹配的子串。如果 suffix[k] 不等于 -1(-1 表示不存在匹配的子串),那我们就将模式串往后移动 j - suffix[k] + 1 位(j 表示坏字符对应的模式串中的字符下标)。如果 suffix[k] 等于 -1,表示模式串中不存在另一个跟好后缀匹配的子串片段。我们可以用下面这条规则来处理。

好后缀的后缀子串 b[r, m-1](其中,r 取值从 j + 2 到 m - 1)的长度 k = m - r,如果 prefix[k] 等于 true,表示长度为 k 的后缀子串,有可匹配的前缀子串,这样我们可以把模式串后移 r 位。

如果两条规则都没有找到可以匹配好后缀及其后缀子串的子串,我们就将整个模式串后移 m 位。

由此我们可以得到完整的 BM 代码实现

/**
 * BM匹配算法
 *
 * @param s 主串
 * @param t 模式串
 * @return 模式串在主串的位置,不存在返回-1
 */
public static int bm(String s, String t) {
    char[] sChars = s.toCharArray();
    char[] tChars = t.toCharArray();
    // 坏字符规则:拿到模式串字符和index的映射
    Map<Character, Integer> map = getCharIndexMap(tChars);
    // 好后缀规则:初始化suffix数组和prefix数组
    int[] suffix = new int[tChars.length];
    boolean[] prefix = new boolean[tChars.length];
    initSuffixAndPrefix(tChars, suffix, prefix);

    int i = 0; // i表示主串与模式串对齐的第一个字符
    while (i <= sChars.length - tChars.length) {
        int j; // j代表坏字符对应模式串中的下标
        for (j = tChars.length - 1; j >= 0; j--) { // 模式串从后往前匹配
            if (sChars[i + j] != tChars[j]) {
                break;
            }
        }
        if (j < 0) {
            return i; // 匹配成功,返回主串与模式串第一个匹配的字符的位置
        }

        // 获取模式串中坏字符的最大index,如果没有找到,则返回-1
        int badCharMaxIndex = map.getOrDefault(sChars[i + j], -1);
        int x = j - badCharMaxIndex; // x代表坏字符规则移动的位数
        int y = 0;
        if (j < tChars.length - 1) { // 如果有好后缀的话
            y = moveByGS(j, tChars.length, suffix, prefix); // y代表好后缀规则移动的位数
        }
        i = i + Math.max(x, y);
    }
    return -1;
}

/**
 * 计算好后缀规则往后移动的位数
 *
 * @param j 坏字符对应的模式串中的字符下标
 * @param m 模式串的长度
 */
private static int moveByGS(int j, int m, int[] suffix, boolean[] prefix) {
    int k = m - 1 - j; // k代表好后缀的长度
    if (suffix[k] != -1) { // 优先匹配好后缀
        return j - suffix[k] + 1; // 移动至前一个匹配到好后缀的位置
    }
    for (int r = j + 2; r <= m - 1; r++) {
        if (prefix[m - r] == true) { // 有可以匹配的前缀
            return r; // 移动至匹配成功的前缀的位置
        }
    }
    return m;
}

至此,我们实现了一个初步版本的 BM 匹配算法,当然,实际的工业级 BM 算法实现比这个还要复杂一些,也需要考虑各种极端情况的存在,如果大家感兴趣,可以去研究一下,最后贴一张 BM 算法和 BF 算法的性能比较。

PS:Naive matching 即 BF 算法机制。

总结

字符串匹配算法是一个经典的算法问题,除了以上讲的 BF、BM 算法之外,还有数十种各有千秋的算法,例如 RK 算法、Sunday 算法以及著名的 KMP 算法等,其中 KMP 算法和 Sunday 算法的思想和 BM 算法的思想很相似。当然,现实生活中我们几乎不可能自己去实现一套复杂的字符串匹配算法,但了解它们的思想有助于我们锻炼思维能力。

参考

1.https://time.geekbang.org/column/intro/126

2.http://www.cs.jhu.edu/~langmea/resources/lecture_notes/boyer_moore.pdf

全文完


以下文章您可能也会感兴趣:

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值