为什么红黑树如此受欢迎?


46

在我看来,似乎到处都在使用红黑树(std::set在C ++,SortedDictionaryC#等中)实现数据结构。

在我的算法课程中刚刚覆盖了(a,b),红黑和AVL树之后,这就是我的收获(也是从问问教授,翻阅几本书并仔细研究一下)得出的:

  • AVL树的平均深度小于红黑树,因此在AVL树中搜索值的速度始终更快。
  • 与AVL树相比,红黑树进行结构调整以平衡自身的可能性要小一些,这可能会使它们的插入/删除速度更快。我可能会说,因为这将取决于对树的结构更改的成本,因为这将在很大程度上取决于运行时和隐含(当树是不可变的时,功能语言是否也可能完全不同?)

网上有很多基准可以比较AVL和红黑树,但令我惊讶的是,我的教授基本上说过,通常您会做以下两件事之一:

  • 要么您根本就不在乎性能,在这种情况下,大多数情况下AVL与Red-black的10-20%的差异根本不重要。
  • 或者,您真的很在乎性能,在这种情况下,您会抛弃AVL树和红黑树,并选择B树,可以对B树进行调整以使其工作得更好(或(a,b)树),我将所有这些都放在一个篮子里。)

这样做的原因是因为B树将数据更紧凑地存储在内存中(一个节点包含许多值),因此缓存未命中的情况将大大减少。您还可以根据用例来调整实现,并使B树的顺序取决于CPU缓存大小等。

问题在于,我几乎找不到任何可以分析现实硬件上不同实现的搜索树实际使用情况的资源。我浏览过许多有关算法的书,但没有发现可以将不同的树变体进行比较的任何东西,除了表明一棵树的平均深度比另一棵树小(这并没有真正说明树的行为方式)在实际程序中。)

话虽这么说,是否有一个特定的原因为什么到处都使用红黑树,而根据上述内容,B树应该胜过它们?(作为唯一的基准测试,我还可以看到http://lh3lh3.users.sourceforge.net/udb.shtml,但这可能只是具体实现的问题)。还是为什么每个人都使用Red-black树是因为它们很容易实现,或者换句话说,很难实现不好呢?

另外,当人们进入功能语言领域时,这将如何改变?似乎Clojure和Scala都使用Hash数组映射的trys,其中Clojure使用32的分支因子。


8
更令人费解的是,大多数比较不同种类搜索树的文章的执行效果都不理想。
拉斐尔

1
我自己从来都不了解这一点,在我看来,AVL树比红黑树(重新平衡的情况更少)更易于实现,并且我从来没有注意到性能上的显着差异。
Jordi Vermeulen,2015年

3
我们的朋友在stackoverflow上进行的相关讨论为什么将std :: map实现为一棵红黑树?
Hendrik

Answers:


10

引用“ 从AVL树和红黑树的根中遍历 ”问题的答案

对于某些种类的二叉搜索树,包括红黑树而不是AVL树,可以很容易地在下降的途中预测树的“修复”,并在一次自上而下的遍历中执行,从而无需进行第二遍。这种插入算法通常是通过循环而不是递归实现的,并且在实践中通常比两次通过算法运行得更快。

因此,RedBlack树插入无需递归即可实现,在某些 CPU上,如果您超出了函数调用缓存,则递归非常昂贵(例如,由于使用了Register窗口而导致的SPARC

(通过删除一个函数调用,我已经看到软件在Sparc上的运行速度快了10倍,这导致通常称为代码路径的深度对于寄存器窗口而言太深。因为您不知道寄存器窗口的深度如何?客户的系统,而且您不知道您在“热代码路径”中的调用堆栈有多远,因此不使用递归就更容易预测。)

同样,不冒用尽堆栈的风险也是有益的。


但是具有2 ^ 32个节点的平衡树将需要不超过约32个级别的递归。即使您的堆栈帧为64字节,也不会超过2 kb的堆栈空间。这真的可以有所作为吗?我会怀疑。
比约恩·林德奎斯特(BjörnLindqvist)

@BjörnLindqvist,在1990年代的SPARC处理器上,我通常通过将公共代码路径从7的堆栈深度更改为6来将速度提高10倍以上!阅读上它是如何注册文件....
伊恩林格罗塞

9

我最近也一直在研究这个主题,所以这是我的发现,但请记住,我不是数据结构专家!

在某些情况下,您根本无法使用B树。

std::mapC ++ STL 是一个突出的例子。该标准要求insert不使现有迭代器无效

没有迭代器或引用无效。

http://en.cppreference.com/w/cpp/container/map/insert

由于插入会在现有元素周围移动,因此将B树排除在实现之外。

另一个类似的用例是侵入式数据结构。也就是说,不是在树的节点内存储数据,而是在结构内存储指向子项/父项的指针:

// non intrusive
struct Node<T> {
    T value;
    Node<T> *left;
    Node<T> *right;
};
using WalrusList = Node<Walrus>;

// intrusive
struct Walrus {
    // Tree part
    Walrus *left;
    Walrus *right;

    // Object part
    int age;
    Food[4] stomach;
};

您不能使B树成为侵入式的,因为它不是仅指针的数据结构。

侵入式红黑树,例如,在jemalloc中用于管理空闲的内存块。这也是Linux内核中流行的数据结构。

我还相信“单遍尾递归”实现并不是红黑树作为可变数据结构流行的原因。

首先,这里的堆栈深度是无关紧要的,因为(给定高度),在堆栈空间用完之前,您会先耗尽主内存。Jemalloc很高兴在堆栈上预分配最坏情况深度。logn

红黑树实现有多种风格。著名的是Robert Sedgewick留下的左倾红黑树(注意!还有其他变体,也被称为“左倾”,但使用不同的算法)。这个变体确实允许在树的下方执行旋转,但是它缺乏 摊销数量的重要属性,这使其变慢了(由jemalloc的作者测量)。或者,如opendatastrutures所说的那样O(1)

与此处定义的RedBlackTree结构相比,Andersson的红黑树变体,Sedgewick的红黑树变体和AVL树都易于实现。不幸的是,它们都不能保证每次更新花费的重新平衡摊销时间为。O(1)

opendatastructures中描述的变体使用父指针,用于插入的递归向下传递和用于修正的迭代循环向上传递。递归调用位于尾部,编译器将其优化为循环(我已在Rust中进行了检查)。

也就是说,如果使用父指针,则可以得到可变搜索树的恒定内存循环实现,而无需任何红黑魔术。这也适用于B树。您需要魔术来实现单遍尾部递归不可变变体,无论如何它将破坏修正。O(1)


3

好吧,这不是权威性的答案,但是每当我不得不编写平衡的二进制搜索树时,它就是一头红黑的树。这有几个原因:

1)红黑树的平均插入成本是恒定的(如果不必搜索),而AVL树的对数是对数的。此外,它最多涉及一个复杂的重组。在最坏的情况下,它仍然是O(log N),但这只是简单的重新着色。

2)他们每个节点仅需要1位额外信息,您通常可以找到一种免费获取信息的方法。

3)我不必经常这样做,因此每次我都要弄清楚如何重新做一遍。简单的规则和与2-4棵树的对应关系使每次看起来都很容易,即使代码每次都变得很复杂。我仍然希望有一天代码会变得简单。

4)仅通过重新着色,红黑树拆分相应的2-4树节点并将中间键插入父2-4节点的方式非常优雅。我只是喜欢这样做。


0

当密钥很长或出于其他原因移动密钥很昂贵时,红黑树或AVL树比B树等具有优势。

std::set由于多种性能原因,我在大型项目中创建了自己的替代方案。出于性能方面的考虑,我选择了AVL而不是红黑的(但性能的小提升并不是我自己而不是std :: set滚动的理由)。“钥匙”复杂且难以移动是一个重要因素。如果您需要在键之前进行另一层间接访问,(a,b)树仍然有意义吗?AVL和红黑树可以在不移动键的情况下进行重组,因此,在键的移动成本很高时,它们具有这一优势。


具有讽刺意味的是,红黑树仅是(a,b)树的特例,因此问题似乎可以归结为参数的调整吗?(cc @Gilles)
拉斐尔
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.