树与二叉树
1、树的基本概念
1.1、树的定义
树是 N(N ≥ 0)个结点的有限集合,N = 0 时,称为空树,这是一种特殊的情况。在任意一棵非空树中应满足:
- 有且仅有一个特定的称为根的结点;
- 当 N >1 时,其余结点可分为 m(m >0)个互不相交的有限集合 T1,T2,…,Tm,其中每一个集合本身又是一棵树,并且称为根结点的子树;
显然树的定义是递归的,是一种递归的数据结构。数作为一种逻辑结构,同时也是一种分层结构,具有以下两种特点:
- 树的根结点没有前驱结点,除根结点之外的所有结点有且只有一个前驱结点;
- 树中所有结点可以有零个或多个后继结点。
1.2、基本术语
-
结点 K。
-
树中一个结点的子结点个数称为该结点的度。树中结点的最大度数称为树的度。
-
分支结点;叶子结点。
-
结点的深度、高度和层次:
结点的层次是树根开始定义的,根结点是第 1 层;
结点的深度是从根结点开始自顶向下逐层累加的;
结点的高度是从叶结点开始自底向上逐层累加的。
树的高度(又称深度)是树中结点的最大层数。
-
有序树和无序树。
-
路径和路径长度。
-
森林。
2、二叉树的概念
2.1、二叉树的定义及其主要特性
2.1.1、二叉树的定义
二叉树也是另一种树形结构,其特点是每个结点至多只有两棵子树(即二叉树中不存在度大于 2 的结点),并且,二叉树的子树有左右之分,其次序不能任意颠倒。
与树相似,二叉树也以递归的形式定义。二叉树是 n(n ≥ 0)个结点的有限集合:
- 或者为空二叉树,即 n = 0。
- 或者由一个根结点和两个互不相交的被称为左子树和右子树组成。左子树和右子树又分别是一棵二叉树。
二叉树是一棵有序树,即左右子树不可随意颠倒。
二叉树的 5 种基本形态:
2.1.2、几种特殊的二叉树
-
满二叉树
一棵高度为 h,并且含有 2h -1 个结点的二叉树称为满二叉树,即树中每一层都含有最多的结点,如图:
满二叉树的叶子结点都集中在二叉树的最下一层,并且除叶子结点之外每个结点度数均为 2。
对于编号为 i 的结点,如果有双亲,其双亲为 i/2;如果有左孩子,左孩子为 2i;如果有右孩子,则右孩子为 2i+1。
-
完全二叉树
设一个高度为 h,有 n 个结点的二叉树,当且仅当其每一个结点都与高度为 h 的满二叉树中编号为 1~n 的结点一一对应时,称为完全二叉树。如图:
这种树的特点如下:
- 若 i ≤ n/2;则结点 i 为分支结点,否则为叶子结点;
- 叶子结点只可能在层次最大的两层上出现。对于最大层次中的叶子结点,都依次排列在该层最左边的位置上;
- 如果有度为 1 的结点,只可能有一个,且该结点只有左孩子没有右孩子(重要特征);
- 按层次编号的话,一旦出现某结点(其编号为 i)为叶子结点或只有左孩子,则编号大于 i 的结点均为叶子结点;
- 若 n 为奇数,则每个分支结点都有左孩子和右孩子;若 n 为偶数,则编号最大的分支结点(编号为 n/2)只有左孩子,没有右孩子,其余分支结点左、右孩子都有。
-
二叉排序树
左子树上所有结点的关键字均小于根结点的关键字;右子树上的所有关键字均大于根结点的关键字。左子树和右子树又各是一棵二叉排序树。
-
平衡二叉树
树上任一结点的左子树和右子树的深度之差不超过 1。
2.1.3、二叉树的性质
-
非空二叉树上叶子结点数等于度为 2 的结点数加 1,即 N0 = N2 + 1。
证明:设度为 0、1 和 2 的结点数分别为 N0、N1 和 N2,结点总数为 N = N0 + N1 + N2。
再看二叉树中的分支数,除根结点外,其余结点都有一个分支进入,设 B 为分支总数,则 N = B + 1。由于这些分支是由度为 1 或 2 的结点射出的,所以又有 B = N1 + 2N2。
于是得:N0 + N1 + N2 = N1 + 2N2 + 1,则 N0 = N2 + 1。
-
非空二叉树上第 k 层上至多有 2k-1 个结点;
-
高度为 h 的二叉树至多有 2h -1 个结点;
-
对完全二叉树按从上到下、从左到右的顺序依次编号 1,2,…,N,则有以下关系:
- 当 i > 1 时,结点 i 的双亲结点编号为 i/2,即当 i 为偶数时,其双亲结点的编号为 i/2,它是双亲结点的左孩子;当 i 为奇数时,其双亲结点的编号为 (i-1)/2,它是双亲结点的右孩子;
- 当 2i ≤ N 时,结点 i 的左孩子编号为 2i,否则无左孩子;
- 当 2i + 1 ≤ N 时,结点 i 的右孩子编号为 2i + 1,否则无右孩子;
- 结点 i 所在层次(深度)为 log2i + 1;
-
具有 N 个结点的完全二叉树的高度为 log2N + 1。
2.2、二叉树的存储结构
2.2.1、顺序存储结构
数组下标要从 1 开始,而且会出现很多的空结点。利用率较低。
2.2.2、链式存储结构
又称为二叉链表。二叉链表至少包含 3 个域:数据域 data、左指针域 lchild 和右指针域 rchild,如图:
二叉链表的描述:
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild,*rchild; //左、右指针域
}BiTNode,*BiTree;
容易验证,在含有 n 个结点的二叉链表中含有 n+1 个空链域。
3、二叉树的遍历和线索二叉树
3.1、二叉树的遍历
3.1.1、先序遍历
根左右
对应的递归算法如下:
void PreOrder(BiTree T){
if(T!=NULL){
visit(T); //访问根结点
PreOrder(T->lchild); //递归遍历左子树
PreOrder(T->rchild); //递归遍历右子树
}
}
对应的非递归算法:
void PreOrder2(BiTree T){
//二叉树中序遍历的非递归算法,需要借助一个栈
InitStack(S); //初始化栈
BiTree p = T; //p 是遍历指针
while(p||!isEmpty(S)){ //栈不空或 p 不空时循环
if(p){
visit(p); //先访问根结点
Push(S,p); //每遇到非空二叉树先向左走
p->lchild;
}
else{
Pop(S,p); //根指针退栈,访问根结点,遍历右子树
p = p->rchild; //再向右子树走
}
}
}
3.1.2、中序遍历
左根右
对应的递归算法如下:
void InOrder(BiTree T){
if(T!=NULL){
InOrder(T->lchild); //递归访问左子树
visit(T); //访问根结点
InOrder(T->rchild); //递归访问右子树
}
}
对应的非递归算法:
void InOrder2(BiTree T){
//二叉树中序遍历的非递归算法,需要借助一个栈
InitStack(S); //初始化栈
BiTree p = T; //p 是遍历指针
while(p||!isEmpty(S)){ //栈不空或 p 不空时循环
if(p){
Push(S,p); //每遇到非空二叉树先向左走
p->lchild;
}
else{
Pop(S,p); //根指针退栈,访问根结点,遍历右子树
visit(p); //退栈,访问根结点
p = p->rchild; //再向右子树走
}
}
}
3.1.3、后序遍历
左右根
对应的递归算法如下:
void PostOrder(BiTree T){
if(T!=NULL){
PostOrder(T->lchild); //递归访问左子树
PostOrder(T->rchild); //递归访问右子树
visit(T); //访问根结点
}
}
对应的非递归算法:
因为后序非递归遍历二叉树的顺序是先访问左子树,再访问右子树,最后访问根结点。当用堆栈来存储结点,必须分清返回根结点时,是从左子树返回的,还是从右子树返回的。所以,使用辅助指针 r,其指向最近访问过的结点。也可以在结点中增加一个标志域,记录是否被访问过。
void PostOrder2(BiTree T){
InitStack(S);
p = T;
r = NULL;
while(p || !isEmpty(S)){
if(p){ //走到最左边
Push(S,p);
p = p->lchild;
}
else{ //向右
GetTop(S,p); //取栈顶结点
if(p->rchild && p->rchild != r){ //如果右子树存在,且未被访问过
p = p->rchild; //转向右
Push(S,p);
p = p->lchild; //再向左
}
else{ //如果右子树不存在,或者已经访问过
Pop(S,p); //弹出一个结点
visit(p->data); //访问该结点
r = p; //记录最近访问过的结点
p = NULL; //结点访问完后,重置 p 指针
}
}//else
}//while
}
3.1.4、层次遍历
要进行层次遍历需要借助一个队列。先将二叉树根结点入队,然后出队,访问该结点。如果它有左子树,则将左子树根结点入队;如果它有右子树,则将右子树根结点入队。然后出队,对出队结点访问,如此反复,直到队列为空。
void LevelOrder(BiTree T){
InitQueue(Q); //初始化辅助队列
BiTree p;
EnQueue(Q,T); //将根结点入队
while(!isEmpty(Q)){ //队列不空时循环
DeQueue(Q,p); //队头元素出队
visit(p); //访问出队元素
if(p->lchild!=NULL) //左子树不空,则左子树入队
EnQueue(Q,p->lchild);
if(p->rchild!=NULL) //如果右子树不空,则右子树入队
EnQueue(Q,p->rchild);
}
}
一个典型的例子:使用层次遍历记录层数,算法如下:
设置变量 level 记录当前结点所在层数,设置变量 last 指向当前层最右结点,每次层次遍历出队时与 last 指针比较,若两者相等,那么层数加 1,并让 last 指向下一层最右结点,直到遍历完成。level 的值即为二叉树的高度。
int Btdepth(BiTree T){
//采用层次遍历的非递归算法求解二叉树的高度
if(!T)
return 0
int front = -1,rear = -1;
int last = 0,level = 0; //last 指向下一层第一个结点的位置
BiTree Q[MaxSize]; //设置队列 Q,元素是二叉树结点指针且容量足够
Q[++rear] = T; //根结点入队
BiTree p;
while(front < rear){ //队不空时循环
p = Q[++front]; //队列元素出队,即正在访问的结点
if(p->lchild)
Q[++rear] = p->lchild; //左孩子入队
if(p->rchild)
Q[++rear] = p->rchild; //右孩子入队
if(front == last){ //处理该层的最右结点
level++; //层数加 1
last = rear; //last 指向下层最右结点
}
}
return level;
}
当然可以使用递归算法:
int Btdepth2(BiTree T){
if(T == NULL)
return 0
ldep = Btdepth2(T->lchild); //左子树高度
rdep = Btdepth2(T->rchild); //右子树高度
return ldep>rdep : ldep+1 ? rdep+1;
}
3.2、线索二叉树
3.2.1、线索二叉树的基本概念
引入线索二叉树是为了加快查找结点前驱和后继的速度。
在线索二叉树中,通常规定:若无左子树,令 lchild 指向其前驱结点;若无右子树,令 rchild 指向其后继结点。
其中标志域的含义如下:
l t a g = { 0 l c h i l d 域 指 向 结 点 的 左 孩 子 1 l c h i l d 域 指 向 结 点 的 前 驱 ltag=\left\{\begin{array}{l} 0 & lchild 域指向结点的左孩子 \\ 1 & lchild 域指向结点的前驱 \end{array}\right. ltag={01lchild域指向结点的左孩子lchild域指向结点的前驱
r t a g = { 0 r c h i l d 域 指 向 结 点 的 右 孩 子 1 r c h i l d 域 指 向 结 点 的 后 继 rtag=\left\{\begin{array}{l} 0 & rchild 域指向结点的右孩子 \\ 1 & rchild 域指向结点的后继 \end{array}\right. rtag={01rchild域指向结点的右孩子rchild域指向结点的后继
线索二叉树的存储结构描述如下:
typedef struct ThreadNode{
ElemType data; //数据元素
struct ThreadNode *lchild,*rchild; //左、右孩子指针
int ltag,rtag; //左、右线索标志
}ThreadNode,*ThreadTree;
3.2.2、线索二叉树的构造
通过中序遍历对二叉树线索化的递归算法如下:
void InThread(ThreadTree &p,ThreadTree &pre){
//中序遍历对二叉树线索化的递归算法
if(p!=NULL){
InThread(p->lchild,pre); //递归,线索化左子树
if(p->lchild == NULL){ //左子树为空,建立前驱线索
p->lchild = pre; //指向前驱
p->ltag = 1; //左标志域置为 1
}
if(pre != NULL || pre->rchild == NULL){
pre->rchild = p; //建立前驱结点的后继线索
pre->rtag = 1; //右标志位置为 1
}
pre = p; //标记当前结点成为刚刚访问过的结点
InThread(p->rchild,pre); //递归,线索化右子树
}
}
通过中序遍历建立中序线索二叉树的主过程算法如下:
void CreateInThread(ThreadTree T){
ThreadTree pre = NULL;
if(T!=NULL){ //非空二叉树,线索化
InThread(T,pre); //线索化二叉树
pre->rchild = NULL; //处理遍历的最后一个结点
pre->rtag = 1;
}
}
4、树、森林
4.1、树的存储结构
4.1.1、双亲表示法
这种存储方式采用一组连续的空间来存储每个结点,同时在每个结点中增设一个伪指针,指示其双亲结点所在的位置。如图:
4.1.2、孩子表示法
孩子表示法是将每个结点的孩子结点都用单链表链接起来形成一个线性结构,则 N 个结点就有 N 个孩子链表(叶子结点的孩子链表为空表),如图:
4.1.3、孩子兄弟表示法
孩子兄弟表示法又称为二叉树表示法,即以二叉链表作为树的存储结构。孩子兄弟表示法是使每个结点包括三部分:结点值、指向结点第一个孩子结点的指针和指向结点下一个兄弟结点的指针(沿此路线可以找到结点的所有兄弟结点),如图:
4.2、树、森林与二叉树的转换
树转换为二叉树的规则:每个结点左指针指向它的第一个孩子结点,右指针指向它在树中的相邻兄弟结点,可表示为 “左孩子右兄弟”。由于根结点没有兄弟,所以,由树转换而得到的二叉树没有右子树。
将森林转换成二叉树的规则与树类似。先将森林中的每棵树转换成二叉树,再将第一棵树的根作为转换后的二叉树的根,第一棵树的左子树作为转化后二叉树的左子树,第二棵树作为转换后的二叉树的右子树,……
4.3、树和森林的遍历
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后跟遍历 | 中序遍历 | 中序遍历 |
5、树与二叉树的应用
5.1、二叉排序树
5.1.1、二叉排序树的定义
二叉排序树(简称 BST),也称为二叉查找树。二叉排序树或者是一棵空树,或者是一棵具有以下特性的非空二叉树:
- 若左子树非空,则左子树上所有结点关键字值均小于根结点的关键字;
- 若右子树非空,则右子树上所有结点关键字值均大于根结点的关键字;
- 左、右子树本身也分别是一棵二叉排序树。
中序序列是一个递增有序的序列。
5.1.2、二叉排序树的查找
显然是一个递归的过程。
非递归的算法如下:
BSTNode *BST_Search(BiTree T,ElemType key,BSTNode *&p){
//查找函数返回指向关键字为 key 的结点指针,若不存在,返回 NULL
p = NULL; //p 指向被查找结点的双亲,用于插入和删除操作中
while(T != NULL && key != T->data){
p = T;
if(key < T->data)
T = T->lchild;
else
T = T->rchild;
}
return T;
}
查找的性能与树的高度有关,查找的平均时间复杂度为 O(log2n)。跟二分查找很类似,但是二分查找的判定树唯一,而二叉排序树不唯一,因为,不同的插入顺序可能导致不一样的二叉排序树。
5.1.3、二叉排序树的插入
插入的都是叶子结点。
5.1.4、二叉排序树的构造
调用插入函数即可。
5.1.5、二叉排序树的删除
3 种情况:
- 若删除的是叶子结点,直接删除;
- 若删除的结点只有一棵左子树或右子树,替代其位置;
- 若删除的结点既有左子树又有右子树,在右子树上找中序第一个子女填补。
5.2、平衡二叉树(AVL)
5.2.1、平衡二叉树的定义
为了避免树的高度增长过快,降低二叉排序树的性能,我们规定在插入和删除二叉树的结点时,要保证任意结点的左、右子树高度差绝对值不超过 1,将这样的二叉树称为平衡二叉树(AVL)。
5.2.2、平衡二叉树的插入
二叉排序树保证平衡的基本思想:每当在二叉排序树中插入(或删除)一个结点时,首先要检查其插入路径上的结点是否因为此次操作而导致不平衡。如果导致了不平衡,则先找到插入路径上离插入结点最近的平衡因子绝对值大于 1 的结点 A,再对以 A 为根的子树,在保持二叉排序树特性的前提下,调整各结点的位置关系,使之重新达到平衡。
注意:每次调整的都是对象都是最小不平衡子树,即在插入路径上离插入结点最近的平衡因子的绝对值大于 1 的结点作为根的子树。
一般可将失去平衡后进行调整的规律归纳为以下 4 种情况:
- LL 平衡旋转(右单旋转)。由于在结点 A 的左孩子(L)的左子树(L)上插入了新结点,A 的平衡因子由 1 增至 2,导致以 A 为根的子树失去了平衡,需要一次向右的旋转操作。
- RR 平衡旋转(左单旋转)。由于在结点 A 的右孩子(R)的右子树(R)上插入了新结点,A 的平衡因子由 -1 减至 -2,导致以 A 为根的子树失去平衡,需要一次向左的旋转操作。
- LR 平衡旋转(先左后右双旋转)。由于在 A 的左孩子(L)的右子树(R)上插入新结点,A 的平衡因子由 1 增至 2,导致以 A 为根的子树失去了平衡,需要进行两次旋转操作。
- RL 平衡旋转(先右后左双旋转)。由于在 A 的右孩子(R)的左子树(L)上插入新结点,A 的平衡因子由 -1 减至 -2,导致以 A 为根的子树失去了平衡,需要进行两次旋转操作。
5.2.3、平衡二叉树的查找
平衡二叉树的平均查找长度为 O(log2n)。
5.3、哈夫曼(Huffman)树和哈夫曼编码
5.3.1、哈夫曼树的定义
树中所有叶子结点的带权路径长度之和称为该树的带权路径长度,记为
W
P
L
=
∑
i
=
1
n
w
i
×
l
i
WPL=\sum_{i=1}^{n} w_{i} \times l_i
WPL=i=1∑nwi×li
式中,wi 是第 i 个叶结点所带的权值;li 是该叶结点到根结点的路径长度。
在含 N 个带权叶子结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称为最优二叉树。
5.3.2、哈夫曼树的构造
给定 N 个权值分别为 w1,w2,…,wN 的结点。通过哈夫曼算法可以构造出最优二叉树,算法描述如下:
- 将这 N 个结点分别作为 N 棵仅含一个结点的二叉树,构成森林 F;
- 构造一个新结点,并从 F 中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和;
- 从 F 中删除刚才选出的两棵树,同时将新得到的树加入 F 中;
- 重复 2 和 3 步骤,直到 F 中只剩下一棵树为止。
哈夫曼树有如下特点:
- 每个初始结点最终都成为叶结点,并且权值越小的结点到根结点的路径长度越大;
- 构造过程中共新建了 N-1 个结点(双分支结点),因此哈夫曼树中结点总数为 2N-1;
- 每次构造都选择 2 棵树作为新结点的孩子,因此哈夫曼树中不存在度为 1 的结点。
5.3.3、哈夫曼编码
对于待处理的一个字符串序列,如果对每个字符用同样长度的二进制位来表示,则称这种编码方式为固定长度编码。若允许对不同字符用不等长的二进制位表示,则这种方式称为可变长度编码。哈夫曼编码是一种被广泛应用而且非常有效的数据压缩编码。
如果没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码。
由哈夫曼树得到哈夫曼编码是很自然的过程,首先,将每个出现的字符当做一个独立的结点,其权值为它出现的频度(次数),构造出对应的哈夫曼树。显然,所有字符结点都出现在叶结点中。我们可以将字符的编码解释为从根结点至该字符的路径上边标记的序列,其中边标记为 0 表示 “转向左孩子”,标记为 1 表示 “转向右孩子”。
利用哈夫曼树可以设计出总长度最短的二进制前缀编码。