前缀树

  本文介绍前缀树这一数据结构。首先从基本定义出发,阐述其核心特性,给出图解示例。随后通过基于数组的标准实现代码,讨论常见优化方法和变种结构。最后,与KMP字符串匹配算法进行对比分析,客观指出其优势与局限。




基本概念

定义

  前缀树(Trie树),又称字典树,是一种树形数据结构,专门用于高效存储和检索字符串。


核心特性

  它的核心特性是利用字符串的公共前缀来减少查询时间,特别适合处理前缀匹配、自动补全、拼写检查等场景。


图解示例

以字符串集合 [“apple”, “app”, “banana”] 为例。

1
2
3
4
5
6
7
8
9
10
11
12
13

/ \
a b
/ \
p a
/ \ \
p* p n
| \ \
l e* a
\ \
e* n
\
a*



代码实现

接下来,我们通过java代码实现基于数组的Trie树。树节点结构如下:

1
2
3
4
5
6
7
8
9
10
private class TrieNode {
private TrieNode[] children;
private boolean isEnd;

public TrieNode() {
// 假设只包含小写字母
children = new TrieNode[26];
isEnd = false;
}
}

插入操作

1
2
3
4
5
6
7
8
9
10
11
12
public void insert(String word) {
TrieNode node = root;
for (int i = 0; i < word.length(); i++) {
char c = word.charAt(i);
int index = c - 'a';
if (node.children[index] == null) {
node.children[index] = new TrieNode();
}
node = node.children[index];
}
node.isEnd = true;
}

查询操作

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
/**
* 是否存在单词
*/
public boolean search(String word) {
TrieNode node = searchPrefix(word);
return node != null && node.isEnd;
}

/**
* 是否存在单词开头的前缀
*/
public boolean startsWith(String prefix) {
TrieNode node = searchPrefix(prefix);
return node != null;
}

private TrieNode searchPrefix(String prefix) {
TrieNode node = root;
for (int i = 0; i < prefix.length(); i++) {
char c = prefix.charAt(i);
int index = c - 'a';
if (node.children[index] == null) {
return null;
}
node = node.children[index];
}
return node;
}

删除操作

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
/**
* 删除单词
*/
public void delete(String word) {
delete(root, word, 0);
}

private boolean delete(TrieNode node, String word, int index) {
if (index == word.length()) {
if (!node.isEnd) {
return false; // 单词不存在
}
node.isEnd = false;
return isEmptyNode(node);
}
char c = word.charAt(index);
int pos = c - 'a';
if (node.children[pos] == null) {
return false; // 单词不存在
}
boolean shouldDeleteChild = delete(node.children[pos], word, index + 1);
if (shouldDeleteChild) {
node.children[pos] = null;
return !node.isEnd && isEmptyNode(node);
}
return false;
}

private boolean isEmptyNode(TrieNode node) {
for (TrieNode child : node.children) {
if (child != null) {
return false;
}
}
return true;
}



优化与变种

压缩 Trie(Radix Tree)

  标准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
public class CompressedTrie {
private TrieNode root = new TrieNode();

private static class TrieNode {
String fragment = ""; // 存储字符串片段(如 "app" 而不是单个字符 'a')
Map<Character, TrieNode> children = new HashMap<>(); // 子节点(按首字符索引)
boolean isEnd = false;

public TrieNode() {
}

public TrieNode(String fragment) {
this.fragment = fragment;
}
}

/**
* 插入单词
*/
public void insert(String word) {
insert(root, word);
}

private void insert(TrieNode node, String word) {
char firstChar = word.charAt(0);
TrieNode child = node.children.get(firstChar);
if (child == null) {
// <1> 没有子节点,直接创建新节点存储剩余字符串,压缩高度
TrieNode newNode = new TrieNode(word);
newNode.isEnd = true;
node.children.put(firstChar, newNode);
} else {
// <2> 有子节点,检查片段匹配情况
String fragment = child.fragment;
// 计算两个字符串的公共前缀长度
int commonPrefixLen = getCommonPrefixLength(fragment, word);
if (commonPrefixLen == fragment.length()) {
// <2.1> 完全匹配片段,继续插入剩余部分
insert(child, word.substring(commonPrefixLen));
} else {
// <2.2> 部分匹配,拆分节点
String commonPrefix = fragment.substring(0, commonPrefixLen);
String remainingFragment = fragment.substring(commonPrefixLen);
String remainingWord = word.substring(commonPrefixLen);

// 创建新节点存储公共前缀
TrieNode splitNode = new TrieNode(commonPrefix);
node.children.put(firstChar, splitNode);

// 原节点的剩余部分变成子节点
child.fragment = remainingFragment;
splitNode.children.put(remainingFragment.charAt(0), child);

// 插入剩余部分
if (!remainingWord.isEmpty()) {
TrieNode newNode = new TrieNode(remainingWord);
newNode.isEnd = true;
splitNode.children.put(remainingWord.charAt(0), newNode);
} else {
splitNode.isEnd = true;
}
}
}
}
}

三向 Trie(TST)

  三向Trie也是是标准 Trie 的一种变体。标准 Trie 使用固定大小的数组存储子节点,而 TST 改用左、中、右三个指针。在稀疏数据的场景下,这种做法可以更高效的存储。

1
2
3
4
5
6
7
8
9
10
private class TSTNode {
/**
* left:比当前字符小的分支
* mid:等于当前字符的分支(继续匹配下一个字符)
* right:比当前字符大的分支
*/
TSTNode left, mid, right;
char c;
boolean isEnd;
}



Trie vs KMP

操作 Trie KMP
预处理 O(m * n) (n 为字符串数量,m 为平均长度) O(m) (仅需计算模式串的LPS数组)
单次查询 O(m) (m 为查询字符串长度) O(n) (n 为主串长度,匹配阶段)
多模式查询 O(k * m)(k 为查询次数) O(k * (n + m)) (k 为查询次数)

总结:

  • Trie的预处理成本较高,且当字符较大时空间占用多。

  • 需要批量处理多个字符串(如字典查询)→ 前缀树;需要在长文本中快速查找单个模式 → KMP。