承接前序线性表、栈与队列的内容,本篇我们来学习线性表的又一特殊形态:串(String) 。串是我们日常开发中接触最多的数据结构------从登录时的用户名密码校验,到搜索引擎的关键词匹配,再到文档的查找替换、正则表达式的底层实现,本质都是串的操作。而串的核心重难点,也是笔试面试的"常驻嘉宾",就是模式匹配算法,尤其是KMP算法,更是无数开发者学数据结构时的"噩梦"。
你有没有过这样的经历:
- 面试时被面试官问"讲一下KMP算法的原理?",只能支支吾吾背出next数组,却讲不清核心思想?
- 写文本查找功能时,只会用嵌套循环暴力匹配,遇到长文本直接超时,却不知道怎么优化?
- 用Python的
str.find()、Java的indexOf()时,好奇这些内置的字符串查找函数,底层到底用了什么黑科技?
其实这些问题的答案,都藏在串的底层逻辑里。本篇文章,我们会从串的基本定义出发,讲透串的三大存储结构、全量基础操作,再从暴力匹配算法入手,一步步推导KMP算法的核心思想、next数组构建、nextval优化,最后拓展工业界更常用的BM、Sunday算法。全文干货拉满,附带完整可运行的代码实现,让你不仅懂原理,更能写得出、用得好、面试说得清。
一、串的定义与核心概念
1. 什么是串?
串(字符串)是由零个或多个字符组成的有限序列,是元素限定为字符型的特殊线性表。
我们用公式表示串:S = "a₁a₂a₃...aₙ",其中:
S是串名,通常用双引号包裹串的内容(双引号本身不属于串);aᵢ是串的元素,只能是字符(可以是字母、数字、符号、中文等);n是串的长度,当n=0时,称为空串 ,用""表示。
2. 串的核心相关概念
这些概念是后续模式匹配的基础,必须彻底搞懂,避免混淆:
| 概念 | 定义 | 示例 |
|---|---|---|
| 主串 | 包含子串的完整字符串 | 主串S = "abcdefg" |
| 子串 | 主串中任意个连续的字符组成的子序列 | S中的"bcd"是子串,单个字符"a"也是子串 |
| 模式串 | 在主串中查找的目标子串,模式匹配的核心对象 | 要在S中找"def","def"就是模式串 |
| 前缀 | 不包含串的最后一个字符的、以第一个字符开头的连续子串 | 串"ababc"的前缀:"a"、"ab"、"aba"、"abab" |
| 后缀 | 不包含串的第一个字符的、以最后一个字符结尾的连续子串 | 串"ababc"的后缀:"c"、"bc"、"abc"、"babc" |
| 空格串 | 由一个或多个空格组成的串,长度不为0 | " "是空格串,不是空串 |
| 串的位置 | 字符在串中的序号,通常从0开始(编程实现)或1开始(教材理论) | 串"abc"中,'a'的位置是0,'b'是1 |
3. 串和普通线性表的核心区别
串本质是线性表,但操作逻辑和普通线性表有本质差异:
- 普通线性表的操作核心是单个元素:比如增删改查单个元素;
- 串的操作核心是子串:比如查找、替换、插入一个连续的子串,而非单个字符。
这也是为什么我们要把串单独作为一个章节学习的核心原因。
二、串的存储结构
串作为特殊的线性表,同样有顺序存储和链式存储两大类,细分为三种主流实现:定长顺序存储 、堆分配存储 、块链存储。
1. 定长顺序存储(静态数组实现)
定长顺序存储是用一组地址连续的固定长度的内存单元存储串的字符序列,底层是静态数组。
核心实现逻辑
- 提前定义数组的最大长度
MAXLEN,超过长度的串内容会被截断(称为"截断溢出"); - 串的长度有两种记录方式:
- 用一个变量
length单独记录串的实际长度(推荐,操作更方便); - 在串的末尾加一个结束标记
'\0'(C语言标准实现,长度需要遍历计算)。
- 用一个变量
代码实现(Python模拟静态数组)
python
# 定长顺序存储的串实现
class StaticString:
# 最大长度固定为100
MAXLEN = 100
def __init__(self):
# 静态数组,固定长度MAXLEN
self.data = [None] * self.MAXLEN
# 实际串长度
self.length = 0
# 赋值操作:从字符串初始化串,超过MAXLEN的部分截断
def assign(self, s: str):
self.length = min(len(s), self.MAXLEN)
for i in range(self.length):
self.data[i] = s[i]
# 剩余位置清空
for i in range(self.length, self.MAXLEN):
self.data[i] = None
# 转为普通字符串,方便输出
def __str__(self):
return "".join(self.data[:self.length])
# 测试
if __name__ == "__main__":
s = StaticString()
s.assign("hello world")
print("定长串内容:", s)
print("定长串长度:", s.length)
优缺点
- 优点:实现简单,字符随机访问速度O(1),没有额外内存开销;
- 缺点:长度固定,容易出现截断溢出,内存利用率低(提前分配了固定内存,哪怕只用了一小部分)。
2. 堆分配存储(动态数组实现)
堆分配存储是目前主流编程语言的标准实现 (Python、Java、C++的string底层都是这个方案),同样用连续的内存单元存储字符,但内存空间是在程序运行时动态分配的,不会有固定长度的限制。
核心实现逻辑
- 串的字符序列存储在一块动态申请的连续内存中,这块内存位于程序的"堆区",因此称为堆分配存储;
- 当串的长度变化时,可以动态重新申请内存,复制原有内容,释放旧内存,不会出现截断问题;
- 用单独的变量记录串的长度,不需要结束标记。
优缺点
- 优点:长度灵活,没有截断问题,内存利用率高,支持随机访问O(1);
- 缺点:动态扩容时有内存复制的开销(均摊复杂度仍为O(1))。
3. 块链存储(链式结构实现)
块链存储是串的链式存储实现,和单链表类似,但为了提高内存利用率,每个节点不止存储一个字符,而是存储一块连续的字符 ,称为"块",因此称为块链存储,也叫块串。
核心实现逻辑
- 每个节点分为两部分:
data域(存储一块字符)、next指针(指向下一个节点); - 每个节点的
data域可以存储多个字符,最后一个节点如果存不满,用特殊字符(如'#')填充; - 核心指标:存储密度 = 字符占用的内存 / 节点总内存(字符+指针),每个节点存的字符越多,存储密度越高。
代码实现
python
# 块链存储的节点类
class BlockNode:
# 每个块存储4个字符,可自定义
BLOCK_SIZE = 4
def __init__(self):
# 字符块,固定长度BLOCK_SIZE
self.data = [None] * self.BLOCK_SIZE
# 指向下一个节点的指针
self.next = None
# 块链串实现
class BlockString:
def __init__(self):
self.head = BlockNode()
self.length = 0
# 赋值操作:从字符串初始化块链串
def assign(self, s: str):
self.length = len(s)
if self.length == 0:
return
cur = self.head
index = 0
# 遍历字符串,分块存储
while index < self.length:
# 填充当前块
for i in range(BlockNode.BLOCK_SIZE):
if index < self.length:
cur.data[i] = s[index]
index += 1
else:
# 最后一个块填不满,用#填充
cur.data[i] = '#'
# 如果还有字符,新建节点
if index < self.length:
cur.next = BlockNode()
cur = cur.next
# 转为普通字符串
def __str__(self):
result = []
cur = self.head
while cur:
for c in cur.data:
if c != '#':
result.append(c)
cur = cur.next
return "".join(result)
# 测试
if __name__ == "__main__":
s = BlockString()
s.assign("hello world")
print("块链串内容:", s)
print("块链串长度:", s.length)
优缺点
- 优点:插入删除不需要移动大量字符,没有长度限制,不会出现溢出;
- 缺点:存储密度低,有指针开销,随机访问需要遍历块,效率极低。
三、串的基础操作与完整实现
串的基础操作是所有字符串处理的基石,我们基于堆分配存储(最主流的实现),自定义一个完整的串类,实现所有核心操作,让你彻底理解串的底层逻辑。
1. 串的抽象数据类型(ADT)
串的核心操作集如下,所有操作都围绕"子串"展开:
| 操作类型 | 操作描述 |
|---|---|
| 赋值 | 用一个字符串初始化串 |
| 拼接 | 将两个串拼接成一个新串 |
| 比较 | 按字典序比较两个串的大小 |
| 求子串 | 截取串中指定位置、指定长度的子串 |
| 插入 | 在指定位置插入一个子串 |
| 删除 | 删除串中指定位置、指定长度的子串 |
| 替换 | 将串中指定的子串替换为新的子串 |
| 辅助操作 | 判空、获取长度、清空、遍历 |
2. 自定义串类的完整实现(Python)
Python的原生str是不可变对象,我们用列表模拟底层的动态数组,实现一个可变的串类,完全手动实现所有基础操作,不依赖Python内置的字符串方法。
python
class MyString:
# 初始化:空串
def __init__(self, s: str = ""):
# 底层用动态列表存储字符,模拟堆分配存储
self.data = list(s)
self.length = len(s)
# 1. 赋值操作:覆盖当前串的内容
def assign(self, s: str):
self.data = list(s)
self.length = len(s)
# 2. 串的拼接:将另一个串拼接到当前串末尾,返回新串
def concat(self, other: 'MyString') -> 'MyString':
new_data = self.data.copy() + other.data.copy()
return MyString("".join(new_data))
# 3. 串的比较:按字典序比较,大于返回1,等于返回0,小于返回-1
def compare(self, other: 'MyString') -> int:
min_len = min(self.length, other.length)
# 逐字符比较
for i in range(min_len):
if ord(self.data[i]) > ord(other.data[i]):
return 1
elif ord(self.data[i]) < ord(other.data[i]):
return -1
# 前面的字符都相等,比较长度
if self.length > other.length:
return 1
elif self.length < other.length:
return -1
else:
return 0
# 4. 求子串:从start位置开始,截取length长度的子串
def substring(self, start: int, length: int) -> 'MyString':
# 边界检查
if start < 0 or start >= self.length:
raise IndexError("起始位置越界")
if length < 0 or start + length > self.length:
raise IndexError("截取长度越界")
# 截取子串
sub_data = self.data[start:start+length]
return MyString("".join(sub_data))
# 5. 插入操作:在pos位置插入子串s
def insert(self, pos: int, s: 'MyString'):
if pos < 0 or pos > self.length:
raise IndexError("插入位置越界")
# 拆分原串,插入子串
self.data = self.data[:pos] + s.data + self.data[pos:]
self.length += s.length
# 6. 删除操作:从pos位置开始,删除length长度的子串
def delete(self, pos: int, length: int):
if pos < 0 or pos >= self.length:
raise IndexError("删除起始位置越界")
if length < 0 or pos + length > self.length:
raise IndexError("删除长度越界")
# 删除子串
self.data = self.data[:pos] + self.data[pos+length:]
self.length -= length
# 7. 子串替换:将串中所有的old_sub替换为new_sub
def replace(self, old_sub: 'MyString', new_sub: 'MyString'):
if old_sub.length == 0:
raise ValueError("被替换的子串不能为空")
i = 0
# 遍历查找所有匹配的子串
while i <= self.length - old_sub.length:
# 匹配子串
match = True
for j in range(old_sub.length):
if self.data[i+j] != old_sub.data[j]:
match = False
break
if match:
# 匹配成功,删除旧子串,插入新子串
self.delete(i, old_sub.length)
self.insert(i, new_sub)
# 跳过新子串,避免循环替换
i += new_sub.length
else:
i += 1
# 辅助操作:判空
def is_empty(self) -> bool:
return self.length == 0
# 辅助操作:清空串
def clear(self):
self.data = []
self.length = 0
# 格式化输出
def __str__(self):
return "".join(self.data)
# 重载比较运算符,方便直接用==、>、<比较
def __eq__(self, other):
return self.compare(other) == 0
def __gt__(self, other):
return self.compare(other) == 1
def __lt__(self, other):
return self.compare(other) == -1
测试代码
python
# 测试自定义串类
if __name__ == "__main__":
# 初始化
s1 = MyString("hello")
s2 = MyString("world")
print("s1:", s1, "长度:", s1.length)
print("s2:", s2, "长度:", s2.length)
# 拼接
s3 = s1.concat(MyString(" ")).concat(s2)
print("拼接结果:", s3)
# 比较
print("s1 > s2:", s1 > s2)
print("s1 == s2:", s1 == s2)
# 求子串
sub = s3.substring(6, 5)
print("子串(6,5):", sub)
# 插入
s3.insert(5, MyString(" python"))
print("插入后:", s3)
# 删除
s3.delete(5, 7)
print("删除后:", s3)
# 替换
s3.replace(MyString("world"), MyString("python"))
print("替换后:", s3)
四、核心重难点:串的模式匹配算法
1. 什么是模式匹配?
模式匹配是串操作中最核心的问题,也是面试最高频的考点:给定主串S(长度n)和模式串T(长度m),在主串S中查找模式串T第一次出现的起始位置;如果找不到,返回-1。
举个例子:主串S = "ababcabcacbab",模式串T = "abcac",模式匹配的结果是5,因为T在S的第5个位置(从0开始)第一次出现。
模式匹配的应用场景极其广泛:
- 文档编辑器的"查找/替换"功能;
- 搜索引擎的关键词匹配;
- 正则表达式的底层实现;
- 日志分析、病毒特征码匹配;
- 数据库的模糊查询。
接下来,我们从最简单的暴力匹配算法入手,一步步优化到KMP、BM、Sunday算法,彻底搞懂模式匹配的底层逻辑。
2. BF(Brute Force)暴力匹配算法
BF算法,也叫朴素匹配算法,是最直观、最容易实现的模式匹配算法,也是所有优化算法的基础。
(1)算法思想
用双指针分别遍历主串和模式串:
- 初始化主串指针
i=0,模式串指针j=0; - 当
i < n且j < m时:- 如果
S[i] == T[j],匹配成功,i++,j++,继续匹配下一个字符; - 如果
S[i] != T[j],匹配失败,主串指针回退到i-j+1,模式串指针回退到0,重新开始匹配;
- 如果
- 循环结束后,如果
j == m,说明匹配成功,返回起始位置i-j;否则返回-1。
(2)代码实现
python
def bf_match(main_str: str, pattern_str: str) -> int:
n = len(main_str)
m = len(pattern_str)
# 边界处理:模式串为空,返回0;模式串比主串长,返回-1
if m == 0:
return 0
if m > n:
return -1
i = j = 0
while i < n and j < m:
if main_str[i] == pattern_str[j]:
# 匹配成功,指针都后移
i += 1
j += 1
else:
# 匹配失败,指针回退
i = i - j + 1
j = 0
# 匹配成功
if j == m:
return i - j
# 匹配失败
return -1
# 测试
if __name__ == "__main__":
S = "ababcabcacbab"
T = "abcac"
print("BF匹配结果:", bf_match(S, T)) # 输出5
print("BF匹配不存在的串:", bf_match(S, "abcd")) # 输出-1
(3)复杂度分析与优缺点
- 时间复杂度 :
- 最好情况:第一次就匹配成功,时间复杂度O(m);
- 最坏情况:每次匹配到模式串最后一个字符才失败,比如主串
S = "aaaaaab",模式串T = "aaab",总比较次数是(n-m+1)*m,时间复杂度O(n*m); - 平均情况:O(n+m)。
- 空间复杂度:O(1),只用到了两个指针变量。
- 优点:逻辑简单,实现容易,没有预处理开销,在短文本、模式串长度小的场景下足够用;
- 缺点:最坏情况效率极低,主串指针频繁回退,做了大量重复的无效比较,在长文本场景下完全不可用。
3. KMP算法:模式匹配的经典优化
KMP算法是由D.E.Knuth、J.H.Morris、V.R.Pratt三位科学家共同提出的,因此得名KMP。它是对BF算法的革命性优化,彻底解决了主串指针回退的问题,把最坏时间复杂度降到了O(n+m),也是面试最高频的考点。
(1)KMP的核心思想:为什么不用回退主串指针?
BF算法的痛点在于:匹配失败时,主串指针会回退,哪怕前面已经有很多字符匹配成功了,也要从头再来。
举个例子:主串S = "abababc",模式串T = "ababc"。
当匹配到i=4, j=4时,S[4] = 'a',T[4] = 'c',匹配失败。
BF算法会让i回退到1,j回退到0,重新匹配。但我们肉眼就能发现:前面已经匹配成功的"abab",有公共的前后缀"ab",主串的i=3、4位置的"ab",和模式串的j=0、1位置的"ab"是完全匹配的,根本不需要回退i,只需要把j回退到2,继续匹配即可!
这就是KMP的核心思想:利用模式串中已经匹配成功的前缀信息,找到最长的相等前后缀,让模式串指针j回退到正确的位置,主串指针i永远不回退,从而避免重复比较,大幅提升效率。
(2)前置知识:最长相等前后缀
KMP算法的核心,是对模式串做预处理,生成一个next数组 ,而next数组的本质,就是模式串每个位置的最长相等前后缀长度。
我们再明确一次定义:
- 前缀:不包含串的最后一个字符,以第一个字符开头的连续子串;
- 后缀:不包含串的第一个字符,以最后一个字符结尾的连续子串;
- 最长相等前后缀:一个串的前缀和后缀中,最长的、相等的那个子串的长度。
举个例子,模式串T = "ababc",我们逐个计算每个位置的最长相等前后缀长度:
| 模式串下标j | 子串T[0...j] | 前缀集合 | 后缀集合 | 最长相等前后缀长度 |
|---|---|---|---|---|
| 0 | "a" | 空 | 空 | 0 |
| 1 | "ab" | ["a"] | ["b"] | 0 |
| 2 | "aba" | ["a","ab"] | ["a","ba"] | 1 |
| 3 | "abab" | ["a","ab","aba"] | ["b","ab","bab"] | 2 |
| 4 | "ababc" | ["a","ab","aba","abab"] | ["c","bc","abc","babc"] | 0 |
(3)next数组的定义与构建
next数组是KMP算法的灵魂,它的定义是:next[j]表示,当模式串的j位置与主串匹配失败时,模式串指针j应该回退到的下标位置。
next数组的值,和模式串j位置之前的子串的最长相等前后缀长度直接相关:
- 我们约定
next[0] = -1:当模式串第一个字符就匹配失败时,主串指针i需要后移一位,模式串指针j保持-1(后续会自增到0); - 对于j>0的位置,
next[j] = 模式串T[0..j-1]的最长相等前后缀长度。
还是以T = "ababc"为例,我们计算它的next数组:
| j | T[j] | T[0...j-1]的最长相等前后缀长度 | next[j] |
|---|---|---|---|
| 0 | 'a' | 无(约定) | -1 |
| 1 | 'b' | T[0] = "a",长度0 | 0 |
| 2 | 'a' | T[0...1] = "ab",长度0 | 0 |
| 3 | 'b' | T[0...2] = "aba",长度1 | 1 |
| 4 | 'c' | T[0...3] = "abab",长度2 | 2 |
next数组的递推实现
手动计算next数组很简单,但我们需要用代码实现,这里用递推法,时间复杂度O(m),是标准的实现方式。
核心递推逻辑:
- 初始化:
next[0] = -1,指针k=-1(表示当前最长相等前后缀的长度),j=0(遍历模式串的指针); - 当
j < m-1时:- 如果
k == -1,或者T[j] == T[k]:说明找到了更长的相等前后缀,j++,k++,next[j] = k; - 如果
T[j] != T[k]:说明当前前后缀不匹配,k = next[k],回退k,继续匹配。
- 如果
python
def get_next(pattern_str: str) -> list:
m = len(pattern_str)
next_arr = [-1] * m
k = -1 # 最长相等前后缀长度
j = 0
while j < m - 1:
if k == -1 or pattern_str[j] == pattern_str[k]:
j += 1
k += 1
next_arr[j] = k
else:
# 匹配失败,k回退
k = next_arr[k]
return next_arr
# 测试
if __name__ == "__main__":
T = "ababc"
print("模式串ababc的next数组:", get_next(T)) # 输出[-1, 0, 0, 1, 2]
(4)KMP主算法实现
有了next数组,KMP主算法就非常简单了,核心就是:主串指针i永远不回退,只有模式串指针j根据next数组回退。
算法步骤:
- 预处理模式串,生成next数组;
- 初始化主串指针
i=0,模式串指针j=0; - 当
i < n且j < m时:- 如果
j == -1,或者S[i] == T[j]:匹配成功,i++,j++; - 如果
S[i] != T[j]:匹配失败,j = next[j],根据next数组回退j,i不回退;
- 如果
- 循环结束后,如果
j == m,匹配成功,返回i-j;否则返回-1。
python
def kmp_match(main_str: str, pattern_str: str) -> int:
n = len(main_str)
m = len(pattern_str)
if m == 0:
return 0
if m > n:
return -1
# 1. 预处理模式串,生成next数组
next_arr = get_next(pattern_str)
i = j = 0
# 2. 匹配过程
while i < n and j < m:
if j == -1 or main_str[i] == pattern_str[j]:
i += 1
j += 1
else:
# 匹配失败,j回退,i不回退
j = next_arr[j]
# 匹配成功
if j == m:
return i - j
# 匹配失败
return -1
# 测试
if __name__ == "__main__":
S = "ababcabcacbab"
T = "abcac"
print("KMP匹配结果:", kmp_match(S, T)) # 输出5
print("KMP匹配不存在的串:", kmp_match(S, "abcd")) # 输出-1
# 测试最坏情况
S = "aaaaaabaaaaaab"
T = "aaab"
print("KMP最坏情况匹配结果:", kmp_match(S, T)) # 输出4
(5)nextval数组:KMP的进一步优化
我们先看一个问题:模式串T = "aaaaab",它的next数组是[-1, 0, 1, 2, 3, 4]。
当匹配到j=4时,T[4] = 'a'和主串不匹配,根据next数组,j会回退到3,而T[3] = 'a',和主串还是不匹配,还要继续回退到2、1、0,做了大量重复的无效比较。
这就是next数组的缺陷:当回退位置的字符和当前字符相等时,回退之后还是会匹配失败,需要继续回退 。而nextval数组就是为了解决这个问题,对next数组做进一步优化,避免重复比较。
nextval数组的计算规则
- 初始化
nextval[0] = -1; - 对于j>0的位置:
- 如果
T[j] == T[next[j]]:说明回退位置的字符和当前字符一样,nextval[j] = nextval[next[j]],继承回退位置的nextval值; - 如果
T[j] != T[next[j]]:说明回退位置的字符和当前字符不一样,nextval[j] = next[j],保持原来的next值。
- 如果
还是以T = "aaaaab"为例,计算nextval数组:
| j | T[j] | next[j] | T[j] == T[next[j]] | nextval[j] |
|---|---|---|---|---|
| 0 | 'a' | -1 | 无 | -1 |
| 1 | 'a' | 0 | T[1] == T[0] → 是 | nextval[0] = -1 |
| 2 | 'a' | 1 | T[2] == T[1] → 是 | nextval[1] = -1 |
| 3 | 'a' | 2 | T[3] == T[2] → 是 | nextval[2] = -1 |
| 4 | 'a' | 3 | T[4] == T[3] → 是 | nextval[3] = -1 |
| 5 | 'b' | 4 | T[5] == T[4] → 否 | next[5] =4 |
nextval数组的代码实现
python
def get_nextval(pattern_str: str) -> list:
m = len(pattern_str)
nextval = [-1] * m
k = -1
j = 0
while j < m - 1:
if k == -1 or pattern_str[j] == pattern_str[k]:
j += 1
k += 1
# 优化:判断当前字符和回退位置的字符是否相等
if pattern_str[j] == pattern_str[k]:
nextval[j] = nextval[k]
else:
nextval[j] = k
else:
k = nextval[k]
return nextval
# 优化后的KMP算法,使用nextval数组
def kmp_match_optimized(main_str: str, pattern_str: str) -> int:
n = len(main_str)
m = len(pattern_str)
if m == 0:
return 0
if m > n:
return -1
nextval = get_nextval(pattern_str)
i = j = 0
while i < n and j < m:
if j == -1 or main_str[i] == pattern_str[j]:
i += 1
j += 1
else:
j = nextval[j]
return i - j if j == m else -1
# 测试
if __name__ == "__main__":
T = "aaaaab"
print("模式串aaaaab的next数组:", get_next(T))
print("模式串aaaaab的nextval数组:", get_nextval(T))
S = "aaaaaabaaaaaab"
print("优化后的KMP匹配结果:", kmp_match_optimized(S, T)) # 输出4
(6)KMP算法的复杂度分析与适用场景
- 时间复杂度 :预处理next数组的时间O(m),匹配过程的时间O(n),总时间复杂度O(n+m),最坏情况也能保持这个效率,远优于BF算法的O(n*m);
- 空间复杂度:O(m),需要存储next数组;
- 适用场景:长文本匹配、模式串重复度高的场景、需要多次匹配同一个模式串的场景(预处理一次,多次使用)。
4. 拓展:工业界常用的模式匹配算法
KMP算法是面试的重点,但在实际工业开发中,更常用的是BM算法 和Sunday算法,它们的平均效率比KMP更高,实现也更简单。
(1)BM(Boyer-Moore)算法:从后往前匹配的高效算法
BM算法是目前文本编辑器"查找"功能的底层实现,比如Windows的记事本、Linux的grep命令,底层都是BM算法。它的核心特点是从模式串的末尾往前匹配,通过两个规则(坏字符规则、好后缀规则)实现大跨度的跳跃,平均时间复杂度O(n/m),远快于KMP。
核心思想
- 从后往前匹配:主串和模式串的对齐位置不变,从模式串的最后一个字符开始往前比较;
- 坏字符规则:当匹配失败时,把不匹配的字符称为"坏字符",模式串直接向右移动,让模式串中最右边的坏字符和主串的坏字符对齐;如果模式串中没有坏字符,直接跳过整个模式串长度;
- 好后缀规则:当匹配失败时,把已经匹配成功的后缀称为"好后缀",模式串向右移动,让模式串中最右边的和好后缀相同的子串和主串的好后缀对齐。
代码实现(简化版,坏字符规则)
python
def bm_match(main_str: str, pattern_str: str) -> int:
n = len(main_str)
m = len(pattern_str)
if m == 0:
return 0
if m > n:
return -1
# 构建坏字符哈希表:记录每个字符在模式串中最右边的位置
bad_char = {}
for i in range(m):
bad_char[pattern_str[i]] = i
i = 0 # 主串中模式串的起始位置
while i <= n - m:
j = m - 1 # 从模式串末尾开始匹配
# 从后往前匹配
while j >= 0 and main_str[i+j] == pattern_str[j]:
j -= 1
if j == -1:
# 匹配成功
return i
# 坏字符规则:计算移动步数
bad_char_pos = bad_char.get(main_str[i+j], -1)
move = j - bad_char_pos
i += max(move, 1)
# 匹配失败
return -1
# 测试
if __name__ == "__main__":
S = "ababcabcacbab"
T = "abcac"
print("BM匹配结果:", bm_match(S, T)) # 输出5
(2)Sunday算法:BM的简化版,更易实现的高效算法
Sunday算法是BM算法的简化版,由Daniel M.Sunday在1990年提出,逻辑更简单,实现更容易,平均效率和BM相当,甚至在某些场景下更快。
核心思想
- 从前往后匹配,和BF、KMP一致;
- 匹配失败时,关注主串中当前匹配窗口的下一个字符(称为"目标字符");
- 如果模式串中存在目标字符,就把模式串中最右边的目标字符和主串的目标字符对齐;
- 如果模式串中没有目标字符,直接跳过整个模式串长度+1,实现大跨度跳跃。
代码实现
python
def sunday_match(main_str: str, pattern_str: str) -> int:
n = len(main_str)
m = len(pattern_str)
if m == 0:
return 0
if m > n:
return -1
# 构建字符哈希表:记录每个字符在模式串中最右边的位置
char_map = {}
for i in range(m):
char_map[pattern_str[i]] = i
i = 0 # 主串中模式串的起始位置
while i <= n - m:
j = 0
# 从前往后匹配
while j < m and main_str[i+j] == pattern_str[j]:
j += 1
if j == m:
# 匹配成功
return i
# 匹配失败,获取匹配窗口的下一个字符
if i + m >= n:
break
next_char = main_str[i + m]
# 计算移动步数
if next_char in char_map:
# 模式串中有该字符,对齐最右边的位置
i += m - char_map[next_char]
else:
# 模式串中没有该字符,直接跳过整个模式串
i += m + 1
# 匹配失败
return -1
# 测试
if __name__ == "__main__":
S = "ababcabcacbab"
T = "abcac"
print("Sunday匹配结果:", sunday_match(S, T)) # 输出5
S = "hello world hello python"
T = "python"
print("Sunday匹配长文本:", sunday_match(S, T)) # 输出18
五、总结与互动
本篇核心总结
串是特殊的线性表,核心操作围绕子串展开,而模式匹配是串的灵魂,我们从基础到进阶,完整覆盖了所有核心内容:
- 串的定义:由字符组成的有限序列,核心操作对象是子串,而非单个字符;
- 存储结构:定长顺序存储(静态数组)、堆分配存储(动态数组,主流实现)、块链存储(链式结构);
- 基础操作:赋值、拼接、比较、求子串、插入、删除、替换,是所有字符串处理的基础;
- 模式匹配算法 :
- BF暴力算法:逻辑简单,最坏时间复杂度O(n*m),适合短文本;
- KMP算法:面试核心,利用最长相等前后缀构建next数组,主串指针不回退,时间复杂度O(n+m),nextval数组进一步优化;
- BM/Sunday算法:工业界主流,通过大跨度跳跃实现更高的平均效率,适合长文本匹配。
写给读者的话
很多人学KMP算法,总觉得太复杂,背下来就忘,其实核心原因是没有搞懂"最长相等前后缀"的本质。KMP算法的精髓,不是next数组的代码,而是**"利用已有的信息,避免重复劳动"**的优化思想,这种思想在很多算法中都有体现。
下一篇文章,我们会正式进入树与二叉树的学习,这是数据结构的又一核心重难点,也是笔试面试的高频考点,从二叉树的遍历,到二叉搜索树、平衡树,我们会一步步讲透。
🎯 互动环节
- 你有没有搞懂KMP算法的核心?可以试着算一下模式串
"abacaba"的next数组和nextval数组,欢迎在评论区留下你的答案! - 你在面试中遇到过哪些字符串相关的考点?是KMP算法,还是字符串变形、正则匹配相关的题目?欢迎在评论区分享你的经历!
- 下一篇关于树与二叉树的文章,你想重点了解二叉树的遍历,还是二叉搜索树的实现?或者有其他想了解的内容,都可以告诉我!
如果这篇文章对你有帮助,欢迎点赞、收藏、转发给你的朋友,让更多人一起吃透数据结构!有任何问题,都可以在评论区留言,我会一一解答~
参考资料:
- 《数据结构(C语言版)》严蔚敏、吴伟民
- 《算法(第4版)》Robert Sedgewick
- LeetCode 高频面试题:实现strStr()、重复的子字符串
- KMP算法官方论文:《Fast Pattern Matching in Strings》