历经了一个多月,终于完成了二叉搜索树的学习和整理。之前只是零散的发布出来,并没有做一个完整的分享。这次,我将之前的内容整理到一起,一次性的对这个话题做个终结。如果看了这篇,还是不懂二叉搜索树,那你就来后台留言找我,我将给您一一解答。这篇文章主要介绍二叉搜索树、平衡二叉树里的AVL树、2-3-4树、红黑树。可以点击分篇链接查看更细致的内容
在生活中我们经常会使用到搜索的功能。在我们数据量不大的情况下,可以使用每次遍历全部数据,查询我们的目标数据。当数据量增加时,我们遍历的方式就有些力不从心了;也可以将数据的数据排序,使用比较高效的二分查找方式,但是在插入或删除数据时,数组表现就会很慢。所以我们可以结合二分查找查询的高效 + 链表添加删除的高效性来实现高效搜索(符号表)的情况
下面我将列举一些树的内容定义(后续所有的代码使用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示
T data; TreeNodeleft; TreeNode right; int size;
查找
每个节点只指向一个父节点,最多包含左右两个子链接
左子边节点的Key小于父节点、右子节点的Key大于父节点 如图-4示
T data; TreeNodeleft; TreeNode right; int size;
查找
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 TreeNodefindMin(TreeNode node){ if (Objects.isNull(node.getLeft())) { return node; } return findMin(node.getLeft()); } //查询极大值,一直向右查询,如果没有右节点,则认为当前节点最大 例子:Z节点 public TreeNode findMax(TreeNode 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"); } TreeNodenode = new TreeNode<>(); node.data = element; if (Objects.isNull(root)) { root = node; return; } addImpl(root, node); } private void addImpl(TreeNode root, TreeNode node) { T val = root.data; T element = node.data; int sub = element.compareTo(val); //包含要插入的值,不处理 if (sub == 0) { return; } //插入的值大于根节点的值,将新节点作为根节点的右子节点 if (sub > 0) { TreeNode right = root.getRight(); if (Objects.isNull(right)) { root.setRight(node); return; } addImpl(right, node); } else { //插入的值小于根节点的值,将新节点作为根节点的左子节点 TreeNode left = root.getLeft(); if (Objects.isNull(left)) { root.setLeft(node); return; } addImpl(root.getLeft(), node); } }
删除
由于删除节点比较复杂,我们先看下删除极大值(极小值)的情况,为节点删除做好准备工作
删除最小值
由于二叉搜索树的特点二(左子边节点的Key小于父节点、右子节点的Key大于父节点)那么最小值节点要么是叶子节点或者包含右子节点的情况
极小值节点是叶子节点,可以直接移除
极小值节点有一个右子节点,将右子节点替换为父节点(如果还包含左子节点,那么当前节点非最小值)
//移除最小的节点,将返回的值作为根节点 private TreeNodedeleteMin(TreeNode node) { if (Objects.isNull(node.getLeft())) { //没有左子节点,返回右子节点 return node.getRight(); } TreeNode min = deleteMin(node.getLeft()); //递归左子树 node.setLeft(min); return node; }
删除最大值
和删除最小值的情况相似。只不过递归的是右子树
极大值节点是叶子节点,可以直接移除
极大值节点有一个左子节点,将左子节点替换为父节点(如果还包含右子节点,那么当前节点非最大值)
if (Objects.isNull(node.getRight())) { return node.getLeft(); } TreeNodemax = deleteMax(node.getRight()); node.setRight(max); return node; }
删除节点
我们将删除节点的情况归纳如下
被删除节点是叶子节点,可以直接移除
被删除节点只包含一个子节点(左子节点或者右子节点),我们需要需要将子节点替换到父节点
被删除节点包含两个子节点,如果直接移除E节点,那么子节点D、F将会丢失。我们需要转换思路,将包含两个子节点的情况转换为上两种情况。下面我们介绍下如何处理(T.Hibbard 1962年提出的方法,膜拜巨佬)
我们使用前驱节点(后续节点)的值替换被删除节点,然后删除前驱节点(后继节点)
前驱节点:当前节点的左子树中的最大值
后继节点:当前节点的右子树中的最小值
//删除element的节点,返回根结点的引用 public TreeNodedelete(T element, TreeNode node){ if (Objects.isNull(node)) { return null; } T val = node.data; int res = val.compareTo(element); if (res < 0) { //被删除节点在node的右子树 TreeNode rNode = delete(element, node.getRight()); node.setRight(rNode); } else if (res > 0) { //被删除节点在node的左子树 TreeNode 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 tmp = node; node = findMin(node.getRight()); TreeNode 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树)
AVL树内容定义
平衡因子BalanceFactor:左右子树的高度差BF=HL - HR
规定左右子树的高度差的绝对值不超过1 |BF| ≤ 1
节点定义
原有节点的基础上增加height属性
class AVLNode> { private T data; //左节点 private AVLNode left; //右节点 private AVLNode right; //当前节点的高度 private int height; }
高度计算
由于平衡二叉树的平衡指高度方面的平衡,我们先来计算树的高度
树的高度H指:左HL右HR子树高度的最大值 + 1
int height(AVLNodenode){ if (Objects.isNull(node)) { return 0; } int rHeight = height(node.getRight()); int lHeight = height(node.getLeft()); return Math.max(rHeight, lHeight) + 1; }
查找
调整平衡
插入分类
接下来我们将处理所有的情况
当插入节点在右子树的右节点上(ADF路径)
操作步骤:
将右子节点D作为根节点
原根节点A作为新根节点D的左子节点
将D节点的左子节点B设置为原根节点A的右子节点
实现代码如下:
AVLNodesingleRightRotation(AVLNode node) { AVLNode result = node.getRight(); AVLNode left = result.getLeft(); node.setRight(left); result.setLeft(node); return result; }
LL插入
当插入的节点在左子树的左节点上(GDB路径)
操作步骤:
将左子节点D作为根结点
原根节点G作为新根节点D的右子节点
将D节点的右子节点F作为原结点G的左子节点
实现代码:
AVLNodesingleLeftRotation(AVLNode node) { AVLNode result = node.getLeft(); AVLNode right = result.getRight(); node.setLeft(right); result.setRight(node); return result;
当插入的节点在右子树的左节点上(ADB路径)
操作步骤:
针对A节点的右子节点D做左旋转
针对A节点做右旋转
实现代码:
AVLNodedoubleRightLeftRotation(AVLNode node){ AVLNode right = singleLeftRotation(node.getRight()); node.setRight(right); return singleRightRotation(node); }
当插入的节点在右子树的左节点上(GDF路径)
操作步骤:
针对G节点的左子节点D做右旋转
针对G节点做左旋转
实现代码:
AVLNodedoubleLeftRightRotation(AVLNode node) { AVLNode left = singleRightRotation(node.getLeft()); node.setLeft(left); return singleLeftRotation(node); }
删除节点
我们在删除节点时,思路如下:
叶子节点直接删除
包含一个子节点,将子节点替换到父节点
包含两个子节点,使用后继节点替换被删除节点,删除后继节点即可
平衡调整的思路:节点被删除后,相当于在兄弟节点插入新的节点
代码如下:
return null; } T nodeData = node.getData(); int flag = data.compareTo(nodeData); if (flag > 0) { //右子树 AVLNoderight = delete(node.getRight(), data); node.setRight(right); AVLNode 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 left = delete(node.getLeft(), data); node.setLeft(left); AVLNode 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 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; }
由于AVL是一个高度严格平衡的二叉搜索树,查找效率在log2n级别。但是在维护节点高度平衡时,需要进行旋转操作(插入时最多两次旋转;删除节点时AVL树需要调整整个查询路径的高度平衡,最多需要log2n次旋转)后面,我们将介绍另外一种平衡搜索二叉树(红黑树)!
引言
红黑树、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树的查询操作和二叉搜索树的操作一致,不再赘叙。本次主要完成插入和删除的操作描述
可以参考前面,熟悉二叉树一些基本定义和操作
二叉搜索树(BST)
平衡二叉树(AVL)
我们把1-10的数字拆入到一棵234树中
依次插入1、2、3节点
插入4节点,需要将4节点分裂成3个2节点的操作
至此,插入逻辑介绍完毕
节点的删除逻辑,和二叉树的删除逻辑区别不大。如果是叶子节点,可以直接删除;如果是非叶子节点,需要转换为后继/前驱节点的删除方式,所有都可以转换为极值的删除
至此,我们的234树的插入和删除操作介绍完了。搞清楚234树的插入和删除操作将是后续红黑树、B树、B+树的前置条件。
从上面的2-3-4树了解到底层原理和操作逻辑,但按照对应逻辑实现代码和各种情况的处理,却不容易。所以我们要减少由于2-3-4树为了实现平衡,而导致的实现复杂度上升的情况。我们现在使用普通的二叉树+颜色来表示2-3-4树(红黑树是多路平路查找树的一种实现)
红链接必须是左链接,根结点必须是黑色的
不能同时存在两条连续的红链接
任一空链接到根节点的路径上经历的黑色节点个数一样
下面我们使用1-3的插入来观察红黑树是如何保持平衡的
根据上面根据上面的操作我们可以发现红黑树对2-3-4树的实现原理:
使用黑+红两个节点来实现3节点(如上图插入2后)
使用三个黑色节点实现4节点(如上图插入3后)
RedBlackNode> { /*颜色的常量定义 red:false black:true 新建节点默认为红色*/ public static final boolean RED = false; public static final boolean BLACK = true; private T data; private RedBlackNode left; private RedBlackNode right; private boolean color; }
我们将红黑树的操作分开描述
查找和普通的二叉搜索树一致,不再赘叙。
可以参考二叉搜索树关于查找的部分
实现步骤:
右子节点的颜色 = 原根结点的颜色
根结点node作为右子节点的左子节点,刷新为红色节点
将右子节点的左子节点设置为原根结点的右子节点
代码示例:
RedBlackNoderotateLeft(RedBlackNode node){ RedBlackNode right = node.getRight(); right.setColor(node.isColor()); RedBlackNode middle = right.getLeft(); node.setRight(middle); node.setColor(RedBlackNode.RED); right.setLeft(node); return right; }
将根结点的左子节点替换到根结点,将左子节点作为根结点返回
实现步骤:
左子节点的颜色 = 原根结点的颜色
根结点node替换到左子节点的右子节点,刷新为红色节点
将左子节点的右子节点设置为原根结点的左子节点
代码示例:
RedBlackNoderotateRight(RedBlackNode node){ RedBlackNode result = node.getLeft(); result.setColor(node.isColor()); RedBlackNode resultRight = result.getRight(); node.setLeft(resultRight); result.setRight(node); node.setColor(RedBlackNode.RED); return result; }
变色
/**如果左右节点都是红色的那么将左右子节点修改为黑色,父节点修改为红色*/ void flushColor(RedBlackNodenode){ node.setColor(RedBlackNode.RED); RedBlackNode left = node.getLeft(); left.setColor(RedBlackNode.BLACK); RedBlackNode right = node.getRight(); right.setColor(RedBlackNode.BLACK); }
插入算法代码示例:
RedBlackNodeinsert(RedBlackNode 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 left = insert(node.getLeft(), data); node.setLeft(left); } else if (flag > 0) { //插入数据大于节点数据,入右子树 RedBlackNode 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; }
由于我们在二叉搜索树BST里介绍过,我们可以将节点删除的逻辑调整为极值的删除
2-3-4树文章里,已经知道单独的2节点是不能直接删除的,需要将2节点转换为3或4节点(2节点对应红黑树中的黑色节点)
综上所述:我们需要极大/小值的删除和2节点的删除方法
主要分为3节点和4节点删除最小值(其中4节点根结点有红或黑两种颜色。CASE比较多,请放大查看)
代码示例:
/** 最小值的删除方法,返回删除后的根节点 */ RedBlackNodedeleteMin(RedBlackNode node){ RedBlackNode left = node.getLeft(); //左节点不为null,最小值在node的左节点,继续向左 if (Objects.isNull(left)) { return null; } //左右节点都不是红色,需要将黑色节点调整为红色Del-2至Del-5示 RedBlackNode ll = left.getLeft(); if (!isRed(left) && !isRed(ll)) { node = removeRedLeft(node); } left = deleteMin(node.getLeft()); node.setLeft(left); return blance(node); } /** 移除红色最小节点 */ RedBlackNode removeRedLeft(RedBlackNode node) { flipsColor(node); RedBlackNode right = node.getRight(); RedBlackNode 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 node) { node.setColor(RedBlackNode.BLACK); RedBlackNode left = node.getLeft(); RedBlackNode right = node.getRight(); if (Objects.nonNull(left)) { left.setColor(RedBlackNode.RED); } if (Objects.nonNull(right)) { right.setColor(RedBlackNode.RED); } } /** 节点删除后的平衡调整方法 */ RedBlackNode balance(RedBlackNode 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; }
最大值的删除逻辑如下图示
代码示例:
/** 最大值的删除方法,返回删除后的根节点 */ RedBlackNodedeleteMax(RedBlackNode node){ if(isRed(node.getLeft())){ node = rotateRight; } RedBlackNode 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 removeRedRight(RedBlackNode node) { flipsColor(node); RedBlackNode left = node.getLeft(); RedBlackNode lr = Objects.isNull(left) ? null : left.getRight(); //如果左右节点是红色节点 if (!isRed(rl)) { return rotateRight(node); } return node; }
我们将以上两个方法结合就可以得到红黑树的删除方法,不再赘叙。
至此,我们就将二叉搜索树的内容介绍完毕了。如果你觉得对你有帮助,记得点个赞和在看哦。同时也期待大家的留言讨论。