经常看到、听到的字典树到底是什么?
基本概念
首先,字典树(Trie)是一种数据结构,从名字来看,它还是一种树形结构,那它具体是什么样子的呢?答案可以从它的别名——前缀树得到。
字典树与一般的树结构类似,也由根节点和子节点组成,根节点不对应任何字符,是字典树的起点;每个子节点代表一个英文字符,每个节点都有 26 个孩子节点指针,所以字典树也可以叫做 26 叉树(好家伙,是 2 叉树的 13 倍呢~😂)。那么,在这种情况下,从根节点到叶子节点的路径就可以看作是一个单词(word,广义上理解就是一个字符串),拥有相同祖先节点的叶子节点,我们就可以认为它们具有相同的前缀,所以它才会被叫做前缀树。同样,当我们要查询某个单词是否在字典树中时,按照单词的字符顺序去遍历字典树,这个过程与查字典非常类似,所以它也被叫做字典树。
特点
在对字典树有了一定的认识后,我们可以分析出字典树的一些特点:
- 字典树也是一种树,所以它也具备树这种数据结构的特点
- 字典树的根节点不包含字符,除根节点外每一个节点都只包含一个字符
- 字典树的根节点到某一节点的路径上的字符连接起来,就是该节点对应的字符串
实现
现在我们考虑实现这种数据结构,为了方便,直接用 C 语言来完成。
定义
先考虑字典树结构体的定义,每个节点都有 26 个孩子节点,并且需要保存当前的字符,同时为了便于判断某个字符串是否在字典树中,增加一个布尔变量来判断是否结束:
1 | typedef struct Node { |
操作函数
有了结构体的定义后,就可以围绕结构体定义设计对应的操作函数了。
trie_init
第一件事情,就是初始化的操作。
初始状态下,只有一个根节点,按照前面的定义,根节点不保存任何数据,但它有 26 孩子:
1 | Trie trie_init() { |
实际上,我们可以将根节点的is_end属性设置为true,表示空串也保存在当前这颗树中。
trie_new_node
字典树的初始化完成后,就可以考虑字典树的插入操作了。既然是插入操作,那必然需要创建新的节点。新节点一定是子节点,那么就需要保存数据,且默认不是终止节点:
1 | Ptr_TNode trie_new_node(char c) { |
trie_insert
再回到插入操作,整个过程应该从根节点开始,从原字符串的开头开始逐个插入每个字符,当当前节点不存在以当前字符为值的孩子时,就创建新节点:
1 | void trie_insert(Trie root, const char *str) { |
trie_search
插入操作完成后,这颗字典树就基本可以认为是建好了,接下来就该考虑如何查找了。分析一下,查找的过程应该与插入是类似的,也需要逐个字符进行查找,直到原字符串的最后一个字符找到了,且当前节点的is_end属性为true,才能认为当前字符串在字典树中:
1 | bool trie_search(Trie root, const char *str) { |
这个地方,我们可以思考一下,当原字符串的最后一个字符找到了,但当前节点的is_end属性为false,意味着什么?
意味着原字符串是字典树中某个字符串的前缀。
trie_destroy
围绕字典树的最后一个操作就是销毁字典树,避免内存泄漏:
1 | void trie_destroy(Trie root) { |
是不是觉得和树的后序遍历很像?
测试
实现完成后,再简单的测试一下正确性:
1 | void unit_test() { |
编译运行,符合预期。
总结
本文主要在讨论字典树这种数据结构,先从其特点入手,然后逐步实现这种数据结构,并完成测试。但我们的实现比较简单,还不具备生产能力;同时,对我们设计的字典树而言,隐含了原数据(原字符串)必须由 26 个英文字母构成的条件。
全部代码:
1 |
|