为什么需要树这种数据结构
- 数组存储方式的分析
优点:通过下标方式访问元素,速度快。对于有序数组,还可使用二分查找提高检索速度。
缺点:如果要检索具体某个值,或者插入值(按一定顺序)会整体移动,效率较低[示意图]
画出操作示意图:
- 链式存储方式的分析
优点:在一定程度上对数组存储方式有优化(比如:插入一个数值节点,只需要将插入节点,链接到链表中即可,删除效率也很好)。
缺点:在进行检索时,效率仍然较低,比如(检索某个值,需要从头节点开始遍历) 【示意图】
操作示意图:
- 树存储方式的分析
能提高数据存储,读取的效率, 比如利用二叉排序树(Binary Sort Tree),既可以保证数据的检索速度,同时也可以保证数据的插入,删除,修改的速度。【示意图,后面详讲】
树示意图
9.1 二叉树
二叉树的概念
- 树有很多种,每个节点最多只能有两个子节点的一种形式称为二叉树
- 二叉树的子节点分为左节点和右节点
- 如果该二叉树的所有叶子节点都在最后一层,并且节点总数 = 2^n-1,n为层数,则我们称为满二叉树
- 如果该二叉树的所有叶子节点都在最后一层或者倒数第二层,而且最后一层的叶子节点在左边连续,倒数第二层的叶子节点在右边连续,我们称为完全二叉树
二叉树的遍历说明
使用前序,中序和后序对下面的二叉树进行遍历.
1) 前序遍历: 先输出父节点,再遍历左子树和右子树
2) 中序遍历: 先遍历左子树,再输出父节点,再遍历右子树
3) 后序遍历: 先遍历左子树,再遍历右子树,最后输出父节点
4) 小结: 看输出父节点的顺序,就确定是前序,中序还是后序
代码实现
/**
* @author xiaososa
* @date 2021/1/1
**/
public class BinaryTreeDemo {
public static void main(String[] args) {
//现需要创建一颗二叉树
BinaryTree binaryTree = new BinaryTree();
//创建需要的节点
HeroNode root = new HeroNode(1, "宋江");
HeroNode node2 = new HeroNode(2, "吴用");
HeroNode node3 = new HeroNode(3, "卢俊义");
HeroNode node4 = new HeroNode(4, "林冲");
HeroNode node5 = new HeroNode(5, "关胜");
//说明,我们先手动创建该二叉树,后面我们学习递归的方式创建二叉树
root.setLeft(node2);
root.setRight(node3);
node2.setLeft(node4);
node2.setRight(node5);
binaryTree.setRoot(root);
//测试
System.out.println("前序遍历");//1,2,3,5,4
binaryTree.preOrder();
//测试
System.out.println("中序遍历");
binaryTree.infixOrder();//2,1,5,3,4
//测试
System.out.println("后序遍历");
binaryTree.postOrder();//2,5,3,4,1
}
}
//定义一个BinaryTree 二叉树
class BinaryTree{
private HeroNode root;
public void setRoot(HeroNode root) {
this.root = root;
}
//前序遍历
public void preOrder(){
if (this.root != null){
this.root.preOrder();
}else {
System.out.println("二叉树为空,无法遍历");
}
}
//中序遍历
public void infixOrder(){
if (this.root != null){
this.root.infixOrder();
}else {
System.out.println("二叉树为空,无法遍历");
}
}
//后序遍历
public void postOrder(){
if (this.root != null){
this.root.postOrder();
}else {
System.out.println("二叉树为空,无法遍历");
}
}
}
//先创建HeroNode节点
class HeroNode{
private int no;
private String name;
private HeroNode left;//默认null
private HeroNode right;//默认null
public HeroNode(int no, String name) {
super();
this.no = no;
this.name = name;
}
public int getNo() {
return no;
}
public void setNo(int no) {
this.no = no;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public HeroNode getLeft() {
return left;
}
public void setLeft(HeroNode left) {
this.left = left;
}
public HeroNode getRight() {
return right;
}
public void setRight(HeroNode right) {
this.right = right;
}
@Override
public String toString() {
return "HeroNode{" +
"no=" + no +
", name='" + name + '\'' +
'}';
}
//编写前序遍历的方法
public void preOrder(){
System.out.println(this);//先输出父节点
//递归向左子树前序遍历
if (this.left != null){
this.left.preOrder();
}
//递归向右子树前序遍历
if (this.right != null){
this.right.preOrder();
}
}
//中序遍历
public void infixOrder(){
//递归向左子树中序遍历
if (this.left != null){
this.left.infixOrder();
}
//输出父节点
System.out.println(this);
//递归向右子树中序遍历
if (this.right != null){
this.right.infixOrder();
}
}
//后序遍历
public void postOrder(){
if (this.left != null){
this.left.postOrder();
}
if (this.right != null){
this.right.infixOrder();
}
//输出父节点
System.out.println(this);
}
}
9.1.1 二叉树-查找指定节点
要求
- 请编写前序查找,中序查找和后序查找的方法。
- 并分别使用三种查找方式,查找heroNO = 5 的节点
- 并分析各种查找方式,分别比较了多少次
- 思路分析图解
代码实现
//前序遍历查找
/**
*
* @param no 查找no
* @return 如果找到就返回该Node,如果没有找到就返回null
*/
public HeroNode preOrderSearch(int no){
System.out.println("进入前序查找");
//比较节点是不是
if (this.no == no){
return this;
}
//1.则判断钱节点的左子节点是否为空,如果不为空,则递归前序查找
//2.如果左递归前序查找,找到节点,则返回
HeroNode resNode = null;
if (this.left != null){
resNode = this.left.preOrderSearch(no);
}
if (resNode != null){//说明我们左子树找到
return resNode;
}
//1.左递归前序查找,找到节点,则返回,否则继续判断
//2.当前的节点的右子节点是否为空,如果不空,则继续向右递归前序查找
if (this.right != null){
resNode = this.right.preOrderSearch(no);
}
return resNode;
}
//中序遍历查找
public HeroNode infixOrderSearch(int no){
//判断当前节点的左子节点是否为空,如果不为空,则递归中序查找
HeroNode resNode = null;
if (this.left != null){
resNode = this.left.infixOrderSearch(no);
}
if (resNode != null){//说明我们左子树找到
return resNode;
}
System.out.println("进入中序查找");
//如果找到,则返回,如果没有找到,就和当前的节点比较,如果是则返回前节点
if (this.no == no){
return this;
}
//否则继续进行右递归的中序查找
if (this.right != null){
resNode = this.right.infixOrderSearch(no);
}
return resNode;
}
//后序遍历查找
public HeroNode postOrderSearch(int no){
HeroNode resNode = null;
if (this.left != null){
resNode = this.left.postOrderSearch(no);
}
if (resNode != null){
return resNode;
}
if (this.right != null){
resNode = this.right.postOrderSearch(no);
}
if (resNode != null){
return resNode;
}
System.out.println("进入后序遍历方式~");
if (this.no == no){
return this;
}
return resNode;
}
9.1.2 二叉树-删除节点
要求
1) 如果删除的节点是叶子节点,则删除该节点
2) 如果删除的节点是非叶子节点,则删除该子树.
3) 测试,删除掉5 号叶子节点和3 号子树.
4) 完成删除思路分析
代码实现
//递归删除节点
//1) 如果删除的节点是叶子节点,则删除该节点
//2) 如果删除的节点是非叶子节点,则删除该子树.
public void delNode(int no){
//思路
/*
* 1. 因为我们的二叉树是单向的,所以我们是判断当前结点的子结点是否需要删除结点,而不能去判断
当前这个结点是不是需要删除结点.
2. 如果当前结点的左子结点不为空,并且左子结点就是要删除结点,就将this.left = null; 并且就返回
(结束递归删除)
3. 如果当前结点的右子结点不为空,并且右子结点就是要删除结点,就将this.right= null ;并且就返回
(结束递归删除)
4. 如果第2 和第3 步没有删除结点,那么我们就需要向左子树进行递归删除
5. 如果第4 步也没有删除结点,则应当向右子树进行递归删除.
*/
//2. 如果当前结点的左子结点不为空,并且左子结点就是要删除结点,就将this.left = null; 并且就返回(结束递归删除)
if (this.left != null&&this.left.no == no){
this.left = null;
return;
}
//3.如果当前结点的右子结点不为空,并且右子结点就是要删除结点,就将this.right= null ;并且就返回(结束递归删除)
if (this.right != null&&this.right.no == no){
this.right = null;
return;
}
//4.我们就需要向左子树进行递归删除
if (this.left != null){
this.left.delNode(no);
}
//5.则应当向右子树进行递归删除
if (this.right != null){
this.right.delNode(no);
}
}
9.1.3 顺序存储二叉树
顺序存储二叉树的概念
基本说明
从数据存储来看,数组存储方式和树的存储方式可以相互转换,即数组可以转换成树,树也可以转换成数组,看右面的示意图。
要求:
- 右图的二叉树的结点,要求以数组的方式来存放arr : [1, 2, 3, 4, 5, 6, 6]
- 要求在遍历数组arr 时,仍然可以以前序遍历,中序遍历和后序遍历的方式完成结点的遍历
顺序存储二叉树的特点:
- 顺序二叉树通常只考虑完全二叉树
- 第n 个元素的左子节点为2 * n + 1
- 第n 个元素的右子节点为2 * n + 2
- 第n 个元素的父节点为(n-1) / 2
- n : 表示二叉树中的第几个元素(按0 开始编号如图所示)
顺序存储二叉树遍历
需求: 给你一个数组{1,2,3,4,5,6,7},要求以二叉树前序遍历的方式进行遍历。前序遍历的结果应当为
1,2,4,5,3,6,7
代码实现
/**
* @author xiaososa
* @date 2021/1/2
**/
public class ArrBinaryTreeDemo {
public static void main(String[] args) {
int[] arr = {1,2,3,4,5,6,7};
//创建衣蛾ArrBinaryTree
ArrBinaryTree arrBinaryTree = new ArrBinaryTree(arr);
arrBinaryTree.preOrder();//1,2,4,5,3,6,7
}
}
//编写一个ArrayBinaryTree,实现顺序存储二叉树遍历
class ArrBinaryTree{
private int[] arr;//存储数据节点的数组
//重载preOrder
public void preOrder(){
this.preOrder(0);
}
public ArrBinaryTree(int[] arr){
this.arr = arr;
}
//编写一个方法,完成顺序存储二叉树的前序遍历
/**
*
* @param index 数组的下标
*/
public void preOrder(int index){
//如果数组为空,或者arr.length = 0
if (arr == null || arr.length == 0){
System.out.println("数组为空,不能按照二叉树的前序遍历");
}
//输出当前这个元素
System.out.println(arr[index]);
//向左递归遍历
if (index * 2 + 1 < arr.length){
preOrder(2 * index + 1);
}
//向右递归遍历
if (index * 2 +2 <arr.length){
preOrder(2 * index +2);
}
}
}
9.1.4 线索化二叉树
先看一个问题
将数列{1, 3, 6, 8, 10, 14 } 构建成一颗二叉树. n+1=7
问题分析:
- 当我们对上面的二叉树进行中序遍历时,数列为{8, 3, 10, 1, 6, 14 }
- 但是6, 8, 10, 14 这几个节点的左右指针,并没有完全的利用上.
- 如果我们希望充分的利用各个节点的左右指针, 让各个节点可以指向自己的前后节点,怎么办?
- 解决方案-线索二叉树
线索二叉树基本介绍
- n个节点的二叉链表中含有n+1 【公式2n-(n-1)=n+1】个空指针域,利用二叉链表中的空指针域,存放指向该节点在某种遍历次序下的前驱和后继节点的指针(这种附加的指针称为"线索")
- 这种加上了线索的二叉树链表称为线索链表,相应的二叉树称为线索二叉树(Threaded BinaryTree)。根据线索性质的不同,线索二叉树可分为前序线索二叉树、中序线索二叉树和后序线索二叉树三种
- 一个节点的前一个节点,称为前驱节点
- 一个节点的后一个节点,称为后继节点
线索二叉树应用案例
应用案例说明:将下面的二叉树,进行中序线索二叉树。中序遍历的数列为{8, 3, 10, 1, 14, 6}
思路分析: 中序遍历的结果:{8, 3, 10, 1, 14, 6}
说明: 当线索化二叉树后,Node 节点的属性left 和right ,有如下情况:
- left 指向的是左子树,也可能是指向的前驱节点. 比如① 节点left 指向的左子树, 而⑩ 节点的left 指向的
就是前驱节点.- right 指向的是右子树,也可能是指向后继节点,比如① 节点right 指向的是右子树,而⑩ 节点的right 指向的是后继节点.
代码实现
/**
* @author xiaososa
* @date 2021/1/2
**/
public class ThreadedBinaryTreeDemo {
public static void main(String[] args) {
//测试一把中序线索二叉树的功能
HeroNode root = new HeroNode(1, "tom");
HeroNode node2 = new HeroNode(3, "jack");
HeroNode node3 = new HeroNode(6, "smith");
HeroNode node4 = new HeroNode(8, "mary");
HeroNode node5 = new HeroNode(10, "king");
HeroNode node6 = new HeroNode(14, "dim");
//二叉树,后面我们要递归创建,现在处理使用手动创建
root.setLeft(node2);
root.setRight(node3);
node2.setLeft(node4);
node2.setRight(node5);
node3.setLeft(node6);
//测试中序化线索化
ThreadedBinaryTree threadedBinaryTree = new ThreadedBinaryTree();
threadedBinaryTree.setRoot(root);
threadedBinaryTree.threadedNodes();
//测试:以10号结点测试
HeroNode leftNode = node5.getLeft();
HeroNode rightNode = node5.getRight();
System.out.println("10号结点的前驱结点是="+leftNode);
System.out.println("10号结点的后继结点是="+rightNode);
}
}
//定义一个ThreadedBinaryTree 实现了线索化功能的二叉树
class ThreadedBinaryTree{
private HeroNode root;
//为了实现线索化,需要创建给指向当前结点的前驱结点的指针
//在递归进行线索化是,pre总是保留前一个结点
private HeroNode pre = null;
public void setRoot(HeroNode root) {
this.root = root;
}
//重载一把threadedNodes方法
public void threadedNodes(){
this.threadedNodes(root);
}
//编写对二叉树进行中序线索化的方法
/**
*
* @param node 就是当前需要线索化的结点
*/
public void threadedNodes(HeroNode node){
//如果node==null 不能线索化
if (node == null){
return;
}
//1.先线索化左子树
threadedNodes(node.getLeft());
//2.线索化当前结点[有点难度的]
//处理当前结点的前驱结点
//以8结点来理解
//8结点的.left = null, 8结点的leftType = 1
if (node.getLeft() == null){
//让当前结点的左指针指向前驱结点
node.setLeft(pre);
//修改当前结点的左指针的类型
node.setLeftType(1);
}
//处理后继结点
if (pre != null && pre.getRight() == null){
//让前驱结点的有指针指向当前结点
pre.setRight(node);
//修改前驱结点的右指针类型
pre.setRightType(1);
}
//!!! 没处理一个结点后,让当前结点的下一个结点的前驱结点
pre = node;
//3.再线索化右子树
threadedNodes(node.getRight());
}
//删除节点
public void delNode(int no){
if (root != null){
//如果只有一个root节点,这里立即判断root是不是就是要删除的节点
if (root.getNo() == no){
root = null;
}else {
//递归删除
root.delNode(no);
}
}else {
System.out.println("空树,不能删除~");
}
}
//前序遍历
public void preOrder(){
if (this.root != null){
this.root.preOrder();
}else {
System.out.println("二叉树为空,无法遍历");
}
}
//中序遍历
public void infixOrder(){
if (this.root != null){
this.root.infixOrder();
}else {
System.out.println("二叉树为空,无法遍历");
}
}
//后序遍历
public void postOrder(){
if (this.root != null){
this.root.postOrder();
}else {
System.out.println("二叉树为空,无法遍历");
}
}
//前序遍历
public HeroNode preOrderSearch(int no){
if (root != null){
return root.preOrderSearch(no);
}else {
return null;
}
}
//中序遍历
public HeroNode infixOrderSearch(int no){
if (root != null){
return root.infixOrderSearch(no);
}else {
return null;
}
}
//后序遍历
public HeroNode postOrderSearch(int no){
if (root != null){
return this.root.postOrderSearch(no);
}else {
return null;
}
}
}
//创建HeroNode 结点
class HeroNode{
private int no;
private String name;
private HeroNode left;//默认null
private HeroNode right;//默认null
//说明
//1.如果leftType == 0表示指向左子树,如果1则表示指向前驱结点
//2.如果rightType == 0表示指向右子树,如果1则表示指向后继结点
private int leftType;
private int rightType;
public int getLeftType() {
return leftType;
}
public void setLeftType(int leftType) {
this.leftType = leftType;
}
public int getRightType() {
return rightType;
}
public void setRightType(int rightType) {
this.rightType = rightType;
}
public HeroNode(int no, String name) {
super();
this.no = no;
this.name = name;
}
public int getNo() {
return no;
}
public void setNo(int no) {
this.no = no;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public HeroNode getLeft() {
return left;
}
public void setLeft(HeroNode left) {
this.left = left;
}
public HeroNode getRight() {
return right;
}
public void setRight(HeroNode right) {
this.right = right;
}
@Override
public String toString() {
return "HeroNode{" +
"no=" + no +
", name='" + name + '\'' +
'}';
}
//递归删除节点
//###### 1) 如果删除的节点是叶子节点,则删除该节点
//###### 2) 如果删除的节点是非叶子节点,则删除该子树.
public void delNode(int no){
//思路
/*
* 1. 因为我们的二叉树是单向的,所以我们是判断当前结点的子结点是否需要删除结点,而不能去判断
当前这个结点是不是需要删除结点.
2. 如果当前结点的左子结点不为空,并且左子结点就是要删除结点,就将this.left = null; 并且就返回
(结束递归删除)
3. 如果当前结点的右子结点不为空,并且右子结点就是要删除结点,就将this.right= null ;并且就返回
(结束递归删除)
4. 如果第2 和第3 步没有删除结点,那么我们就需要向左子树进行递归删除
5. 如果第4 步也没有删除结点,则应当向右子树进行递归删除.
*/
//2. 如果当前结点的左子结点不为空,并且左子结点就是要删除结点,就将this.left = null; 并且就返回(结束递归删除)
if (this.left != null&&this.left.no == no){
this.left = null;
return;
}
//3.如果当前结点的右子结点不为空,并且右子结点就是要删除结点,就将this.right= null ;并且就返回(结束递归删除)
if (this.right != null&&this.right.no == no){
this.right = null;
return;
}
//4.我们就需要向左子树进行递归删除
if (this.left != null){
this.left.delNode(no);
}
//5.则应当向右子树进行递归删除
if (this.right != null){
this.right.delNode(no);
}
}
//编写前序遍历的方法
public void preOrder(){
System.out.println(this);//先输出父节点
//递归向左子树前序遍历
if (this.left != null){
this.left.preOrder();
}
//递归向右子树前序遍历
if (this.right != null){
this.right.preOrder();
}
}
//中序遍历
public void infixOrder(){
//递归向左子树中序遍历
if (this.left != null){
this.left.infixOrder();
}
//输出父节点
System.out.println(this);
//递归向右子树中序遍历
if (this.right != null){
this.right.infixOrder();
}
}
//后序遍历
public void postOrder(){
if (this.left != null){
this.left.postOrder();
}
if (this.right != null){
this.right.infixOrder();
}
//输出父节点
System.out.println(this);
}
//前序遍历查找
/**
*
* @param no 查找no
* @return 如果找到就返回该Node,如果没有找到就返回null
*/
public HeroNode preOrderSearch(int no){
System.out.println("进入前序查找");
//比较节点是不是
if (this.no == no){
return this;
}
//1.则判断钱节点的左子节点是否为空,如果不为空,则递归前序查找
//2.如果左递归前序查找,找到节点,则返回
HeroNode resNode = null;
if (this.left != null){
resNode = this.left.preOrderSearch(no);
}
if (resNode != null){//说明我们左子树找到
return resNode;
}
//1.左递归前序查找,找到节点,则返回,否则继续判断
//2.当前的节点的右子节点是否为空,如果不空,则继续向右递归前序查找
if (this.right != null){
resNode = this.right.preOrderSearch(no);
}
return resNode;
}
//中序遍历查找
public HeroNode infixOrderSearch(int no){
//判断当前节点的左子节点是否为空,如果不为空,则递归中序查找
HeroNode resNode = null;
if (this.left != null){
resNode = this.left.infixOrderSearch(no);
}
if (resNode != null){//说明我们左子树找到
return resNode;
}
System.out.println("进入中序查找");
//如果找到,则返回,如果没有找到,就和当前的节点比较,如果是则返回前节点
if (this.no == no){
return this;
}
//否则继续进行右递归的中序查找
if (this.right != null){
resNode = this.right.infixOrderSearch(no);
}
return resNode;
}
//后序遍历查找
public HeroNode postOrderSearch(int no){
HeroNode resNode = null;
if (this.left != null){
resNode = this.left.postOrderSearch(no);
}
if (resNode != null){
return resNode;
}
if (this.right != null){
resNode = this.right.postOrderSearch(no);
}
if (resNode != null){
return resNode;
}
System.out.println("进入后序遍历方式~");
if (this.no == no){
return this;
}
return resNode;
}
}
遍历线索化二叉树
- 说明:对前面的中序线索化的二叉树, 进行遍历
- 分析:因为线索化后,各个结点指向有变化,因此原来的遍历方式不能使用,这时需要使用新的方式遍历
线索化二叉树,各个节点可以通过线型方式遍历,因此无需使用递归方式,这样也提高了遍历的效率。遍历的次序应当和中序遍历保持一致。
//遍历线索化二叉树的方法
public void threadedList(){
//定义一个变量,存储当前遍历的节点,从root开始
HeroNode node = root;
while (node != null){
//循环找到leftType == 1的结点,第一个找到的就是8节点
//后面随着遍历而变化,因为当leftType == 1 时,说明该节点是按照线索化
//处理后的有效结点
while (node.getLeftType() == 0){
node = node.getLeft();
}
//打印当前这个结点
System.out.println(node);
//如果当前结点的右指针指向的是后继节点,就一直输出
while (node.getRightType() == 1){
//获取到当前结点的后继结点
node = node.getRight();
System.out.println(node);
}
//替换这个遍历的节点
node = node.getRight();
}
}
9.2 树结构实际应用
9.2.1 堆排序
9.2.1 堆排序
一.堆排序基本介绍
- 堆排序是利用堆这种数据结构而设计的一种排序算法,堆排序是一种选择排序,它的最坏,最好,平均时间复杂度均为O(nlogn),它也是不稳定排序。
- 堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆, 注意: 没有要求结点的左孩子的值和右孩子的值的大小关系。
- 每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆
- 大顶堆举例说明
5) 小顶堆举例说明
6) 一般升序采用大顶堆,降序采用小顶堆
二.堆排序基本思想
- 将待排序序列构造成一个大顶堆
- 此时,整个序列的最大值就是堆顶的根节点
- 将其与末尾元素进行交换,此时末尾就为最大值
- 然后将剩余n-1个元素重新构造成一个锥,这样会得到n个元素的次小值,如此反复执行,便能得到一个有序序列了
可以看到在构建大顶堆的过程中,元素的个数逐渐减少,最后就得到一个有序序列了.
三.堆排序步骤图解说明
要求:给你一个数组{4,6,8,5,9} , 要求使用堆排序法,将数组升序排序。
步骤一构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。
原始的数组[4, 6, 8, 5, 9]
1) .假设给定无序序列结构如下
- .此时我们从最后一个非叶子结点开始(叶结点自然不用调整,第一个非叶子结点
arr.length/2-1=5/2-1=1,也就是下面的6 结点),从左至右,从下至上进行调整。
3. 找到第二个非叶节点4,由于[4,9,8]中9 元素最大,4 和9 交换。
4) 这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中6 最大,交换4 和6。
此时,我们就将一个无序序列构造成了一个大顶堆。步骤二将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换
- .将堆顶元素9 和末尾元素4 进行交换
- .重新调整结构,使其继续满足堆定义
3) .再将堆顶元素8 与末尾元素5 进行交换,得到第二大元素8.
4) 后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序
再简单总结下堆排序的基本思路:
1).将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
2).将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
3).重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
代码实现
/**
* @author xiaososa
* @date 2021/1/2
**/
public class HeapSort {
public static void main(String[] args) {
//要求将数组进行升序排序
System.out.println("堆排序");
int[] arr = {4,6,8,5,9};
// //分步完成
// adjustHeap(arr,1,arr.length);
// System.out.println("第一次"+ Arrays.toString(arr));//4,9,8,5,6
heapSort(arr);
}
//编写一个堆排序的方法
public static void heapSort(int[] arr){
System.out.println("堆排序!");
int temp = 0;
//完成我们最终代码
//将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
for (int i = arr.length / 2 - 1; i >= 0 ; i--) {
adjustHeap(arr,i,arr.length);
}
/**
* 2).将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
* 3).重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,
* 直到整个序列有序。
*/
for (int j = arr.length - 1; j > 0 ; j--) {
//交换
temp = arr[j];
arr[j] = arr[0];
arr[0] = temp;
adjustHeap(arr,0,j);
}
System.out.println("数组 = "+Arrays.toString(arr));
}
//将一个数组(二叉树),调整成一个大顶堆
/**
* 功能:完成 将以i 对应的非叶子结点的树调整成大顶堆
* 举例:int arr[] = {4,6,8,5,9} => i = 1 => adjustHeap => 得到{4,9,8,5,6}
* 如果我们再次调用adjustHeap 传入的是i = 0 => 得到{9,6,8,5,4}
* @param arr 待调整的数组
* @param i 表示非叶子结点的在数组中索引
* @param length 表示对多少个元素继续调整,length实在逐渐的减少
*/
public static void adjustHeap(int arr[],int i,int length){
int temp = arr[i];//先取出当前元素的值,保存在临时变量
//开始调整
//说明
//1.k = i * 2 + 1 是i结点的左子结点
for (int k = i * 2 + 1; k < length; k = k * 2 + 1) {
if (k + 1< length&&arr[k] < arr[k+1]){//说明左子结点的值小于右子结点的值
k++;//k指向右子结点
}
if (arr[k] > temp){//如果子结点大于父结点
arr[i] = arr[k];//把较大的值 赋给当前结点
i = k;//!!! i指向k,继续循环比较
}else {
break;
}
}
//当for循环结束后,我们已经将以i为父结点的树的最大值,放在了最顶上(局部)
arr[i] = temp;//将temp值放到调整后的位置
}
}
9.2.2 赫夫曼树
9.2.2 赫夫曼树
基本介绍
- 给定n 个权值作为n 个叶子结点,构造一棵二叉树,若该树的带权路径长度(wpl)达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree), 还有的书翻译为霍夫曼树。
- 赫夫曼树是带权路径长度最短的树,权值较大的结点离根较近
赫夫曼树几个重要概念和举例说明
- 路径和路径长度:在一棵树中,从一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度。若规定根结点的层数为1,则从根结点到第L 层结点的路径长度为L-1
- 结点的权及带权路径长度:若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结
点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积- 树的带权路径长度:树的带权路径长度规定所有叶子结点的带权路径长度之和,记为WPL(weighted path length) ,权值越大的结点离根结点越近的二叉树才是最优二叉树。
- WPL 最小的就是赫夫曼树
赫夫曼树创建思路图解
给你一个数列{13, 7, 8, 3, 29, 6, 1},要求转成一颗赫夫曼树.
思路分析(示意图):
{13, 7, 8, 3, 29, 6, 1}构成赫夫曼树的步骤:
- 从小到大进行排序, 将每一个数据,每个数据都是一个节点, 每个节点可以看成是一颗最简单的二叉树
- 取出根节点权值最小的两颗二叉树
- 组成一颗新的二叉树, 该新的二叉树的根节点的权值是前面两颗二叉树根节点权值的和
- 再将这颗新的二叉树,以根节点的权值大小再次排序, 不断重复1-2-3-4 的步骤,直到数列中,所有的数
据都被处理,就得到一颗赫夫曼树- 图解:
赫夫曼树的代码实现
代码实现
/**
* @author xiaososa
* @date 2021/1/3
**/
public class HuffmanTree {
public static void main(String[] args) {
int[] arr = {13,7,8,3,29,6,1};
Node root = createHuffmanTree(arr);
//测试一把
preOrder(root);
}
//编写一个前序遍历的方法
public static void preOrder(Node root){
if(root != null){
root.preOrder();
}else {
System.out.println("是空树,不能遍历~");
}
}
//创建赫夫曼树的方法
/**
*
* @param arr 需要创建哈夫曼树的数组
* @return 创建好后的赫夫曼树的root节点
*/
public static Node createHuffmanTree(int[] arr){
//第一步为了操作方便
//1.遍历arr数组
//2.将arr的每个元素构建成一个Node
//3.将Node放入到ArrayList中
List<Node> nodes = new ArrayList<Node>();
for (int value : arr) {
nodes.add(new Node(value));
}
//我们处理的过程是循环的过程
while (nodes.size() > 1){
//排序 从大到小
Collections.sort(nodes);
System.out.println(nodes);
//取出根节点权值最小的两颗二叉树
//1.取出权值最小的节点(二叉树)
Node leftNode = nodes.get(0);
//2.取出第二小的结点(二叉树)
Node rightNode = nodes.get(1);
//3.构建一颗新的二叉树
Node parent = new Node(leftNode.value + rightNode.value);
parent.left = leftNode;
parent.right = rightNode;
//4.从ArrayList删除处理过的二叉树
nodes.remove(leftNode);
nodes.remove(rightNode);
//5.将parent加入到nodes
nodes.add(parent);
System.out.println("第一次处理后"+nodes);
}
//返回哈夫曼树的root节点
return nodes.get(0);
}
}
//创建结点类
//为了让Node对象持续排序Collections集合排序
//让Node实现Comparable解救
class Node implements Comparable<Node>{
int value;//结点权值
Node left;//指向左子结点
Node right;//指向右子结点
//写一个前序遍历
public void preOrder(){
System.out.println(this);
if (this.left != null){
this.left.preOrder();
}
if (this.right != null){
this.right.preOrder();
}
}
public Node(int value) {
this.value = value;
}
@Override
public String toString() {
return "Node{" +
"value=" + value +
'}';
}
@Override
public int compareTo(Node o) {
//表示从小到大排序
return this.value - o.value;
}
}
9.2.2.1 赫夫曼编码
基本介绍
- 赫夫曼编码也翻译为哈夫曼编码(Huffman Coding),又称霍夫曼编码,是一种编码方式, 属于一种程序算法
- 赫夫曼编码是赫哈夫曼树在电讯通信中的经典的应用之一。
- 赫夫曼编码广泛地用于数据文件压缩。其压缩率通常在20%~90%之间
- 赫夫曼码是可变字长编码(VLC)的一种。Huffman 于1952 年提出一种编码方法,称之为最佳编码
原理剖析
通信领域中信息的处理方式1-定长编码
通信领域中信息的处理方式2-变长编码
通信领域中信息的处理方式3-赫夫曼编码
步骤如下;
传输的字符串
- i like like like java do you like a java
- d:1 y:1 u:1 j:2 v:2 o:2 l:4 k:4 e:4 i:5 a:5 :9 // 各个字符对应的个数
- 按照上面字符出现的次数构建一颗赫夫曼树, 次数作为权值
步骤:
构成赫夫曼树的步骤:- 从小到大进行排序, 将每一个数据,每个数据都是一个节点, 每个节点可以看成是一颗最简单的二叉树
- 取出根节点权值最小的两颗二叉树
- 组成一颗新的二叉树, 该新的二叉树的根节点的权值是前面两颗二叉树根节点权值的和
- 再将这颗新的二叉树,以根节点的权值大小再次排序, 不断重复1-2-3-4 的步骤,直到数列中,所有的数据都被处理,就得到一颗赫夫曼树
- 根据赫夫曼树,给各个字符,规定编码(前缀编码), 向左的路径为0 向右的路径为1 , 编码
如下:o: 1000 u: 10010 d: 100110 y: 100111 i: 101
a : 110 k: 1110 e: 1111 j: 0000 v: 0001
l: 001 : 01
- 按照上面的赫夫曼编码,我们的"i like like like java do you like a java" 字符串对应的编码为(注
意这里我们使用的无损压缩)10101001101111011110100110111101111010011011110111101000011000011100110011110000110
01111000100100100110111101111011100100001100001110 通过赫夫曼编码处理长度为1336) 长度为: 133
说明:
原来长度是359 , 压缩了(359-133) / 359 = 62.9%此编码满足前缀编码, 即字符的编码都不能是其他字符编码的前缀。不会造成匹配的多义性
赫夫曼编码是无损处理方案
注意事项
注意, 这个赫夫曼树根据排序方法不同,也可能不太一样,这样对应的赫夫曼编码也不完全一样,但是wpl 是
一样的,都是最小的, 最后生成的赫夫曼编码的长度是一样,比如: 如果我们让每次生成的新的二叉树总是排在权值相同的二叉树的最后一个,则生成的二叉树为:
9.2.2.2 最佳实践-数据压缩(创建赫夫曼树)
将给出的一段文本,比如"i like like like java do you like a java" , 根据前面的讲的赫夫曼编码原理,对其进行数据压缩处理, 形式如
“1010100110111101111010011011110111101001101111011110100001100001110011001111000011001111000100100100110111101111011100100001100001110”步骤1:根据赫夫曼编码压缩数据的原理,需要创建"i like like like java do you like a java" 对应的赫夫曼树.
思路:前面已经分析过了,而且我们已然讲过了构建赫夫曼树的具体实现。
代码实现
//可以通过List 创建对应的赫夫曼树
private static Node createHuffmanTree(List<Node> nodes){
while (nodes.size()>1){
//排序,从小到大
Collections.sort(nodes);
//取出第一颗最小的二叉树
Node leftNode = nodes.get(0);
//取出第二颗最小的二叉树
Node rightNode = nodes.get(1);
//创建一颗新的二叉树,它的根节点,没有data,只有权值
Node parent = new Node(null,leftNode.weight + rightNode.weight);
parent.left = leftNode;
parent.right = rightNode;
//将已经处理的两颗二叉树从nodes删除
nodes.remove(leftNode);
nodes.remove(rightNode);
//将新的二叉树,加入到nodes
nodes.add(parent);
}
//nodes 最后的节点,就是赫夫曼树的根结点
return nodes.get(0);
}
}
9.2.2.3 最佳实践-数据压缩(生成赫夫曼编码和赫夫曼编码后的数据)
我们已经生成了赫夫曼树, 下面我们继续完成任务
- 生成赫夫曼树对应的赫夫曼编码, 如下表:
=01 a=100 d=11000 u=11001 e=1110 v=11011 i=101 y=11010 j=0010 k=1111 l=000 o=0011- 使用赫夫曼编码来生成赫夫曼编码数据,即按照上面的赫夫曼编码,将"i like like like java do you like a java"字符串生成对应的编码数据, 形式如下.
1010100010111111110010001011111111001000101111111100100101001101110001110000011011101000111100101000101111111100110001001010011011100- 思路:前面已经分析过了,而且我们讲过了生成赫夫曼编码的具体实现。
代码实现
/**
* 功能:将传入的node结点的所有叶子结点的赫夫曼编码得到,并放入到huffmanCodes集合
* @param node 传入结点
* @param code 路径:左子结点是0,右子结点是1
* @param stringBuilder 用于拼接路径
*/
private static void getCodes(Node node,String code,StringBuilder stringBuilder){
StringBuilder stringBuilder2 = new StringBuilder(stringBuilder);
//将code加入到stringBuilder2
stringBuilder2.append(code);
if (node != null){//如果node == null 不处理
//判断当前node是叶子结点还是非叶子结点
if (node.data == null){//非叶子结点
//递归处理
//向左
getCodes(node.left,"0",stringBuilder2);
//向右递归
getCodes(node.right,"1",stringBuilder2);
}else {//说明是一个叶子结点
//就表示找到某个叶子结点的最后
huffmanCodes.put(node.data,stringBuilder2.toString());
}
}
}
//为了调用方便,我们重载getCodes
public static Map<Byte,String> getCodes(Node root){
if (root == null){
return null;
}
//处理root的左子树
getCodes(root.left,"0",stringBuilder);
//处理root的右子树
getCodes(root.right,"1",stringBuilder);
return huffmanCodes;
}
9.2.2.4 最佳实践-数据解压(使用赫夫曼编码解码)
使用赫夫曼编码来解码数据,具体要求是
- 前面我们得到了赫夫曼编码和对应的编码
byte[] , 即:[-88, -65, -56, -65, -56, -65, -55, 77, -57, 6, -24, -14, -117, -4, -60, -90, 28]- 现在要求使用赫夫曼编码, 进行解码,又重新得到原来的字符串"i like like like java do you like a java"
- 思路:解码过程,就是编码的一个逆向操作。
代码实现
/**
* 将一个byte转成一个二进制字符串
* @param b 传入的byte
* @param flag 标志是否需要补高位,如果是true,表示需要补高位,如果是false表示不补,如果是最后一个字节,无需补高位
* @return 是该b 对应的二进制的字符串,注意是按补码返回
*/
private static String bytesToBitString(boolean flag,byte b){
//使用变量保存b
int temp = b;//将b转成int
//如果是整数我们还存在补高位
if (flag){
temp |= 256;//按位或 256 1 0000 0000 | 0000 0001 => 1 0000 0001
}
String str = Integer.toBinaryString(temp);//返回的是temp对应的二进制补码
if (flag){
return str.substring(str.length() - 8);
}else {
return str;
}
}
//编写一个方法,完成对压缩数据的解码
/**
*
* @param huffmanCodes 赫夫曼编码表map
* @param huffmanBytes 赫夫曼编码得到的字节数组
* @return 就是原来的字符串对应的数组
*/
private static byte[] decode(Map<Byte,String> huffmanCodes,byte[] huffmanBytes){
//1.先得到huffmanBytes 对应的二进制的字符串,形式1010100010111
StringBuilder stringBuilder1 = new StringBuilder();
//将byte数组转成二进制的字符串
for (int i = 0; i < huffmanBytes.length; i++) {
byte b = huffmanBytes[i];
//判断是不是最后一个字节
boolean flag = (i == huffmanBytes.length - 1);
stringBuilder.append(bytesToBitString(!flag,b));
}
//把字符串按照指定的赫夫曼编码进行解码
//吧赫夫曼编码表进行调换,因为反向查询 a->100 100->a
Map<String,Byte> map = new HashMap<>();
for (Map.Entry<Byte, String> entry : huffmanCodes.entrySet()) {
map.put(entry.getValue(),entry.getKey());
}
//创建要给集合,存放byte
List<Byte> list = new ArrayList<>();
//i 可以理解成就是索引,扫描stringBuilder
for (int i = 0; i < stringBuilder.length();) {
int count = 1;//小的计数器
boolean flag = true;
Byte b = null;
while (flag){
//1010100010111
//取出一个'1','0'
String key = stringBuilder.substring(i, i + count);//i不动,让count移动,指定匹配到一个字符
b = map.get(key);
if (b == null){//说明没有匹配到
count++;
}else {//匹配到
flag = false;
}
}
list.add(b);
i += count;//i 直接移动到count
}
//当for循环结束后,我们list中就存放了所有的字符"i like like like java do you like a java"
//把list中的数据放入到byte[] 并返回
byte[] b = new byte[list.size()];
for (int i = 0; i < b.length; i++) {
b[i] = list.get(i);
}
return b;
}
9.2.2.5 最佳实践-文件压缩
我们学习了通过赫夫曼编码对一个字符串进行编码和解码, 下面我们来完成对文件的压缩和解压, 具体要求:
给你一个图片文件,要求对其进行无损压缩, 看看压缩效果如何。
- 思路:读取文件-> 得到赫夫曼编码表-> 完成压缩
代码实现
//编写方法,将一个文件进行压缩
/**
*
* @param srcFile 你传入的希望压缩的文件的全路径
* @param dstFile 我们压缩将压缩文件方法哪个目录
*/
public static void zipFile(String srcFile,String dstFile){
//创建输出流
OutputStream os = null;
ObjectOutputStream oos = null;
//创建文件的输入流
FileInputStream is = null;
try {
is = new FileInputStream(srcFile);
//创建一个和源文件大小一样的byte[]
byte[] b = new byte[is.available()];
//读取文件
is.read(b);
//直接对源文件压缩
byte[] huffmanBytes = huffmanZip(b);
//创建文件的输出流,存放压缩文件
os = new FileOutputStream(dstFile);
//创建一个和文件输出流关联的ObjectOutputStream
oos = new ObjectOutputStream(os);
//把赫夫曼编码后的字节数组写入压缩文件
oos.writeObject(huffmanBytes);//我们是把
//这里我们以对象流的方式写入 赫夫曼编码,是为了以后我们回复源文件时使用
//注意一定要把赫夫曼编码写入压缩文件
oos.writeObject(huffmanCodes);
} catch (Exception e) {
System.out.println(e.getMessage());
}finally {
try {
is.close();
oos.close();
os.close();
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
9.2.2.6 最佳实践-文件解压(文件恢复)
具体要求:将前面压缩的文件,重新恢复成原来的文件。
- 思路:读取压缩文件(数据和赫夫曼编码表)-> 完成解压(文件恢复)
代码实现
//编写一个方法,完成对压缩文件的解压
/**
*
* @param zipFile 准备解压的文件
* @param dstFile 将文件解压到哪个路径
*/
public static void unZipFile(String zipFile,String dstFile){
//定义文件输入流
InputStream is = null;
//定义一个对象输入流
ObjectInputStream ois = null;
//定义文件的输出流
OutputStream os = null;
try {
//创建文件输入流
is = new FileInputStream(zipFile);
//创建一个和is关联的对象输入流
ois = new ObjectInputStream(is);
//读取byte数组 huffmanBytes
byte[] huffmanBytes =(byte[]) ois.readObject();
//读取赫夫曼编码表
Map<Byte,String> huffmanCodes = (Map<Byte,String>)ois.readObject();
//解码
byte[] bytes = decode(huffmanCodes,huffmanBytes);
//将bytes数组写入到目标文件
os = new FileOutputStream(dstFile);
//写数据到dstFile文件
os.write(bytes);
} catch (Exception e) {
System.out.println(e.getMessage());
} finally {
try {
os.close();
ois.close();
is.close();
} catch (Exception e2) {
System.out.println(e2.getMessage());
}
}
}
9.2.2.6 赫夫曼编码压缩文件注意事项
- 如果文件本身就是经过压缩处理的,那么使用赫夫曼编码再压缩效率不会有明显变化, 比如视频,ppt 等等文件[举例压一个.ppt]
- 赫夫曼编码是按字节来处理的,因此可以处理所有的文件(二进制文件、文本文件) [举例压一个.xml 文件]
- 如果一个文件中的内容,重复的数据不多,压缩效果也不会很明显.
9.2.3 二叉排序树
先看一个需求
给你一个数列(7, 3, 10, 12, 5, 1, 9),要求能够高效的完成对数据的查询和添加
解决方案分析
使用数组
数组未排序, 优点:直接在数组尾添加,速度快。缺点:查找速度慢. [示意图]
数组排序,优点:可以使用二分查找,查找速度快,缺点:为了保证数组有序,在添加新数据时,找到插入位置后,后面的数据需整体移动,速度慢。[示意图]使用链式存储-链表
不管链表是否有序,查找速度都慢,添加数据速度比数组快,不需要数据整体移动。[示意图]
使用二叉排序树
二叉排序树介绍
二叉排序树:BST: (Binary Sort(Search) Tree), 对于二叉排序树的任何一个非叶子节点,要求左子节点的值比当
前节点的值小,右子节点的值比当前节点的值大。
特别说明:如果有相同的值,可以将该节点放在左子节点或右子节点
比如针对前面的数据(7, 3, 10, 12, 5, 1, 9) ,对应的二叉排序树为:
二叉排序树创建和遍历
一个数组创建成对应的二叉排序树,并使用中序遍历二叉排序树,比如: 数组为Array(7, 3, 10, 12, 5, 1, 9) , 创建成对应的二叉排序树为:
二叉排序树的删除
二叉排序树的删除情况比较复杂,有下面三种情况需要考虑
- 删除叶子节点(比如:2, 5, 9, 12)
- 删除只有一颗子树的节点(比如:1)
- 删除有两颗子树的节点. (比如:7, 3,10 )
- 操作的思路分析
//对删除结点的各种情况的思路分析:
第一种情况:
删除叶子节点(比如:2, 5, 9, 12)
思路
(1) 需求先去找到要删除的结点targetNode
(2) 找到targetNode 的父结点parent
(3) 确定targetNode 是parent 的左子结点还是右子结点
(4) 根据前面的情况来对应删除
左子结点parent.left = null
右子结点parent.right = null;
第二种情况: 删除只有一颗子树的节点比如1
思路
(1) 需求先去找到要删除的结点targetNode
(2) 找到targetNode 的父结点parent
(3) 确定targetNode 的子结点是左子结点还是右子结点
(4) targetNode 是parent 的左子结点还是右子结点
(5) 如果targetNode 有左子结点5.1)如果targetNode 是parent 的左子结点
parent.left = targetNode.left;
5.2)如果targetNode 是parent 的右子结点
parent.right = targetNode.left;
(6) 如果targetNode 有右子结点
6.1) 如果targetNode 是parent 的左子结点
parent.left = targetNode.right;
6.2) 如果targetNode 是parent 的右子结点
parent.right = targetNode.right
情况三: 删除有两颗子树的节点. (比如:7, 3,10 )
思路
(1) 需求先去找到要删除的结点targetNode
(2) 找到targetNode 的父结点parent
(3) 从targetNode 的右子树找到最小的结点
(4) 用一个临时变量,将最小结点的值保存temp = 11
(5) 删除该最小结点
(6) targetNode.value = temp
代码实现
/**
* @author xiaososa
* @date 2021/1/5
**/
public class BinarySortTreeDemo {
public static void main(String[] args) {
int[] arr = {7,3,10,12,5,1,9,2};
BinarySortTree binarySortTree = new BinarySortTree();
//循环的添加结点到二叉排序树
for (int i = 0; i < arr.length; i++) {
binarySortTree.add(new Node(arr[i]));
}
//中序遍历二叉排序树
System.out.println("中序遍历二叉排序树~");
binarySortTree.infixOrder();//1,3,5,7,9,10,12
//测试一下删除叶子结点
//binarySortTree.delNode(2);
//binarySortTree.delNode(1);
binarySortTree.delNode(7);
System.out.println("删除结点后");
binarySortTree.infixOrder();
}
}
//创建二叉排序树
class BinarySortTree{
private Node root;
//查找要删除的结点
public Node search(int value){
if (root == null){
return null;
}else {
return root.search(value);
}
}
//查找父结点
public Node searchParent(int value){
if (root == null){
return null;
}else {
return root.searchParent(value);
}
}
//编写方法
//1.返回以node 为根结点的二叉排序树的最小结点的值
//2.删除node 为根结点的二叉排序树的最小结点
/**
*
* @param node 传入的结点(当做二叉排序树的根结点)
* @return 返回以node 为根结点的二叉排序树的最小结点的值
*/
public int delRightTreeMin(Node node){
Node target = node;
//循环的查找左节点,就会找到最小值
while (target.left != null){
target = target.left;
}
//这时target就指向了最小结点
//删除最小结点
delNode(target.value);
return target.value;
}
//删除节点
public void delNode(int value){
if (root == null){
return;
}else {
//1.需求先去找到要删除的结点 targetNode
Node targetNode = search(value);
//如果没有找到要删除的结点
if (targetNode == null){
return;
}
//如果我们发现当前这颗二叉排序树只有一个结点
if (root.left == null && root.right == null){
root = null;
return;
}
//去找到targetNode的父结点
Node parent = searchParent(value);
//如果要删除的结点是叶子结点
if (targetNode.left == null && targetNode.right == null){
//判断targetNode是父结点的左子结点,还是右子结点
if (parent.left != null && parent.left.value == value){//是左子结点
parent.left = null;
}else if (parent.right != null && parent.right.value == value){//是右子结点
parent.right = null;
}
}else if (targetNode.left != null && targetNode.right != null){//删除有两颗子树的结点
int minVal = delRightTreeMin(targetNode.right);
targetNode.value = minVal;
}else {//删除只有一颗子树的结点
//如果要删除的结点有左子结点
if (targetNode.left != null){
if (parent != null){
//如果targetNode是parent的左子结点
if (parent.left.value == value){
parent.left = targetNode.left;
}else {
parent.right = targetNode.left;
}
}else {
root = targetNode.left;
}
}else {//如果要删除的结点有右子结点
if (parent != null){
//如果targetNode是parent的左子结点
if (parent.left.value == value){
parent.left = targetNode.right;
}else {//如果targetNode是parent的右子结点
parent.right = targetNode.right;
}
}else {
root = targetNode.left;
}
}
}
}
}
//添加结点的方法
public void add(Node node){
if (root == null){
root = node;//如果root为空直接让root指向node
}else {
root.add(node);
}
}
//中序遍历
public void infixOrder(){
if (root != null){
root.infixOrder();
}else {
System.out.println("二叉排序树为空不能遍历");
}
}
}
//创建Node结点
class Node{
int value;
Node left;
Node right;
public Node(int value) {
this.value = value;
}
//查找要删除的结点
/**
*
* @param value 希望删除的结点的值
* @return 如果找到返回该节点,否则返回null
*/
public Node search(int value){
if (value == this.value){//找到就是该结点
return this;
}else if (value < this.value){//如果查找的值小于当前结点,向左子树递归查找
//如果左子结点为空
if (this.left == null){
return null;
}
return this.left.search(value);
}else {//如果查找的值不小于当前结点,向右子树递归查找
if (this.right == null){
return null;
}
return this.right.search(value);
}
}
//查找要删除结点的父结点
/**
*
* @param value 要找到的结点的值
* @return 返回的是要删除的结点的父结点,如果没有就返回null
*/
public Node searchParent(int value){
//如果当前结点就是要删除的结点的父结点,就返回
if ((this.left != null && this.left.value == value) ||
(this.right != null && this.right.value == value)){
return this;
}else {
//如果查找的值小于当前结点的值,并且当前结点的左子结点不为空
if (value < this.value && this.left != null){
return this.left.searchParent(value);
}else if (value >= this.value && this.right != null){
return this.right.searchParent(value);
}else {
return null;//没有找到父结点
}
}
}
@Override
public String toString() {
return "Node{" +
"value=" + value +
'}';
}
//添加节点的方法
//递归的形式添加结点,注意需要满足二叉排序树的要求
public void add(Node node){
if (node == null){
return;
}
//判断传入结点的值,和当前子树的根结点的值的关系
if (node.value < this.value){
//如果当前结点左子结点为null
if (this.left == null){
this.left = node;
}else {
//递归的向左子树添加
this.left.add(node);
}
}else {//添加的结点的值大于当前结点的值
if (this.right == null){
this.right = node;
}else {
//递归的向右子树添加
this.right.add(node);
}
}
}
//中序遍历
public void infixOrder(){
if (this.left != null){
this.left.infixOrder();
}
System.out.println(this);
if (this.right != null){
this.right.infixOrder();
}
}
}
9.2.4 平衡二叉树(AVL 树)
看一个案例(说明二叉排序树可能的问题)
给你一个数列{1,2,3,4,5,6},要求创建一颗二叉排序树(BST), 并分析问题所在.
左边BST 存在的问题分析:
- 左子树全部为空,从形式上看,更像一个单链表.
- 插入速度没有影响
- 查询速度明显降低(因为需要依次比较), 不能发挥BST的优势,因为每次还需要比较左子树,其查询速度比单链表还慢
- 解决方案-平衡二叉树(AVL)
基本介绍
- 平衡二叉树也叫平衡二叉搜索树(Self-balancing binary search tree)又被称为AVL 树, 可以保证查询效率较高。
- 具有以下特点:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵
平衡二叉树。平衡二叉树的常用实现方法有红黑树、AVL、替罪羊树、Treap、伸展树等。- 举例说明, 看看下面哪些AVL 树, 为什么?
应用案例-单旋转(左旋转)
- 要求: 给你一个数列,创建出对应的平衡二叉树.数列{4,3,6,5,7,8}
- 思路分析(示意图)
单旋转(左旋转)
代码实现
//左旋转方法
private void leftRotate(){
//创建新的结点,以当前根结点的值
Node newNode = new Node(value);
//把新的节点的左子树设置成当前结点的左子树
newNode.left = left;
//把新的结点的右子树设置成当前结点的右子树的左子树
newNode.right = right.left;
//把当前结点的值替换成右子结点的值
value = right.value;
//把当前结点的右子树设置成当前结点的右子树的右子树
right = right.right;
//把当前结点的左子树(左子结点)设置成新的结点
left = newNode;
}
应用案例-单旋转(右旋转)
- 要求: 给你一个数列,创建出对应的平衡二叉树.数列{10,12, 8, 9, 7, 6}
- 思路分析(示意图)
单旋转(右旋转)
代码实现
//右旋转
private void rightRotate(){
Node newNode = new Node(value);
newNode.right = right;
newNode.left = left.right;
value = left.value;
left = left.left;
right = newNode;
}
应用案例-双旋转
前面的两个数列,进行单旋转(即一次旋转)就可以将非平衡二叉树转成平衡二叉树,但是在某些情况下,单旋转
不能完成平衡二叉树的转换。比如数列
int[] arr = { 10, 11, 7, 6, 8, 9 }; 运行原来的代码可以看到,并没有转成AVL 树.
int[] arr = {2,1,6,5,7,3}; // 运行原来的代码可以看到,并没有转成AVL 树
- 问题分析
2) 解决思路分析
- 当符号右旋转的条件时
- 如果它的左子树的右子树高度大于它的左子树的高度
- 先对当前这个结点的左节点进行左旋转
- 在对当前结点进行右旋转的操作即可
双旋转
代码实现[AVL 树的汇总代码(完整代码)]
/**
* @author xiaososa
* @date 2021/1/5
**/
public class AVLTreeDemo {
public static void main(String[] args) {
//int[] arr = {4,3,6,5,7,8};
//int[] arr = {10,12,8,9,7,6};
int[] arr = {10,11,7,6,8,9};
//创建一个AVLTree
AVLTree avlTree = new AVLTree();
//添加结点
for (int i = 0; i < arr.length; i++) {
avlTree.add(new Node(arr[i]));
}
//遍历
System.out.println("中序遍历");
avlTree.infixOrder();
System.out.println("在平衡处理~");
System.out.println("树的高度="+avlTree.getRoot().height());//4
System.out.println("树的左子树高度="+avlTree.getRoot().leftHeight());//1
System.out.println("树的右子树高度="+avlTree.getRoot().rightHeight());//3
System.out.println("当前根结点的值="+ avlTree.getRoot());
System.out.println("根结点的左子结点= " + avlTree.getRoot().right.left);
}
}
//创建AVLTree
class AVLTree{
private Node root;
public Node getRoot() {
return root;
}
//查找要删除的结点
public Node search(int value){
if (root == null){
return null;
}else {
return root.search(value);
}
}
//查找父结点
public Node searchParent(int value){
if (root == null){
return null;
}else {
return root.searchParent(value);
}
}
//编写方法
//1.返回以node 为根结点的二叉排序树的最小结点的值
//2.删除node 为根结点的二叉排序树的最小结点
/**
*
* @param node 传入的结点(当做二叉排序树的根结点)
* @return 返回以node 为根结点的二叉排序树的最小结点的值
*/
public int delRightTreeMin(Node node){
Node target = node;
//循环的查找左节点,就会找到最小值
while (target.left != null){
target = target.left;
}
//这时target就指向了最小结点
//删除最小结点
delNode(target.value);
return target.value;
}
//删除节点
public void delNode(int value){
if (root == null){
return;
}else {
//1.需求先去找到要删除的结点 targetNode
Node targetNode = search(value);
//如果没有找到要删除的结点
if (targetNode == null){
return;
}
//如果我们发现当前这颗二叉排序树只有一个结点
if (root.left == null && root.right == null){
root = null;
return;
}
//去找到targetNode的父结点
Node parent = searchParent(value);
//如果要删除的结点是叶子结点
if (targetNode.left == null && targetNode.right == null){
//判断targetNode是父结点的左子结点,还是右子结点
if (parent.left != null && parent.left.value == value){//是左子结点
parent.left = null;
}else if (parent.right != null && parent.right.value == value){//是右子结点
parent.right = null;
}
}else if (targetNode.left != null && targetNode.right != null){//删除有两颗子树的结点
int minVal = delRightTreeMin(targetNode.right);
targetNode.value = minVal;
}else {//删除只有一颗子树的结点
//如果要删除的结点有左子结点
if (targetNode.left != null){
if (parent != null){
//如果targetNode是parent的左子结点
if (parent.left.value == value){
parent.left = targetNode.left;
}else {
parent.right = targetNode.left;
}
}else {
root = targetNode.left;
}
}else {//如果要删除的结点有右子结点
if (parent != null){
//如果targetNode是parent的左子结点
if (parent.left.value == value){
parent.left = targetNode.right;
}else {//如果targetNode是parent的右子结点
parent.right = targetNode.right;
}
}else {
root = targetNode.left;
}
}
}
}
}
//添加结点的方法
public void add(Node node){
if (root == null){
root = node;//如果root为空直接让root指向node
}else {
root.add(node);
}
}
//中序遍历
public void infixOrder(){
if (root != null){
root.infixOrder();
}else {
System.out.println("二叉排序树为空不能遍历");
}
}
}
//创建Node结点
class Node{
int value;
Node left;
Node right;
public Node(int value) {
this.value = value;
}
//返回左子树的高度
public int leftHeight(){
if (left == null){
return 0;
}
return left.height();
}
//返回右子树的高度
public int rightHeight(){
if (right == null){
return 0;
}
return right.height();
}
//返回当前结点的高度,以该结点为根结点的树的高度
public int height(){
return Math.max(left == null ? 0 : left.height(),right == null ? 0: right.height())+ 1;
}
//左旋转方法
private void leftRotate(){
//创建新的结点,以当前根结点的值
Node newNode = new Node(value);
//把新的节点的左子树设置成当前结点的左子树
newNode.left = left;
//把新的结点的右子树设置成当前结点的右子树的左子树
newNode.right = right.left;
//把当前结点的值替换成右子结点的值
value = right.value;
//把当前结点的右子树设置成当前结点的右子树的右子树
right = right.right;
//把当前结点的左子树(左子结点)设置成新的结点
left = newNode;
}
//右旋转
private void rightRotate(){
Node newNode = new Node(value);
newNode.right = right;
newNode.left = left.right;
value = left.value;
left = left.left;
right = newNode;
}
//查找要删除的结点
/**
*
* @param value 希望删除的结点的值
* @return 如果找到返回该节点,否则返回null
*/
public Node search(int value){
if (value == this.value){//找到就是该结点
return this;
}else if (value < this.value){//如果查找的值小于当前结点,向左子树递归查找
//如果左子结点为空
if (this.left == null){
return null;
}
return this.left.search(value);
}else {//如果查找的值不小于当前结点,向右子树递归查找
if (this.right == null){
return null;
}
return this.right.search(value);
}
}
//查找要删除结点的父结点
/**
*
* @param value 要找到的结点的值
* @return 返回的是要删除的结点的父结点,如果没有就返回null
*/
public Node searchParent(int value){
//如果当前结点就是要删除的结点的父结点,就返回
if ((this.left != null && this.left.value == value) ||
(this.right != null && this.right.value == value)){
return this;
}else {
//如果查找的值小于当前结点的值,并且当前结点的左子结点不为空
if (value < this.value && this.left != null){
return this.left.searchParent(value);
}else if (value >= this.value && this.right != null){
return this.right.searchParent(value);
}else {
return null;//没有找到父结点
}
}
}
@Override
public String toString() {
return "Node{" +
"value=" + value +
'}';
}
//添加节点的方法
//递归的形式添加结点,注意需要满足二叉排序树的要求
public void add(Node node){
if (node == null){
return;
}
//判断传入结点的值,和当前子树的根结点的值的关系
if (node.value < this.value){
//如果当前结点左子结点为null
if (this.left == null){
this.left = node;
}else {
//递归的向左子树添加
this.left.add(node);
}
}else {//添加的结点的值大于当前结点的值
if (this.right == null){
this.right = node;
}else {
//递归的向右子树添加
this.right.add(node);
}
}
//当添加完一个结点后,如果:(右子树的高度 - 左子树的高度) > 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();
}
}
}
//中序遍历
public void infixOrder(){
if (this.left != null){
this.left.infixOrder();
}
System.out.println(this);
if (this.right != null){
this.right.infixOrder();
}
}
}
9.3 多路查找树
9.3.1 二叉树与B 树
二叉树的问题分析
二叉树的操作效率较高,但是也存在问题, 请看下面的二叉树
- 二叉树需要加载到内存的,如果二叉树的节点少,没有什么问题,但是如果二叉树的节点很多(比如1 亿), 就存在如下问题:
- 问题1:在构建二叉树时,需要多次进行i/o 操作(海量数据存在数据库或文件中),节点海量,构建二叉树时,速度有影响
- 问题2:节点海量,也会造成二叉树的高度很大,会降低操作速度.
多叉树
- 在二叉树中,每个节点有数据项,最多有两个子节点。如果允许每个节点可以有更多的数据项和更多的子节点,就是多叉树(multiway tree)
- 后面我们讲解的2-3 树,2-3-4 树就是多叉树,多叉树通过重新组织节点,减少树的高度,能对二叉树进行优化。
- 举例说明(下面2-3 树就是一颗多叉树)
B 树的基本介绍
B 树通过重新组织节点,降低树的高度,并且减少i/o 读写次数来提升效率。
- 如图B 树通过重新组织节点, 降低了树的高度.
- 文件系统及数据库系统的设计者利用了磁盘预读原理,将一个节点的大小设为等于一个页(页得大小通常为4k),这样每个节点只需要一次I/O 就可以完全载入
- 将树的度M 设置为1024,在600 亿个元素中最多只需要4 次I/O 操作就可以读取到想要的元素, B 树(B+)广泛应用于文件存储系统以及数据库系统中
9.3.2 2-3 树
2-3 树是最简单的B 树结构, 具有如下特点:
2-3 树的所有叶子节点都在同一层.(只要是B 树都满足这个条件)
有两个子节点的节点叫二节点,二节点要么没有子节点,要么有两个子节点.
有三个子节点的节点叫三节点,三节点要么没有子节点,要么有三个子节点.
2-3 树是由二节点和三节点构成的树。
2-3 树应用案例
将数列{16, 24, 12, 32, 14, 26, 34, 10, 8, 28, 38, 20} 构建成2-3 树,并保证数据插入的大小顺序。(演示一下构建2-3树的过程.)
插入规则:
- 2-3 树的所有叶子节点都在同一层.(只要是B 树都满足这个条件)
- 有两个子节点的节点叫二节点,二节点要么没有子节点,要么有两个子节点.
- 有三个子节点的节点叫三节点,三节点要么没有子节点,要么有三个子节点
- 当按照规则插入一个数到某个节点时,不能满足上面三个要求,就需要拆,先向上拆,如果上层满,则拆本层,拆后仍然需要满足上面3 个条件。
- 对于三节点的子树的值大小仍然遵守(BST 二叉排序树)的规则
其它说明
除了23 树,还有234 树等,概念和23 树类似,也是一种B 树。如图:
9.3.3 B 树、B+树和B*树
B 树的介绍
B-tree 树即B 树,B 即Balanced,平衡的意思。有人把B-tree 翻译成B-树,容易让人产生误解。会以为B-树
是一种树,而B 树又是另一种树。实际上,B-tree 就是指的B 树。
B 树的介绍
前面已经介绍了2-3 树和2-3-4 树,他们就是B 树(英语:B-tree 也写成B-树),这里我们再做一个说明,我们在学习Mysql 时,经常听到说某种类型的索引是基于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*树的说明:
- B*树定义了非叶子结点关键字个数至少为(2/3)*M,即块的最低使用率为2/3,而B+树的块的最低使用率为的
1/2。- 从第1 个特点我们可以看出,B*树分配新结点的概率比B+树要低,空间使用率更高