以太坊的核心状态之一是账户余额,即一组 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:计算叶子节点
对每个账户的余额数据分别计算哈希:
步骤 2:计算中间节点
将相邻两个叶子节点的哈希值拼接后哈希:
步骤 3:计算根节点
将两个中间节点的哈希值拼接后哈希:
完整的树结构如下:
[Root]
/ \
[hAB] [hCD]
/ \ / \
[hA] [hB][hC] [hD]
| | | |
A B C D
这个 Root 就是这 4 个账户余额数据的"数字指纹"——任何账户的余额被篡改,Root 都会改变。
上面的例子有 4 个叶子节点(偶数),每层都能两两配对。如果叶子节点数量是奇数,我们可以将最后一个叶子节点复制一份凑成偶数。
Merkle Proof:存在性证明
Merkle Tree 最强大的能力是 Merkle Proof:在不拥有整棵树的情况下,验证某个数据块确实属于这棵树。
假设你想验证账户 C 的余额是否正确。你首先通过某种方式得到正确的 Merkle Root,但你不信任当前查询数据的节点。
验证过程:
- 查询节点返回:账户 C 的余额数据 + Merkle Proof
- Merkle Proof 包含从账户 C 到 Root 路径上所有兄弟节点的哈希值
对于 4 个账户的树,验证账户 C 的 Proof 只需要两个哈希值:\(h_D\)(C 的兄弟)和 \(h_{AB}\)(\(h_{CD}\) 的兄弟)。
验证步骤:
- 计算 C 的哈希:\(h_C' = H(\text{C 的余额数据})\)
- 与兄弟 \(h_D\) 拼接计算:\(h_{CD}' = H(h_C' \| h_D)\)
- 与兄弟 \(h_{AB}\) 拼接计算:\(Root' = H(h_{AB} \| h_{CD}')\)
- 对比 \(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 ETH,root → a → 3 → f → 9 → d → 20 ETH,root → d → e → 1 → 2 → c → 30 ETH。
查找某个账户的余额时,从根节点出发,逐字符匹配沿路径走到底部即可定位目标数据。对一个以太坊地址来说,路径长度为 40,即只需 40 步即可定位数据;而若使用 Merkle Tree,查找次数取决于数据总量,远不如固定的 40 步高效。
Patricia Trie
对以太坊这种应用场景,普通的 Trie 空间效率极低。从上面的树结构可以看到问题所在:路径 root → a → 3、root → d → e → 1 → 2 都是只有一个子节点的单分支链,每个中间节点只消耗一个字符,不携带任何有用信息。以太坊的 20 字节地址有 40 个十六进制字符,意味着一个地址对应的路径长度为 40。存储数百万个地址将产生大量冗余的单分支链。
因此以太坊采用了改进后的 Trie,即 Patricia Trie(Practical Algorithm To Retrieve Information Coded In Alphanumeric)。它的核心思想:将连续的单分支链合并为一个节点,即前缀压缩。具体做法是:
- 遍历 Trie,找到所有只有一个子节点的连续路径
- 将这条路径上的所有字符合并为一个"共享前缀"
- 用一个扩展节点存储这个前缀,直接指向路径终点
对上面的例子进行前缀压缩:
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,剩余部分交给子节点处理。
a3f7b和a3f9d的首个字符都是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 节点只有 a 和 d 两个位置有子节点,其余 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
编码步骤
- 若是叶子节点,去掉 nibble 数组末尾的终止符
0x10 - 根据节点类型和 nibble 数组长度,确定标志值
- 构造第一个字节:高 4 位放标志 nibble;若 nibble 数组长度为奇数,低 4 位放第一个数据 nibble(并从数组中去掉);若为偶数,低 4 位填
0x0 - 将剩余 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 位就可解出长度和类型信息。
解码步骤
解码是编码的逆过程:
- 将字节数组拆分为 nibble 数组
- 提取第一个 nibble 作为标志,判断节点类型和原始长度的奇偶性
- 偶数长度:去掉前两个 nibble(标志 + 补零);奇数长度:去掉第一个 nibble(标志)
- 若是叶子节点,在末尾添加终止符
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:查找共同前缀
0xa3f7b 和 0xa3f9d 共享前缀 [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 → A 和 0xa3f9d → 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 节点可以与子节点合并,当且仅当:
- Branch 节点只有一个非空子节点
- 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 --
暂无评论,来抢沙发吧!