数据结构与算法-字符串匹配与KMP

主串S:"abcacabdc",模式串T:"abd",请找出模式串在主串中第一次出现的位置。

提示:主串和模式串均为小写字母且都是合法输入。

1.1 思路1

  • 匹配肯定头部要相等才开始比较后面的
  • 如果开始匹配,每一个字符都应该相等,且不为结束符\00NULL
    • 如果匹配结束时,子串已经到结束符,那说明和子串完全匹配

过程:

  1. abcacabdc 和 abd 匹配不上,移动到下一个a开头的子串
  2. abcacabdc 和 abd 匹配不上,移动到下一个a开头的子串
  3. abcacabdc 和 abd 匹配上了,返回abd开头的索引
#define NOT_FOUND -1
int findStringInString(char *a, char *b) {
    if (!*a || !*b) return NOT_FOUND;
    int i = 0, j;
    char *p;
    while (a[i]) {
        if (a[i] == b[0]) {
            p = a + i; // 当前字符串的开头
            j = 1; // 头已经比较过了,从下一个位置开始比较
            while (p[j] && b[j] && p[j] == b[j]) // 一次比较
                j++;
            if (!b[j]) // 子串完全匹配
                return i;
            if (!p[j]) // 主串已经到末尾了,之后字符串长度都不够,就不需要再比较了
                return NOT_FOUND;
        }
        i++; // 移动字符开始的索引
    }
    return NOT_FOUND;
}

1.2 思路2

假设主串:"abxabcabcaby",子串:"abcaby"。

思路1中,a指针都是依次增加的,所以a需要移动次,为主串长度,为子串长度。

参考每日气温 栈练习题 中题目2的思路2,跳跃对比的想法,在比较过程中如果出现了a开头,我们就可以直接跳过中间不是a开头的字符。

例如,abxabcabcaby中,abcabc不匹配之后,我们可以直接跳过bc来到下一个a

int findStringInString2(char *a, char *b) {
    if (!*a || !*b) return NOT_FOUND;
    int i = 0, j, next = NOT_FOUND;
    char *p;
    while (a[i]) {
        if (a[i] == b[0]) {
            p = a + i; // 当前字符串的开头
            j = 1; // 头已经比较过了,从下一个位置开始比较
            while (p[j] && b[j]) { // 一次比较
                if (next == NOT_FOUND && p[j] == b[0]) { // 匹配过程中找到了下一个开头
                    next = i + j;
                }
                if (p[j] != b[j]) break;
                j++;
            }
            if (!b[j]) // 子串完全匹配
                return i;
            if (!p[j]) // 主串已经到末尾了,之后字符串长度都不够,就不需要再比较了
                return NOT_FOUND;
        }
        if (next != NOT_FOUND) {
            i = next;
            next = NOT_FOUND;
        } else {
            i++; // 移动字符开始的索引
        }
    }
    return NOT_FOUND;
}

1.3 思路3

假设主串:"abxabcabcaby",子串:"abcaby"。

思路2 abxabcabcaby 中,abcabc不匹配之后,我们可以直接跳过bc来到下一个a

在匹配 abxabcabcaby 中,其实我们发现最后的c和子串的y不匹配,但是c之前的ababy中的ab是匹配过的,且ababcaby的前面也出现过,能否用这个思路来进行跳跃对比呢?

显然我们需要对我们的子串进行处理,当发生不匹配时,用来进行跳跃。要怎么构建这个跳跃信息呢?

以"abcaby"为例,重复的子串是ababcaby匹配abcabc时:

  • 当前面字符均匹配,来到最后的yc时发生不匹配。
  • 我们可以认为y之前的ab已经匹配过了,我们从abcaby跳过最前面的abc开始继续进行匹配。

也就是说,当模式串存在重复子串时,我们可以回溯到重复子串之后一个位置继续进行匹配。

0 1 2 3 4 5 // 索引

a b c a b y // 模式串

0 0 0 1 2 0 // 回溯索引

第二次出现ab时,前一次他们出现的下一个位置是c。当y不匹配时,我们看到前一个位置索引为2,正是我们子串c的索引值,然后继续进行比较。

1.3.1 构建回溯数组

那么构建这个数组的思路就明了了,即求出子串中重复子串的下一个位置。

我们用aabaabaaa进行一次构建来说明:

构建回溯数组
int *GetNext(char *pattern){
    size_t length = strlen(pattern); // 获取数组长度
    int *res = (int *)malloc(sizeof(length) * length);
    res[0] = 0; // 第一个回溯索引为0
    for (int i = 1, j = 0; i < length;) {
        if (pattern[i] == pattern[j]) { // 匹配位置i和j的值相同,则res[i]等于j+1,i和j后移
            res[i] = j + 1;
            j++;
            i++;
        } else { // 匹配位置i和j的值不同
            if (j != 0) { // 还j不等于0,j等于前一个字符的回溯索引,重新比较
                j = res[j-1];
            } else {
                res[i] = 0; // j等于0,则res[i]填入0,i后移
                i++;
            }
        }
    }
    return res;
}
1.3.2 匹配过程

下面我们来说说比较问题:

这里我们用abxabcabcabyabcaby进行匹配。

匹配过程
int FindStringInString3(char *a, char *b){
    if (!*a || !*b) return NOT_FOUND;
    int length = (int)strlen(b); // 获取子串长度
    int *arr = GetNext(b, length); // 构建回溯索引
    int i = 0, j = 0;
    while (a[i] && j < length) {
        if (a[i] == b[j]) { // 匹配位置i和j的值相同,i和j后移
            i++;
            j++;
        } else { // 匹配位置i和j的值不同
            if (j != 0) { // j不等于0,j等于前一个字符的回溯索引,重新比较
                j = arr[j-1];
            } else { // j等于0,i后移
                i++;
            }
        }
    }
    free(arr);
    if (j == length) { // 如果循环结束时是因为子串到头了,说明匹配上了
        return i - length; // 子串的开始位置为i减去匹配字符串的长度
    }
    return NOT_FOUND;
}
1.3.3 总结

这个算法其实就是著名的KMP算法。

核心思想:

若当前位置不匹配,当前位置前存在重复子串,则通过回溯可以跳过重复的子串继续匹配,而不是从头开始。

  • 这里相比于思路2,同时跳过了主串和模式串成功匹配的部分
  • 1.3.2 匹配过程中的图示8~10很好地体现了这个优势。

不管是构建模式串的回溯数组还是匹配算法,其实两个函数的处理是非常相似的。

int *GetNext(char *pattern, size_t length) {
    int *res = (int *)malloc(sizeof(int) * length);
    res[0] = 0; // 第一个回溯索引为0
    for (int i = 1, j = 0; i < length;) {
        if (pattern[i] == pattern[j]) { // 匹配位置i和j的值相同,则res[i]等于j+1,i和j后移
            res[i] = j + 1;
            j++;
            i++;
        } else { // 匹配位置i和j的值不同
            if (j != 0) { // j还不等于0,j等于前一个字符的回溯索引,重新比较
                j = res[j-1];
            } else {
                res[i] = 0; // j等于0,则res[i]填入0,i后移
                i++;
            }
        }
    }
    return res;
}

int FindStringInString3(char *a, char *b){
    if (!*a || !*b) return NOT_FOUND;
    int length = (int)strlen(b); // 获取子串长度
    int *arr = GetNext(b, length); // 构建回溯索引
    int i = 0, j = 0;
    while (a[i] && j < length) {
        if (a[i] == b[j]) { // 匹配位置i和j的值相同,i和j后移
            i++;
            j++;
        } else { // 匹配位置i和j的值不同
            if (j != 0) { // j不等于0,j等于前一个字符的回溯索引,重新比较
                j = arr[j-1];
            } else { // j等于0,i后移
                i++;
            }
        }
    }
      free(arr);
    if (j == length) { // 如果循环结束时是因为子串到头了,说明匹配上了
        return i - length; // 子串的开始位置为i减去匹配字符串的长度
    }
    return NOT_FOUND;
}

int main(int argc, const char * argv[]) {
    char *a = "abxabcabcaby";
    char *b = "abcaby";
    int idx = findStringInString3(a, b);
    printf("%d\n", idx);
    return 0;
}

2. RK算法

该算法有几点值得学习的地方:

  • 把字符串比较问题,转换为了Hash值比较问题。
  • 利用前一个Hash值计算结果,辅助计算下一个Hash值。(可以算是动态规划)
  • 在比较的过程中,进行判断,同时解决Hash冲突问题。

2.1 字符串转Hash值

十进制中一个数可以被表示为:

这里只考虑小写字母,我们有26个字母,所以字母的进制是二十六进制'a'的ASC码为97,如果直接用,很容易造成溢出问题,所以在计算Hash值时会减去'a'。比如"abc"就可以被表示为:

这里我们可以发现一个Hash冲突问题,比如"abc""bc"的Hash值是一样的,因为最高位是0

这里要解决Hash冲突有两个办法:

  • 使用更优的Hash算法,比如我们不使用减去'a',而是减去'a'-1。(a在最高位时不会为0)

  • 在发生冲突的时候,我们根据字符的起点,比较一下两个字符串。

注意:Hash算法再牛逼,还是存在冲突的可能性。所以我们就算用了优化的算法,最好也使用方法2再比较一次。

2.2 利用前一个结果计算下一个Hash值

我们还是以十进制为例,主串6512,第一项为651,第二项为512
\begin{equation}\begin{split} S_1 &= 6 * 10^2 + 5 * 10^1 + 1 * 10^0 \\ S_2 &= 5 * 10^2 + 1 * 10^1 + 2 * 10^0 \\ &= (6 * 10^2 + 5 * 10^1 + 1 * 10^0 - 6 * 10^2) * 10 + 2 * 10^0\\ &= (S_1 - 6 * 10^2) * 10 + 2 * 10^0 \end{split}\end{equation}
我们假设d为使用的进制,m表示匹配串的长度,那么字符串的Hash值我们可以通过下面的公式进行计算:

当然我们使用取余也是可以的:

RK算法实现:

#define NOT_FOUND -1

bool isMatch(char *mainStr, int i, char *matchStr, int m) {
    for (int j = 0; j < m; j++) {
        if (mainStr[i+j] != matchStr[j])
            return false;
    }
    return true;
}

int RK(char *mainStr, char *matchStr) {
    int d = 26; // 表示进制
    int n = (int)strlen(mainStr); // 主串长度
    int m = (int)strlen(matchStr); // 子串长度
    
    unsigned int mainHash = 0; // 主串分解子串的哈希值
    unsigned int matchHash = 0; // 模式串的哈希值
    // 1.求得子串与主串中0~m字符串的哈希值[计算子串与主串0-m的哈希值]
    // 循环[0,m)获取模式串A的HashValue以及主串第一个[0,m)的HashValue
    int offset = 'a' - 1; // a为最小值,保证a是最高位时,不会为0
    // 2.计算哈希值同时获取d^m-1值(因为经常要用d^m-1进制值)
    int dm1 = 0;
    for (int i = 0; i < m; i++) { // 小于m,不计算\0的值,数字会小一点
        matchHash = (d * matchHash + (matchStr[i] - offset));
        mainHash = (d * mainHash + (mainStr[i] - offset));
        dm1 = dm1 > 0 ? dm1 * d : 1;
    }
    
    // 3.遍历比较哈希值
    for (int i = 0; i <= n-m; i++) {
        if (matchHash == mainHash // 判断模式串哈希值是否和其他子串的哈希值一致
            && isMatch(mainStr, i, matchStr, m)) // 哈希值相等后,再次用字符串进行比较,防止哈希值冲突
            return i;
        // 计算下一个子串的哈希值
        mainHash = (mainHash - (mainStr[i] - offset) * dm1) * d + mainStr[i+m] - offset;
//        mainHash = (mainHash % dm1) * d + mainStr[i+m] - offset; // 或者取余
    }
    return NOT_FOUND;
}

3. KMP改进

对于aaaaab这个匹配串,next数组为0 1 2 3 4 0,但实际如果发生不匹配,不需要回溯前一个a,因为还是不匹配,应该直接回溯到头部。

这里修改一下KMP的实现,即现在next[j]对应的是当前不匹配时应该回溯的索引,而不是通过next[j-1]获取。同时头部回溯索引使用-1,可以更方便判断是否回到了头部。

#define NOT_FOUND -1

int *GetNext(char *pattern, int length) {
    int *next = (int *)malloc(sizeof(int) * length);
    int i, j;
    j = next[0] = -1;
    i = 0;
    while (i < length-1) {
        // 当因为回溯j<0时,说明一直不匹配,回溯到了头部,当前字符没法匹配,所以i和j都进行后移
        // j<0时,i++是为了开始下一个字符的比较(因为当前没法再回溯了,说明这个字符没法匹配)
        // j<0时,j++是为了使得j=0来到匹配串的开头,开始下一轮的比较
        if (j < 0 || pattern[j] == pattern[i]) {
            i++;
            j++;
//            next[i] = j; // 未优化
            // 如果当前匹配,i和j后移之后还匹配,可以使用之前j的回溯值(更快回溯到头部)
            // 为什么要使用next[j],因为重复串回溯回去还是会不匹配,不如直接通过next[j]回溯到更前面
            // 如果当前匹配,i和j后移之后不匹配,优化前的策略得到回溯值
            next[i] = pattern[j] == pattern[i] ? next[j] : j;
        } else {
            // 当前不匹配时,通过next[j]进行回溯
            j = next[j];
        }
    }
    return next;
}

int FindStringInString(char *a, char *b) {
    if (!*a || !*b) return NOT_FOUND;
    int length = (int)strlen(b);
    int *next = GetNext(b, length); // 获取next数组
    int i, j;
    i = j = 0;
    while (a[i] && j < length) {
        // 开始j = 0,所以直接比较字符是否相等
        // 当因为回溯j<0时,说明一直不匹配,回溯到了头部,当前字符没法匹配,所以i和j都进行后移
        // j<0时,i++是为了开始下一个字符的比较(因为当前没法再回溯了,说明这个字符没法匹配)
        // j<0时,j++是为了使得j=0来到匹配串的开头,开始下一轮的比较
        if (j < 0 || a[i] == b[j]) {
            i++;
            j++;
        } else {
            j = next[j];
        }
    }
    free(next);
    if (j == length) {
        return i - length;
    }
    return NOT_FOUND;
}

你可能感兴趣的:(数据结构与算法-字符串匹配与KMP)