C++学习记录(16)红黑树
前言
在前面我们见识过了AVL控制平衡的方法,也就是借助平衡因子,如果平衡因子不对劲就进行旋转。但是实践中人们更多使用红黑树来作为平衡二叉树的典例来使用,可是为什么是这样的呢?只有学习了以后才能知道问题到底出在哪里。
一、红黑树的概念
红黑树是一棵平衡二叉搜索树,每个结点都有颜色的存在,而颜色只能是红色或黑色,通过严格控制结点的颜色即可实现最长路径不超过最短路径的两倍,从而间接的实现平衡。
二、红黑树的规则
- 每个结点不是黑色就是红色
- 根结点是黑色的
- 如果一个结点是红色的,那么它的两个孩子结点如果存在就必须为黑色的,即红黑树任意一条路径不允许红色结点连续存在
- 从任意一个结点出发到所有NULL结点的路径上黑色结点的数量相同
结合这几条规则,很容易推导出来:
极端情况下
从根结点出发(黑色结点)最短的一条路径全部都是黑色结点,将其设为hb;
从根结点出发(黑色结点)最长的一条路径一定是黑-红-黑-红.....-黑-红,也就是一个黑色结点一个红色结点交叉组成,不难想到,由于从一个结点出发的到NULL结点的所有路径的黑色结点相同,那么最长路径这种交叉的情况的长度一定就是2hb,因为有一个黑就一个红才是最长路径。
我已经说了极端情况下,不要说什么玩意全黑路径不存在呢?万一黑红黑红路径不存在呢?理论上肯定是可以存在的。
比如:
就这图,你也不用给我急着去找最短和最长路径,我就问你根据红黑树的规则,从根结点到NULL结点一共有多少个路径。
相信如果不仔细想,直接想当然:
一问就说4个路径,问题是规则写的好好的是从任意一个结点到NULL结点,我跟你说的也是从根结点到NULL结点的路径,但是你还是眼睁睁的去数了从根结点到叶子结点的所有路径,因为我自己也是这么数的,到叶子结点到空了吗?
直接显式写NULL结点:
你拿着这图你总不能给我搞错了吧:
一共9条路径,明显最短路径就是最左边的那条,当然,NULL结点就是方便你看路径的,肯定不能算上,那么现在很明显最短路径只有两个黑色结点。
而最长路径就是最右边的那条,黑红相间,刚好是最短路径的两倍。
举这个例子也没啥其它的意思,主要是见识见识,不然光说最长路径理论上是最短路径的两倍这个事太抽象了。
而且根据这个图还可以验证验证红黑树那几条规则。
设最短路径为bh,则最长路径为2bh,可知:
bh<= h <= 2bh
因为很明显,最短就得是全黑路径走完,最长就是黑红路径走完,其它路径的长度不可能超过这个范围,则红黑树的理论高度就是介于理论长度之间。
三、红黑树的效率
关注查找树的效率其实也就是关注树的高度,而根据上面的分析,容易得知,如果红黑树结点个数为N个,最短路径高度为h,那么一定有:
- 1<= N <
- 1
基本上h就是logN,因此对于红黑树的查找效率很明显也是logN。
单单这么看其实感觉红黑树也没比AVL树强到哪里去啊,那咋地还说红黑树应用的多,用的多不应该更高效吗?
因为我们这里是时间复杂度估算,也看不出来啥,所以还得继续看下去。
四、红黑树的结构
直接给出:
enum Color
{RED,BLACK
};
template <class K,class V>
struct RBTreeNode
{RBTreeNode(const pair<K, V>& kv):_kv(kv),_left(nullptr),_right(nullptr),_parent(nullptr){}pair<K, V> _kv;struct RBTreeNode* _left;struct RBTreeNode* _right;struct RBTreeNode* _parent;Color _color;
};
pair和三个指针就不解释了。
至于红黑树调节平衡的方式,我已经说了,就是严格遵守那几个规则,所以必须能够存储下来它的这个特性(类似于AVL树中的_bf),引用就非常适用于这种场景,红黑树结点的颜色不是黑就是红,所以枚举只有两个可能值,红或者黑。
至于为什么不给_color初始化,主要是考虑来考虑去其实也不知道默认搞成啥合适,毕竟如果是AVL树,一个新结点肯定是叶子结点,_bf天然就是0。
五、红黑树的插入
刚开始的根基肯定还是严格遵守二叉排序树的插入,至于违不违反平衡的规则,肯定是插入后考虑的,直接给出来了:
template <class K,class V>
class RBTree
{typedef struct RBTreeNode<K, V> Node;
public:bool Insert(const pair<K, V>& kv){if (_root == nullptr){_root = new Node(kv);_root->_color = BLACK;return true;}Node* cur = _root;Node* parent = nullptr;while (cur){if (kv.first < cur->_kv.first){parent = cur;cur = cur->_left;}else if (kv.first > cur->_kv.first){parent = cur;cur = cur->_right;}elsereturn false;}cur = new Node(kv);if (cur->_kv.first < parent->_kv.first)parent->_left = cur;elseparent->_right = cur;cur->_parent = parent;}private:Node* _root = nullptr;
};
这点玩意还是老生常谈,重点还是要看插入以后如何通过维护红黑结点维护平衡。
1.插入的结点颜色
new完Node可没有给新结点颜色,那么到底该给什么颜色呢?
既然不知道就做假设,当然,先把规则拉过来:
- 每个结点不是黑色就是红色
- 根结点是黑色的
- 如果一个结点是红色的,那么它的两个孩子结点如果存在就必须为黑色的,即红黑树任意一条路径不允许红色结点连续存在
- 从任意一个结点出发到所有NULL结点的路径上黑色结点的数量相同
可以知道,由于枚举类型设置好了结点的颜色取值,只可能是黑色或者红色;插入根结点我们已经设置好就是黑色,现在讨论的就是插入叶子结点的情况。
因此由以上分析,插入叶子结点时规则1 2绝对不会被破坏
假设插入的结点为黑色
如果是黑色,不会违反规则3,因为不管往黑色上挂黑色结点和往红色上挂黑色结点,都不会出现连续的红色结点。
但是一定会违反规则4,看图:
如果顺着18->30->25->23->NULL或者18->30->40->50->56->NULL
那么很明显这两条路径黑色结点个数是3,总共几条路径呢?
一共11条,有4条是3个黑色结点,其它肯定还是2个黑色结点,这种插入以后需要改7条路径。
其实也不难想到,假设原来一共有n条路径,每条路径有N个黑色结点,那么插入一个结点也就是新增一个叶子结点后路径变为n+1条,而其中n-1条是N个黑色结点,2条是N+1个黑色结点,选简单的修改就需要修改2条路径的颜色。
假设插入的结点为红色
如果是红色规则4明显就不会违背了,因为插入红色不会让路径上增加黑色路径,原来相等的插入红色结点以后肯定依然相等。
但是一旦插入的是红色结点,那么如果它父亲结点是黑色那没啥事,黑-红是允许的;如果它的父亲结点是红色那就需要修改来维持平衡,因为违反规则3,红色结点的孩子不允许是红色结点,或者直接说红色结点的父亲不能是红色结点。
比如,看图:
插入23不犯啥毛病,插入56你可就是不懂事了,因为这样岂不是红-红,违反规则3了,但是这个插入只影响了:
其实其它地方根本没有被影响,插入红色结点其实对整棵树的影响很小。
结论
虽然有点草率,但是直接说了,红黑树插入结点的时候要求插入红色,如果不符合要求那就再调整。毕竟人家是观察研究出来的,我们现在基本上就是个用,但是大概通过上面的例子能感觉出来其实插入红色结点影响确实小一点,当然,其实咋说呢,主要是研究出来了插入红色结点以后如果调整就能使其符合红黑树规则,假如你研究出来了插入黑色结点以后如果让那2条路和那n-1条路的黑色结点数量相同,你硬要插入黑色结点也没啥事。不过肯定还有时间复杂度的对比,就跟堆排序向上调整建堆和向下调整建堆一样。
说这么多,还是因为我们是数据结构的学习者、使用者,人家研究好了,没有精力去研究反驳的情况下只能老老实实用。
cur = new Node(kv);cur->_color = RED;
new完就定下来结点颜色。
2.插入结点以后的调整
经过上面插入结点我们容易知道,大类上可以这么分类:
插入结点的父亲结点是红色->继续调整
插入结点的父亲结点是黑色->符合规则,插入结束
插入的是红色结点该怎么调整?
直接给出结论:
研究插入结点(cur,下面简称c),插入结点的父亲结点(parent,下面简称p),插入结点父亲结点的父亲结点(grandfather,下面简称g),插入结点父亲节点的兄弟结点(uncle。下面简称u)。
比如:
明确前提条件
再次明确昂,走到这里我们已经处理好了插入结点就是根结点的情况了,所以插入的一定是叶子结点,那么c一定存在不用说,p也是一定存在;但是g和u可能不存在。
不仅如此,由于插入结点是红色,往黑色结点上插入就不需要调整,我们排除在外了,所以现在一定是往红色结点上插入叶子结点,从而造成的调整。
如果g不存在,那么只可能是往根结点上插入红色结点,而根结点是黑色结点,显然一定会被往黑色结点上插入红色结点拦截,因此到这里的话还必须g存在。
等于只有往红色结点上插入红色的结点(叶子结点)才需要调整,此时c p g一定存在。u不一定存在。
而如何调整呢?
余下情况的分类就是基于u的情况:
u不存在
u存在且为红
u存在且为黑
当然,根据人家的研究,如果写代码就是:
c,p(红),g存在(黑)(前提条件),u存在且为红
解决方法:变色
看处理方式昂:
有非抽象图就看非抽象图,真不行我可就上抽象图了。
既然p-c是红-红,插入结点不是必须为红嘛,我们已经分析过了,所以p->黑:
但是这样一旦遇到p不就多了个黑结点嘛,所以这个时候的操作,注意看昂:
既然u存在且为红,那么直接把u变黑,这样由g所在的子树肯定每个分支都多一个黑色结点,那直接把根变红,这样至少:
这棵子树没毛病了。
当然,很明显看到这样变色,又造成了红-红,就是例子里的30-40,但是吧,这是好事啊,为啥说是好事呢?
如果这样一直把问题往上调整,总能遍历到根结点附近吧,到时候再处理一下不就完了,就跟啥一样吧,堆插入一个数据,极端情况下会影响那棵子树,那就不断把矛盾上移上移直至到根结点,全部捋顺了不就完了。
迭代的话就是c = p,把p当成新的c继续去上面调整,由c可以找到新的p、g、u,当然,存在不存在另说。
多嘴几句昂,这个u的寻找还得讨论一下,因为c找p不用管啥方向,直接顺着_parent成员就行了,p找g同理,但是g找u就得看看p是g的左还是右,因为这关系到u是g的左还是右。
c,p(红),g存在(黑)(前提条件),u存在且为红的迭代分析
如果c不是新插入的结点,说明就得发生循环,那么现在肯定是c = g:
此时分析,如果c无p:
你就是根那还说啥,直接cur->_color = BLACK
如果c有p,那么p可能为黑可能为红,如果p是黑那循环又结束了;如果p是红那么又进入这种情况的检查了,但是g一定存在吗,还真不好说。
如果不存在:
你要注意啊,如果p的p不存在也就是g不存在的话,那么很明显不对啊,按照这么说p一定是根结点,咱又没有动过它,它咋是红的。
因此g不存在时,p必定为黑,要是p不为黑,那直接炸缸了,说明你之前的插入就错了;
g存在的话还得g必为黑,不然又不符合红黑树规则:
然后就又是进入红-红的判断。
只不过这次很明显昂,u是黑,那就是另外的事了,先把我们分析完的情况敲一下代码:
while (parent){//新插入结点必定是叶子结点//p为黑c为红,不用继续调整了if (parent->_color == BLACK)break;//如果p是红,那么g一定存在且一定为黑//初始情况下是这样的情况//即使迭代起来也是else if (parent->_color == RED){ Node* grandfather = parent->_parent;Node* uncle = nullptr;//parent与uncle相对位置不知道,不能武断if (parent == grandfather->_left)// g// p uuncle = grandfather->_right;else// g// u puncle = grandfather->_left;if (uncle->_color == RED){parent->_color = BLACK;uncle->_color = BLACK;grandfather->_color = RED; // g(r)// p(b) u(b)// c(r)cur = grandfather;parent = cur->_parent;if (parent == nullptr)_root->_color = BLACK;}}
补充下,循环条件是我这么想的:
如果循环中c的p不存在,一定是往上遍历到根了,这个时候cur是红,给它改黑,循环就应该结束了。
然后正写上面那句话的时候,我又想到了一个优化,既然黑进来还得break,那为什么不:
while (parent&& parent->_color == RED)//p为黑c为红,不用继续调整了{//新插入结点必定是叶子结点//如果p是红,那么g一定存在且一定为黑//初始情况下是这样的情况//即使迭代起来也是 Node* grandfather = parent->_parent;Node* uncle = nullptr;//parent与uncle相对位置不知道,不能武断if (parent == grandfather->_left)// g// p uuncle = grandfather->_right;else// g// u puncle = grandfather->_left;if (uncle->_color == RED){parent->_color = BLACK;uncle->_color = BLACK;grandfather->_color = RED; // g(r)// p(b) u(b)// c(r)cur = grandfather;parent = cur->_parent;if (parent == nullptr)_root->_color = BLACK;}}
如果parent不存在,说明遍历到根了,那么还循环啥啊;
如果parent存在,但是它就是黑,那也不用管了,不进循环直接结束了。
当然,以上代码基于u红写的,还有两种情况。
c,p(红),g存在(黑)(前提条件),u存在且为黑+u不存在
别问我为啥这俩一个逻辑的代码就能解决,也不是我研究出来的,我们只需要研究研究这种情况下代码咋写的。
先分析分析u不存在
有这样一个典例,那么很明显,如果还想把g变黑,u变黑,p变红来分担路径上的黑结点不行,我也不啰嗦,这里直接旋转就行了:
至于怎么旋,很明显取决于pgc三者相对位置,2*2一共4种旋转方式,而且不难知道正好对应着我们学过的四种旋转:
画的有点潦草啊,大概就这意思。
当然,旋转以后肯定得变变色,这里的旋转是为了让g位置既有左孩子又有右孩子,就可以分担黑色到分支上了。
图也画好了,到时候咋分配颜色就知道了,不难发现,如果是单旋,那就让p是黑,c和g搞成红就行;如果是双旋,那就让c是黑,p和g搞成红就行。
至于旋转的逻辑还是一样的:
void RotateR(Node* parent){Node* subL = parent->_left;Node* subLR = subL->_right;//parent subL subLR//childparent->_left = subLR;subL->_right = parent;//parentsubL->_parent = parent->_parent;if (subLR)subLR->_parent = parent;parent->_parent = subL;//_root?if (subL->_parent == nullptr)_root = subL;//left or right?else if (subL->_kv.first < subL->_parent->_kv.first)subL->_parent->_left = subL;elsesubL->_parent->_right = subL;}void RotateL(Node* parent){Node* subR = parent->_right;Node* subRL = subR->_left;//childparent->_right = subRL;subR->_left = parent;//parentsubR->_parent = parent->_parent;if (subRL)subRL->_parent = parent;parent->_parent = subR;if (_root == parent)_root = subR;else if (subR->_kv.first < subR->_parent->_kv.first)subR->_parent->_left = subR;elsesubR->_parent->_right = subR;}void RotateLR(Node* parent){Node* subL = parent->_left;Node* subLR = parent->_left->_right;//parent subL subLRRotateL(subL);RotateR(parent);}void RotateRL(Node* parent){Node* subR = parent->_right;Node* subRL = subR->_left;//parent subR subRLRotateR(subR);RotateL(parent);}
为了方便复用旋转逻辑,我选择旋转的时候就单单旋转,不在里面更新平衡因子或者结点颜色,等到旋转完以后自己更新平衡因子或者颜色,实现旋转和底层树的解耦。
else if(uncle == nullptr){//右单旋// g// p u//cif (grandfather->_left == parent && parent->_left == cur){RotateR(grandfather);parent->_color = BLACK;cur->_color = grandfather->_color = RED;}//左右双旋// g// p u// celse if (grandfather->_left == parent && parent->_right == cur){RotateLR(grandfather);cur->_color = BLACK;parent->_color = grandfather->_color = RED;}//左单旋// g// u p// celse if (grandfather->_right == parent && parent->_right == cur){RotateL(grandfather);parent->_color = BLACK;cur->_color = grandfather->_color = RED;}//右左双旋// g// u p// c//else if (grandfather->_right == parent && parent->_left == cur)else{RotateRL(grandfather);cur->_color = BLACK;parent->_color = grandfather->_color = RED;}cur = grandfather;parent = cur->_parent;}
u为空就是这样的,而且非常容易发现,一旦u为空,那么不难发现:
根本就不用继续往上走了。u为空的话最后再补一句break:
并且可以得知,如果u为空的话,那么c一定是新增结点,因为如果c不是新增结点,p是红,g是黑,c产生红-红链接一定是因为新插入结点的红-红变色后p变红,c = p所致,c不是新增结点p是红咱们没有动过,那原来c就是黑色,如果c原来是黑色,u又不存在,就会导致黑色结点个数不同。
最后分析分析u存在且为黑
依旧有例子,不看抽象图光自己分析还是爽的。
结论:
直接变色也就是p染黑,根本解决不了问题,所以先旋转,旋转逻辑还是和u为空一样的。
给它转一转昂:
顺手就染色了,你会发现其实根本一个玩意,染色逻辑都是一样的。
绝对不会有问题,比如25这个结点,原来跟着一个p红结点,肯定是黑的,黑结点不管是挂到红结点还是黑结点都没毛病。
所以上面的代码改成:
bool Insert(const pair<K,V>& kv){if (_root == nullptr){_root = new Node(kv);_root->_color = BLACK;return true;}Node* cur = _root;Node* parent = nullptr;while (cur){if (kv.first < cur->_kv.first){parent = cur;cur = cur->_left;}else if (kv.first > cur->_kv.first){parent = cur;cur = cur->_right;}elsereturn false;}cur = new Node(kv);//新插入的结点颜色必定为红cur->_color = RED;if (cur->_kv.first < parent->_kv.first)parent->_left = cur;elseparent->_right = cur;while (parent&& parent->_color == RED)//p为黑c为红,不用继续调整了{//新插入结点必定是叶子结点//如果p是红,那么g一定存在且一定为黑//初始情况下是这样的情况//即使迭代起来也是 Node* grandfather = parent->_parent;Node* uncle = nullptr;//parent与uncle相对位置不知道,不能武断if (parent == grandfather->_left)// g// p uuncle = grandfather->_right;else// g// u puncle = grandfather->_left;//根据uncle分类讨论if (uncle&&uncle->_color == RED){parent->_color = BLACK;uncle->_color = BLACK;grandfather->_color = RED; // g(r)// p(b) u(b)// c(r)cur = grandfather;parent = cur->_parent;if (parent == nullptr)_root->_color = BLACK;}//else if(uncle == nullptr||uncle->_color == BLACK)else{//右单旋// g// p u//cif (grandfather->_left == parent && parent->_left == cur){RotateR(grandfather);parent->_color = BLACK;cur->_color = grandfather->_color = RED;}//左右双旋// g// p u// celse if (grandfather->_left == parent && parent->_right == cur){RotateLR(grandfather);cur->_color = BLACK;parent->_color = grandfather->_color = RED;}//左单旋// g// u p// celse if (grandfather->_right == parent && parent->_right == cur){RotateL(grandfather);parent->_color = BLACK;cur->_color = grandfather->_color = RED;}//右左双旋// g// u p// c//else if (grandfather->_right == parent && parent->_left == cur)else{RotateRL(grandfather);cur->_color = BLACK;parent->_color = grandfather->_color = RED;}cur = grandfather;parent = cur->_parent;break;} }return true;}
本来想加个||,但是一考虑,没必要啊,直接else算了。
另外就是看着看着突然发现一个问题,如果u为空,到时候先走的是uncle->_color == RED,设计空指针解引用,当然可以把这两段代码互换一下顺序,但是我懒得粘贴了,直接:
多加一句算了。
3.测试
说实话,写完了,但是有点后怕,毕竟这玩意不管是写起来还是到时候修改起来都是个老难事了,抓紧拉过来中序遍历,看看有没有毛病。
一测试就给我吓尿了:
原因也可简单,这种代码分支细节那么多,我查资料,听好几遍,又用一下午画图敲代码,要是炸缸了,那我不废了。
但是就像你工作时候碰见bug一样,规定时间内完成任务,就算是代码上有一坨屎你也得吃下去,毕竟看在钱的面子上。
当然,一查我发现:
其实根本跟我下面的逻辑没关系,上面new结点并建立连接的时候忘写:
所以grandfather不能通过parent找_parent。
只能说还好是这个小bug,我之前写AVL树都老忘_parent这个指针,真吓尿我了,要是最麻烦的调整部分错了,我基本又得画一大堆图,检查检查代码逻辑。不幸中的万幸。
bool Insert(const pair<K,V>& kv){if (_root == nullptr){_root = new Node(kv);_root->_color = BLACK;return true;}Node* cur = _root;Node* parent = nullptr;while (cur){if (kv.first < cur->_kv.first){parent = cur;cur = cur->_left;}else if (kv.first > cur->_kv.first){parent = cur;cur = cur->_right;}elsereturn false;}cur = new Node(kv);//新插入的结点颜色必定为红cur->_color = RED;if (cur->_kv.first < parent->_kv.first)parent->_left = cur;elseparent->_right = cur;cur->_parent = parent;while (parent&& parent->_color == RED)//p为黑c为红,不用继续调整了{//新插入结点必定是叶子结点//如果p是红,那么g一定存在且一定为黑//初始情况下是这样的情况//即使迭代起来也是 Node* grandfather = parent->_parent;Node* uncle = nullptr;//parent与uncle相对位置不知道,不能武断if (parent == grandfather->_left)// g// p uuncle = grandfather->_right;else// g// u puncle = grandfather->_left;//根据uncle分类讨论if (uncle&&uncle->_color == RED){parent->_color = BLACK;uncle->_color = BLACK;grandfather->_color = RED; // g(r)// p(b) u(b)// c(r)cur = grandfather;parent = cur->_parent;if (parent == nullptr)_root->_color = BLACK;}//else if(uncle == nullptr||uncle->_color == BLACK)else{//右单旋// g// p u//cif (grandfather->_left == parent && parent->_left == cur){RotateR(grandfather);parent->_color = BLACK;cur->_color = grandfather->_color = RED;}//左右双旋// g// p u// celse if (grandfather->_left == parent && parent->_right == cur){RotateLR(grandfather);cur->_color = BLACK;parent->_color = grandfather->_color = RED;}//左单旋// g// u p// celse if (grandfather->_right == parent && parent->_right == cur){RotateL(grandfather);parent->_color = BLACK;cur->_color = grandfather->_color = RED;}//右左双旋// g// u p// c//else if (grandfather->_right == parent && parent->_left == cur)else{RotateRL(grandfather);cur->_color = BLACK;parent->_color = grandfather->_color = RED;}cur = grandfather;parent = cur->_parent;break;} }return true;}
行了至少我们现在用起来没啥毛病了。
六、红黑树的查找
其实这条纯是为了文章的全面,其实你想吧,红黑树再牛逼,也是二叉查找树,直接根据二叉查找树弄就行:
Node* Find(const K& key){Node* cur = _root;while (cur){if (key < cur->_kv.first)cur = cur->_left;else if (key > cur->_kv.first)cur = cur->_right;elsereturn cur;}return nullptr;}
七、红黑树的验证
即使我们简单样例通过了,也不能说明就是好的,原因也很简单,万一这次最后一个插入的数据刚好是坏的,但是由于我们没有继续插入数据了,我们也看不出来红黑树出问题了。
所以写一个函数来验证。
验证的逻辑肯定还是围绕着红黑树的规则,所以先把规则拉过来:
- 每个结点不是黑色就是红色
- 根结点是黑色的
- 如果一个结点是红色的,那么它的两个孩子结点如果存在就必须为黑色的,即红黑树任意一条路径不允许红色结点连续存在
- 从任意一个结点出发到所有NULL结点的路径上黑色结点的数量相同
由于我们颜色用的枚举类型来弄,天然的结点就只有红黑;
根结点是黑色的,我们检查也容易,拿着根的颜色检查就可以了;
麻烦的就是3和4,但是既然下定决心搞个红黑树的验证,那总有千难万险还是得走下去。
对于3,我的思路是这样的,检查红色结点的孩子必须为黑色结点,那我就遍历到红色就遍历它的孩子是不是都是黑色或者不存在,但是吧很麻烦,又得先判断存不存在,又得判断啥颜色,我们反过来检查,因为红色结点不能连续存在,直接检查红色结点父亲是什么颜色就行,红色结点的父亲一定存在,省去检查存不存在。
对于4,其实很麻烦,因为从某个结点到根结点有很多条路径,很多很多,每个结点都得计算所有路径的黑色结点个数根本没法弄啊看起来,毕竟我计算肯定得存起来比较吧,用多个单个的值存我又不知道一个结点到NULL有多少条路径;要是搞个vector啥的存起来,我岂不是要搞N个vector吗,虽然空间相对于时间不值钱,但是也不能说就往死里用啊,麻烦的很,那该怎么弄呢?
用点递归的思想啊,有点抽象,我们现在只检查根结点到NULL结点的所有路径的黑色结点个数是否相等其它的都不管了,原因是如果根结点到NULL结点的所有路径的黑色结点个数相等,那么你随便点一个结点,由它产生的分支的路径的黑色结点个数应该是相等的,否则,前面验证的不可能成立。
验证的时候用递归看:
算出来一条路径(我直接就算最左边的路径了)黑色结点个数,然后给函数附带上一个形参记录到当前结点的黑色结点个数,每次遍历到底就对比。
说着有点抽象,看图:
比如根据最左边的路径我们算出来从根结点到NULL结点的个数是2,那么写个递归,参数带一个根结点到当前结点的黑色结点个数的值,如果递归到NULL结点,则应该比较二者是否相等。
写出来代码:
bool IsBalanceTree(){if (_root == nullptr)return true;if (_root->_color == RED)return false;Node* cur = _root;int BlackNum = 0;while (cur){if (cur->_color == BLACK)++BlackNum;cur = cur->_left;}return _Check(_root,0,BlackNum);}
private:bool _Check(Node* cur,int BlackNow,int BlackNum){if (cur == nullptr)return BlackNow == BlackNum;if (cur->_color == RED && cur->_parent->_color == RED)return false;if (cur->_color == BLACK)++BlackNum;return _Check(cur->_left,BlackNow,BlackNum) && _Check(cur->_right,BlackNow,BlackNum);}
有几个要点解释一下:
- 检查根结点的逻辑
我写成了:
if (_root->_color == RED)return false;
为啥不写成_root->_color == BLACK呢,因为我写的时候细想了一下,如果写个这,true了下面的逻辑也还得继续验证;false了if进不去又不会return false,等于写了个废话,所以干脆写成这个。
- 检查红色结点的父亲的逻辑
if (cur->_color == RED && cur->_parent->_color == RED)return false;
跟上一条有点那异曲同工的感觉,就是说,如果检查红色结点的父亲cur->_parent->_color==BLACK,true了有啥用,下面的代码还得继续走;false了if进不去也不能return false,所以又反着写,要是红色结点父亲为红那就false了,要是不为红没啥事,继续往下走。
- 检查所有路径黑色结点个数相同逻辑
大致画一下检查所有路径黑色结点个数相同的逻辑:
这个逻辑算出来树某条路径的黑色结点总个数,记录下来将来和所有路径比较。
稍微画了画递归图,大致意思就搞一个形参记录从根结点到当前结点的黑色结点个数,如果到NULL结点就跟某一条路径算出来的对比,要是每个都是相等的,那不就是所有路径都相等。
当然,我一测试又发现个笔误:
因为我真不想去再看一遍insert了,所以逼着自己看了看是不是Check的逻辑错了。
经测试:
好了,没毛病了噢。
八、AVL树和红黑树对比
插入效率对比
插入效率在调整之前的代码基本完全一样,效率没有差别;
但是一旦涉及调整,我们两棵树都实现了插入,很明显,对于AVL树来说,不管怎么样的调整,都免不了去调用旋转,到后续可能插入一个结点都得搞一次旋转,一个个旋转累加起来就会造成时间用的多。
而红黑树我们可以发现,有的时候(红-红,uncle红的情况)O(1)的时间复杂度就能完成,没有旋转函数栈帧的开销;而且循环过程中只有u红/u黑的情况,u红就是O(1)变色,u黑就是旋转+O(1)变色,往往只变色的情况多,因为每次插入新叶子结点的颜色是红色。
由此红黑树的插入效率稍优于AVL树。
查找效率对比
用起来的话都认为是logN,这个毋庸置疑,但是实践中由插入的差别可知,红黑树相较于AVL比较松散,它调整的时候不一定旋转,只是最多维持最长路径是最短路径两倍,相同红黑树的层数可能就多于AVL树,但是算一笔账:是1024,就算成1000,等于一百万个数据AVL树查找最差20次,红黑树查可能也就是20+三四次,它俩查找逻辑都一样,所以红黑树查找略劣于AVL树,但是对于计算机来说,多执行几次查找总比多执行几次旋转好。
方便性对比
啥叫方便性呢,这是我自己取的名字昂,就是说,如果让你手搓个自平衡查找二叉树,毕竟map、set底层搞的就是嘛,红黑树好写还是AVL树好写呢?
其实想想也知道,红黑树好写。
四种旋转俩人都得写吧,这个一样的,那就看旋转以外的逻辑了。
红黑树其实多次使用就知道了,while里就俩情况,一个是u为红,那就p和u变黑,g变红,再迭代;一个就当成u不存在搞,因为u为黑跟他一个逻辑,u不存在的c只可能是新增结点,三个结点的旋转变色还是好画的,毕竟c和p都是红,p是黑,只不过位置不同有四个图,你挨着画就行。
AVL树别说现在我刚写完红黑树插入了,就是刚写完AVL树插入我也记不住啊,它那玩意搞的是平衡因子啊,到时候我还得严格画画算算平衡因子,要是红黑树我甚至空想都行。
所以没必要为了那么丁点的查找效率去牺牲插入和写代码的方便性。就算你说我说的写代码可以训练,但是频繁插入重要还是查找那一丁点的效率重要。