文本里的侦探 —— 字符串匹配算法
你每天按Ctrl+F搜索文本、搜索引擎检索网页、杀毒软件扫描病毒特征码——这些背后都是字符串匹配算法在工作。从最朴素的逐字比对到巧妙的模式跳跃,字符串匹配是一门充满智慧的技术。
📋 开篇自测:你已经知道多少?
- 在一本10万字的小说中查找一个10个字的句子,最笨的方法要比较多少次?有没有更快的方法?
- KMP算法的核心思想是什么?它为什么比暴力匹配快?
- 你知道Ctrl+F搜索文本时,编辑器用的是什么算法吗?
一、问题的定义——我们到底在做什么
字符串匹配问题可以这样描述:
- 文本串(Text): 一段很长的文本,比如一篇文章
- 模式串(Pattern): 你要查找的内容,比如一个关键词
- 目标: 在文本串中找到模式串出现的位置
text = "大家好我是数据结构与算法的学习者大家好"
pattern = "数据结构"
# 期望输出:模式串在位置4出现
这看起来很简单——Python一行就能搞定:
print(text.find(pattern)) # 4
但你有没有想过,find()背后是怎么工作的?如果文本有10GB,模式串有1000个字符呢?不同的算法,性能差距可能是几十倍甚至上百倍。
二、暴力匹配——最直接的方法
思路
把模式串放在文本串的每一个位置上试一试,逐字符比较。匹配不上就往后挪一位,从头再来。
就像你拿着一把尺子在一根长绳上滑动,每个位置都量一下尺寸是否吻合。
def brute_force_match(text, pattern):
"""暴力匹配"""
n, m = len(text), len(pattern)
positions = []
for i in range(n - m + 1):
match = True
for j in range(m):
if text[i + j] != pattern[j]:
match = False
break
if match:
positions.append(i)
return positions
text = "ABABABABCABABD"
pattern = "ABABC"
print(brute_force_match(text, pattern)) # [4]
时间复杂度: 最坏 O(n * m),其中n是文本长度,m是模式长度 空间复杂度: O(1)
当模式串是”AAAAB”而文本串是”AAAAAAAAAA…AB”这种情况时,暴力匹配会非常慢——每次都匹配到最后一个字符才发现不对,然后只后移一位重来。
🤔 想一想 在暴力匹配中,当匹配失败时,我们把模式串往后移了一位,之前匹配的信息全部丢弃了。有没有办法利用已经匹配的部分来跳过一些不必要的比较?
三、KMP算法——永不回头的智慧
核心思想
KMP算法(Knuth-Morris-Pratt)的关键洞察是:当匹配失败时,我们已经知道了文本中已经匹配过的部分是什么。利用这个信息,可以跳过一些不可能匹配的位置。
打个比方:你在一篇文章中找”ABCABD”这个词。你已经匹配了”ABCAB”,在第6个字符发现不匹配。暴力方法会退回到起点的下一位重来。但你注意到——“ABCAB”的前缀”AB”和后缀”AB”是一样的。这意味着你不需要完全从头开始,可以利用已经匹配的”AB”继续往前。
next数组(部分匹配表)
KMP的精髓在于预处理模式串,计算出一个next数组(也叫部分匹配表/前缀函数)。它记录的是:模式串的每个前缀中,最长相等前后缀的长度。
def build_next(pattern):
"""构建KMP的next数组"""
m = len(pattern)
next_arr = [0] * m
j = 0 # 前缀末尾位置
for i in range(1, m):
while j > 0 and pattern[i] != pattern[j]:
j = next_arr[j - 1]
if pattern[i] == pattern[j]:
j += 1
next_arr[i] = j
return next_arr
# 例子
pattern = "ABCABD"
print(build_next(pattern))
# [0, 0, 0, 1, 2, 0]
# 解读:
# A -> 0(没有真前缀等于真后缀)
# AB -> 0
# ABC -> 0
# ABCA -> 1(前缀A = 后缀A)
# ABCAB -> 2(前缀AB = 后缀AB)
# ABCABD -> 0
KMP匹配过程
def kmp_search(text, pattern):
"""KMP字符串匹配"""
if not pattern:
return [0]
next_arr = build_next(pattern)
n, m = len(text), len(pattern)
positions = []
j = 0 # 模式串的匹配位置
for i in range(n):
while j > 0 and text[i] != pattern[j]:
j = next_arr[j - 1] # 利用next数组跳过不必要的比较
if text[i] == pattern[j]:
j += 1
if j == m:
positions.append(i - m + 1)
j = next_arr[j - 1] # 继续查找下一个匹配
return positions
text = "ABABABABCABABD"
pattern = "ABABC"
print(kmp_search(text, pattern)) # [4]
# 测试多次匹配
text2 = "AABAABAABAAB"
pattern2 = "AABAA"
print(kmp_search(text2, pattern2)) # [0, 3, 6]
时间复杂度: O(n + m)——预处理O(m),匹配O(n) 空间复杂度: O(m)——存储next数组
KMP最大的优点是:文本串的指针永远不会回退。 每个文本字符最多被比较两次,所以时间复杂度是严格的O(n + m)。
用一个直观的例子理解KMP
文本串: A B A B A B A B C
模式串: A B A B C
↑ 不匹配!
暴力法:模式串后移1位
文本串: A B A B A B A B C
模式串: A B A B C
↑ 从头开始比较
KMP法:利用已匹配的"ABAB"中AB=AB的信息,直接跳过
文本串: A B A B A B A B C
模式串: A B A B C
↑↑ 已知匹配,从这里继续
KMP跳过了暴力法中肯定不会成功的那些位置,省去了大量无用功。
四、BM算法——从后往前的逆向思维
核心思想
Boyer-Moore(BM)算法从模式串的末尾开始比较(从右往左),利用两个启发式规则来决定跳过多少位。
为什么从后往前?因为如果末尾就不匹配,我们可以一次跳过很多字符。
BM有两条规则:
坏字符规则: 当某个字符不匹配时,看这个”坏字符”在模式串中最后出现的位置,据此计算跳跃距离。
好后缀规则: 如果模式串的某个后缀已经匹配了,看这个后缀在模式串的其他地方有没有出现过。
def bm_bad_char_only(text, pattern):
"""BM算法简化版:仅使用坏字符规则,未实现好后缀规则。
匹配成功时只移动1位(完整BM会利用好后缀规则跳更远)。"""
n, m = len(text), len(pattern)
if m == 0:
return [0]
# 构建坏字符表:每个字符在模式串中最后出现的位置
bad_char = {}
for i in range(m):
bad_char[pattern[i]] = i
positions = []
i = 0 # 文本串中的对齐位置
while i <= n - m:
j = m - 1 # 从模式串末尾开始比较
while j >= 0 and pattern[j] == text[i + j]:
j -= 1
if j < 0:
positions.append(i)
# 注意:这里用了简化策略,找到匹配后只移动1位。
# 完整的BM算法会利用"好后缀规则"计算更大的跳跃距离,
# 即跳到模式串中下一个能与已匹配后缀对齐的位置,
# 从而避免逐位移动。实际工程实现(如grep)会同时
# 使用坏字符规则和好后缀规则,取两者中更大的跳跃值。
i += 1
else:
# 坏字符:text[i+j]
bad_char_pos = bad_char.get(text[i + j], -1)
shift = j - bad_char_pos
i += max(1, shift)
return positions
text = "HERE IS A SIMPLE EXAMPLE"
pattern = "EXAMPLE"
print(bm_bad_char_only(text, pattern)) # [17]
时间复杂度: 平均O(n/m)——在实际文本中通常比KMP更快 最坏情况: O(n * m)
BM算法在实际中往往是最快的字符串匹配算法。许多文本编辑器的”查找”功能(包括grep命令)都是基于BM或其变体实现的。
为什么BM实际更快?因为在普通文本中,不匹配的情况远多于匹配。当末尾字符不匹配时,BM可以一次跳过多个字符,跳跃距离最多可达模式串的长度m。
🤔 想一想 如果模式串很短(比如只有2-3个字符),BM算法的优势还明显吗?为什么?
五、Rabin-Karp算法——用哈希加速比较
核心思想
Rabin-Karp算法用一个巧妙的方式避免了逐字符比较:先计算模式串的哈希值,然后滑动窗口计算文本子串的哈希值,只有当哈希值相同时才逐字符验证。
关键技巧是滚动哈希——当窗口右移一位时,不需要重新计算整个子串的哈希值,只需要减去最左边字符的贡献、加上最右边字符的贡献。
def rabin_karp(text, pattern):
"""Rabin-Karp字符串匹配"""
n, m = len(text), len(pattern)
if m > n:
return []
base = 256 # 字符集大小
mod = 101 # 取模用的质数
positions = []
# 计算模式串和第一个窗口的哈希值
pattern_hash = 0
window_hash = 0
h = 1 # base^(m-1) % mod
for i in range(m - 1):
h = (h * base) % mod
for i in range(m):
pattern_hash = (pattern_hash * base + ord(pattern[i])) % mod
window_hash = (window_hash * base + ord(text[i])) % mod
# 滑动窗口
for i in range(n - m + 1):
if pattern_hash == window_hash:
# 哈希匹配,逐字符验证(防止哈希冲突导致的误判)
if text[i:i + m] == pattern:
positions.append(i)
if i < n - m:
# 滚动哈希:去掉最左字符,加上最右字符
window_hash = ((window_hash - ord(text[i]) * h) * base
+ ord(text[i + m])) % mod
if window_hash < 0:
window_hash += mod
return positions
text = "ABABABABCABABD"
pattern = "ABABC"
print(rabin_karp(text, pattern)) # [4]
时间复杂度: 平均O(n + m),最坏O(n * m)——取决于哈希冲突的频率
Rabin-Karp的一个独特优势是可以同时搜索多个模式串——只需要把所有模式串的哈希值存在一个集合中,每个窗口位置查一次集合就行了。
六、算法对比与实际选择
| 算法 | 平均时间 | 最坏时间 | 预处理 | 适用场景 |
|---|---|---|---|---|
| 暴力匹配 | O(n*m) | O(n*m) | 无 | 短模式串、小数据 |
| KMP | O(n+m) | O(n+m) | O(m) | 需要严格线性时间保证 |
| BM | O(n/m) | O(n*m) | O(m+字符集) | 普通文本搜索,实际最快 |
| Rabin-Karp | O(n+m) | O(n*m) | O(m) | 多模式串匹配 |
实际工程中的选择
- grep命令: 使用BM算法的变体(GNU grep使用了混合策略)
- Python的
str.find()和in: 使用自适应策略——默认采用Boyer-Moore-Horspool变体,当检测到性能不佳时自动切换到Two-Way算法以保证O(n)最坏时间 - 搜索引擎: 使用倒排索引+高效匹配,不是简单的字符串匹配
- 杀毒软件: 需要同时匹配成千上万个病毒特征码,使用Aho-Corasick多模式匹配算法
# Python中的字符串查找
text = "Hello World, Hello Python"
# find():返回第一个匹配位置
print(text.find("Hello")) # 0
# index():和find类似,但找不到会抛异常
print(text.index("Hello")) # 0
# count():统计出现次数
print(text.count("Hello")) # 2
# replace():替换所有匹配
print(text.replace("Hello", "Hi")) # "Hi World, Hi Python"
# 使用正则表达式进行复杂匹配
import re
results = re.findall(r"Hello \w+", text)
print(results) # ['Hello World', 'Hello Python']
⚠️ 常见误区
- 误区1:“KMP是最好的字符串匹配算法” —— KMP在理论上很优美(最坏O(n+m)),但在实际文本处理中,BM通常更快,因为它能跳过更多字符。
- 误区2:“暴力匹配没有存在的价值” —— 当模式串很短(1-3个字符)时,暴力匹配简单高效,不需要预处理时间。很多编程语言的内置实现在短模式串时就是暴力匹配。
- 误区3:“学KMP就是为了面试” —— KMP的核心思想——利用已知信息避免重复计算——是一种通用的算法思想,在很多其他问题中都有应用。
七、动手实战——构建一个简易全文搜索引擎
class SimpleSearchEngine:
"""简易全文搜索引擎"""
def __init__(self):
self.documents = {} # id -> 文本
self.index = {} # 关键词 -> {文档id: [出现位置列表]}
def add_document(self, doc_id, text):
"""添加文档并建立索引"""
self.documents[doc_id] = text
words = text.lower().split()
for pos, word in enumerate(words):
word = word.strip(".,!?;:")
if word not in self.index:
self.index[word] = {}
if doc_id not in self.index[word]:
self.index[word][doc_id] = []
self.index[word][doc_id].append(pos)
def search(self, query):
"""搜索包含关键词的文档"""
query = query.lower().strip()
if query not in self.index:
return []
results = []
for doc_id, positions in self.index[query].items():
results.append({
'doc_id': doc_id,
'occurrences': len(positions),
'preview': self.documents[doc_id][:100]
})
# 按出现次数排序
results.sort(key=lambda x: -x['occurrences'])
return results
# 使用
engine = SimpleSearchEngine()
engine.add_document(1, "Python is a great programming language for data science")
engine.add_document(2, "Data structures and algorithms are fundamental in programming")
engine.add_document(3, "Python data analysis with pandas and numpy")
results = engine.search("data")
for r in results:
print(f"文档{r['doc_id']}(出现{r['occurrences']}次): {r['preview']}")
📝 掌握度自测
- 概念题: 用自己的话解释KMP算法的next数组是什么意思。为什么它能帮助跳过不必要的比较?
- 手算题: 计算模式串 “ABABACA” 的next数组。
- 分析题: BM算法为什么在实际中通常比KMP更快?在什么情况下BM会比KMP慢?
- 编码题: 用任意一种匹配算法,实现一个函数,统计模式串在文本中出现的总次数(允许重叠)。比如 “ABA” 在 “ABABABA” 中出现3次。
- 设计题: 如果你需要在一个大型代码仓库中搜索某个函数名,你会怎么设计搜索系统?需要考虑哪些因素?
💡 自我评估
- 全部答对:字符串匹配算法已经是你的利器了。
- 答对3-4题:理解透彻,KMP的next数组构建过程可以再多模拟几遍。
- 答对1-2题:字符串匹配算法确实是一个难点。建议先彻底理解暴力匹配的过程,再对比理解KMP的改进之处。
- 全部答错:这一章内容确实比较抽象。建议用具体的短字符串手动模拟每一步匹配过程。
到目前为止,我们已经学习了各种数据结构和具体的算法。但解决问题光有工具还不够,还需要”解题思路”——面对一个全新的问题,应该用什么策略去拆解它?下一章,我们将学习算法设计的三大核心思想:动态规划、贪心与回溯。它们不是具体的数据结构,而是解决问题的方法论。
购买课程解锁全部内容
刷题到通关:数据结构与算法面试实战
¥29.90