基数树的空间复杂度是多少?

Posted

技术标签:

【中文标题】基数树的空间复杂度是多少?【英文标题】:What's the space complexity of a radix tree? 【发布时间】:2014-01-01 14:51:26 【问题描述】:

我一直关心基数树的空间使用情况,但我没有找到任何有用的讨论。

现在假设我们有一个与 linux radix-tree.c 相同的基数树实现,它采用一个整数并使用每 6 位来索引树中的下一个位置。我可以很容易地想到基数树的空间使用量远远超过二叉搜索树的情况。如果我错了,请纠正我:

用例:(0,1,1,1,1), (1,1,1,1,1), (2,1,1,1,1), ... (63,1, 1,1,1)。

这里为了方便起见,我使用 (a,b,c,d,e) 来表示一个 30 位整数键,每个元素代表一个 6 位值。 a 是 MSB,e 是 LSB。

基数树:

对于这个用例,基数树的高度为 5,每个键将占用 4 个单独的节点,因为它们位于根的不同子树上。所以会有 ((5-1) * 64 + 1) = 257 个节点。

每个节点包含 2^6 = 64 个指针,因此它将使用 257 * 64 * 4Byte = 65KB

二叉搜索树

我们只关心有多少键。在这种情况下,它有 64 个键。

假设每个 BST 节点每个节点使用 3 个指针,它将使用 64 * 3 * 4Byte = 768 字节。

比较

看起来基数树的空间效率很低。给定相同数量的节点,它使用的空间是二叉搜索树的 100 倍!我不明白为什么即使在linux内核中也使用它。

我错过了什么吗?谢谢。

【问题讨论】:

【参考方案1】:

您要求空间复杂度,所以让我们来解决。

如果我们认为叶子上的非空指针是一个感兴趣的值,那么不难证明最坏的情况是一个完全填充的树,每个叶子节点都有一个值。

如果分支是 N 路(在您的用例 64 中)并且高度是 H(在您的用例 5 中),则此树中有 N^(H-1) 个叶节点,存储相同数量的值。节点总数为

1 + N + N^2 + ... N^(H-1) = (N^H - 1) / (N-1)

所以以指针衡量的存储需求是这个数量的 N 倍。

(N^H - 1)  [N / (N-1)]  

这样产生的存储效率为

(N^H - 1)  [N / (N-1)]  
--------------------
       N^(H-1)

这是指针总数除以有效数据指针的计数。

随着 N 变大,这会接近 N。在您的示例用例中,它实际上是 65.01(对于 N=64)。所以我们可以说存储复杂度是 O(NV) 其中 V 是要存储的数据值的数量。

虽然我们通过第一性原理分析来到这里,但它完全有道理。完整树的叶级存储比其余存储高出近 N 倍。该存储的大小为 NV。

当然,像这样具有巨大分支因子的树(以及例如数据库中的 B 树)的优势在于到达正确的叶子需要更少的节点遍历。

此外,当每次遍历都是单个数组查找时,就像在基数树中一样,您无法获得更快的速度。

在您的用例中,完美平衡的二叉搜索树将需要多达 30 次比较,伴随的分支会刷新管道。这与 5 个数组索引操作相比可能要慢得多。数组索引往往比比较更快,因为它是非分支代码。但即使它们相同,二叉树也只需要 2^5=32 个元素即可产生与包含 2^30 个元素的基数树相同的索引工作量。

为了概括这一点,如果键比较和数组索引操作具有相同的成本,则 2^H 元素的二叉树将需要与能够保存 N^(H-1) 个元素的索引树相同的查找工作。

正如其他人所说,如果树的顶层的索引位倾向于几个公共前缀(即它们是同一 VM 空间地址的最高位),则基数树的最坏情况存储行为不会发生。

【讨论】:

空间复杂度应该是一个以键数为变量的函数。在您的情况下,我了解您将节点总数计算为 ((N^H - 1) * [N / (N-1)])/N^(H-1),但 N 是 a 的分支数基数树,应该是一个给定的常数。重要的是您评论中的变量 V 对吗?我不明白您是如何得出空间复杂度与 V 成正比的结论的。您能澄清一下吗? 复杂性用问题中存在的任何相关自变量来表示。如果您选择将 N 设为常数,那么 big-O 会将其从问题中删除,并且您有 O(V),这无济于事。上面解释了对 V 的依赖性。最坏的情况是每个叶节点有一个值。 O(NV) 表示在最坏的情况下,您将使用大约 N 倍于索引的存储作为值(非空指针)本身的存储。更精确的表达是“随着 N 变大而接近 NV”。没有隐藏的常数因子。【参考方案2】:

Linux 中的基数树最初是作为支持页面缓存的数据结构出现的,其中键(文件偏移)的这种分布并不常见。

(FWIW,最初的变种使用了张开树,但 Linus 拒绝了 :)

基数树又宽又浅,因此在其中查找会访问相对较少的不同缓存行,这显然对性能非常有利。

它还有一个性质,就是page cache访问的locality是radix tree中的locality 节点访问,不像哈希表等替代设计。

【讨论】:

所以在使用基数树之前,我们总是需要假设键将大部分共享相同的前缀?所以听起来基数树不太像通用容器。 @Jun,好吧,就像任何数据结构一样,基数树有其优点和缺点。没有普遍适用的“最佳容器”,根据具体需求,一个或另一个可能是最合适的。这并不罕见,例如基数或计数排序也可能不是“最佳”排序方法,但最适合具体用法。或者在磁盘存储的情况下,通常认为 B 树比二叉树更可取。甚至对于内存容器,检查code.google.com/p/cpp-btree。【参考方案3】:

基数树用于保存带有公共/共享前缀的长字符串。在这种情况下,基数树会更经济。

对于您指定的数据类型是另一回事。

编辑

带有前缀的长字符串的一个很好的例子是在您的计算机上存储所有带有完整路径的文件名。有了这样的数据,它将比其他方法更经济,并且可以非常快速地查找文件名是否存在。在某些情况下甚至可能比哈希表更快。

看看这两个文件:

"c:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include\streambuf" "c:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include\string"

它们的共享前缀是: "c:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include\str",只存储一次。

【讨论】:

任意字符串在这种情况下没有任何区别。您可以将我发布的用例转换为“aaaaa”、“bbbbb”、...“zzzzz”,它仍然会比 BST 占用更多的空间。但我同意,如果用例共享很多前缀,它会变得更好。 通过示例查看更新的答案。顺便说一句,“aaaaa”和“bbbbb”没有共同的前缀。 我明白你在说什么。但总的来说,我们不能确保键具有共同的前缀。我说的是任意情况,其中键可以是任何值,并且它的可能性均匀分布在所有可能的值中。 @Jun - 然后使用不同的容器,例如 map/set 或 unordered_map/set

以上是关于基数树的空间复杂度是多少?的主要内容,如果未能解决你的问题,请参考以下文章

为啥基数排序的空间复杂度为 O(k + n)?

基数排序与基数排序

基数计数——HyperLogLog

算法——基数排序

十大排序总结(js实现稳定性内外部排序区别时间空间复杂度冒泡快速直接选择堆直接插入希尔桶基数归并计数排序)

各个排序算法的时间复杂度和空间复杂度