什么是前缀树?
前缀树
是N叉树
的一种特殊形式。通常来说,一个前缀树是用来存储字符串
的。前缀树的每一个节点代表一个字符串
(前缀
)。每一个节点会有多个子节点,通往不同子节点的路径上有着不同的字符。子节点代表的字符串是由节点本身的原始字符串
,以及通往该子节点路径上所有的字符
组成的。
下面是前缀树的一个例子:
在上图示例中,我们在节点中标记的值是该节点对应表示的字符串。例如,我们从根节点开始,选择第二条路径 ‘b’,然后选择它的第一个子节点 ‘a’,接下来继续选择子节点 ‘d’,我们最终会到达叶节点 “bad”。节点的值是由从根节点开始,与其经过的路径中的字符按顺序形成的。
值得注意的是,根节点表示空字符串
。
前缀树的一个重要的特性是,节点所有的后代都与该节点相关的字符串有着共同的前缀。这就是前缀树
名称的由来。
我们再来看这个例子。例如,以节点 “b” 为根的子树中的节点表示的字符串,都具有共同的前缀 “b”。反之亦然,具有公共前缀 “b” 的字符串,全部位于以 “b” 为根的子树中,并且具有不同前缀的字符串来自不同的分支。
前缀树有着广泛的应用,例如自动补全,拼写检查等等。我将在后面的章节中介绍实际应用场景。
如何表示一个前缀树?
在前面的文章中,我们介绍了前缀树的概念。在这篇文章中,我们将讨论如何用代码表示这个数据结构。
在阅读一下内容前,请简要回顾N叉树的节点结构。
//Definition for a Node.
class Node {
public int val;
public List<Node> children;
public Node() {}
public Node(int _val) {
val = _val;
}
public Node(int _val, List<Node> _children) {
val = _val;
children = _children;
}
};
前缀树的特别之处在于字符和子节点之间的对应关系。有许多不同的表示前缀树节点的方法,这里我们只介绍其中的两种方法。
方法一 - 数组
第一种方法是用数组
存储子节点。
例如,如果我们只存储含有字母 a
到 z
的字符串,我们可以在每个节点中声明一个大小为26的数组来存储其子节点。对于特定字符 c
,我们可以使用 c - 'a'
作为索引来查找数组中相应的子节点。
class TrieNode {
// change this value to adapt to different cases
public static final int N = 26;
public TrieNode[] children = new TrieNode[N];
// you might need some extra values according to different cases
};
/** Usage:
* Initialization: TrieNode root = new TrieNode();
* Return a specific child node with char c: root.children[c - 'a']
*/
访问子节点十分快捷
。访问一个特定的子节点比较容易
,因为在大多数情况下,我们很容易将一个字符转换为索引。但并非所有的子节点都需要这样的操作,所以这可能会导致空间的浪费
。
方法二 - Map
第二种方法是使用 Hashmap
来存储子节点。
我们可以在每个节点中声明一个Hashmap。Hashmap的键是字符,值是相对应的子节点。
class TrieNode {
public Map<Character, TrieNode> children = new HashMap<>();
// you might need some extra values according to different cases
};
/** Usage:
* Initialization: TrieNode root = new TrieNode();
* Return a specific child node with char c: root.children.get(c)
*/
通过相应的字符来访问特定的子节点更为容易
。但它可能比使用数组稍慢一些
。但是,由于我们只存储我们需要的子节点,因此节省了空间
。这个方法也更加灵活
,因为我们不受到固定长度和固定范围的限制。
补充
我们已经提到过如何表示前缀树中的子节点。除此之外,我们也需要用到一些其他的值。
例如,我们知道,前缀树的每个节点表示一个字符串,但并不是所有由前缀树表示的字符串都是有意义的。如果我们只想在前缀树中存储单词,那么我们可能需要在每个节点中声明一个布尔值(Boolean)作为标志,来表明该节点所表示的字符串是否为一个单词。
前缀树插入
我们已经在另一篇文章中讨论了 (如何在二叉搜索树中实现插入操作)。
提问:
你还记得如何在二叉搜索树中插入一个新的节点吗?
当我们在二叉搜索树中插入目标值时,在每个节点中,我们都需要根据 节点值
和 目标值
之间的关系,来确定目标值需要去往哪个子节点。同样地,当我们向前缀树中插入一个目标值时,我们也需要根据插入的 目标值
来决定我们的路径。
更具体地说,如果我们在前缀树中插入一个字符串 S
,我们要从根节点开始。 我们将根据 S[0]
(S中的第一个字符),选择一个子节点或添加一个新的子节点。然后到达第二个节点,并根据 S[1]
做出选择。 再到第三个节点,以此类推。 最后,我们依次遍历 S 中的所有字符并到达末尾。 末端节点将是表示字符串 S 的节点。
下面是一个例子:
我们来用伪代码总结一下以上策略:
1. Initialize: cur = root
2. for each char c in target string S:
3. if cur does not have a child c:
4. cur.children[c] = new Trie node
5. cur = cur.children[c]
6. cur is the node which represents the string S
通常情况情况下,你需要自己构建前缀树。构建前缀树实际上就是多次调用插入函数。但请记住在插入字符串之前要 初始化根节点
。
前缀树搜索
搜索前缀
正如我们在前缀树的简介中提到的,所有节点的后代都与该节点相对应字符串的有着共同前缀。因此,很容易搜索以特定前缀开头的任何单词。
同样地,我们可以根据给定的前缀沿着树形结构搜索下去。一旦我们找不到我们想要的子节点,搜索就以失败终止。否则,搜索成功。
我们来用伪代码总结一下以上策略:
1. Initialize: cur = root
2. for each char c in target string S:
3. if cur does not have a child c:
4. search fails
5. cur = cur.children[c]
6. search successes
搜索单词
你可能还想知道如何搜索特定的单词,而不是前缀。我们可以将这个词作为前缀,并同样按照上述同样的方法进行搜索。
- 如果搜索失败,那么意味着没有单词以目标单词开头,那么目标单词绝对不会存在于前缀树中。
- 如果搜索成功,我们需要检查目标单词是否是前缀树中单词的前缀,或者它本身就是一个单词。为了进一步解决这个问题,你可能需要稍对节点的结构做出修改。
提示:往每个节点中加入布尔值可能会有效地帮助你解决这个问题。
应用
Trie (发音为 “try”) 或前缀树是一种树数据结构,用于检索字符串数据集中的键。这一高效的数据结构有多种应用:
1. 自动补全
图 1. 谷歌的搜索建议
2. 拼写检查
图2. 文字处理软件中的拼写检查
3. IP 路由 (最长前缀匹配)
图 3. 使用Trie树的最长前缀匹配算法,Internet 协议(IP)路由中利用转发表选择路径。
4. T9 (九宫格) 打字预测
图 4. T9(九宫格输入),在 20 世纪 90 年代常用于手机输入
5. 单词游戏
图 5. Trie 树可通过剪枝搜索空间来高效解决 Boggle 单词游戏
还有其他的数据结构,如平衡树和哈希表,使我们能够在字符串数据集中搜索单词。为什么我们还需要 Trie 树呢?尽管哈希表可以在 O(1)时间内寻找键值,却无法高效的完成以下操作:
- 找到具有同一前缀的全部键值。
- 按词典序枚举字符串的数据集。
Trie 树优于哈希表的另一个理由是,随着哈希表大小增加,会出现大量的冲突,时间复杂度可能增加到 O(n),其中 n 是插入的键的数量。与哈希表相比,Trie 树在存储多个具有相同前缀的键时可以使用较少的空间。此时 Trie 树只需要 O(m) 的时间复杂度,其中 mm 为键长。而在平衡树中查找键值需要 O*(mlog*n) 时间复杂度。
完整实现
Trie 树的结点结构
Trie 树是一个有根的树,其结点具有以下字段:。
- 最多 R 个指向子结点的链接,其中每个链接对应字母表数据集中的一个字母。
本文中假定 R 为 26,小写拉丁字母的数量。 - 布尔字段,以指定节点是对应键的结尾还是只是键前缀。
图 6. 单词 “leet” 在 Trie 树中的表示
class TrieNode {
// R links to node children
private TrieNode[] links;
private final int R = 26;
private boolean isEnd;
public TrieNode() {
links = new TrieNode[R];
}
public boolean containsKey(char ch) {
return links[ch -'a'] != null;
}
public TrieNode get(char ch) {
return links[ch -'a'];
}
public void put(char ch, TrieNode node) {
links[ch -'a'] = node;
}
public void setEnd() {
isEnd = true;
}
public boolean isEnd() {
return isEnd;
}
}
向 Trie 树中插入键
我们通过搜索 Trie 树来插入一个键。我们从根开始搜索它对应于第一个键字符的链接。有两种情况:
- 链接存在。沿着链接移动到树的下一个子层。算法继续搜索下一个键字符。
- 链接不存在。创建一个新的节点,并将它与父节点的链接相连,该链接与当前的键字符相匹配。
重复以上步骤,直到到达键的最后一个字符,然后将当前节点标记为结束节点,算法完成。
图 7. 向 Trie 树中插入键
class Trie {
private TrieNode root;
public Trie() {
root = new TrieNode();
}
// Inserts a word into the trie.
public void insert(String word) {
TrieNode node = root;
for (int i = 0; i < word.length(); i++) {
char currentChar = word.charAt(i);
if (!node.containsKey(currentChar)) {
node.put(currentChar, new TrieNode());
}
node = node.get(currentChar);
}
node.setEnd();
}
}
复杂度分析
- 时间复杂度:O(m),其中 mm 为键长。在算法的每次迭代中,我们要么检查要么创建一个节点,直到到达键尾。只需要 mm 次操作。
- 空间复杂度:O(m)。最坏的情况下,新插入的键和 Trie 树中已有的键没有公共前缀。此时需要添加 m个结点,使用 O(m)空间。
在 Trie 树中查找键
每个键在 trie 中表示为从根到内部节点或叶的路径。我们用第一个键字符从根开始,。检查当前节点中与键字符对应的链接。有两种情况:
-
存在链接。我们移动到该链接后面路径中的下一个节点,并继续搜索下一个键字符。
-
不存在链接。若已无键字符,且当前结点标记为
isEnd
,则返回 true。否则有两种可能,均返回 false :
- 还有键字符剩余,但无法跟随 Trie 树的键路径,找不到键。
- 没有键字符剩余,但当前结点没有标记为
isEnd
。也就是说,待查找键只是Trie树中另一个键的前缀。
图 8. 在 Trie 树中查找键
class Trie {
...
// search a prefix or whole key in trie and
// returns the node where search ends
private TrieNode searchPrefix(String word) {
TrieNode node = root;
for (int i = 0; i < word.length(); i++) {
char curLetter = word.charAt(i);
if (node.containsKey(curLetter)) {
node = node.get(curLetter);
} else {
return null;
}
}
return node;
}
// Returns if the word is in the trie.
public boolean search(String word) {
TrieNode node = searchPrefix(word);
return node != null && node.isEnd();
}
}
复杂度分析
- 时间复杂度 : O(m)。算法的每一步均搜索下一个键字符。最坏的情况下需要 m 次操作。
- 空间复杂度 : O(1)。
查找 Trie 树中的键前缀
该方法与在 Trie 树中搜索键时使用的方法非常相似。我们从根遍历 Trie 树,直到键前缀中没有字符,或者无法用当前的键字符继续 Trie 中的路径。与上面提到的“搜索键”算法唯一的区别是,到达键前缀的末尾时,总是返回 true。我们不需要考虑当前 Trie 节点是否用 “isend” 标记,因为我们搜索的是键的前缀,而不是整个键。
图 9. 查找 Trie 树中的键前缀
class Trie {
...
// Returns if there is any word in the trie
// that starts with the given prefix.
public boolean startsWith(String prefix) {
TrieNode node = searchPrefix(prefix);
return node != null;
}
}
复杂度分析
- 时间复杂度 : O(m)。
- 空间复杂度 : O(1)。
完整代码
class Trie {
private final int ALPHABET_SIZE = 26;
private Trie[] children = new Trie[ALPHABET_SIZE];
boolean isEndOfWord = false;
public Trie() {}
public void insert(String word) {
Trie tmp = this;
for (char i : word.toCharArray()) {
if (tmp.children[i-'a'] == null) {
tmp.children[i-'a'] = new Trie();
}
tmp = tmp.children[i-'a'];
}
tmp.isEndOfWord = true;
}
public boolean search(String word) {
Trie tmp = this;
for (char i : word.toCharArray()) {
if (tmp.children[i-'a'] == null) {
return false;
}
tmp = tmp.children[i-'a'];
}
return tmp.isEndOfWord ? true : false;
}
public boolean startsWith(String prefix) {
Trie tmp = this;
for (char i : prefix.toCharArray()) {
if (tmp.children[i-'a'] == null) {
return false;
}
tmp = tmp.children[i-'a'];
}
return true;
}
}