目录
树
知识框架
一.树的基本概念
1.树的定义
树是n(n>=0)个结点的有限集。当n=0时,称为空树。任意一颗非空树应满足:
1.有且仅有一个特定的称为根的结点。
2.当n>1时,其余节点可分为m个互不相交的有限集,其中每个集又是一颗新的树,称为根的子树。
很显然,对于树的定义是递归的,树是一种递归的数据结构,在往后我们对树的各种操作都要利用到递归这个重要的性质!树作为一种逻辑结构,同时也是一种分层结构,具有一下特征:
1.树的根节点没有前驱,除根节点外的所有结点有且只有一个前驱。
2.树中所有结点可以有零个或多个后继。
2.基本术语
1.度:树中一个结点的孩子个数称为该结点的度,树中结点的最大度数称为树的度。
2.高度:从根节点开始,从1开始依次往下计数,到最后一层的总层数。
3.层数:从根节点开始,从1开始依次往下计数。
3.树的性质
树具有下面一些基本性质:
1.树中的结点数等于所有结点的度加1.
2.度为m的树中第i层至多有m^(i-1)个结点(i>=1).
3.高度为h的m叉树至多有(m^h -1)/(m-1)个结点
4.具有n个结点的m叉树的最小高度为 logm (n(m-1)+1).
二叉树
一.二叉树的概念
1.二叉树的定义
二叉树是另一种树形结构,其特点是每个结点至多只有两颗子树(即二叉树中不存在度大于2的结点),并且二叉树的子树有左右之分,其次序不能任意颠倒。与树的定义相似,二叉树也以递归的形式定义。二叉树是n(n>=0)个节点的集合:
1.空二叉树,n为0;
2.或由一个根节点和两个互不相交的被称为根的左子树和右子树组成,他们分别又是一颗二叉树
一般为以下几种形式
2.特殊的二叉树
(1)满二叉树
一棵高度为h,且含有2^h -1个结点的二叉树称为满二叉树,若根节点从1开始编号,左孩子为2i,右孩子为2i+1
(2)完全二叉树
只有最下面两层有度数小于2的节点,且最下面一层的叶节点集中在最左边的若干位置上的二叉树我们称为完全二叉树
(3)二叉排序树
左子树上所有结点的关键字均小于根结点的关键字,右子树上的所有结点的关键字都大于根节点的关键字,左右子树分别又是一颗二叉排序树。
(4)平衡二叉树
首先平衡二叉树是一棵合理的二叉排序树,做上任意结点的左子树和右子树的深度之差不超过1,在构建平衡二叉树过程中,一但发现不符合特性,需要进行调整,这个我们之后再细聊。
3.二叉树的一些性质
1.任意一棵二叉树,若节点数量为n,则边的数量为n-1;
2.非空二叉树上叶子结点树总是等于度为2的结点数量加1:n0=n2+1
3.非空二叉树上第i层最多有2^(i-1)个结点。
4.高度为h的二叉树至多有(2^h)-1个结点
4.二叉树的存储结构
(1)顺序存储
二叉树的顺序存储如何实现呢?我们用一组地址连续的存储单元从上到下,从左到右存储完全二叉树的结点元素,即将完全二叉树上编号为i的结点元素存在一维数组下标为i-1的分量中,这样我们查找起来会很方便,但是也有一个巨大的问题哦!那就是为了让数组的下标和结点编号对应上,我们必须要将二叉树变成完全二叉树,不存在的结点也要补上,不然编号对应不上,这就会造成空间的巨大浪费。如下图所示
(2)链式存储
通过观察我们不难发现,二叉树的左孩子右孩子我们完全可以通过两个左右指针来指向他们。
看看我们如何定义结点的结构类型
typedef struct linkTree{
int data;//数据
struct linkTree* lchild;//左孩指针
struct linkTree* rchild;//右孩指针
}linkTree;
二.二叉树的创建和遍历
从根节点出发,按照某种顺序依次访问所有结点,每个结点有且只能被访问一次。
1.先序遍历
先访问根节点,先序遍历左子树,先序遍历右子树
代码如下:
//前序遍历
void preOrder(TreeNode* T)
{
if(T==NULL)
return ;
else
{
printf("%c ",T->data);
preOrder(T->lchild);
preOrder(T->rchild);
}
}
2.中序遍历
中序访问左子树,访问根节点,中序访问右子树
//中序遍历
void inOrder(TreeNode* T)
{
if(T==NULL)
return ;
else
{
inOrder(T->lchild);
printf("%c ",T->data);
inOrder(T->rchild);
}
}
3.后序遍历
后序访问左子树,后序访问右子树,访问根节点
代码如下:
//后序遍历
void postOrder(TreeNode* T)
{
if(T==NULL)
return ;
else
{
preOrder(T->lchild);
preOrder(T->rchild);
printf("%c ",T->data);
}
return ;
}
三种遍历我们都用到了递归的方法,且无论是哪种遍历算法,我们每个结点都只访问了一次,所以时间复杂度为O(n),在使用递归时一定要注意退出递归的条件!不然可能会陷入死循环无法退出,那么如果不使用递归如何遍历呢?
4.层次遍历
层次遍历顾名思义就是从上往下从左到右每一层遍历完成后再往下一层开始遍历,如下图所示
那么要如何实现层次遍历呢,这里就要用到我们之前的队列了,我们先将二叉树根结点入队,然后出队,访问出队结点,若有左子树,则将左子树根节点入队,如果有右子树,将右子树根节点入队。然后出队,访问出队结点。。。如此循环,直到队列为空。
思路如下图:
//层次遍历
void levelTraverse(QueueNode* Q,TreeNode* T)
{
enQueue(T,Q);//根节点入队
while(!isEmpty(Q))//队列不为空则循环
{
QueueNode* node=deQueue(Q);接收出队结点
printf("%c ",node->T->data);访问出队结点
if(node->T->lchild)
{
enQueue(node->T->lchild,Q);//左子树不空,则左子树根结点入队
}
if(node->T->rchild)
{
enQueue(node->T->rchild,Q);//右子树不空,则右子树根节点入队
}
}
}
5.递归算法和非递归算法的转换
之前我们三种遍历都用了递归的思想,那么如果不用递归能否实现呢?我们以下图为例子来分析三种遍历的非递归算法吧:
(1)中序遍历的非递归算法
这里我们要借用栈先进后出的性质来帮助我们达成中序遍历的非递归算法:
1.先沿着根的左孩子依次入栈,直到左孩子为空为止,这时说明找到可以输出的结点,如果按照我们上图所示,此时我们栈内元素为ABD。
2.将栈顶元素出栈并访问他的右孩子,如果右孩子为空则继续访问,如果不为空,则入栈右孩子进行第一步操作
如图所示,你可能会更清楚:
void inOrder(TreeNode* T)
{
initStack(S);//初始化栈
TreeNode* p=T;
while(p || !isempty(s))//栈不空或p不空时循环
{
if(p)
{
push(S,p);//当前节点入栈
p=p->lchild;//左孩子不空,则一直向左走
}
else
{
pop(S,p);//栈顶元素出栈
visit(p);
p=p->rchild;//向右子树走
}
}
}
(2)先序遍历的非递归算法
先序遍历和中序遍历的思路是类似的,不过由于先序先访问的是根,所以我们要将访问操作结点放在入栈操作的前面。
(3)后序遍历的非递归算法
后序遍历的话和先序中序有一点差别,我们需要在结点中增加一个标志域,来记录是否被访问,以免成为死循环,算法思路如下图,我将思路总结成下面两句话
1.从根结点开始,寻找最左边的节点,并依次入栈
2.出栈前,判断栈顶元素是否有右子树,还要判断右子树是否被访问过,如果有且未被访问则将右子树入栈,重复1的操作。
typedef struct TreeNode{
char data;
struct TreeNode* lchild;
struct TreeNode* rchild;
int flag;//判断是否被访问的标志域元素
}TreeNode;
//链栈类型定义
typedef struct StackNode{
TreeNode* data;//指向对应结点元素的指针
struct StackNode* next;//指向下一个栈元素的指针
}StackNode;
//栈的初始化
StackNode* initStack()
{
StackNode* S=(StackNode*)malloc(sizeof(StackNode));
S->data=NULL;
S->next=NULL;
return S;
}
//入栈操作
void push(TreeNode* data,StackNode* S)
{
StackNode* node=(StackNode*)malloc(sizeof(StackNode));
node->data=data;
node->next=S->next;
S->next=node;
}
//判空
int isEmpty(StackNode* S)
{
if(S->next==NULL)
return 1;
else
return 0;
}
//出栈
StackNode* pop(StackNode* S)
{
if(isEmpty(S))
{
return NULL;
}
else
{
StackNode* node=S->next;
S->next=node->next;
return node;
}
}
//获取栈顶
StackNode* getTop(StackNode* S)
{
if(isEmpty(S))
{
return NULL;
}
else
{
StackNode* node=S->next;
return node;
}
}
//后序的非递归遍历
void postOrder(TreeNode* T)
{
TreeNode* node=T;
StackNode* S=initStack();//初始化链栈
while(node || !isEmpty(S))
{
if(node)
{
push(node,S);//入栈操作
node=node->lchild;//将左孩子依次入栈
}
else
{
TreeNode* top=getTop(S)->data;//指向栈顶元素
if(top->rchild&&top->flag==0)//如果有右孩子且未被访问
{
top=top->rchild;//将指针指向右孩子
push(top,S);//将右孩子入栈
node=top->lchild;//指向左孩子重复上述判断
}
else
{
top=pop(S)->data;
printf("%c ",top->data);
top->flag=1;//将标志域置1表示被访问过
}
}
}
}
6.由遍历序列构造出二叉树
1.由二叉树的先序序列和中序序列可以唯一确定一棵二叉树
先序序列,第一个结点一定是二叉树的根结点,而在中序遍历,根结点必然将中序序列分割成两个子序列,前一个子序列是根节点的左子树的中序序列,后一个子序列是根节点的右子树的中序序列。再根据这两个子序列,在先序序列中找到对应的子序列,左子序列的第一个结点时左子树的根节点,右子序列的第一个结点是右子树的根节点,递归即可得到这个二叉树。
2.由二叉树的后序序列和中序序列也可以唯一确定一棵二叉树
后序序列的最后一个结点为根节点,和上面一样根据这个将中序序列分割成两个子序列
3.由二叉树的层次序列和中序序列也可以唯一确定一棵二叉树
4.先序和后序不能唯一确定一棵二叉树
来看个例子吧:
已知先序为:ABCDEFG,中序为CDBAEFG,画出二叉树
二叉树的应用
一.二叉排序树
1.定义
二叉排序树,左子树上的结点值均小于根节点的值,右子树上的结点值均大于根结点的值。
可以发现如果我们将一棵二叉排序树按中序遍历,可以得到一个递增的有序序列,因此如果用来查找效率会快很多,但是也会出现一个问题,由于没有其余限制条件,可能会导致某一侧子树长度过长如下图所示,那么这样查找起来效率会慢很多,所以后面会我们引入二叉平衡树!
2.插入
二叉排序树的插入操作很简单,放到比较大小放在合适位置即可
TreeNode* bstInsert(TreeNode** T,int data)
{
if(*T==NULL)//如果是空树则直接为根节点
{
*T=(TreeNode*)malloc(sizeof(TreeNode));//开辟空间
(*T)->data=data;//赋值
(*T)->lchild=NULL;//初始化
(*T)->rchild=NULL;//初始化
}
else if(data<(*T)->data)//如果比根节点小则继续递归和左孩子比较
{
bstInsert(&((*T)->lchild),data);
}
else if(data==(*T)->data)//如果相等则不进行操作,因为不能有相同的数存在
return ;
else
bstInsert(&((*T)->rchild),data);//如果比根节点大则继续递归和右孩子比较
}
3.查找
//查找
TreeNode* bstSearch(TreeNode* T,int data)
{
if(T)
{
if(T->data==data)
{
return T;
}
else if(data<T->data)
{
return bstSearch(T->lchild,data);
}
else
{
return bstSearch(T->rchild,data);
}
}
else
return NULL;
}
二.二叉平衡树
1.定义
平衡二叉树其实就是一棵合理的二叉排序树,每个节点的左子树和他的右子树的高度差至多等于1.
我们将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子,平衡二叉树上所有结点的平衡因子只会是-1,0,1,如果大于1则不是平衡二叉树,此时我们需要调整它成为平衡二叉树。
注:由于平衡二叉树是高度平衡的,所以我们在进行查找时效率很快,但是如果进行插入等操作由于要考虑平衡,效率则会很慢。平衡二叉树的查找算法时间复杂度为O(log2n)
2.平衡二叉树的插入
在进行插入删除等操作时,我们要考虑到是否会导致不平衡来进行调整。
注:每次调整的对象都是最小不平衡子树。
调整规律有以下四种情况:我会分别用一个例子来进行说明,但首先我们需要判断该子树的调整类型,这里我总结了一些规律让我们来判断:
1.首先我们要找到失衡树的根结点root.
2.找到导致树失衡的结点node,先判断node在root哪一侧,左侧为L,右侧为R
3.再判断node在root孩子child的哪一侧,左侧为L,右侧为R(比他小在左,比他大在右边)
4.通过2,3即可判断调整类型。
1.RR旋转
例:1 2 3 4 5
如果按照我们平时二叉排序树来进行插入那么会先出现下面这种情况:
此时已经发生了失衡情况根结点的平衡因子为-2,首先我们要判断调整类型,通过上图我们可以发现node在root的右侧,在child的右侧,所以我们需要用到RR平衡旋转类型对它进行调整再进行数据的插入,这里我总结了这个类型的调整方法:
取中间的结点,使它的父亲成为它的左孩子,如果它有左孩子,那么这个左孩子连接到父亲的右孩子上面(因为它的左孩子肯定比父亲大)
于是通过两个操作我们将它旋转:
root为1是根结点,child为2结点
1.root->rchild=child->lchild
2.child->lchild=root
接着我们再将剩余数据插入,但是我们会发现插入后还会发生失衡问题,如下图,我们发现此时两棵树都发生了失衡状况,此时我们要先处理小树和上一步骤一样我们先判断调整类型,再进行对应操作,类型依然为RR
root为3结点,child为4结点
1.root->rchild=child->lchild
2.child->lchild=root
于是旋转为下图:
2.LL旋转
例:5 4 3 2 1
如果按照平时的二叉排序会成为下面情况
按照我们上述的方法我们可以判断出类型为LL,我总结了LL型的调整方法如下:
取中间的结点,使它的父亲成为它的的右孩子,如果自己有右孩子的话,那么将自己的右孩子连接到父亲的左孩子上。
通过下面两个操作来调整:
1.root->lchild=child->rchild
2.child->rchild=root
可以看到我们调整了两次才达到效果,所以在代码中每次插入一个数据我们都需要判断左右子树高度差来决定是否需要再次调整。
3.LR旋转
例:8 7 9 5 6
按照二叉排序树,出现下面这种情况,按照规律我们先调整小树:
调整操作如下:
取最后一个结点作为父亲,将他的父亲作为自己的左孩子,将它父亲的父亲作为自己的右孩子,如果它有左孩子或者右孩子,将它原来的左孩子连接到父亲的右孩子上,它的右孩子连接到父亲的左孩子上
结果如下:
4.RL旋转
例:1 8 6 7 10
还是一样的操作先判断类型,明显这里是RL类型,因为node在root右侧,在child左侧,调整方法如下:
取最后一个结点,将它的父亲作为自己的右孩子,将它父亲的父亲作为自己的左孩子
如果自己有左孩子或者右孩子,自己原来的左孩子连接到父亲的父亲的右孩子上,他原来的右孩子连接到父亲的左孩子上
注意:其实RL和LR都是基于RR,LL上的旋转,RL是先LL在RR,而LR是先RR再LL。
二叉排序树还有另外的平衡算法,如红黑树,相比较下各有优势,红黑树在进行查找和增加删除等操作效率快,相比平衡二叉树在进行增删时效率没有那么快,因为还要考虑到平衡。
红黑树
1.根结点为黑色
2.每一个节点不是黑色就是红色
3.每个叶子结点是黑色的。
4.如果一个结点是红色的,那么子节点必须为黑色。
5.从一个节点到该节点的叶子节点的所有路径上包含相同数目的黑节点。
三.哈夫曼树
1.定义
将树中结点赋值,这个值称为这个结点的权值。从树的根到任意结点的路径长度与该节点上权值的乘积,称为该节点的带权路径长度。树中所有叶结点的带权路径长度之和称为该树的带权路径长度。
在含有n个叶结点的二叉树中,其中带权路径长度最小的二叉树称为哈夫曼树,也叫做最优二叉树如下图所示:
2.构造哈夫曼树
那么如何通过权值结点来构造一棵哈夫曼树呢,我总结了两个步骤:
1.从结点列表中选出权值第一小和第二小的节点,并组成一棵树,父亲结点的权值等于两结点权值之和。
2.将生成的新树再次放到结点列表之中重复第一步,直到列表中只剩下一个结点
我们来看一个例子就清楚了:
3.哈夫曼编码
哈夫曼编码是一种被广泛应用且非常有效的数据压缩编码,将左右子树权值变成0,1,再来进行编码。