这是我参与8月更文挑战的第5天,活动详情查看:8月更文挑战
本系列文章为个人学习总结,如果有发现错误或存在疑问之处,欢迎留言指点!
本文是重学数据结构系列的第五篇,系列文章如下:
1.算法时间复杂度和空间复杂度
2.重学数据结构--链表
3.重学数据结构--队列
4.重学数据结构--栈
5.重学数据结构--树
1.为什么需要树这种结构
数组存储方式分析
- 优点:通过下标方式访问元素,速度快。对于有序数组还可以使用二分查找提高检索速度
- 缺点:如果需要检索某个值,需要一个个检索,插入某个值或者删除某个值需要整体移动,效率低
链式存储方式分析
- 优点:在一定程度上对数组存储方式优化(例如:插入一个节点,只需要插入节点链接到链表中即可,删除效率也很好)
- 缺点:在进行检索时,比如检索某个值,需要从头节点检索,效率仍然比较低。
树存储结构方式分析
能提高数据存储、读取效率,例如利用二叉排序树,既可以保证数据的检索速度,同时也可以保证数据的插入、删除、修改的速度。
2.术语和相关概念
2.1相关术语
- 节点
- 根节点
- 父节点
- 子节点
- 叶子节点(没有子节点的节点)
- 节点的权(节点的值)
- 路径(从根节点找到对应节点的路线)
- 层
- 度:对于一个结点,拥有的子树数(结点有多少分支)称为结点的度
- 子树
- 树的高度(最大层数)
- 森林(多颗子树构成森林)
2.2相关概念
二叉树
树有很多种,每个节点最多只有两个子节点的一种树称为二叉树。二叉树的子节点分为左节点和右节点
满二叉树
如果二叉树的所有叶子节点都在最后一层,并且节点总数=2^n-1,n为层数,则称该树为满二叉树。
完全二叉树
如果二叉树的所有叶子节点都在最后一层或者倒数第二层,而且最后一层的叶子节点在左边连续,倒数第二层的叶子节点在右边连续,我们称该树为完全二叉树。
二叉树的存储结构有两种,分别为顺序存储和链式存储。
3.链式存储二叉树的相关操作
二叉树的整体结构为链表,树的相关操作有增删改查,但是只要掌握了树的遍历、删除其他的基本上也就简单了。
3.1遍历
树的遍历有三种方法,分别为先序、中序、后序(这里的先中后表示 根节点遍历的顺序)
- 先序遍历:根节点、左节点、右节点
- 中序遍历:左节点、根节点、右节点
- 后序遍历:左节点、右节点、根节点
实现
// 先序遍历查找
public HeroNode preTraverSearch(int no){
if(this.no == no){
return this;
}
// 用于存储查找结果
HeroNode resNode = null;
if(this.left!=null){
resNode = this.left.preTraverSearch(no);
}
if(resNode!=null){
return resNode;
}
if(this.right!=null){
resNode = this.right.preTraverSearch(no);
}
return resNode;
}
// 中序遍历查找
public HeroNode midTraverSearch(int no){
HeroNode resNode = null;
if(this.left!=null){
resNode = this.left.midTraverSearch(no);
}
if(resNode!=null){
return resNode;
}
if(this.no == no){
resNode = this;
}
if(resNode!=null){
return resNode;
}
if(this.right!=null){
resNode = this.right.midTraverSearch(no);
}
return resNode;
}
//后续遍历查找
public HeroNode postTraverSearch(int no){
HeroNode resNode = null;
if(this.left!=null){
resNode = this.left.postTraverSearch(no);
}
if(resNode!=null){
return resNode;
}
if(this.right!=null){
resNode = this.right.postTraverSearch(no);
}
if(resNode!=null){
return resNode;
}
if(this.no == no){
resNode = this;
}
return resNode;
}
3.2层序遍历
-
1.层序遍历首先需要知道二叉树有几层(递归实现)
// 获取树的高度(即几层) public int depth(HeroNode node){ int leftDepth=0; int rightDepth=0; if(node==null){ return 0; } if(node.left!=null){ leftDepth = depth(node.left); } if(node.right!=null){ rightDepth = depth(node.right); } return (leftDepth>rightDepth?leftDepth:rightDepth)+1; } -
2.每一层遍历输出
// 层序输出 public void levelPrint(HeroNode node,int level){ //空树或层级不合理 if(node==null||level<1){ return; } if(level==1){ System.out.print(node+"\t"); } levelPrint(node.left,level-1); levelPrint(node.right,level-1); } // 层序遍历 public void levelTraverse(HeroNode node){ if(node==null){ return; } int depth = depth(node); for (int i = 1; i <= depth ; i++) { levelPrint(node,i); System.out.println(); } }
3.3删除
对于链式存储二叉树,你会遍历之后,对于删除其实很简单。你只要找到要删除的对应节点,然后将其置空即可。
实现
// 递归删除节点
public void delNode(int no){
// 左子节点不为空且左子节点对应数据就是要删数据
if(this.left!=null && this.left.no == no){
this.left = null;
return;
}
// 右子节点不为空且右子节点就是要删除的数据
if(this.right!=null && this.right.no == no){
this.right = null;
return;
}
// 左子节点不为空,向左递归
if(this.left!=null){
this.left.delNode(no);
}
// 右子节点不为空,向右递归
if(this.right !=null){
this.right.delNode(no);
}
}
4.顺序存储二叉树
二叉树的顺序存储,指的是使用顺序表(数组)存储二叉树。需要注意的是,顺序存储只适用于完全二叉树。换句话说,只有完全二叉树才可以使用顺序表存储。因此,如果我们想顺序存储普通二叉树,需要提前将普通二叉树转化为完全二叉树。
普通二叉树转完全二叉树的方法很简单,只需给二叉树额外添加一些节点,将其"拼凑"成完全二叉树即可。
特点:
- 第n个元素的左节点为
2*n+1 - 第n个元素的右节点为
2*n+2 - 第n个元素的父节点为
(n-1)/2 - n表示二叉树中的第几个元素(从上到下,从左至右计数,从0开始)
- 数组可以转换成二叉树,二叉树也可以转换成数组(层序遍历)
5.线索化二叉树
5.1相关介绍
为什么会有线索化二叉树?当我们希望得到二叉树中某一个节点的前一个节点(前驱节点)或者后一个节点(后驱节点)时,普通的二叉树是无法直接得到的,只能通过遍历一次二叉树得到。每当涉及到求前驱节点或者后驱节点就要将二叉树遍历一次,这样很不方便。这个时候你是否想得到了双向链表这种结构呢?每一个节点存储前一个节点跟后一个节点的信息。线索二叉树就是基于这种结构实现的。
观察下图二叉树结构,我们会发现6、8、10、14节点的左右指针没有被完全利用。
对于一个有n个节点的二叉链表,每个节点都有指向左右节点的两个指针域,一共有2n个指针域。而n个节点的二叉树又有n-1条分支线(除了根节点,没一条分支都指向一个节点),也就是存在2n-(n-1)=n+1个空指针域。这些空指针域的空间就被浪费了,因此,可以用空链域来存放节点的前驱和后继。线索二叉树就是利用n+1个空链域来存放前驱和后继节点的信息。
我们把指向前驱和后继的指针叫做线索 ,加上线索的二叉树就称之为线索二叉树。
对普通二叉树以某种次序遍历使其成为线索二叉树的过程就叫做线索化。因为前驱和后继结点只有在二叉树的遍历过程中才能得到,所以线索化的具体过程就是在二叉树的遍历中修改空指针。
中序线索后如下图所示:
- 8的后继节点为3
- 10的前驱节点为3,10的后继节点为1
- 14的前驱节点为1,14的后继节点为6
5.2具体实现
如果只在原二叉树的基础上利用空节点,那么就存储在一个问题,不知道左指针指向的是它的左孩子还是前驱节点,右指针指向的是它的右孩子还是后继节点。于是我们可以加上它们的指向设置标志加以区分。
如果 leftType == 0 表示指向的是左子树, 如果 1 则表示指向前驱结点
如果 rightType == 0 表示指向是右子树, 如果 1 表示指向后继结点
6.树结构的实际应用
6.1堆排序
在学习堆排序之前,首先需要了解堆的含义:在含有 n 个元素的序列中,如果序列中的元素满足下面其中一种关系时,此序列可以称之为堆。
- 小顶推:ki ≤ k2i 且 ki ≤ k2i+1(在 n 个记录的范围内,第 i 个关键字的值小于第 2i 个关键字,同时也小于第 2i+1 个关键字)每个节点的值都小于或等于其左右节点的值
- 大顶堆:ki ≥ k2i 且 ki ≥ k2i+1(在 n 个记录的范围内,第 i 个关键字的值大于第 2i 个关键字,同时也大于第 2i+1 个关键字)每个节点的值都大于或等于其右孩子节点的值
对于堆的定义也可以使用完全二叉树来解释,因为在完全二叉树中第 i 个结点的左孩子恰好是第 2i 个结点,右孩子恰好是 2i+1 个结点。如果该序列可以被称为堆,则使用该序列构建的完全二叉树中,每个根结点的值都必须不小于(或者不大于)左右孩子结点的值。
以无序表{49,38,65,97,76,13,27,49}来讲,其对应的堆用完全二叉树来表示为:
堆用完全二叉树表示时,其表示方法不唯一,但是可以确定的是树的根结点要么是无序表中的最小值,要么是最大值。
通过将无序表转化为堆,可以直接找到表中最大值或者最小值,然后将其提取出来,令剩余的记录再重建一个堆,取出次大值或者次小值,如此反复执行就可以得到一个有序序列,此过程为堆排序(一般升序采用大顶堆,降序采用小顶堆)。
思路
- 将无序序列构建成一个堆即满足堆的条件(从最后一个非叶子节点开始),根据升序降序需求选择大顶堆或小顶堆
- 将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端
- 重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤, 直到整个序列有序。
举例图解说明
给你一个数组 {4,6,8,5,9} , 要求使用堆排序法,将数组升序排序。
步骤一:构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。 原始的数组 [4, 6, 8, 5, 9]
-
假设给定的无序列结构如下
-
此时我们从最后一个非叶子结点开始(叶结点自然不用调整,第一个非叶子结点 arr.length/2-1=5/2-1=1,也就是下面的 6 结点),从左至右,从下至上进行调整。[6,5,9]中9元素最大,6和9进行交换
-
找到第二个非叶节点 4,由于[4,9,8]中 9 元素最大,4 和 9 交换
-
这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中 6 最大,交换 4 和 6。
此时,我们就将一个无序序列构造成了一个大顶堆。
步骤二: 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。
-
将堆顶元素 9 和末尾元素 4 进行交换
-
重新调整结构,使其继续满足堆定义
-
再将堆顶元素 8 与末尾元素 5 进行交换,得到第二大元素 8
-
后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序
具体实现
堆排序是一种选择排序,它的最坏,最好,平均时间复 杂度均为 O(nlogn) ,它也是不稳定排序。
6.2赫夫曼树
赫夫曼树,别名“哈夫曼树”、“最优树”以及“最优二叉树”。学习哈夫曼树之前,首先要了解几个名词。
赫夫曼树相关名词
- 路径:在一棵树中,一个结点到另一个节点之间的通路,称为路径。
- 路径长度:在一条路径中,每经过一个节点,路径长度都要加 1 。例如在一棵树中,规定根结点所在层数为1层,那么从根结点到第 i 层结点的路径长度为 i - 1 。下图 中从根结点到结点 c 的路径长度为 3。
- 节点的权:给每一个节点赋予一个新的数值,这个值称为节点的权。如下图中a节点的权为7,b节点的权为5
- 节点的带权路径长度:指的是从根节点到该节点的路径长度与该点权值的乘积。例如,下图节点b的带权路径长度为2*5=10。
树的带权路径长度为树中所有叶子结点的带权路径长度之和。通常记作 “WPL” 。例如下图所示的这颗树的带权路径长度为:
WPL = 7 * 1 + 5 * 2 + 2 * 3 + 4 * 3
什么是赫夫曼树
当用 n 个节点(都做叶子节点且都有各自的权值)试图构建一棵树时,如果构建的这棵树的带权路径长度(WPL)最小,称这棵树为“最优二叉树”,也叫“赫夫曼树”。
在构建赫夫曼树时,要使树的带权路径长度最小,只需要遵循一个原则,那就是:权重越大的结点离树根越近。
实现
6.3二叉排序树
问题思考
给你一个数列 (7, 3, 10, 12, 5, 1, 9),要求能够高效的完成对数据的查询和添加。
解决方案分析
数组
数组未排序,优点:直接在数组尾添加速度快。缺点:查找速度慢(从前到后查找)
数组已排序,优点:使用二分查找速度很快。缺点:为了使数组有序,插入时需要数据整体移动速度慢
链表
链表不管有序还是无序,查找速度都很慢,添加速度比数组快,不需要数据整体移动。
二叉排序树
正满足需求。
什么是二叉排序树?
二叉排序树,又称BST(Binary Soft(Search) Tree)。对于二叉树的任何一个非叶子节点,满足其左子节点的值比父节点的值小,其右子节点的值比父节点的值大,这样的二叉树称为二叉排序树(中序遍历正好升序)。
特别说明:如果有相同的值,可以将该节点放在左子节点或右子节点。
比如数据 (7, 3, 10, 12, 5, 1, 9) ,对应的二叉排序树为:
二叉排序树删除节点
这里创建二叉树(采用递归)和遍历二叉树(中序遍历)都相对简单。相对难理解的是删除节点,因为需要考虑的情况比较多。因为二叉排序树需要满足条件左子节点比父节点值小,右子节点比父节点值大,所以每次都要找到两个节点,一个是要删除的节点,另一个是要删除节点的父节点。找到这两个节点后分下面三种情况:
1.需要删除的节点是叶子节点
-
如何判断:要删除节点没有左子节点也没有右子节点就是这种情况
-
将叶子节点的值设为空就将对应节点删除了
if(targetNode.left==null&&targetNode.right==null){ // 如果要删除的节点是叶子节点 if(parent.left!=null&&parent.left.value==value){ // targetNode是父节点的左子节点 parent.left=null; }else if(parent.right!=null&&parent.right.value==value){ // targetNode节点是父节点的右子节点 parent.right=null; } }
3.需要删除的节点有两颗子树
-
如何判断:要删除节点既有右子树又有左子树
-
找出右子节点中最小值对应的节点将其删除,并把其节点值替换给要删除的节点。例如上图序列 (7, 3, 10, 12, 5, 1, 9) ,要删除节点7,只需要将其右子节点最小值9删除,并将7替换为9就完成了删除(满足中序遍历结果s升序)
2.需要删除的节点只有一颗子树
-
如何判读:除了以上两种情况剩下就是这种情况了
-
这里分两种情况考虑
-
要删除的节点只有左子树
- 要删除的节点非根节点
- 要删除的节点是根节点
-
要删除的节点只有右子树
- 要删除的节点非根节点
- 要删除的节点是根节点
下面贴个图只有左子节点情况,方便理解。只有右子节点类比。
// 3.删除只有一颗子树的节点 if(targetNode.left!=null){ // 如果要删除的节点有左子节点 if(parent!=null){ if(parent.left.value==value){ parent.left = targetNode.left; }else { // targetNode 是parent的右子节点 parent.right = targetNode.right; } }else { // 要删除的节点是根节点 root = targetNode.left; } }else { // 如果要删除的节点有右子节点 if(parent!=null){ if(parent.left.value==value){ // 如果targetNode是parent的左子节点 parent.left = targetNode.right; }else{ // 如果targetNode 是parent的右子节点 parent.right = targetNode.right; } }else { // 需要删除的节点有根节点 root = targetNode.right; } } -
6.4平衡二叉树
看一个问题
给你一个数列{1,2,3,4,5,6},要求创建一颗二叉排序树(构建的二叉排序树如下图),并分析存在的问题。
存在的问题:
- 左子树全部为空,从形式上看,更像一个单链表
- 查询速度明显降低(需要依次比较),不能发挥BST的优势,因为每次还要比较左子树,其速度比单链表还慢
- 解决方案:平衡二叉树(AVL)
什么是平衡二叉树
平衡二叉树也叫平衡二叉搜索树(Self-balancingbinarysearchtree)又被称为AVL树,可以保证查询效率较高。
具有以下特点:
- 平衡二叉树满足二叉排序树的条件(平衡二叉树也是二叉排序树)
- 它的左右子树的高度差绝对值不超过1,并且左右子树都是一颗平衡二叉树
- 平衡二叉树的常用实现方法又红黑树、AVL、替罪羊树、伸展树
当一颗二叉排序树不是平衡二叉树时可以通过旋转转换为平衡二叉树。旋转有左旋转、右旋转、双旋转三种。
左旋转
例如由{4,3,6,5,7,8}构建的二叉排序树,我们通过左旋转就可以把它转换成平衡二叉树。
左旋转条件:(右子树高度-左子树高度)>1
左旋转思路:
- 1.以当前节点的值为基准创建一个新的节点
- 2.把新节点的左子树设置为当前节点的左子树
- 3.把新节点的右子树设置为当前节点的右子树的左子树
- 4.把当前节点的值替换为当前节点右子树的值
- 5.把当前节点的右子树的值设置为当前节点右子树的右子节点的值
- 6.把当前节点的左子树的值设置为新节点
友情提醒:这么多步骤是不是有点晕了?没关系,不用纠结,多画几次图自然就明白了。就像小时候玩魔方,刚开始的时候背公式,后面玩多了,不用公式也可以把它拼好。
下面是我画的左旋转过程。
代码实现:
public void leftRotate(){
// 以当前节点的值创建新的节点
Node3 newNode = new Node3(value);
//把新的节点的左子树设置成当前节点的左子树
newNode.left = left;
//把新的节点的右子树设置为当前节点右子树的左子树
newNode.right = right.left;
//把当前节点的值替换成右子节点的值
value = right.value;
//把当前节点的右子树设置成当前节点右子树的左子树
right = right.right;
//把当前节点的左子树设置成新的节点
left = newNode;
}
右旋转
例如由{10,12, 8, 9, 7, 6}数列构建的二叉排序树,我们通过右旋转就可以把它转换成平衡二叉树。
右旋转条件:(左子树高度-右子树高度)>1
右旋转思路:
- 1.以当前节点的值创建新的节点
- 2.把新节点的左子树设置为当前节点左子树的右子节点
- 3.把新节点右子树设置为当前节点的右子树
- 4.替换当前节点的值为当前节点左子节点的值
- 5.把当前节点的左子节点设置为当前节点左子树的左子节点的值
- 6.把当前节点右子树设置为新的节点
画图旋转过程如下。
代码实现:
public void rightRotate(){
Node3 newNode = new Node3(value);
newNode.right = right;
newNode.left = left.right;
value = left.value;
left = left.left;
right = newNode;
}
双旋转
有些数列我们通过单旋转不能转换成平衡二叉树,这时候我们可以通过双旋转实现。例如由{ 10, 11, 7, 6, 8, 9 }构建成的排序二叉树,我们可以通过双旋转转换成平衡二叉树。
双旋转条件:
- 1.满足右旋转条件时:它的左子树的右子树高度>它的左子树的左子树高度
- 2.满足左旋转条件时:它的右子树的左子树高度>它的右子树的右子树高度
思路:
- 满足右旋转条件同时满足双旋转条件,先把当前节点左子树进行左旋转,再把当前节点进行右旋转
- 满足左旋转条件同时满足双旋转条件,先把当前节点的右子树进行右旋转,再把当前节点进行左旋转
图示过程:
代码实现:
//当添加完一个节点后,如果(右子树高度-左子树高度)>1 左旋转
if(rightHeight()-leftHeight()>1){
// 如果它的右子树的左子树的高度大于它的右子树的高度
if(right!=null&&right.leftHeight()>right.rightHeight()){
// 先对右子树进行右旋转
right.rightRotate();
// 然后再对当前节点进行左旋转
leftRotate();
}else {
// 直接进行左旋转
leftRotate();
}
return;
}
// 当添加完一个节点后,如果(左子树高度-右子树高度)>1 右旋转
if(leftHeight()-rightHeight()>1){
// 如果它的左子树的右子树高度大于它的左子树的高度
if(left!=null&&left.rightHeight()> left.leftHeight()){
// 先对当前节点的左子树进行左旋转
left.leftRotate();
// 再对当前节点进行右旋转
rightRotate();
}else {
// 直接进行右旋转
rightRotate();
}
}
7.多路查找树(了解)
1.二叉树和B树
二叉树问题分析
二叉树的操作效率较高,但是也存在问题。因为二叉树是需要加载到内存的,如果节点少没有什么问题,如果二叉树的节点很多,就存在如下问题:
- 在构建二叉树时,需要多次进行i/o操作(海量数据存在数据库文件中),节点海量,构建二叉树时,速度有影响。
- 节点海量也会造成二叉树的高度很大,会降低操作速度。
多叉树
在二叉树中,每个节点有数据项,最多有两个子节点。如果允许每个节点可以有更多的数据项和更多的子节点就是多叉树。2-3树、2-3-4树、B树就是多叉树,多叉树通过重新组织节点,减少树的高度,能对二叉树进行优化。
B树的基本介绍
B树通过重新组织节点,降低树的高度,并且减少io读写操作来提升效率。
如下图就是一颗B树。
- B树通过重新组织节点降低了树的高度。
- 文件系统及数据库系统的设计者利用了磁盘预读原理,将一个节点的大小设为等于一个页(页通常大小为4k),这样每个节点只需要一次io读取就可以完全载入。
2. 2-3树
2-3树是最简单的B树结构,具有如下特点
- 2-3树所有叶子节点都在同一层(只要是B树都满足这个条件)
- 有两个节点的节点叫做二节点,二节点要么没有子节点,要么有两个子节点
- 有三个节点的节点叫做三节点,三节点要么没有节点,要么有三个节点。
- 2-3树是由二节点和三节点构成的数。
- 2-3树任然满足二叉排序树(BST)的规则
除了2-3树,还有2-3-4树,概念和2-3树类似,也是一种B树。
3. B树、B+树和B*树
B树的介绍
B-tree树即B树,B即Balance,平衡的意思。有人把B-tree翻译成B-树,容易让人产生误解,会以为B-树是另一种树,而B树又是另外树。实际上,B-tree就是指B树。我们在学习MySQL时,经常听说某种类型的索引是基于B树或者B+树的,B树如下图
对上图的说明:
- B树的阶:节点的最多节点个数。比如2-3树的阶是3,2-3-4树的阶是4
- B树的搜索:从根节点开始,对节点内的关键字序列进行二分查找,如果命中则结束,否则进入查询关键字所属范围的儿子节点;重复,知道所有儿子接地那的指针为空或已经是叶子节点。
- 关键字集合分布在整颗树中,即叶子节点和非叶子节点都存放数据。
- 搜索有可能在非叶子节点结束。
- 搜索性能等价于在关键字全集内做一次二分查找。
B+树的介绍
B+树是B树的变体,也是一种多路搜索树。
对上图说明:
- B+树的搜索与B树基本相同,区别是B+树只有达到叶子节点才命中(B树可以在非叶子节点命中),其性能也等价于在关键字全集做一次二分查找。
- 所有关键字都出现在叶子节点的链表中(即数据只能在叶子节点【也叫稠密索引】),且链表中的关键字恰好是有序的。
- 不可能在非叶子节点命中
- 非叶子节点相当于是叶子节点的所有(稀疏索引),叶子节点相当于是存储(关键字)数据的数据层。
- B+树更适合文件索引系统
- B树和B+树各有自己的应用场景,不能说B+树完全比B树好,反之亦然。
B*树
B*树是B+树的变体,在B+树的非根和非叶子节点再增加指向兄弟的指针。