Skip to content

第 11 章:搜索与字符串匹配


元数据卡

属性
难度(进阶型)
前置第 5 章(二叉树/BST)、递归与分治思想
关键词二分搜索·边界条件·三分搜索·旋转数组·Rabin-Karp·滚动哈希·KMP·Partial Match Table·Trie·前缀树·字符串匹配
预计阅读110 分钟
代码行~240 行(三语言合计)

你在哪

在森林里找一样东西——如果不是直接知道位置(哈希表)、也不是按顺序排好的(BST)——你还能怎么办?二分法、字符串匹配——老陈说的"在干草堆里找针"就是这样。

前两章你成了排序大师。数组中的元素被你整得服服帖帖——想怎么排就怎么排。但排序本身只是手段,你真正的目的是找到东西

想象一个场景:你在驿站找到了一本前人的冒险者笔记,里面按顺序记录了森林里所有石碑上的冒险者排名——总共十亿条。你要找「林间行者」——排名最高的冒险者之一。你不可能一条一条翻,你会对半翻,再对半翻,再对半翻……这就是二分搜索的精髓。

但更棘手的情况来了。笔记里的记录没排序?只有部分排序?甚至你要在一段林间日志全文里找一个名字——比如在厚厚的冒险日志里找「火焰行者」出现了多少次?

这一章解决的就是这个问题:如何在不同的数据形态中高效定位目标。从最经典的二分搜索和它的各种边界变体,到字符串匹配领域的三大神器:Rabin-Karp(哈希)、KMP(自动机)、Trie(前缀树)。


你的任务

本章分层

  • 必读:二分搜索(标准版、左边界、右边界);简单的字符串匹配(暴力或 Rabin-Karp 的直觉)
  • 选读:旋转数组中的二分搜索;Trie 树(前缀树)的插入、搜索、前缀匹配
  • 深水区:KMP 算法的 Partial Match Table 构建与匹配过程;三分搜索(连续函数极值)

本章不会要求你掌握

  • KMP 的完整实现——理解核心思想(text 指针不回退)即可
  • Trie 树的 delete 操作
  • BM 算法、Suffix Array 等高级字符串匹配

读完本章,你应当能:

  1. 不假思索地写出二分搜索——以及它的左边界、右边界版本
  2. 处理旋转数组:在「断开」的有序数组中寻找目标
  3. 了解简单的字符串匹配(暴力法和 Rabin-Karp 的滚动哈希直觉)
  4. (选)实现 Trie 树的插入、搜索、前缀匹配三种操作
  5. (选)理解 KMP 的核心思想:text 指针从不回头

遭遇战→获得技能

① 二分搜索(Binary Search)—— 区间切割的艺术

核心直觉:每次排除一半。 你不是在遍历,你是在收缩区间。

最朴素的二分——在有序数组中找一个值,存在返回索引,不存在返回 -1:

python
def binary_search(arr, target):
    """标准二分搜索——寻找 target 在有序数组中的位置"""
    left, right = 0, len(arr) - 1

    while left <= right:
        mid = left + (right - left) // 2  # 防溢出写法
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1

    return -1

# 试运行
arr = [1, 3, 5, 7, 9, 11, 13, 15]
print(binary_search(arr, 7))   # → 3
print(binary_search(arr, 10))  # → -1

代码只有几行,但 90% 的人第一次会写错。 错在哪里?无外乎这几个地方:

  1. while left <= right 还是 while left < right
  2. right = mid - 1 还是 right = mid
  3. mid = left + (right - left) // 2 在 Python 里无所谓,但在 Java/C++ 里 (left + right) // 2 如果 left+right 超过 int 最大值就会溢出。养成用减法的习惯。

这组问题背后是一个深刻的事实:二分搜索不是一种算法,而是一类算法。不同的边界条件和搜索目标,你会写出不同的二分。

1.1 左边界二分 —— 找到第一个 ≥ target 的位置

python
def binary_search_leftmost(arr, target):
    """寻找第一个 ≥ target 的索引(C++ lower_bound)"""
    left, right = 0, len(arr)  # 注意 right = len(arr),不是 len-1

    while left < right:  # 左闭右开区间
        mid = left + (right - left) // 2
        if arr[mid] < target:
            left = mid + 1
        else:
            right = mid

    return left  # 返回时 left == right

arr = [1, 2, 4, 4, 4, 5, 6]
print(binary_search_leftmost(arr, 4))  # → 2 (第一个 4 的位置)
print(binary_search_leftmost(arr, 7))  # → 7 (超出范围,arr[7] 不存在)
print(binary_search_leftmost(arr, 0))  # → 0

1.2 右边界二分 —— 找到最后一个 ≤ target 的位置

python
def binary_search_rightmost(arr, target):
    """寻找最后一个 ≤ target 的索引"""
    left, right = 0, len(arr)

    while left < right:
        mid = left + (right - left) // 2
        if arr[mid] > target:
            right = mid
        else:
            left = mid + 1

    return left - 1  # left 停在了第一个 > target 的位置

arr = [1, 2, 4, 4, 4, 5, 6]
print(binary_search_rightmost(arr, 4))  # → 4 (最后一个 4 的位置)
print(binary_search_rightmost(arr, 5))  # → 5

为什么 rightmost 比 leftmost 多了个 return left - 1

因为左边界版本的 arr[mid] < target → left = mid + 1 直接定位 target 的第一个出现。而右边界版本我们要找「最后一个 ≤ target」,当 arr[mid] <= target 时我们收缩 left = mid + 1——这一步把 left 推过了正确位置,所以返回前要回退一格。

** Java 差异:** Java 没有 lower_boundupper_bound 这样的标准库。你需要手写:

java
int lowerBound(int[] arr, int target) {
    int left = 0, right = arr.length;
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] < target) left = mid + 1;
        else right = mid;
    }
    return left;
}

** C++ 差异:** C++ 标准库直接提供了 std::lower_boundstd::upper_bound,你的工作不是实现它,是用对它们:

cpp
`vector<int> v = {1, 2, 4, 4, 4, 5, 6};`
auto it = lower_bound(v.begin(), v.end(), 4);
auto it2 = upper_bound(v.begin(), v.end(), 4);
int first4 = it - v.begin();     // 2
int after4 = it2 - v.begin();    // 5

② 搜索旋转数组 —— 有序断开后怎么找?

题目来了:一个有序数组在某个未知点被旋转了,比如 [0,1,2,4,5,6,7] 变成了 [4,5,6,7,0,1,2]。给你这个旋转后的数组和一个 target,找到它的位置。要求 O(log n)。

核心洞察:数组被分成两段,总有一半是严格有序的——要么左半,要么右半。我们就利用这一半来判断 target 在哪。

python
def search_rotated(arr, target):
    """在旋转有序数组中搜索 target(无重复元素)"""
    left, right = 0, len(arr) - 1

    while left <= right:
        mid = left + (right - left) // 2

        if arr[mid] == target:
            return mid

        # 左半部分有序
        if arr[left] <= arr[mid]:
            if arr[left] <= target < arr[mid]:
                right = mid - 1
            else:
                left = mid + 1
        # 右半部分有序
        else:
            if arr[mid] < target <= arr[right]:
                left = mid + 1
            else:
                right = mid - 1

    return -1

print(search_rotated([4, 5, 6, 7, 0, 1, 2], 0))   # → 4
print(search_rotated([4, 5, 6, 7, 0, 1, 2], 3))   # → -1

数组旋转的几种情况

[4, 5, 6, 7, 0, 1, 2]
 ↑        ↑        ↑
左半有序部分  右半有序部分
  [4..7]      [0..2]

情况1: target=0 → 不在左半(mid=7, arr[left]=4 ≤ target=0? 不成立) → 去右半
情况2: target=5 → 左半搜索 (arr[left]=4 ≤ 5 < mid=7) → 收缩 right

延伸:有重复元素怎么办?

arr[left] == arr[mid] == arr[right] 时,你无法判断哪半有序——例如 [1, 1, 1, 1, 1, 1, 0, 1, 1]。退化场景下只能逐个排除,最坏 O(n):

python
def search_rotated_with_duplicates(arr, target):
    """旋转数组搜索(允许重复)"""
    left, right = 0, len(arr) - 1

    while left <= right:
        mid = left + (right - left) // 2

        if arr[mid] == target:
            return True

        # 左右中三值相等 → 退化为线性缩进
        if arr[left] == arr[mid] == arr[right]:
            left += 1
            right -= 1
            continue

        if arr[left] <= arr[mid]:
            if arr[left] <= target < arr[mid]:
                right = mid - 1
            else:
                left = mid + 1
        else:
            if arr[mid] < target <= arr[right]:
                left = mid + 1
            else:
                right = mid - 1

    return False

附录:三分搜索(Ternary Search)—— 适用于连续函数极值

三分搜索在离散数组中用处不大,仅在连续函数极值搜索中有用。这里作为附录,需要时再回来看。

核心直觉:同时取两个中间点,比较后砍掉 1/3。 适用于单峰函数(先升后降或先降后升)找极值。

三分搜索在离散数组中用处不大(二分已经够好),但在连续函数的极值搜索中有一席之地。比如找 f(x) = -x² + 5x + 10 的最大值。

python
def ternary_search(f, left, right, eps=1e-7):
    """三分搜索找单峰函数极值(f 为凸函数时找最大值)"""
    while right - left > eps:
        m1 = left + (right - left) / 3
        m2 = right - (right - left) / 3

        if f(m1) < f(m2):
            left = m1      # f(m2) 更大 → 极值在右半
        else:
            right = m2     # f(m1) 更大 → 极值在左半

    return (left + right) / 2  # 返回极值点

def f(x):
    return -(x ** 2) + 5 * x + 10

max_x = ternary_search(f, -10, 10)
print(f"最大值在 x ≈ {max_x:.4f}, f(x) = {f(max_x):.4f}")
# → 最大值在 x ≈ 2.5000, f(x) = 16.2500

为什么不用三分的「数组版」找有序数组中的目标?

因为数组的搜索中,一次三分做两次比较但只排除 1/3 元素,而二分做一次比较排除一半。数学上:

  • 二分:log₂n 次比较
  • 三分:2 × log_(3/2)n ≈ 2 × 1.7 × log₂n = 3.4 × log₂n 次比较

三分要多做 3.4 倍的工作。所以离散搜索用二分,连续极值才用三分。


④ Rabin-Karp —— 滚动哈希让字符串匹配跑起来

问题:给定一段文本 text(长度 n)和一个模式 pattern(长度 m),找出 pattern 在 text 中的所有出现位置。

暴力法是每个位置逐字符比较——O(n×m)。Rabin-Karp 的思路是:别比字符了,比哈希值。

python
def rabin_karp(text, pattern):
    """Rabin-Karp 字符串匹配——滚动哈希"""
    n, m = len(text), len(pattern)
    if m > n or m == 0:
        return []

    base = 256      # 字符集大小(ASCII 够了)
    mod = 10**9 + 7  # 大质数,减少哈希冲突
    result = []

    # 计算 pattern 的哈希值和 base^(m-1) 的余数
    # 这两个值在整个过程中只算一次
    pat_hash = 0
    top_val = 1

    for i in range(m):
        pat_hash = (pat_hash * base + ord(pattern[i])) % mod
    for i in range(m - 1):
        top_val = (top_val * base) % mod

    # 计算 text 第一个窗口的哈希
    txt_hash = 0
    for i in range(m):
        txt_hash = (txt_hash * base + ord(text[i])) % mod

    # 滚动比较
    for i in range(n - m + 1):
        if pat_hash == txt_hash:
            # 哈希匹配但可能有冲突,二次校验
            if text[i:i + m] == pattern:
                result.append(i)

        # 滚动:移除前一个字符,加入后一个字符
        if i < n - m:
            txt_hash = (txt_hash - ord(text[i]) * top_val) % mod
            txt_hash = (txt_hash * base + ord(text[i + m])) % mod
            txt_hash %= mod

    return result

text = "AABAACAADAABAAABAA"
pattern = "AABA"
print(rabin_karp(text, pattern))  # → [0, 9, 13]

核心逻辑——滚动哈希是怎么「滚」的?

假设 text = "AAB",它的哈希值是:ord('A')×base² + ord('A')×base + ord('B')。

现在要向右移一位,变成 text[1:4] = "ABA"

  1. 移除 ord('A') × base² 的贡献
  2. 整体左移一位(乘以 base)
  3. 加入 ord('A')(新窗口的最后一个字符)

这整个过程是 O(1) 的——不重新扫描 m 个字符。这就是「滚动」的含义。

哈希冲突怎么办? 上述代码已经处理了:哈希相等时不立即返回,而是做一次 text[i:i+m] == pattern 的字符级校验。如果模数选得好,冲突极少,整体复杂度接近 O(n+m)。

什么时候 Rabin-Karp 会退化? 文本和 pattern 高度相似时——比如全部是同一个字符——每次哈希都相等,等于退化回逐个比较,O(n×m)。但概率极低。


KMP(Knuth-Morris-Pratt)—— 不让指针往回走(进阶选读)

KMP 是字符串匹配的经典算法,但面试和工程中直接手写的机会不多。 主线先掌握二分搜索和 Rabin-Karp 的直觉即可。理解 KMP 的核心思想(text 指针不回退)比记忆代码更重要。

暴力匹配最让人心疼的地方:当匹配到 text = "AAAB...", pattern = "AAAA" 时,你在第四个字符碰壁了,然后暴力法乖乖把 pattern 往后挪一位,重新开始。但你已经知道 text 的前三个字符是 'A' 了,为什么不利用这个信息?

KMP 的核心思想:模式串自己知道该怎么「退」——不需要 text 指针回溯。

5.1 Partial Match Table(也叫 next 数组 / 失配函数)

先看一个表。

对于 pattern = "ABABAC",PMT 长这样:

字符:  A  B  A  B  A  C
PMT:  0  0  1  2  3  0

解释:PMT[i] = 「pattern[0:i] 这个前缀中,最长的相等前缀-后缀的长度」。

  • i=0, "A":没有真前后缀 → 0
  • i=1, "AB":前缀 {A},后缀 {B} → 无匹配 → 0
  • i=2, "ABA":前缀 {A, AB},后缀 {BA, A} → A 匹配 → 1
  • i=3, "ABAB":前缀 {A, AB, ABA},后缀 {BAB, AB, B} → AB 匹配 → 2
  • i=4, "ABABA":前缀 {A, AB, ABA, ABAB},后缀 {BABA, ABA, BA, A} → ABA 匹配 → 3
  • i=5, "ABABAC":前缀 {A, AB, ABA, ABAB, ABABA},后缀 {BABAC, ABAC, BAC, AC, C} → 无匹配 → 0

手工推 PMT 的技巧:算出来 PMT[i] 后,算 PMT[i+1] 时先看 pattern[i+1] 能否和已有的最长前缀的下一个字符匹配。能就直接 +1,不能就回退到前一个更短的前缀——有点像 KMP 自己对自己做 KMP

python
def build_pmt(pattern):
    """构建 Partial Match Table(next 数组)"""
    m = len(pattern)
    pmt = [0] * m
    j = 0  # 当前最长匹配前缀的长度

    for i in range(1, m):
        # 当不匹配时,j 回退到前一个 PMT 位置
        while j > 0 and pattern[i] != pattern[j]:
            j = pmt[j - 1]

        if pattern[i] == pattern[j]:
            j += 1

        pmt[i] = j

    return pmt

print(build_pmt("ABABAC"))  # → [0, 0, 1, 2, 3, 0]

5.2 KMP 主流程 —— 拿着 PMT 匹配文本

python
def kmp_search(text, pattern):
    """KMP 字符串匹配——text 指针从不回头"""
    n, m = len(text), len(pattern)
    if m == 0:
        return []
    if m > n:
        return []

    pmt = build_pmt(pattern)
    result = []
    j = 0  # pattern 的当前匹配位置

    for i in range(n):
        # 不匹配 → j 通过 PMT 回退
        while j > 0 and text[i] != pattern[j]:
            j = pmt[j - 1]

        if text[i] == pattern[j]:
            j += 1

        if j == m:
            result.append(i - m + 1)
            # 找到后继续找下一个(重叠匹配也允许)
            j = pmt[j - 1]

    return result

text = "ABABABACABA"
pattern = "ABABAC"
print(kmp_search(text, pattern))  # → [2]
print(kmp_search("AAAAA", "AA"))  # → [0, 1, 2, 3]

KMP 的可视化运行

text:    A B A B A B A C A B A
pattern: A B A B A C
         0 1 2 3 4 5
         ✓ ✓ ✓ ✓ ✓ ✗  ← i=5 处 text[5]=B ≠ pattern[5]=C

暴力法:text 指针回退到 1,pattern 从头开始
KMP:  text 指针停在 5,利用 PMT[4]=3 将 j 跳到 3
       重新比较 text[5]=B 和 pattern[3]=B → ✓
                                j=3

这就是 KMP 的浪漫之处:text 指针永远向前,犯过的错只靠 pattern 自己消化。

** Java 差异:** Java 的 String.indexOf() 用的是暴力法——对短字符串来说,KMP 的 PMT 构建开销反而更贵。JDK 的开发者做了权衡:

  • 短串(最常见场景):暴力法 O(n×m) 但常数极小
  • 长串、重复模式:才是 KMP 发挥了优势

Trie(前缀树 / 字典树)—— 共享前缀的自动补全引擎

Trie 是字符串查找的实用数据结构。 建议先掌握主线再回来看。

问题:给你 10⁵ 个单词,要求实现一个 API:输入前缀,返回所有匹配单词。或者——判断某个单词是否存在于字典中。

问题:给你 10⁵ 个单词,要求实现一个 API:输入前缀,返回所有匹配单词。或者——判断某个单词是否存在于字典中。

HashSet 可以回答「单词是否存在」,但回答不了「以 abc 开头」的问题。Trie 就是为此而生的一棵树。

python
class TrieNode:
    def __init__(self):
        self.children = {}     # 字符 → TrieNode
        self.is_end = False    # 是否是某个单词的结尾


class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word: str) -> None:
        """插入一个单词"""
        node = self.root
        for ch in word:
            if ch not in node.children:
                node.children[ch] = TrieNode()
            node = node.children[ch]
        node.is_end = True

    def search(self, word: str) -> bool:
        """精确搜索:单词是否完整存在"""
        node = self._find_node(word)
        return node is not None and node.is_end

    def starts_with(self, prefix: str) -> bool:
        """前缀搜索:是否存在以 prefix 开头的单词"""
        return self._find_node(prefix) is not None

    def _find_node(self, prefix: str) -> TrieNode | None:
        """辅助:沿前缀行走,返回最后一个节点"""
        node = self.root
        for ch in prefix:
            if ch not in node.children:
                return None
            node = node.children[ch]
        return node

    def autocomplete(self, prefix: str) -> list[str]:
        """自动补全:返回所有以 prefix 开头的单词"""
        node = self._find_node(prefix)
        if node is None:
            return []

        results = []
        self._dfs(node, prefix, results)
        return results

    def _dfs(self, node: TrieNode, path: str, results: list[str]):
        """深度优先收集所有单词"""
        if node.is_end:
            results.append(path)

        for ch, child in node.children.items():
            self._dfs(child, path + ch, results)


# 试运行
trie = Trie()
words = ["apple", "app", "application", "appetite", "banana"]
for w in words:
    trie.insert(w)

print(trie.search("app"))       # → True
print(trie.search("appl"))      # → False(不是完整单词)
print(trie.starts_with("app"))  # → True
print(trie.autocomplete("app"))
# → ['app', 'apple', 'appetite', 'application']

Trie 的结构图("app", "apple", "banana")

           root
         /      \
        a        b
       /          \
      p            a
     /              \
    p                n
   /  \               \
  $    l               a
       |                \
       e                 n
       |                  \
       $                   a
                            \
                             $
($ 标记 is_end = True)

Trie 的时间复杂度

操作时间空间
插入O(m)O(总字符数 × 字符集大小)
搜索O(m)
前缀匹配O(m)
自动补全O(m + 结果数)

m = 单词长度。所有操作都和字典大小无关——这就是 Trie 性感的地方。

空间问题:每个节点存一个 children 字典(或数组),在字符集大时(比如 Unicode)内存爆炸。工程上的优化方案:

  • Radix Tree / Patricia Trie:把只有一个孩子的链压缩成一条边
  • Double-array Trie:用两个数组表示,内存紧凑(AC 自动机的常用底层)

** C++ 差异:** 用数组实现 children 更高效(假设仅小写字母):

cpp
struct TrieNode {
    TrieNode* children[26];
    bool isEnd;
    TrieNode() : isEnd(false) {
        fill(begin(children), end(children), nullptr);
    }
};

优点:O(1) 的子节点查找,无哈希开销。缺点:每个节点固定占 26 个指针空间(208 字节)。


常见陷阱

二分搜索为什么「只有 10% 的人能一次性写对」?

写二分,本质上是在回答三个问题:

  1. 搜索空间是什么? 左闭右闭 [left, right] 还是左闭右开 [left, right)
  2. 收缩规则是什么? 找到 target 时返回;没找到时排除 mid 还是保留 mid?
  3. 终止状态是什么? left > right 还是 left == right?

如果你用左闭右闭,就用 <=right = mid - 1。如果用左闭右开,就用 <right = mid混用一定会错。

Rabin-Karp vs KMP vs Trie:什么时候用哪个?

适用场景复杂度特点
Rabin-Karp单模式匹配,长文本O(n+m) 平均简单,哈希冲突退化
KMP单模式匹配,文本流式处理O(n+m) 确定text 指针永不回溯,在线
Trie多模式匹配,前缀查询O(m) 每次空间大,自动补全绝配

KMP 用来自动机思路解决问题,比 Rabin-Karp 更可靠(无冲突);Rabin-Karp 用来解决「多模式」时可以通过哈希预处理更优雅(比 KMP 逐个跑一遍快);Trie 是另一条路——用空间换时间,适合前缀密集的场景。


通关挑战

  1. 搜索峰值元素:一个数组是单峰的(先升后降),在 O(log n) 时间内找到峰值索引(二分法的变体)
  2. KMP + Trie 混合:实现 AC 自动机(Aho-Corasick)——给一份文本和一组模式串,一次遍历找出所有模式串的所有出现位置(本质是 Trie + KMP 的失配指针)
  3. 最短回文串:给定一个字符串 s,通过在前面添加字符将其变成回文串,输出最短的结果。提示:KMP
  4. 二次哈希 Rabin-Karp:用两个不同模数同时计算哈希,把冲突概率降到几乎为零
  5. Trie 的 delete 操作:实现 trie.delete(word),注意删除后清理冗余节点

验收标准

  • [ ] 能一口气写对二分搜索的三种变体(存在、左边界、右边界)
  • [ ] 能解释为什么旋转数组的二分要判断 arr[left] <= arr[mid]
  • [ ] 能手工推导任意模式串(如 "AAABAAA")的 PMT 表
  • [ ] 能口述 Rabin-Karp 的滚动哈希公式
  • [ ] 能实现 Trie 树并分析空间开销

常见卡点

卡点破解
二分的 mid 计算忘记防溢出养成习惯:mid = left + (right - left) // 2
左边界右边界分不清记住公式:找左边界(第一个≥target)用 right = len(arr) + while left < right;找右边界(最后一个≤target)也是这套模板,返回 left - 1
旋转数组的 arr[left] == arr[mid] 场景有重复元素就要做线性缩进,最坏 O(n) 无法避免
KMP 的 PMT 构建 while j > 0 循环这是在回退 j:当前字符和 pattern[j] 不匹配时,尝试利用已匹配部分的最长前缀-后缀
Trie 的自动补全递归太深1000 层递归 Python 会爆栈。可以改用栈做迭代 DFS
Rabin-Karp 的模运算负数Python 的 % 对负数结果为正,Java/C++ 要手动 + mod 调整

现在不需要理解

  • BM 算法(Boyer-Moore)—— 比 KMP 更快(在某些场景是 KMP 的 3-5 倍),但构建复杂。它用「坏字符规则」+「好后缀规则」让模式串大步跳跃。C 标准库 memmem() 和 GNU grep 用的都是 BM 的变体。
  • Suffix Array / Suffix Tree—— 文本索引的利器,O(n) 构建后能在 O(m) 时间内回答任意子串是否出现。但构建算法(DC3 / SA-IS)极其复杂。
  • Z Algorithm—— 另一种线性时间字符串匹配,比 KMP 更直观,但知名度低。

旅人笔记

  • 二分搜索是「三分钟学会,一辈子写不完美」的算法。80% 的边界 bug 出在区间开闭不一致。
  • 旋转数组问题是二分搜索最漂亮的变体——它让你认识到「有序」是一个相对概念。
  • KMP 是算法美学的一个巅峰:用一个只有 m 长度的 PMT 表,省掉了 text 指针所有不必要的回溯。看懂 KMP,你就懂了「预计算+利用历史信息」这个极其重要的思维范式。
  • Trie 是「空间换时间」最彻底的执行者。不要因为它吃内存就轻视它——在实际系统中(输入法、搜索引擎、拼写检查),Trie 的变体无处不在。
  • 字符串匹配的世界里没有银弹。暴力的 O(n×m) 在短串场景反而是最优的(Java 的选择),工程选型永远要考虑数据分布。

一句话记住本章:「二分划区间,KMP建表不回头,Trie 通用前缀秀。」


→ 下一站预告

第 12 章我们将跳出「搜索」这个具体的意图,进入更高层次的算法设计范式:分治与回溯

  • 分治:三步走——分(Divide)、治(Conquer)、合(Combine)。最大子数组、逆序对。
  • 回溯:做选择 → 递归 → 撤销。N 皇后、全排列、子集、子集和。
  • 这两个范式是你进入动态规划之前的最后两把钥匙。

准备好了吗?思维的疆域要再次扩张了。

← 回到第 10 章:排序算法(下) | → 进入第 12 章:分治与回溯

Built with VitePress | Software Systems Atlas