为什么需要用到树的结构,它有什么好处?
数组存储方式的分析:
优点:通过下标方式访问元素,速度快。对于有序数组,还可使用二分查找提高检索速度。
缺点:如果要检索具体某个值,或者插入值就会按一定顺序会整体移动,效率较低
![image-20210620102611815](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210620102611815.png)
链式存储方式的分析:
优点:插入节点和删除节点效率高。
缺点:在进行检索时效率低(需要从头节点开始遍历)
![image-20210620102902903](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210620102902903.png)
树存储方式的分析:
能提高数据存储,读取的效率,比如利用二叉排序树(Binary Sort Tree)既可以保证数据的检索速度,同时也可以保证数据的插入,删除,修改的速度。
树的介绍
树是一种非线性的数据结构,是由n(n >=0)个结点组成的有限集合。
如果n==0,树为空树。如果n>0,树有一个特定的结点,叫做根结点(root)。根结点只有后继,没有前驱。除根结点以外的其他结点划分为m(m>=0)个互不相交的有限集合,T0,T1,T2,…,Tm-1,每个集合都是一棵树,称为根结点的子树(sub tree)。
- 节点的权:就是节点的值
- 节点的度:节点拥有的子树个数
- 叶子节点:没有子节点的节点,其度为0
- 路径:从root节点找到该节点的路线
- 树的高度:树中节点的最大层数,也叫做树的深度
- 森林:多颗子树构成森林
![image-20210620103427596](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210620103427596.png)
深入理解递归
递归是一种重要的编程技术,该方法用来让一个函数(方法)从其内部调用其自身。一个含直接或间接调用本函数语句的函数,被称之为递归函数。
对于树结构的遍历和处理,最为常用的代码结构就是递归(Recursion)。
递归的实现有两个必要条件:
- 必须定义一个基准条件,这也是递归终止的条件(在Java中递归没有基准条件无法结束递归,直到栈溢出)。在这种情况下,可以直接返回结果,无需继续递归
- 在方法中通过调用自身,向着基准情况不断前进
通过阶乘举例:
一个简单示例就是计算阶乘:
0 的阶乘被特别地定义为 1
n的阶乘可以通过计算 n-1的阶乘再乘以n来求得…
n-1的阶乘可以通过计算 n-2的阶乘再乘以n-1来求得…
…
2的阶乘可以通过计算1的阶乘在乘以2求得
1的阶乘可以通过计算0的阶乘(1)乘以1求得为1
代码实现
// 递归
public class Recursion {
public static void main(String[] args) {
System.out.println(factorial(6));
}
// 求一个数的阶乘
public static int factorial(int num) {
if (num == 0) return 1;
return factorial1(num - 1) * num;
}
}
0的阶乘为1才是基准条件,只有定义此基准才会使得栈的递归调用终止
理解栈帧
JVM Stack(Stack 或虚拟机栈、线程栈、栈)中存放的是 Stack Frame(栈帧)。
一个线程对应一个 JVM Stack。JVM Stack 中包含一组 Stack Frame(栈帧)。线程每调用一个方法就对应着 JVM Stack 中 Stack Frame 的入栈,方法执行完毕或者异常终止对应着出栈(销毁)。在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。
当 JVM 调用一个 Java 方法时,它从对应类的类型信息中得到此方法的局部变量区和操作数栈的大小,并据此分配栈帧内存,然后压入 JVM 栈中。
通过debug理解递归
通过debug发现递归是将栈帧一层层压入栈,发现调用factorial1方法最多压入了七层,他们携带的参数分别是:factorial(nums = 6)、factorial(nums = 5)、factorial(nums = 4)、factorial(nums = 3)、factorial(nums = 2)、factorial(nums = 1)、factorial(nums = 0)
![image-20210621161708289](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210621161708289.png)
继续debug放行,发现栈中的元素一个个的出栈。
nums = 0是该递归方法的基准条件,即栈中最上层的方法factorial(nums = 0)返回的是1,
最上层出栈后,factorial(nums = 1)方法成为了最上层,factorial(nums = 1)里面需要返回factorial1(1 - 1) * 1 = 1 ,随后出栈
以此类推,直到全部出栈后得到递归结果
尾递归
// 尾递归计算阶乘,需要多一个参数保存“计算状态”
public static int factorial(int num, int acc) {
if (num == 1) return acc;
return factorial2(num - 1, acc * num);
}
尾递归的实现,是把递归调用置于函数的末尾,即正好在return语句之前。这种形式的递归被称为尾递归 (tail recursion),其形式相当于循环。一些语言的编译器对于尾递归可以进行优化(C,Java没有优化)来节约递归调用的栈资源。
二叉树
概念
对于树这种数据结构,使用最频繁的是二叉树
每个节点最多只有2个子节点的树,叫做二叉树(某个节点有一个子节点也行)。二叉树中,每个节点的子节点作为根的两个子树,一般叫做节点的左子树和右子树。
二叉树的性质
二叉树有以下性质:
-
若二叉树的层次从0开始,则在二叉树的第i层至多有2^i个结点(i>=0)
-
高度为k的二叉树最多有2^(k+1) - 1个结点 (**注: ** k>=-1,空树的高度为-1)
![image-20210621001051354](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210621001051354.png)
满二叉树和完全二叉树
-
满二叉树:
除了叶子节点外,每个节点都有两个子节点,每一层都被完全填充。
-
完全二叉树:
完全二叉树:除最后一层外,每一层都被完全填充,并且最后一层所有节点保持向左对齐。
如果删掉61节点,就不是完全二叉树,因为在倒数第二层的叶子节点在右边不连续了
对于相同深度的满二叉树和完全二叉树,满二叉树的节点数一定 大于 完全二叉树
![image-20210620153830032](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210620153830032.png)
![image-20210620153924291](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210620153924291.png)
二叉树的遍历
二叉树的遍历包含前序遍历、中序遍历、后序遍历
- 中序遍历:即左-根-右遍历,对于给定的二叉树根节点,寻找其左子树;对于其左子树的根,再去寻找其左子树;递归遍历,直到寻找最左边的节点i,其必然为叶子,然后遍历i的父节点,再遍历i的兄弟节点。随着递归的逐渐出栈,最终完成遍历
- 先序遍历:即根-左-右遍历
- 后序遍历:即左-右-根遍历
- 层序遍历:按照从上到下、从左到右的顺序,逐层遍历所有节点。
![image-20210620110940590](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210620110940590.png)
比如上图正常的一个满节点,A:根节点、B:左节点、C:右节点,前序顺序是ABC(根节点排最先,然后同级先左后右);中序顺序是BAC(先左后根最后右);后序顺序是BCA(先左后右最后根)
举例
![image-20210620114628970](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210620114628970.png)
前序遍历:输出[ A,B,C,D,E,F,G,H,K ]
中序遍历:输出[ B,D,C,A,E,H,G,K,F ]
后序遍历:输出[ D,C,B,H,K,G,F,E,A ]
**中序遍历的讲解:**中序遍历比较难也比较重要(java很多树排序是基于中序的)
将空缺的位置用”空“补上,就比较好理解了
![image-20210620114925666](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210620114925666.png)
第一步:将A节点下面分为左右两部分 partOne,partTwo
![image-20210620115720949](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210620115720949.png)
遍历结果 :
[partOne,A,partTwo ]
**第二步:**在partOne里面再细分
![image-20210620120211013](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210620120211013.png)
[空,B,partThree , A, partTwo]
**第三步:**解决partThree部分
[空,B,D , C, 空,A , partTwo]
这样左边就处理完了。
**第四步:**右边同理
[空 ,B, D, C, 空,A,空,E,H , G, K, F, 空]
**第五步:**去掉空值
[B, D, C, A ,E,H,G,K,F]
这仅仅是我自己的技巧而已
前中后序遍历的代码实现
Node类
package com.zlq.tree.binary_tree;
/**
* @ProjectName:dataStructuresAndAlgorithms
* @Package:com.zlq.tree.binary_tree
* @ClassName: TreeNode
* @description:
* @author: LiQun
* @CreateDate:2021/6/20 2:36 下午
*/
public class TreeNode {
String val;
TreeNode left;
TreeNode right;
public TreeNode(String val) {
this.val = val;
}
@Override
public String toString() {
return "TreeNode{" +
"val='" + val + '\'' +
", left=" + left +
", right=" + right +
'}';
}
public class BinaryTree {
public static void main(String[] args) {
TreeNode root = createBinaryTree();
//前序遍历
BinaryTree binaryTree = new BinaryTree(root);
System.out.println("前序遍历结果为:");
binaryTree.preOrder(root);
System.out.println();
System.out.println("中序遍历结果为:");
binaryTree.infixOrder(root);
System.out.println();
System.out.println("后序遍历结果为:");
binaryTree.postOrder(root);
System.out.println();
System.out.println("=============");
}
public TreeNode root;
/**
* 创建二叉树
* @return 返回根节点
*/
public static TreeNode createBinaryTree(){
TreeNode treeNodeA = new TreeNode("A");
TreeNode treeNodeB = new TreeNode("B");
TreeNode treeNodeC = new TreeNode("C");
TreeNode treeNodeD = new TreeNode("D");
TreeNode treeNodeE = new TreeNode("E");
TreeNode treeNodeF = new TreeNode("F");
TreeNode treeNodeG = new TreeNode("G");
TreeNode treeNodeH = new TreeNode("H");
TreeNode treeNodeK = new TreeNode("K");
treeNodeA.setLeft(treeNodeB);
treeNodeA.setRight(treeNodeE);
treeNodeB.setRight(treeNodeC);
treeNodeC.setLeft(treeNodeD);
treeNodeE.setRight(treeNodeF);
treeNodeF.setLeft(treeNodeG);
treeNodeG.setLeft(treeNodeH);
treeNodeG.setRight(treeNodeK);
return treeNodeA;
}
public BinaryTree(TreeNode root) {
this.root = root;
}
public void preOrder(TreeNode root) {
// 递归遍历,直到当前节点为空即结束递归
if (root == null) return;
// 先输出当前节点
System.out.print(root.val + "\t");
// 递归遍历当前节点的左子节点
preOrder(root.left);
// 递归遍历当前节点的右子节点
preOrder(root.right);
}
public void infixOrder(TreeNode root) {
if (root == null) return;
infixOrder(root.left);
System.out.print(root.val + "\t");
infixOrder(root.right);
}
public void postOrder(TreeNode root) {
if (root == null) return;
postOrder(root.left);
postOrder(root.right);
System.out.print(root.val + "\t");
}
}
二叉树的查找
-
前序查找思路
-
判断当前节点的值是否等于要查找的值,如果是相等,则返回当前结点
-
如果不等,则判断当前结点的左子节点是否为空,如果不为空,则递归前序查找
-
如果左递归前序查找找到结点则返回,否则继续判断,当前的结点的右子节点是否为空,如果不空,则继续向右递归前序查找
-
-
中序查找思路
-
判断当前结点的左子节点是否为空,如果不为空,则递归中序查找
-
如果找到,则返回,如果没有找到,就和当前结点比较,如果是则返回当前结点,否则继续进行右递归的中序查找
-
如果右递归中序查找,找到就返回,否则返回null
-
-
后序查找思路
- 判断结点的左子节点是否为空,如果不为空,则递归后序查找
- 如果找到,就返回,如果没有找到,就判断当前结点的右子节点是否为空,如果不为空,则右递归进行后序查找如果找到,就返回
- 就和当前结点进行,比如,如果是则返回,否则返回null
前中后序查找的代码实现
// 前序查找
public Node preOrderSearch(String val){
// 1.如果上来就找到则直接返回
if (this.val == val) return this;
// 2.1初始化result结果
Node resNode = null;
// 2.2如果左节点不空,向左递归查找
if (this.left != null)
resNode = this.left.preOrderSearch(val);
// 2.3如果左递归找到结果了,直接返回
if (resNode != null) return resNode;
// 3.如果右节点不空,向左递归查找
if (this.right != null)
resNode = this.right.preOrderSearch(val);
return resNode;
}
// 中序查找
public Node infixOrderSearch(String val){
// 1.1初始化result结果
Node resNode = null;
// 1.2如果左节点不空,向左递归查找
if (this.left != null)
resNode = this.left.infixOrderSearch(val);
// 1.3如果左递归找到结果了,直接返回
if (resNode != null) return resNode;
// 2.如果当前节点是要找的结果,则返回
if (this.val == val) return this;
// 3.如果右节点不空向右递归查找
if (this.right != null)
resNode = this.right.infixOrderSearch(val);
return resNode;
}
// 后序查找
public Node postOrderSearch(String val){
// 1.1初始化result结果
Node resNode = null;
// 1.2如果左节点不空,向左递归查找
if (this.left != null)
resNode = this.left.postOrderSearch(val);
// 1.3如果左递归找到结果了,直接返回
if (resNode != null) return resNode;
// 2.如果右节点不空向右递归查找
if (this.right != null)
resNode = this.right.postOrderSearch(val);
// 3.如果当前节点是要找的结果,则返回
if (this.val == val) return this;
return resNode;
}
二叉树的删除
过程很简单,如果是叶子节点,直接删,如果不是叶子节点,则直接删除该子树
public void delNode(String val){
// 如果当前节点的左节点不空,且左节点就是要删除的节点,将左节点置空
if (this.left != null && this.left.val == val){
this.left = null;
return;
}
// 如果当前节点的右节点不空,且右节点就是要删除的节点,将右节点置空
if (this.right != null && this.right.val == val){
this.right = null;
return;
}
if (this.left != null) this.left.delNode(val);
if (this.right != null) this.right.delNode(val);
}
二叉搜索树
二叉搜索树也称为有序二叉查找树,具有如下性质:
- 任意节点左子树如果不为空,则左子树中节点的值均小于根节点的值
- 任意节点右子树如果不为空,则右子树中节点的值均大于根节点的值
- 任意节点的左右子树,也分别是二叉搜索树
- 没有键值相等的节点,如果存在相等的节点,则不满足二叉搜索树的性质
![image-20210621163647382](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210621163647382.png)
基于二叉搜索树的这种特点,在查找某个节点的时候,可以采取类似于二分查找的思想,快速找到某个节点。n 个节点的二叉查找树,正常的情况下,查找的时间复杂度为 O(logN)。
二叉搜索树的局限性
一个二叉搜索树是由n个节点随机构成,所以,对于某些情况,二叉查找树会退化成一个有n个节点的线性链表。如下图:
![image-20210621163809986](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210621163809986.png)
平衡的二叉搜索树
通过二叉搜索树的分析我们发现,二叉搜索树的节点查询、构造和删除性能,与树的高度相关,如果二叉搜索树能够更“平衡”一些,避免了树结构向线性结构的倾斜,则能够显著降低时间复杂度。
平衡二叉搜索树:简称平衡二叉树。由前苏联的数学家Adelse-Velskil和Landis在1962年提出的高度平衡的二叉树,根据科学家的英文名也称为AVL树。
它具有如下几个性质:
- 可以是空树
- 假如不是空树,任何一个结点的左子树与右子树都是平衡二叉树,并且高度之差的绝对值不超过1
平衡的意思,就是向天平一样保持左右水平,即两边的分量大约相同。如定义,假如一棵树的左右子树的高度之差超过1,如左子树的树高为2,右子树的树高为0,子树树高差的绝对值为2就打破了这个平衡!
比如,依次插入1,2,3三个结点后,根结点的右子树树高减去左子树树高为2,树就失去了平衡。我们希望它能够变成更加平衡的样子。
![image-20210621164028826](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210621164028826.png)
AVL树是带有平衡条件的二叉搜索树,它是严格的平衡二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过1)。不管我们是执行插入还是删除操作,只要不满足上面的条件,就要通过旋转来保持平衡,而旋转是非常耗时的。旋转的目的是为了降低树的高度,使其平衡。
![image-20210621164112630](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210621164112630.png)
使用场景:AVL树适合用于插入删除次数比较少,但查找多的情况。也在Windows进程地址空间管理中得到了使用。
顺序存储二叉树
从数据存储来看,数组存储方式和树的存储方式可以相互转换,即数组可以转换成树,树也可以转换成数组,看右面的示意图。
要求
- 右图的二叉树的结点,要求以数组的方式来存放arr:[1,2,3,4,5,6,7]
- 要求在遍历数组a时,仍然可以以前序遍历,中序遍历和后序遍历的方式完成结点的遍历
顺序存储二叉树的特点
- 顺序二树通常只考虑完全二叉树
- 第n个元素的左子节点为2 * n + 1
- 第n个元素的右子节点为2 * n + 2
- 第n个元素的父节点为(n-1)/2
- n表示二叉树中从0开始编号的第几个元素
需求:给定一个数组[ 1,2,3,4,5,6,7] ,要使用二叉树的方式进行中序遍历,结果应为[4,2,5,1,6,3,7]
代码实现
// 顺序存储二叉树的遍历(中序)
public class ArrayBinaryTree {
private int[] arr = null;
public static void main(String[] args) {
int[] arr = {1,2,3,4,5,6,7};
ArrayBinaryTree arrayBinaryTree = new ArrayBinaryTree(arr);
arrayBinaryTree.infixOrder(0);
}
public ArrayBinaryTree(int[] arr) {
this.arr = arr;
}
public void infixOrder(int index) {
if (arr.length == 0 || arr == null) System.out.println("数组为空!");
// 向左递归遍历
if ((index * 2 + 1) < arr.length)
infixOrder(index * 2 + 1);
System.out.print(arr[index]);
if ((index * 2 + 2) < arr.length)
infixOrder(index * 2 + 2);
}
}
线索化二叉树
线索二叉树原理
二叉树可以使用两种存储结构:顺序存储和二叉链表。在使用二叉链表的存储结构的过程中,会存在大量的空指针域,为了充分利用这些空指针域,引申出了“线索二叉树”。回顾一下二叉链表存储结构,如下图:
![image-20210621092510388](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210621092510388.png)
通过观察上面的二叉链表,存在着若干个没有指向的空指针域。对于一个有n个节点的二叉链表,每个节点有指向左右节点的2个指针域,整个二叉链表存在2n个指针域。而n个节点的二叉链表有n-1条分支线,那么空指针域的个数=2n-(n-1) = n+1个空指针域,从存储空间的角度来看,这n+1个空指针域浪费了内存资源。
从另外一个角度来分析,如果我们想知道按中序方式遍历二叉链表时B节点的前驱节点或者后继节点时,必须要按中序方式遍历二叉链表才能够知道结果,每次需要结果时都需要进行一次遍历,是否可以考虑提前存储这种前驱和后继的关系来提高时间效率呢?
综合以上两方面的分析,可以通过充分利用二叉链表中的空指针域,存放节点在某种遍历方式下的前驱和后继节点的指针。 我们把这种指向前驱和后继的指针成为线索,加上线索的二叉链表成为线索链表,对应的二叉树就成为“线索二叉树(Threaded Binary Tree)” 。
构建线索二叉树过程
-
我们对二叉树进行中序遍历,将所有的节点右子节点为空的指针域指向它的后继节点。如下图:
通过中序遍历我们知道H的right指针为空,并且H的后继节点为D(如上图第1步),I的right指针为空,并且I的后继节点为B(如上图第2步),以此类推,知道G的后继节点为null,则G的right指针指向null。
-
接下来将这颗二叉树的所有节点左指针域为空的指针域指向它的前驱节点。如下图:
如上图,H的left指针域指向Null(如第1步),I的前驱节点是D,则I的left指针指向D,以此类推。
-
最后将这颗二叉树的所有节点右指针域为空的指针指向他的后继节点
通过上面两步完成了整个二叉树的线索化,最后结果如下图:
通过观察上图(蓝色虚线代表后继、绿色虚线代表前驱),可以看出,线索二叉树,等于是把一棵二叉树转变成了一个特殊的双向链表(后面会解释为什么叫特殊的双向链表),这样对于我们的新增、删除、查找节点带来了方便。所以我们对二叉树以某种次序遍历使其变为线索二叉树的过程称做是线索化。如下图:
仔细分析上面的双向链表,与线索化之后的二叉树相比,比如节点D与后继节点I,在完成线索化之后,并没有直接线索指针,而是存在父子节点的指针;节点A与节点F,在线索化完成之后,节点A并没有直接指向后继节点F的线索指针,而是通过父子节点遍历可以找到最终的节点F,前驱节点也存在同样的问题,正因为很多节点之间不存在直接的线索,所以我将此双向链表称做“ 特殊的双向链表”,再使用过程中根据指针是线索指针还是子节点指针来分别处理,所以在每个节点需要标明当前的左右指针是线索指针还是子节点指针,这就需要修改节点的数据结构。修改后的数据结构如下:
Java实现线索二叉树
public class ThreadBinaryTree {
private Node preNode; //线索化时记录前一个节点
//节点存储结构
static class Node {
String data; //数据域
Node left; //左指针域
Node right; //右指针域
boolean isLeftThread = false; //左指针域类型 false:指向子节点、true:前驱或后继线索
boolean isRightThread = false; //右指针域类型 false:指向子节点、true:前驱或后继线索
Node(String data) {
this.data = data;
}
}
/**
* 通过数组构造一个二叉树(完全二叉树)
* @param array
* @param index
* @return
*/
static Node createBinaryTree(String[] array, int index) {
Node node = null;
if(index < array.length) {
node = new Node(array[index]);
node.left = createBinaryTree(array, index * 2 + 1);
node.right = createBinaryTree(array, index * 2 + 2);
}
return node;
}
/**
* 中序线索化二叉树
* @param node 节点
*/
void inThreadOrder(Node node) {
if(node == null) {
return;
}
//处理左子树
inThreadOrder(node.left);
//左指针为空,将左指针指向前驱节点
if(node.left == null) {
node.left = preNode;
node.isLeftThread = true;
}
//前一个节点的后继节点指向当前节点
if(preNode != null && preNode.right == null) {
preNode.right = node;
preNode.isRightThread = true;
}
preNode = node;
//处理右子树
inThreadOrder(node.right);
}
/**
* 中序遍历线索二叉树,按照后继方式遍历(思路:找到最左子节点开始)
* @param node
*/
void inThreadList(Node node) {
//1、找中序遍历方式开始的节点
while(node != null && !node.isLeftThread) {
node = node.left;
}
while(node != null) {
System.out.print(node.data + ", ");
//如果右指针是线索
if(node.isRightThread) {
node = node.right;
} else { //如果右指针不是线索,找到右子树开始的节点
node = node.right;
while(node != null && !node.isLeftThread) {
node = node.left;
}
}
}
}
/**
* 中序遍历线索二叉树,按照前驱方式遍历(思路:找到最右子节点开始倒序遍历)
* @param node
*/
void inPreThreadList(Node node) {
//1、找最后一个节点
while(node.right != null && !node.isRightThread) {
node = node.right;
}
while(node != null) {
System.out.print(node.data + ", ");
//如果左指针是线索
if(node.isLeftThread) {
node = node.left;
} else { //如果左指针不是线索,找到左子树开始的节点
node = node.left;
while(node.right != null && !node.isRightThread) {
node = node.right;
}
}
}
}
/**
* 前序线索化二叉树
* @param node
*/
void preThreadOrder(Node node) {
if(node == null) {
return;
}
//左指针为空,将左指针指向前驱节点
if(node.left == null) {
node.left = preNode;
node.isLeftThread = true;
}
//前一个节点的后继节点指向当前节点
if(preNode != null && preNode.right == null) {
preNode.right = node;
preNode.isRightThread = true;
}
preNode = node;
//处理左子树
if(!node.isLeftThread) {
preThreadOrder(node.left);
}
//处理右子树
if(!node.isRightThread) {
preThreadOrder(node.right);
}
}
/**
* 前序遍历线索二叉树(按照后继线索遍历)
* @param node
*/
void preThreadList(Node node) {
while(node != null) {
while(!node.isLeftThread) {
System.out.print(node.data + ", ");
node = node.left;
}
System.out.print(node.data + ", ");
node = node.right;
}
}
public static void main(String[] args) {
String[] array = {"A", "B", "C", "D", "E", "F", "G", "H"};
Node root = createBinaryTree(array, 0);
ThreadBinaryTree tree = new ThreadBinaryTree();
tree.inThreadOrder(root);
System.out.println("中序按后继节点遍历线索二叉树结果:");
tree.inThreadList(root);
System.out.println("\n中序按后继节点遍历线索二叉树结果:");
tree.inPreThreadList(root);
Node root2 = createBinaryTree(array, 0);
ThreadBinaryTree tree2 = new ThreadBinaryTree();
tree2.preThreadOrder(root2);
tree2.preNode = null;
System.out.println("\n前序按后继节点遍历线索二叉树结果:");
tree.preThreadList(root2);
}
}
线索二叉树的总结
- 线索化的实质就是将二叉链表中的空指针改为指向前驱节点或后继节点的线索;
- 线索化的过程就是修改二叉链表中空指针的过程,可以按照前序、中序、后序的方式进行遍历,分别生成不同的线索二叉树;
- 有了线索二叉树之后,我们再次遍历时,就相当于操作一个双向链表。
- 使用场景:如果我们在使用二叉树过程中经常需要遍历二叉树或者查找节点的前驱节点和后继节点,可以考虑采用线索二叉树存储结构。
注:线索化二叉树部分转自:https://blog.csdn.net/UncleMing5371/article/details/54176252
红黑树
红黑树是一种特殊的二叉查找树。它的统计性能要好于平衡二叉树(AVL树),红黑树的每个节点上都有存储位表示节点的颜色,可以是红(Red)或黑(Black)。
性质:
- 节点是红色或黑色
- 根节点是黑色
- 每个叶子节点都是黑色的空节点(NIL节点)。
- 每个红色节点的两个子节点都是黑色(因此从每个叶子到根的所有路径上不能有两个连续的红色节点)
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
在插入一个新节点时,默认将它涂为红色(这样可以不违背最后一条规则),然后进行旋转着色等操作,让新的树符合所有规则。
红黑树也是一种自平衡二叉查找树,可以认为是对AVL树的折中优化。
使用场景
红黑树多用于搜索,插入,删除操作多的情况下。红黑树应用比较广泛:
- 广泛用在各种语言的内置数据结构中。Java中的TreeSet,TreeMap也都是用红黑树实现的。JDK1.8后Hashmap中单链表长度超过8使用红黑树
- 著名的linux进程调度Completely Fair Scheduler,用红黑树管理进程控制块。
- epoll在内核中的实现,用红黑树管理事件块
- nginx中,用红黑树管理timer等
2-3 树
2-3树是最简单的B树结构,具有如下特点
- 2-3树的所有叶子节点必须要在同一层,只要是B树都满足这个条件
- 有两个子节点的节点叫二节点,二节点要么没有子节点,要么有两个子节点
- 有三个子节点的节点叫三节点,三节点要么没有子节点,要么有三个子节点
- 2-3树是由二节点和三节点构成的树。
![image-20210622112239546](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210622112239546.png)
插入规则
当按照规则插入一个数到某个节点时,如不满足上面1,2,3特点,就需要拆,先向上拆,如果上层满,则拆本层
拆后仍然需要满足上面1,2,3特点。
对于三节点的子树的值大小仍然遵守(BST二叉排序树)的规则
显然对于2-3树的性能要高于二叉树,毕竟二叉树一个节点只能放一个数,2-3树一个节点可以最多放2个,从一定程度上降低了树的高度,提升了效率
总结:红黑树是改良版的平衡二叉树,每个路径上黑色节点个数都相同,且红节点的父节点和子节点都是黑色
*B树、B+树
B树
B树(B-Tree)是一种自平衡的树,它是一种多路搜索树(并不是二叉的),能够保证数据有序。同时,B树还保证了在查找、插入、删除等操作时性能都能保持在O(logn),为大块数据的读写操作做了优化,同时它也可以用来描述外部存储。
特点:
- 定义任意非叶子结点最多只有M个儿子;且M>2
- 根结点的儿子数为[2, M] (根节点至少有两个子节点)
- 除根结点以外的非叶子结点的儿子数为[M/2, M] (非叶子节点)
- 每个结点存放至少M/2-1(取上整)和至多M-1个关键字;(至少2个key)
- 非叶子结点的关键字个数 = 指向儿子的指针个数 – 1
- 非叶子结点的关键字:K[1], K[2], …, K[M-1];且K[i] < K[i+1]
- 非叶子结点的指针:P[1], P[2], …, P[M],其中P[1]指向关键字小于K[1]的子树,P[M]指向关键字大于K[M-1]的子树,其它P[i]指向关键字属于(K[i-1], K[i])的子树
- 所有叶子结点位于同一层
![](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210622103213060.png)
下面是往B树中依次插入
6 10 4 14 5 11 15 3 2 12 1 7 8 8 6 3 6 21 5 15 15 6 32 23 45 65 7 8 6 5 4
的演示动画:
B+ 树
B+树是B-树的变体,也是一种多路搜索树。
B+树和B树的最大区别是,B+树的所有数据都会放在叶子节点,而B树在非叶子节点上也存放数据
B+的搜索与B树也基本相同,区别是B+树只有达到叶子结点才命中(B树可以在非叶子结点命中),其性能也等价于在关键字全集做一次二分查找。
B+的特性:
-
非叶子节点相当于叶子节点的索引,它不存储数据,因此不可能在非叶子结点命中数据
-
所有数据都是以链表的形式存储在叶子节点中(稠密索引)
-
链表中的数据恰好是有序的
因此:非叶子节点用来存储数据的索引,叶子结点用来存储数据,且数据是有序的,被每一片索引分成了单独的小区间,这样就更能适合做数据的范围查询和扫库的操作
下图是B+树的插入动画:
B+ 树的优点:
- 层级更低,IO 次数更少
- 每次都需要查询到叶子节点,查询性能稳定
- 叶子节点形成有序链表,范围查询方便。这使得B+树方便进行“扫库”,也是很多文件系统和数据库底层选用B+树的主要原因,因此B+树更是个做文建索引
贪心策略和哈夫曼树
贪心策略
概念和思想
贪心算法(Greedy)是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,它所做出的仅仅是在考虑某种意义上的局部最优解。
贪心算法设计的关键是贪心策略的选择。必须注意的是,贪心算法不是对所有问题都能得到整体最优解,选择的贪心策略必须具备无后效性。
基本思路
- 建立数学模型来描述问题。
- 一般结合分治的思想,把求解的问题分成若干个子问题。
- 对每一子问题求解,得到子问题的局部最优解。
- 把子问题的解,也就是局部最优解,合成原来解问题的一个解(可能并非全局最优,需要无后效性)。
实现框架
从问题的某一初始解出发;
while (能朝给定总目标前进一步)
{
利用可行的决策,求出可行解的一个解元素;
}
由所有解元素组合成问题的一个可行解;
适用场景
贪心算法存在的问题:
- 不能保证求得的最后解是最佳的
- 不能用来求最大值或最小值的问题
- 只能求满足某些约束条件的可行解的范围
所以贪心策略适用的前提是:局部最优策略能导致产生全局最优解。
哈夫曼树
在二叉树中,不同的深度的节点,在查询访问时耗费的时间不同。怎样让二叉树的查询效率更高呢?一个简单的想法是,如果能统计出节点被访问的频率,我们就可以让高频访问的节点深度小一点,以便更快速地访问到
所以,我们可以给树中的每个节点赋予一个权重,代表该节点的访问频率;而从根节点到这个节点的访问路径,可以计算出它的长度,这就是访问一次的代价。把所有节点按照频率,求一个加权和,这就是我们平均一次访问代价的期望。
基本概念
哈夫曼树又叫最优二叉树,它是由n个带权叶子节点构成的所有二叉树中,带权路径长度(WPL)最短的二叉树。
带权路径长度即为权值与路径乘积的累加,所以哈夫曼树首先是一棵二叉树;其次,构建二叉树时通过调整节点位置,使得带权路径长度最小。
下面给出哈夫曼树中的一些基本概念定义:
- 路径:指从一个节点到另一个节点之间的分支序列。
- 路径长度:指从一个节点到另一个节点所经过的分支数目。若根节点的层数为1,则从根节点到第L节点的路径长度为L - 1
- 节点的权:给树的每个节点赋予一个具有某种实际意义的实数,我们称该实数为这个节点的权,代表该节点的访问频率。
- 带权路径长度:在树形结构中,我们把从树根到某一节点的路径长度与该节点的权的乘积,叫做该节点的带权路径长度。
- 结点的权及带权路径长度:从根结点到该结点之间的路径长度与该结点的权的乘积
- 树的带权路径长度(WPL):为树中所有叶子节点的带权路径长度之和,通常记为:
其中n为叶子节点的个数,ωi为第i个叶子节点的权值, li 为第i个叶子节点的路径长度。
哈夫曼树的构建思路
具体构造哈夫曼树的算法,就用到贪心策略:每次都选取当前权值最小的两个节点,让它们合并成一棵树;之后定义它们根节点的权值为左右子树之和,再让根节点和其它节点比较,最小的两个节点合并一棵树。这样,我们每一步都选取当前最优策略,最终的效果就是得到了全局最小路径权值的二叉树。这就是贪心策略的具体应用。
-
初始化
将每一个节点的权值按从小到大进行排序,每个数据都是一个节点,每个节点可以看成是一颗最简单的二叉树,构成一个森林(用给定的n个权值对应的n个节点构成n棵二叉树的森林从小到大排序)
![image-20210628093111809](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210628093111809.png)
-
找最小树
在森林F中选择两棵根节点权值最小的二叉树,作为一棵新二叉树的左、右子树,标记新二叉树的根节点权值为其左右子树的根节点权值之和。
-
继续加入
从F中删除被选中的那两棵二叉树,同时把新构成的二叉树加入到森林F中。
-
判断
重复(2)、(3)操作,直到森林中只含有一棵二叉树为止,此时得到的这棵二叉树就是哈夫曼树。
![image-20210628094124398](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210628094124398.png)
![image-20210628094140785](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210628094140785.png)
举例,节点权值为[ 13 , 7 , 8 , 3 , 29 , 6 , 1 ]构成的哈夫曼树
![image-20210628094300738](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210628094300738.png)
哈夫曼编码
- 赫夫曼编码也翻译为哈夫曼编码(Huffman Coding),又称霍夫曼编码,是一种编码方式,属于一种程序算法
- 赫夫曼码是可变字长编码(VLC)的一种。是 Huffman于1952年提出一种编码方法,称之为最佳编码
- 赫夫曼编码是赫哈夫曼树在电讯通信中的经典的应用之一。
- 赫夫曼编码广泛地用于数据文件压缩。其压缩率通常在20%~90%之间
哈夫曼编码就是哈夫曼树的实际应用。主要目的,就是根据使用频率来最大化节省字符(编码)的存储空间。
比如,我们可以定义字母表[A, B, C, D, E],每个字母出现的频率为[5, 2, 1, 3, 7],现在希望得到它的最优化编码方式。也就是说,我们希望一段文字编码后平均码长是最短的。
构建哈夫曼树如下:
![image-20210628101507453](https://figure-bed-liqun.oss-cn-beijing.aliyuncs.com/uPic/image-20210628101507453.png)
可以看到,我们的编码规则为: A – 10 B – 1101 C – 1100 D – 111 E – 0