土法炼钢兴趣小组的算法知识备份

持久化数据结构:函数式世界的基石

目录

一、什么是持久化数据结构

在命令式编程的世界里,我们习惯了”修改”:向数组追加元素、在哈希表中覆盖键值、在树中插入节点。每一次修改都摧毁了旧版本——这些数据结构是 ephemeral(短命的)。

持久化数据结构(persistent data structure)则完全不同:每一次”修改”操作都会产生一个新版本,同时保留旧版本完好无损。这并不意味着简单的深拷贝——那样的代价是任何人都无法接受的。持久化数据结构的精髓在于 结构共享(structural sharing):新旧版本共享绝大部分内存,只有被修改的路径上的节点需要复制。

持久化的三个层次

持久化并非铁板一块,根据支持的操作强度,可以分为三个层次:

层次 英文名 含义 典型实现
部分持久化 Partial persistence 只能查询历史版本,只能修改最新版本 Fat node 方法
完全持久化 Full persistence 可以查询和修改任意历史版本 Path copying、Node copying
汇合持久化 Confluent persistence 支持将两个历史版本合并为一个新版本 需要更复杂的技术,如 confluent trie

大多数实际应用中,我们关心的是 完全持久化。函数式语言中的不可变集合——Clojure 的 vector、Haskell 的 Data.Map、Scala 的 Vector——都属于这个范畴。

与不可变数据结构的关系

严格来说,持久化数据结构一定是不可变的(每次操作返回新版本),但不可变数据结构不一定是持久化的(如果每次都深拷贝,虽然不可变,但没有结构共享,也就谈不上”持久化”的效率保证)。持久化 = 不可变 + 结构共享 + 高效。

         不可变数据结构
              |
    +--------------------+
    |                    |
  深拷贝实现        持久化数据结构
  (低效)          (结构共享,高效)

二、路径复制:最直观的持久化技术

路径复制(path copying)是实现持久化的最基本技巧。其核心思想极其简单:当需要修改树中的某个节点时,只复制从根到该节点的路径上的所有节点,其余节点直接共享

原理图解

路径复制示意图

以二叉搜索树为例,假设我们要在一棵高度为 h 的 BST 中插入一个新节点:

  1. 找到插入位置的路径:从根节点出发,沿着搜索路径向下走;
  2. 复制路径上的每一个节点,创建它们的新副本;
  3. 新副本中,沿路径方向的子指针指向下一个新副本,非路径方向的子指针直接指向原树中的对应子树;
  4. 返回新的根节点作为新版本的入口。

复杂度分析

操作 时间复杂度 空间复杂度(新增节点数)
查找 O(h) O(1)(不产生新节点)
插入 O(h) O(h)
删除 O(h) O(h)

对于平衡 BST(如 AVL 树、红黑树),h = O(log n),因此每次更新只需要 O(log n) 的额外空间。这个代价相当低廉——如果树中有 100 万个节点,每次更新只需复制大约 20 个节点。

路径复制的局限

路径复制要求数据结构是 树状 的(或者说,任何节点最多被一个父节点指向)。如果数据结构中存在共享子节点(DAG 结构),路径复制会导致不必要的重复复制。此外,路径复制天然不支持自底向上的指针(parent pointer),因为修改子节点时无法同时更新所有指向它的父节点。

三、Fat Node 方法

与路径复制不同,fat node(胖节点)方法选择在节点内部记录所有修改历史。每个节点不再只存储当前值,而是存储一个 (版本号, 值) 的列表。

class FatNode:
    def __init__(self, key):
        self.key = key
        self.left_history = []   # [(version, left_child), ...]
        self.right_history = []  # [(version, right_child), ...]

    def get_left(self, version):
        """获取指定版本的左子节点"""
        result = None
        for v, child in self.left_history:
            if v <= version:
                result = child
            else:
                break
        return result

    def set_left(self, version, child):
        self.left_history.append((version, child))

优劣对比

方面 Path Copying Fat Node
空间开销 每次更新 O(log n) 新节点 每次更新 O(1) 额外空间
时间开销 与原操作相同 查询需要二分搜索版本号
实现复杂度 较简单 较复杂
适用场景 函数式编程、并发 部分持久化场景
GC 友好度 好(旧版本可整体回收) 差(历史记录不断增长)

Fat node 方法理论上很优雅——Driscoll、Sarnak、Sleator 和 Tarjan 在 1986 年的经典论文中证明,使用 fat node 可以将任何链式数据结构以 O(1) 摊还空间开销转换为部分持久化版本。但在实践中,路径复制因为更简洁、更适合函数式编程范式而被广泛采用。

四、Clojure 的持久化向量:HAMT 与 32 路分支

Clojure 语言的持久化向量(persistent vector)是持久化数据结构最成功的工程实现之一。它的核心数据结构是 HAMT(Hash Array Mapped Trie),由 Phil Bagwell 在 2001 年提出,Rich Hickey 将其发扬光大。

32 路分支树

Clojure 的 persistent vector 本质上是一棵 32 路宽的前缀树(trie)。每个内部节点包含一个长度为 32 的数组,每个槽位可以指向子节点或叶子数据。

深度 0 (root):  [ 0..31 指向子节点 ]
深度 1:         每个子节点 [ 0..31 指向子节点 ]
深度 2:         每个子节点 [ 0..31 指向子节点 ]
...
叶子层:         [ 0..31 实际数据元素 ]

为什么选择 32?这是一个精心权衡的数字:

索引计算

给定索引 i,通过位运算逐层提取 5 位来定位:

;; 伪代码:查找索引 i 的元素
(defn tree-lookup [root i depth]
  (if (leaf? root)
    (aget root (bit-and i 0x1f))        ;; 取最低 5 位
    (let [slot (bit-and (unsigned-bit-shift-right i (* depth 5)) 0x1f)]
      (recur (aget root slot) i (dec depth)))))

对应的 Java 实现(简化版):

public Object nth(int i) {
    Object[] node = root;
    for (int level = shift; level > 0; level -= 5) {
        node = (Object[]) node[(i >>> level) & 0x1f];
    }
    return node[i & 0x1f];
}

尾部优化(Tail Optimization)

Clojure 的一个关键优化是维护一个独立的 tail 数组。最近追加的元素先放入 tail 中,当 tail 满了(达到 32 个元素)时,才将其整合到主树中。

PersistentVector:
  count: 65
  shift: 5
  root: [node_0, node_1, null, ..., null]  (32 slots)
  tail: [elem_64]                           (1 element in tail)

尾部优化的好处:

  1. append 操作通常是 O(1):只要 tail 未满,追加只是在 tail 数组末尾写入,不触及主树;
  2. 减少内存分配:避免了每次 append 都需要复制路径;
  3. 提高缓存局部性:tail 数组中的元素在内存中连续存储。

更新操作的路径复制

当需要修改向量中索引 i 的元素时:

public PersistentVector assocN(int i, Object val) {
    if (i >= tailoff()) {
        // 修改在 tail 中,复制 tail 即可
        Object[] newTail = tail.clone();
        newTail[i & 0x1f] = val;
        return new PersistentVector(count, shift, root, newTail);
    }
    // 修改在主树中,需要路径复制
    Object[] newRoot = doAssoc(shift, root, i, val);
    return new PersistentVector(count, shift, newRoot, tail);
}

private Object[] doAssoc(int level, Object[] node, int i, Object val) {
    Object[] ret = node.clone();  // 复制当前节点
    if (level == 0) {
        ret[i & 0x1f] = val;
    } else {
        int subidx = (i >>> level) & 0x1f;
        ret[subidx] = doAssoc(level - 5, (Object[]) node[subidx], i, val);
    }
    return ret;
}

每次更新最多复制 7 层的 7 个 32 元素数组——总共 7 * 32 * 8 = 1792 字节(64 位指针),对于任意规模的向量,这个开销都是固定的。这就是所谓的 “effectively O(1)”

五、持久化红黑树

红黑树是最常用的自平衡 BST 之一。将红黑树持久化需要处理两个挑战:

  1. 插入/删除后的旋转操作会修改多个节点;
  2. 需要确保旋转后路径复制的正确性。

Okasaki 的函数式红黑树

Chris Okasaki 在 1998 年提出了一种优雅的函数式红黑树实现,将插入操作中的四种旋转情况统一为一个 balance 函数:

data Color = R | B
data RBTree a = E | T Color (RBTree a) a (RBTree a)

balance :: Color -> RBTree a -> a -> RBTree a -> RBTree a
-- 四种不平衡情况统一处理
balance B (T R (T R a x b) y c) z d = T R (T B a x b) y (T B c z d)
balance B (T R a x (T R b y c)) z d = T R (T B a x b) y (T B c z d)
balance B a x (T R (T R b y c) z d) = T R (T B a x b) y (T B c z d)
balance B a x (T R b y (T R c z d)) = T R (T B a x b) y (T B c z d)
balance color a x b                  = T color a x b

insert :: Ord a => a -> RBTree a -> RBTree a
insert x s = makeBlack (ins s)
  where
    ins E = T R E x E
    ins (T color a y b)
      | x < y    = balance color (ins a) y b
      | x > y    = balance color a y (ins b)
      | otherwise = T color a y b
    makeBlack (T _ a y b) = T B a y b

这个实现天然就是持久化的——每次 insert 返回新树,旧树不受影响。balance 函数在模式匹配时创建新节点,自动完成了路径复制。

删除操作

删除比插入复杂得多。Okasaki 的原始论文没有涵盖删除,Matt Might 后来补充了一种基于”双重黑色”(double black)的方法:

data Color = R | B | BB  -- BB = double black

-- 删除的核心:处理双重黑色节点
bubble :: Color -> RBTree a -> a -> RBTree a -> RBTree a
bubble color left x right
  | isBB left || isBB right =
      balance (blacker color) (redder left) x (redder right)
  | otherwise = balance color left x right

函数式红黑树的删除操作仍然是 O(log n) 的,但常数因子比命令式版本大。不过在绝大多数应用场景中,这个差异并不显著。

六、Git 的对象模型:一个持久化数据结构

Git 可能是世界上使用最广泛的持久化数据结构。其对象模型是一个完美的持久化 DAG:

三种核心对象

blob    -- 文件内容的快照(不含文件名)
tree    -- 目录结构,包含指向 blob 和子 tree 的指针
commit  -- 指向一棵 tree,加上元数据(作者、时间、parent commit)

结构共享

当你修改一个文件并提交时,Git 的行为与路径复制如出一辙:

Commit A:
  tree_root_A
    ├── blob_readme    (README.md)
    ├── tree_src_A
    │   ├── blob_main  (main.c)
    │   └── blob_util  (util.c)
    └── blob_makefile  (Makefile)

修改 main.c 后:

Commit B:
  tree_root_B          (新的根 tree)
    ├── blob_readme    (共享!与 A 相同)
    ├── tree_src_B     (新的 src tree)
    │   ├── blob_main2 (新的 main.c blob)
    │   └── blob_util  (共享!与 A 相同)
    └── blob_makefile  (共享!与 A 相同)

只有 tree_root_Btree_src_Bblob_main2 是新创建的,其余对象全部通过引用共享。这正是路径复制在文件系统上的体现。

内容寻址

Git 的对象通过 SHA-1 哈希(现在正在迁移到 SHA-256)进行寻址。这意味着:

# 查看一个 commit 对象的结构
$ git cat-file -p HEAD
tree 4b825dc642cb6eb9a060e54bf899d8e244f6ee8f
parent a1b2c3d4e5f6...
author ltl <ltl@example.com> 1713792000 +0800
committer ltl <ltl@example.com> 1713792000 +0800

Add persistent data structure article

# 查看 tree 对象
$ git cat-file -p 4b825dc6...
100644 blob a906cb2a...    README.md
040000 tree 9fb6e3e2...    src
100644 blob 1f7a7a47...    Makefile

这种设计让 Git 天然支持分支、合并、cherry-pick 等操作——因为每个 commit 都是独立的,不会破坏任何已有的历史。Git 本质上是一个 内容寻址的持久化文件系统

七、C 语言实现:持久化平衡 BST

下面是一个完整的持久化 AVL 树实现。通过路径复制,每次插入和删除都返回新的根节点,旧树完全不受影响。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* ========== 持久化 AVL 树 ========== */

typedef struct Node {
    int key;
    int height;
    struct Node *left;
    struct Node *right;
} Node;

/* 创建新节点 */
static Node *node_new(int key, Node *left, Node *right) {
    Node *n = (Node *)malloc(sizeof(Node));
    if (!n) {
        fprintf(stderr, "malloc failed\n");
        exit(1);
    }
    n->key = key;
    n->left = left;
    n->right = right;
    int lh = left ? left->height : 0;
    int rh = right ? right->height : 0;
    n->height = 1 + (lh > rh ? lh : rh);
    return n;
}

/* 复制节点(路径复制的基本操作) */
static Node *node_copy(const Node *src) {
    Node *n = (Node *)malloc(sizeof(Node));
    if (!n) {
        fprintf(stderr, "malloc failed\n");
        exit(1);
    }
    memcpy(n, src, sizeof(Node));
    return n;
}

static int height(const Node *n) {
    return n ? n->height : 0;
}

static int balance_factor(const Node *n) {
    if (!n) return 0;
    return height(n->left) - height(n->right);
}

static void update_height(Node *n) {
    int lh = height(n->left);
    int rh = height(n->right);
    n->height = 1 + (lh > rh ? lh : rh);
}

/* 持久化右旋:返回新子树根,不修改原节点 */
static Node *rotate_right(const Node *y) {
    const Node *x = y->left;
    Node *new_x = node_copy(x);
    Node *new_y = node_copy(y);
    new_y->left = new_x->right;
    update_height(new_y);
    new_x->right = new_y;
    update_height(new_x);
    return new_x;
}

/* 持久化左旋 */
static Node *rotate_left(const Node *x) {
    const Node *y = x->right;
    Node *new_y = node_copy(y);
    Node *new_x = node_copy(x);
    new_x->right = new_y->left;
    update_height(new_x);
    new_y->left = new_x;
    update_height(new_y);
    return new_y;
}

/* 平衡操作:对路径复制后的新节点进行平衡 */
static Node *do_balance(Node *n) {
    update_height(n);
    int bf = balance_factor(n);

    if (bf > 1) {
        if (balance_factor(n->left) < 0) {
            n->left = rotate_left(n->left);
        }
        return rotate_right(n);
    }
    if (bf < -1) {
        if (balance_factor(n->right) > 0) {
            n->right = rotate_right(n->right);
        }
        return rotate_left(n);
    }
    return n;
}

/* 持久化插入:返回新树的根,原树不变 */
Node *persistent_insert(const Node *root, int key) {
    if (!root) {
        return node_new(key, NULL, NULL);
    }

    Node *new_node = node_copy(root);

    if (key < root->key) {
        new_node->left = persistent_insert(root->left, key);
    } else if (key > root->key) {
        new_node->right = persistent_insert(root->right, key);
    } else {
        return new_node;  /* 键已存在,返回副本 */
    }

    return do_balance(new_node);
}

/* 查找最小节点 */
static const Node *find_min(const Node *n) {
    while (n->left) n = n->left;
    return n;
}

/* 持久化删除:返回新树的根,原树不变 */
Node *persistent_delete(const Node *root, int key) {
    if (!root) return NULL;

    Node *new_node = node_copy(root);

    if (key < root->key) {
        new_node->left = persistent_delete(root->left, key);
    } else if (key > root->key) {
        new_node->right = persistent_delete(root->right, key);
    } else {
        /* 找到要删除的节点 */
        if (!root->left) {
            free(new_node);
            return root->right ? node_copy(root->right) : NULL;
        }
        if (!root->right) {
            free(new_node);
            return node_copy(root->left);
        }
        /* 双子节点:用右子树最小值替换 */
        const Node *successor = find_min(root->right);
        new_node->key = successor->key;
        new_node->right = persistent_delete(root->right, successor->key);
    }

    return do_balance(new_node);
}

/* 查找 */
int persistent_search(const Node *root, int key) {
    while (root) {
        if (key < root->key) root = root->left;
        else if (key > root->key) root = root->right;
        else return 1;
    }
    return 0;
}

/* 中序遍历 */
void inorder(const Node *root) {
    if (!root) return;
    inorder(root->left);
    printf("%d ", root->key);
    inorder(root->right);
}

/* 统计节点数 */
int count_nodes(const Node *root) {
    if (!root) return 0;
    return 1 + count_nodes(root->left) + count_nodes(root->right);
}

/* 释放整棵树(注意:有结构共享时需要引用计数或 GC) */
void free_tree(Node *root) {
    if (!root) return;
    free_tree(root->left);
    free_tree(root->right);
    free(root);
}

/* ========== 版本管理 ========== */

#define MAX_VERSIONS 1024

typedef struct VersionStore {
    Node *roots[MAX_VERSIONS];
    int count;
} VersionStore;

void vs_init(VersionStore *vs) {
    vs->count = 0;
    memset(vs->roots, 0, sizeof(vs->roots));
}

int vs_save(VersionStore *vs, Node *root) {
    if (vs->count >= MAX_VERSIONS) {
        fprintf(stderr, "version store full\n");
        return -1;
    }
    int ver = vs->count;
    vs->roots[vs->count++] = root;
    return ver;
}

Node *vs_get(const VersionStore *vs, int version) {
    if (version < 0 || version >= vs->count) return NULL;
    return vs->roots[version];
}

/* ========== 测试程序 ========== */

int main(void) {
    VersionStore vs;
    vs_init(&vs);

    /* 版本 0:空树 */
    Node *tree = NULL;
    vs_save(&vs, tree);

    /* 版本 1:插入 10, 20, 5, 15, 30 */
    int keys[] = {10, 20, 5, 15, 30, 3, 7, 25, 35, 12};
    int n = sizeof(keys) / sizeof(keys[0]);

    for (int i = 0; i < n; i++) {
        tree = persistent_insert(tree, keys[i]);
    }
    int v1 = vs_save(&vs, tree);

    printf("Version %d: ", v1);
    inorder(vs_get(&vs, v1));
    printf(" (%d nodes)\n", count_nodes(vs_get(&vs, v1)));

    /* 版本 2:删除 20 */
    Node *tree2 = persistent_delete(tree, 20);
    int v2 = vs_save(&vs, tree2);

    printf("Version %d: ", v2);
    inorder(vs_get(&vs, v2));
    printf(" (%d nodes)\n", count_nodes(vs_get(&vs, v2)));

    /* 版本 3:在版本 1 的基础上插入 100 */
    Node *tree3 = persistent_insert(vs_get(&vs, v1), 100);
    int v3 = vs_save(&vs, tree3);

    printf("Version %d: ", v3);
    inorder(vs_get(&vs, v3));
    printf(" (%d nodes)\n", count_nodes(vs_get(&vs, v3)));

    /* 验证版本 1 未被修改 */
    printf("Version %d (unchanged): ", v1);
    inorder(vs_get(&vs, v1));
    printf(" (%d nodes)\n", count_nodes(vs_get(&vs, v1)));

    /* 搜索测试 */
    printf("\nSearch 20 in v1: %s\n", persistent_search(vs_get(&vs, v1), 20) ? "found" : "not found");
    printf("Search 20 in v2: %s\n", persistent_search(vs_get(&vs, v2), 20) ? "found" : "not found");
    printf("Search 100 in v1: %s\n", persistent_search(vs_get(&vs, v1), 100) ? "found" : "not found");
    printf("Search 100 in v3: %s\n", persistent_search(vs_get(&vs, v3), 100) ? "found" : "not found");

    return 0;
}

编译和运行:

gcc -O2 -Wall -o persistent_avl persistent_avl.c
./persistent_avl

预期输出:

Version 1: 3 5 7 10 12 15 20 25 30 35 (10 nodes)
Version 2: 3 5 7 10 12 15 25 30 35 (9 nodes)
Version 3: 3 5 7 10 12 15 20 25 30 35 100 (11 nodes)
Version 1 (unchanged): 3 5 7 10 12 15 20 25 30 35 (10 nodes)

Search 20 in v1: found
Search 20 in v2: not found
Search 100 in v1: not found
Search 100 in v3: found

注意内存管理的微妙之处:由于结构共享的存在,不能简单地对某个版本调用 free_tree——那会破坏其他版本共享的节点。在实际项目中,需要使用引用计数或垃圾回收来管理节点的生命周期。

八、Okasaki 的惰性重建与实时持久化队列

Chris Okasaki 在《Purely Functional Data Structures》一书中系统地解决了持久化数据结构的摊还分析问题。核心洞察是:传统的摊还分析在持久化数据结构中会失效

摊还分析为何失效

考虑一个基于两个列表实现的函数式队列:

data Queue a = Queue [a] [a]

-- 入队:追加到后端列表
enqueue :: a -> Queue a -> Queue a
enqueue x (Queue front rear) = check (Queue front (x:rear))

-- 出队:从前端列表取
dequeue :: Queue a -> (a, Queue a)
dequeue (Queue (x:front) rear) = (x, check (Queue front rear))

-- 当前端为空时,翻转后端
check :: Queue a -> Queue a
check (Queue [] rear) = Queue (reverse rear) []
check q = q

在 ephemeral 设置中,reverse 操作的 O(n) 代价可以摊还到之前的 n 次 enqueue 上,得到 O(1) 摊还复杂度。但在持久化设置中,我们可以反复访问翻转前的版本,每次都触发 O(n) 的 reverse!

-- 攻击性使用模式
let q = enqueue_many n empty  -- 构造 n 个元素的队列
-- q 的前端为空,下一次 dequeue 会触发 reverse
let (x1, q1) = dequeue q     -- O(n) reverse
let (x2, q2) = dequeue q     -- 又是 O(n) reverse!同一个 q!
let (x3, q3) = dequeue q     -- 再一次 O(n)!

Okasaki 的解决方案:惰性求值 + 调度

Okasaki 提出了两个关键技术:

1. 惰性求值(Lazy Evaluation)

通过将昂贵操作包装在惰性 thunk 中,并利用记忆化(memoization),确保每个昂贵操作只被实际执行一次:

-- 使用惰性列表的队列(Banker's Queue)
data Queue a = Queue Int [a] Int [a]
  -- 维护不变量: |front| >= |rear|
  -- [a] 是惰性列表(Haskell 默认惰性)

check :: Queue a -> Queue a
check (Queue flen front rlen rear)
  | rlen <= flen = Queue flen front rlen rear
  | otherwise    = Queue (flen + rlen) (front ++ reverse rear) 0 []
  -- front ++ reverse rear 是惰性的,不会立即执行

2. 实时队列(Real-time Queue)

对于需要最坏情况 O(1) 保证的场景,Okasaki 设计了实时队列,通过增量执行(incremental execution)将翻转操作分摊到每一步:

data Queue a = Queue [a] [a] [a]
  -- Queue front rear schedule
  -- schedule 是 front 的一个旋转副本,用于驱动增量求值

rotate :: [a] -> [a] -> [a] -> [a]
rotate []     [y]    acc = y : acc
rotate (x:xs) (y:ys) acc = x : rotate xs ys (y : acc)

exec :: Queue a -> Queue a
exec (Queue front rear (_:schedule)) = Queue front rear schedule
exec (Queue front rear []) = let f' = rotate front rear []
                             in Queue f' [] f'

enqueue :: a -> Queue a -> Queue a
enqueue x (Queue front rear schedule) = exec (Queue front (x:rear) schedule)

dequeue :: Queue a -> (a, Queue a)
dequeue (Queue (x:front) rear schedule) = (x, exec (Queue front rear schedule))

这个实现保证了每次 enqueue 和 dequeue 都是 最坏情况 O(1),即使在持久化使用模式下也如此。

九、性能基准:持久化 vs 短命数据结构

理论上持久化数据结构的渐近复杂度与短命版本相同,但常数因子和实际性能如何?以下是一组典型的基准测试数据。

测试环境

CPU: AMD Ryzen 9 7950X
RAM: 64GB DDR5-5600
OS: Linux 6.8
JVM: OpenJDK 21 (for Clojure/Scala tests)
GCC: 14.1 -O2 (for C tests)

操作延迟对比(纳秒/操作)

操作 C 数组 (ephemeral) C 持久化 AVL Clojure PersistentVector Java ArrayList
随机读取 3 45 18 5
尾部追加 8 (摊还) 120 80 12 (摊还)
随机更新 4 130 95 6
遍历 (per elem) 0.5 8 4 1.2

内存开销对比

数据结构 100 万个 int 的内存 单次更新额外内存
C 数组 4 MB 0(原地修改)
C 持久化 AVL 48 MB ~480 B (log n 个节点)
Clojure PersistentVector 20 MB ~1.8 KB (路径 + 新数组)
Java ArrayList 16 MB 0(原地修改)

关键观察

  1. 随机读取:持久化向量(HAMT)只比原生数组慢约 6 倍,因为 32 路分支使得树高极低;
  2. 追加操作:得益于 tail 优化,Clojure 的 append 性能出奇地好;
  3. 内存开销:持久化 AVL 树的每个节点需要额外存储两个指针和高度,开销较大;HAMT 由于 32 路分支的空间利用率问题,也有一定开销;
  4. 遍历性能:这是持久化数据结构最大的弱点——指针追踪(pointer chasing)导致缓存不友好,遍历性能可能差一个数量级。

吞吐量测试(100 万次操作/秒)

操作: 混合读写 (80% 读 20% 写), n = 100,000

Ephemeral Red-Black Tree:     12.5 M ops/s
Persistent Red-Black Tree:     3.8 M ops/s
Clojure PersistentHashMap:     6.2 M ops/s
Scala immutable.TreeMap:       4.5 M ops/s
Java ConcurrentHashMap:        9.8 M ops/s

持久化版本的吞吐量通常是 ephemeral 版本的 30%-50%。但请注意:这个比较并不完全公平——持久化版本同时提供了版本历史、天然线程安全、无锁并发等额外特性。在需要这些特性的场景中,持久化数据结构往往是 净收益

十、现实世界中的持久化数据结构

持久化数据结构绝非学术玩具。以下是它在工业界的重要应用:

Datomic 数据库

Rich Hickey(Clojure 的作者)设计的 Datomic 数据库将持久化数据结构的理念推向了极致:

;; 查询数据库在特定时间点的状态
(let [db-yesterday (d/as-of (d/db conn) #inst "2026-04-21")]
  (d/q '[:find ?name
          :where [?e :user/name ?name]]
        db-yesterday))

Clojure 的并发模型

Clojure 的 atom、ref、agent 等并发原语全部基于持久化数据结构:

(def state (atom {:users [] :count 0}))

;; swap! 使用 CAS 循环,持久化数据结构保证了无锁更新
(swap! state (fn [s]
  (-> s
      (update :users conj "Alice")
      (update :count inc))))

;; 旧的 state 快照仍然可用——不会有 ABA 问题

Scala Collections

Scala 标准库提供了丰富的持久化集合,scala.collection.immutable 包中的 VectorHashMapTreeMap 等都是基于 HAMT 或红黑树的持久化实现:

val v1 = Vector(1, 2, 3, 4, 5)
val v2 = v1 :+ 6          // O(~1),结构共享
val v3 = v1.updated(2, 99) // O(~1),路径复制

// v1 仍然是 (1, 2, 3, 4, 5)
assert(v1(2) == 3)
assert(v3(2) == 99)

Haskell 生态

Haskell 作为纯函数式语言,所有数据结构天然都是持久化的:

import qualified Data.Map.Strict as Map

let m1 = Map.fromList [("a", 1), ("b", 2), ("c", 3)]
let m2 = Map.insert "d" 4 m1
let m3 = Map.delete "b" m1

-- m1 不变: fromList [("a",1),("b",2),("c",3)]
-- m2: fromList [("a",1),("b",2),("c",3),("d",4)]
-- m3: fromList [("a",1),("c",3)]

CRDTs(无冲突复制数据类型)

CRDT 是分布式系统中实现最终一致性的关键技术。许多 CRDT 的实现依赖于持久化数据结构的思想:

CRDT 和持久化数据结构共享一个核心理念:不破坏历史,只追加新信息

// 一个简单的 G-Counter CRDT
interface GCounter {
    readonly nodeId: string;
    readonly counts: ReadonlyMap<string, number>;  // 不可变!
}

function increment(counter: GCounter): GCounter {
    const newCounts = new Map(counter.counts);  // 结构共享的 Map
    newCounts.set(counter.nodeId,
        (counter.counts.get(counter.nodeId) || 0) + 1);
    return { nodeId: counter.nodeId, counts: newCounts };
}

function merge(a: GCounter, b: GCounter): GCounter {
    const merged = new Map(a.counts);
    for (const [node, count] of b.counts) {
        merged.set(node, Math.max(merged.get(node) || 0, count));
    }
    return { nodeId: a.nodeId, counts: merged };
}

十一、工程实践中的陷阱

持久化数据结构并非银弹。在实际工程中,有一些常见的陷阱需要注意:

常见问题速查表

问题 表现 原因 缓解方案
内存膨胀 程序内存占用持续增长 保留了过多历史版本的引用 及时释放不需要的版本引用;使用弱引用
GC 压力 频繁的 GC 暂停 大量短生命周期的中间节点 使用分代 GC;考虑对象池;批量操作
缓存局部性差 遍历操作异常缓慢 节点在内存中分散分布 定期压缩(compaction);使用连续内存分配器
序列化开销 网络传输/磁盘写入慢 共享结构在序列化时被展平为完整副本 增量序列化;传输 diff 而非完整快照
空间泄漏 惰性求值导致 thunk 累积 未强制求值的惰性链 使用严格(strict)求值;定期 force
并发写入冲突 CAS 重试率过高 多线程频繁更新同一个 atom 减小更新粒度;使用 STM 或分段
调试困难 难以追踪数据流 缺少明确的修改点 使用 tracing/logging;保留版本元数据
哈希碰撞退化 HAMT 操作变慢 哈希函数质量差 使用高质量哈希;处理碰撞链

何时不应使用持久化数据结构

  1. 高频小规模数据:如果数据规模很小(几十个元素),直接拷贝的开销可能比维护持久化结构更低。

  2. 纯计算密集型场景:如果只需要最终结果,不需要历史版本,ephemeral 结构通常更快。

  3. 嵌入式/实时系统:GC 暂停和不可预测的内存分配可能导致实时性问题。

  4. 超大规模数据:当数据量达到 TB 级别时,内存中的持久化结构不再适用,需要考虑基于磁盘的方案(如 LSM-Tree)。

内存管理策略

在非 GC 语言(C/C++/Rust)中使用持久化数据结构,内存管理是最大的挑战:

// 方案一:引用计数
typedef struct RCNode {
    int refcount;
    int key;
    struct RCNode *left, *right;
} RCNode;

void rc_retain(RCNode *n) {
    if (n) n->refcount++;
}

void rc_release(RCNode *n) {
    if (!n) return;
    if (--n->refcount == 0) {
        rc_release(n->left);
        rc_release(n->right);
        free(n);
    }
}

// 方案二:Arena 分配器(适合版本生命周期明确的场景)
typedef struct Arena {
    char *base;
    size_t offset;
    size_t capacity;
} Arena;

void *arena_alloc(Arena *a, size_t size) {
    if (a->offset + size > a->capacity) return NULL;
    void *ptr = a->base + a->offset;
    a->offset += size;
    return ptr;
}

// 当某个版本不再需要时,可以整体释放 arena
void arena_reset(Arena *a) {
    a->offset = 0;
}

Rust 的所有权系统为持久化数据结构提供了另一种方案——通过 Rc<T>Arc<T> 实现引用计数的结构共享:

use std::rc::Rc;

#[derive(Clone)]
enum PList<T: Clone> {
    Nil,
    Cons(T, Rc<PList<T>>),
}

impl<T: Clone> PList<T> {
    fn prepend(&self, val: T) -> PList<T> {
        PList::Cons(val, Rc::new(self.clone()))
    }
}

十二、个人思考与总结

持久化数据结构改变了我对软件设计的根本看法。

最初接触这个概念时,我的第一反应是”这也太浪费了”——每次修改都复制一堆节点,内存不够用怎么办?但深入理解之后,我发现这种直觉是错误的。路径复制的 O(log n) 空间开销,在现代硬件的内存容量面前微不足道。而它带来的好处——版本历史、线程安全、可预测性——在复杂系统中价值巨大。

不可变性是一种解放

在可变的世界里,你需要时刻警惕:这个引用是否被别人修改了?这个缓存是否还有效?这个对象是否线程安全?不可变性消除了所有这些顾虑。当你确信一个数据结构永远不会被修改时,你可以自由地传递、缓存、并行处理它,完全不需要锁。

Git 是最好的教科书

如果你想真正理解持久化数据结构的威力,去研究 Git。Git 的设计展示了一个深刻的洞察:如果你的数据模型天然就是追加式的,那么分支、合并、回滚、审计都会变得极其自然。Datomic 将这个洞察应用到了数据库设计中,CRDTs 将其应用到了分布式系统中。

性能不是唯一标准

我见过太多项目因为过度关注微基准测试而拒绝了持久化数据结构。是的,持久化 HashMap 比 java.util.HashMap 慢。但当你因为共享可变状态而花了三天追踪一个并发 bug 时,那几十纳秒的性能差距还重要吗?

当然,这不意味着持久化数据结构适用于所有场景。热路径上的性能关键代码、内存受限的嵌入式系统、需要极低延迟的交易系统——这些场景可能需要 ephemeral 结构。工程是权衡的艺术。

未来展望

我认为持久化数据结构的应用范围会继续扩大:

最后,我想引用 Rich Hickey 的一句话作为结尾:

“State is the root of all evil in concurrent programming. Immutable values and persistent data structures are the cure.”

持久化数据结构不是银弹,但它是我们对抗复杂性的最有力武器之一。掌握它,你会发现很多曾经棘手的问题突然变得简单了。

附录:参考资料

  1. Driscoll, Sarnak, Sleator, Tarjan. “Making Data Structures Persistent.” Journal of Computer and System Sciences, 1989.
  2. Okasaki, Chris. Purely Functional Data Structures. Cambridge University Press, 1998.
  3. Bagwell, Phil. “Ideal Hash Trees.” EPFL Technical Report, 2001.
  4. Hickey, Rich. “Clojure Data Structures.” clojure.org, 2007.
  5. Might, Matt. “Deletion: The Curse of the Red-Black Tree.” Blog post, 2014.
  6. Kaplan, Haim, and Tarjan, Robert. “Purely Functional, Real-Time Deques with Catenation.” Journal of the ACM, 1999.
  7. Shapiro et al. “A Comprehensive Study of Convergent and Commutative Replicated Data Types.” INRIA Technical Report, 2011.

上一篇: 线段树与树状数组 下一篇: Van Emde Boas 树

相关阅读: - Merkle 树与认证数据结构 - 红黑树 vs AVL


By .