今天谈下技术。介绍一个蛮有趣的数据结构 Ternary Search Tree,我毕设里的关键代码就依赖于这个数据结构。
这个数据结构的思路 1960 年左右就有了,不过其实际使用价值一直被忽略了。Jon Bentley 和 Robert Sedgewick 在 1997 年发表的论文 Fast Algorithms for Sorting and Searching Strings 中利用这个数据结构实现了针对字符串的快速排序和查找算法,网页上除了论文以外还有实现和测试代码。后来他们又专门针对 Ternary Search Tree 发表了一篇文章,Dr. Dobb 上有在线版本。我是在 Robert Sedgewick 的《Algorithms in C, 3rd edition》中 Radix Search Tree 这一章看到这个数据结构的,这本书将 multi-way branching tries 和 TST 放在一起进行介绍,把 TST 作为 multi-way branching tries 的另一种表现形式来看待,另外也将 TST 和 digital search tries 进行了比较。这里只是对这个数据结构进行概述,详细内容还是看作者的论文去。
Ternary Search Tree 的主要应用场景如下:字符串作为 key,每个字符串的长度不定,需要一种支持动态集合操作的数据结构以便能够快速的通过 key 查找到其对应的 value。用 hash table 的话可以做到快速的搜索,但是有两个缺点,一是随着保存的字符串的增多,需要重新构建 hash table 以保证快速的查找,二是 hash table 中不维护 key 之间的顺序关系。使用平衡 Binary Search Tree (BST) 两个问题都能解决,但是查找的速度相对 hash table 变慢了。如果使用 digital search trie 的话查找虽然非常快,但是需要的内存空间太大。
Ternary Search Tree 结合了 BST 和 trie 两者的优点,利用它还可以很容易的实现 partial-match searching, near-neighbor searching 这样的字符串搜索操作。而且 TST 本身非常简单,实现起来也很容易。与 BST 相比,TST 中每个节点存放的不是整个 key,而只是 key 中的一个字符。而对 tries,只需要进行一点小的修改就得到了 TST。(用 trie 描述起来比较方便,如果不知道 tries 的话可以先看下 trie 是什么。)
- 把 tries 中的每个节点保存的 bit 换成一个字符
- 和 tries 一样,数据都保存在叶子节点
- tries 中每个节点最多有两个子节点,TST 有三个,分别称为 left, mid, right
插入 key 的时候,从树根作为当前节点开始(树为空的时候树根为 nil),一个字符一个字符的递归执行下面过程。
- 如果当前节点为 nil,则创建一个新的节点,将当前字符保存在节点中,将其设置为当前节点。
- 如果当前字符和当前节点保存的字符都为 ‘\0’,将数据存于当前节点,终止调用。
- 如果当前字符小于当前节点保存的字符,那么递归将当前字符插入节点的 left 子节点,大于则插入 right 子节点;相等则将下一个字符插入当前节点的 mid 子节点。
搜索的过程和插入类似,把 key 一个字符一个字符的和树中节点保存的字符进行比较,同样从树根开始递归执行。
- 如果当前节点为 nil,则查找失败。
- 如果当前字符比节点中的小,递归对 left 子节点查找,依然比较当前字符。
- 如果当前字符比节点中的大,递归对 right 子节点查找,依然比较当前字符。
- 如果当前字符与节点中的相等且都为 ‘\0’,则这个节点中就存着 key 对应的 value,终止调用。如果相等但是不是 ‘\0’,则递归对 mid 子节点查找,比较字符为下一个字符。
对 TST 性能的最坏情况分析在 “The Analysis of Hybrid Trie Structures” 这篇论文中,不过这篇论文下不到。对失败的查找,字符比较次数往往远小于其中包含的字符个数。使用 hash table 的话仅计算 hash 值就需要访问 key 中的所有字符(比如这里的 string hashing function),而且得到 hash value 之后还得检查 search key 与实际保存的 key 是否相等。所以 TST 的不成功的搜索比 hash table 更快。
论文的两位作者提供的 C 源代码里给出了迭代的插入和搜索的实现。对插入还作了优化,通过预先分配内存池的办法避免了内存分配成为性能瓶颈。对于数据的保存使用了一点 hack,通过 type cast 直接用叶节点的 mid 指针保存 value。因为 char 默认为 unsigned (至少 gcc 如此),所以保存 ‘\0’ 的节点的 mid 和 left 肯定不会指向节点(right 节点可能指向树的节点),所以可以利用他们来保存 value。示例代码直接将 key 保存在叶节点中,但实际上 key 保不保存都没关系,除非以后要用到。对示例代码还有一点要说明的是递归版本和迭代版本有一点小差别,递归版本在实现遇到相同的 key 的时候会将新 value 的存入,而迭代的版本则不会。
对于 partial-match search, near-neighbor search 我就不罗嗦了,论文作者肯定比我说的清楚多了。我毕设里面使用的 search 也是标准 search 的一个小变种。我的 TST 中的 key 是文件系统的路径(假设文件系统用 UTF-8 编码,所以树的节点存放 char 就 OK 了),给定一个 search key (path),如果 TST 中存有 search path 的祖先目录,我希望能够找到与之距离最近的祖先目录。用 TST 来实现这个操作也是非常容易的,有兴趣的可以自己想一下 :–)
TST 也有可以改进的地方。插入过程中,往往到了末尾的字符的时候树种的每一个节点都只有一个 mid 字节点,也就是所谓的 one-way branching,这导致了使用多余的空间,而且对于成功的搜索速度也不一定是最优的。一种改进办法是在接近字符串的尾部使用 multi-way branching 来减少需要的 node,不过怎样确定在什么地方开始 multi-way branching 比较麻烦,而且还要处理多种节点类型的情况。另一种方法是使用 PATRICIA 的思想,在每个节点上再存一个索引,表示其存放的字符是 key 中的第几个字符,比较的时候直接将其与 key 中的特定字符进行比较而不是一个一个的往下比较。这样一来减少了使用的节点,二来对成功的查找,如果保存着的两个 key 之间有几个连续的相同字符就可以跳过这些字符,直接跳到能够区分出这两个不同字符串的字符位置。但这样要求树中也保存 key,在找到节点之后还不要比较一下 search key 和 TST 中保存的 key 是否相同才能决定是否是成功的查找。
发现描述数据结构算法之类的东西还是挺麻烦的,看别人的文章的时候不觉得,自己写的时候就发现难了,也不知道写的能不能让人明白了。欢迎提出建议 :–)