程序员内功心法(二叉树搜索树、AVL树、234树、红黑树汇总)
在生活中我们经常会使用到搜索的功能。在我们数据量不大的情况下,可以使用每次遍历
全部数据,查询我们的目标数据。当数据量增加时,我们遍历的方式就有些力不从心了;也可以将数据的数据排序,使用比较高效的二分查找
方式,但是在插入或删除数据时,数组
表现就会很慢。所以我们可以结合二分查找
查询的高效 + 链表
添加删除的高效性来实现高效搜索(符号表)的情况
下面我将列举一些树的内容定义(后续所有的代码使用Java语言实现)
树由节点构成,每个节点都有一个父节点(根结点不存在父节点)
节点包含的链接可以指向不存在的
NULL
或者其他真实存在的节点每个节点都可以包含多个子链接,将子链接的个数称为
度
;树的度是指所有的子节点中最大的度(将度为2的树称为二叉树、度为3的树称为三叉树等)。如图1~3示叶节点:没有子节点的节点
如图-1的B、C、D节点
父节点:有子树的节点是其子树的根节点的父节点
图-1的A节点是B、C、D节点的父节
子节点:若A节点是B节点的父节点,那么B是A的子节点,子节点也可以成为孩子节点
图-3的A节点是B、C、D的父节点,同时也是H节点的子节点
兄弟节点:具有相同一个父节点的个节点彼此是兄弟节点
图-1的B、C、D
每个节点只指向一个父节点,最多包含左右两个子链接
左子边节点的Key小于父节点、右子节点的Key大于父节点 如图-4示
//二叉搜索树的节点定义
public class TreeNode<T extends Comparable<T>>{
T data;
TreeNode<T> left;
TreeNode<T> right;
int size;
}
//二叉搜索树的定义
public class BinarySearchTree<T extends Comparable<T>> {
private TreeNode<T> root;
}
//查询算法
public TreeNode<T> find(T element, TreeNode<T> node){
if (Objects.isNull(node)) {
return null;
}
T val = node.data;
int res = val.compareTo(element); //和node节点比较
if (res == 0) { //等于node的值,表示查询到
return node;
}
if (res < 0) { //节点的值小于要查询的值,向右递归
return find(element, node.getRight());
}
return find(element, node.getLeft()); //节点的值大于查询的值,向左递归
}
根据查找二叉树的特性,极值存在于叶节点或者只包含一个子节点的父节点中
//查询极小值,一直向左查询,如果没有左节点,则认为当前节点最小 例子:A节点
public TreeNode<T> findMin(TreeNode<T> node){
if (Objects.isNull(node.getLeft())) {
return node;
}
return findMin(node.getLeft());
}
//查询极大值,一直向右查询,如果没有右节点,则认为当前节点最大 例子:Z节点
public TreeNode<T> findMax(TreeNode<T> node){
if (Objects.isNull(node.getRight())) {
return node;
}
return findMax(node.getRight());
}
å图-7展示了插入Z的作为F右子节点的情况(插入到左子节点的情况类似,不再赘叙)
图-8展示了被插入节点存在的情况。
public void add(T element) {
if (element == null) {
throw new RuntimeException("数据不能为NULL");
}
TreeNode<T> node = new TreeNode<>();
node.data = element;
if (Objects.isNull(root)) {
root = node;
return;
}
addImpl(root, node);
}
private void addImpl(TreeNode<T> root, TreeNode<T> node) {
T val = root.data;
T element = node.data;
int sub = element.compareTo(val);
//包含要插入的值,不处理
if (sub == 0) {
return;
}
//插入的值大于根节点的值,将新节点作为根节点的右子节点
if (sub > 0) {
TreeNode<T> right = root.getRight();
if (Objects.isNull(right)) {
root.setRight(node);
return;
}
addImpl(right, node);
} else { //插入的值小于根节点的值,将新节点作为根节点的左子节点
TreeNode<T> left = root.getLeft();
if (Objects.isNull(left)) {
root.setLeft(node);
return;
}
addImpl(root.getLeft(), node);
}
}
由于删除节点比较复杂,我们先看下删除极大值(极小值)的情况,为节点删除做好准备工作
由于二叉搜索树的特点二(左子边节点的Key小于父节点、右子节点的Key大于父节点)那么最小值节点要么是叶子节点或者包含右子节点的情况
极小值节点是叶子节点,可以直接移除
极小值节点有一个右子节点,将右子节点替换为父节点(如果还包含左子节点,那么当前节点非最小值)
//移除最小的节点,将返回的值作为根节点
private TreeNode<T> deleteMin(TreeNode<T> node) {
if (Objects.isNull(node.getLeft())) { //没有左子节点,返回右子节点
return node.getRight();
}
TreeNode<T> min = deleteMin(node.getLeft()); //递归左子树
node.setLeft(min);
return node;
}
和删除最小值的情况相似。只不过递归的是右子树
极大值节点是叶子节点,可以直接移除
极大值节点有一个左子节点,将左子节点替换为父节点(如果还包含右子节点,那么当前节点非最大值)
//移除node节点的最大值,使用返回值替换原节点
public TreeNode<T> deleteMax(TreeNode<T> node) {
if (Objects.isNull(node.getRight())) {
return node.getLeft();
}
TreeNode<T> max = deleteMax(node.getRight());
node.setRight(max);
return node;
}
我们将删除节点的情况归纳如下
被删除节点是叶子节点,可以直接移除
被删除节点只包含一个子节点(左子节点或者右子节点),我们需要需要将子节点替换到父节点
被删除节点包含两个子节点,如果直接移除E节点,那么子节点D、F将会丢失。我们需要转换思路,将包含两个子节点的情况转换为上两种情况。下面我们介绍下如何处理(T.Hibbard 1962年提出的方法,膜拜巨佬)
我们使用前驱节点(后续节点)的值替换被删除节点,然后删除前驱节点(后继节点)
前驱节点:当前节点的左子树中的最大值
后继节点:当前节点的右子树中的最小值
//删除element的节点,返回根结点的引用
public TreeNode<T> delete(T element, TreeNode<T> node){
if (Objects.isNull(node)) {
return null;
}
T val = node.data;
int res = val.compareTo(element);
if (res < 0) { //被删除节点在node的右子树
TreeNode<T> rNode = delete(element, node.getRight());
node.setRight(rNode);
} else if (res > 0) { //被删除节点在node的左子树
TreeNode<T> lNode = delete(element, node.getLeft());
node.setLeft(lNode);
} else { //node为被删除节点
//包含一个子节点,使用子节点替换父节点
if (Objects.isNull(node.getLeft())) {
return node.getRight();
}
if (Objects.isNull(node.getRight())) {
return node.getLeft();
}
//左右节点均存在,使用后继节点代替,移除后继节点
TreeNode<T> tmp = node;
node = findMin(node.getRight());
TreeNode<T> rNode = deleteMin(tmp.getRight());
node.setRight(rNode);
node.setLeft(tmp.getLeft());
}
return node;
}
至此,我们已经完成了二叉搜索树的增加、查询、删除的方法。我们发现二叉搜索树的实现并不困难,并且在大多数场景下也能正常运行。二叉搜索树在极端情况的性能也是不可忍受的。
后面我们将讲述一种在任何场景初始化,运行时间都将是对数级的
接上面二叉树搜索树了解到二叉搜索树在极端情况也不能满足我们对于查询性能的要求。
第n层最多的节点个数2n-1
高度为h的二叉树,最多包含2h-1个节点,所以n个节点的二叉树的最小高度是log2n + 1
查找成功时,查找次数不会超过树的高度h
我们下面来使用 A - H字符来观察二叉搜索树在不同的插入顺序下构造的树的结果
自然顺序的平均查找长度为ASL=(1+ 2 + 3 + 4+ 5+ 6+ 7 +8) / 8 = 4.5
计算特定顺序的平均查找长度
ASL=(1 + 2*2 + 3*4 + 4*1) / 8 = 2.6
当我们数据相同,但是采用不同的插入顺序,使平均查找长度不一样。所以我们要解决这个问题,先观察两个初始化方式两个树的特点,大致发现使用特定顺序初始化的树,感觉树的节点分布比较平衡。由于统计特点3和特点2,我们希望n个节点的二叉树的接近log2n + 1,那么我们就可以最大化的提升查询性能.
所以为了解决这个问题,我们引入新的二叉搜索树实现-平衡二叉树(AVL树)
平衡因子BalanceFactor:左右子树的高度差BF=HL - HR
规定左右子树的高度差的绝对值不超过1 |BF| ≤ 1
原有节点的基础上增加height属性
class AVLNode<T extends Comparable<T>> {
private T data;
//左节点
private AVLNode<T> left;
//右节点
private AVLNode<T> right;
//当前节点的高度
private int height;
}
由于平衡二叉树的平衡指高度方面的平衡,我们先来计算树的高度
树的高度H指:左HL右HR子树高度的最大值 + 1
int height(AVLNode<T> node){
if (Objects.isNull(node)) {
return 0;
}
int rHeight = height(node.getRight());
int lHeight = height(node.getLeft());
return Math.max(rHeight, lHeight) + 1;
}
由于平衡二叉树也是二叉查找树的一种,查询方式和二叉搜索树相同,不再赘述。
为了保证左右平衡,所以我们一系列的操作来维持左右子树的高度在BF规定的范围之内
空树时,直接初始化为根结点。
针对作为子节点的插入,插入节点只能为被插入节点的左节点B或者右节点F。而被插入节点D可以是其父节点G的左节点或其父节点A的右节点。所以我们将所有情况分为4类:GDB路径(LL插入)、GDF路径(LR插入)、ADF路径(RR插入)、ADB路径(RL插入)
接下来我们将处理所有的情况
RR插入
当插入节点在右子树的右节点上(ADF路径)
操作步骤:
将右子节点D作为根节点
原根节点A作为新根节点D的左子节点
将D节点的左子节点B设置为原根节点A的右子节点
实现代码如下:
AVLNode<T> singleRightRotation(AVLNode<T> node) {
AVLNode<T> result = node.getRight();
AVLNode<T> left = result.getLeft();
node.setRight(left);
result.setLeft(node);
return result;
}
LL插入
当插入的节点在左子树的左节点上(GDB路径)
操作步骤:
将左子节点D作为根结点
原根节点G作为新根节点D的右子节点
将D节点的右子节点F作为原结点G的左子节点
实现代码:
AVLNode<T> singleLeftRotation(AVLNode<T> node) {
AVLNode<T> result = node.getLeft();
AVLNode<T> right = result.getRight();
node.setLeft(right);
result.setRight(node);
return result;
}
RL插入
当插入的节点在右子树的左节点上(ADB路径)
操作步骤:
针对A节点的右子节点D做左旋转
针对A节点做右旋转
实现代码:
AVLNode<T> doubleRightLeftRotation(AVLNode<T> node){
AVLNode<T> right = singleLeftRotation(node.getRight());
node.setRight(right);
return singleRightRotation(node);
}
LR插入
当插入的节点在右子树的左节点上(GDF路径)
操作步骤:
针对G节点的左子节点D做右旋转
针对G节点做左旋转
实现代码:
AVLNode<T> doubleLeftRightRotation(AVLNode<T> node) {
AVLNode<T> left = singleRightRotation(node.getLeft());
node.setLeft(left);
return singleLeftRotation(node);
}
我们在删除节点时,思路如下:
叶子节点直接删除
包含一个子节点,将子节点替换到父节点
包含两个子节点,使用后继节点替换被删除节点,删除后继节点即可
平衡调整的思路:节点被删除后,相当于在兄弟节点插入新的节点
代码如下:
AVLNode<T> delete(AVLNode<T> node, T data) {
if (Objects.isNull(node)) {
returnreturn null;
}
T nodeData = node.getData();
int flag = data.compareTo(nodeData);
if (flag > 0) { //右子树
AVLNode<T> right = delete(node.getRight(), data);
node.setRight(right);
AVLNode<T> lNode = node.getLeft();
int rHeight = getHeight(right);
int lHeight = getHeight(lNode);
int bf = lHeight - rHeight;
if (bf == 2) {//右子树被删除节点,不平衡
//查看左兄弟节点,如果左兄弟有右子节点高度大于左子节点需要进行左右旋转 (删除情况2)
if (getRightNodeHeight(lNode) > getLeftNodeHeight(lNode)) {
node = doubleLeftRightRotation(node);
} else { //右节点的高度小于或者等于左子节点的高度,左单旋即可(删除情况1)
node = singleLeftRotation(node);
}
}
} else if (flag < 0) { //左子树
AVLNode<T> left = delete(node.getLeft(), data);
node.setLeft(left);
AVLNode<T> right = node.getRight();
int lHeight = getHeight(node.getLeft());
int rHeight = getHeight(right);
int bf = rHeight - lHeight;
if (bf == 2) {//左子树被删除节点,不平衡
//查看右兄弟节点,如果左子节点高度大于右子节点高度,进行右左旋转 (删除情况4)
if ( getLeftNodeHeight(right) > getRightNodeHeight(right)) {
node = doubleRightLeftRotation(node);
} else {
//左子树的高度小于等于右子节点的高度,左单旋转即可(删除情况3)
node = singleRightRotation(node);
}
}
} else { //found
if (Objects.nonNull(node.getLeft()) && Objects.nonNull(node.getRight())) { //存在左右子节点
AVLNode<T> rMin = findMin(node.getRight()); //后继节点替代
node.setData(rMin.getData());
delete(node.getRight(), rMin.getData()); //删除后继节点
} else {
node = Objects.isNull(node.getLeft()) ? node.getRight() : node.getLeft();
}
}
if (Objects.nonNull(node)) {
buildHeight(node);
}
return node;
}
红黑树、B树、B+树,都是软件开发中一个比较难理解和掌握的知识点。他们的本质依然是平衡二叉搜索树。如果直接去学习红黑树、B树、B+树的知识点,无异于雾里看花。这次我们从这些数据结构的底层逻辑设计出发,不牵扯任何代码层面上的内容。
二节点
一个key和左右两个链接;其中key大于左链接、小于右链接
三节点
包含两个key和三个链接(两个key分别称为key1和key2,key1小于key2)
1、2、3三个子链接(子链接1的key小于根结点key1、子链接2的key大于根结点key1且小于根结点key2、子链接3的key大于根结点key2)
四节点
包含三个key和四个子链接(三个key分别为key1、key2、key3且从小到大排列)
1、2、3、4三个子链接(子链接1的key小于根结点key1、子链接2的key大于根结点key1且小于根结点key2、子链接3的key大于根结点key2且小于根结点key3、子链接4的key大于根结点key3)
上述的节点计数指子链接的数量,而非节点包含的key的数量
由于2、3、4树的查询操作和二叉搜索树的操作一致,不再赘叙。本次主要完成插入和删除的操作描述
可以参考前面,熟悉二叉树一些基本定义和操作
我们把1-10的数字拆入到一棵234树中
依次插入1、2、3节点
插入4节点,需要将4节点分裂成3个2节点的操作
至此,插入逻辑介绍完毕
节点的删除逻辑,和二叉树的删除逻辑区别不大。如果是叶子节点,可以直接删除;如果是非叶子节点,需要转换为后继/前驱节点的删除方式,所有都可以转换为极值的删除
非2节点的删除
2节点的删删除
对于2节点的删除,需要转换为3、4节点中节点的删除
父节点为非2节点,兄弟节点是2节点
父节点是非2节点,兄弟节点是非2节点
父节点是2节点,兄弟节点非2节点
父节点是2节点,兄弟节点也是2节点
至此,我们的234树的插入和删除操作介绍完了。搞清楚234树的插入和删除操作将是后续红黑树、B树、B+树的前置条件。
4
从上面的2-3-4树了解到底层原理和操作逻辑,但按照对应逻辑实现代码和各种情况的处理,却不容易。所以我们要减少由于2-3-4树为了实现平衡,而导致的实现复杂度上升的情况。我们现在使用普通的二叉树+颜色来表示2-3-4树(红黑树是多路平路查找树的一种实现)
红黑树的定义:
红链接必须是左链接,根结点必须是黑色的
不能同时存在两条连续的红链接
任一空链接到根节点的路径上经历的黑色节点个数一样
下面我们使用1-3的插入来观察红黑树是如何保持平衡的
根据上面根据上面的操作我们可以发现红黑树对2-3-4树的实现原理:
使用黑+红两个节点来实现3节点(如上图插入2后)
使用三个黑色节点实现4节点(如上图插入3后)
RedBlackNode<T extends Comparable<T>> {
/*颜色的常量定义 red:false black:true 新建节点默认为红色*/
public static final boolean RED = false;
public static final boolean BLACK = true;
private T data;
private RedBlackNode<T> left;
private RedBlackNode<T> right;
private boolean color;
}
操作
我们将红黑树的操作分开描述
查找和普通的二叉搜索树一致,不再赘叙。
可以参考关于查找的部分
左旋转
实现步骤:
右子节点的颜色 = 原根结点的颜色
根结点node作为右子节点的左子节点,刷新为红色节点
将右子节点的左子节点设置为原根结点的右子节点
代码示例:
RedBlackNode<T> rotateLeft(RedBlackNode<T> node){
RedBlackNode<T> right = node.getRight();
right.setColor(node.isColor());
RedBlackNode<T> middle = right.getLeft();
node.setRight(middle);
node.setColor(RedBlackNode.RED);
right.setLeft(node);
return right;
}
右旋转
将根结点的左子节点替换到根结点,将左子节点作为根结点返回
实现步骤:
左子节点的颜色 = 原根结点的颜色
根结点node替换到左子节点的右子节点,刷新为红色节点
将左子节点的右子节点设置为原根结点的左子节点
代码示例:
RedBlackNode<T> rotateRight(RedBlackNode<T> node){
RedBlackNode<T> result = node.getLeft();
result.setColor(node.isColor());
RedBlackNode<T> resultRight = result.getRight();
node.setLeft(resultRight);
result.setRight(node);
node.setColor(RedBlackNode.RED);
return result;
}
/**如果左右节点都是红色的那么将左右子节点修改为黑色,父节点修改为红色*/
void flushColor(RedBlackNode<T> node){
node.setColor(RedBlackNode.RED);
RedBlackNode<T> left = node.getLeft();
left.setColor(RedBlackNode.BLACK);
RedBlackNode<T> right = node.getRight();
right.setColor(RedBlackNode.BLACK);
}
向2节点插入
向3节点插入
插入算法代码示例:
RedBlackNode<T> insert(RedBlackNode<T> node, T data){
if (Objects.isNull(node)) {
node = new RedBlackNode<>();
node.setData(data);
node.setColor(RedBlackNode.RED);
return node;
}
T nodeData = node.getData();
int flag = data.compareTo(nodeData);
if (flag < 0) { //插入数据小于节点数据,入左子树
RedBlackNode<T> left = insert(node.getLeft(), data);
node.setLeft(left);
} else if (flag > 0) { //插入数据大于节点数据,入右子树
RedBlackNode<T> right = insert(node.getRight(), data);
node.setRight(right);
}
//插入位置在右子节点,且左子树非红色,进行左旋转
if (isRed(node.getRight()) && !isRed(node.getLeft())) {
node = rotateLeft(node);
}
//插入的节点在左子树的左子节点上,右旋
if (isRed(node.getLeft()) && isRed(node.getLeft().getLeft())) {
node = rotateRight(node);
}
if (isRed(node.getLeft()) && isRed(node.getRight())) {
flushColor(node);
}
return node;
}
由于我们在里介绍过,我们可以将节点删除的逻辑调整为极值的删除
里,已经知道单独的2节点是不能直接删除的,需要将2节点转换为3或4节点(2节点对应红黑树中的黑色节点)
综上所述:我们需要极大/小值的删除和2节点的删除方法
主要分为3节点和4节点删除最小值(其中4节点根结点有红或黑两种颜色。CASE比较多,请放大查看)
代码示例:
/**
最小值的删除方法,返回删除后的根节点
*/
RedBlackNode<T> deleteMin(RedBlackNode<T> node){
RedBlackNode<T> left = node.getLeft();
//左节点不为null,最小值在node的左节点,继续向左
if (Objects.isNull(left)) {
return null;
}
//左右节点都不是红色,需要将黑色节点调整为红色Del-2至Del-5示
RedBlackNode<T> ll = left.getLeft();
if (!isRed(left) && !isRed(ll)) {
node = removeRedLeft(node);
}
left = deleteMin(node.getLeft());
node.setLeft(left);
return blance(node);
}
/** 移除红色最小节点
*/
RedBlackNode<T> removeRedLeft(RedBlackNode<T> node) {
flipsColor(node);
RedBlackNode<T> right = node.getRight();
RedBlackNode<T> rl = Objects.isNull(right) ? null : right.getLeft();
//如果右左节点是红色节点(对应图中的Del-3、Del-5图)
if (isRed(rl)) {
right = rotateRight(right);
node.setRight(right);
node = rotateLeft(node);
}
return node;
}
/**变色Del-2至Del-5示*/
void flipsColor(RedBlackNode<T> node) {
node.setColor(RedBlackNode.BLACK);
RedBlackNode<T> left = node.getLeft();
RedBlackNode<T> right = node.getRight();
if (Objects.nonNull(left)) {
left.setColor(RedBlackNode.RED);
}
if (Objects.nonNull(right)) {
right.setColor(RedBlackNode.RED);
}
}
/**
节点删除后的平衡调整方法
*/
RedBlackNode<T> balance(RedBlackNode<T> node){
if (isRed(node.getRight())) { //右节点为红,左旋(图中的2列)
node = rotateLeft(node);
}
if (isRed(node.getRight()) && !isRed(node.getLeft())) {
node = rotateLeft(node);
}
if (isRed(node.getLeft()) && isRed(node.getLeft().getLeft())) {
node = rotateRight(node);
}
if (isRed(node.getLeft()) && isRed(node.getRight())) {
flushColor(node);
}
return node;
}
最大值的删除逻辑如下图示
代码示例:
/**
最大值的删除方法,返回删除后的根节点
*/
RedBlackNode<T> deleteMax(RedBlackNode<T> node){
if(isRed(node.getLeft())){
node = rotateRight;
}
RedBlackNode<T> right = node.getRight();
if(right == null){
return null;
}
if (!isRed(right) && !isRed(right.getLeft())) {
node = removeRedRight(node);
}
right = deleteMax(right);
node.setRight(right);
return balance(node);
}
/** 移除红色右节点
*/
RedBlackNode<T> removeRedRight(RedBlackNode<T> node) {
flipsColor(node);
RedBlackNode<T> left = node.getLeft();
RedBlackNode<T> lr = Objects.isNull(left) ? null : left.getRight();
//如果左右节点是红色节点
if (!isRed(rl)) {
return rotateRight(node);
}
return node;
}
删除
我们将以上两个方法结合就可以得到红黑树的删除方法,不再赘叙。
至此,我们就将二叉搜索树的内容介绍完毕了。如果你觉得对你有帮助,记得点个赞和在看哦。同时也期待大家的留言讨论。