有了二叉搜索树,为什么还需要平衡二叉树?
- 在学习二叉搜索树、平衡二叉树时,我们不止一次提到,二叉搜索树容易退化成一条链
- 这时,查找的时间复杂度从O(log2N) 也将退化为O(N)
- 引入对左右子树高度差有限制的平衡二叉树,保证查找操作的最坏时间复杂度也为O(log2N)
有了平衡二叉树,为什么还需要红黑树?
- AVL的左右子树高度差不能超过1,每次进行插入/删除操作时,几乎都需要通过旋转操作保持平衡
- 在频繁进行插入/删除的场景中,频繁的旋转操作使得AVL的性能大打折扣
- 红黑树通过牺牲严格的平衡,换取插入/删除时少量的旋转操作,整体性能优于AVL
- 红黑树插入时的不平衡,不超过两次旋转就可以解决;删除时的不平衡,不超过三次旋转就能解决
- 红黑树的红黑规则,保证最坏的情况下,也能在O(log2N)时间内完成查找操作
红黑树,是一种二叉搜索树,但在每个结点上增加一个存储位表示结点的颜色,可以是Red或Black。 通过对任何一条从根到叶子的路径上各个结点着色方式的限制,红黑树确保没有一条路径会比其他路径长出俩倍(最长路径不超过最短路径的2倍),因而是接近平衡的。
同是二叉搜索平衡树,但是AVL树控制的比红黑树严格的多,AVL树要是每个节点的平衡因子绝对值不超过1,就会导致不断的去旋转调整,付出相对较高的代价,而这里红黑树更像是一种近似平衡,条件没有这么苛刻。
如下一棵树,站在红黑树的角度看是平衡的,站在AVL树的角度看就是不平衡的,需要旋转调整:
但是从搜索效率的角度看AVL树还是好一点,因为它的平衡标准高,就导致其更加平衡,相同数量的节点情况下AVL树的高度会更低,加上存100w个数据,AVL树大概有20层(log100w),而红黑树最坏就能达到40层,显然AVL树的搜索效率高。但是在内存里找20次和找40次没有什么区别,因为CPU足够的快,这里简单提一下。
- 1、每个结点不是红色就是黑色
- 2、根节点必须是黑色的
- 3、如果一个节点是红色的,则它的两个孩子结点是黑色的(没有连续的红色节点)
- 4、对于每个结点,从该结点到其所有后代叶结点的简单路径上,均包含相同数目的黑色结点(每条路径都包含相同数量的黑色节点)
- 5、每个叶子结点都是黑色的(此处的叶子结点指的是空结点 -> NIL节点)
根据这些规则,红黑树是如何保证最长路径不超过最短路径的2倍的呢?
首先我们根据规则分析得知,我们假设一条路径的黑色节点的个数为N个,则最长路径和最短路径的情况如下:
- 最短路径:全黑
- 最长路径:一黑一红间隔
而这里一黑一红间隔的原因在于红黑树不允许出现连续的红节点,为了能最大程度的保证最长节点数,唯有一黑一红间隔的方式才能达到最长,综上当黑节点个数固定为N时,最短路径节点个数为N,最长路径节点个数为2N
这里节点的实现相较于AVL树我们依旧是创建成KV模型、三叉链结构,唯一有所改变的是这里要通过枚举的方式把红色和黑色定义好,并在节点类内部定义变量_col表示节点颜色,最后记得写上构造函数。
enum Colour { RED, BLOCK, }; //节点类 template <class K, class V> struct RBTreeNode { //三叉链结构 RBTreeNode<K, V>* _left; RBTreeNode<K, V>* _right; RBTreeNode<K, V>* _parent; //存储的键值对 pair<K, V> _kv; //节点的颜色 Colour _col; //构造函数 RBTreeNode(const pair<K, V>& kv) :_left(nullptr) , _right(nullptr) , _parent(nullptr) , _kv(kv) , _col(RED) {} };
为什么插入的节点在构造函数这里要处理成红色?
- 如果处理成黑色,则一定导致新插入节点的那条路径多出一个黑色节点,不再满足各个路径黑色节点个数相同的性质,一定破坏性质4,此时很难维护。
- 如果处理成红色,则可能父亲节点也是红色,此时就出现了连续的红色节点,破坏性质3,不过此时我们向上调整即可,但如果父亲节点是黑色,那就无需操作了,不违反任何性质。
综合利弊,插入黑色节点一定会破坏性质4,而插入红色节点可能破坏性质3,因此处理成红色为宜。
此模板类主要是用于红黑树的插入、旋转、调整、验证等等操作,基本框架如下:
//红黑树的类 template <class K, class V> class RBTree { typedef RBTreeNode<K, V> Node; public: // 插入 bool Insert(const pair<K, V>& kv); // 中序遍历 void InOrder(); // 查找函数 Node* Find(const K& key); // 判断是否为平衡树 bool IsBalanceTree(); private: void _InOrder(Node* root); bool _IsValidRBTree(Node* pRoot, size_t k, const size_t blackCount); private: Node* _root = nullptr; };
红黑树的插入操作主要分为这几大步骤:
- 1、一开始为空树,直接new新节点
- 2、一开始非空树,寻找插入的合适位置
- 3、找到插入的合适位置后,进行父亲与孩子的双向链接
- 4、检测新节点插入后,红黑树的性质是否造到破坏
- 5、调整节点的颜色和位置,保持红黑树的性质不被破坏
接下来对其进行逐个分析:
- 1、一开始为空树,直接new新节点:
因为树为空的,所以直接new一个新插入的节点,将其作为根_ root即可,接着更新颜色_col为黑色。
- 2、一开始非空树,寻找插入的合适位置:
这里和二叉搜索树的寻找合适的插入位置的思想一样,都要遵循以下几步:
- 插入的值 > 节点的值,更新到右子树查找
- 插入的值 < 节点的值,更新到左子树查找
- 插入的值 = 节点的值,数据冗余插入失败,返回false
当循环结束的时候,就说明已经找到插入的合适位置,即可进行下一步链接。
- 3、找到插入的合适位置后,进行父亲与孩子的双向链接:
注意这里节点的构成为三叉链,因此最后链接后端孩子和父亲是双向链接,具体操作如下:
- 插入的值 > 父亲的值,把插入的值链接在父亲的右边
- 插入的值 < 父亲的值,把插入的值链接在父亲的左边
- 因为是三叉链结构,插入后记得双向链接(孩子链接父亲)
走到这,说明节点已经插入完毕,接下来就要对红黑树的颜色进行调整了
- 4、检测新节点插入后,红黑树的性质是否造到破坏:
不是所有的情况都是需要进行调整的,当**插入节点的父亲为黑色(新节点的默认颜色是红色),那么就不需要进行调整,因为没有破坏红黑树的任何一条性质**。
只有当插入节点的父亲为红色时(新节点的默认颜色也是是红色),才需要进行调整,因为此时插入的节点和父亲都是红色节点,但是红黑树不允许出现连续的红色节点,此时就要进行调整。
注意这里既然插入节点cur的父亲p是红色,那么根据红黑树的性质(根结点是黑色的),其父亲的父亲g也就是祖父必然存在且一定是黑色,那么其父亲的兄弟节点u(可能不存在)也就是新插入节点cur的叔叔。因此我们约定:cur为当前节点,p为父节点,g为祖父节点,u为叔叔节点。
- 5、这里调整的办法主要是看叔叔节点的颜色如何,叔叔节点的不同,会导致三种不同的情况需要调整:
- 情况一:cur为红,p为红,g为黑,u存在且为红
- 情况二:cur为红,p为红,g为黑,u不存在
- 情况三:cur为红,p为红,g为黑,u存在且为黑
接下来分别进行讨论:
- 情况一:cur为红,p为红,g为黑,u存在且为红
为了避免出现连续的红色节点,我们可以把父节点p变黑,但是为了保证每条路径的黑色节点个数相同,我们需要把祖父节g点变红(不影响其它路径黑节点的个数),再把叔叔节点u变黑。
a、b、c、d、e不存在
a、b、c、d、e存在
调整并未结束,此时祖父节点g为红色,但是如果这棵树本就是一颗完整的树呢?也就是g为根节点,那么只需要把节点g变成黑色即可。
如果这棵树是一棵树的子树,那么刚好把祖父节点g作为新插入的节点cur向上继续调整(继续判断父亲、叔叔如何……),直至调整结束。
- 补充**:**情况一不关心左右关系,只变色不旋转,所以 p、u是g的左或右是无所谓的,cur是p的左或右也是无所谓的。
- 说明:u的情况有两种
- 如果节点u不存在,则cur一定是新插入节点,因为如果cur不是新插入节点,则cur和p一定有一个节点的颜色是黑色,就不满足性质4:每条路径黑色节点个数相同。
- 如果节点u存在,则其一定是黑色的,那么cur节点原来的颜色一定是黑色的,现在看到其是红色的原因是因为cur的子树在调整的过程中将cur节点的颜色由黑色改成红色。
接下来分析情况2:
- 情况二:cur为红,p为红,g为黑,u不存在
重要结论:u不存在,那么a、b、c、d、e都不存在
此时就是一个很经典的右单旋结构(新节点插入较高左子树的左侧)我们可以先对其进行一个右单旋,再来更新颜色。具体步骤如下:
- 让祖父g变成父亲p的右子树
- 父亲p作为根节点
- 更新父亲节点p为黑色
- 更新祖父g为红色
- 补充:
如若p为g的右孩子,cur为p的右孩子,则针对p做左单旋转,示例:
如若祖孙三代的关系是折线(cur、parent、grandfather这三个结点为一条折线),则我们需要先进行双旋操作,再进行颜色调整,颜色调整后这棵被旋转子树的根是黑色的,因此无需继续往上进行处理。示例:
左右双旋
右左双旋
综上:
- p为g的左,cur为p的左,则进行右单旋 + p变黑,g变红
- p为g的右,cur为p的右,则进行左单旋 + p变黑,g变红
- p是g的左,cur是p的右,则进行左右双旋 + cur变黑, g变红
- p是g的右,cur是p的左,则进行右左双旋 + cur变黑, g变红
下面进入情况三
- 情况三:cur为红,p为红,g为黑,u存在且为黑
此情况绝非单独存在,绝不可能是真的新节点cur插入,然后还会出现p为红,g为黑,u存在且为黑的情况,如果存在,那么只能说明先前插入节点或者构造函数就有问题,因为插入前就不符合红黑树的性质(每个路径的黑节点个数均相同)
既然情况三出现了,那么一定是合理的,它就是建立在情况一的基础上继续往上调整从而出现的一种特殊情况,具体咱就是画图演示:
此时就是很明显的一个情况3了,cur为红,pp为红,gg为黑,u存在且为黑,由此证明,情况三是通过情况一向上继续调整演化出来的。并且此新节点一定是从p和x任意一颗左右子树插入或演化上来的,才引发后续的cur从黑变红。
此时就是一个很经典的右单旋结构(cur在较高左子树的左侧)我们可以先对其进行一个右单旋,再来更新颜色。具体步骤如下:
- 让p的右子树变成g的左子树
- 让p变成根节点位置
- p的右子树指向g
- 更新p的颜色为黑色
- 更新g的颜色为红色
- 补充:
如若p为g的右孩子,cur为p的右孩子,则进行左单旋 + 调色,示例:
若祖孙三代的关系是折现(cur、parent、grandfather这三个结点为一条折线),则我们需要先进行双旋操作,再进行颜色调整,颜色调整后这棵被旋转子树的根是黑色的,因此无需继续往上进行处理。示例:
左右双旋
右左双旋
综上:
- p为g的左,cur为p的左,则进行右单旋 + p变黑,g变红
- p为g的右,cur为p的右,则进行左单旋 + p变黑,g变红
- p是g的左,cur是p的右,则进行左右双旋 + cur变黑, g变红
- p是g的右,cur是p的左,则进行右左双旋 + cur变黑, g变红
情况二和情况三旋转 + 变色后,这颗子树不违反红黑树规则,相比插入前,且黑色节点的数量不变,不会影响上层,处理结束了。
代码如下:
bool Insert(const pair<K, V>& kv) { //1、一开始为空树,直接new新节点 if (_root == nullptr) { _root = new Node(kv); _root->_col = BLACK;//新插入的节点处理成黑色 return true; } //2、寻找插入的合适位置 Node* cur = _root; Node* parent = nullptr; while (cur) { if (cur->_kv.first < kv.first) { parent = cur; cur = cur->_right;//插入的值 > 节点的值,更新到右子树查找 } else if (cur->_kv.first > kv.first) { parent = cur; cur = cur->_left;//插入的值 < 节点的值,更新到左子树查找 } else { return false;//插入的值 = 节点的值,数据冗余插入失败,返回false } } //3、找到了插入的位置,进行父亲与插入节点的链接 cur = new Node(kv); cur->_col = RED;//插入的节点处理成红色 if (parent->_kv.first < kv.first) { parent->_right = cur;//插入的值 > 父亲的值,链接在父亲的右边 } else { parent->_left = cur;//插入的值 < 父亲的值,链接在父亲的左边 } cur->_parent = parent;//三叉链,要双向链接 //4、检测新节点插入后,红黑树的性质是否造到破坏 while (parent && parent->_col == RED)//存在连续的红色节点 { Node* grandfather = parent->_parent; assert(grandfather); //先确保叔叔的位置 if (grandfather->_left == parent) { Node* uncle = grandfather->_right; //情况一:cur为红,p为红,g为黑,u存在且为红 if (uncle && uncle->_col == RED) { //变色 parent->_col = uncle->_col = BLACK; grandfather->_col = RED; //继续往上处理 cur = grandfather; parent = cur->_parent; } //情况二+情况三:叔叔不存在,或者叔叔存在且为黑 else { if (cur == parent->_left)//p为g的左,cur为p的左,则进行右单旋 + p变黑,g变红 { // g // p // cur RotateR(grandfather); parent->_col = BLACK; grandfather->_col = RED; } else//p是g的左,cur是p的右,则进行左右双旋 + cur变黑, g变红 { // g // p // cur RotateLR(grandfather); cur->_col = BLACK; grandfather->_col = RED; } break; } } else//grandfather->_right == parent { Node* uncle = grandfather->_left; //情况一:cur为红,p为红,g为黑,u存在且为红 if (uncle && uncle->_col == RED) { //变色 parent->_col = uncle->_col = BLACK; grandfather->_col = RED; //继续往上处理 cur = grandfather; parent = cur->_parent; } //情况二+情况三:叔叔不存在,或者叔叔存在且为黑 else { if (cur == parent->_right)//p为g的右,cur为p的右,则进行左单旋 + p变黑,g变红 { // g // p // cur RotateL(grandfather); parent->_col = BLACK; grandfather->_col = RED; } else//p是g的右,cur是p的左,则进行右左双旋 + cur变黑, g变红 { // g // p // cur RotateRL(grandfather); cur->_col = BLACK; grandfather->_col = RED; } break; } } } _root->_col = BLACK;//暴力处理把根变成黑色 return true; } //1、左单旋 void RotateL(Node* parent) { Node* subR = parent->_right; Node* subRL = subR->_left; Node* ppNode = parent->_parent;//提前保持parent的父亲 //1、建立parent和subRL之间的关系 parent->_right = subRL; if (subRL)//防止subRL为空 { subRL->_parent = parent; } //2、建立subR和parent之间的关系 subR->_left = parent; parent->_parent = subR; //3、建立ppNode和subR之间的关系 if (parent == _root) { _root = subR; _root->_parent = nullptr; } else { if (parent == ppNode->_left) { ppNode->_left = subR; } else { ppNode->_right = subR; } subR->_parent = ppNode;//三叉链双向链接关系 } } //2、右单旋 void RotateR(Node* parent) { Node* subL = parent->_left; Node* subLR = subL->_right; Node* ppNode = parent->_parent; //1、建立parent和subLR之间的关系 parent->_left = subLR; if (subLR) { subLR->_parent = parent; } //2、建立subL和parent之间的关系 subL->_right = parent; parent->_parent = subL; //3、建立ppNode和subL的关系 if (parent == _root) { _root = subL; _root->_parent = nullptr; } else { if (parent == ppNode->_left) { ppNode->_left = subL; } else { ppNode->_right = subL; } subL->_parent = ppNode;//三叉链双向关系 } } //3、左右双旋 void RotateLR(Node* parent) { RotateL(parent->_left); RotateR(parent); } //4、右左双旋 void RotateRL(Node* parent) { RotateR(parent->_right); RotateL(parent); }
下面给出动图演示:
1.随即插入构建红黑树
2.以降序插入构建红黑树
3.以升序插入构建红黑树
红黑树的验证主要分为两大步骤:
- 1、检测其是否满足二叉搜索树(中序遍历是否为有序序列)
- 2、检测其是否满足红黑树的性质
接下来分别演示:
- 1、检测其是否满足二叉搜索树(中序遍历是否为有序序列):
这里只需要递归写一个中序遍历,并判断测试用例的结果是否为一个有序序列即可判断二叉搜索树:
//验证是否为一颗搜索二叉树 void InOrder() { _InOrder(_root);//调用中序遍历子树 cout << endl; } //中序遍历的子树 void _InOrder(Node* root) { if (root == nullptr) return; _InOrder(root->_left); cout << root->_kv.first << " "; _InOrder(root->_right); }
- 2、检测其是否满足红黑树的性质:
这里只要判断是否满足红黑树的5大规则即可,具体操作如下:
- 1、根节点是否为黑色
- 2、任意一条路径黑色节点数是否相同(递归每一条和确定的一条比较是否相同)
- 3、递归检测是否违反性质三从而出现连续的红节点
bool IsBalanceTree() { Node* pRoot = _root; // 空树也是红黑树 if (pRoot == nullptr) return true; // 检测根节点是否满足情况 if (pRoot->_col != BLACK) { cout << "违反红黑树性质二:根节点必须为黑色" << endl; return false; } // 获取任意一条路径中黑色节点的个数-->拿最左路径作为比较基准值 size_t blackCount = 0; Node* pCur = pRoot; while (pCur) { if (pCur->_col == BLACK) blackCount++; pCur = pCur->_left; } // 检测是否满足红黑树的性质,k用来记录路径中黑色节点的个数 size_t k = 0; return _IsValidRBTree(pRoot, k, blackCount); } bool _IsValidRBTree(Node* pRoot, size_t k, const size_t blackCount) { //走到null之后,判断k和black是否相等 if (pRoot == nullptr) { if (k != blackCount) { cout << "违反性质四:每条路径中黑色节点的个数必须相同" << endl; return false; } return true; } // 统计黑色节点的个数 if (pRoot->_col == BLACK) k++; // 检测当前节点与其双亲是否都为红色 Node* pParent = pRoot->_parent; if (pParent && pParent->_col == Red && pRoot->_col == RED) { cout << "违反性质三:没有连在一起的红色节点,而这里出现了" << endl; return false; } return _IsValidRBTree(pRoot->_left, k, blackCount) && _IsValidRBTree(pRoot->_right, k, blackCount); }
红黑树的查找函数与二叉搜索树的查找方式一模一样,逻辑如下:
1、若树为空树,则查找失败,返回nullptr。
2、若key值小于当前结点的值,则应该在该结点的左子树当中进行查找。
3、若key值大于当前结点的值,则应该在该结点的右子树当中进行查找。
4、若key值等于当前结点的值,则查找成功,返回对应结点。
代码如下:
//查找函数 Node* Find(const K& key) { Node* cur = _root; while (cur) { if (key < cur->_kv.first) //key值小于该结点的值 { cur = cur->_left; //在该结点的左子树当中查找 } else if (key > cur->_kv.first) //key值大于该结点的值 { cur = cur->_right; //在该结点的右子树当中查找 } else //找到了目标结点 { return cur; //返回该结点 } } return nullptr; //查找失败 }
红黑树的删除这里和AVL树一样就不做过多演示了,具体可参考《算法导论》或者《STL源码剖析》,也可参考此大佬的博文:红黑树的插入删除操作
红黑树和AVL树都是高效的平衡二叉树,增删改查的时间复杂度都是O(logN),红黑树不追求绝对平衡,其只需保证最长路径不超过最短路径的2倍,相对而言,降低了插入和旋转的次数,所以在经常进行增删的结构中性能比AVL树更优,而且红黑树实现比较简单,所以实际运用中红黑树更多。
- Java中,TreeMap、TreeSet都使用红黑树作为底层数据结构
- JDK 1.8开始,HashMap也引入了红黑树:当冲突的链表长度超过8时,自动转为红黑树
- Linux底层的CFS进程调度算法中,vruntime使用红黑树进行存储。
- 多路复用技术的Epoll,其核心结构是红黑树 + 双向链表。
参考文档:为什么这么多关于红黑树的面试题呢?