串:从字符序列到模式匹配的完整理解
这一章讨论的数据对象是"串"。如果说线性表强调的是一组同类型元素的有序组织,那么串可以看作一种更特殊的线性表:它的元素限定为字符,因此它既保留了线性结构的顺序特征,又带有非常鲜明的文本处理色彩。无论是查找关键词、判断子串是否出现,还是进行文本替换、编辑器检索、搜索引擎预处理,本质上都离不开串及其匹配算法。整章内容主要围绕两个层面展开,一层是串本身的定义、存储与基本操作,另一层是串的模式匹配,尤其是从朴素匹配一路推导到 KMP 算法的思想升级。
一、串并不是普通线性表的简单替代,而是面向字符处理的专门结构
串的定义可以概括为:由零个或多个字符组成的有限序列。这个定义看起来和线性表非常接近,但真正的差别在于元素类型和实际应用。线性表中的元素往往是一般数据对象,而串中的元素通常是字符,因此串更适合描述单词、句子、命令、代码片段、标识符等文本信息。
理解串时,有几个概念必须先区分清楚。一个串中的字符个数称为串长,长度为零的串称为空串。由一个或多个连续字符组成的片段称为子串,而原串中某个子串第一次出现的位置往往是模式匹配问题最关心的结果。除了空串以外,子串总是依附于某个主串存在;如果一个子串在主串中连续出现,那么就说模式在主串中匹配成功。
这一章里,串最重要的特点不是"它也能插入、删除、比较",而是"它天然带有匹配需求"。也就是说,串的核心任务并不只是保存字符,更重要的是高效判断某段字符序列是否在另一段字符序列中出现,以及出现在哪里。后面整章关于 BF 和 KMP 的讨论,都是围绕这个核心问题展开的。
二、串的基本操作,本质上是在围绕字符序列做组织与截取
教材在串的定义之后,给出了一组常见操作,例如赋值、复制、判空、求长、比较、连接、求子串、插入、删除、清空等。这些操作本身不难,但它们共同说明了一件事:串虽然是特殊的线性表,却有一套更符合文本处理直觉的接口体系。
例如,求长操作强调字符个数;比较操作往往采用字典序思想,从左到右逐字符比较,直到分出大小或比较结束;连接操作把两个串首尾拼接成新串;求子串操作则是从主串中截取某一段连续字符;插入和删除操作更像文本编辑器里对字符串的修改;判空和清空则对应串是否含有有效字符,以及是否需要恢复到空状态。
真正需要把握的,并不是这些函数名,而是其中蕴含的思维方式。对于串而言,最常见的处理不是任意位置上的复杂结构变化,而是围绕"连续字符片段"进行提取、比较和匹配。因此,串的很多操作都比一般线性表更强调连续区间和顺序一致性。
三、串的存储方式,关键不在形式多样,而在是否有利于字符处理
串的存储方式通常可以分为顺序存储和链式存储两大类。顺序存储是最直观的方式,使用一段连续空间依次存放字符,再配合长度信息记录当前串的实际大小。它的优势在于访问方便、实现简单,而且非常符合字符串在程序设计语言中的常见表示方式。
顺序存储下,又可以进一步区分定长顺序存储和堆分配存储。定长方式通常预先分配固定大小的数组空间,优点是结构简单、操作直接,但缺点是空间容量受到限制;堆分配方式则通过动态申请空间来存放字符串内容,更灵活,也更适合长度不固定的应用场景。无论哪一种,本质上都是在用连续地址保存字符序列。
链式存储则把串表示为若干节点的链接,每个节点中可以存放一个字符,也可以存放多个字符。单字符节点的表示更直观,便于理解链式结构;多字符节点的表示则能减少指针域开销,提高存储密度。链式存储的优势在于对长度变化更灵活,但它在实际串处理,尤其是高频比较与模式匹配中,通常不如顺序存储自然。原因很简单:模式匹配本质上要求频繁按顺序比较字符,顺序存储能够更直接地支持这种扫描过程。
因此,这一章虽然介绍了多种存储结构,但从实际算法角度看,最值得掌握的仍然是顺序存储下的串处理思想。后面的模式匹配算法,也基本都是围绕顺序存储展开说明的。

四、模式匹配是这一章真正的中心问题
所谓模式匹配,就是在主串中查找某个模式串是否出现,并求出其出现的位置。这里的"主串"可以理解为被搜索的文本,"模式串"则是要查找的目标片段。比如在一段文章中查找某个关键词,在 DNA 序列中查找某段基因片段,在代码中查找某个函数名,本质上都可以抽象成模式匹配问题。
这个问题表面上很简单,但真正难点在于效率。最直接的做法是从主串的每个可能起点出发,尝试与模式串逐字符比较;一旦失败,就移动主串中的起点,再重新开始比较。这个方法逻辑朴素、实现直观,但可能产生大量重复比较。教材正是以这个问题为主线,先引出简单模式匹配算法,再进一步说明为什么需要 KMP 这样更高效的方法。
所以学习这一章时,不能只把 BF 和 KMP 当成两套孤立代码去记。更重要的是理解:它们解决的是同一个问题,只是利用的信息量不同。朴素算法只知道"哪里失配了",而 KMP 进一步利用了"已经匹配成功的那一段中蕴含的结构信息",因此能够避免不必要的回退。
五、简单模式匹配算法的思路很自然,但效率并不理想
简单模式匹配算法,也常被称为朴素匹配或 BF 算法。它的思想非常直接:设主串当前位置为起点,让模式串从第一个字符开始与主串逐个字符比较;如果当前字符相同,则两边指针同时后移;如果发生失配,则主串起点后移一位,模式串回到开头,再重新开始下一轮比较。
这个算法为什么容易理解,是因为它几乎完全符合人的直觉。当我们拿着一个词去文章中查找时,也会自然地从前往后试,一旦发现不相同,就换到下一个位置继续试。因此 BF 算法的难点不在思想,而在于认识它的低效来源。
它的问题在于,当一次匹配过程中已经比较了很多字符,最后却在某个位置失配时,之前那些比较结果几乎没有被利用。下一轮比较重新开始后,很多字符实际上又会再比一遍。也就是说,BF 算法会产生大量重复劳动,尤其当主串和模式串中存在较多相同前缀、但又经常在后部失配时,这种低效会特别明显。
从复杂度角度看,BF 算法在最坏情况下需要进行数量级为 (O(nm)) 的字符比较,其中 (n) 是主串长度,(m) 是模式串长度。这个复杂度并不意味着它一无是处。事实上,在模式串较短、数据规模不大、或者工程实现追求简单时,BF 仍然是可用的。但如果希望在理论和实践上都获得更好的匹配效率,就必须进一步挖掘模式串自身的结构信息,这就引出了 KMP。

六、KMP 的关键突破,不是"比较更快",而是"失配后不盲目回退"
KMP 算法之所以经典,不是因为它把字符比较本身变快了,而是因为它改变了失配后的处理方式。BF 算法一旦失配,主串指针通常要回退到下一起点,模式串指针也回到开头;而 KMP 的做法是:主串指针不回退,模式串根据已经匹配成功的信息跳到一个合适的位置继续比较。
这个改进非常关键。它意味着,主串中那些已经看过的字符尽量不再被重复扫描。换句话说,KMP 把"失配后如何移动模式串"这个动作做成了有依据的跳转,而不是简单粗暴地从头再来。
要理解这种跳转,就必须回答一个问题:如果模式串前面已经有一段与主串匹配成功了,那么在失配时,模式串接下来应该移动到哪里,才不会漏掉可能的匹配,又能尽量少做无效比较?这个问题的答案,就体现在 next 数组中。


七、next 数组记录的不是机械下标,而是模式串的结构信息
next 数组是 KMP 的核心。它反映的是:当模式串在某个位置失配时,模式串指针下一步应该跳到哪里继续比较。更深一层说,它编码了模式串前缀与后缀之间的重合关系,因此本质上是一张"模式串自我匹配信息表"。
要理解 next,最重要的不是背定义,而是抓住"最长相等前后缀"这个思想。对于模式串的某一前缀,如果它的某个前缀同时又是该前缀的后缀,那么当匹配过程中失配时,模式串就没有必要完全从头再来,因为前面已经匹配过的一部分内容仍然可能继续发挥作用。也就是说,模式串可以直接滑动到那个"前缀等于后缀"的位置,再继续比较。
这背后的逻辑很巧妙。假设主串与模式串已经成功匹配了若干字符,那么这些匹配结果说明:主串中的某段子串已经等于模式串前面的某一段。如果这段模式串内部本身又存在前后缀相等关系,那么失配后,模式串完全可以把那段相等部分对齐过去,而不需要把所有已知信息全部丢弃。
因此,next 数组真正保存的,是"发生失配时模式串还能保留多少有效匹配信息"。一旦把这一点看懂,KMP 就不再神秘,它只是把模式串中可复用的信息预处理出来,并在匹配时加以利用。

八、KMP 的匹配过程,本质上是一边扫描主串,一边按 next 调整模式串
在 KMP 匹配过程中,主串指针始终从左到右单向推进,这一点和 BF 最大不同。模式串指针也在推进,但一旦失配,并不是简单回到开头,而是根据 next 的值跳转到合适位置。只有当模式串指针已经退到特殊边界时,主串指针才继续前进并重新开始一段新的比较。
因此,KMP 的核心运行特征可以概括成两句话。第一,主串字符尽量只看一遍,不轻易回头。第二,模式串通过 next 数组完成自适应滑动,把已经得到的匹配信息转化为下一步移动依据。
也正因为如此,KMP 的时间复杂度能够达到 (O(n+m))。其中对模式串构造 next 数组需要 (O(m)),实际匹配主串需要 (O(n))。这和 BF 的 (O(nm)) 相比,是非常显著的提升。尤其当主串很长、模式串又存在大量重复结构时,KMP 的优势会非常明显。
九、next 数组的求法,需要真正理解递推关系而不是死记结果
很多人在学习 KMP 时,最容易卡住的不是匹配过程,而是 next 数组怎么求。其实这一部分的关键仍然不在记忆,而在理解递推关系。
计算 next 时,通常也是从前到后逐步构造。设当前要求的是某个位置对应的 next 值,那么就要看它之前那一段模式串中,最长相等前后缀的长度是多少。如果当前字符和候选前缀后面的字符相等,那么最长相等前后缀就可以继续延长;如果不等,就要继续缩短候选长度,转而考察更短的相等前后缀,直到找到能匹配的位置,或者退回到初始边界。
这个过程本质上仍然是在做"模式串与自身的匹配"。只不过匹配对象不是主串,而是模式串自己的前缀结构。因此,next 的构造并不是凭空出现的一张表,而是模式串自我分析的结果。
真正掌握这一点之后,再看 next 的递推公式,就会发现它并不抽象。它只是把"当前候选最长相等前后缀能否继续扩展"这件事写成了程序流程。
十、nextval 是在 next 基础上的进一步优化
教材后面还介绍了 nextval,它是在 next 的基础上做出的进一步优化。它的出发点是:有些情况下,即使按照 next 跳转,模式串仍然可能马上遇到一个和当前失配字符相同的位置,于是又会再次失配,导致比较没有真正减少。为了避免这种无意义的比较,可以继续利用模式串中字符相等的信息,对 next 做进一步修正,形成 nextval。
所以,nextval 的意义并不在于它推翻了 next,而在于它更激进地减少了重复比较。可以把它理解为一种"失配后的再优化策略":如果跳过去之后注定还会因为同一个字符关系而失败,那就不如一步跳得更远一点。
从学习角度看,nextval 的地位略低于 next。真正必须吃透的是 KMP 为什么要利用前后缀信息,以及 next 数组究竟表达了什么;在这个基础上,再去理解 nextval,就会比较顺畅。否则如果一开始就把 next、nextval 当作两张需要硬记的表,反而容易混乱。
十一、这一章最值得建立的,不是代码模板,而是整条推导链
如果把整章内容串起来,其实它在训练一条非常完整的算法推导思路。
首先,从串这种特殊线性表出发,明确字符序列的基本概念和常见操作。接着,提出一个非常自然又非常实际的问题:如何在主串中高效查找模式串。然后先用最朴素的办法解决,也就是 BF 算法。再进一步分析 BF 为什么会慢,慢在哪里。最后针对这个低效点,引入模式串内部的前后缀结构信息,设计出 KMP 及其 next、nextval 机制。
这条路线特别值得反复体会,因为它展示了算法设计中一种非常典型的方法:先有直接解法,再分析其冗余,最后利用问题自身的结构进行优化。真正学到手的,不应该只是"会写 KMP",而是"知道 KMP 是怎么被推出来的,为什么这样推是合理的"。
十二、这一章的高频易错点,基本都集中在概念和下标处理上
这一章最常见的错误,第一类是把串、子串、模式串、主串这些概念混用。串是一般字符序列,子串强调它是某个串中的连续片段,模式串是拿去匹配的目标,主串是被搜索的对象。它们之间有联系,但角色不同,一旦混淆,后面的匹配过程就容易看乱。
第二类错误是 next 数组的含义没搞清。很多人会机械记住某个模式串的 next 结果,却说不清它表示什么。实际上,只有把它理解成"失配后模式串应该跳到哪里"以及"这个跳转由最长相等前后缀决定",后面的匹配和构造过程才会真正通顺。
第三类错误是下标体系混乱。不同教材、不同代码实现,可能会使用从 0 开始或从 1 开始的下标习惯,某些版本的 next 定义还会有所不同。学习时一定要先固定一套表示方式,再在这一套约定下理解代码。否则明明思想已经懂了,却可能因为下标偏移把实现写错。
第四类错误是把 KMP 理解成"任何情况下都比 BF 快很多"。从理论上说,KMP 的最坏复杂度更优,这没有问题;但在某些简单场景中,BF 由于实现直接、常数较小,未必就一定表现很差。因此真正应该记住的是:KMP 的优势在于避免最坏情况下的大量重复比较,而不是神奇地让所有匹配过程都瞬间加速。
十三、这一章真正讲透的是"如何利用已经得到的信息"
从更高的角度看,这一章最有价值的地方,不只是让人掌握了串和 KMP,更重要的是它展示了一个非常深刻的算法思想:不要浪费已经得到的信息。
BF 算法每次失配后几乎把前面所有比较结果都丢掉了,于是不得不重新开始;KMP 则意识到,那些已经匹配成功的字符并不是毫无价值,它们恰恰暴露了模式串内部的某种结构规律。只要把这种规律提前分析出来,就能在失配时做出更聪明的移动决策。
这种思想并不只属于模式匹配。后面学习更多算法时,也会不断遇到类似逻辑:前面做过的工作能不能复用,当前得到的信息能不能帮助减少后续代价,某个中间结果能不能转化成下一步的依据。KMP 之所以经典,很大程度上正是因为它把这种"利用已有信息降低重复劳动"的思想体现得非常纯粹。
结语
这一章表面上讲的是串、子串、模式匹配和 KMP,实际上讲的是字符序列处理中最核心的一类问题:如何在保持正确性的前提下,尽量减少重复比较。串作为特殊线性表,奠定了文本处理的基本对象;简单模式匹配给出了直观可行的起点;而 KMP 则通过 next 和 nextval,把模式串自身的结构信息转化为匹配过程中的效率优势。
当这些内容真正串起来之后,这一章就不再只是"会背 next 数组""会写匹配代码"那么简单,而会变成一种更深的理解:一个高效算法往往不是凭空出现的,它通常来自对朴素做法中冗余部分的识别,以及对问题内部结构的充分利用。
重点问题
