前言
接上次的队列,我们今天来梳理一下既复杂又简单的数据结构,就是树,大致还是按照之前的流程,先手动实现一遍,然后再学一些树在时实际开发中的不同类型,例如二叉排序树、堆等,主要是针对二叉树来说。
目录
1、二叉树的结构、基本操作、常见类型
2、基于数组实现二叉树
3、基于链表实现二叉树
4、二叉排序树
5、平衡二叉树
6、堆
正文
1、二叉树的结构、基本操作、常见类型
1.1 二叉树的结构
先来看看二叉树的概念,有个基本的认识,二叉树,是一种树状的数据结构,对其中每个节点来说,包含这样几个数据,一个是该节点代表的值,一个是该节点的左孩子节点,一个是该节点的右孩子节点,从整体结构来看,每个节点只能有两个孩子节点,没有孩子节点的节点称为叶子节点。
上面说了这么多,感觉还是一张清晰的图来的实在,如下
怎么样,是不是一图胜千言,嘻嘻,下面我们再来学习一下二叉树的基本操作。
1.2 二叉树的基本操作
对二叉树来说,它的基本操作除了增加节点,删除节点,更新节点的值之外,最重要的就是各种"花式遍历"了,包括,前序遍历,中序遍历,后序遍历,层序遍历。
如果只是一颗普通的二叉树,那么对于增删改这三个操作来说,就很简单了,增加只需要在树的最后一个位置增加节点即可,删除只需要遍历整个树,然后删除该节点,并将其左右子树中的任意一个替换到删除节点的位置,若无叶子节点,就直接删除即可,修改节点也是一样,遍历找到该节点,然后修改其值即可。
然后就是遍历操作,上面说了,遍历一共有四种常见的遍历方式,具体的遍历方法如下:
- 前序遍历:先遍历根节点,再遍历根节点的左子节点,再遍历根节点的右子节点。在遍历子节点的时候,同样递归继续遍历。简称“根左右”
- 中序遍历:和前序遍历类似,它的遍历顺序是“左根右”
- 后序遍历:和前序遍历类似,它的遍历顺序是“左右根”
- 层序遍历:和上面三种不同, 这种遍历顺序是从树的根节点开始,逐层网下遍历,对每一层来说,一般是从左边开始往右边遍历。
这里我最初接触到的时候,也是觉得难以一下子理解,后来自己拿一颗树来比划一下之后,就很容易明白了这四种遍历方式及其区别,所以,下面我拿一颗树来举例子,看这四种遍历方式最后会得到什么结果。
首先是分析前序遍历,为什么是前序遍历呢,因为这个遍历比较符合大多数人的正常遍历思维,所以就拿这个来举例详解,前序遍历的顺序是根左右,我们只要时刻遵循这三个字的顺序即可,然后我们来尝试访问。
首先从根节点开始,首先访问5(根),然后是4(5的左),由于4有子节点,所以不能接着访问5的右节点,要先把左子树访问完,所以此时4应作为根节点,继续从根节点开始访问,4(根)的下一个节点是2(4的左),由于2没有子节点,所以下一个是8(4的右),由于8有子节点,所以8作为根,继续访问6(8的左),由于8没有右子节点,所以8这棵树访问完毕,从而4的右子树也访问完毕,从而5的左子树访问完毕,接着访问3(5的右),由于3有子节点,所以3作为根节点,接着访问,由于没有左节点,所以访问3的右节点7(3的右)。
综上,前序遍历的结果就是5428637,然后同样的分析方法,我们可以得到中序遍历的结果是2468537,后序遍历的结果是2684735。
最后只剩下个层序遍历了,我们再来看看这个的遍历结果是什么,首先是根节点开始,访问5(第一层),现在第一层没有其它元素了,开始访问4(第二层从左至右第一个元素),然后是3(第二层从左至右第二个元素),然后第二层没了,接下来是第三层,同样的先访问2(第三层最左边的元素),然后是8(第三层从左至右第二个元素),然后是7(第三层从左至右第三个元素),第三层也访问完了,接下来是第四层,由于只有一个元素,所以访问6。至此,访问完毕,所以层序遍历的最终结果是5432876。
怎么样,是不是有了实例之后,对这几种遍历一下子就明白了许多,至于这几种遍历方式要怎么去实现,马上就会说到啦!
1.3 二叉树的常见类型
二叉树其实也和队列一样,在实际使用的时候,会加上许多额外的特性来方便使用,所以这就会演化出各种各样类型的二叉树,如果要全部罗列他们的话,估计有数十种,而且也没有去全部掌握他们的必要,我们同样的会挑几个非常典型的来学习,达到举一反三的效果。
我这里把我认为比较典型的树罗列出来,在后面再挑几个详细介绍,主要有:完全二叉树,满二叉树,二叉排序树,平衡二叉树,堆,红黑树,B树,B+树,B-树。
这里额外说明一下,B/B+/B-树,这三个其实并不能算作二叉树,它们应该叫多叉树,不过由于使用的比较多,更多的是和后台相关,所以我就罗列了出来,不做过多的介绍。然后后面我会详细介绍二叉排序树,平衡二叉树,以及堆这三者,所以它们就先放着,然后剩下完全二叉树、满二叉树和红黑树,这里来看看它们的概念。
首先是完全二叉树,它的概念如下:满足二叉树的性质的条件下,最后一层的叶子节点都是靠左的。然后是满二叉树:满足二叉树的性质的条件下,每个节点都有两个子节点,其实还有一种树,叫完美二叉树,完美二叉树其实就是每层都是“饱满的”,下面三张图就可以清晰的区分这三者。
完全二叉树如下:
满二叉树如下:
完美二叉树如下:
然后是红黑树,红黑树是一种非常特别的树,它除了具有二叉树的性质之外,额外的性质如下:
性质1. 节点是红色或黑色。
性质2. 根节点是黑色。
性质3. 每个叶节点(NIL节点,空节点)是黑色的。
性质4. 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
性质5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
一颗符合条件的红黑树如下:
2、基于数组实现二叉树
上面对二叉树有了一个基本的了解,下面我们来手动实现一个简单的二叉树,和之前一样,我们先实现基于数组的。
Ok,我们接下来在实现的时候,可能遇到的最大的一个问题就是如何用数组这个线性的数据结构来存储一个树状的数据结构,至于其它的问题,例如初始化以及基本操作的实现,基本上只要把这个问题解决了,都不是什么问题了,所以这是一个最核心的问题。这时候,我们就需要去发散我们的思维了,我们尝试找一颗树来看,从根节点开始,从上往下,从左往右,依次按照下标0、1、2、、、标号,会发现一个规律,左孩子节点的下标是父节点下标乘以2再加1,右孩子节点的下标就是父节点下标乘以2再加2,分析到了这里,其实大部分问题都已经解决了,还有一个就是如何存储空节点,这个可以使用null来存储,Ok,核心问题解决了,现在我们来写代码。
第一个问题就是成员变量和初始化,我们可以预设一个数组来用来初始化,然后就是一个声明一个数组长度的变量,如下
private int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 };
private int size = arr.length;
当然,这个工作也可以放在构造方法中,然后就是插入和删除节点的方法了,由于这里只是一颗普通的二叉树,所以插入删除节点的逻辑就很简单,只需要在数组尾端加上相应的数据即可。如下是插入节点的代码。
public void insert(int value){
int[] temp=new int[size];
for(int i=0;i<size;i++){
temp[i]=arr[i];
}
arr=new int[size+1];
for(int i=0;i<size;i++){
arr[i]=temp[i];
}
arr[size]=value;
size=arr.length;
}
然后我们为了使用的方便,可以为其加上一些常用的方法,例如获取左孩子节点、右孩子节点、获取树的深度等等,由于我们明白了父节点和子节点的下标关系,所以这些方法的实现都很简单,如下
//获取树的深度
public int getDeep() {
int deep;
deep = (int) Math.ceil(Math.log10((double) size + 1) / Math.log10(2.0));
return deep;
}
//获取左孩子节点的值
public int getLeftChild(int index) {
if (index * 2 + 1 >= size) {
throw new RuntimeException("越界啦");
}
return arr[index * 2 + 1];
}
//获取右孩子节点的值
public int getRightChild(int index) {
if (index * 2 + 1 >= size) {
throw new RuntimeException("越界啦");
}
return arr[index * 2 + 2];
}
//获取左孩子节点的下标
public int getLeftChildIndex(int index) {
if (index * 2 + 1 >= size) {
return -1;
}
return index * 2 + 1;
}
//获取右孩子节点的下标
public int getRightChildIndex(int index) {
if (index * 2 + 2 >= size) {
return -1;
}
return index * 2 + 2;
}
//获取父节点的值
public int getParent(int index) {
if (index < 2 || index >= size) {
throw new RuntimeException("越界啦");
}
return arr[index / 2 - 1];
}
有了基本操作之后,接下来就是我们最重要的一部分了,那就是遍历,上面关于二叉树的几种遍历方式已经做了比较详细的解释,下面我们来实现这些遍历,对于前三种遍历,前序遍历、中序遍历、后序遍历,我们很自然的想到的方式是递归,ok,我们不难写出如下的递归代码
// 先序遍历
public void preOrderTraverse(int index) {
if (arr == null) {
throw new RuntimeException("空指针异常");
}
System.out.print(arr[index] + " ");
if (getLeftChildIndex(index) != -1) {
preOrderTraverse(getLeftChildIndex(index));
}
if (getRightChildIndex(index) != -1) {
preOrderTraverse(getRightChildIndex(index));
}
}
// 中序遍历
public void inOrderTraverse(int index) {
if (arr == null) {
throw new RuntimeException("空指针异常");
}
if (getLeftChildIndex(index) != -1) {
inOrderTraverse(getLeftChildIndex(index));
}
System.out.print(arr[index] + " ");
if (getRightChildIndex(index) != -1) {
inOrderTraverse(getRightChildIndex(index));
}
}
// 后序遍历
public void postOrderTraverse(int index) {
if (arr == null) {
throw new RuntimeException("空指针异常");
}
if (getLeftChildIndex(index) != -1) {
postOrderTraverse(getLeftChildIndex(index));
}
if (getRightChildIndex(index) != -1) {
postOrderTraverse(getRightChildIndex(index));
}
System.out.print(arr[index] + " ");
}
最后,我们可以写一些测试用例,来判断代码的正确性,由于使用数组存储的树没有太多复杂的地方,所以实现的也很简略,下面重点看看基于链表的是怎么实现的 。
3、基于链表实现二叉树
我们再来基于链表实现,由于是基于链表,所以我们上面遇到的问题,如何存储树状的数据元素这个核心问题,其实就已经解决了,因为可以使用对象指针,ok,现在我们来思考节点的初始化和成员变量的问题,由于每个节点主要有三个值,左孩子、右孩子和数据值,所以节点的初始化代码如下
// 二叉链表实现法,即节点只有数据域+左节点指针+右节点 指针
public class Node {
private int data;
private Node lChild;
private Node rChild;
public Node() {
this(0);
}
public Node(int data) {
this.data = data;
lChild = null;
rChild = null;
}
public int getData() {
return data;
}
public void setData(int data) {
this.data = data;
}
public Node getlChild() {
return lChild;
}
public void setlChild(Node lChild) {
this.lChild = lChild;
}
public Node getrChild() {
return rChild;
}
public void setrChild(Node rChild) {
this.rChild = rChild;
}
}
Node
是一个内部类,然后我们再来看成员变量,因为是基于链表,所以我们可以不设置容量限制,只需要设置一个根节点的成员变量即可,如下
private Node root = null;
public LinkedBiTree() {
}
然后就是树的初始构建,这个可以通过手动添加节点来实现,也可自行写一个构建树的方法来实现,我这里为了方便就写了一个构建树的方法,如下
public void createBiTree() {
root = new Node(1);
Node NodeB = new Node(2);
Node NodeC = new Node(8);
Node NodeD = new Node(9);
Node NodeE = new Node(4);
Node NodeF = new Node(7);
root.lChild = NodeB;
root.rChild = NodeC;
NodeB.lChild = NodeD;
NodeB.rChild = NodeE;
NodeC.rChild = NodeF;
}
有了数据元素之后,我们再同样的为其添加上如下的普通操作方法,方便使用
public boolean isEmpty() {
return root == null;
}
// 求某个节点的子节点的个数,包括自身
public int getChildNodeNum(Node node) {
if (node == null) {
return 0;
} else {
int leftSize = getChildNodeNum(node.lChild);
int rightSize = getChildNodeNum(node.rChild);
return leftSize + rightSize + 1;
}
}
// 求指定节点的高度
public int getNodeheight(Node node) {
if (node == null) {
return 0; // 递归结束:空树高度为0
} else {
int leftHeight = getNodeheight(node.lChild) + 1;
int rightHeight = getNodeheight(node.rChild) + 1;
return leftHeight > rightHeight ? leftHeight : rightHeight;
}
}
public int getDeep() {
return getNodeheight(root);
}
// 后序递归释放节点
public void destroy(Node node) {
if (node == null) {
return;
} else {
destroy(node.lChild);
destroy(node.rChild);
node = null;
}
}
接下来就是核心问题,遍历,这里我们的常规思路是直接使用递归来实现,相关代码如下
// 递归前序遍历 根-左-右
public void preOrderTraverse(Node node) {
if (node == null) {
return;
}
System.out.print(node.data);
if (node.lChild != null) {
preOrderTraverse(node.lChild);
}
if (node.rChild != null) {
preOrderTraverse(node.rChild);
}
}
// 递归中序遍历 左-根-右
public void inOrderTraverse(Node node) {
if (node == null) {
return;
}
if (node.lChild != null) {
inOrderTraverse(node.lChild);
}
System.out.print(node.data);
if (node.rChild != null) {
inOrderTraverse(node.rChild);
}
}
// 递归后序遍历 左-右-根
public void postOrderTraverse(Node node) {
if (node == null) {
return;
}
if (node.lChild != null) {
postOrderTraverse(node.lChild);
}
if (node.rChild != null) {
postOrderTraverse(node.rChild);
}
System.out.print(node.data);
}
但是有时候我们会发现递归的效率是非常慢的,所以为了提高遍历的效率,我们可以使用非递归来实现,于是我们自然想到了常用的数据结构,那就是栈,我们可以使用栈来模拟递归的效果、
拿前序遍历来举例,具体的思路为:首先将根节点入栈,然后一直往左访问,也就是找到“最左边的”节点,然后开始逐个出栈并访问右孩子节点,每次出栈都继续找当前右孩子节点的“最左节点”访问,依次循环下去,直到栈为空即访问完毕,相关代码如下
// 非递归前序遍历 根-左-右
public void preOrderTraverseN() {
if (root == null) {
return;
}
Stack<Node> stack = new Stack<Node>();
Node node = root;
while (node != null || !stack.isEmpty()) {
while (node != null) {
System.out.print(node.data);
stack.push(node);
node = node.lChild;
}
node = stack.pop();
node = node.rChild;
}
System.out.println();
}
同样的思路,我们可以写出中序遍历的非递归代码,如下
// 非递归中序遍历 左-根-右
public void inOrderTraverseN() {
if (root == null) {
return;
}
Stack<Node> stack = new Stack<Node>();
Node node = root;
while (node != null || !stack.isEmpty()) {
while (node != null) {
stack.push(node);
node = node.lChild;
}
if (!stack.isEmpty()) {
node = stack.pop();
System.out.print(node.data);
node = node.rChild;
}
}
System.out.println();
}
最后是非递归的后序遍历,这个是最为复杂的,我们可以按照上面的思路,发现在尝试写后序遍历的时候,由于必须先访问根,才能访问左孩子和右孩子,所以对于后序遍历来说,无法像前序和中序那样简单的入栈和出栈即可完成,为此我们必须在中间记录一些标志位,用来辅助我们遍历,相关的后序遍历代码如下
// 非递归后序遍历 左-右-根
public void postOrderTraverseN() {
if (root == null) {
return;
}
Stack<Node> stack = new Stack<Node>();
Node node = root;
// 上一次被打印的节点,作为标志使用
Node prevNode = null;
while (node != null || !stack.isEmpty()) {
while (node != null) {
stack.push(node);
node = node.lChild;
}
// 取到栈顶节点
Node leftNode = stack.peek();
// 如果最左节点的右节点为空或者已经打印过,则打印本节点
if (leftNode.rChild == null || leftNode.rChild == prevNode) {
System.out.print(leftNode.data);
stack.pop();
prevNode = leftNode;
} else {
node = leftNode.rChild;
}
}
System.out.println();
}
搞定了三种访问顺序的递归和非递归的实现之后,最后还剩下一个层序遍历,我们要实现按照层次遍历的效果,可能一开始没什么思路,但是当我告诉你可以借助队列这个数据结构的时候,是不是一下子就明白了,现在我们来尝试借助队列实现,理一下思路:首先将根节点入队,然后根节点出队,在出队的时候,判断其是否有左孩子节点,若有,则入队,再判断是否有右孩子节点,若有,同样的入队,然后接着出队,对每个出队的元素,都作同样的处理,这样一遍下来,你就完成了按照层次遍历二叉树的效果,最终代码如下
// 层序遍历
public void levelOrderTraverse() {
if (root == null) {
return;
}
Queue<Node> queue = new ArrayDeque<>();
queue.add(root);
Node current;
while (!queue.isEmpty()) {
current = queue.peek();
System.out.print(current.data);
if (current.lChild != null) {
queue.offer(current.lChild);
}
if (current.rChild != null) {
queue.offer(current.rChild);
}
queue.poll();
}
System.out.println();
}
ok,到这里为止,一个功能相对完善的基于链表的二叉树数据结构就实现完成,我们为其写上一些测试代码测试无误之后,也可再在这个基础之上添枝加叶,让其功能更加的丰富,因为目前实现的只是一颗非常普通的二叉树,没有任何其它额外的特性,但是只要我们把最基础的掌握了,其它额外的“枝叶”掌握起来也就会轻松许多。
下面我们再来学一些这些二叉树的“枝叶”!
4、二叉排序树
首先就是二叉树中最常见的一种,那就是二叉排序树,我们首先来看一下它的“枝叶”,也就是它的额外特性是什么,它的定义如下
二叉排序树或者是一棵空树;或者是具有下列性质的二叉树: (1)若左子树不空,则左子树上所有结点的值均小于它的根结点的值; (2)若右子树不空,则右子树上所有结点的值均大于它的根结点的值; (3)左、右子树也分别为二叉排序树;
换个简单的说法就是左孩子节点的值是小于根节点的,右孩子节点的值是大于根节点的。ok,再加上这个特性之后,带来的影响是什么呢?答案就是遍历的效率,我们不难想到二分查找的思想,这里也类似,在二叉排序树中查找一个元素时,首先和根元素相比较,然后如果大于根节点的值,则在较小的一边,也就是左子树继续递归查找,若大于根节点的值,则在右子树中查找。
现在我们再来思考如何实现二叉排序树,也就是如何将“枝叶”特性给二叉树添加上去,我们不难想到的是在添加节点的方法中做处理,让其保持左子树节点值小,右子树节点值大。那么如何实现呢?我们可以采用递归,当插入节点的值大于根节点时,则在根节点的右子树中继续递归比较,当小于时,则在左子树中继续递归,如果发现根节点为空,则将带插入节点放在这里即可,递归结束。相关代码如下
//递归插入节点
//待插入节点:node 根节点:root
public Node insertOrder(Node node, Node root) {
if (root == null) {
root = node;
return root;
}
if (node.data < root.data) {
root.lChild = insertOrder(node, root.lChild);
} else if (node.data > root.data) {
root.rChild = insertOrder(node, root.rChild);
} else {
// 节点已经存在,做相应处理
}
return root;
}
现在实现了插入,我们是采用的递归实现,那么我们是否可以采用非递归来实现插入呢?答案当然是可以的,只需要找到带插入节点的值在树中相应的未知即可,插入节点的非递归代码如下
// 非递归插入一个节点
public void insert(Node node) {
if (root == null) {
root = node;
return;
}
Node current = root;
// 记录上一个节点
Node parent = null;
while (true) {
parent = current;
if (node.data < current.data) {
current = current.lChild;
if (current == null) {
parent.lChild = node;
break;
}
} else if (node.data > current.data) {
current = current.rChild;
if (current == null) {
parent.rChild = node;
break;
}
} else {
System.out.println("节点重复");
return;
}
}
}
在实现完了插入节点之后,那么我们自然而然还需要给删除节点的方法做处理,否则我们这棵树在删除节点的时候,就无法保证二叉排序树的特性了,现在我们再来思考如何删除一个节点让其仍然维持特性呢?这里情况就比较复杂了,我们分为如下四种情况:
- 待删除节点是叶子节点
- 待删除节点只有左子树
- 待删除节点只有右子树
- 待删除节点左右子树都有
对第一种情况,也就是为叶子节点的情况来说,非常简单,我们只需要删除它即可;现在来看第二种和第三种情况,如果待删除节点只有左子树或右子树,这种情况也很简单,我们只要将它的孩子节点“接在”它的父节点下面即可,也就是直接让孩子节点替换到自己的位置;最后一种情况就稍微复杂点了,如果待删除节点左右子树都有,那么为了维持特性,我们有两种方案,一种是选择左子树中最大的节点替换当前节点,另一种是选择右子树中最小的节点替换当前节点。
ok,我们有了上面的思路,现在来实现这个删除方法,如下
// 删除一个节点
public void remove(Node node) {
if (!contains(root, node)) {
System.out.println("删除的节点不存在");
return;
}
// 待删除节点的父节点
Node parent = root;
// 待删除节点
Node current = root;
// 待删除节点是父节点的左儿子还是右儿子
boolean isRightChild = false;
while (node.data != current.data) {
parent = current;
if (node.data < current.data) {
current = current.lChild;
isRightChild = false;
} else if (node.data > current.data) {
current = current.rChild;
isRightChild = true;
}
}
// 分四种情况讨论
// 叶子节点,左右子树都为空
if (current.lChild == null && current.rChild == null) {
if (current == root) {
root = null;
return;
}
if (isRightChild) {
parent.rChild = null;
} else {
parent.lChild = null;
}
return;
}
// 只有右子树
if (current.lChild == null && current.rChild != null) {
if (current == root) {
root = root.rChild;
return;
}
if (isRightChild) {
parent.rChild = current.rChild;
} else {
parent.lChild = current.rChild;
}
return;
}
// 只有左子树
if (current.lChild != null && current.rChild == null) {
if (current == root) {
root = root.lChild;
return;
}
if (isRightChild) {
parent.rChild = current.lChild;
} else {
parent.lChild = current.lChild;
}
return;
}
// 左右子树都有
if (current.lChild != null && current.rChild != null) {
parent = current;
// 转向右子树,然后向左走到尽头 PS:也可以转向左子树,然后向右走到尽头,二者一样都维护了结构的完整性
Node s = current.rChild;
while (s.lChild != null) {
parent = s;
s = s.lChild;
}
node.data = s.data;// 将node的值设为右子树最左侧s的值,也就是右子树最大的值
if (parent == node) {
parent.rChild = s.rChild;
} else {
parent.lChild = s.rChild;
}
return;
}
}
在实现完之后,我们发现虽然我们逻辑清晰,但是我们的代码却非常的冗长,原因就是我们分了很多种不同的情况,我们如果想再精简一点的话,可以将第二种情况和第三种情况合并,来减少代码冗余,当然这并不是最好的办法,我们换个思路,不难想到使用递归的方法来解决删除节点的问题,现在我们来尝试一下递归删除,理一下思路:首先通过递归比较,找到待删除元素在树中的位置,然后找到左子树最大的节点或右子树中最小的节点替换待删除节点即可。
为了便于理解,这里可以将找左子树中最大值的节点和找右子树中最小的节点单独写一个方法,最终代码如下
// 递归删除结点
// node->待删除结点
// root->根结点
private Node remove(Node node, Node root) {
// 根结点为空,直接返回
if (root == null) {
return null;
}
// 新的根为右儿子
if (node.data > root.data) {
root.rChild = remove(node, root.rChild);
} else if (node.data < root.data) {
root.lChild = remove(node, root.lChild);
} else {
if (root.lChild != null && root.rChild != null) {
// 找到右子树中最小结点的值赋值给待删除结点,然后删除右子树的最小结点
Node replaceNode = findMin(root.rChild);
root.data = replaceNode.data;
root.rChild = remove(replaceNode, root.rChild);
} else {
root = (root.lChild != null) ? root.lChild : root.rChild;
}
}
return root;
}
// 寻找指定子树中的最小元素
public Node findMin(Node root) {
if (root == null) {
return null;
}
Node current = root;
while (current.lChild != null) {
current = current.lChild;
}
return current;
}
现在插入删除节点的方法都完成了,我们也就完成了一颗二叉排序树的“枝叶”,最后记得写测试用例测试正确性。
5、平衡二叉树
下面再来看看另一个二叉树,平衡二叉树,先来看看它的定义
平衡二叉树,是一颗特殊的二叉搜索树,它的性质如下:它是一 棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
我们先来思考为什么要有平衡二叉树存在,上面我们讲到了二叉排序树,其最重要的作用就体现在查找这里,其可实现二分的效果,也就是时间复杂度可达到O(log(N))级别,但是可否想过,假设原序列有序,按照顺序依次往二叉排序树中插入元素,那么最终一颗二叉排序树将会退化为一个链表,导致查找的效率降低为O(N),所以我们需要有一种办法来规避这种情况,而这个解决办法就是平衡二叉树,因为二叉排序树进化成了平衡二叉树,所以其永远维持在“平衡”的状态,也就是左右子树高度差不超过1,时间复杂度也将一直维持在O(log(N))级别。
对平衡二叉树有一个基本的了解了之后,同样的,我们再来看看怎么实现它,由于平衡二叉树是在二叉排序树上又新添加的“枝叶”,所以我们还是围绕核心的两个方法,插入节点和删除节点。在二叉排序树的节点插入基础上,当我们发现插入某个节点使其性质不满足平衡二叉树了,那么就需要对其进行调整,由于这里比较复杂,涉及到二叉树的旋转问题,限于篇幅,所以具体的调整方法就不赘述了,主要分为四种,左旋、右旋、左右旋和右左旋,相关的插入节点代码和删除节点代码如下
// node 为插入的树的根结点
// insertNode 为插入的节点
private Node insert(Node node, Node insertNode) {
if (node == null) {
node = insertNode;
} else {
if (insertNode.data < node.data) {// 将data插入到node的左子树
node.lChild = insert(node.lChild, insertNode);
// 如果插入后失衡
if (getHeight(node.lChild) - getHeight(node.rChild) == 2) {
if (insertNode.data < node.lChild.data) {// 如果插入的是在左子树的左子树上,即要进行LL翻转
node = leftLeftRotation(node);
} else {// 否则执行LR翻转
node = leftRightRotation(node);
}
}
} else if (insertNode.data > node.data) {
// 将data插入到node的右子树
node.rChild = insert(node.rChild, insertNode);
// 如果插入后失衡
if (getHeight(node.rChild) - getHeight(node.lChild) == 2) {
if (insertNode.data > node.rChild.data) {
node = rightRightRotation(node);
} else {
node = rightLeftRotation(node);
}
}
} else {
System.out.println("节点重复啦");
}
node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
}
return node;
}
// 删除节点
private Node remove(Node node, Node removeNode) {
if (node == null) {
return null;
}
// 待删除节点在node的左子树中
if (removeNode.data < node.data) {
node.lChild = remove(node.lChild, removeNode);
// 删除节点后,若失去平衡
if (getHeight(node.rChild) - getHeight(node.lChild) == 2) {
Node rNode = node.rChild;// 获取右节点
// 如果是左高右低
if (getHeight(rNode.lChild) > getHeight(rNode.rChild)) {
node = rightLeftRotation(node);
} else {
node = rightRightRotation(node);
}
}
} else if (removeNode.data > node.data) {// 待删除节点在node的右子树中
node.rChild = remove(node.rChild, removeNode);
// 删除节点后,若失去平衡
if (getHeight(node.lChild) - getHeight(node.rChild) == 2) {
Node lNode = node.lChild;// 获取左节点
// 如果是右高左低
if (getHeight(lNode.rChild) > getHeight(lNode.lChild)) {
node = leftRightRotation(node);
} else {
node = leftLeftRotation(node);
}
}
} else {// 待删除节点就是node
// 如果Node的左右子节点都非空
if (node.lChild != null && node.rChild != null) {
// 如果左高右低
if (getHeight(node.lChild) > getHeight(node.rChild)) {
// 用左子树中的最大值的节点代替node
Node maxNode = maxNode(node.lChild);
node.data = maxNode.data;
// 在左子树中删除最大的节点
node.lChild = remove(node.lChild, maxNode);
} else {// 二者等高或者右高左低
// 用右子树中的最小值的节点代替node
Node minNode = minNode(node.rChild);
node.data = minNode.data;
// 在右子树中删除最小的节点
node.rChild = remove(node.rChild, minNode);
}
} else {
// 只要左或者右有一个为空或者两个都为空,直接将不为空的指向node
// 两个都为空的话,想当于最后node也指向了空,逻辑仍然正确
node = node.lChild == null ? node.rChild : node.lChild;// 赋予新的值
}
}
if(node!=null) {
node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
}
return node;
}
对应的四种二叉树的旋转代码如下
// 左左翻转 LL
// 返回值为翻转后的根结点
private Node leftLeftRotation(Node node) {
// 首先获取待翻转节点的左子节点
Node lNode = node.lChild;
node.lChild = lNode.rChild;
lNode.rChild = node;
node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
lNode.height = max(getHeight(lNode.lChild), node.height) + 1;
if (node == root) {
root = lNode;// 更新根结点
}
return lNode;
}
// 右右翻转 RR
// 返回值为翻转后的节点
private Node rightRightRotation(Node node) {
// 首先获取待翻转节点的右子节点
Node rNode = node.rChild;
node.rChild = rNode.lChild;
rNode.lChild = node;
node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
rNode.height = max(getHeight(rNode.lChild), node.height) + 1;
if (node == root) {
root = rNode;// 更新根结点
}
return rNode;
}
// 左右翻转 LR 先对左子节点进行RR翻转,再对自身进行LL翻转
// 返回值为翻转后的节点
private Node leftRightRotation(Node node) {
node.lChild = rightRightRotation(node.lChild);
return leftLeftRotation(node);
}
// 右左翻转 RL 先对右子节点进行LL翻转,再对自身进行RR翻转
// 返回值为翻转后的节点
private Node rightLeftRotation(Node node) {
node.rChild = leftLeftRotation(node.rChild);
return rightRightRotation(node);
}
由于这里涉及到的二叉树的旋转并没有详细说明,所以这一块需要额外去学习,一般只要弄懂了四种旋转中的其中一种,然后剩下的三种旋转也就比较好理解了。
6、堆
最后我们来看看另外一个经常在开发中遇到的数据结构,那就是堆,其实准确的说,也不能把堆叫做“另外一个数据结构”,因为它其实就是一颗普通的二叉树,只不过和二叉排序树一样,它是在节点数据元素值的大小上做了一些限制,堆一般分为两种,大根堆和小根堆,这两者类似,然后一般默认情况下,我们说的堆是指二叉堆,也就是孩子节点数目小于等于两个,当然言外之意就是说还有多叉堆,这个就是孩子节点数目的问题,不作过多讨论。现在我们就来看看大根堆的定义。如下
根结点(亦称为堆顶)的关键字是堆里所有结点关键字中最大者,称为大根堆,又称最大堆(大顶堆)。堆的子树也是堆。
或者另一种我更喜欢的定义:
最大堆 根节点大于左右节点的完全二叉树;
最小堆 根节点小于左右节点的完全二叉树
由此,我们可以很自然的想到小根堆的定义,就不赘述了,然后画个图再来加深下对这两个堆的映像
怎么样,是不是很简单,现在我们再来看看堆在实际中的简单应用,也就是为什么要有堆这个东西存在,在实际中,听到的最多的可能就是堆排序了,但是你会发现一般的排序场景,我们都是直接写个快速排序或简单的插入排序等解决问题,那什么场景下才会利用堆排序呢,答案就是海量数据排序,或者海量数据排序的变种,例如海量数据TopK问题
TopK问题,就是求一组数据中最大或最小的某几个值,例如,有十亿条数据,找出现次数最多的100个。
上面说到的一些情况,我们会发现都离不开一个关键字,那就是海量数据,这也正是适合堆的应用场景,为什么堆会适合这个应用场景呢?原因就是在堆这个数据结构中,堆顶元素总是整个数据元素中最大或者最小的元素,这就会带来很多方便性,例如上面这个问题,求十亿条数据中,出现次数最多的100个,首先通过HashMap
或者其它手段统计每个元素对应的次数,得到一个次数序列,然后维护一个大小为100的小根堆,然后遍历这个次数序列,每次和堆顶元素值比较,一旦大于堆顶元素值,就往小根堆中插入这个值,最终小根堆中的100个次数值对应的数据就是出现次数最多的100个,因为堆顶元素是最小的,所以堆中剩余元素都是大于这个值的,而且堆顶的次数值正好是出现次数第100的数据对应的次数值。
接下来,我们可以自己实现一个堆,其核心的方法就是维护堆的性质,也就是当插入数据和删除数据之后,如果堆的性质被破坏了,就要及时调整过来,也就是这个调整方法的实现。
其实我们只要掌握了如何来调整,也就成功了一半,我们先理清一下思路:在一个小根堆中,当插入删除一个数据元素的时候,如果孩子节点的值小于根节点,那么我们就需要调换根节点和孩子节点的值,来让最小的值始终在根节点,但是要知道,在堆这个树状的数据结构中,一旦交换了节点值,就可能产生连带影响,也就是说会影响祖父节点的值情况,这样可能导致祖父节点也需要调整,所以这里就需要递归或者循环,下面我们来看看这个调整方法
// 构建最小堆
public void buildMinHeap() {
// 叶子节点没有子节点不用重构堆,所以从长度的一半开始调整
for (int i = length / 2; i >= 0; i--) {
minHeapFixed(heap, i, length);
}
}
// 从i节点开始调整,n为节点总数 从0开始计算 i节点的子节点为 2*i+1, 2*i+2
// 删除节点时,把根元素和最后的一个元素交换,并根据新交换的新根元素调整整个堆(从上到下沉降调整),所以此时i为0
public void minHeapFixed(int arr[], int i, int n) {
// 获取要调整的节点的值
int child = 2 * i + 1;//先获取左孩子
while (child <= n) {
// 保证有右孩子并且右孩子比左孩子小
if (child + 1 <= n && arr[child + 1] < arr[child]) {
child++;
}
// 根比左右孩子都小则结束循环
if (arr[child] >= arr[i]) {
break;
}
// 根比左和右大,用左、右的最小值覆盖根值,并定义新的根和孩子向下沉降递归
int temp = arr[i];
arr[i] = arr[child];
arr[child] = temp;
child = 2 * i + 1;
i = child;
child = child * 2 + 1;
}
}
我们首先看到buildMinHeap
这个方法,注意到我们是从length/2
处开始调整的,为什么呢,这个其实非常好理解,因为对于叶子节点来说,它没有孩子节点,这种情况下是肯定满足堆的性质的,所以对于这些节点,我们可以跳过,不用从它开始调整,而选择第一个有孩子节点的节点开始调整,而第一个有孩子节点的节点,根据树的排序规律,正好是下标位于长度的一半的位置,文字不如图好理解,看下图
对上图这个长度为11的堆来说,我们只需要从长度的一半,也就是11/2=5,从下标为5的元素,也就是元素值为8开始调整即可。
当然堆还有很多其它的应用,例如jvm里对象内存的分配等等,由于目前能力尚浅,就不深入分析了,以后如果关于堆有新发现了,再来这里补充上也不迟,hhhhhh
结语
呼,关于树这一节,算是暂告一段落了,本来准备写的内容没有这么多,结果发现树这里东西还是太多了,其中每一个不同类型的树单独拿出来学习都可以有很多东西,为了方便,我还是将它们尽可能的整合到了一起,也就是这篇文章,日后,应该会再来这里补充,树这一篇,就到这吧!!
下一篇,图!!!ready---->>>>>