前言
偶然翻出了以前学习红黑树时写的代码,发现有些细节确实记不太清楚了,记得当时学习二叉树的时候没有找到好用的可视化工具,脑内想象和画图也不太好使,于是自己边学边写用js canvas做了一个可视化实现。现在看来还有很多没有处理好的地方,于是花了点时间重新整理了一下代码,同时优化了绘制树的空间占用,这里写篇博客记录一下。
代码已经上传至Github,github.com/ColorfulHor…
不关心实现可以打开html文件直接使用。
实现数据结构
先定义Node类以及通用操作
const RED = 0;
const BLACK = 1;
function Node(value) {
// 树的高度/层次
this.height = 1;
this.value = value;
this.left = null;
this.right = null;
this.parent = null;
// 红黑树节点颜色
this.color = RED;
// 绘制用
this.width = 0;
this.offset = 0;
}
function getHeight(node) {
return node ? node.height : 0;
}
普通二叉树操作
普通二叉树只需要定义一个递归实现的数组转二叉树函数,以便接收用户输入数组生成树
/**
* 数组转二叉树
* @param array
* @param index 当前节点下标
* @param node 当前节点
*/
function BiTreeInsert(array, index, node) {
// 左右子节点下标
let leftIndex = index * 2 + 1;
let rightIndex = leftIndex + 1;
let num;
if (leftIndex >= array.length)
return
num = parseInt(array[leftIndex]);
if (!isNaN(num)) {
let left = new Node(num);
node.left = left;
BiTreeInsert(array, leftIndex, left);
}
if (leftIndex >= array.length)
return
num = parseInt(array[rightIndex]);
if (!isNaN(num)) {
let right = new Node(num);
node.right = right;
BiTreeInsert(array, rightIndex, right);
}
// 计算高度
node.height = Math.max(getHeight(node.left), getHeight(node.right)) + 1;
}
排序树操作
二叉排序树/搜索树是一颗特殊的二叉树,该树左子节点值比父节点小,右子节点值比父节点大,且左右子树也满足此性质。
插入
自顶向下递归查找目标位置进行插入
/**
* 搜索树插入节点
* @param node
* @param value
* @param selfBalance 自平衡为AVL
* @returns {Node|*}
*/
function BSTreeInsert(node, value, selfBalance) {
if (node == null) {
return new Node(value);
}
if (value < node.value) {
node.left = BSTreeInsert(node.left, value, selfBalance);
} else if (value > node.value) {
node.right = BSTreeInsert(node.right, value, selfBalance);
}
// 重新计算高度,递归自底向上
node.height = Math.max(getHeight(node.left), getHeight(node.right)) + 1;
// AVL树平衡用
if (selfBalance) {
node = balanceSelf(node)
}
return node;
}
删除
删除时需要考虑三种情况
- 待删除节点为叶子节点时,直接删除该节点
- 待删除节点只有左子树或者右子树时,删除该节点,将此节点子树接续到双亲节点上
- 待删除节点两边子树都存在时,找到右子树最小节点或者左子树中最大节点(这两种做法等效)InsteadNode,然后从子树中删除InsteadNode(此时转为情况1或2),最后交换待删除节点和InsteadNode的值
如上图中删除节点5,先将节点6的值赋给5,然后删除节点6,由情况3转为情况2,最终是删除了节点5的值和节点7的位置。
/**
* 搜索树删除节点
* @param node
* @param value
* @param selfBalance 自平衡为AVL
* @returns {Node|*}
*/
function BSTreeRemove(node, value, selfBalance) {
if (node == null) {
return null;
}
if (value < node.value) {
node.left = BSTreeRemove(node.left, value, selfBalance);
} else if (value > node.value) {
node.right = BSTreeRemove(node.right, value, selfBalance);
} else {
// 当前节点为待删除节点
if (node.left == null && node.right == null) {
// 情况1
return null;
} else if (node.left == null || node.right == null) {
// 情况2
return node.left == null ? node.right : node.left;
} else {
// 情况3
// 左右子节点都不为空,找到右子节点的最小子节点
let min = getBSTreeMin(node.right);
// 删除最小子节点
node.right = BSTreeRemove(node.right, min.value, selfBalance);
node.value = min.value;
}
}
node.height = Math.max(getHeight(node.left), getHeight(node.right)) + 1;
// AVL树平衡用
if (selfBalance) {
node = balanceSelf(node)
}
return node;
}
AVL树操作
普通排序树在极端情况下(节点值为递增/递减数列时)会退化成单链表,导致效率降低到O(N),由此发明了AVL树。它是一颗平衡的排序树,保证所有节点的左右子树高度差不超过1,所以需要在插入和删除操作后进行自平衡调整。
在插入/删除节点后导致某一子树不平衡时,通过右旋和左旋操作减小当前子树的高度,回归平衡。需要旋转的子树分为LL RR LR RL四种情况,这里只分析RR和RL两种情况,其他两种情况只是方向反过来而已。
RR
上图中两种都是RR型,左边为简单的RR,第一个R表示当前树右子树高度 - 左子树高度 > 1,第二个R表示右子树的右子树高度 - 右子树左子树高度 >= 0。
分析右边,以子树5为目标,以节点8为中心左旋,5和9分别变为8的左右子树,然后节点8的左子树6变为节点5的右子树(5.right = 5.right.left)。
RL
上图为简单的RL,需要进行两次旋转,先将子树8进行右旋,然后将子树5进行左旋,最终达到平衡。
AVL树的插入删除函数不需要额外定义了,只需要在排序树的插入/删除之后调用balanceSelf函数进行自平衡,这里定义自平衡以及旋转函数如下。
function getHeight(node) {
return node ? node.height : 0;
}
/**
* 获取平衡因子
* @param node
* @returns {number}
*/
function getBalanceFactor(node) {
let lh = getHeight(node.left);
let rh = getHeight(node.right);
return lh - rh;
}
/**
* 右旋
* @param node
* @returns {*}
*/
function rightRotate(node) {
let res = node.left;
node.left = res.right;
res.right = node;
node.height = Math.max(getHeight(node.left), getHeight(node.right)) + 1;
res.height = Math.max(getHeight(res.left), getHeight(res.right)) + 1;
return res;
}
/**
* 左旋
* @param node
* @returns {*}
*/
function leftRotate(node) {
let res = node.right;
node.right = res.left;
res.left = node;
node.height = Math.max(getHeight(node.left), getHeight(node.right)) + 1;
res.height = Math.max(getHeight(res.left), getHeight(res.right)) + 1;
return res;
}
/**
* 排序树自平衡
* @param node
* @returns {*}
*/
function balanceSelf(node) {
if (getBalanceFactor(node) > 1 && getBalanceFactor(node.left) >= 0) {
// LL 右旋
node = rightRotate(node);
}
if (getBalanceFactor(node) < -1 && getBalanceFactor(node.right) <= 0) {
// RR
node = leftRotate(node);
}
if (getBalanceFactor(node) > 1 && getBalanceFactor(node.left) < 0) {
// LR 左旋左子树,然后右旋本身
node.left = leftRotate(node.left);
node = rightRotate(node);
}
if (getBalanceFactor(node) < -1 && getBalanceFactor(node.right) > 0) {
// RL
node.right = rightRotate(node.right);
node = leftRotate(node);
}
return node;
}
红黑树
红黑树也是一种特殊的排序树,相对于AVL树它又不那么平衡,复读一下红黑树性质
- 每个结点要么是红的要么是黑的。
- 根结点是黑的。
- 每个叶结点都是黑的(定义真实叶子节之下的nil/null节点为叶节点,nil/null节点视为黑色),如下图。
- 红节点的两个子节点都是黑色(父子节点不能同时为红)。
- 任意节点到叶子节点的黑节点数量都相同,即黑色平衡。
红黑树只保证了黑色平衡,由于其性质限制,保证了它从任意节点到任意叶子节点的最长搜索路径不超过最短搜索路径的两倍,达到相对平衡。相对于AVL树它的平衡性没那么好,所以搜索效率低于AVL树,但是它在删除节点进行自平衡操作时效率通常高于AVL树。
插入
插入时节点默认颜色为红色(尽量不影响黑色平衡),插入后需要通过变色和旋转操作进行自平衡,主要分为一下几种情况
- 插入节点的父节为黑色,此时不影响平衡,无需额外操作
- 插入节点的父节点为红色,同时父节点的兄弟节点也为红色,此时 祖父节点->父节点->插入节点 = 黑->红->红,通过变色将三层颜色变为 红->黑->红即可。下图变为红/黑/红后由于根节点必须为黑色所以将5也变为黑色。
- 插入节点的父节点为红色,父节点的兄弟节点为黑色或者null,此时也分为LL RR LR RL四种情况,这里图示LL和LR的情况,其他两种情况与之对称
看一下插入代码实现,插入时需要记录父节点,插入完成后将当前节点视为祖父节点,进行自平衡操作
/**
* 红黑树插入节点
* @param node
* @param value
* @returns {Node|*}
* @constructor
*/
function RBTreeInsert(node, value) {
if (node == null) {
return new Node(value);
}
if (value < node.value) {
node.left = RBTreeInsert(node.left, value);
node.left.parent = node;
} else if (value > node.value) {
node.right = RBTreeInsert(node.right, value);
node.right.parent = node;
}
node.height = Math.max(getHeight(node.left), getHeight(node.right)) + 1;
// 将当前节点作为祖父节点检查是否需要自平衡操作
if (isRed(node.left) && isRed(node.right)) {
// 插入节点的父节点为红色,父节点的兄弟节点也为红色,
// 也就是 祖父节点->父节点->插入节点 = 黑->红->红,将三层颜色变为 红->黑->红 即可
if (isRed(node.left.left) || isRed(node.left.right) || isRed(node.right.right) || isRed(node.right.left)) {
node.left.color = BLACK;
node.right.color = BLACK;
node.color = RED;
}
} else if (isRed(node.left)) {
// 插入节点的父节点为红色,父节点的兄弟节点为黑色
if (isRed(node.left.left)) {
// LL
// 右旋祖父节点
node.color = RED;
node.left.color = BLACK;
node = rightRotate(node);
} else if (isRed(node.left.right)) {
// LR
node.left = leftRotate(node.left);
node.color = RED;
node.left.color = BLACK;
node = rightRotate(node);
}
} else if (isRed(node.right)) {
if (isRed(node.right.right)) {
// RR
node.color = RED;
node.right.color = BLACK;
node = leftRotate(node);
} else if (isRed(node.right.left)) {
// RL
node.right = rightRotate(node.right);
node.color = RED;
node.right.color = BLACK;
node = leftRotate(node);
}
}
return node;
}
删除
删除时情况更为复杂,首先根据排序树的删除一样的思路将删除任意节点问题都转化为删除此节点的叶子节点的问题。
- 删除节点为叶子节点直接删除
- 删除节点有一个子节点时,子节点值赋给当前节点,删除子节点,变为情况1
- 删除节点有两个子节点时,获取右子树最左节点作为替代节点,将替代节点值赋给删除节点,然后删除替代点,变为情况1或2(替代节点有右子树时变为情况2,否则为1)
/**
* 红黑树删除节点
* @param node
* @param value
* @returns {null|*}
* @constructor
*/
function RBTreeRemove(node, value) {
if (node == null) {
return;
}
if (value < node.value) {
RBTreeRemove(node.left, value);
} else if (value > node.value) {
RBTreeRemove(node.right, value);
} else {
// 该节点为要删除的节点
if (node.left == null && node.right == null) {
// case1 删除节点为叶子节点
fixRBNode(node);
// 修复完成后再删除节点
if (node === node.parent.left) {
node.parent.left = null;
} else {
node.parent.right = null;
}
node.parent = null;
} else if (node.left == null || node.right == null) {
// case2 删除节点有一个子节点
// -
// / \
// del -
// / / \
// child - -
// 删除本节点相当于删除它的子节点child
let child = node.left == null ? node.right : node.left;
node.value = child.value;
// 转为case1
RBTreeRemove(child, child.value);
} else {
// case3 删除节点有两个子节点,找到右子节点的最小叶子节点(后继)
// -
// / \
// del -
// / \ / \
// - - - -
// /
// child <- 相当于删除这个节点
// \
// -
let min = getBSTreeMin(node.right);
node.value = min.value;
// 转为case1或case2
RBTreeRemove(min, min.value);
}
}
node.height = Math.max(getHeight(node.left), getHeight(node.right)) + 1;
}
平衡/修复
删除叶子节点时,需要根据不同情况先对红黑树进行平衡,之后再删除
- 删除节点为红色,此时直接删除,不需要额外操作,因为删除红节点不会导致黑高发生变化
- 删除节点为黑色,此时有八种不同情况(两两对称),需要分别处理。删除节点为黑色时,一边子树黑高减少1,为了黑色平衡首先需要从兄弟节点拿一个红节点过来变为黑色(变色旋转操作)保证平衡;当兄弟节点也无法提供红节点时,则需要向上找父节点要红节点,尽量在小范围内保持平衡(黑高不变)。 下面讨论删除节点为黑色,且该节点为父节点的左子节点时的4种情况,另外4种情况由于对称性不再展开。
- 删除节点的兄弟节点为黑色
- case1,兄弟节点右子节点为红,左子节点为任意颜色或null,可看作RR型,即删除节点的兄弟节点为父节点的右子节点(R),红节点为兄弟节点的右子节点(R)
- case2,兄弟节点左子节点为红,右子节点为黑或者null,可看作RL型,这里同样需要进行两次旋转,先将兄弟节点右旋,然后左旋父节点
- case3,兄弟节点没有红色子节点(子节点为null或黑色),此时无法再从兄弟节点处获取红节点了,需要向上回溯,转为向父节点要红节点。将兄弟节点设为红色,然后把父节点当作修复节点自底向上继续修复,直到修复节点为红色结束,最后将修复节点置为黑色(相当于以此红节点弥补删除掉的黑节点)
- case1,兄弟节点右子节点为红,左子节点为任意颜色或null,可看作RR型,即删除节点的兄弟节点为父节点的右子节点(R),红节点为兄弟节点的右子节点(R)
- 删除节点的兄弟节点为红色
- case4 此时只有一种情况,因为红节的父节点和子节点都只能为黑色(null也视为黑色),交换兄弟节点和父节点颜色,然后左旋父节点,此时变为删除节点的兄弟节点为黑色的情况,回溯到case1/case2/case3继续处理
- case4 此时只有一种情况,因为红节的父节点和子节点都只能为黑色(null也视为黑色),交换兄弟节点和父节点颜色,然后左旋父节点,此时变为删除节点的兄弟节点为黑色的情况,回溯到case1/case2/case3继续处理
删除节点为父节点的右子节点时四种情况和上述四种情况对称,不再赘述
删除前修复节点代码
/**
* 删除前修复
* @param node
*/
function fixRBNode(node) {
while (node.parent != null && node.color === BLACK) {
let parent = node.parent;
let grand = parent.parent;
let brother;
if (node.value < parent.value) {
// 删除节点为父节点的左子节点
brother = parent.right;
if (!isRed(brother)) {
// 删除节点的兄弟节点为黑色
if (isRed(brother.right)) {
// case1 兄弟节点的右节点为红,左节点任意颜色,即RR
// 向因为左子树删除了黑节点,所以将右子树的红节点拿过来变黑即可
// 红/黑 黑 红/黑 红/黑
// / \ / \ / \ / \
// del 黑 => del 红/黑 => 黑 黑 => 黑 黑
// / \ / \ / \ \
// - 红 - 黑 del - -
brother.color = parent.color;
// 父节点的颜色给到兄弟节点,父节点和兄弟节点的右子节点都变黑色,左旋
parent.color = BLACK;
brother.right.color = BLACK;
parent = leftRotate(parent);
linkParent(parent, grand);
break;
} else if (!isRed(brother.right) && isRed(brother.left)) {
// case2 兄弟节点的左节点为红,右子节点为黑色或null,即RL
// 红/黑 红/黑 红/黑
// /\ / \ 右旋兄弟 / \
// del 黑 => del 红 => del 黑 => 转为case1 RR
// / / \
// 红 黑 红
brother.left.color = BLACK;
brother.color = RED;
brother = rightRotate(brother);
linkParent(brother, parent);
} else if (!isRed(brother.left) && !isRed(brother.right)) {
// case3 兄弟节点无子节点或子节点都为黑
// 红/黑 红/黑 <- 指定为新的平衡点,然后自底向上修复
// /\ => / \
// del 黑 del 红
// / \ / \
// 黑 黑 黑 黑
brother.color = RED;
node = parent;
}
} else {
// case4 兄弟节点为红色,则父节点为黑节点,兄弟节点有两个黑节点
// 黑 红 黑
// / \ / \ 左旋 / \
// del 红 => del 黑 => 红 黑 => 转为case1/case2/case3
// / \ / \ / \
// 黑 黑 黑 黑 del 黑
brother.color = BLACK;
parent.color = RED;
parent = leftRotate(parent);
linkParent(parent, grand);
}
} else {
// 删除节点为父节点的右子节点
brother = parent.left;
if (!isRed(brother)) {
// 删除节点的兄弟节点为黑色
if (isRed(brother.left)) {
// case5 兄弟节点的左子节点为红,右节点为任意颜色,即LL
brother.color = parent.color;
// 父节点的颜色给到兄弟节点,父节点和兄弟节点的右子节点都变黑色,左旋
parent.color = BLACK;
brother.left.color = BLACK;
parent = rightRotate(parent);
linkParent(parent, grand);
break;
} else if (!isRed(brother.left) && isRed(brother.right)) {
// case6 兄弟节点的右节点为红,左子节点为黑色或null,即LR
brother.right.color = BLACK;
brother.color = RED;
brother = leftRotate(brother);
linkParent(brother, parent);
} else if (!isRed(brother.left) && !isRed(brother.right)) {
// case7 兄弟节点无子节点或子节点都为黑
brother.color = RED;
node = parent;
}
} else {
// case8 兄弟节点为红色,则父节点为黑节点,兄弟节点有两个黑节点
brother.color = BLACK;
parent.color = RED;
parent = rightRotate(parent);
linkParent(parent, grand);
}
}
}
// 向上回溯结束时变色
node.color = BLACK;
}
可视化
测量
绘制一颗二叉树其实很简单,DFS遍历然后画出来就好了,但是由于普通二叉树可能非常不平衡,导致多出大量空白空间,所以在绘制前应该先对整个树的宽度进行测量,同时用offset记录左右子节点相对于当前节点的偏移距离,尽量减少空白空间。
- 左右子树有一边为空时,使当前子树宽度等于存在一边的宽度+空隙,这样省去了空子树的空间
- 左右子树不平衡时,尽可能拉近它们的距离
3. 通过上面两步压缩空间后有可能会出现另一种情况,需要对它进行修复,通过切线和三角函数调整子树到不会交叉的位置。
// 节点半径
var radius = 20;
// 兄弟节点间距
var spacing = 20;
// 每层间距,父节点到子节点圆心距离
var height = radius * 2 + 30;
// 画布留白
var padding = 20;
// 树
var root = null;
// canvas context
var ctx = null;
/**
* 自底向上测量整个树宽度以及各节点位置
* @param node
*/
function measure(node) {
if (node == null) {
return;
}
if (!node.left && !node.right) {
node.isLinkedList = true;
node.width = radius * 2;
return;
}
measure(node.left);
measure(node.right);
// 左右子树宽度
let leftWidth = getWidth(node.left);
let rightWidth = getWidth(node.right);
let fixNode = null;
if (!node.left || !node.right) {
node.width = leftWidth + rightWidth;
node.width += spacing;
node.offset = spacing / 2;
} else {
// 当前为满二叉树时子树应该占据的空间
let childSpace = Math.max(rightWidth, leftWidth);
let factor = getHeight(node.left) - getHeight(node.right);
node.width = childSpace * 2;
node.offset = childSpace / 2;
// 宽度比较小子树的边缘到当前树中线的空隙间距,这部分空白可以去掉
let mixWidth = Math.abs(leftWidth - rightWidth) / 2;
if (factor > 0) {
if (leftWidth >= rightWidth) {
// 较高的子树宽度大于较低的子树时,减去较高子树对应较低子树最后一层的边缘到中线距离
// 实际上这部分距离只有在较高子树为满二叉树时才足够准确,如果包含很多单链表这个值就会差很多
let div = Math.pow(2, getHeight(node.right));
let leftSpace = leftWidth / div - radius;
if (leftSpace < 0) {
leftSpace = 0;
}
mixWidth += leftSpace;
fixNode = node.left;
}
} else if (factor < 0) {
if (rightWidth >= leftWidth) {
let div = Math.pow(2, getHeight(node.left));
let rightSpace = rightWidth / div - radius;
if (rightSpace < 0) {
rightSpace = 0;
}
mixWidth += rightSpace;
fixNode = node.right;
}
}
node.width -= mixWidth;
node.offset -= mixWidth / 2;
// 节点之间添加空隙
node.width += spacing;
node.offset += spacing / 2;
// 左右子节点圆心距离
let distance = childSpace - mixWidth + spacing;
if (fixNode) {
let fix = fixWidth(fixNode.offset, distance);
node.width += fix;
node.offset += fix / 2;
}
}
}
/**
* 修复连接线和节点重合问题
* @param offset 子树N圆心到其下一层子树NChild圆心的距离
* @param distance 子树N圆心到其兄弟节点NBrother圆心的距离
* @returns {number}
*/
function fixWidth(offset, distance) {
// 这里通过判断夹角是否比切线夹角小确定是否有连接线和节点重叠
if (Math.atan(height / offset) < Math.asin(radius / distance)) {
// let sin = radius / (x + distance);
// let tan = height / (x + f);
// tan = sin/sqrt(1-sin^2)
let sR = radius * radius;
let sF = offset * offset;
let sD = distance * distance;
let sH = height * height;
// 一元二次方程求根
let a = sR - sH;
let b = 2 * (sR * offset - sH * distance);
let c = sH * sR + sR * sF - sH * sD;
let res = (-b + Math.sqrt(b * b - 4 * a * c)) / (2 * a);
let res2 = (-b - Math.sqrt(b * b - 4 * a * c)) / (2 * a);
return res >= 0 ? res : res2;
}
return 0;
}
绘制
测量好整个树之后绘制起来当然是非常简单的,先设置画布,然后递归进行绘制就好了
/***
* 重新设置画布大小
*/
function initCanvas() {
canvas = document.getElementById('canvas');
const cvHeight = (root.height - 1) * height + radius * 2 + padding * 2;
const cvWidth = root.width + padding * 2;
canvas.style.height = cvHeight + 'px';
canvas.style.width = cvWidth + 'px';
canvas.height = cvHeight;
canvas.width = cvWidth;
ctx = canvas.getContext('2d');
ctx.strokeStyle = '#000';
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold ' + 20 + 'px serif';
}
/**
* 绘制树
* @param node
* @param x
* @param y
* @param color
*/
function render(node, x, y, color = false) {
if (node == null) {
return;
}
if (node.left != null) {
let lx = x - node.offset;
let ly = y + height;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(lx, ly);
ctx.stroke();
render(node.left, lx, ly, color);
}
if (node.right != null) {
let rx = x + node.offset;
let ry = y + height;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(rx, ry);
ctx.stroke();
render(node.right, rx, ry, color);
}
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI)
ctx.closePath();
if (color) {
ctx.fillStyle = node.color === RED ? 'red' : 'black';
ctx.fill('nonzero');
ctx.fillStyle = 'white';
} else {
ctx.fillStyle = 'white';
ctx.fill('nonzero');
ctx.fillStyle = 'red';
}
ctx.stroke();
ctx.fillText(node.value.toString(), x, y);
}