第六章:串——字符串的底层逻辑与匹配艺术
串,说白了就是字符串。在C语言里,我们天天跟它打交道——从命令行参数到文本解析,从协议处理到日志输出,串无处不在。但很多人写了好几年代码,对串的理解还停留在「char数组」这个层面。今天我们就把它彻底讲透。
核心要点:串是一种特殊的线性表,它的数据元素只能是字符。别小看这个限制,正是这个特性让串有了自己独特的存储结构和算法体系。
6.1 串的定义与基本概念
串(String)是由零个或多个字符组成的有限序列。记作:S = "a1 a2 ... an",其中S是串名,引号内的字符序列是串值。
几个关键术语你得记住:
- 空串:长度为0的串,写作""。注意和空格串" "区分,空格串长度不为0。
- 子串:串中任意连续字符组成的子序列。比如"ello"是"Hello"的子串。
- 主串:包含子串的串。
- 位置:字符在串中的序号,通常从1开始计数(C语言习惯从0开始,但教材里常从1开始,注意区分)。
我个人习惯在嵌入式项目里用从0开始的位置,因为和数组下标一致,不容易出错。但如果你在写算法题,建议按教材来,免得扣分。
6.2 串的存储结构
串的存储结构有两种主流方式:顺序存储和链式存储。嗯,这里要注意,选择哪种取决于你的应用场景。
6.2.1 顺序存储
用一组连续的存储单元存放串的字符序列。C语言里最典型的就是char数组。
#define MAXLEN 255
typedef struct {
char ch[MAXLEN]; // 存储串的数组
int length; // 串的实际长度
} SString;
这种方式的优点是随机存取快,缺点是长度固定。我在做串口协议解析时遇到过一个问题:协议帧长度不确定,用固定数组要么浪费空间,要么溢出。后来我改用动态分配的方式:
typedef struct {
char *ch; // 动态分配
int length;
} HString;
我的建议:如果串的最大长度能确定,用SString;如果长度变化大,用HString。别为了省事一律用动态分配,嵌入式环境里malloc/free是有代价的。
6.2.2 链式存储
用链表存储串,每个节点存放若干字符。这种结构在插入删除时方便,但存储密度低。
#define CHUNKSIZE 4
typedef struct Chunk {
char ch[CHUNKSIZE];
struct Chunk *next;
} Chunk;
typedef struct {
Chunk *head, *tail;
int length;
} LString;
说实话,链式串在实际项目中用得不多。我曾经在一个文本编辑器项目里试过,结果发现频繁的指针操作让代码变得很脆弱,最后还是换回了顺序存储。
6.3 串的模式匹配算法
模式匹配,说白了就是「找子串」。给定主串S和模式串T,判断T是否是S的子串,并返回位置。这是串操作里最核心的问题。
为什么重要?文本搜索、关键词匹配、DNA序列分析、网络入侵检测……几乎所有涉及字符串处理的场景都离不开模式匹配。
6.3.1 BF算法(暴力匹配)
BF算法,全称Brute-Force,也叫朴素匹配。思路很简单:从主串的第一个字符开始,逐个和模式串比较,如果匹配失败,主串回溯到下一个位置,模式串回到开头,重新比较。
int BF(char S[], char T[]) {
int i = 0, j = 0;
while (S[i] != '\0' && T[j] != '\0') {
if (S[i] == T[j]) {
i++; j++;
} else {
i = i - j + 1; // 主串回溯
j = 0; // 模式串重置
}
}
if (T[j] == '\0')
return i - j;
else
return -1;
}
BF算法的时间复杂度是O(n*m),n是主串长度,m是模式串长度。最坏情况下,比如主串是"AAAAAAAAAB",模式串是"AAAB",每次匹配到最后一个字符才失败,然后回溯,效率极低。
我曾经踩过的坑:在一个日志分析工具里用了BF算法,日志文件有几百MB,模式串是"ERROR"。结果程序跑了十几分钟还没出结果。后来一分析,日志里大量"ERR"开头的单词,导致频繁回溯。换KMP后,几秒就搞定了。
6.3.2 KMP算法
KMP算法由Knuth、Morris和Pratt三位大神提出,核心思想是:利用已经匹配过的信息,不让主串回溯。
为什么会这样?你想想看,BF算法的问题在于,每次匹配失败后,主串的i要回退,模式串的j要归零。但很多时候,我们已经知道前面哪些字符是匹配的,这些信息完全可以利用起来。
KMP的关键是next数组,它记录了模式串中每个位置之前的子串的最长相等前后缀长度。
举个例子,模式串"ABABAA"的next数组:
| 位置j | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| 模式串 | A | B | A | B | A | A |
| next[j] | 0 | 1 | 1 | 2 | 3 | 4 |
next数组的计算方法:
void getNext(char T[], int next[]) {
int i = 1, j = 0;
next[1] = 0;
while (i < T[0]) { // T[0]存放模式串长度
if (j == 0 || T[i] == T[j]) {
i++; j++;
next[i] = j;
} else {
j = next[j];
}
}
}
有了next数组,KMP的匹配过程就简单了:
int KMP(char S[], char T[], int next[]) {
int i = 1, j = 1;
while (i <= S[0] && j <= T[0]) {
if (j == 0 || S[i] == T[j]) {
i++; j++;
} else {
j = next[j]; // 主串不回溯,只移动模式串
}
}
if (j > T[0])
return i - T[0];
else
return -1;
}
KMP的时间复杂度是O(n+m),比BF的O(n*m)好太多了。而且主串指针i永不回溯,这在处理流式数据时特别有用——你不需要把数据存下来,边读边匹配就行。
我的经验:KMP的next数组计算容易出错,建议先手算几个例子验证。我一般会在纸上画一遍"ABABAA"的匹配过程,确保next数组正确后再写代码。另外,实际工程中还有改进版的nextval数组,能进一步优化某些特殊情况。
6.4 知识体系总览
下面这张图把本章的核心内容串起来了,你可以对照着复习:
这张图把串的知识分成了三个层次:定义→存储→算法。你从顶层往下看,每个层次都有对应的技术选型。我个人建议初学者先把BF算法写熟练,理解回溯的代价,然后再去啃KMP。直接上KMP容易懵。
总结一下:串不是简单的字符数组,它有自己的一套理论和算法。BF算法是基础,KMP算法是进阶。在实际项目中,如果模式串很短(比如长度小于10),BF完全够用;如果模式串长且匹配频繁,KMP是更好的选择。
好了,串的内容就讲到这里。记住,理解KMP的关键是next数组,而理解next数组的关键是「最长相等前后缀」。把这个概念吃透了,KMP就没什么神秘的了。
公众号:蓝海资料掘金营,微信deep3321