数据结构和算法 | 键树详细分析
键树
,又称数字查找树
(Digital Search Trees),是一棵度>=2
的树,它的某个节点不是包含一个或多个关键字,而是只包含组成关键字的一部分(字符或数字)。
如果关键字本身是字符串,则键树中的一个结点只包含有一个字符;如果关键字本身是数字,则键树中的一个结点只包含一个数位。每个关键字都是从键树的根结点到叶子结点中经过的所有结点中存储的组合。
根结点
不代表任何字符,根以下第一层的结点对应于字符串的第一个字符,第二层的结点对应于字符串的第二个字符……每个字符串可由一个特殊的字符如“$
”等作为字符串的结束符,用一个叶子结点来表示该特殊字符。
把从根到叶子的路径上,所有结点(除根以外)对应的字符连接起来,就得到一个字符串。因此,每个叶子结点对应一个关键字。在叶子结点还可以包含一个指针,指向该关键字所对应的元素。整个字符串集合中的字符串的数目等于叶子结点的数目。如果一个集合中的关键字都具有这样的字符串特性,那么,该关键字集合就可采用这样一棵键树来表示。事实上,还可以赋予“字符串”更广泛的含义,它可以是任何类型的对象组成的串。常见键树如图所示:
注意:键树中叶子结点的特殊符号 $ 为结束符,表示字符串的结束。使用键树表示查找表时,为了方便后期的查找和插入操作,约定键树是有序树(兄弟结点之间自左至右有序),同时约定结束符 ‘$’ 小于任何字符。
键树的存储结构
键树的存储结构有两种:
- 一种是通过使用树的孩子兄弟表示法来表示键树,即
双链树
; - 一种是以树的多重链表表示键树,即
Trie 树
,又称字典树
。
双链树
当使用孩子兄弟表示法来表示键树时,树的结点构成分为3部分:
- symbol域:存储关键字的一个字符;
- first域:存储指向第一棵子树的根的指针;
- next域:存储指向右兄弟结点的指针。
注意:对于叶子结点来说,由于其没有孩子结点,在构建叶子结点时,将 first 指针换成 info 指针(可选的,记录附加数据),用于指向该关键字。当叶子结点(结束符 ‘$’ 所在的结点)中使用 info 域指向各自的关键字时,此时的键树被称为双链树。
如下图:
提示
:每个关键字的叶子结点 $ 的 info 指针指向的是各自的关键字,通过该指针就可以找到各自的关键字的首地址。
双链树查找功能的具体实现
查找过程是:从根结点出发,顺着first查找,如果相等,继续下一个first;否则沿着next(first 结点的兄弟结点)查找。直到到了空指针为止。此时若仍未完成key的匹配,查找不成功。
具体实现的代码(来自百度):
#include <stdio.h>
typedef enum{LEFT,BRANCH}NodeKind;//定义结点的类型,是叶子结点还是其他类型的结点
typedef struct {
char a[20];//存储关键字的数组
int num;//关键字长度
}KeysType;
//定义结点结构
typedef struct DLTNode{
char symbol;//结点中存储的数据
struct DLTNode *next;//指向兄弟结点的指针
NodeKind *kind;//结点类型
union{//其中两种指针类型每个结点二选一
struct DLTNode* first;//孩子结点
struct DLTNode* info;//叶子结点特有的指针
};
}*DLTree;
//查找函数,如果查找成功,返回该关键字的首地址,反则返回NULL。T 为用孩子兄弟表示法表示的键树,K为被查找的关键字。
DLTree SearchChar(DLTree T, KeysType k){
int i = 0;
DLTree p = T->first;//首先令指针 P 指向根结点下的含有数据的孩子结点
//如果 p 指针存在,且关键字中比对的位数小于总位数时,就继续比对
while (p && i < k.num){
//如果比对成功,开始下一位的比对
if (k.a[i] == p->symbol){
i++;
p = p->first;
}
//如果该位比对失败,则找该结点的兄弟结点继续比对
else{
p = p->next;
}
}
//比对完成后,如果比对成功,最终 p 指针会指向该关键字的叶子结点 $,通过其自有的 info 指针找到该关键字。
if ( i == k.num){
return p->info;
}
else{
return NULL;
}
}
Trie树(字典树)
对于Trie树更详细的介绍:小白详解 Trie 树。这篇文章写得很细致,如果没有耐心看的话,最好也要浏览一遍,做个了解
若以树的多重链表表示键树,则树中如同双链树一样,会含有两种结点:
- 分支结点:含有 d 个指针域和一个整数域(记录非空指针域的个数(可选));
- 叶子结点:含有关键字域(完整的关键字、可选)和指向该关键字的指针域(可选);
d 表示每个结点中存储的关键字的所有可能情况,如果存储的关键字为数字,则 d= 11(0—9,以及 $),同理,如果存储的关键字为字母,则 d=27(26个字母加上结束符 $)。
实际实现的时候,一般都偷懒,只包含那d个指针域。
如下图:
在标准Trie树的基础上,可以压缩:若从键树中某个结点到叶子结点的路径上每个结点都只有一个孩子,则可将该路径上的所有结点压缩成一个叶子结点。如下图所示:
Trie树查找功能的具体实现
使用 Trie 树进行查找时,从根结点出发,沿和对应关键字中的值相对应的指针逐层向下走,一直到叶子结点,如果全部对应相等,则查找成功;反之,则查找失败。
具体实现的代码(来自百度):
typedef enum{LEFT,BRANCH}NodeKind;//定义结点类型
typedef struct {//定义存储关键字的数组
char a[20];
int num;
}KeysType;
//定义结点结构
typedef struct TrieNode{
NodeKind kind;//结点类型
union{
struct { KeysType k; struct TrieNode *infoptr; }lf;//叶子结点
struct{ struct TrieNode *ptr[27]; int num; }bh;//分支结点
};
}*TrieTree;
//求字符 a 在字母表中的位置
int ord(char a){
int b = a - 'A'+1;
return b;
}
//查找函数
TrieTree SearchTrie(TrieTree T, KeysType K){
int i=0;
TrieTree p = T;
while (i < K.num){
if (p && p->kind==BRANCH && p->bh.ptr[ord(K.a[i])]){
i++;
p = p->bh.ptr[ord(K.a[i])];
}
else{
break;
}
}
if (p){
return p->lf.infoptr;
}
return p;
}
延伸阅读:中文Trie树
摘抄自:http://hxraid.iteye.com/blog/618962
由于中文的字远比英文的26个字母多的多。因此对于trie树的内部结点,不可能用一个26的数组来存储指针。如果每个结点都开辟几万个中国字的指针空间。估计内存要爆了,就连磁盘也消耗很大。
一般我们采取这样种措施:
- 以词语中相同的第一个字为根组成一棵树。这样的话,一个中文词汇的集合就可以构成一片Trie森林。这篇森林都存储在磁盘上。森林的root中的字和root所在磁盘的位置都记录在一张以Unicode码值排序的有序字表中。字表可以存放在内存里。
- 内部结点的指针用可变长数组存储。
特点:由于中文词语很少操作4个字的,因此Trie树的高度不长。查找的时间主要耗费在内部结点指针的查找。因此将这项指向字的指针按照字的Unicode码值排序,然后加载进内存以后通过二分查找能够提高效率。
补充:我觉得对于字典这种应用,改动会很小的,真的可以内存中Trie树+二分查找搞定。