Skip to content

第15章:高级数据结构


yaml
难度: ⚔️⚔️⚔️⚔️ (四星)
前置: 链表(→第8章)、二叉搜索树(→第12章)、散列表(→第14章)
场景: 森林驿站消息检索、林间营地关系查询、物资搜索、路线优化

你在哪

你站在算法森林的尽头。面前是一道通往地下的入口——那里是计算机的地心。老陈给你的地图已经用完了。接下来的路,你要靠自己了。

前三章我们把基础数据结构一个个磨完了:数组、链表、树、图、散列表。够打小怪兽了,但遇到大魔王——广阔森林+千万物资+实时响应——你会发现:

  • 散列表的 O(1) 查找看起来很香,但范围查询是它永远的痛;
  • 二叉搜索树最坏情况退化成链表,AVL 和红黑树平衡策略又让并发写变得沉重;
  • 动态等价关系(比如森林营地里"同伴的同伴")靠图上的 DFS/BFS 每次 O(n),几百个营地可以忍,几万个呢?

这一章给你四把钥匙:跳过无聊的中间节点把一堆集合合并到飞起把堆做到理论极限把字符串前缀组织成一副检索地图

每把钥匙背后都是一个真实世界的石锤案例。


你的任务

本章分层

  • 必读(独立专题):并查集(Union-Find)——路径压缩+按秩合并的核心原理;Trie 树(前缀树)——插入、搜索、前缀匹配
  • 选读:跳表(Skip List)——多层快车道原理;AC 自动机——多模式串同时匹配的概念
  • 深水区:斐波那契堆——了解概念即可("理论上美、工程上慎用")

本章不会要求你掌握

  • 斐波那契堆的完整实现(保留概念)
  • AC 自动机的失配指针构建细节
  • 跳表的随机层级概率分析

📌 每节都是独立专题。 以下结构彼此不依赖,可按需跳跃学习。

  • 并查集:在**图算法章节(Kruskal 算法)**前会独立出现,这里首次了解即可
  • Trie:与**字符串章节(第11章)**更相关,可在那里深入
  • AC 自动机:字符串匹配的进阶
  • 斐波那契堆:只保留概念性理解,不看也不影响后续

读完这章,你应当能:

  1. 理解并查集的"路径压缩+按秩合并"双优化为什么能把复杂度降到几乎常数
  2. 用 Trie 树实现基本的自动补全
  3. 了解跳表为什么比平衡树更适合并发
  4. 理解斐波那契堆为什么"理论上很美,实际上很少人写"
  5. 了解 AC 自动机如何"同时匹配所有模式串"

不要求一次记住所有实现细节。重点是理解每个数据结构解决的是什么痛点


遭遇战 1:跳表(Skip List)—— 链表也能二分查找?

问题

你在驿站发现一张需要实现的 OrderedSet(有序集合)清单,支持 insertdeletecontains,全部 O(log n)。

你脑中蹦出三个候选:

  • AVL / 红黑树 —— 代码量爆炸,一个旋转写错全崩
  • 散列表 —— O(1) 但无序,范围查询死给你看
  • 二分查找数组 —— 插入 O(n),告辞

有没有一种结构,既简单又好写,还能有 O(log n) 查找?

答案:空间换时间,多建几层"快车道"

跳表的核心直觉非常简单:

让链表拥有"高速层"和"普通层",高层跳过大量不必要节点。

想象森林里的主干兽道。如果你要翻山去另一个营地,你不会从山脚每个岔路口都找路——你走大路直达山口,再换小路走最后一程。跳表就是这个逻辑。

具体来说:

  • 底层(level 0):完整的排序链表
  • 上层(level 1, 2, ...):每层是下层的"快车层",跳过部分节点
  • 每个节点被随机"晋升"到更高层的概率为 p(通常 p=1/2)

查找的时候:从最高层开始,向右走;如果下一个节点已经超过了目标值,就降一层。

python
# 上下文:跳表节点定义 + 查找核心逻辑
# Python — 构建一个完全可运行的跳跃列表

import random
import math

class SkipNode:
    """跳表节点,每个节点有多个前向指针"""
    def __init__(self, val: int, level: int):
        self.val = val
        self.forward = [None] * (level + 1)  # level 0..level

class SkipList:
    def __init__(self, max_level: int = 16, p: float = 0.5):
        self.MAX_LV = max_level
        self.p = p
        self.head = SkipNode(-math.inf, self.MAX_LV)
        self._level = 0   # 当前实际最高层

    def _random_level(self) -> int:
        lv = 0
        while random.random() < self.p and lv < self.MAX_LV:
            lv += 1
        return lv

    def search(self, target: int) -> bool:
        """从最高层开始向右+向下搜索"""
        cur = self.head
        for i in range(self._level, -1, -1):
            # 在当前层向右走到要跨过 target 为止
            while cur.forward[i] and cur.forward[i].val < target:
                cur = cur.forward[i]
        # 降到底层后,检查下一个节点
        cur = cur.forward[0]
        return cur is not None and cur.val == target

    def insert(self, val: int):
        update = [None] * (self.MAX_LV + 1)
        cur = self.head
        for i in range(self._level, -1, -1):
            while cur.forward[i] and cur.forward[i].val < val:
                cur = cur.forward[i]
            update[i] = cur

        lv = self._random_level()
        if lv > self._level:
            for i in range(self._level + 1, lv + 1):
                update[i] = self.head
            self._level = lv

        node = SkipNode(val, lv)
        for i in range(lv + 1):
            node.forward[i] = update[i].forward[i]
            update[i].forward[i] = node

    def delete(self, val: int):
        update = [None] * (self.MAX_LV + 1)
        cur = self.head
        for i in range(self._level, -1, -1):
            while cur.forward[i] and cur.forward[i].val < val:
                cur = cur.forward[i]
            update[i] = cur

        target = cur.forward[0]
        if target is None or target.val != val:
            return

        for i in range(len(target.forward)):
            update[i].forward[i] = target.forward[i]

        # 可选:清理空层
        while self._level > 0 and self.head.forward[self._level] is None:
            self._level -= 1

# 验证
sl = SkipList()
for v in [3, 6, 7, 9, 12, 19, 17, 26, 21, 25]:
    sl.insert(v)

print("Search 19:", sl.search(19))   # True
print("Search 42:", sl.search(42))   # False
sl.delete(19)
print("Search 19 after delete:", sl.search(19))  # False

# 预期输出:
# Search 19: True
# Search 42: False
# Search 19 after delete: False
java
// Java 对比 — 跳表搜索关键片段(省略构造模板)
// 强调:Java 的泛型和数组类型擦除让跳表实现略啰嗦

public boolean search(int target) {
    SkipNode cur = head;
    for (int i = level; i >= 0; i--) {
        while (cur.forward[i] != null && cur.forward[i].val < target)
            cur = cur.forward[i];
    }
    cur = cur.forward[0];
    return cur != null && cur.val == target;
}
// Java 中 forward 数组使用 SkipNode[] 无法用泛型 new T[n]
// 常见方案:用 List<SkipNode> 或 @SuppressWarnings
cpp
// C++ 对比 — 节点定义 + 随机层级
// 注意:C++ 的内存管理与 RAII 让跳表实现多了析构负担

struct SkipNode {
    int val;
    vector<SkipNode*> forward;
    SkipNode(int v, int lv) : val(v), forward(lv + 1, nullptr) {}
};

int randomLevel() {
    int lv = 0;
    while ((rand() % 100) < 50 && lv < MAX_LV) ++lv;
    return lv;
}
// C++ 标准库中的 multiset 和 map 通常用红黑树实现
// 但 Redis 的 zset 用了跳表——因为跳表实现简单且范围查询友好

为什么跳表在工程中比红黑树更受欢迎?

特性跳表红黑树
代码量≈70 行 Python≈200+ 行 C
并发友好锁细粒度到节点级别重平衡需要全局锁
范围查询底层链表顺序遍历中序遍历(递归/栈)
实现门槛数学上随机化红黑规则复杂

证据:Redis 的有序集合(zset)内部就是跳表 + 散列表的组合。为什么不用红黑树?作者 antirez 的回答简洁有力:"It's simpler and faster."


遭遇战 2:并查集(Union-Find)—— 你是我兄弟吗?

问题

给你一片森林里的营地网络,营地 1000 个。网络中有 n 条兽道连接关系。正有猛兽出没的消息在传播。你要做的就是:

实时判断:营地 A 和营地 B 是否处于同一个连通区域(即有没有兽道相通)?

用图论的话说:动态连通性问题。支持两个操作:

  • union(a, b) :连接 a 和 b
  • find(a) :返回 a 所属集合的"代表元"

朴素解法 → 树形结构

每个集合用一棵树表示。根节点就是代表元。

python
# Python — 并查集,含路径压缩 + 按秩合并

class UnionFind:
    def __init__(self, n: int):
        self.parent = list(range(n))
        self.rank = [0] * n          # 按秩合并的"秩"
        self.count = n               # 连通分量数量

    def find(self, x: int) -> int:
        """路径压缩:找根的同时把路径上所有节点指向根"""
        if self.parent[x] != x:
            # 递归实现,简洁但 Python 递归深度限制要注意
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def find_iter(self, x: int) -> int:
        """迭代版路径压缩,避免递归栈溢出"""
        root = x
        while self.parent[root] != root:
            root = self.parent[root]
        # 二次遍历压平路径
        while x != root:
            nxt = self.parent[x]
            self.parent[x] = root
            x = nxt
        return root

    def union(self, a: int, b: int) -> bool:
        """按秩合并:总是把矮树嫁接到高树上"""
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False                   # 已经在同一集合
        if self.rank[ra] < self.rank[rb]:
            ra, rb = rb, ra               # 确保 ra 是根
        self.parent[rb] = ra
        if self.rank[ra] == self.rank[rb]:
            self.rank[ra] += 1
        self.count -= 1
        return True

    def connected(self, a: int, b: int) -> bool:
        return self.find(a) == self.find(b)

# 验证
uf = UnionFind(10)
uf.union(0, 1); uf.union(1, 2); uf.union(3, 4)
print("0-2 连通:", uf.connected(0, 2))     # True
print("0-3 连通:", uf.connected(0, 3))     # False
uf.union(2, 3)
print("0-3 连通(合并后):", uf.connected(0, 3))  # True
print("连通分量数:", uf.count)                     # 5 (0-1-2-3, 4, 5, 6, 7, 8, 9?)

# 预期输出:
# 0-2 连通: True
# 0-3 连通: False
# 0-3 连通(合并后): True
# 连通分量数: 6   {0,1,2,3}, {4}, {5}, {6}, {7}, {8}, {9} — 啊,忘了 4 和 3 已经合并
# 修正预期:0-1-2-3-4 是一组,+ {5},{6},{7},{8},{9} => 6 个分量
java
// Java 对比 — 典型的算法竞赛写法
// 特点:使用数组而非对象数组,极致性能

class UnionFind {
    int[] parent, rank;
    int count;

    public UnionFind(int n) {
        parent = new int[n];
        rank = new int[n];
        count = n;
        for (int i = 0; i < n; i++) parent[i] = i;
    }

    // 非递归路径压缩
    public int find(int x) {
        int root = x;
        while (parent[root] != root) root = parent[root];
        while (x != root) {
            int next = parent[x];
            parent[x] = root;
            x = next;
        }
        return root;
    }

    public boolean union(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;
        if (rank[ra] < rank[rb]) { int t = ra; ra = rb; rb = t; }
        parent[rb] = ra;
        if (rank[ra] == rank[rb]) rank[ra]++;
        count--;
        return true;
    }
}
// 面试中常考的变体:UnionFind 还能顺便统计每个集合的大小
// 加一个 size[] 数组,union 时合并 size 即可
cpp
// C++ 对比 — 模板化 + 可选的离线欧拉序 LCA 优化
// 核心代码与 Java/Python 类似,但 C++ 常用按大小合并(size)而非 rank

class UnionFind {
    vector<int> parent, sz;
public:
    UnionFind(int n) : parent(n), sz(n, 1) {
        iota(parent.begin(), parent.end(), 0);
    }

    int find(int x) {
        while (parent[x] != x) {
            parent[x] = parent[parent[x]];  // 隔代压缩,比全量压缩更保守
            x = parent[x];
        }
        return x;
    }

    void unite(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return;
        if (sz[ra] < sz[rb]) swap(ra, rb);
        parent[rb] = ra;
        sz[ra] += sz[rb];
    }

    int size(int x) { return sz[find(x)]; }
};
// 注:C++ 的 iota 在 <numeric> 头文件
// Kruskal 最小生成树算法中,并查集是核心组件

复杂度分析——为什么会接近 O(1)?

  • 只有按秩合并:每个 find 最坏 O(log n)
  • 只有路径压缩:一系列操作的均摊复杂度 ≈ O(α(n)),其中 α 是反阿克曼函数
  • 两个都用:O(α(n)),而 α(2^65536) ≈ 5,可视为常数

反阿克曼函数增长有多慢?对于全宇宙的原子数量(≈10^80),α ≈ 4。它就是常数。

常见陷阱:并查集的实际应用

  1. Kruskal 最小生成树 —— 每次加入最短边前检查两端是否连通
  2. 岛屿数量(LeetCode 200) —— O(mn) 时间,每个格子和周围 union
  3. 被围绕的区域(LeetCode 130) —— 把边界上的 'O' 和虚拟节点 union
  4. 除法求值(LeetCode 399) —— 带权并查集,维护倍数关系
  5. 冗余连接(LeetCode 684) —— 加边时发现两端已经连通,说明产生了环

遭遇战 3:斐波那契堆(Fibonacci Heap)—— 理论上最美的堆(概念了解)

斐波那契堆只保留概念性了解即可。 知道它的关键特点和为什么很少工程实现就够了,不看也不影响后续学习。

问题

Dijkstra 算法处理稀疏图时,用二叉堆每次提取最小距离 O(n log n)。如果要处理百万节点的大图呢?能不能更快?

斐波那契堆:惰性到极致

斐波那契堆的核心哲学是**"推迟所有能推迟的工作"**。

操作二叉堆斐波那契堆
插入O(log n)O(1)
取最小值O(log n)O(log n)
减小键值O(log n)O(1) (均摊)
合并两个堆O(n)O(1)

为什么工程中几乎没人用?

python
# 伪代码 — 斐波那契堆的懒删除原理
# 实际代码 ≈ 400+ 行,这里只展示核心思想

class FibHeapNode:
    """斐波那契堆节点:比二叉堆多了一堆指针"""
    def __init__(self, key):
        self.key = key
        self.degree = 0       # 孩子数
        self.marked = False   # 是否失去过孩子(剪枝用)
        self.parent = None
        self.child = None
        self.left = self      # 循环双向链表
        self.right = self

# 核心操作:插入 = 塞到根链表
# 懒到极致 —— 插入 O(1)
def fib_insert(heap, node):
    # 把节点加到根链表中(双向循环链表)
    # 如果新节点 key 小于最小节点,更新 min 指针
    pass

# 核心操作:decrease-key = cut + cascading cut
def fib_decrease_key(node, new_key):
    node.key = new_key
    if node.parent and node.key < node.parent.key:
        # 把 node 从 parent 上剪下来,塞到根链表
        cut(node)
        # 对 parent 执行 cascading cut
        cascading_cut(node.parent)
    pass

# 核心操作:extract-min = 把孩子合并到根链表 + 合并同度树
def fib_extract_min(heap):
    # 把 min 的孩子都提到根链表
    # 把 min 从根链表移除
    # 合并度数相同的树(consolidation)
    pass

血淋淋的真相:

  • 理论常数很大。对于绝大多数实际场景,二叉堆已经快得飞起
  • 实现复杂,调试地狱。一个 bug 让你怀疑人生
  • 缓存不友好。指针满天飞,数据散落在堆里各个角落

结论:斐波那契堆是理论家的宠儿,工程师的噩梦。知道它"存在"就够了。除非你在写极端性能敏感的图算法库,否则老老实实使用二叉堆,或者用配对堆(Pairing Heap)作为实用替代。


遭遇战 4:Trie 树(前缀树)—— 林间驿站的自动索引

Trie 与字符串搜索紧密相关。 本章介绍核心概念,第11章(搜索与字符串匹配)有更完整的实现和讨论。

问题

你在驿站石碑前刻字查询物资。刚刻下"火"字,石碑立刻浮现"火焰草""火石""火把""火种"等候选名字。驿站里有 10 亿条物资名称记录,是怎么做到瞬间响应的?

如果挨个比对 /^火.*/ —— 每输入一个字符扫描全量清单,驿站早关门了。

答案:把前缀索引组织成树

Trie 树的核心:多个字符串共享共同前缀,节省空间,加速查找。

python
# Python — Trie 树的完整实现(含自动补全)

class TrieNode:
    def __init__(self):
        self.children = {}    # 字符 → TrieNode
        self.is_end = False   # 是否有单词在此结束
        self.count = 0        # 经过此节点的单词数(可选)

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

    def insert(self, word: str):
        cur = self.root
        for ch in word:
            if ch not in cur.children:
                cur.children[ch] = TrieNode()
            cur = cur.children[ch]
            cur.count += 1
        cur.is_end = True

    def search(self, word: str) -> bool:
        cur = self.root
        for ch in word:
            if ch not in cur.children:
                return False
            cur = cur.children[ch]
        return cur.is_end

    def starts_with(self, prefix: str) -> bool:
        """是否有以 prefix 为前缀的单词"""
        cur = self.root
        for ch in prefix:
            if ch not in cur.children:
                return False
            cur = cur.children[ch]
        return True

    def autocomplete(self, prefix: str) -> list[str]:
        """返回所有以 prefix 为前缀的单词"""
        cur = self.root
        for ch in prefix:
            if ch not in cur.children:
                return []
            cur = cur.children[ch]
        # DFS 收集以 cur 为起点的所有单词
        result = []
        self._dfs(cur, list(prefix), result)
        return result

    def _dfs(self, node: TrieNode, path: list[str], result: list[str]):
        if node.is_end:
            result.append(''.join(path))
        # 按字母顺序排序输出(字典序)
        for ch in sorted(node.children.keys()):
            path.append(ch)
            self._dfs(node.children[ch], path, result)
            path.pop()

# 验证
t = Trie()
words = ["北京烤鸭", "北京烤鸭店", "北京烤鸭做法", "北京烤鸭哪家好吃",
         "北京烤鱼", "北京烤肉", "上海小笼包"]
for w in words: t.insert(w)

print("search 北京烤鸭:", t.search("北京烤鸭"))        # True
print("search 北京炸鸡:", t.search("北京炸鸡"))        # False
print("prefix 北京烤:", t.starts_with("北京烤"))       # True
print("autocomplete 北京烤:", t.autocomplete("北京烤"))
# 预期输出:
# search 北京烤鸭: True
# search 北京炸鸡: False
# prefix 北京烤: True
# autocomplete 北京烤: ['北京烤鸭', '北京烤鸭哪家好吃', '北京烤鸭店', '北京烤鸭做法', '北京烤鱼', '北京烤肉']
java
// Java 对比 — HashMap<Character, TrieNode> 与数组的分叉选择
// 对于英文 26 个字母,可以用 TrieNode[26] 替代 HashMap 获得更快速度

class Trie {
    static class TrieNode {
        TrieNode[] children = new TrieNode[26]; // 假设只有小写字母
        boolean isEnd = false;
    }

    private TrieNode root = new TrieNode();

    public void insert(String word) {
        TrieNode cur = root;
        for (char c : word.toCharArray()) {
            int idx = c - 'a';
            if (cur.children[idx] == null)
                cur.children[idx] = new TrieNode();
            cur = cur.children[idx];
        }
        cur.isEnd = true;
    }

    public boolean search(String word) {
        TrieNode cur = root;
        for (char c : word.toCharArray()) {
            int idx = c - 'a';
            if (cur.children[idx] == null) return false;
            cur = cur.children[idx];
        }
        return cur.isEnd;
    }
}
// 数组方案:快,但只适合固定字母表
// HashMap 方案:灵活,适配 Unicode(中文、emoji)
cpp
// C++ 对比 — 用 std::unordered_map 实现 Trie
// 显式释放内存是痛点,常用 unique_ptr 或 pool 管理

#include <unordered_map>
#include <string>
#include <vector>

class Trie {
    struct Node {
        std::unordered_map<char, Node*> children;
        bool isEnd = false;
    };
    Node* root;

public:
    Trie() { root = new Node(); }
    ~Trie() { /* 递归释放,略 */ }

    void insert(const std::string& word) {
        Node* cur = root;
        for (char ch : word) {
            if (!cur->children.count(ch))
                cur->children[ch] = new Node();
            cur = cur->children[ch];
        }
        cur->isEnd = true;
    }

    bool search(const std::string& word) {
        Node* cur = root;
        for (char ch : word) {
            if (!cur->children.count(ch)) return false;
            cur = cur->children[ch];
        }
        return cur->isEnd;
    }
};
// C++ 的优势:在内存池(pool allocation)场景下,
// 可以 malloc 一大块内存高效管理 Trie 节点,减少 cache miss

常见陷阱:Trie 树的实战场景

  • 拼写检查:搜索 "recieve" 时提示 "receive"(Trie + 编辑距离)
  • IP 路由最长前缀匹配:路由器查找时,二进制 Trie 查找最长匹配前缀
  • 搜索引擎关键词建议:前缀搜索 + 热度排序
  • 基因序列匹配:DNA 是 A/C/G/T 四字母表,天然适合 Trie

进阶:AC 自动机(Aho–Corasick)—— 一次性匹配所有模式串(字符串进阶)

AC 自动机是字符串匹配的进阶内容。 知道"结合了 Trie 和 KMP 思想可以实现多模式串同时匹配"就够了,完整实现留到字符串进阶章节。

如果问题不是"一个文本,一个关键词",而是**"一个文本,成千上万个敏感词,我要找出所有出现过且不重叠的匹配"**,Trie 逐词搜索就成了 O(n × m)。

AC 自动机 = Trie + KMP 的思想(失配指针)。

python
# Python — AC 自动机的核心:失配指针(failure link)构建

from collections import deque

class ACTrieNode:
    def __init__(self):
        self.children = {}
        self.fail = None          # 失配指针
        self.output = []          # 以当前节点结尾的模式串索引

def build_ac_trie(patterns: list[str]) -> ACTrieNode:
    """构建 AC 自动机"""
    root = ACTrieNode()

    # 1. 构建普通 Trie
    for idx, pat in enumerate(patterns):
        cur = root
        for ch in pat:
            if ch not in cur.children:
                cur.children[ch] = ACTrieNode()
            cur = cur.children[ch]
        cur.output.append(idx)

    # 2. BFS 构建 fail 指针
    q = deque()
    for ch, node in root.children.items():
        node.fail = root
        q.append(node)

    while q:
        cur = q.popleft()
        for ch, child in cur.children.items():
            # 找 fail
            fail = cur.fail
            while fail and ch not in fail.children:
                fail = fail.fail
            child.fail = fail.children[ch] if fail else root
            child.output.extend(child.fail.output)
            q.append(child)
    return root

def ac_search(text: str, root: ACTrieNode, total_patterns: int):
    """在 text 中同时搜索所有模式串"""
    found = [False] * total_patterns
    cur = root
    for i, ch in enumerate(text):
        while cur is not root and ch not in cur.children:
            cur = cur.fail
        if ch in cur.children:
            cur = cur.children[ch]
        else:
            cur = root
        for idx in cur.output:
            found[idx] = True
    return found

# 验证
patterns = ["he", "she", "his", "hers"]
text = "ushers"
root = build_ac_trie(patterns)
found = ac_search(text, root, len(patterns))
for pat, f in zip(patterns, found):
    print(f"'{pat}' in '{text}': {f}")

# 预期输出:
# 'he' in 'ushers': True    (位置 1)
# 'she' in 'ushers': True   (位置 0)
# 'his' in 'ushers': False
# 'hers' in 'ushers': True  (位置 1)
# 仔细看:"ushers" 包含 "she"(u-she-rs) 和 "he"(us-he-rs) 和 "hers"(us-hers)

AC 自动机的核心魔力是:当匹配到字符 'h' 后下一个字符是 'x' 时,不回到根节点重来,而是跳到 fail 指针指向的节点继续匹配。

时间复杂度从 O(n × |word|) 降到了 O(n + total_matches)

实战应用:敏感词过滤系统(微信/抖音)、病毒特征串检测、多关键词高亮


通关挑战

在往下读之前,先试试这些:

  1. 查一下:链表也可以做二分查找?跳表的期望层数为什么是 O(log n)?
  2. 改一改:把上面的跳表改成支持 range_query(low, high) 返回区间内所有值
  3. 算一算:n=10^6 时,并查集在最坏情况下需要多少次操作才能把复杂度压到 O(α(n))?
  4. 想一想:Python 的 set 为什么不内置前缀搜索功能?用 Trie 替换有什么代价?
  5. 拼一拼:写一段代码,用 AC 自动机过滤掉一段中文文本中的所有敏感词,替换为 ***

验收标准

  • [ ] 能口述跳表的"多层快车道"原理,以及它为什么适合并发
  • [ ] 能徒手写并查集(含两个优化),并解释时间复杂度为什么接近常数
  • [ ] 能说出斐波那契堆的懒标记思想和 why not use it
  • [ ] 能写 Trie 树实现 insert + search + starts_with + 自动补全
  • [ ] 能画出 AC 自动机的 fail 指针,并说出它比"Trie 逐词匹配"快在哪

常见卡点

卡点 1:跳表的随机层级让人不放心 "随机化算法?那最坏情况不就是 O(n) 吗?" 是的,但概率极低。用高度 = 3 的跳表处理 10^6 个元素,最坏情况发生的概率 ≈ 1/(2^10^6),比宇宙射线翻转 CPU 比特的概率还低。

卡点 2:并查集的路径压缩递归爆栈 Python 的递归深度限制默认是 1000。如果你在 LeetCode 上写的 find 是递归版,遇到 10^5 级别的数据就直接 RuntimeError。用迭代版

卡点 3:斐波那契堆的 cut 和 cascading cut 总是搞混 记住一条规则:每个非根节点只允许丢失一个孩子。丢失第二个时,自己也要被 cut。这个规则保证了堆性质的结构强度——也是"斐波那契"这个名称的由来(树的形状服从斐波那契数列的增长)。

卡点 4:Trie 的 children 结构选型纠结

  • 英文小写字母:TrieNode children[26](最快)
  • 中英文混合:HashMap<Character, TrieNode>(最灵活)
  • 中英混合但希望均衡性能:Array[TrieNode?] 按 UTF-8 byte 分叉(也就是 Double-Array Trie)

现在不需要理解

  • 跳表的确定性版本(Deterministic Skip List) —— 不是随机选层,而是靠精确规则。知道存在即可
  • 并查集的删除操作 —— union 好做,de-union(撤销合并)难。需要可撤销并查集(Union-Find with Rollback),这在离线算法中会用到。先别管
  • 斐波那契堆的势能分析(Potential Function) —— 知道它能做到 O(1) 均摊即可,证明过程是研究生水平
  • Double-Array Trie —— 压缩 Trie 到两个数组,内存极致优化。中日韩词典常用,但理解它需要你先吃透普通 Trie
  • AC 自动机的输出链优化 —— 当 fail 链很长时,建立 output 链(只指向有输出的节点)。知道存在优化手段即可

旅人笔记

数据结构一句话精神最骄傲的应用
跳表空间换时间,给链表修高速Redis zset
并查集我只关心你属于哪个圈子Kruskal、社交网络
斐波那契堆所有工作都是"回头再说"理论极致,工程慎用
Trie相同前缀不用重复存搜索引擎、路由器

这四个数据结构的共同主线是:用空间换时间,用结构换效率。它们在基础数据结构的废墟上搭建了更快的乌托邦——但每一次都带着新的 tradeoff。


→ 下一站预告

铁打的柱子,流水的结构。你已经把二分法、分治、数组、链表、树、图、散列、跳表、并查集、堆、Trie 这些积木都领回家了。

接下来——Vol 3:钻进计算机地心

我们不再满足于在内存里摆弄指针和数组。我们要扒开冯·诺依曼结构的皮,看看 CPU 怎么用一整套微架构把代码变成电子,看看内存层级如何让缓存命中率决定你的程序生死,看看操作系统怎么把这些几百行的算法塞进几十纳秒的时钟周期里。

你将理解为什么 std::vector::push_back 比你手写的动态数组快、为什么内存对齐能让你的并查集跑快 50%、为什么现代 CPU 的分支预测器不喜欢斐波那契堆那种指针跳跃式的代码。

准备好了吗?Vol 3 见。那才是真正的"计算机内部"。


"你站在算法森林的尽头。面前是一道通往地下的入口——那里是计算机的地心。老陈给你的地图已经用完了。接下来的路,你要靠自己了。"


—— Software Systems Atlas Vol.2 第15章 · 完

Built with VitePress | Software Systems Atlas