定义
每个节点具有唯一的前驱节点,可能具有多个后继节点。
树(Tree)是n(n≥0)个结点的有限集
空树:n=0
n>0时,满足以下两个条件
(1)根结点有且仅有一个。
(2)其余结点可分为m(m≥0)个互不相交的有限集T1,T2,T3,…,Tm,其中每一个集合本身又是一棵树,并称为根的子树(SubTree)
由第2个定义可以知道树是递归定义的,所以在程序中一般由递归的方式来处理树。
树的表示方式
嵌套集合,广义表,凹入表示
术语
结点:数据元素以及指向子树的分支
根节点:没有前驱结点
结点的度:结点拥有的后继(子树数,分支的个数)
即时结点M只是一个结点,但在树的定于中它能算是一颗树,M算是H的一个分支,也算是一个子树,也算是一个后继。
那么可以看出图中的结点D是有H,I,J三个后继结点的,所以结点D有三个子树,三个分支,三个后继,因此结点D的度为3。
比较快捷能找出结点的分支数的方式就是看有几条线与该结点相连接,有几条线相连接就有几个分支数,度就是几。
数的度:树内各个结点的度的最大值。
叶子结点(度=0):终端结点。
分支结点(度≠0):根节点以外的分支结点称为内部结点。
双亲:某一结点的前驱结点。
孩子:某一结点的后继结点。
兄弟结点:具有同一前驱结点的同一层的结点。
兄弟结点:不具有同一前驱结点的,它们的前驱结点与它们都但处于同一层的结点。
祖先:从某一结点出发,经过某些结点能够到达该结点,经过的那些结点称为该结点的祖先。
子孙:子树上的所有的结点都是它的子孙(既是孩子又是子孙不影响)。
树的深度(树的高度):树的层次,结点的最低的那一层,如上图所示层数:4。
有序树:树中的各子树从左至右有次序(最左边的为第一个孩子)
无序树:树中结点的各子树无次序。
森林:m(m≥0)棵互不相交的树的集合。把根结点删除就变成了森林,一棵树可以看成是一个特殊的森林。给森林的各个子树加上一个双亲结点,森林就变成了树。(树一定是森林 ,森林不一定是树)
树的抽象数据类型定义
基本操作
二叉树
所有的树都能转为唯一对应的二叉树。
定义
由一个根结点及两棵互不相交的分别称作这个根的左子树和右子树的二叉树组成。
特点
每个结点最多只有俩孩子(二叉树中不存在度大于2的结点)
子树有左右之分,其次序不能颠倒
二叉树可以是空集合,根可以有空的左子树或空的右子树
与树的区别
二叉树结点的子树要区分左子树和右子树
树中当只有一个孩子时就无需区分它时左还是右的次序。
思考:
基本形态
性质与存储结构
性质1:在二叉树的第i层上至多有2i-1个结点。
扩展:第i层上至少有1个结点。
性质2:深度为k的二叉树至多有2k-1个结点(K≥1)
实际上它就是一个等比数列,使用等比数列的求和公式即可求出
扩展:深度为k时至少有k个结点。
性质3:对任何一颗二叉树T,如果其叶子树为n0,度为2的节点数为n2,则n0=n2+1。
将最下面的运算式约分求和后就能够得出上面结论的公式。
二叉排序树
新建类BinaryTree.java:
package tree;
import javax.security.auth.kerberos.KerberosKey;
/**
* @version:
* @author: 零乘一
* @description: 二叉树
* @date: 2021/10/19 15:52
**/
public class BinaryTree<Key extends Comparable<Key>,Value>{
//记录根节点
private Node root;
//记录书中元素个数
private int N;
//获取树中元素的个数
public int size(){
return N;
}
//向树中添加元素key-val
public void put(Key key, Value value){
root = put(root, key, value);
}
/**
* 向指定的树x中添加key-value,并返回添加元素后新的树
* @param x 根结点
* @param key 要新增的结点的key
* @param value 要新增的结点的value
* @return
*/
private Node put(Node x, Key key, Value value){
//如果x子树为空,
if (x == null){//说明上一次递归时进入的结点已是叶子结点
N++;//新增节点时,节点个数+1
return new Node(key, value,null,null);//新增结点
}
//如果x子树不为空
//比较x结点的键和key的大小
int cmp = key.compareTo(x.key);
if (cmp>0){
//如果key大于x结点的键,则继续找到x结点的右子树
x.right = put(x.right,key,value);
}else if (cmp<0){
//如果key小于x结点的键,则继续找x结点的左子树
x.left = put(x.left,key,value);
}else {
//如果key等于x结点的键,则替换x节点的值为value即可
x.value = value;
}
//返回结点以便能够将新增的结点添加到树上
return x;
}
//查询树中指定key对应的value
public Value get(Key key){
return get(root,key);
}
//从指定的x中,查找key对应的值
public Value get(Node x, Key key){
//x树为null
if(x==null){
return null;//当此时的结点已经为null时,说明没有键为key的结点
}
//x树不为null
int cmp = key.compareTo(x.key);
if (cmp>0){
//如果key大于x结点的键,则继续找到x结点的右子树
return get(x.right,key);
}else if (cmp<0){
//如果key小于x结点的键,则继续找x结点的左子树
return get(x.left,key);
}else {
//如果key等于x结点的键,就找到了键为key的结点,只要返回x结点的值即可
return x.value;
}
}
//删除树中key对应的value
public void delete(Key key){
delete(root,key);
}
//删除指定树x中的key对应的value,并返回删除后的新树
public Node delete(Node x,Key key){
//x树为null
if(x == null){//当结点为null时,说明没有键为key的结点
return null;
}
//x树不为null
//x树不为null
int cmp = key.compareTo(x.key);
if (cmp>0){
//如果key大于x结点的键,则继续找到x结点的右子树
x.right = delete(x.right,key);
}else if (cmp<0){
//如果key小于x结点的键,则继续找x结点的左子树
x.left = delete(x.left,key);
}else {
//如果key等于x结点的键,完成真正的删除结点动作,要删除的结点就是x
//得找到右子树中最小的结点
if (x.right == null){//如果该节点的右子树已经为空,则说明该结点的左子树的第一个结点就能够被替换上(画图)
//让元素个数-1
N--;
return x.left;
}
if(x.left == null){//该结点的左子树为空时,说明此结点的右子树的第一个结点就能够替换该结点(画图)
//让元素个数-1
N--;
return x.right;
}
Node minNode = x.right;//找右子树中最小的那个结点替换这个要删除的结点,当然也可以找左子树中最大(最右边的结点)的结点
//循环到当minNode结点没有更左边的结点时,说明已经找到右子树中最小的那个结点
while (minNode.left != null){//一直在右子树中找,直到找到右子树中最小的结点
minNode = minNode.left;
}
Node n = x.right;
while (n.left != null){//找到要被替换的结点的父结点,因为要将最后一个替换到被删除的结点的位置
if (n.left.left == null){//此时该结点的位置为最后一个结点的上一个结点
n.left = null;//只需要将最后一个结点的上一个结点的left设置为null就能删除最后一个结点
}else {
//变换n结点即可
n = n.left;
}
}
//此时的minNode结点已经被替换为x,所以要将结点x的左右子树给minNode
//让x结点的左子树成为minNode的左子树
minNode.left = x.left;
//让x结点的右子树成为minNode的右子树
minNode.right = x.right;
//让x结点的父节点指向minNode,因为x的父节点一直指向x,所以只要将minNode赋给x就能达到令x的父节点指向minNode的效果
x = minNode;
//让元素个数-1
N--;
}
return x;
}
//内部类:结点
private class Node{
//存储键
public Key key;
//存储值
public Value value;
//记录左子结点
public Node left;
//记录右子结点
public Node right;
public Node(Key key, Value value, Node left, Node right) {
this.key = key;
this.value = value;
this.left = left;
this.right = right;
}
}
}
新建类BinaryTreeTest.java:
package tree;
/**
* @version:
* @author: 零乘一
* @description: 二叉排序树的测试类
* @date: 2021/10/19 18:27
**/
public class BinaryTreeTest {
public static void main(String[] args) {
//创建二叉查找树对象
BinaryTree<Integer, String> tree = new BinaryTree<>();
//测试插入
tree.put(1,"张三");
tree.put(2,"李四");
tree.put(3,"王五");
System.out.println("插入完毕后元素的个数:"+tree.size());
//测试获取
System.out.println("键2对应的元素是:"+tree.get(2));
//测试删除
tree.delete(3);
System.out.println("删除后的元素个数:"+tree.size());
System.out.println("删除后键3对应的元素:"+tree.get(3));
}
}
运行结果:
满二叉树
根据性质二:一颗深度为k且有2k-1个结点的二叉树称为满二叉树。
特点
每一层的结点数都是最大的节点数(即每层都满)
叶子结点全都在最底层
满二叉树在同样深度的二叉树中结点个数最多
满二叉数在同样深度的二叉树中叶子结点个数最多。
完全二叉树
完全二叉树的结点编号与满二叉树的结点编号一一对应。
在满二叉树中,从最后一个结点开始,连续去掉任意个结点,即是一颗完全二叉树。(一定是连续的去掉)
特点
叶子只可能分布在层次最大的两层上。
对任一结点,如果其右子树的最大层次为i,则其左子树的最大层次必为i或i+1。
满二叉树一定是完全二叉树,但完全二叉树不一定是满二叉树。
满二叉树与完全二叉树在顺序存储方式下可以复原。
完全二叉树的性质
具有n个结点的完全二叉树的深度为【log2n】+1
注:【x】:称作x的底,表示不大于x的最大整数
在k层上最多的元素个数:2k-1,最少的元素个数:2k-1
最大元素个数上面就有解释这边就不过多进行赘述,而最少的元素个数为什么是2k-1虽然上面也有提及,但是这边就再说明一次,因为最后一层的结点至少也要有一个,因此拥有这么k层树的总结点数,就会变成k-1层的总结点数加上最后一层的结点数也就是+1,因为k-1层的总结点数的计算公式是2k-1-1,再+1即为2k-1。
性质5:有一颗n个结点的完全二叉树(深度为[log2n]+1)的结点按层序编号(从第1层到第[log2n]+1层,每层从左到右),则对任意结点i(1≤i≤n)有:
理解:
n为次数的结点个数。
编号为1是根节点。
假设有结点i
结点i的根结点的双亲结点的编号为i/2
i结点的左孩子编号为2i,若2i>n则i结点无左孩子
i结点的右孩子编号为2i+1,若2i+1>n则i结点无右孩子
证明思路:
假设结点i的左孩子编号为2i,右孩子编号为2i+1,则编号为i+1的根结点的左孩子的编号一定为2i+2,为什么如此肯定因为完全二叉树的每一层都是满的。
存储结构
顺序存储
按满二叉树的结点层次编号,依次存放二叉树中的数据元素。
顺序存储后恢复成树
构造出一颗层次相同的完全二叉树,根据顺序表中的编号内的值依次填入完全二叉树中,去掉没有填入值得结点就还原了树。
缺点
空间扩容麻烦:顺序表的长度是固定的,当树中的元素要不断的进行增删改查的操作时就会非常的不方便。
浪费存储空间:最坏的情况下,存储深度为k的单支树。
顺序存储时,存储的有效的元素个数只有k个,但却需要2k-1个存储空间,于是就浪费了大量的存储空间。
如上图:存储3个有效的元素却需要7个存储空间。
适用于满二叉树和完全二叉树。
链式存储
二叉链表
便于找后继结点,定义两个结点,一个指向自己的左子树,一个指向自己的右子树,如果没有哪个子树或没有子树时,那么两个指针的值就为空。
空指针域个
总链域:2n(n结点个数,因为每个结点都定义了两个结点分别指向自己的左孩子与右孩子)
每个孩子的双亲结点都会有一条线指向自己,只有根结点没有,因此有有被用上的指针域变为n-1(-1就是因为根结点没有指向它的线,其余结点的双亲都有一个指针域指向它的孩子结点)
所以空指针域的个数为:2n-(n-1)
三叉链表
在二叉链表的基础上再加一个结点指向它的双亲结点,便于找前驱结点。
只有根结点的双亲结点是空的,其它的结点必有双亲结点。
遍历二叉树
定义:顺着某一条搜索路径巡防二叉树中的结点,使得每个结点均被访问依次,而且仅被访问依次(又称周游)
目的:得到树中所有结点的一个线性队列。
用途:它是树结构插入、删除、修改、查找和排序运算的前提,是二叉树一切运算的基础和核心。
D:根,L:左结点,R:右节点
图1
图2
先序遍历
图1:ABC
图2:ABELDHMIJ
中序遍历
图1:BAC
图2:ELBAMHIDJ
后序遍历
图1:BCA
图2:LEBMIHJDA
遍历练习
答案:
答案:
先序遍历称为波兰式
后序遍历称为逆波兰式
在以上是这个种以运算式子推演的树可以仅凭中序遍历就还原树的结构,虽然构造出来的树可能有多棵,但是只需要记住叶子结点都是数字即可。
已知先序序列和中序序列构造树
先根据先序遍历的特点取得树的根结点是A,然后再根据根节点A将中序遍历分为左右两个子树然后再将两个子树在根据一样的方式进行划分。
为什么能够确定结点D是结点C的右子树呢,因为在中序序列中D在C的右侧(中序的遍历顺序:左中右,因此在在右边的是右子树)
已知中序序列和后序序列构造树
与上一种构造二叉树的方式一样,不详细写出
已知先序序列和后序序列构造树
不知道中序序列是无法构造二叉树的。要记住哦。亲
递归遍历算法
为什么每个结点都会经过3次呢,因为第一次会先经过,然后遍历左子树时回来又会经过一次,访问右子树时回来时又会再经过一次。
时间复杂度:O(n)。
我的理解是:每个结点都会经过3次,所以次数是n+n+n,O(3n),因为时间复杂度去掉常数的系数,因此时间复杂度就变成了O(n)。
空间复杂度:O(n),栈占用的最大辅助空间。
中序遍历非递归算法
将树的结点根据一定的条件依次入栈然后再根据一定的条件依次出栈。
出栈时要该结点的左子树已经完全遍历完毕然后将该根节点出栈,然后再遍历该树的右子树,然后按照相同的方式入栈以及出栈。
层次遍历
从上到下,从左到右
按照上面的方式就是跟结点先入队
出队后再将根节点的左右两个孩子入队(如果有左右孩子)
将一个结点出队,再将出队的结点的两个孩子入队(如果有孩子)
将a结点出队后,a结点的孩子入队
将结点b出队后,b结点的孩子入队
f结点出队,f的孩子入队
c结点出队后因为没有孩子结点,所以就没有孩子入队
结点d出队,d的孩子结点入队
结点g出队,g的孩子结点入队
结点e出队,没有孩子结点不进行入队操作
结点h出队,没有孩子结点不进行入队操作
此时队列为空,层次遍历完成。
遍历的应用
先序序列构造二叉树
复制二叉树
线索二叉树
作用
在遍历二叉树时,如果只是向下遍历也就是遍历孩子的结点,那会很方便,但是如果时找到的是父亲结点,那就很麻烦,因为结点中并没有存储父亲结点的地址,所以要么要在结构体中多添加地址变量用于存储父亲结点的地址,要么要从头遍历才能找到所需要的父亲结点。
线索二叉树就是将二叉树结点中那些空着的地址空间利用起来,左结点存储前驱结点,右结点存储后继结点,但是为了区分这个结点是左孩子右孩子还是前驱结点与后继结点,对于二叉链表中的每一个结点都需要新增设两个标志域ltag与rtag,并做好如下约定:
先序线索二叉树
中序线索二叉树
后序线索二叉树
练习
写出以下对应的中序线索二叉树
做完线索二叉树后发现还有两个指针的空指针,根据中序遍历的结果:结点H没有前驱结点左结点为空,结点G没有后继结点右结点为空。
为了操作方便,新增一个头节点进行如下操作。
树和森林
森林:m(m≥0)棵互不相交的树的集合。
树的存储结构
双亲表示法
孩子链表
以这种存储方式要找双亲就比较困难的,例如我要找结点为6的双亲结点,那么最坏的情况下就要找过所有的结点才能够找到它的双亲是谁。
带双亲的孩子链表
红色边框圈起来的部分就是每个结点的双亲双亲。
孩子兄弟表示法(存储树型结构并非二叉树)
每个结点的左指针指向了它的孩子的位置,右指针指向它的兄弟结点,从跟结点开始遍历时,找到第一个结点然后根据它的右结点的存储的结点,就能够找到它的第二个孩子,依次类推。
树转为二叉树
步骤:
示例
兄弟相邻
留长子(留下最左边孩子与双亲结点的连线,去掉其他结点的与双亲结点的连线)
绕根旋转45
二叉树转为树
示例
左孩又又连双亲
去掉原来右孩线
将旋转的角度旋转回来(将斜着的结点放在同一行上)
树和森林
森林转为二叉树
将树转为二叉树
将转成的二叉树相连,以第一棵树的根节点为二叉树的根节点
二叉树转为森林
示例
去掉右孩线变成孤立的树
在通过二叉树转为树的方法将孤立的二叉树转为树
树的遍历
先根(次序)遍历
若树不空,则先访问根节点,然后依次先根遍历各棵子树。
以上图为例,先序遍历序列:ABCDE
后根(次序)遍历
若树不空,则先依次后根遍历各棵子树,然后访问根节点。
后序遍历序列:BDCEA
层次遍历
若树不空,则自上而下自左向右访问树中每个节点。
层次遍历序列:ABCED
森林的遍历
将森林分为以上三个部分。
先序遍历
遍历结果:ABCDEFGHIJ
即:依次从左至右对森林中的每一棵树进行先跟遍历。
中序遍历
即:依次从左至右对森林中的每一棵树进行后根遍历。
森林的中序遍历与树的后序遍历的形式是一样的。(不要质疑,真是这样的),因为当你将森林构造成二叉树时,使用中序遍历的方式遍历出来的式子就是如下,如不能理解,将森林转为二叉树构造出来看着图就能更好的理解。
以上图为例,遍历结果:BCDAFEHJIG
哈夫曼树
概念
但是节点数相同的二叉树中,路径最短的不一定是完全二叉树。
如上图的树b,当节点H变为E的子节点,I变为F的子节点,虽然此时的路径长度还是与完全二叉树的长度相同,但是它已经不是完全二叉树了。
wk:权值。
lk:结点到根的路径长度。
由上图可以看明白,结点个数相同且权值也相同的但可以构造出不同的二叉树,他们的带权路径也不相同。
哈夫曼树:(最优树)带权路径(WPL)最短的树。
“带权路径最短”是在“度相同”的树中比较而得得结果,因此有最优二叉树、最优三叉树之称等等。
哈夫曼树
(最优二叉树)带权路径(WPL)最短的二叉树。
满二叉树不一定是哈夫曼树(构造出的满二叉树的带权路径不一定是最短的),只要是带权路径最短的树都能称为哈夫曼树。
特点:权值越大的结点离根节点越近。
1、构造森林全是根
2、选用两小造新树
3、删除两小添新人
4、重复2、3剩单根
特点:
哈夫曼树的节点的度数要那么为0要么为2。因为哈夫曼树中要么是叶子节点,要么就是两两合并后产生的新节点,合并产生的新节点度数为2。
度为2的结点:n-1,因为一开始有n个节点,需要进行n-1次合并才能将n个节点合并为一棵二叉树,而每经过一次合并就会产生新的节点为2的节点。
哈夫曼树中共包含有n个叶子结点,共有2n-1个结点。总结点数=原有的n个节点+因合并而产生的新节点的个数。
一维结构数组存储
例,有n=8,权值为W={7,19,2,6,32,3,21,10}构造哈夫曼树。
初始化数组,数组的长度是2n,使用的范围是从1-(2*n)-1,为了方便,不适用第一个下标为0的节点
先将所有的节点构造为一棵树。
找到两个权值最小的节点,造一颗新树。(通过该n个结点会新增n-1个中间结点)
将造出来的新树的那个节点也归入待构造的节点中(去掉节点增新人)并写入被构造的节点的父节点编号与新增的节点的左右孩子节点的编号。
一直重复造树的操作,直至数组中parent中只有一个位置为0。
实现
步骤:
1、初始化HT[1…2n-1]:lch=rch=parent=0;
2、输入初始n个叶子节点,置HT[1…n]的weight值;
3、进行n-1次合并,依次产生n-1个基点HT[i],i=n+1…2n-1;
a)在HT[1…i-1]中选两个未被选过(从parent==0的结点中选)的weight最小的两个结点HT[s1]和HT[s2],s1、s2为两个最小结点下标;
b)修改HT[s1]和HT[s2]的parent值:
初始化:
构造哈夫曼树:
树的应用
哈夫曼编码:在构造哈夫曼树时,左分支标注0,右分支标注1,哈夫曼编码并不唯一,左右的位置是可以进行交换的。
构造出的哈夫曼树:
用于存储结点的编码
暂存编码,编码最长的长度为:n-1(因为建造的时候取最小的两个结点构造树,深度最多:n-1)因为从叶子结点开始找编码,所以编码要从尾开始记录,然后取编码时只要从头开始取就行。
构造出哈夫曼树要找到哈夫曼编码:从叶子结点往上找根据父结点就能知道自己是左孩子还是又孩子,从这个线索网上找到根(父节点为0),找到的这个串从后往前就是所需要得到的哈夫曼编码。
分配的空间:n-start,是编码的长度+1,为什么是n-start,因为start开始是编码的最后一位:n-1,根据循环的条件就可以得到这个结果。例如A字符举例,start一开始是6,经过循环后得到5,n-start得到2,编码长度为1,所以得到的长度就是编码的长度+1,因为这是c的实现,所以要多留一个位置填写字符串结束表示“/0”。
根据哈夫曼编码与树还原出的字符串:
将运算符存放在根节点中,运算的值存放在叶子节点中。