本篇文章主要针对二叉树相关数据结构进行简单介绍,如二叉查找树、平衡二叉树、红黑树等。为了方便理解,尽量通过图片的形式来展示。
目录
1. 二叉树
二叉树是有限节点的集合,这个集合可以是空集,也可以是一个根节点和至多两个子二叉树组成的集合。其中一颗树叫做根的左子树,另一棵叫做根的右子树。
简单的说,二叉树的每个节点最多有两个子树。
1.1 基本概念
①节点:所有的元素都叫节点
②根节点:最上面的为根节点,每个树只会有一个根节点。
③节点的度:节所拥有的子树的个数,B节点的度为3
④叶子节点(终端节点):度为0的节点
⑤分支节点:度不为0的节点
⑥节点的层次:根节点层次为1,往下依次加1
⑦树的深度:从根节点向下计算,根节点为0。即B的深度为1,树的深度为3。
⑧树的高度:从叶子节点到根节点最长简单路径中边的条数,数的高度为3。
网上许多地方在树的高度和深度上有很大争议,这里不做评价,只要明白高度是从下往上数、深度是从上往下数就行了。
1.2 满二叉树
如果一个树的高度为K,则节点数为(2^k - 1),每个节点要么有两个子树要么没有子树。
1.3 完全二叉树
- 所有的叶子节点都出现在第k层或者k-1层。且从1~k-1 层为满二叉树。
- 第k层可以是不满的,但是节点必须在左边。每个节点如果有右子树的话一定要有左子树。
1.4 二叉查找(排序)树(BST)
1.4.1 概念
二叉查找树,英文叫Binary Search Tree,目的是为了提高查询效率。特点如下:
- 若左子树不空,则左子树上所有结点的值均小于它的根结点的值。
- 若右子树不空,则右子树上所有结点的值均大于或等于它的根结点的值。
- 左、右子树也分别为二叉排序树。
因为以上特性,所以二叉查找树的中序遍历,一定是从小到大的。
中序遍历结果是:1 3 4 6 7 8 10 13 14
中序遍历下面会讲,这里简单提一下。前、中、后是根节点相对左右子树的遍历顺序。
最好的情况下,二叉排序树查找效率较高是 O(logn),左右子树分布比较均匀。
最差的情况下是O(n),比如说插入的元素是有序的,那么二叉树就形成了链表,需要全部遍历才行。
如果我们可以保证二叉排序树不出现上面提到的极端情况(插入的元素是有序的,导致变成一个链表),就可以保证很高的效率了。
但这在插入有序的元素时不太好控制,按二叉排序树的定义,我们无法判断当前的树是否需要调整。
因此就要用到平衡二叉树(AVL 树)了。
1.4.2 二叉查找树删除节点
一切操作都不能违背二叉查找树的性质。
- 如果删除元素是叶子节点,可以直接删除。
- 如果删除的元素只有一个子节点,将子节点指向待删除元素的父节点 (即子节点移动到被删除元素的位置)。
- 如果删除的元素有两个子节点,找出该元素下左子树的最大值Lmax或右子树的最小值Rmin(中序遍历找到待删除元素的前继节点或后继节点),将待删除的元素替换为Lmax或Rmin,然后删除左子树或者右子树中找到的值。
替换并删除
public TreeNode deleteNode(TreeNode root, int key) {
if(root == null){
return root;
}
if(key < root.val){
root.left = deleteNode(root.left, key);
return root;
}
if(key > root.val){
root.right = deleteNode(root.right, key);
return root;
}
//开始执行删除操作
//(1)删除根节点
if(root.left == null && root.right == null){
root = null;
return root;
}
//(2)只有一个child,只有左子树
if(root.left == null && root.right != null){
root = root.right;
return root;
}
//(2)只有一个child,只有右子树
if(root.right == null && root.left != null){
root = root.left;
return root;
}
//(3)有两个child
if(root.left != null && root.right != null){
//挑选左子树中最大的或者右子树中最小的,替换当前节点,再将替换的节点置空
int val = findMaxInLeftTree(root.left);
root.val = val;
root.left = deleteNode(root.left, val);
return root;
}
return root;
}
//找到左子树中最大的值
private int findMaxInLeftTree(TreeNode left) {
if(left == null){
return 0;
}
if(left.right == null){
return left.val;
}
if(left.right == null && left.left == null){
return left.val;
}
return findMaxInLeftTree(left.right);
}
}
1.5 平衡二叉树(AVL)
平衡二叉树把查找的时间复杂度维持在了O(logn)。
平衡二叉树的提出就是为了保证二叉排序树的平衡,以此提高查询、添加、删除的效率。因此定义如下:
- 平衡二叉树要么是一颗空树
- 要么保证左右子树的高度差不大于1
- 子树也必须是一颗平衡二叉树
左边为平衡二叉树,任意节点的两个树的高度差都小于等于1。比如节点5左子树的高度为3,右子树的高度为2,高差等于1。比如节点2左子树的高度为1,右子树的高度为2,高差等于1。比如节点5,左子树的高度为1,右子树为0,高差等于1。
右边为非平衡二叉树,某节点左右子树的高度差大于1。比如节点7,左子树高度3,右子树高度为1,高差等于2。
1.5.1 四种失衡的情况
平衡二叉树中,当我们插入元素后,为了保持查找树的特性(左小又大),很容易导致某些节点失衡,即该点的平衡因子大于1或小于-1。平衡因子取值应该为 -1、0、1,三种情况。
平衡二叉树失衡即某个节点两个树的高度差大于1。导致一个节点失衡的插入操作有以下4种。
- 在节点的左子树的左子树插入元素,LL插入型。
- 在节点的左子树的右子树插入元素,LR插入型。
- 在节点的右子树的左子树插入元素,RL插入型。
- 在节点的右子树的右子树插入元素,RR插入型。
-
LL(一)中,因为节点1的插入,导致了节点8的失衡。而插入的位置是在节点8左子树的左子树上。同样LL(二)中,因为节点3的插入,导致了节点8失衡。这里需要注意子树是从受影响的节点算起,虽然3插在了右边,但是它依旧在8(失衡节点)左子树的左子树上,因此属于LL型插入。
-
LR(一)中,因为节点5的插入,导致节点8失衡。而插入的位置是在节点8(失衡节点)的左子树的右子树上,同样LR(二)也是如此,因此他们都属于LR插入型。
后面的RL、RR型原理和上面都一样,好好体会一下。
1.5.2 平衡二叉树的调整
面对以上4种失衡的情况,在AVL 树中将采用LL(左左),LR(左右),RR(右右)和RL(右左) 四种旋转方式进行调整。
1. LL型—> 右旋 (左手掌心向右,向内旋转)
1.左手掌心向右
2.将左手平行于荧光笔标注的线段
3.将指尖看做10、手背指关节看做7
4.将手指向右旋转
5.此时手背指关节7就变为根节点,10变为7的右子树,因为10将7原来的右子树覆盖了,所以7的右子树变为10的左子树
因为左边节点多了,所以要将左边的节点旋转到右边。
代码实现:
/**
* 右旋
*/
public Node rightRotate(Node node) {
// 将失衡结点的左子树赋给一个临时结点,也就是将10的左子树7赋给新的结点
Node newNode = node.left;
// 将7的右子树赋值给10的左子树
node.left = newNode.right;
//7的右子树为失衡的结点即10
newNode.right = node;
//更新高度
node.height = Math.max(height(node.left), height(node.right)) + 1;
newNode.height = Math.max(height(newNode.left), height(newNode.right)) + 1;
return newNode;
}
2. RR型—>左旋 (右手掌心向左,向内旋转)
代码实现:
/**
* 左旋
*/
public Node leftRotate(Node node) {
Node newNode = node.right;
node.right = newNode.left;
newNode.left = node;
node.height = Math.max(height(node.left), height(node.right)) + 1;
newNode.height = Math.max(height(newNode.left), height(newNode.right)) + 1;
return newNode;
}
3. LR型—> 先左旋再右旋
LR型需要经过两次操作,先将LR型转为LL型,在右旋,到平衡。
代码实现:
/**
* LR
*/
private Node leftRightRotate(Node node) {
// 先左旋,转换为LL型,再右旋
node.left = leftRotate(node.left);
return rightRotate(node);
}
}
4. RL型—> 先右旋再左旋
RL型也需要经过两次操作,先将RL型转为RR型,在左旋,到平衡。
代码实现:
* RL
*/
private TreeNode<T> rightLeftRotate(TreeNode<T> node) {
// 先右旋再左旋
node.right = rightRotate(node.right);
return leftRotate(node);
}
总结:
当左边节点多出时,将左边节点旋转到右边,即右旋。当右边节点多出时,将右边节点旋转到左边,即左旋。
右旋使用左手,左旋使用右手。千万不能乱。
平衡二叉树的代码实现,网上也有很多,这里就不贴了。
简单比较平衡二叉树和二叉搜索树
在平衡二叉树中查找11这个元素,最大比较4次,而在二叉搜索树中需要找6次。很明显,平衡二叉树,查找的效率要高于二叉搜索树。
1.5.3 AVL删除节点
我们需要明确一个概念。不管是插入还是删除操作,操作完成后,仍需要保证平衡二叉树的特性。也就是说如果插入或者删除后导致不平衡需要通过旋转来保持树的平衡。
AVL的删除和二叉排序树相差不大,唯一的变化就是在删除后需要通过旋转保持AVL的平衡。总结起来的话,就两个判断:
① 要删除的元素是什么类型的节点 ② 删除完成后是否失衡
节点的类型有三种:
①叶子节点 ② 只有一个子树 ③ 有两个子树
首先查找该元素是否存在,不存在就直接返回,存在做以下操作:
1. 如果要删除的元素有两个子树:删除后失衡需要进行旋转。
(1)左子树的高度大于右子树的高度,将左子树的最大值赋值给要删除的元素,然后删除左子树中最大值的节点。
(2) 右子树的高度大于左子树的高度,将右子树的最小值赋值给要删除的元素,然后删除右子树中最小值的节点。
(3) 左子树的高度和右子树的相等,将左子树的最大值赋值给要删除的元素,然后删除左子树中最大值的节点 或者 将右子树的最小值赋值给要删除的元素,然后删除右子树中最小值的节点。
失衡判断:从父节点开始判断是否失衡,如果没有失衡,在判断父节点的父节点是否失衡,直到根节点,如果根节点也没有失衡,则说明此树是平衡的。如果过程中,有任何节点失衡,则判断为何种类型的失衡,在进行相应的旋转。
2.如果要删除的元素有且仅有一个子树,将该元素的子节点直接指向该元素的父节点。然后进行失衡判断。
3.如果要删除的元素是叶子节点,直接删除。然后进行失衡判断。
1.5.4 总结
平衡二叉树的查找时间复杂度为O(logN)。
插入元素后,可能失衡,需要进行旋转最多两次,因此总体上插入时间复杂度仍然在O(logN)级别上,插入之前需要先查找元素。
删除元素后,需要检查从删除节点到根节点路径上所有节点的平衡因子,最多需要O(logn)次检查,所以删除的代价稍大,因此时间复杂度为O(logN)+O(logN)=O(2logN)。
1.6 二叉树的遍历
-
前序遍历
-
中序遍历
-
后序遍历
前、中、后是根节点相对左右子树的遍历顺序。
前序遍历:根结点 —> 左子树 —> 右子树
中序遍历:左子树 —> 根结点 —> 右子树
后序遍历:左子树 —> 右子树 —> 根节点
层次遍历:从上到下,从左向右
private void visitNode(Node node) {
System.out.print(node.val+" ");
}
// 前序遍历
void preOrder(TreeNode node) {
if (node != null) {
visitNode(node);
preOrder(node.getLeftChild());
preOrder(node.getRightChild());
}
}
// 中序遍历
void midOrder(TreeNode node) {
if (node != null) {
midOrder(node.getLeftChild());
visitNode(node);
midOrder(node.getRightChild());
}
}
// 后序遍历
void postTraversal(TreeNode node) {
if (node != null) {
postTraversal(node.getLeftChild());
postTraversal(node.getRightChild());
visitNode(node);
}
}
前序遍历: 4 1 2 3 6 5 7
中序遍历: 1 2 3 4 5 6 7
后序遍历: 1 3 2 5 7 6 4
层次遍历: 4 2 6 1 3 5 7
1.7红黑树
1.7.1 红黑树概念
红黑树顾名思义就是结点是红色或者黑色的二叉查找树。
红黑树的查找、插入、删除的时间复杂度基本为O(logn)。
性质:
- 每个节点不是黑色就是红色。
- 根节点为黑色。
- 每个叶子节点(NIL空节点)都是黑色的。
- 每个红色节点下面一定是两个黑色子节点,也就是说一条路径上不可能出现相邻的两个红色节点。
- 从任意一个节点到叶子节点(null节点)的路径上都包含相同数目的黑色节点。
从性质5可以推出:
① 如果一个节点存在黑色子节点,那么它肯定有两个子节点。
② 根节点的左右子树黑色节点层数相等。
这五条性质约束了红黑树,满足这五条性质的二叉树可以将查找删除维持在对数时间内 O(logn)。
1.7.2 红黑树插入
这里默认大家已经了解了二叉排序树的插入操作了。
红黑树中,添加节点后,一定要保持红黑树原有的性质,如果不满足,需要进行调整。
首先我们先来统一下各节点的名称:
因为要满足红黑树的特性,如果插入的节点的颜色为黑色,必定会违背性质5。如果我们插入的是红色节点,那么只有在插入节点的父节点也是红色时,才会违反性质4,如果插入的是根节点,违反性质2。所以把插入节点的颜色以红色为标准,会减少许多调整。
下面是可能遇到的插入的几种状况:
1.当插入的节点是根节点时,直接涂黑。
2.当插入的节点的父节点是黑色,此时插入的为红色节点,并不会影响红黑树的五条性质。
3.当插入的节点的父节点是红色(违反性质4),且父节点在祖父节点左支时,分两种情况:
(1) 当叔叔节点为黑色时,也分两种情况:
① 要插入的节点是父节点的左支
将原祖父节点和原父亲节点的颜色互换,调整后,使之符合性质4。
② 要插入的节点是父节点的右支
先绕父节点左旋,左旋后,又回到了插入节点在左支的情况,同样的操作,绕祖父节点右旋,然后互换原祖父节点和原父节点的颜色。
(2) 叔叔节点为红色时:
插入节点 M 的父亲节点和叔叔节点都为红色,直接同时将父亲节点和叔叔节点染成黑色,将祖父节点G 染成红色。
如果G 的 父节点也是红色(违反性质4),则以G 作为新的节点继续进行调整,以此循环,直到父节点不是红色。(直到满足红黑树特性)
4.当插入节点的父节点为红色、且在祖父节点的右支时,和左支成镜像操作,这里就不一一赘述了。
这里介绍一个可以画红黑树的网站:
https://www.cs.usfca.edu/~galles/visualization/RedBlack.html
1.7.3 红黑树删除
红黑树的删除,是基于二叉查找树的基础上进行的删除,删除完成后,需要为保持红黑树的特性,进行相应的调整。
二叉查找树的删除,分为三种情况:
① 要删除的元素为叶子节点,直接删除。
② 要删除的元素有且仅有一个子节点,将子节点的值赋值给要删除的节点,然后删除子节点。(将子节点移到要删除的元素位置)(将要删除元素的父节点指向其子节点)
③ 要删除的元素有两个节点,找到要删除元素左子树中的最大值Lmax,把Lmax赋值给要删除的元素,然后删除左子树中的Lmax 或者 找到要删除元素右子树中的最小值Rmin,把min赋给要删除的元素,然后删除右子树中的min。
因为找的是最值,如左子树的最大值 Lmax 所在的节点,该节点不可能存在右子节点,但是可能存在左子节点,即最值所在的节点最多只有一个子节点,且该子节点肯定为红色。因此删除问题,在转换后,又回归到了 ① 和 ② 的可能性中。而②又可以转化为①,所以删除问题就回到了删除叶子节点的问题。
举例:
如果要删除节点5 (删除情况③),将节点5左子树中最大值4赋值给要删除的节点,然后删除4节点,而4节点存在一个红色子节点 (删除情况②),即将2节点的值赋给节点4,删除红色子节点。
红黑树删除分析:
tips:
可以这么理解,加入颜色后,元素的互换,只会互换值,不会互换颜色。
下面的待删除节点,指的是替换的节点,或者是可以直接删除的节点。
红黑树中,有多种条件不可能出现如:
① 待删除的红色节点 D,不可能只有一个子节点。 如果子节点是黑色,则肯定违背了性质5,如果子节点是红色,则违背性质4。
② 待删除的节点 D 为黑色,该节点不可能只有一个黑色的子节点,因为这样违背性质5。
待删除节点为红色
待删除的节点为红色时,可能性只有一种,为叶子节点。直接删除,不会影响红黑树的特性。
如果要删除的节点是19,该节点为叶子节点,可直接删除。
如果要删除的节点是13,则替换节点为 7 或 19 ,即把7或19赋值给13所在的节点,然后删除7或19所在的红色节点,可直接删除。
待删除节点为黑色
① 待删除节点的兄弟节点为红色,待删节点是左节点的情况:
删除节点1,左旋,将兄弟节点和父节点换色,在将父节点和兄弟节点的左节点换色。
分析: 删除节点1,发现根节点所在左子树路径上的黑色节点个数少了一个(违背性质5),但是发现右边有两个红色节点。
思考: 如果能将右边某个红色节点移到左边,在将其染成黑色,这样就能保持平衡了。
实现: 先左旋(右边黑色节点多),左旋后发现,根节点为红色(违背了性质1),且根节点所在左子树最大黑色节点数为2,右子树为1(违背性质5)。交换根节点4和节点2的颜色,交换后发现2节点只有一个黑色的子节点(违背性质5),所以交换节点2和节点3的颜色。
或者 将待删除节点的父节点变红、兄弟节点变黑、父节点左旋、转换为待删除节点为为黑,兄弟节点也为黑的情况。这种情况下面会有处理方法。
② 待删除节点的兄弟节点为红色,待删节点是右节点的情况:
和在左边成镜像操作,也可以将其转换为兄弟节点为黑色的情况来处理。
③ 当被删除节点3为黑,兄弟节点为黑,兄弟节点的两个子节点也为黑。
将兄弟节点8设为红色,这样在删除8后,左右两支的黑色节点数就相等了,但是经过 5 路径上的黑色节点会少1,所以需要以父节点作为代替节点,重新进行平衡,这样一直向上,直到新的起始点为根节点。
④ 当被删除节点为黑,父节点为红。兄弟节点为黑,兄弟节点的两个子节点也为黑。
删除节点6,交换父节点和兄弟节点的颜色。
⑤ 当被删除节点为黑,兄弟节点为黑,兄弟节点的一个子节点为红时:
-
当被删除节点1为黑、并且为父节点的左子节点,且兄弟颜色为黑,兄弟的右支为红色,删除1,然后类似于AVL中的左旋,将兄弟的右子树涂黑。
-
当被删除元素为黑、并且为父节点的左子节点,且兄弟颜色为黑,兄弟的左支为红色。
先以兄弟节点为支点进行右旋,旋转后回到上面的情况,相同的处理方法。 -
当被删除元素为黑,并且为父节点的右子节点,且兄弟颜色为黑,兄弟左子节点为红。
-
当被删除元素为黑,并且为父节点的右支,且兄弟颜色为黑,兄弟右子树为红。
以3和4所连的枝为旋转点进行左旋,转为上面的情况,进行相同的操作。
虽然删除的情况比较多,但是删除的过程无需死记硬背,理解为主。其实通过插入或者删除元素后,我们将原本满足条件的红黑树破坏了,所以需要通过一些操作,如旋转、换色等,将红黑树再次调整为满足其5条性质的树。不管怎么变,只是为了达到一个目的,满足所有性质。
删除操作大体分为三种情况:
- 删除后当前子树能够通过一些操作自平衡。
- 删除后需要向兄弟节点借节点来实现平衡。
- 删除后需要向父节点借节点来实现平衡。
旋转是为了将多出的一个元素转移到另外一边,并且符合二叉排序树左小又大的性质,交换颜色是为了让树保持5条规则。
示例:
参考文档:
30张图带你彻底理解红黑树
算法导论:机械工业出版社出版(第二版)
最后,若有不足或者不正之处,欢迎指正批评,感激不尽!