数据结构之红黑树
前言
在我的介绍中, 没有对应的插图, 这本身就是在一边思考, 一边在本子上写写画画理解的.
在二叉树的介绍中, 提到过二叉树依然存在的性能问题, 那就是在最坏情况下, 如输入值为 987654321 这样有序的数据的时候, 导致树高和 输入值n相同, 自然的, 无论查找, 还是更新等其他操作, 所需要的量级都已经不再是 log2N.
因此需要一种更好的数据结构, 平衡的二叉树, 使得以任何一个节点为根节点的节点, 其左右两侧的树高完全相同, 通过这样的方式, 就能够始终保证操作的量级.
所以这样的树, 我们就称它为平衡二叉树.
平衡二叉树
但是, 平衡二叉树又是怎样做到平衡的呢?
通过一开始的描述, 不难发现, 平衡二叉树的特点就是 从任意根节点到其子节点中的 任意一个空节点 的距离都相等.
2-3查找树
但, 怎样实现这样一棵二叉树呢?
只要在二叉树插入, 删除 的时候, 对二叉树略做调整, 就能够实现一棵平衡二叉树.
在这里, 先来看这样一棵树, 树的结构, 就不画出来了, 我相信各种网络资源下, 只要知道名字, 找到对应的并不难.
2-3查找树: 树的名字2-3表示 一个节点有2~3个直系子节点, 与之对应的, 当前节点也会有一个或两个键. 分别就称作 2-节点, 3-节点.
put()
我们最终要处理的无非是将 2, 3个节点重新排列组合.生成新的节点组合而已.
2: 也就是说, 查找结束于叶节点, 则将当前节点和被插入节点, 组合成一个3-节点即可. 高度为1;
3: 查找结束于3-节点, 将当前节点变为临时的 4-节点, 然后取出最大或最小键, 上移至父节点, 将父节点变为3-节点 或 临时4-节点, 一直向上, 直到遇到一个2-节点的父节点为止, 如果根节点, 即 root 节点变为 4-节点, 则将这个 4-节点, 拆解为一个高度为2的 2-节点.
如果遇到的是2-节点的父节点, 将父节点变为 3-节点, 需要将当前节点拆解为两个节点. 放在父节点的左中右三个子节点位置.
delete()
至于删除, 就不再这里多写, 在红黑树中, 有更详尽的解释.
另外呢, 不仅仅有2-3树, 2-3-4, 甚至更多的组合也都存在, 在理解了 2-3树的插入操作之后, 其他的树插入操作也就顺理成章.
红黑树
接下来就是要介绍的重点, 在这之前, 确保务必理解了, 2-3树的处理方式, 是怎样维持树高度不变的. 如果依然有不理解的, 细细揣摩.
红黑树, 可以对比2-3树来理解, 区别是, 红黑树依然是一棵标准的二叉树, 无非是为每一个节点都添加了一个额外标识, 表示当前节点的颜色. 也就是当前节点与父节点的链接的颜色. 同时, 我们规定, 红色链接只能为左链接.
从运用来看, 将红黑树中的 红色链接拉平, 放在与父节点同一高度, 则可以表示 当前节点与父节点处在同一高度, 这代表什么呢? 代表了2-3查找树中的 3-节点.
在之前, 正是巧妙地利用了3-节点, 和4-节点的生成与拆解, 维持树高度不变.在直观的感受中, 树的高度并没有变, 也就巧妙的实现了一棵平衡二叉树.
那么一点点来看:
如果连续出现了两个红色链接, 将红色链接拉平, 就会发现 有三个节点处在同一高度, 也就是2-3树中的 4-节点. 但4-节点在 2-3树种事实上是不存在的.因此红黑树的第二条规定:
不能出现连续的两条红色链接.
同时, 最后一条不应该叫做规定, 而是随之而来的性质:
任意节点到其任意 空 子节点的经过的黑色链接数量相同.
红色链接表示同一高度, 自然而然, 经过多少黑链接, 就表示树高度为多少.
实现
Node
private Node root;
private final static boolean RED = true, BLACK = false;
private class Node{
Node right;
Node left;
boolean color;
int N;
Key key;
Value value;
Node(Key key, Value value) {
this.key = key;
this.value = value;
this.N = 1;
this.color = RED;
}
}
private boolean isRed(Node node) {
return node == null ? false : node.color;
}
put()
虽然在规定上有 红色链接必须为左链接, 但我们在操作过程中, 很有可能
出现右链接, 两条连续的左链接这种情况出现, 但都是不被允许的, 因此, 需要我们将之转换为合理的结构.
当前为红色右链接时, 通过左旋操作:
private Node rotateLeft(Node node) {
Node rNode = node.right;
node.right = rNode.left;
rNode.color = node.color;
node.color = RED;
rNode.N = node.N;
node.N = size(node.left) + size(node.right) + 1;
rNode.left = node;
return rNode;
}
即可将当前节点转换为红色左链接.
而另一个问题, 如果出现两条红色的左链接该怎么办呢? 就得借助右旋操作.
private Node rotateRight(Node node) {
Node rNode = node.left;
node.left = rNode.right;
rNode.color = node.color;
node.color = RED;
rNode.N = node.N;
node.N = size(node.left) + size(node.right) + 1;
rNode.right = node;
return rNode;
}
然而仅仅右旋操作是不够的, 对于连续两条红色左链接:
对于插入操作之间的各种转换:
最好还是自己试着推演一下对应的操作, 体会更深刻.
需要注意的是, 在任何插入, 删除操作之后, 最终root节点的颜色必定为 黑色.
而另一点则是: 每次插入的新节点, 在插入的时候必然为红色, 在之前2-3查找树种, 我们就是这样处理的, 但并没有解释过.
在2-3查找树, 或红黑树中, 如果这个节点可以直接被插入, 也就是不需要转换为3-节点后处理, 那么无论兄弟节点存在与否, 均不满足高度统一这一原则.
private void flipColor(Node node) {
node.right.color = node.left.color = BLACK;
node.color = RED;
}
private int size(Node node) {
return node == null ? 0 : node.N;
}
public void put(Key key, Value value) {
root = put(root, key, value);
root.color = BLACK;
}
private Node put(Node node, Key key, Value value) {
if (node == null) {
return new Node(key, value);
}
int cmp = key.compareTo(node.key);
if (cmp > 0) {
node.right = put(node.right, key, value);
} else if (cmp < 0) {
node.left = put(node.left, key, value);
} else {
node.value = value;
}
/**
* 在插入的多种情况中, 无非是需要将当前节点左旋, 左旋后可能为一个 3-节点, 或两个3-节点, 在后者的情况下, 就需要
* 右旋处理, 在这之后, 就需要变色. 因此按照这样的顺序, 已经考虑到了所有的情况, 简洁明快.
*
* 一个红链接所链接的两个节点构成了一个3-节点.
*
* 同时这段代码在递归之后, 表示向上遍历, 如果放在递归之前就可以表示向下遍历.
*/
if (isRed(node.right) && !isRed(node.left)) {
//在一个2-节点中插入一个新节点, 将父节点变为 3-节点即可
node = rotateLeft(node);
}
if (isRed(node.left) && node.left != null && isRed(node.left.left)) {
//在一个父节点为3-节点中插入一个新节点, 将当前节点变为 4-节点. 逐层向上拆解.
node = rotateRight(node);
}
if (isRed(node.right) && isRed(node.left)) {
//拆解一个 4-节点, 将其变为 父节点为 3-节点, 子节点为两个2-节点即可.
//或是 父节点为3-节点, 将其变为4-节点, 然后递归逐层拆解即可.
flipColor(node);
}
node.N = size(node.left) + size(node.right) + 1;
return node;
}
deleteMin()
在delete()操作之前, 由易到难, 分别看看 deleteMin() deleteMax() delete() 这三个方法.
同时需要注意的一点是: 删除与插入有所不同, 我们没有办法删除一个 2-节点, 原因在下面有解释.
我们需要的不过是一个自上而下遍历将节点都变为非2-节点, 在删除后, 将自下向上拆解所有的4-节点.
public void deleteMin() {
if (root != null && !isRed(root.left)) {
/**
* 保证在 moveRedLeft时, 其父节点必须为红色节点.
*/
root.color = RED;
}
root = deleteMin(root);
if (! isEmpty()) {
root.color = BLACK;
}
}
private Node deleteMin(Node node) {
/**
* 通过这句代码就直接将最小节点删除了.
*/
if (node.left == null) {
return null;
}
/**
* 如果按照 2-3树的方法, 找到要删除的最小节点, 需要保证被删除的节点为一个 3-节点, 从中删除最小键即可.
* 但要保证被删除的节点为3-节点, 在当前节点非3-节点的时候, 需要从兄弟节点借一个节点过来, 或从父级节点借一个节点.
* 这就出现问题了, 如果兄弟节点非3-节点. 且因为是平衡二叉树, 兄弟节点必然不存在子节点, 于是需要从父级节点借一个节点来
* 但同样的问题又出现在父级节点, 除非父级节点为3-或4-节点, 才能够借来节点.
*
* 因此就需要在向下遍历的过程中, 保证左节点始终不是2-节点.
*
* 同时也不难发现, 在向下遍历的过程中, 唯有遇到两个连续的黑色节点时才会停留下来进行转换, 而此时当前节点必然为红色
* 这就很好的满足了 moveRedLeft的条件,当前节点必定为红色.
*/
if (!isRed(node.left) && !isRed(node.left.left)) {
/**
*要验证左子节点为2-节点, 除了要验证 左子节点本身不为红, 同时需验证左子节点的左子节点也不为红才行.
*此时直接使用 moveRedLeft即可
*/
node = moveRedLeft(node);
}
/**
* 到这里为止, 就是向下不断深入这棵树, 将所有的左子节点均变为非 2-节点.
*/
node.left = deleteMin(node.left);
/**
* 这一步的目的, 需要自底向上, 分解所有的4-节点, 还原所有的不合理的 红色右节点.
* 删除最底层节点的最小值.
*/
return balance(node);
}
/**
* 这个方法并不是自己想出来的, 仅仅能一点点解析, 并理解
* @param node
* @return
*/
private Node moveRedLeft(Node node) {
/**
* 在这里, 无论是在任何时候, 操作一棵二叉树的时候, 最关键的一点, 维持树的高度不变, 或是在根节点
* root 节点进行增高操作. flipColors 就很好的维持了树高不变, 当node节点为 红节点, 子节点
* 左树不为红, 右树不可能为红节点的情况下, 将自身节点转为黑色, 两个子节点颜色变红, 维持了树高不变.
*
* 至于原因, 在我看来, 是当对两个子节点都为黑色节点的 父节点进行操作的时候, 会使得变化后的左节点变为红色
* 节点, 令左侧树高减一. 因此需要在保证左节点已经为红色节点的时候, 进行旋转操作. 至于保证左节点为红, 就是
* 采取 flipColors操作即可.
*/
colorFlip(node);
/**
* 这句代码, 如果右节点为 2-节点, 直接返回, 在总的变化来看, 便是将左, 右子节点,以及父节点中的最小节点,
* 三者合并为一个 4-节点.同样的关键点在于 flipColors.
*
* 而在这个方法中有一个很关键的点, 则是保证 node节点为红色节点. 如果node节点本身为黑色节点, 则无法保证在变换后
* 树的高度维持不变.
*
* 如果右节点为3-节点. 从右节点中借来一个节点, 使得左节点变为 3-节点. 而令当前节点变为了4-节点. 换句话说是使得当前节点变为
* 5-节点, 保持树高不变.
*/
if (isRed(node.right.left)) {
node.right = rotateRight(node.right);
node = rotateLeft(node);
}
return node;
}
private void colorFlip(Node n) {
/**
* 与colorFlip相对应, 生成一个4-节点.
*/
n.color = BLACK;
n.left.color = n.right.color = RED;
}
private Node balance(Node node) {
/**
* 在这里需要考虑当前的树是一种怎样的情形, 就需要看如何深入的, 如果左子节点为红色节点, 或左节点的左子节点为红色节点, 则直接深入,则不难
* 发现, 每次进行 moveRedLeft变换的节点, 当前节点必然为红色节点, 变换之后, 当前节点为4-节点, 左右节点均为红色, 同时跳过下个节点.
* 这样就会有以下几种情况, 左右节点均为红色同时当前节点为黑色. 左节点红色, 连续两个左节点均为红色.且右节点红色.
*
* 那么下面这句代码只会在最末级节点, 即左右节点均为红色的同时, 左节点被删除, 因此需要进行左旋转且无法变色.
* 如果当前左右均为红色节点, 则会导致连续两个红色左节点出现.
*/
if (isRed(node.right)) {
node = rotateLeft(node);
}
if (isRed(node.left) && node.left != null && isRed(node.left.left)) {
node = rotateRight(node);
}
if (isRed(node.right) && isRed(node.left)) {
flipColor(node);
}
node.N = size(node.left) + size(node.right) + 1;
return node;
}
deleteMax()
deleteMax() 并不是 deleteMin() 的简单重复. 揭示了在红黑树删除操作中另一个需要注意的地方.
public void deleteMax() {
if (root != null && !isRed(root.left) && !isRed(root.right)) {
root.color = RED;
}
root = deleteMax(root);
if (isEmpty()) {
root.color = BLACK;
}
}
private Node deleteMax(Node node) {
/**
* 有所不同, 在要删除最大节点时, 如果此时存在左子节点, 但右节点为空, 应该删除当前节点, 如果通过
* node.right == null 直接判断的话, 会删除当前节点及其左节点. 所以需要先进行右旋操作.这里的右旋操作
* 其目的并不是为了借一个节点, 而是为了防止上述情况出现.
*
* 在deleteMin中解释了如何保证当前节点必然为红色, 而在这里则是凭借这段代码保证了当前节点必然为红色.有兴趣的可以
* 画图想一下.
*
* 所以其实也不难明白, 我们在deleteMax() 无参数的方法中,主要保证第一次调用的时候 node为红色/node的左节点为红色即可.
*/
if (isRed(node.left)) {
node = rotateRight(node);
}
if (node.right == null) {
return null;
}
/**
* 当右节点为2-节点的时候, 和deleteMin()的处理策略一样. 同时 !isRed(node.right) 也使得刚刚旋转过的节点可以继续深入.
*/
if (!isRed(node.right) && !isRed(node.right.left)) {
node = moveRedRight(node);
}
return balance(node);
}
private Node moveRedRight(Node node) {
colorFlip(node);
/**
* 如果亲兄弟节点为 2-节点, 则右旋, 否则, 不变. 不符合之前的理论, 如果亲兄弟节点不为 2-节点, 则借一个节点过来.
*
* 这里的处理方式需要特别注意, 如果自己尝试画图就能够明白, 当在删除最小节点的时候, 并没有这样的要求,在这里我用-表示左链接, +表示右链接,
* 用r表示当前根节点,~ 表示链接, 同时仅标注红色节点.
* 在删除最小节点的最坏情况是 a- ~ b- ~ c(r) ~ d+ , 不难看出为两个红色的左链接, 在向回追溯的时候,按照我们的处理方式一定能够处理掉.
*
* 但在这里,情况有所不同, 让我们试试在 左左节点为红色节点的时候变换会发生什么:
* a- ~ b(r) ~ c+ ~ d+, 问题出现了, 出现了两个连续的 红色右节点, 再按照balance的方式向上回溯, 第一次回溯
* a- ~ b(r) ~ d+ ~ c-, 顺着节点继续向上回溯:
* a- ~ b- ~ c+ ~ d(r), 问题出现了, 此时我们的节点在向上回溯的过程中已经到达了 d节点, 但其左节点b的右节点c仍然为红色, 但又很
* 无奈的发现没有办法进行向下深入, 因为处在向上回溯的过程中. 错误就出现了.
*
* 因此综上必须在 左左节点为黑色的情况下才能进行转换.
*/
if (!isRed(node.left.left)) {
node = rotateRight(node);
}
return node;
}
delete
在理解了 deleteMax 与 deleteMin 之后, 再来看, delete 方法就比较容易了.
/**
* 在删除操作中, 每一步都设计的很精巧. 需要多多揣摩, 没有任何一步是冗余设计.
*/
private Node delete(Node node, Key key) {
int cmp = key.compareTo(node.key);
if (cmp < 0) {
/**
* 如果小于则将当前节点变为红色节点, 或从右节点借来一个节点, 无论哪种情况都不会影响继续向左子树深入.
* 从右节点借来的必然替换当前节点, 且大于所有的左子节点.
*/
if (!isRed(node.left) && node.left != null && !isRed(node.left.left)) {
node = moveRedLeft(node);
}
node.left = delete(node.left, key);
} else {
/**
* 如果大于等于, 且节点为红色, 右旋后必定不会等于 0.
*/
if (isRed(node.left)) {
node = rotateRight(node);
}
/**
* 如果右旋后, 结果必定为false, 仅仅是将 cmp 重新赋值而已.
* 如果当前节点为要被删除节点, 同时左节点为红色, 会导致当前节点下沉至右子树;
* 如果不是被删除节点, 同样需要向右深入.
*
* 如果本节点为被删除节点, 同时右节点为null, 且左节点为黑色, 则本节点必然为叶节点.
*
* 还少一种情况, 本节点为被删除节点, 且左节点为黑色不为空节点.需要将本节点变为红色.然后做进一步处理.
*/
if (key.compareTo(node.key) == 0 && node.right == null) {
return null;
}
/**
* 在这里就不难发现, 就是在deleteMax中的, 转换, 将当前节点转换为3-节点:
* 1:需要且可以转换, 则会导致两种情况, 当前节点下沉一个节点, 左节点上浮; 当前节点不变, 只改变颜色.
* 在这里只考虑如果当前节点恰好是被删除节点(因为比较复杂), 如果不是被删除节点, 常规操作, 不多解释.
* 1.1会不断下沉, 直到1.2情况出现, 或第二种情况出现.
* 1.2: 只改变颜色, 意味着, 当前节点, 左右节点构成了一个4-节点, 可以进行删除操作.
* 2:如果颜色不转换, 对应的:
* 如果当前节点为被删除节点: 左节点为黑色, 右节点不为空, 且右左节点为黑色.
* 直接进行下一个 if操作即可, 删除节点.
*
* 不难发现:在下面的删除节点操作中, 当前节点是否为3-/4-节点都不影响删除操作, 因为这里的删除操作事实上
* 是将删除当前节点转换为deleteMin(node.right) 实现的. 而非删除当前节点, 不会影响树平衡性.
* 那为什么要多此一举?做出这一步操作?
* 在下面进行解释:
*/
if (!isRed(node.right) && node.right != null && !isRed(node.right.left)) {
node = moveRedRight(node);
}
if (key.compareTo(node.key) == 0) {
Node temp = min(node.right);
node.value = temp.value;
node.key = temp.key;
/**
* 在这里解释当前节点为被删除节点的第二种情况.左节点为黑色, 右节点不为空, 且右左节点为黑色.
*
* 首先:
* 在这种删除操作中, 需要保证的一点是, 保证树的高度不变. 在正常的 deleteMin方法中, 事实上是允许
* 根节点的高度-1这种情况存在的, 但在这里并不允许这种情况出现:
* 假设根节点 也就是 node.right为根节点的树, 高度 -1, 几种可能:
* 1:左节点为黑色节点, 进行删除的时候, 右节点变红, 同时左旋, 高度减一. deleteMin(node)进行了一个 colorFlip()操作,
* 此时如果说 node.right本来为黑色, 进行操作之后, 高度减一, 因此才需要上面的 if判断语句, 将node.right颜色置为红色,
* 在 colorFlip操作中, 使得高度不变.
*
* 2:左节点为红色节点 此时左左节点也为红色节点, 通过右旋操作, 将右树变红, flipColor变色,根节点颜色由 黑-红, 即完成了一次转换.
* 需要注意到当这种情况时, 上面的if内容并没有执行, 因此 node.right依然为黑色节点. 在变色之后, 变为红色节点.
* 完美的保持了此次转换高度不变.
*
* 为什么要防止高度减一, 则是因为node.right节点未必是根节点, 在后续的对balance()操作中, 并没有办法对 以
* node.right为根节点的树 高度减一的变化产生影响, 导致左右树高度不平衡.
*/
node.right = deleteMin(node.right);
} else {
node.right = delete(node.right, key);
}
}
return balance(node);
}
红黑树的基本也是关键操作到这里已经结束了. 也越来越能体会数据结构的重要性.
在这里进行一个补充:
红黑树的删除操作实现起来实在是不容易, 而在Java的TreeMap中, 同样给出了另一种思路, 也更为简洁明快的操作. 有兴趣的可以看一下我的另一篇博客:
其中的 TreeMap的put() 和 remove()方法, 是实现的核心.