259 阅读8分钟

定义

由一个或多个字符组成的有限序列

存储结构

定长顺序存储

#define Length 255

struct sstring {
    int length;
    char ch[Length];
};

此时使用结尾的 '\0' 或长度 length 标记结束位

块链存储

image.png

模式匹配

Brute-Force 算法(暴力算法)

令主串为 s, 需要匹配的字符串称为“模式” t

步骤

以主串 "BBC ABCDAB ABCDABCDABDE" 和模式 "ABCDABD" 为例, 下标从 1 开始

  1. 比较第一个字符

image.png

  1. 发现不匹配, 则模式后移一位继续匹配 image.png 表现为 i(2) = i(1) - j(1) + 2, 即继续比较 s[2] 与 t[1]

  2. s[2] 与 t[1] 也不匹配, 继续后移一位

image.png

  1. 以此类推, 到 D 发现不匹配

image.png

  1. 模式再后移一位, 模式从头开始重新匹配

image.png

表现为 i(6) = i(11) - j(7) + 2, 即继续比较 s[6] 与 t[1]

  1. 完全匹配后返回第一个匹配字符下标

image.png

表现为 index(12) = i(19) - t.length(7)

i=19 因为最后一次匹配(i=18)时相等, i++

代码实现
#include<stdio.h>

#define Length 255

struct sstring {
    int length;
    char ch[Length];
};

int find(sstring s, sstring t, int pos = 1) {
    int i = pos; // 默认从 1 开始查找
    int j = 1;
    
    int count = 0; // 比较次数
    
    // 下标不能超过两个字符串
    while (i <= s.length && j <= t.length) {
        count++;
        // 如果字符相等, 则继续下一个字符
        if (s.ch[i] == t.ch[j]) {
            i++;
            j++;
        }
        else {
            // 遇到不匹配的字符
            // 模式后移一位
            i = i - j + 2;

            // 模式重新从 1 开始匹配
            j = 1;
        }
    };
    
    printf("BF 算法比较次数: %d\n", count); // 26

    // j > t.length 说明模式完全匹配
    if (j > t.length) {
        // 返回模式第一个匹配的字符位置
        return i - t.length;
    }
    else {
        // 匹配不成功返回 -1
        return -1;
    }
}

int main() {
    // 前面一个 - 占位, 不起作用, 字符长度不包括 -
    sstring s = {23, "-BBC ABCDAB ABCDABDCABDE"}
        , t = {7, "-ABCDABD" };

    int index = find(s, t);

    printf("第一个匹配字符下标: %d", index);
}
复杂度分析

假设主串 s 长 n, 模式串 t 长 m

可能出现匹配的位置 i[1,nm+1]i\in[1, n - m + 1]

image.png

最好的情况下, 如果在第 i 个位置匹配成功, 则前面 i-1 个位置都只比较一次就跳到下一个位置, 总共比较了 (i1)+m(i-1) + m

平均比较次数为

i=1nm+1p(i1+m)=1nm+1i=1nm+1(i1+m)=12(m+n)\sum_{i=1}^{n-m+1}p(i-1+m)=\frac{1}{n-m+1}\sum_{i=1}^{n-m+1}(i-1+m)=\frac{1}{2}(m+n)

最坏的情况下, 如果第 i 个位置匹配成功, 则前面 i-1 个位置都比较 m 次(即每次都只是最后一个位置不一样), 总共比较了 (i1)×m+m=i×m(i-1)×m+m=i×m

平均比较次数:

i=1nm+1p(i×m)=1nm+1i=1nm+1(i×m)=12m(nm+2)\sum_{i=1}^{n-m+1}p(i×m)=\frac{1}{n-m+1}\sum_{i=1}^{n-m+1}(i×m)=\frac{1}{2}m(n-m+2)
存在问题

image.png

前面都是匹配的, 突然遇到一个不匹配的, 如果再后移一位去比较, 之前匹配的字符就完全错位, 肯定是不会匹配的

KMP 算法

前缀和后缀

前缀: 除了最后一个字符外的字符子串集合

后缀: 除了第一个字符外的字符子串集合

以字符串 'ababa' 为例

  1. a 没有前缀和后缀, 最大相等前后缀长度为 0
  2. ab 的前缀为 {a}, 后缀为 {b}, 最大相等前后缀长度为 0
  3. aba 的前缀为 {a, ab}, 后缀为 {a, ba}, 相等的前后缀为 {a}, 最大相等前后缀长度为 1
  4. abab 的前缀为 {a, ab, aba}, 后缀为 {b, ab, bab}, 相等的前后缀为 {ab}, 因此最大相等前后缀长度为 2
  5. ababa 的前缀为 {a, ab, aba, abab}, 后缀为 {a, ba, aba, baba}, 相等的前后缀为 {a, aba}, 最大相等前后缀长度为 3
PM 表

根据前后缀最大长度可以得出部分匹配表(PM 表)

image.png

最前面的 -1 是方便当 j=1 时出现不匹配时能够访问 PM(j-1), 为什么是 -1 与代码有关(后面说明)

这个表有什么用?

image.png

如上图, 当第 5 个不匹配时, 找到最后一个匹配字符(b)对应的部分匹配表值(2), 也就是说已经匹配了的字符串是 "abab", 其从左起, 有一个子字符串 "ab", 其在右边能找到一个一模一样的子字符串 "ab", 只需要将整体移动到后边, 就可以继续比较, 好处是 i 不需要“回退”了

ab 是最长相等前后缀, 所以两个 ab 之间不会存在更长的重复子字符串

image.png

那么这个移动距离是多少呢?

位移=已经匹配字符数最后匹配字符的部分匹配表值位移 = 已经匹配字符数 - 最后匹配字符的部分匹配表值

image.png

实际使用时, 不是把字符移动, 而是改变下标 j
j = j - ((j-1) - PM[j-1])=1+PM(j-1)
其中 j-1 是已经匹配的字符数

j=1 时出现不匹配(即t[1]≠s[1]), 则移动后下标 j = 1 + (-1) = 0
这时就需要在代码中判断当 j=0 时直接 i++, j++, 这样 j 又变成 1, 但是 i 变大了, 相当于t[1]s[2]继续进行比较

next 表

使用部分匹配表求位移, 需要知道最后一个匹配字符的表值(也就是PM[j-1]), 如果把部分匹配表值右移一位, 就可以直接使用当前下标 j 获取表值(也就是Next[j])

image.png

部分匹配表最后一个 a 的 3 是不需要的, 因为能用到最后一个, 说明已经完全匹配了

这里还可以优化一下, 每次不匹配都需要 j=1+next[j] 有点麻烦, 如果 next 表在原来的基础上 +1 岂不美哉?

image.png

现在不匹配就可以直接 j=next[j] 了 🎉🎉🎉🎉

回到刚刚的例子:

image.png

next 表值的特殊意义:

当在 j 处遇到不匹配时, 跳转到 next[j] 处继续与主串比较

代码实现

例子与 BF 算法一致

#include<stdio.h>
#include<string>

#define Length 255

struct sstring {
    int length;
    char ch[Length];
};

// 手动生成 next 表
// 第一个 0 占位
int next[8] = { 0, 0, 1, 1, 1, 1, 2, 3 };

int find(sstring s, sstring t, int pos = 1) {
    int i = pos;
    int j = 1;

    int count = 0;

    while (i <= s.length && j <= t.length) {
        if (j == 0) {
            i++;
            j++;
        }
        else if (s.ch[i] == t.ch[j]) {
            count++;
            i++;
            j++;
        }
        else {
            j = next[j];
        }
    };

    printf("KMP 算法比较次数: %d\n", count); // 13

    if (j > t.length) {
        return i - t.length;
    }
    else {
        return -1;
    }
}



int main() {
    sstring s = { 23, "-BBC ABCDAB ABCDABDCABDE" }
    , t = { 7, "-ABCDABD" };

    int index = find(s, t);

    printf("第一个匹配字符下标: %d", index);
}
next 表代码实现
  1. next[1] 一定等于 0
  2. next[j]=k 时, 存在 t1t2tk1=tjk+1tj2tj1t_1t_2\cdots t_{k-1}=t_{j-k+1}\cdots t_{j-2}t_{j-1}

为什么 1<k<j ? k-1 代表1~j-1 子串最大相等前后缀长度, 因此一定存在 k-1<j-1, 即 k 不会超过 j, 并且 t1t2tk1t_1t_2\cdots t_{k-1} 就是最长前缀

next[j]=k 时, next[j+1] 等于?

如果 t[k]=t[j] , 则有 t1t2tk=tjk+1tj2tjt_1t_2\cdots t_{k}=t_{j-k+1}\cdots t_{j-2}t_{j}, 因此 next[j+1] 等于 next[j]+1=k+1

比如

image.png

如果 t[k]≠t[j], 则将其看作一个迷你版模式匹配

image.png

因为 j'=next[k] < k, 所以 t1t2tkt_1t_2\cdots t_{k} 一定是向右滑动, 如果滑一次不匹配, 则 j''=next[next[k]], 直到最后 tx=tjt_x=t_j 为止, 这时 next[j+1]=x+1

  1. 其他情况下都是 1
void buildNext(sstring t, int next[]) {
    int j = 1, k = 0;
    next[1] = 0; // 固定值
    while (j < t.length) {
        if (k == 0 || t.ch[j] == t.ch[k]) {
            k++;
            j++;
            next[j] = k;
        }
        else {
            k = next[k];
        }
    }
}
🌟过程分析 1. j=1, k=0, 结束后 j=2, k=1, next[2]=1, 代表 1 子串不存在相等前后缀(next[j]=k说明 1~j-1 子串存在 k-1 长度的相等前后缀)

image.png

image.png

  1. j=2, k=1, 此时 t[2]!=t[1], k=next[1]=0
  2. j=2, k=0, 结束后 j=3, k=1, next[3]=1, 说明 1~2 子串不存在相等前后缀, 因为前面判断过 t[2]!=t[1]

image.png

  1. j=3, k=1, t[3]==t[1], 结束后 j=4, k=2, next[4]=2, 说明1~3 子串有长度为 1 的相等前后缀(即 t[1] 和 t[3])

image.png

  1. j=4, k=2, t[4]==t[2], 结束后 j=5, k=3, next[5]=3, 因为此时 k>1 了, 所以判断 t[j]t[k] 是否相等, 相等则 next[j+1]=next[j]+1=k+1

image.png

如果此时不相等呢, 比如

image.png

那就移到 1 号位(next[2]=1), 此时 j=4, k=1, 发现相等了, next[5]=k+1=2

image.png

复杂度分析

建立 next 表需要 O(m)O(m), 匹配字符需要 O(n)O(n), 总体需要 O(m+n)O(m+n)

例题

image.png

BM 算法

坏字符(BC)

image.png

好后缀(GS)

image.png

步骤
  1. 首先对齐主串和模式串, 比较模式串的尾部, 发现不匹配, 则 C 被称为“坏字符”

尾部比较的好处是, 只要尾部不相同, 前面的都不用比了

image.png

  1. 坏字符 C 出现在模式串中, 因此将其与坏字符对齐, 继续比较尾部

image.png

那么位移是多少呢?

设坏字符在模式串中出现的位置(最后出现, 也就是最右边的)为 x ,出现坏字符的位置为 y , 则位移为 y-x , 如果模式串中没有坏字符, 则 x=0

注意, 本文字符下标都是从 1 开始的, 如果从 0 开始, 则 x=-1

image.png

为什么是最靠右的? 举个例子:

image.png

最靠右位移较小, 不会丢失匹配项

如果坏字符在左边没有出现, 却在好后缀里出现, 怎么办(也就是 x>y)? 比如:

image.png

那就往后移动一个单位(坏字符的原则是不倒退)

注意, 这是在左边没有的情况下才这样, 左边和好后缀都有, 根据左边来

image.png

  1. 由于空格不在模式串中, 整体移动到空格后边, 发现一个好后缀 D

image.png

  1. 一旦有好后缀, 则往前继续比较

image.png

  1. 比较完成

image.png

如果好后缀之后遇到坏字符怎么办?

比如:

image.png

如果根据坏字符规则, 由于 I 不在模式串中, 则移动 3-0=3 个单位, X 在模式串中, 移动 7-2=5 个单位, 最后也能匹配成功

image.png

🧐 能否借鉴 KMP 算法充分利用好后缀?

好后缀规则位移与坏字符类似, 即:

设好后缀在模式串中出现的位置(最后一个字符)为 x , 出现好后缀的位置(最后一个字符)为 y , 则位移为 y-x , 如果模式串中没有好后缀了, 则 x=0

注意, 对上面的例子而言, 好后缀可以是 MPLE PLE LE E
只有 MPLE 可以是左边任意位置, 而非最长好后缀只能在开头

下图以 0 为起始

image.png

上面的例子不好说明好后缀的好处, 我们换个例子:

完全使用坏字符规则, 完成匹配需要 5 步

image.png

采用好后缀规则, 仅需要 4 步!

image.png

与 KMP 算法类似, 这样可以省略一些不必要的比较

📢当同时存在坏字符和好后缀时, 谁的位移最大取谁的

复杂度分析

对于坏字符规则:

最好情况下: O(n/m)O(n/m)

image.png

假设在第 i 个位置匹配成功, 则前 i - 1 次都只比较一次, 则平均情况:

i=1nmp(i1+m)=mni=1nm(i1+m)=m+n2m12\sum_{i=1}^{\frac{n}{m}}p(i-1+m)=\frac{m}{n}\sum_{i=1}^{\frac{n}{m}}(i-1+m)=m+\frac{n}{2m}-\frac{1}{2}

最坏的情况下和 BF 算法一样, 复杂度 O(n×m)O(n×m)

image.png

当加入了好后缀后, 最差的情况变成了与 KMP 一样, 即 O(n+m)O(n+m)

image.png