11. 串:从定义到KMP,一场关于字符串的深度对话

大家好,我是你们的老朋友。今天我们来聊聊串——也就是字符串。说实话,串这个东西,在C语言里是个让人又爱又恨的存在。爱它是因为几乎所有实用程序都离不开它,恨它是因为……嗯,你想想看,一个字符数组就能搞定的东西,为什么我们要专门开一章来讲?

别急,等你看完KMP算法的精妙之处,就会明白我的意思了。

11.1 串的定义:不只是字符数组

串(String),是由零个或多个字符组成的有限序列。一般记作:S = "a1 a2 ... an",其中S是串名,引号内的部分是串值。

这里有几个关键概念,我建议你记牢:

  • 空串:长度为0的串,写作 ""。注意,它和空格串 " " 不是一回事。
  • 子串:串中任意连续字符组成的子序列。比如 "ello""Hello" 的子串。
  • 主串:包含子串的串。
  • 位置:字符在串中的序号,通常从1开始(C语言里从0开始,这里要注意区分)。

核心要点:串和线性表的区别在于,串的操作对象是「字符」这个整体,而不是单个元素。你在项目中处理日志文件、解析协议时,本质上都是在操作串。

11.2 串的存储结构:两种流派

串的存储,说白了就两种方式。我个人更偏爱第二种,但第一种在某些场景下也有它的优势。

11.2.1 定长顺序存储

用固定长度的字符数组来存储串。比如:

#define MAXLEN 255
typedef struct {
    char ch[MAXLEN];
    int length;  // 串的实际长度
} SString;

这种方式的优点是简单、访问快。但缺点也很明显——长度固定,容易溢出。我在早期做嵌入式开发时,就吃过这个亏。当时解析一个GPS数据帧,没注意长度检查,结果缓冲区溢出,整个系统直接挂了……嗯,从那以后我再也不敢轻视边界检查了。

避坑指南:定长存储时,一定要检查插入/拼接操作是否会导致长度超过MAXLEN。C语言不会自动帮你做这件事。

11.2.2 堆分配存储

用动态内存分配来存储串,长度可以动态变化:

typedef struct {
    char *ch;
    int length;
} HString;

使用时需要手动管理内存:

void InitString(HString *S) {
    S->ch = NULL;
    S->length = 0;
}

void StrAssign(HString *S, const char *chars) {
    if (S->ch) free(S->ch);
    int len = strlen(chars);
    S->ch = (char *)malloc((len + 1) * sizeof(char));
    strcpy(S->ch, chars);
    S->length = len;
}

这种方式的灵活性更高,但你要小心内存泄漏。我建议你养成一个习惯:谁分配,谁释放。

11.3 朴素的模式匹配算法

模式匹配,就是在一个主串中查找一个子串(模式串)的位置。最直接的方法,就是暴力匹配。

算法思路很简单:从主串的第一个字符开始,与模式串的第一个字符比较。如果相等,继续比较下一个;如果不相等,主串回溯到第二个字符,模式串回到开头,重新开始。

int Index_BF(const char *S, const char *T) {
    int i = 0, j = 0;
    int lenS = strlen(S);
    int lenT = strlen(T);
    
    while (i < lenS && j < lenT) {
        if (S[i] == T[j]) {
            i++; j++;
        } else {
            i = i - j + 1;  // 主串回溯
            j = 0;          // 模式串回到开头
        }
    }
    if (j == lenT) return i - j;  // 匹配成功
    else return -1;               // 匹配失败
}

这个算法的时间复杂度是O(n*m),n为主串长度,m为模式串长度。最坏情况下,比如主串是 "AAAAAAAAAAAAAAB",模式串是 "AAAB",每次都要比较到最后一个字符才发现不匹配,然后回溯……效率极低。

我的经验:在实际项目中,如果模式串很短(比如长度小于10),或者主串规模不大,朴素算法完全够用。别一上来就上KMP,过度优化是万恶之源。

11.4 KMP模式匹配算法

KMP算法由Knuth、Morris和Pratt三位大神提出。它的核心思想是:主串不回溯,模式串尽量少回溯

为什么会这样?你想想看,在朴素算法中,每次匹配失败后,主串指针i要回退,模式串指针j要归零。但很多时候,我们已经知道前面一部分字符是匹配的,这些信息完全可以利用起来。

11.4.1 next数组:KMP的灵魂

KMP的关键在于一个next数组。next[j]表示:当模式串中第j个字符匹配失败时,模式串应该跳到哪个位置继续匹配。

next数组的计算规则:

  • next[0] = -1(C语言中,下标从0开始)
  • next[1] = 0
  • 对于j > 1,next[j] = 模式串前j-1个字符中,最长相等前后缀的长度

举个例子,模式串 "ABABAC" 的next数组:

j 0 1 2 3 4 5
模式串 A B A B A C
next[j] -1 0 0 1 2 3

怎么算的?以j=4为例,前3个字符是 "ABA",最长相等前后缀是 "A",长度为1,所以next[4]=1。

11.4.2 计算next数组的代码

void GetNext(const char *T, int *next) {
    int len = strlen(T);
    next[0] = -1;
    int i = 0, j = -1;
    
    while (i < len - 1) {
        if (j == -1 || T[i] == T[j]) {
            i++; j++;
            next[i] = j;
        } else {
            j = next[j];
        }
    }
}

这段代码看起来有点绕,但核心逻辑就是:用两个指针i和j,i往前走,j回溯。如果T[i]==T[j],说明找到了一个更长的前后缀;否则j回退到next[j]的位置继续尝试。

注意:这里j的初始值是-1,配合next[0]=-1,形成了一个巧妙的递归结构。我第一次看这段代码时也懵了,后来画了几遍图才明白——说白了,就是在用模式串自己匹配自己。

11.4.3 KMP匹配过程

有了next数组,KMP的匹配就很简单了:

int Index_KMP(const char *S, const char *T) {
    int i = 0, j = 0;
    int lenS = strlen(S);
    int lenT = strlen(T);
    int *next = (int *)malloc(lenT * sizeof(int));
    GetNext(T, next);
    
    while (i < lenS && j < lenT) {
        if (j == -1 || S[i] == T[j]) {
            i++; j++;
        } else {
            j = next[j];  // 主串i不动,模式串j跳转
        }
    }
    free(next);
    if (j == lenT) return i - j;
    else return -1;
}

和朴素算法对比一下,区别就在else分支:朴素算法是 i = i - j + 1; j = 0;,而KMP是 j = next[j];。i没有回溯,j也没有归零,而是跳到了一个合理的位置。

11.4.4 时间复杂度分析

KMP的时间复杂度是O(n+m)。为什么?因为在整个匹配过程中,i只增不减,最多移动n次;j虽然会回溯,但每次回溯都会让j变小,而j总共增加的次数不超过n,所以回溯的总次数也不超过n。加上计算next数组的O(m),总复杂度就是O(n+m)。

我的建议:KMP虽然理论优美,但在实际工程中,如果模式串长度不大,或者主串是随机文本,朴素算法的平均性能并不差。我一般在以下情况才会用KMP:模式串很长、主串是重复性很强的数据(比如基因序列)、或者对最坏情况有严格要求。

知识体系总览

下面这张图,是我为你梳理的本章知识结构:

串(String) 串的定义 存储结构 模式匹配 空串 / 空格串 子串 / 主串 定长顺序 堆分配 朴素算法 KMP算法 next数组 匹配过程 O(n+m)复杂度 图:串的知识体系结构

好了,关于串的内容就讲到这里。从定义到存储,从朴素匹配到KMP,每一步都有它存在的道理。KMP的next数组确实需要花点时间消化,但一旦理解了它的「利用已匹配信息」这个核心思想,你会发现它其实很自然。

下次遇到字符串匹配的问题,你可以先问问自己:这个场景值得上KMP吗?如果值得,那就大胆用;如果不值得,朴素算法也挺好。毕竟,合适的才是最好的。


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