字符串与KMP算法:从朴素匹配到高效查找

字符串匹配,说白了就是在一个长字符串里找一段短字符串。这活儿我们天天干——编辑器里的查找功能、搜索引擎的关键词匹配、甚至你手机通讯录的搜索,背后都是这套逻辑。

我个人习惯把字符串匹配分成两类:一类是“暴力找”,一类是“聪明找”。今天我们就从暴力开始,一步步走到KMP这个经典算法。

字符串的存储结构

在C语言里,字符串其实就是一个字符数组,以'\0'结尾。这个设计有好有坏——好处是简单,坏处是你得时刻记着那个结尾符。

// 常见的字符串存储方式
char str1[] = "hello";          // 自动加'\0',长度6
char str2[] = {'h','e','l','l','o','\0'}; // 手动加,效果一样
char *str3 = "hello";           // 字符串常量,不可修改

注意:字符串常量存在只读区,我曾经在项目里试图修改它,结果程序直接崩溃。那会儿刚入行,查了半天才发现是这里的问题。

对于模式匹配来说,我们通常用两种方式表示字符串长度:

  • 显式长度:用一个int变量存长度,比如int len = 5;
  • 隐式长度:靠'\0'判断结尾,每次遍历都检查

我个人更推荐显式长度,尤其在算法实现里——少一次判断,少一个坑。

朴素模式匹配:暴力但直观

朴素匹配的思路很简单:从主串的第一个字符开始,跟模式串逐个比较。如果匹配失败,主串指针回溯到下一个位置,模式串指针回到开头,重新来。

int naive_match(char *text, char *pattern) {
    int n = strlen(text);
    int m = strlen(pattern);
    
    for (int i = 0; i <= n - m; i++) {
        int j;
        for (j = 0; j < m; j++) {
            if (text[i + j] != pattern[j]) {
                break;
            }
        }
        if (j == m) {
            return i;  // 匹配成功,返回起始位置
        }
    }
    return -1;  // 没找到
}

这个算法的时间复杂度是O(n*m)。最坏情况什么样?你想想看——主串是"AAAAAA...AAB",模式串是"AAAB",每次都要比到最后一个字符才发现不对,然后回溯。我遇到过这种数据,跑起来慢得让人抓狂。

核心问题:朴素匹配每次失败后,主串指针只前进一位,模式串指针回到0。很多已经比较过的信息被白白丢弃了。

KMP算法原理:不回溯的智慧

KMP算法的精髓就一句话:主串指针永不回溯,模式串指针根据已知信息跳着走

为什么会这样?因为当匹配到某个位置失败时,我们已经知道前面那些字符是什么了。利用这个信息,我们可以让模式串跳过一些肯定匹配不上的位置。

举个例子:主串"ababcabcabababd",模式串"ababd"。当匹配到第5个字符时,'c'和'd'不匹配。但前面"abab"我们已经比对过了,其中"ab"这个前缀在模式串里重复出现过。所以模式串可以直接跳到第三个字符开始比,主串不用动。

我的经验:理解KMP的关键不是背代码,而是想明白“为什么可以跳”。我当年学的时候,在纸上画了十几遍才真正搞懂。建议你也试试。

next数组:KMP的核心

next数组记录了模式串每个位置之前的子串中,最长相等前后缀的长度。说白了就是:如果在这个位置匹配失败,模式串应该跳到哪个位置继续比。

我们来看怎么计算next数组:

void get_next(char *pattern, int *next) {
    int m = strlen(pattern);
    next[0] = -1;  // 第一个字符匹配失败,主串前进
    int i = 0, j = -1;
    
    while (i < m - 1) {
        if (j == -1 || pattern[i] == pattern[j]) {
            i++;
            j++;
            next[i] = j;
        } else {
            j = next[j];  // 回溯
        }
    }
}

这里有个细节:next[0] = -1。为什么是-1?因为第一个字符就匹配失败时,模式串已经没有可以回退的位置了,只能让主串前进一位。用-1作为一个特殊标记。

我曾经踩过的坑:next数组的下标和模式串的下标容易搞混。记住:next[i]表示的是模式串第i个字符匹配失败时,模式串应该跳到哪个位置。不是第i个字符的前后缀长度,而是那个长度对应的下标。

有了next数组,KMP的匹配过程就清晰了:

int kmp_match(char *text, char *pattern) {
    int n = strlen(text);
    int m = strlen(pattern);
    int next[m];
    get_next(pattern, next);
    
    int i = 0, j = 0;
    while (i < n && j < m) {
        if (j == -1 || text[i] == pattern[j]) {
            i++;
            j++;
        } else {
            j = next[j];  // 模式串跳,主串不动
        }
    }
    
    if (j == m) {
        return i - m;
    }
    return -1;
}

你看,主串的指针i只增不减,这就是KMP高效的原因——时间复杂度O(n+m)。

知识体系总览

下面这张图把整个流程串起来了,建议你对照着看:

字符串匹配知识体系 字符串存储结构 匹配算法 实际应用 字符数组 '\0'结尾 朴素匹配 KMP算法 next数组 前缀后缀 最长相等 主串不回溯,模式串跳转 O(n*m) 回溯 文本搜索/编辑器 DNA序列匹配 网络入侵检测

next数组的优化:改进版

标准的next数组有个小问题。你想想看,如果pattern[i] == pattern[next[i]],那跳过去之后还是比不上的,白跳了。所以有了改进版:

void get_next_improved(char *pattern, int *next) {
    int m = strlen(pattern);
    next[0] = -1;
    int i = 0, j = -1;
    
    while (i < m - 1) {
        if (j == -1 || pattern[i] == pattern[j]) {
            i++;
            j++;
            // 改进点:如果相等,继续递归
            if (pattern[i] != pattern[j]) {
                next[i] = j;
            } else {
                next[i] = next[j];
            }
        } else {
            j = next[j];
        }
    }
}

这个优化在实际项目中很有用。我记得有一次处理超长文本搜索,用改进版比标准版快了将近20%。虽然看起来只是多了一个判断,但减少了很多无效跳转。

小技巧:面试时如果被问到KMP,先讲清楚朴素匹配的问题,再引出next数组的思想,最后给代码。这样显得思路清晰。我面人的时候,最怕听到“我背过KMP代码”这种话——背代码谁都会,理解才是关键。

好了,字符串匹配这块儿就聊到这儿。KMP不是唯一的高效匹配算法,但它是理解更高级算法(比如BM、Sunday)的基础。把next数组搞明白,后面学什么都快。


公众号:蓝海资料掘金营,微信deep3321