在我看来,看源码是一件既痛苦又兴奋的事。当我们在推敲其中的难点时,是及其痛苦的,但当发现实现代码是那么丝滑简洁时,“wc, nb!”。
1. 导语
如果我们去看关联式容器map、set、multimap、multiset源代码,我们发现绝大部分操作如插入、修改、删除、搜索,均是由其内含的红黑树来完成的,我们有必要去揭开她的神秘面纱,一览她的绝世风姿。
(如果你手头还没有《STL源码剖析》时,强烈建议你现在就去买一本or文末的百度云链接or网路上的其他资源)
关键词:RB-tree、BST、AVL tree 、STL Sources
从哪里讲起呢?
二叉搜索树,每个节点最多有两个子节点,而每个节点键值一定大于左子树键值节点键值,而小于右子树节点键值。这样一来,就可以提供对数时间的插入和搜索。
当然,较为复杂的是它的删除操作。
1.如果删除的是叶子,那么直接删除delete该指针即可;
2. 如果删除的不是叶子,而他只有一个节点,那么就将其子节点连至它的父节点;
3. 如果删除的不是叶子,而他有两个节点,那么就用它的右子树的最小节点代替他(值赋给它),删除该右子树最小节点。
(这是为什么?因为删除之后还要保证二叉树的搜索,所以替代他的元素就需要是比他大的下一个元素,而比他大的元素都在右子树,且最小的哪一个就是最左边的那一个。其实在二叉搜索树中,要找最大就一直向右子树找,要找最小就一直往左子树找)
上述例子是在二叉树平衡的情况下进行的。平衡即左右子树高度相近,完全平衡则要求左右子树高度(深度/层数)完全相等。要是完全平衡的条件下,我们的搜索和插入操作就会是对数时间,这无疑是相当快的(当然比起Hashtable的大致O(1)来说是较慢的),这在关联式容器中是我们所追求的。因为它们的底层均不是线性结构,能达到常数时间的查找/搜索。
但是要知道,维护一个二叉树的完全平衡是非常耗时的,比如我插入之后,很大概率就会使得二叉树不完全平衡,就需要复杂度旋转移位操作,这对于插入来说非常不划算,也就是说,我们没必要为了平衡而平衡,只要达到大致平衡,就可以得到统计上的对数查找插入时间。
那么进入我们的正题,平衡二叉搜索树。
2. 平衡二叉搜索树
这里的平衡,是指没有任何节点的深度过大,而非绝对的平衡。
代表结构:RB-tree、AVL-tree、AA-tree
本片主要介绍红黑树,AVL-tree将在其他篇章中讲解。
3. RB-tree
红黑树,一种自平衡的二叉查找树。它的最坏情况运行时间也是良好的,并且在实践中是高效的,它可以在O(log n)时间内做查找,插入和删除。(来自百度百科)
无疑,它的实现是非常复杂的,搜索几乎是是它最为简单的操作,复杂度O(log n),最坏也是如此。而插入和删除就比较困难了,查找到插入节点/删除节点复杂度O(log n),在插入时、插入后、删除后都需要满足它的红黑规则限定,进行变色和旋转。
- 各个节点非黑即红
- 根节点黑
- 红色节点的子节点必须为黑
- 任一节点到null的路径所含黑色节点相等
隐含意思是,不能出现连续的父子红色节点,且新增节点必须为红色(如果是黑色,经过他那一条路径上的黑色节点不就多了一个吗?不符合规则4),其父节点必须为黑色。
对于AVL-tree的“左左、右右、左右、右左”的情况,我们需要在插入后,进行单旋转和双旋转来使得它的每个节点的左右子树深度相差不超过1,红黑树难度更甚。她不仅要进行单旋转和双旋转,旋转之后还要进行变色。
我无意把问题讲的极其复杂,数图以蔽之。
插入节点的旋转与变色
1. 树空,插入节点只要变为黑色即可;
2. 插入节点的父节点是黑色,不违反规则,不做旋转变色调整。
例:插入节点->70/85。
3. 插入节点在外侧,父节点是红色,“叔叔”是红色,违反规则,需要右旋和变色(插入节点)。
4. 插入节点在内侧,父节点是红色,“叔叔”是红色,违反规则,需要双旋转(先左旋。再右旋)和变色。
5. 插入节点在外侧,父节点是红色,“叔叔”是黑色,违反规则,需要右旋和变色(父节点和父父节点)。
(这里后来想想,应该是先左旋再右旋,最终使得99黑色成为根,90,100红分别为左右儿子,80黑为90红左儿子, 120黑为100红右儿子)
6. 插入节点在内侧,父节点是红色,“叔叔”是黑色,违反规则,需要右旋和变色(父节点和父父节点)
至此,红黑树的插入操作的旋转和变色,我们就讨论的差不多了。
下面,我们来看源码。
4. RB-tree 部分源码
红黑树节点有红黑二色,节点要访问父节点和子节点,所以不难想到节点的数据结构
所以节点模型如下(一种颜色三个指针,和vector三个指针一样简洁)
元素操作(主要介绍插入和搜索,后续的旋转和变色比较晦涩,上图已大致说明,自行参考源码消化)
搜索( O(logn) )
从root节点开始向下搜索,若节点值大于搜索值,走左子树,否则走右子树。用一个指针保存最后一个不小于给定值的节点指针,最后用它来判断是否查找到。最后的逻辑看的很迷,要么找到直接跳出返回就好,要么在最后判断保存的节点值与给定值相等与否,返回结果即可。要我实现,我不会这样做(大言不惭)。
插入(insert_unique, insert_equal)( O(logn) )
rb-tree提供两种插入操作,一种执行不重复插入,供map,set使用,还有一种允许重复插入,供multimap,multiset使用。
(8line, y = x)
(line3, 第二元素),这个插入函数与上面那个有较大不同,要排除重复插入的情况。先遍历找到插入点,即子节点为空,得到它的父节点y,如果父节点是最左节点,可以插入。如果不是最左节点,那么无法插入(因为要往父节点的左子节点插入,但是该点有节点),那么--迭代器,使得插入父节点下移,以期能够插入(到达最左节点,可以插入)。最后,还不能满足的话,则是有重复元素值,不插入。
更为底层的插入操作
看源码函数,最好自己画些例子,能更快的理解。其中我也有一些无法理解的地方,很迷,其中也不乏一些错误之处,欢迎指正。如果你有好的见解,欢迎留言讨论。