现代 C++ 实战(23):字符串搜索与动态规划
第 22 篇 的图算法处理「关系」;本篇进入算法面试的两大支柱:字符串搜索(在文本中找模式)和动态规划(把大问题拆成重叠子问题)。KMP 和 Boyer-Moore 解决搜索,背包和 LCS 解决 DP——掌握这些,LeetCode 中等题大半有思路。
demo:ref/cpp_demo/algorithms/string_search/ + ref/cpp_demo/algorithms/dynamic_programming/。
这是「现代 C++ 实战」系列的第 23 篇。建议先读 第 22 篇:图算法。
一、字符串搜索全景
在文本 text(长度 n)中查找模式 pattern(长度 m):
| 算法 | 时间 | 思路 | 适用 |
|---|---|---|---|
| 暴力 | O(nm) | 每个位置逐字符比 | 短模式、教学 |
| KMP | O(n+m) | 前缀函数,失配不回退 text | 通用、面试必考 |
| Boyer-Moore | 最好 O(n/m) | 从右向左,坏字符跳跃 | 长模式、实际文本搜索 |
| Rabin-Karp | 平均 O(n+m) | 滚动哈希,先比哈希再验证 | 多模式匹配 |
1 | text: A B A B D A B A C D A B A B C A B A B |
二、暴力匹配:基线
1 | std::vector<int> bruteForceSearch(const std::string& text, |
最坏情况:text = "AAAA...AA", pattern = "AAA...AB" → 每次比较几乎走满 m,总 O(nm)。
三、KMP:前缀函数,不回退 text
核心思想:模式串失配时,利用已匹配部分的最长相等前后缀(LPS),跳过已知不可能的位置。
1 | pattern: A B A B |
1 | std::vector<int> computeLPS(const std::string& pattern) { |
匹配时 text 的指针 i 只前进不后退:
1 | // 失配时:j = lps[j-1],而非 i 回退 |
| 复杂度 | 值 |
|---|---|
| 预处理 LPS | O(m) |
| 搜索 | O(n) |
| 总计 | O(n+m) |
应用:编译器词法分析、DNA 序列匹配、Git diff 中的子串查找。
四、Boyer-Moore:从右向左跳
从模式串末尾开始比较,失配时根据坏字符规则大幅跳跃:
1 | text: ... H O R S E ... |
1 | // 坏字符表:字符 c 在 pattern 中最后出现位置距末尾的距离 |
实际工程(grep、文本编辑器)常用 BM 或其变种;完整版还有好后缀规则,demo 实现了坏字符启发式。
五、Rabin-Karp:滚动哈希
把长度为 m 的窗口映射为哈希值,先比哈希再逐字符验证:
1 | // 滚动:去掉 text[i],加入 text[i+m] |
| 优点 | 缺点 |
|---|---|
| 多模式并行(同一 text 滚一次) | 哈希冲突需二次验证 |
| 实现简洁 | 需选大质数减少碰撞 |
应用:抄袭检测、多关键词过滤、生物信息学批量匹配。
六、动态规划入门
三要素:
- 状态:
dp[i]或dp[i][j]表示什么 - 转移:当前状态如何从之前状态推导
- 边界:初始值
两种实现:
| 方式 | 方向 | 实现 |
|---|---|---|
| 记忆化(Memoization) | 自顶向下 | 递归 + unordered_map 缓存 |
| 递推(Tabulation) | 自底向上 | 循环填表 |
斐波那契对比(demo 测 n=40):
1 | // 朴素递归 O(2^n) — 重复计算爆炸 |
七、经典 DP 问题
0-1 背包
n 个物品,重量 w[i]、价值 v[i],背包容量 W,每件最多选一次:
1 | dp[i][j] = max(不选第i个, 选第i个) |
一维优化:容量 j 从大到小遍历,避免同一轮重复使用物品:
1 | for (int i = 0; i < n; i++) |
demo 结果:重量 {2,3,4,5}、价值 {3,4,5,6}、容量 8 → 最大价值 10。
最长公共子序列(LCS)
1 | if (s1[i-1] == s2[j-1]) |
"ABCDGH" vs "AEDFHR" → LCS 长度 3,字符串 "ADH"。
最长递增子序列(LIS)
| 方法 | 时间 | 思路 |
|---|---|---|
| DP | O(n²) | dp[i] = 以 nums[i] 结尾的 LIS 长度 |
| 贪心+二分 | O(n log n) | 维护 tail 数组,lower_bound 替换 |
{10,9,2,5,3,7,101,18} → LIS 长度 4(如 2,3,7,101)。
编辑距离
s1 变 s2 的最少操作(插入/删除/替换,各代价 1):
1 | dp[i][j] = s1[i-1]==s2[j-1] ? dp[i-1][j-1] |
"kitten" → "sitting":距离 3(k→s, e→i, 插入 g)。
应用:拼写纠错、DNA 比对、diff 算法基础。
硬币找零
凑金额 amount 的最少硬币数(硬币无限):
1 | dp[0] = 0; |
硬币 {1,3,4}、金额 6 → 2 枚(3+3)。
八、选型速查
| 问题类型 | 首选算法 |
|---|---|
| 单模式精确匹配 | KMP |
| 长文本搜索 | Boyer-Moore |
| 多模式 / 哈希场景 | Rabin-Karp |
| 最优化(选/不选) | 0-1 背包 DP |
| 两串相似度 | LCS / 编辑距离 |
| 子序列最值 | LIS |
| 最少步数凑目标 | 硬币找零 BFS 或 DP |
九、运行 demo
1 | # 字符串搜索 |
| demo | 预期输出要点 |
|---|---|
| 四个搜索 | ABAB 在位置 0 10 15 |
dp_demo |
斐波那契性能对比、背包 10、LCS “ADH”、LIS 4、编辑距离 3、硬币 2 |
十、小结
| 模块 | 要点 |
|---|---|
| 暴力 | O(nm) 基线 |
| KMP | LPS 数组,text 不回退 |
| Boyer-Moore | 右向左 + 坏字符跳跃 |
| Rabin-Karp | 滚动哈希 + 验证 |
| DP | 状态 + 转移 + 边界;记忆化 vs 递推 |
| 经典题 | 背包、LCS、LIS、编辑距离、硬币找零 |
第三季算法篇收官。下一篇进入第四季进阶项目:用 cpp-httplib 搭 REST 服务、nlohmann/json 做序列化——见 第 24 篇:HTTP 服务与 JSON(计划)。










