手把手带你剖析BM算法[C/C++]

本文详细介绍了Boyer-Moore算法,包括其坏字符规则和好后缀规则的实现过程,以及如何通过suffix表和GS表优化字符串匹配。该算法在模式串短且重复字符多时表现优秀。

目录

前言

规则一:坏字符规则

规则二:好后缀规则

匹配过程示例

坏字符规则的实现

好后缀规则的实现

suffix表的建立

suffix代码实现

gs表的建立

GS代码实现

解释1、找和好后缀匹配的前缀

解释2、找好后缀匹配的串中子串

BM最终代码

题目练习



前言

1977年,德克萨斯大学的Robert S. Boyer教授和J Strother Moore教授发明了一种新的字符串匹配算法:Boyer-Moore算法,简称BM算法。该算法从模式串的尾部开始匹配,且拥有在最坏情况下O(N)的时间复杂度。在实践中,大多数情况比KMP算法的实际效能高,尤其是在文本串较长、模式串较短以及模式串中存在重复字符的情况下,BM算法的性能优势更为明显。。 "文本串"与"模式串"头部对齐,从尾部开始比较.BM算法大量使用在文本编辑器中

我们KMP算法优化掉了文本串指针回退的动作,BM算法类似,在BM算法中,模式串和文本串从后向前匹配,如果遇到了不匹配的字符,我们就让文本串指针后移几位,相当于模式串往后滑动几位,跳过那些冗余匹配操作。

BM算法引入了两个规则:

规则一:坏字符规则

当文本串中的字符与模式串的某个字符不相同时,我们称文本串中这个字符为”坏字符“,此时我们向右滑动模式串,

移动的位数 = 坏字符此时与模式串匹配的位置 - 坏字符在模式串中最右出现位置 (如果坏字符不在模式串中,最右出现位置为-1)

规则二:好后缀规则

当匹配失败时候,已经匹配成功的子字符串称为好后缀,这个时候同样向右滑动模式串

移动的位数 = 好后缀起始与模式串匹配位置 - 好后缀在模式串中上一次出现位置的起始位置,如果好后缀不在模式串中出现,上一次位置为-1

匹配过程示例

下面我们以字符串匹配中一个经典的例子文本串“HERE IS A SIMPLE EXAMPLE”,和模式串“EXAMPLE”来举例示范。如果模式串在文本串中,那就返回在文本串中的位置,否则返回-1.

我们先把文本串和模式串首元素对齐,从尾部开始匹配,‘S’ 和 'E'不匹配,此时不存在好后缀,但是有坏字符’S‘,它此时对应着模式串的下标为6的位置,但是它没有在模式串中出现过,所以我们往后滑动6 - (-1)位,从而滑到了’S‘后面。

滑动后,再次从尾部开始匹配,又发现’P'和‘E’不匹配,此时我们的坏字符是'P',这次‘P’在模式串里面是存在的,且最右位置为4,所以我们把模式串往后滑动6 - 4 = 2位

再次移动后,我们再次从后往前匹配,我们发现‘I’和‘A’不匹配,‘I’为坏字符,同时我们有了前两次不曾有的好后缀“MPLE”,“PLE”,"LE","E“,这时我们发现两条规则都可以向后滑动,那么我们选择那条规则呢?显然是选择向后滑动长度最大的

对于好后缀,我们发现”E“可以使模式串向后滑动6 - 0 = 6位

对于坏字符,我们发现‘I’可以向后滑动2 - (-1) = 3位

所以我们选择好后缀规则,把模式串向后滑动6位

此时又有坏字符’P‘,我们可以向后滑动6 - 4 = 2 位

终于,我们成功匹配了我们的模式串

至此,我们对于BM算法应该有一定的理解了,与KMP算法相似,KMP算法跳过冗余匹配,去寻找能与当前后缀匹配的前缀进行移动,而我们的BM算法则是通过滑动移动到最近的坏字符或者好后缀的位置接着匹配,如果不存在,则将模式串的头尾部的下一个,当我们的模式串很短重复元素过多而文本串过于庞大,BM算法相对于我们KMP算法的优越性就体现出来了,前者一次移动几位而后者一次一位,甚至我们BM算法最好情况可以达到O(N/M)的时间复杂度。

坏字符规则的实现

坏字符规则的实现非常简单,只要构建一个坏字符表,我们不妨称为BC(bad char)表,存放各个字符最右位置,由于字符的范围为0~255,所以我们开一个256大小的数组即可,如果限定了字符的范围,可以缩小数组大小,对于BC表的建立,我们只需要遍历数组一次,不断更新每个字符的下标,对于未出现的字符,直接设置为-1即可,这样滑动时保证整体滑动到该字符的后一位

坏字符规则实现如下:

constexpr int ALPHABET = 256;

std::vector<int> BC(std::string p){
    int m = p.size();
    std::vector<int> bc(ALPHABET, -1);
    for (int i = 0; i < m; ++ i) {
        bc[p[i]] = i;
    }
    return bc;
}

int BM(std::string s, std::string p) {
    int n = s.size(), m = p.size();
    auto bc = BC(p);
    int i = 0;
    while (i + m - 1 < n) {
        int j = m - 1;
        while (j >= 0 && p[j] == s[i + j]) {
            -- j;
        }
        if (j < 0) {
            return i;
        }
        i += std::max(j - bc[s[i + j]], 1);
    }

    return -1;
}

好后缀规则的实现

和坏字符规则实现相似,我们显然需要一个好后缀表,好后缀表上记录我们每次匹配失败根据好后缀需要移动的距离,我们不妨称为gs(good suffix)表

我们先想建立gs表我们需要知道什么,假如我们匹配失败了,我们有着若干好后缀,而要确定移动的距离显然找到一个和好后缀匹配的子串,所以我们在建立gs表之前一般都会先建立一个suffix表来辅助我们构建gs表。

suffix表的建立

suffix表定义如下:

对于模式串p

suffix[ i ] = p(i, m - 1) 的最长相等前后缀中,后缀的起点下标

如果无 最长前后缀,那么 值为m

为了方便递推,suffix[m] = m + 1 作为哨兵

suffix代码实现
constexpr int ALPHABET = 256;

std::vector<int> Suffix(std::string p) {
    int m = p.size();
    std::vector<int> suffix(m + 1);
    suffix[m] = m + 1;
    int k = m + 1;
    for (int j = m; j > 0; -- j) {
        while (k <= m && p[j - 1] != p[k - 1]) {
            k = suffix[k];
        }
        suffix[j - 1] = -- k;
    }
    return suffix;
}
gs表的建立

有了suffix表的辅助,我们就可以构建gs表了

gs[ i ]表示遇到好后缀时,模式串应该移动的距离,i 指向好后缀的下一个位置,也就是坏字符的位置

对于gs表的移动模式一共有三种

串中子串匹配好后缀

前缀串匹配好后缀

及不存在串中子串也不存在前缀匹配好后缀

对于我们的好后缀可能会有多种好后缀匹配情况,对于多种情况时,我们选择滑动距离最小的,也就是最靠右的匹配子串,这和我们坏字符的规则类似

GS代码实现
constexpr int ALPHABET = 256;

std::vector<int> GS(std::string p) {
    int m = p.size();
    std::vector<int> gs(m), suffix(m + 1);
    suffix[m] = m + 1;
    int k = m + 1;
    for (int j = m; j > 0; -- j) {
        while (k <= m && p[j - 1] != p[k - 1]) {
            if (gs[k - 1] == 0) {
                gs[k - 1] = k - j;
            }
            k = suffix[k];
        }
        suffix[j - 1] = -- k;
    }
    k = suffix[0];
    for (int j = 0; j < m; ++ j) {
        if (gs[j] == 0) {
            gs[j] = k;
        }
        if (j + 1 == k) {
            k = suffix[k];
        }
    }
    return gs;
}

解释1、找和好后缀匹配的前缀

前缀滑动的距离显然比串中子串滑动的距离远,我们根据我们sufiix找匹配的前缀,suffix[i] == i + 1 说明0到i的i + 1个字符和后缀匹配,那么len -i - 2处就是坏字符的位置,那么从0到len - i - 2都可以设置为len - i - 1,单凭文字不够清晰,下图做出了描述

那么还有一个问题,为什么我们枚举i要从len - 1开始枚举,前缀越长,坏字符和前缀末端距离越近,滑动距离越短,我们要尽可能保证滑动距离短

解释2、找好后缀匹配的串中子串

这段代码很短,理解起来反而不是很容易。

我们GS[i]的含义是好后缀的开头的前一个字符也就是坏字符位置对应的滑动距离,那么GS[len - 1 - suffix[i]]中的len - 1 - suffix[i]对应的一定是一个坏字符的位置,而我们suffix[ i ] 表示以 i 为右边界,与模式串后缀匹配的最大长度,

再看 GS[len - 1 - suffix[i]] = len - 1 - i; 我们明白suffix[i]是串中和好后缀匹配的子串长度,那么len - 1减去这个长度就是坏字符位置,那么滑动距离就是len - 1和i的差

BM最终代码

有了GS表和BC表后,我们只需要把模式串和文本串倒序匹配,匹配失败就让我们主串往后滑动BC和GS滑动距离中大的那个

我们具体代码可以用shift代表主串和模式串匹配部分的起始位置,然后枚举pos从len - 1开始枚举,pos代表模式串和主串匹配的位置,那么对应的主串匹配的位置就是shift + pos,每次用一个for循环去匹配,检查退出循环的时候pos的值,pos为-1说明成功,否则我们让shift偏移即可

代码如下

constexpr int ALPHABET = 256;

std::vector<int> BC(std::string p){
    int m = p.size();
    std::vector<int> bc(ALPHABET, -1);
    for (int i = 0; i < m; ++ i) {
        bc[p[i]] = i;
    }
    return bc;
}

std::vector<int> GS(std::string p) {
    int m = p.size();
    std::vector<int> gs(m), suffix(m + 1);
    suffix[m] = m + 1;
    int k = m + 1;
    for (int j = m; j > 0; -- j) {
        while (k <= m && p[j - 1] != p[k - 1]) {
            if (gs[k - 1] == 0) {
                gs[k - 1] = k - j;
            }
            k = suffix[k];
        }
        suffix[j - 1] = -- k;
    }
    k = suffix[0];
    for (int j = 0; j < m; ++ j) {
        if (gs[j] == 0) {
            gs[j] = k;
        }
        if (j + 1 == k) {
            k = suffix[k];
        }
    }
    return gs;
}

int BM(std::string s, std::string p) {
    int n = s.size(), m = p.size();
    auto bc = BC(p);
    auto gs = GS(p);
    int i = 0;
    while (i + m - 1 < n) {
        int j = m - 1;
        while (j >= 0 && p[j] == s[i + j]) {
            -- j;
        }
        if (j < 0) {
            return i;
        }
        i += std::max(j - bc[s[i + j]], gs[j]);
    }

    return -1;
}

题目练习

原题链接

28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)

思路分析

主要用来检验代码的正确性

AC代码

constexpr int ALPHABET = 256;

std::vector<int> BC(std::string p){
    int m = p.size();
    std::vector<int> bc(ALPHABET, -1);
    for (int i = 0; i < m; ++ i) {
        bc[p[i]] = i;
    }
    return bc;
}

std::vector<int> GS(std::string p) {
    int m = p.size();
    std::vector<int> gs(m), suffix(m + 1);
    suffix[m] = m + 1;
    int k = m + 1;
    for (int j = m; j > 0; -- j) {
        while (k <= m && p[j - 1] != p[k - 1]) {
            if (gs[k - 1] == 0) {
                gs[k - 1] = k - j;
            }
            k = suffix[k];
        }
        suffix[j - 1] = -- k;
    }
    k = suffix[0];
    for (int j = 0; j < m; ++ j) {
        if (gs[j] == 0) {
            gs[j] = k;
        }
        if (j + 1 == k) {
            k = suffix[k];
        }
    }
    return gs;
}

int BM(std::string s, std::string p) {
    int n = s.size(), m = p.size();
    auto bc = BC(p);
    auto gs = GS(p);
    int i = 0;
    while (i + m - 1 < n) {
        int j = m - 1;
        while (j >= 0 && p[j] == s[i + j]) {
            -- j;
        }
        if (j < 0) {
            return i;
        }
        i += std::max(j - bc[s[i + j]], gs[j]);
    }

    return -1;
}

class Solution {
public:
    int strStr(string s, string t) {
        return BM(s, t);
    }
};

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_Equinox

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值