压缩字典树 (Compressed Trie),也常被称为 基数树 (Radix Trie)Patricia Trie (Practical Algorithm to Retrieve Information Coded in Alphanumeric),是一种经过优化的字典树 (Trie) 数据结构。它在标准字典树的基础上,通过合并那些路径上只有一个子节点的节点,显著提高了空间效率,尤其适用于存储具有长公共前缀的字符串集合。

核心思想:标准字典树的每个节点通常只存储一个字符。当路径上出现连续的单子节点时,这些节点可以被合并成一个节点,该节点存储一个字符串片段。这样既能保持字典树的快速前缀查找能力,又能大幅减少节点数量和内存占用。


一、标准字典树 (Trie) 概述及其局限性

在深入压缩字典树之前,我们先回顾一下标准字典树 (Trie) 的基本概念。

1.1 标准字典树 (Trie)

  • 定义:Trie 是一种树形数据结构,用于存储字符串集合。它的名称来源于 “retrieval”,意为检索。
  • 结构
    • 根节点通常为空字符串。
    • 每个节点表示一个字符。
    • 从根节点到任意节点的路径,拼接起来就是一个字符串。
    • 一个节点可以标记为“单词的结尾”,表示从根节点到此节点的路径构成一个完整的单词。
  • 用途:广泛用于前缀匹配、自动补全、拼写检查等。

示例:插入单词 “apple”, “apply”, “app”, “banana”

图中,{text - end} 表示该节点是某个单词的结尾。

1.2 标准字典树的局限性

标准字典树虽然功能强大,但在某些场景下存在效率问题:

  1. 空间效率低下:当字符串集合中存在大量长公共前缀或许多不分叉的路径时,会出现大量的单子节点。例如,插入 “apple” 和 “apply”,a -> p -> p -> l 这条路径上的节点都只有一个子节点,造成了内存的浪费。
  2. 树的深度过大:树的深度与最长字符串的长度成正比,可能导致额外的节点遍历开销。

为了解决这些问题,压缩字典树应运而生。

二、压缩字典树 (Radix Trie) 核心概念

压缩字典树通过将路径上连续的单子节点合并成一个节点来优化标准字典树。

2.1 定义与核心特点

  • 定义:压缩字典树是一种字典树,其中每个节点可以表示一个字符串片段,而不是仅仅一个字符。它将路径上只有一个子节点的节点合并。
  • 核心特点
    • 节点存储字符串片段:每个节点不再只存储一个字符,而是存储从其父节点到此节点路径上的一个字符串片段。
    • 子节点分叉:除了根节点,每个非叶子节点至少有两个子节点(或者代表一个完整单词的结束)。这确保了每个节点代表的字符串片段是路径上唯一的,直到下一个分叉点或单词结束。
    • 空间效率:显著减少了节点数量,降低了内存占用。
    • 查找效率:在匹配字符串时,可以一次性匹配整个字符串片段,而不是逐字符匹配,理论上可以减少比较次数。

2.2 节点结构

一个典型的压缩字典树节点可能包含以下字段:

  • Key (string): 从父节点到当前节点路径上的字符串片段。
  • Children (map[rune]*Node 或 map[string]*Node): 一个映射,将下一个字符(或下一个字符串片段的第一个字符)映射到其对应的子节点。通常使用 map[rune]*Node,因为子节点的选择是基于下一个字符的分歧。
  • IsEndOfWord (bool): 标记当前节点是否代表一个完整单词的结束。
  • Value (interface{}): 如果是字典,可以存储与单词关联的值。

2.3 压缩字典树示例

重新以上面的 “apple”, “apply”, “app”, “banana” 为例,看看压缩字典树如何存储:

对比标准 Trie

  • a p p 这条路径被压缩成一个 app_node 节点。
  • banana 这条路径也被压缩成一个 banana_node 节点。
  • 节点数量显著减少。

三、工作原理:插入、查找、删除

压缩字典树的复杂性主要体现在插入和删除操作上,它们涉及到字符串匹配、节点分裂 (split) 和节点合并 (merge) 逻辑。

3.1 插入 (Insert)

插入操作需要遍历树,找到与待插入字符串最长的公共前缀,并根据匹配情况进行以下处理:

  1. 无公共前缀或部分匹配
    • 如果当前节点没有子节点,或者没有子节点的 Key 与待插入字符串的首字符匹配:直接创建一个新节点,其 Key 为剩余的待插入字符串,并将其作为当前节点的子节点。
  2. 完全匹配现有节点 Key
    • 如果待插入字符串完全匹配到当前子节点 Key 的末尾:将当前节点更新为该子节点,继续处理待插入字符串的剩余部分。
  3. 待插入字符串完全匹配子节点 Key 的前缀
    • 例如,树中有 “apple”,插入 “app”。当匹配到 app 节点时,待插入字符串 “app” 已经匹配完毕。此时,只需将当前节点的 IsEndOfWord 标记为 true。
  4. 子节点 Key 完全匹配待插入字符串的前缀
    • 例如,树中有 “app”,插入 “apple”。当匹配到 app 节点时,待插入字符串还有 “le” 剩余。此时,将当前节点更新为 app 节点,并从 app 节点继续插入 “le”。
  5. 部分匹配,需要分裂节点 (Split Node)
    • 这是最复杂的情况。例如,树中有 “apple”,插入 “apply”。当匹配到 appl 节点时,待插入字符串的下一个字符是 y,而 apple 的下一个字符是 e
    • 此时,需要将 appl 节点分裂。公共前缀 appl 成为一个新节点。原 appl 节点的剩余部分 e 成为新节点的子节点。待插入字符串的剩余部分 y 成为新节点的另一个子节点。

插入 “ape” 到包含 “app”, “apple”, “apply”, “banana” 的树中:

初始树:

插入 “ape” 的步骤:

  1. root 开始,尝试匹配 “ape”。
  2. root 有子节点 A1 (Key=”app”) 和 B1_banana (Key=”banana”)。
  3. “ape” 的首字符 ‘a’ 匹配 A1 (Key=”app”) 的首字符。
  4. 比较 “ape” 与 A1 节点的 Key (“app”)。公共前缀是 “ap”。
  5. “ap” 的长度是 2。A1 节点的 Key (“app”) 的长度是 3。
  6. 发生分裂
    • 创建一个新节点 AP_node(Key=”ap”)。
    • AP_node 节点作为 root 的子节点,替换原来的 A1 节点。
    • A1 节点变为 P_node(Key=”p”),作为 AP_node 的一个子节点。P_node 继承原 A1 节点的所有子节点 (A2) 和 IsEndOfWord 状态。
    • 为 “ape” 的剩余部分 “e” 创建一个新节点 E_ape_node(Key=”e”),作为 AP_node 的另一个子节点。标记 E_ape_nodeIsEndOfWord:true

分裂后的树结构:

查找操作相对简单:

  1. 从根节点开始,尝试匹配待查找字符串与当前节点的 Key
  2. 如果当前 Key 与待查找字符串的对应部分不完全匹配,则字符串不存在。
  3. 如果当前 Key 完全匹配,则继续到下一个子节点,用待查找字符串的剩余部分进行匹配。
  4. 重复此过程,直到字符串匹配完毕。
  5. 如果最终匹配到的节点 IsEndOfWord 为 true,则找到。

3.3 删除 (Delete)

删除操作是最复杂的,需要先找到对应的单词,然后:

  1. 标记删除:将找到单词结尾节点的 IsEndOfWord 标记为 false。
  2. 节点合并/清理
    • 如果标记为 false 后,该节点不再是任何单词的结尾,且其没有任何子节点,则可以从父节点中删除。
    • 如果标记为 false 后,该节点不再是任何单词的结尾,且其只有一个子节点,则可以将其 Key 与子节点的 Key 合并,并删除子节点(进行合并操作)。这个合并过程可能需要递归向上执行。

四、Go 语言实现示例 (简化版)

以下是一个简化的 Go 语言压缩字典树实现,主要展示 Node 结构和 Insert / Search 的核心逻辑。为了简洁,省略了错误处理和高级功能(如删除、范围查询)。请注意,Insert 方法中的节点分裂逻辑是 Radix Trie 实现中最精细和容易出错的部分,这里的实现是一个基础演示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
package main

import (
"fmt"
"strings"
)

// Node 表示压缩字典树的一个节点
type Node struct {
Key string // 从父节点到此节点的字符串片段
Children map[rune]*Node // 子节点,键是下一个字符
IsEndOfWord bool // 标记是否为单词的结尾
Value interface{} // 与单词关联的值 (可选)
}

// NewNode 创建一个新的节点
func NewNode(key string) *Node {
return &Node{
Key: key,
Children: make(map[rune]*Node),
}
}

// RadixTrie 表示整个压缩字典树
type RadixTrie struct {
Root *Node
}

// NewRadixTrie 创建一个新的 RadixTrie
func NewRadixTrie() *RadixTrie {
return &RadixTrie{
Root: NewNode(""), // 根节点通常为空字符串
}
}

// Insert 将一个单词插入到 RadixTrie 中
func (t *RadixTrie) Insert(word string, value interface{}) {
curr := t.Root
remainingWord := word

for {
if len(remainingWord) == 0 { // 单词已完全匹配到某个节点
curr.IsEndOfWord = true
curr.Value = value
return
}

// 检查是否有匹配的子节点
firstChar := rune(remainingWord[0])
childNode, ok := curr.Children[firstChar]

if !ok { // 没有匹配的子节点,直接创建新路径
newNode := NewNode(remainingWord)
newNode.IsEndOfWord = true
newNode.Value = value
curr.Children[firstChar] = newNode
return
}

// 找到匹配的子节点,开始比较 Key
commonPrefixLen := 0
for i := 0; i < len(remainingWord) && i < len(childNode.Key); i++ {
if remainingWord[i] == childNode.Key[i] {
commonPrefixLen++
} else {
break
}
}

if commonPrefixLen == len(childNode.Key) {
// 子节点的 Key 完全匹配
remainingWord = remainingWord[commonPrefixLen:]
curr = childNode
// 继续循环,处理剩余的单词
} else if commonPrefixLen == len(remainingWord) {
// 待插入单词完全匹配子节点 Key 的前缀,需要分裂 childNode
// 例如:树中有 "apple",插入 "app"
newSplitNode := NewNode(childNode.Key[:commonPrefixLen]) // 新节点Key="app"
newSplitNode.IsEndOfWord = true // 标记 "app" 为单词结尾
newSplitNode.Value = value

// 原childNode的剩余部分作为newSplitNode的子节点
childNode.Key = childNode.Key[commonPrefixLen:] // childNode.Key="le"
newSplitNode.Children[rune(childNode.Key[0])] = childNode

curr.Children[firstChar] = newSplitNode // 更新父节点的子节点
return
} else {
// 待插入单词与子节点 Key 有共同前缀,但都不完全匹配,需要分裂 childNode
// 例如:树中有 "apple",插入 "apply"(这里是"ape")
newSplitNode := NewNode(childNode.Key[:commonPrefixLen]) // 新节点Key="ap"

// 将原 childNode 的剩余部分作为 newSplitNode 的一个子节点
childNode.Key = childNode.Key[commonPrefixLen:] // childNode.Key="ple"
newSplitNode.Children[rune(childNode.Key[0])] = childNode

// 创建新节点存储待插入单词的剩余部分
newWordNode := NewNode(remainingWord[commonPrefixLen:]) // newWordNode.Key="e"
newWordNode.IsEndOfWord = true
newWordNode.Value = value
newSplitNode.Children[rune(newWordNode.Key[0])] = newWordNode

curr.Children[firstChar] = newSplitNode // 更新父节点的子节点
return
}
}
}

// Search 查找一个单词是否存在于 RadixTrie 中,并返回其关联的值
func (t *RadixTrie) Search(word string) (interface{}, bool) {
curr := t.Root
remainingWord := word

for len(remainingWord) > 0 {
firstChar := rune(remainingWord[0])
childNode, ok := curr.Children[firstChar]
if !ok {
return nil, false // 未找到匹配的子节点
}

// 比较当前子节点的 Key 和剩余单词
if strings.HasPrefix(remainingWord, childNode.Key) {
// remainingWord 以 childNode.Key 开头
remainingWord = remainingWord[len(childNode.Key):]
curr = childNode
} else if strings.HasPrefix(childNode.Key, remainingWord) && childNode.IsEndOfWord {
// childNode.Key 以 remainingWord 开头 (即 remainingWord 是 childNode.Key 的前缀)
// 例如,查询 "ap",而节点 Key 是 "apple",但节点Key是 "ap" 且IsEndOfWord
// 这个条件是为了处理查询前缀正好是一个单词的情况
return childNode.Value, true
} else {
return nil, false // Key 不匹配
}
}

if curr.IsEndOfWord {
return curr.Value, true
}
return nil, false
}

// 辅助函数:打印 Radix Trie 结构 (用于调试)
func printTrie(node *Node, indent int) {
fmt.Printf("%sNode Key: \"%s\", IsEndOfWord: %t, Value: %v\n", strings.Repeat(" ", indent), node.Key, node.IsEndOfWord, node.Value)
for char, child := range node.Children {
fmt.Printf("%s Child for '%c':\n", strings.Repeat(" ", indent), char)
printTrie(child, indent+2)
}
}

func main() {
trie := NewRadixTrie()

fmt.Println("--- 插入单词 ---")
trie.Insert("apple", "A sweet fruit")
trie.Insert("apply", "To put into operation")
trie.Insert("app", "A software application")
trie.Insert("banana", "A yellow fruit")
trie.Insert("band", "A group of musicians")
trie.Insert("bat", "A flying mammal")
trie.Insert("bath", "Washing oneself")
trie.Insert("ape", "A primate") // 插入导致分裂

fmt.Println("\n--- Trie 结构 (部分打印) ---")
printTrie(trie.Root, 0)

fmt.Println("\n--- 查找单词 ---")
testSearch(trie, "apple")
testSearch(trie, "apply")
testSearch(trie, "app")
testSearch(trie, "banana")
testSearch(trie, "band")
testSearch(trie, "bat")
testSearch(trie, "bath")
testSearch(trie, "ape")
testSearch(trie, "ap") // "ap" 不是一个独立单词 (如果 "ap" 自身不是一个词,并且没有被标记为isEndOfWord)
testSearch(trie, "aple") // "aple" 不存在
testSearch(trie, "bana") // "bana" 不是一个独立单词
testSearch(trie, "bang") // "bang" 不存在
}

func testSearch(t *RadixTrie, word string) {
val, found := t.Search(word)
if found {
fmt.Printf("'%s' 找到,值: %v\n", word, val)
} else {
fmt.Printf("'%s' 未找到\n", word)
}
}

注意:上面的 Insert 方法是一个简化的实现,特别是涉及到节点分裂和合并的逻辑会更加复杂,需要仔细处理各种边缘情况。真实生产环境的 Radix Trie 实现通常会包含更多细节和优化,例如,处理空字符串插入、数值存储、更健壮的删除操作等。

五、优缺点与适用场景

5.1 优点:

  1. 空间效率高:通过合并单子节点,显著减少了节点数量,尤其对于具有大量长公共前缀的字符串集合,存储空间远小于标准字典树。
  2. 查询速度快:在匹配过程中,可以一次性跳过整个字符串片段,而不是逐字符比较,对于长字符串而言,这可能减少 CPU 周期。在最坏情况下,查询时间复杂度与字符串长度成正比 ($O(L)$,其中 $L$ 是字符串长度),但常数因子更小。
  3. 支持所有 Trie 操作:能够高效地进行前缀查找、自动补全、最长前缀匹配等操作。
  4. 适用于路由表:IP 路由表查找是其经典应用场景之一,IP 地址具有明显的前缀结构。

5.2 缺点:

  1. 实现复杂度高:相较于标准字典树,插入和删除操作涉及字符串匹配、节点分裂和合并等复杂逻辑,代码实现难度较大,容易出错。
  2. 字符串操作开销:每个节点存储字符串片段,这意味着在 Go 中会有字符串切片、比较等操作,可能带来一些性能开销(尽管通常比标准 Trie 的节点遍历节省的开销小)。
  3. 不适用于字符集非常庞大且无公共前缀的场景:如果字符串集合中的单词几乎没有公共前缀,或者字符集(如 Unicode)非常庞大,其优势将不明显,甚至可能因为 map[rune] 的查找开销而略逊于其他结构。

5.3 适用场景:

  • IP 路由表查找:如路由器中的 Longest Prefix Match (最长前缀匹配)。
  • 网络数据包过滤:基于 IP 地址或域名进行快速过滤。
  • 词典和拼写检查:高效存储大量单词。
  • 命令行自动补全:根据用户输入的前缀建议命令或参数。
  • URL 路由:某些 Web 框架使用 Radix Trie 进行高效的 URL 路径匹配。
  • 搜索引擎自动补全:快速给出输入前缀的搜索建议。

六、总结

压缩字典树 (Radix Trie/Patricia Trie) 是一种高度优化的字典树变体,通过巧妙地合并节点,解决了标准字典树在空间效率上的短板。它在字符串前缀匹配和存储方面表现出色,特别适用于那些数据具有明显公共前缀的场景,如路由表和自动补全系统。尽管其实现相对复杂,但它带来的性能和内存收益,使其成为高性能字符串处理应用中不可或缺的数据结构。