惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
博客园_首页
H
Hackread – Cybersecurity News, Data Breaches, AI and More
T
ThreatConnect
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - 聂微东
H
Help Net Security
T
Threat Research - Cisco Blogs
Blog — PlanetScale
Blog — PlanetScale
A
Arctic Wolf
G
Google Developers Blog
量子位
U
Unit 42
I
InfoQ
V
V2EX
F
Fox-IT International blog
P
Privacy & Cybersecurity Law Blog
V
Visual Studio Blog
J
Java Code Geeks
大猫的无限游戏
大猫的无限游戏
C
CERT Recently Published Vulnerability Notes
博客园 - 三生石上(FineUI控件)
T
The Exploit Database - CXSecurity.com
T
Tailwind CSS Blog
SecWiki News
SecWiki News
Know Your Adversary
Know Your Adversary
MyScale Blog
MyScale Blog
宝玉的分享
宝玉的分享
The Hacker News
The Hacker News
Project Zero
Project Zero
Application and Cybersecurity Blog
Application and Cybersecurity Blog
月光博客
月光博客
Recent Commits to openclaw:main
Recent Commits to openclaw:main
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
G
GRAHAM CLULEY
C
Cisco Blogs
I
Intezer
Simon Willison's Weblog
Simon Willison's Weblog
O
OpenAI News
Recorded Future
Recorded Future
T
Tenable Blog
W
WeLiveSecurity
腾讯CDC
Stack Overflow Blog
Stack Overflow Blog
T
The Blog of Author Tim Ferriss
www.infosecurity-magazine.com
www.infosecurity-magazine.com
D
Docker
C
Cybersecurity and Infrastructure Security Agency CISA
PCI Perspectives
PCI Perspectives

追梦人物的博客

0x04:ECDSA LeetCode 105 从前序与中序遍历构造二叉树 迭代算法原理 + 完整证明 0x03:Address 0x02:Secp256k1 - 以太坊设计与实现 0x00:专栏开篇 0x01:RLP 编码 - 以太坊设计与实现 Bellman-Ford 算法原理及其在 DeFi 套利中的应用 迪杰斯特拉(Dijkstra)最短路径算法原理、实现与证明 实战:CEX-DEX 稳定币套利监控程序开发 CEX-DEX 稳定币套利模型 Uniswap 手续费和协议费机制剖析 - 追梦人物的博客 Uniswap 流动性机制及相关数学原理分析 uv 替代 pyenv + pipx + poetry 环境管理实践 Rust 项目从创建到发布 VS Code 调试 Python 比特币跨市场套利的数学模型 ERC-20 相关知识点总结 Django 老项目如何从 SQLite 迁到 PostgreSQL 自动生成接口文档 单元测试 限制接口访问频率 API 版本管理 如何在 Windows 下搭建高效的 django 开发环境 拓展Python Markdown 加缓存为接口提速 基于 drf-haystack 实现文章搜索接口 评论接口 实现分类、标签、归档日期接口 在接口返回Markdown解析后的内容 文章详情接口 分页 使用视图集简化代码 用类视图实现首页 API 实现博客首页文章列表 API 初始化 RESTful API 风格的博客系统 django-rest-framework 是什么鬼? 结束 or 开始? Coverage.py 统计测试覆盖率 单元测试:测试评论应用 单元测试:测试 blog 应用 Django Haystack 全文检索与关键词高亮 Django 博客实现简单的全文搜索 开启 Django 博客的 RSS 功能 统计各个分类和标签下的文章数 稳定易用的 Django 分页库,完善分页功能 通过 Django Pagination 实现简单分页 在脚本中使用 ORM:Faker 批量生成测试数据 Django 官方推荐的姿势:类视图 Django 使用 union 合并不同模型(Model) 的查询集(QuerySet)
0x05:Merkle Tree & Patricia Trie
2026-05-26 · via 追梦人物的博客

以太坊的核心状态之一是账户余额,即一组 address → balance 的映射关系——每个以太坊地址对应一个余额。以太坊系统的核心功能之一是存储这些状态,并维护状态的更新与查询。

看到这种映射结构,我们很自然地就会想到用哈希表来实现:address 作 key,balance 作 value,查找和更新都只需要常数时间复杂度。但以太坊是分布式系统,系统的每个节点都存储着一份状态副本,而不同节点的副本可能不一样(可能是某个节点因网络延迟未同步至最新状态,也可能是程序 BUG 导致存储了错误的状态,任何原因都有可能)。假设 A 节点记录地址 0xabc... 有 100 ETH,B 节点记录同一地址有 101 ETH。作为用户,查余额时问 A 得到 100,问 B 得到 101,该信谁?更坏的情况,B 恶意告诉我们余额只有 90,我们怎么知道它在骗人?

在哈希表方案下,验证余额真实性的唯一方式是获取一份完整的权威状态副本,逐一比对。但对普通用户而言代价极高——以太坊完整状态现在以 TB 计,而我们可能只想查一下自己的余额,并不想同步整个以太坊的状态。

简单的哈希表数据结构不可行,需要设计更加精巧的数据结构。以太坊采用 Merkle Tree 与 Patricia Trie 结合的数据结构解决了上述查询和验证问题。本文将独立讲解 Merkle Tree 和 Patricia Trie 这两种基础数据结构,下一篇文章将讲解以太坊如何将二者结合。

Merkle Tree

为了解决高效验证数据的问题,以太坊使用 Merkle Tree。

Merkle Tree 由三种节点组成:

  • 叶子节点:最底层,每个叶子节点存储一个原始数据块的哈希值,\(h_i = H(d_i)\),其中 \(d_i\) 是第 \(i\) 个数据块。
  • 中间节点:每个中间节点存储其两个子节点哈希值拼接后再哈希的结果,\(h_{parent} = H(h_{left} \| h_{right})\)\(\|\) 表示拼接
  • 根节点:最顶层,即 Merkle Root,代表整棵树所有数据的"数字指纹",从叶子节点逐层向上计算,最终得到唯一的 Merkle Root

任何数据块的修改都会导致从该叶子节点到根节点路径上的所有哈希值发生变化,最终 Merkle Root 改变——这就是 Merkle Tree 检测篡改的原理。

构建过程示例

假设以太坊中有 4 个账户,我们用 Merkle Tree 存储它们的余额:

账户 余额
A 10 ETH
B 20 ETH
C 30 ETH
D 40 ETH

步骤 1:计算叶子节点

对每个账户的余额数据分别计算哈希:

\[h_A = H(\text{A 的余额数据})\]

\[h_B = H(\text{B 的余额数据})\]

\[h_C = H(\text{C 的余额数据})\]

\[h_D = H(\text{D 的余额数据})\]

步骤 2:计算中间节点

将相邻两个叶子节点的哈希值拼接后哈希:

\[h_{AB} = H(h_A \| h_B)\]

\[h_{CD} = H(h_C \| h_D)\]

步骤 3:计算根节点

将两个中间节点的哈希值拼接后哈希:

\[Root = H(h_{AB} \| h_{CD})\]

完整的树结构如下:

            [Root]
           /      \
        [hAB]    [hCD]
        /   \    /   \
      [hA] [hB][hC] [hD]
       |    |    |    |
       A    B    C    D

这个 Root 就是这 4 个账户余额数据的"数字指纹"——任何账户的余额被篡改,Root 都会改变。

上面的例子有 4 个叶子节点(偶数),每层都能两两配对。如果叶子节点数量是奇数,我们可以将最后一个叶子节点复制一份凑成偶数。

Merkle Proof:存在性证明

Merkle Tree 最强大的能力是 Merkle Proof:在不拥有整棵树的情况下,验证某个数据块确实属于这棵树。

假设你想验证账户 C 的余额是否正确。你首先通过某种方式得到正确的 Merkle Root,但你不信任当前查询数据的节点。

验证过程:

  1. 查询节点返回:账户 C 的余额数据 + Merkle Proof
  2. Merkle Proof 包含从账户 C 到 Root 路径上所有兄弟节点的哈希值

对于 4 个账户的树,验证账户 C 的 Proof 只需要两个哈希值:\(h_D\)(C 的兄弟)和 \(h_{AB}\)\(h_{CD}\) 的兄弟)。

验证步骤:

  1. 计算 C 的哈希:\(h_C' = H(\text{C 的余额数据})\)
  2. 与兄弟 \(h_D\) 拼接计算:\(h_{CD}' = H(h_C' \| h_D)\)
  3. 与兄弟 \(h_{AB}\) 拼接计算:\(Root' = H(h_{AB} \| h_{CD}')\)
  4. 对比 \(Root'\) 与已知的正确 Root:相等则验证通过
            [Root']  ← 对比已知 Root
           /      \
        [hAB]    [hCD']  ← 计算
        /   \    /    \
      [hA] [hB][hC']  [hD]
       ↑Proof  ↑计算   ↑Proof
              C 的余额

关键点:验证者只需要 Proof 中的兄弟节点哈希值和待验证数据本身,就能从叶子节点一路算到根节点。如果中间有任何数据被篡改,最终算出的根哈希一定与正确的 Root 不一致。这保证了被查询节点必须返回正确的数据——如果数据被篡改,最终计算的 Merkle Root 就会与已知的不匹配。

验证的效率也很高,假设以太坊有 100 万个账户,Merkle Tree 的高度为 \(\lceil\log_2(1{,}000{,}000)\rceil = 20\)。验证任意一个账户的余额,只需要 20 个哈希值,而不用下载完整状态数据。

Trie

Merkle Tree 解决了分布式验证问题,但它本身只是一种"验证结构",不提供高效的键值查找能力。朴素 Merkle Tree 的叶子节点是顺序排列的,没有按键组织——要查找某个地址的数据,需要遍历所有叶子节点,效率为 \(O(n)\)

以太坊需要的是:给定一个地址,快速定位到对应的账户数据。也就是说,需要一种按键(地址)组织的树形结构,既能快速查找、插入和删除,又能高效存储稀疏的键值对。以太坊地址是 160 位(20 字节),理论上可以表示 \(2^{160}\) 个不同地址,实际使用的地址只占极小一部分。

Trie(字典树,也称前缀树)正是按键组织数据的常用数据结构。它将键的每个字符沿路径逐层展开,从根节点到值节点的路径就是键本身。

用 Trie 存储以太坊数据

假设以太坊中有 3 个账户,我们用 Trie 存储它们的余额。为了方便演示,地址只取前 5 个十六进制字符(原理与完整的 20 字节地址完全一样):

地址
0xa3f7b 10 ETH
0xa3f9d 20 ETH
0xde12c 30 ETH

Trie 将地址的每个十六进制字符沿路径逐层展开,存储这三个地址的 Trie 结构如下:

                root
               /    \
              a      d
              |      |
              3      e
              |      |
              f      1
             / \     |
            7   9    2
            |   |    |
            b   d    c
            |   |    |
           10  20   30

三条路径分别是:root → a → 3 → f → 7 → b → 10 ETHroot → a → 3 → f → 9 → d → 20 ETHroot → d → e → 1 → 2 → c → 30 ETH

查找某个账户的余额时,从根节点出发,逐字符匹配沿路径走到底部即可定位目标数据。对一个以太坊地址来说,路径长度为 40,即只需 40 步即可定位数据;而若使用 Merkle Tree,查找次数取决于数据总量,远不如固定的 40 步高效。

Patricia Trie

对以太坊这种应用场景,普通的 Trie 空间效率极低。从上面的树结构可以看到问题所在:路径 root → a → 3root → d → e → 1 → 2 都是只有一个子节点的单分支链,每个中间节点只消耗一个字符,不携带任何有用信息。以太坊的 20 字节地址有 40 个十六进制字符,意味着一个地址对应的路径长度为 40。存储数百万个地址将产生大量冗余的单分支链。

因此以太坊采用了改进后的 Trie,即 Patricia Trie(Practical Algorithm To Retrieve Information Coded In Alphanumeric)。它的核心思想:将连续的单分支链合并为一个节点,即前缀压缩。具体做法是:

  1. 遍历 Trie,找到所有只有一个子节点的连续路径
  2. 将这条路径上的所有字符合并为一个"共享前缀"
  3. 用一个扩展节点存储这个前缀,直接指向路径终点

对上面的例子进行前缀压缩:

                root [a,d; nil] <- Branch
                     /   \
Extension -> [3,f; next] [e,1,2,c; 30] <- Leaf
                     |
                [7,9; nil] <- Branch
                /   \
          [b; 10]   [d; 20] <- Leaf

Patricia Trie 通过三种节点类型的组合实现前缀压缩:

  • 分支节点(Branch):包含 16 个子节点槽位(对应十六进制字符 0-f)和 1 个值槽位,用于路径分叉。进入某个槽位即消耗路径中的一个字符
  • 扩展节点(Extension) [prefix, child]:存储连续的共享前缀,child 是子节点的引用
  • 叶子节点(Leaf) [suffix, value]:存储路径剩余部分和终端数据

上图中,root 分支的 a 号位消耗了键的首个字符 a,剩余部分交给子节点处理。

  • a3f7ba3f9d 的首个字符都是 a,在 root 分支的 a 号位汇聚,剩余部分共享前缀 3f,由扩展节点存储
  • de12c 的首个字符是 d,在 root 分支的 d 号位独占,剩余部分 e12c 不再有其他键共享前缀,直接用叶子节点存储

压缩效果:节点数从 13 减少到 6(root 分支 + 扩展节点 + 内部分支 + 3 个叶子节点),树深从 6 层减少到 4 层。

Leaf 节点有一个 value 槽位用于存储数据,Branch 节点也设计了一个 value 槽位(上例中所有 Branch 节点的 value 槽位均为空)。Branch 节点需要 value 槽位的原因是:一个 key 可能是另一个 key 的前缀,当路径在前缀末端发生分叉时,较短的那个 key 已经结束,需要在此处存储关联的数据。对以太坊地址而言,这种情况不会出现(地址长度固定为 40 个 nibble),但以太坊中还有其他数据需要存储,这些数据的 key 长度可能不一,就可能出现一个 key 是另一个 key 前缀的情况。

Branch 节点实际有 16 个子节点槽位,但由于地址的稀疏性,大部分槽位都是空的。上例中,根 Branch 节点只有 ad 两个位置有子节点,其余 14 个位置都是空节点,空节点没有在上例的图中画出来。

Nibble 编码

上面的例子中,路径的每一步对应地址中的一个十六进制字符(0-f)。以太坊地址是 20 字节的十六进制字符表示,每个十六进制字符恰好占用 4 位二进制,即半个字节,称为一个 nibble。因此一个 20 字节的地址包含 40 个 nibble。

Nibble 编码将地址拆分为 nibble 序列作为 Trie 的路径单位。由于 nibble 只有 16 种取值(0-f),分支节点只需要 16 个子节点槽位:

[Nibble 编码的分支节点]
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ a │ b │ c │ d │ e │ f │ value │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───────┘
  16 个槽位,每个对应一个 nibble 值

以太坊的 20 字节地址经 Nibble 编码后变为 40 个 nibble。

终止标记

叶子节点的 nibble 数组末尾会添加一个终止符 0x10(十进制 16),用于标识路径在此结束。选择 0x10 的原因是 nibble 的取值范围是 0x0-0xF(0-15),0x10 是第一个超出该范围的值,因此不会与路径中的任何 nibble 冲突。扩展节点的 nibble 数组不加终止符——因为扩展节点的作用是继续路径,它永远不会是路径的终点。

叶子节点的 nibble 数组: [n1, n2, ..., nk, 0x10]
                                          ↑
                                        终止符
表示:路径到这里结束,后面跟着的是实际的值

扩展节点的 nibble 数组: [n1, n2, ..., nk]
表示:路径还没有结束,后面跟着的是另一个节点的引用

如果没有终止符,叶子节点 [0xa, 0x3, 0xf] 和扩展节点 [0xa, 0x3, 0xf] 看起来完全一样,无法区分。加上终止符后,叶子节点变为 [0xa, 0x3, 0xf, 0x10],扩展节点保持 [0xa, 0x3, 0xf],一眼可辨。

注意终止符的主要作用是区分节点类型,不要和 nibble 字符混淆。0x10 不是 nibble 字符 0x1 + 0x0 的组合。在有上下文可区分节点类型的情况下,终止符是没有意义的。例如 nibble 数组被序列化存储时,HP 编码(下面将详细介绍)会记录类型信息,终止符会被移除。

终止符提供了区分叶子节点和扩展节点的一种方式,但仅靠终止标记还不够——还需要 HP 编码。

HP(Hex-Prefix)编码

Nibble 编码的数组存在两个歧义,这两个歧义都发生在 nibble 数组打包成字节数组的过程中——我们需要把 nibble 数组序列化为字节才能存储到磁盘或通过网络传输。

歧义 1:长度奇偶歧义

nibble 数组打包成字节时,每两个 nibble 合并为一个字节。如果 nibble 数组长度是奇数,需要在前面补一个 0x0。这导致不同长度的 nibble 数组可能打包出相同的字节数组:

Nibble 数组 A: [0xa]         (长度 1,奇数)
          ↓ 补零
          [0x0, 0xa]
          ↓ 打包
          字节: 0x0a

Nibble 数组 B: [0x0, 0xa]    (长度 2,偶数)
          ↓ 打包
          字节: 0x0a

解码时拿到 0x0a,无法知道原来是一个 nibble 还是两个 nibble!

歧义 2:节点类型歧义

叶子节点的终止符 0x10 不是有效的 nibble,无法打包。打包前必须先移除终止符,但这导致叶子节点和扩展节点的 nibble 数组变得一模一样:

叶子节点原始数组: [0xa, 0x3, 0xf, 0x10]
          ↓ 移除终止符
          [0xa, 0x3, 0xf](长度 3,奇数)
          ↓ 补零
          [0x0, 0xa, 0x3, 0xf]
          ↓ 打包
          字节: 0x0a 0x3f

扩展节点原始数组: [0xa, 0x3, 0xf](长度 3,奇数)
          ↓ 补零
          [0x0, 0xa, 0x3, 0xf]
          ↓ 打包
          字节: 0x0a 0x3f

解码时拿到 0x0a 0x3f,无法知道是叶子节点还是扩展节点!

HP 编码的解决方案

HP(Hex-Prefix)编码的核心思想:在编码结果的第一个字节中,用高 4 位作为标志 nibble,同时记录节点类型和原始长度的奇偶性。标志 nibble 的 4 位二进制含义如下:

标志 nibble: b3 b2 b1 b0
             │  │  │  │
             │  │  │  └─ 长度奇偶位: 0 = 偶数,1 = 奇数
             │  │  └──── 节点类型位: 0 = 扩展节点,1 = 叶子节点
             │  └─────── 保留位: 始终为 0
             └────────── 保留位: 始终为 0

四种取值:

标志值 二进制 节点类型 nibble 数组长度
0x0 0000 扩展节点 偶数
0x1 0001 扩展节点 奇数
0x2 0010 叶子节点 偶数
0x3 0011 叶子节点 奇数

用之前的歧义例子验证 HP 编码:

原来两个冲突的情况,加上标志后不再冲突:

长度奇偶歧义解决:
  [0xa](奇数扩展)→ 标志 0x1 → [0x1, 0xa]         → 打包: 0x1a
  [0x0, 0xa](偶数扩展)→ 标志 0x0 → [0x0, 0x0, 0xa] → 补零 → 打包: 0x00 0x0a

节点类型歧义解决:
  叶子 [0xa, 0x3, 0xf] → 标志 0x3 → [0x3, 0xa, 0x3, 0xf] → 打包: 0x3a 0x3f
  扩展 [0xa, 0x3, 0xf] → 标志 0x1 → [0x1, 0xa, 0x3, 0xf] → 打包: 0x1a 0x3f

编码步骤

  1. 若是叶子节点,去掉 nibble 数组末尾的终止符 0x10
  2. 根据节点类型和 nibble 数组长度,确定标志值
  3. 构造第一个字节:高 4 位放标志 nibble;若 nibble 数组长度为奇数,低 4 位放第一个数据 nibble(并从数组中去掉);若为偶数,低 4 位填 0x0
  4. 将剩余 nibble 两两合并为一个字节(高 4 位在前,低 4 位在后)

用一个完整的例子展示编码全过程:

原始叶子节点,nibble 数组: [0xa, 0x3, 0xf, 0x7, 0xb, 0x10]
                                                     ↑
                                                   终止符

步骤 1: 去掉终止符
        [0xa, 0x3, 0xf, 0x7, 0xb](长度 5,奇数)

步骤 2: 确定标志值
        叶子节点 + 奇数长度 → 标志 = 0x3

步骤 3: 构造第一个字节
        高 4 位: 标志 0x3;低 4 位: 第一个 nibble 0xa(奇数长度)→ 0x3a
        剩余 nibble: [0x3, 0xf, 0x7, 0xb]

步骤 4: 剩余 nibble 打包
        [0x3a, 0x3f, 0x7b]
原始扩展节点,nibble 数组: [0x3, 0xf](长度 2,偶数)

步骤 1: 不需要去掉终止符(扩展节点没有终止符)

步骤 2: 确定标志值
        扩展节点 + 偶数长度 → 标志 = 0x0

步骤 3: 构造第一个字节
        高 4 位: 标志 0x0;低 4 位: 0x0(偶数长度,填零)→ 0x00
        剩余 nibble: [0x3, 0xf]

步骤 4: 剩余 nibble 打包
        [0x00, 0x3f]

根据上面的过程可以看出,无论 nibble 数组长度是奇数还是偶数,节点类型是什么,HP 编码后第一个字节的高 4 位一定是标志 nibble,解码时提取第一个字节的高 4 位就可解出长度和类型信息。

解码步骤

解码是编码的逆过程:

  1. 将字节数组拆分为 nibble 数组
  2. 提取第一个 nibble 作为标志,判断节点类型和原始长度的奇偶性
  3. 偶数长度:去掉前两个 nibble(标志 + 补零);奇数长度:去掉第一个 nibble(标志)
  4. 若是叶子节点,在末尾添加终止符 0x10

0x3a 0x3f 0x7b 为例:

步骤 1: 拆成 nibble 数组
        [0x3, 0xa, 0x3, 0xf, 0x7, 0xb]

步骤 2: 提取标志
        0x3 → 二进制 0011 → 叶子节点,原始长度奇数

步骤 3: 去掉标志(奇数长度只去掉 1 个 nibble)
        [0xa, 0x3, 0xf, 0x7, 0xb]

步骤 4: 因为是叶子节点,添加终止符
        [0xa, 0x3, 0xf, 0x7, 0xb, 0x10]

路径分裂

当插入一个新的键值对时,如果新键与现有节点的路径共享部分前缀但最终分叉,就需要进行路径分裂。分裂过程会创建新的 Branch 节点来分叉路径。

示例:假设已有 Trie 包含 0xa3f7b → A,现在插入 0xa3f9d → B

步骤 1:查找共同前缀

0xa3f7b0xa3f9d 共享前缀 [0xa, 0x3, 0xf],之后分叉。

步骤 2:创建 Branch 节点

将原 Leaf 节点分裂为 Extension + Branch + 两个 Leaf:

原结构(单个 Leaf 节点):

[a,3,f,7,b; A] <- Leaf

    ↓ 插入 0xa3f9d → B,路径在 [a,3,f] 后分裂

Extension -> [a,3,f; next]
                       |
                    [7, 9; nil] <- Branch
                    /    \
                [b; A]  [d; B] <- Leaf

路径分裂的核心思想:当两条路径在某个点分叉时,将分叉点提升为 Branch 节点,两条路径分别成为 Branch 的子节点。

路径合并

当删除一个键值对时,如果删除后某个 Branch 节点只剩下一个子节点且自身没有值,就需要进行路径合并。合并过程会将 Branch 节点与其唯一子节点合并。

示例:假设已有 Trie 包含 0xa3f7b → A0xa3f9d → B,现在删除 0xa3f9d

步骤 1:删除节点

从 Branch 节点的 0x9 位置移除 Leaf 节点。

步骤 2:检查合并条件

删除后,Branch 节点只剩下 0x7 位置一个子节点,且自身没有值(v = ∅)。满足合并条件。

步骤 3:执行合并

将 Extension + Branch + Leaf 合并为单个 Leaf:

原结构(删除 0xa3f9d 后,Branch 只剩一个子节点,自身无值):

Extension -> [a,3,f; next]
                  |
                [7; nil] <- Branch(只剩一个子节点,自身无值)
                /
            [b; A] <- Leaf

    ↓ Branch 只有一个子节点且自身无值,合并为 Leaf

[a,3,f,7,b; A] <- Leaf

合并条件:Branch 节点可以与子节点合并,当且仅当:

  1. Branch 节点只有一个非空子节点
  2. Branch 节点自身的值为空

如果 Branch 节点有值,即使只有一个子节点也不能合并,因为合并会丢失节点自身的值。以下是一个具体示例:

Branch 自身有值 D,只有一个子节点 [0x7]:

Extension -> [a,3,f; next]
                  |
                [7; D] <- Branch(有值,不能合并)
                /
            [b; A] <- Leaf

    ✗ 不能合并为 [a,3,f,7,b; A](<- Leaf)
      因为这样会丢失 Branch 自身存储的值 D

基本操作

Get 操作

Get 操作根据键查找对应的值。查找过程是一个递归遍历,从根节点开始,逐步消耗键的 nibble,直到找到目标值或确认键不存在。

// get: 在以 node 为根的子树中查找 key 对应的值
// 参数:
//   node - 当前节点
//   key  - 完整的 nibble 数组
//   pos  - 当前已消耗的 nibble 数量(即 key 中下一个待匹配的位置)
// 返回值: 目标值(存在时)或 nil(不存在时)
function get(node, key, pos):
    if node is nil:
        return nil  // 空节点,键不存在

    switch node.type:
        case Branch:
            if pos == len(key):
                return node.value  // key 所有 nibble 已消耗完毕,返回 Branch 自身的值
            nibble = key[pos]  // 取当前待匹配的 nibble
            return get(node.children[nibble], key, pos + 1)  // 进入对应槽位,消耗 1 个 nibble

        case Extension:
            prefix = node.encodedPath
            if !matchPrefix(key, pos, prefix):
                return nil  // key 在 pos 位置开始的部分与扩展前缀不匹配,键不存在
            return get(node.next, key, pos + len(prefix))  // 前缀匹配,跳过整个前缀长度,递归进入子节点

        case Leaf:
            suffix = node.encodedPath
            if matchSuffix(key, pos, suffix):
                return node.value  // 剩余部分完全匹配,返回 Leaf 存储的值
            return nil  // 剩余部分不匹配,键不存在

pos 参数追踪当前已消耗的 nibble 数量:

  • Branch 节点:消耗 1 个 nibble,pos + 1
  • Extension 节点:消耗前缀长度的 nibble,pos + len(prefix)
  • Leaf 节点:消耗剩余所有 nibble,匹配完成

示例:查找键 0xa3f9d(nibble: [0xa, 0x3, 0xf, 0x9, 0xd]

步骤 1: 从根 Branch 开始,pos = 0
        nibble = key[0] = 0xa
        递归查找 children[0xa] → Extension

步骤 2: 在 Extension 节点,pos = 1
        prefix = [0x3, 0xf]
        匹配 key[1:3] = [0x3, 0xf] ✓
        递归查找 next → Branch,pos = 3

步骤 3: 在 Branch 节点,pos = 3
        nibble = key[3] = 0x9
        递归查找 children[0x9] → Leaf

步骤 4: 在 Leaf 节点,pos = 4
        suffix = [0xd]
        匹配 key[4:5] = [0xd] ✓
        返回值 B

Update 操作

Update 操作插入或更新一个键值对。插入过程可能触发路径分裂。

// insert: 在以 node 为根的子树中插入或更新 key → value
// 参数:
//   node  - 当前节点
//   key   - 待插入的 nibble 数组(当前位置到末尾的剩余部分)
//   value - 待关联的值
// 返回值: 插入/更新后的子树根节点
function insert(node, key, value):
    if node is nil:
        return Leaf(key, value)  // 空位置,直接创建 Leaf

    switch node.type:
        case Leaf:
            if key == node.encodedPath:
                return Leaf(key, value)  // 路径完全匹配,用新值替换
            // 路径分叉:找到共同前缀长度,创建 Branch 在分叉点分岔
            commonLen = prefixLen(key, node.encodedPath)
            branch = new Branch()

            // 将原 Leaf 的分叉 nibble 之后的剩余路径插入 Branch
            oldSuffix = node.encodedPath[commonLen+1:]
            if len(oldSuffix) == 0:
                branch.value = node.value  // 原路径在分叉点结束,值存入 Branch 的 value 槽位
            else:
                branch.children[oldSuffix[0]] = Leaf(oldSuffix[1:], node.value)  // 消耗分叉 nibble,剩余作为 Leaf

            // 将新 key 的分叉 nibble 之后的剩余路径插入 Branch
            newSuffix = key[commonLen+1:]
            if len(newSuffix) == 0:
                branch.value = value  // 新路径在分叉点结束,值存入 Branch 的 value 槽位
            else:
                branch.children[newSuffix[0]] = Leaf(newSuffix[1:], value)  // 消耗分叉 nibble,剩余作为 Leaf

            // 如果有共同前缀,用 Extension 包装 Branch
            if commonLen > 0:
                return Extension(key[:commonLen], branch)
            return branch

        case Extension:
            extKey = node.encodedPath
            commonLen = prefixLen(key, extKey)

            if commonLen == len(extKey):
                // key 完全匹配扩展前缀,递归插入子节点
                newChild = insert(node.next, key[commonLen:], value)
                return Extension(extKey, newChild)
            else:
                // key 仅部分匹配扩展前缀,需要分裂 Extension
                branch = new Branch()

                // 保留原 Extension 的分叉 nibble 之后的路径
                oldSuffix = extKey[commonLen+1:]
                if len(oldSuffix) == 0:
                    branch.children[extKey[commonLen]] = node.next  // 原路径在分叉点直接连接子节点
                else:
                    branch.children[extKey[commonLen]] = Extension(oldSuffix, node.next)  // 剩余部分仍为 Extension

                // 插入新 key 的分叉 nibble 之后的路径
                newSuffix = key[commonLen+1:]
                if len(newSuffix) == 0:
                    branch.value = value  // 新路径在分叉点结束
                else:
                    branch.children[newSuffix[0]] = Leaf(newSuffix[1:], value)

                // 如果有共同前缀,用 Extension 包装 Branch
                if commonLen > 0:
                    return Extension(key[:commonLen], branch)
                return branch

        case Branch:
            if len(key) == 0:
                node.value = value  // key 所有 nibble 已消耗完毕,更新 Branch 自身的值
                return node
            nibble = key[0]  // 取当前待匹配的 nibble
            node.children[nibble] = insert(node.children[nibble], key[1:], value)  // 递归插入,消耗 1 个 nibble
            return node

空节点插入

原结构:nil
插入 0xa3f7b → A 后:
[a,3,f,7,b; A] <- Leaf

路径分裂示例

原结构(单个 Leaf 节点):

[a,3,f,7,b; A] <- Leaf

    ↓ 插入 0xa3f9d → B,路径在 [a,3,f] 后分裂

Extension -> [a,3,f, next]
                       |
                    [7, 9; nil] <- Branch
                    /    \
                [b; A]  [d; B] <- Leaf

Delete 操作

Delete 操作删除一个键值对。删除过程可能触发路径合并。

// delete: 在以 node 为根的子树中删除 key 对应的键值对
// 参数:
//   node - 当前节点
//   key  - 待删除的 nibble 数组(当前位置到末尾的剩余部分)
// 返回值: (更新后的子树根节点, 是否删除成功)
function delete(node, key):
    if node is nil:
        return nil, false  // 空节点,删除失败

    switch node.type:
        case Leaf:
            if key == node.encodedPath:
                return nil, true  // 路径完全匹配,删除节点(返回 nil 表示节点已移除)
            return node, false  // 路径不匹配,删除失败

        case Extension:
            extKey = node.encodedPath
            if !startsWith(key, extKey):
                return node, false  // key 不以扩展前缀开头,键不存在

            // 递归删除子节点,传入剥离前缀后的剩余 key
            newChild, deleted = delete(node.next, key[len(extKey):])
            if !deleted:
                return node, false  // 子树删除失败,整体不变

            if newChild is nil:
                return nil, true  // 子节点被完全移除,Extension 失去子节点,自身也移除

            // 子节点仍存在,检查是否可以合并(缩短路径)
            if newChild is Leaf:
                return Leaf(extKey + newChild.encodedPath, newChild.value), true  // Extension + Leaf 合并为新 Leaf
            if newChild is Extension:
                return Extension(extKey + newChild.encodedPath, newChild.next), true  // 两个 Extension 合并前缀

            return Extension(extKey, newChild), true  // 子节点是 Branch,无法合并,保持原结构

        case Branch:
            if len(key) == 0:
                node.value = nil  // key 所有 nibble 已消耗完毕,清除 Branch 自身的值
            else:
                nibble = key[0]  // 取当前待匹配的 nibble
                node.children[nibble], deleted = delete(node.children[nibble], key[1:])  // 递归删除,消耗 1 个 nibble
                if !deleted:
                    return node, false  // 子树删除失败,整体不变

            // 删除成功,检查删除后是否满足合并条件
            pos = -1
            for i from 0 to 15:  // 遍历 16 个子节点槽位
                if node.children[i] is not nil:
                    if pos == -1:
                        pos = i  // 记录第一个非空槽位的位置
                    else:
                        pos = -2  // 发现第二个非空槽位,不需要合并
                        break

            if pos == -1:
                return nil, true  // 所有子节点槽位为空且自身无值,移除整个 Branch
            if pos >= 0 and node.value is nil:
                // 只有一个子节点且自身无值,满足合并条件
                child = node.children[pos]
                if child is Leaf:
                    return Leaf([pos] + child.encodedPath, child.value), true  // Branch 的分叉 nibble + Leaf 路径合并为新 Leaf
                if child is Extension:
                    return Extension([pos] + child.encodedPath, child.next), true  // Branch 的分叉 nibble + Extension 前缀合并为新 Extension
                // child is Branch: 无法直接合并,用 Extension 包装分叉 nibble
                return Extension([pos], child), true

            return node, true  // 不满足合并条件,保持原结构

路径合并示例

原结构:

Extension -> [a,3,f, next]
                      |
                   [7, 9; nil] <- Branch
                    /   \
                 [b; A] [d; B] <- Leaf

    ↓ 删除 0xa3f9d → B

Extension -> [a,3,f, next]
                  |
                [7; nil] <- Branch(只剩一个子节点,自身无值)
                /
              [b; A] <- Leaf

    ↓ Branch 只有一个子节点且自身无值,合并为 Leaf

[a,3,f,7,b; A] <- Leaf

操作效率分析

三种操作的时间复杂度都是 \(O(k)\),其中 \(k\) 是键的长度(nibble 数量):

操作 时间复杂度 说明
Get \(O(k)\) 最多遍历 \(k\) 层节点
Update \(O(k)\) 最多遍历 \(k\) 层,可能触发路径分裂
Delete \(O(k)\) 最多遍历 \(k\) 层,可能触发路径合并

路径分裂和合并机制确保树结构在动态操作后保持紧凑,避免了冗余的单分支链。

总结

Merkle Tree 和 Patricia Trie 各自解决一个核心问题:

  • Merkle Tree 解决验证问题:通过 Merkle Proof,验证者只需 \(O(\log n)\) 个哈希值即可证明某个数据属于整棵树,无需下载完整状态。任何数据篡改都会导致根哈希变化,从而被检测。
  • Patricia Trie 解决查找问题:通过前缀压缩,将键的每个 nibble 沿路径展开,查找、插入、删除的时间复杂度均为 \(O(k)\)\(k\) 为键的 nibble 长度),同时避免了普通 Trie 的空间浪费。

Patricia Trie 的三种节点类型(Branch、Extension、Leaf)配合路径分裂与合并机制,保证树结构在动态操作后始终保持紧凑。Nibble 编码将地址拆分为路径单位,HP 编码解决了 nibble 数组序列化时的长度奇偶歧义和节点类型歧义。

两者结合即为 Merkle Patricia Trie——一种既能高效查找又能高效验证的数据结构,这是以太坊状态存储的基础。下一篇文章将讲解以太坊如何将二者结合。

-- EOF --