第4章 树与二叉树
4.1 树的基本概念
4.1.1 树的定义
树是n(n≥0)个结点的有限集合,n = 0时,称为空树,这是一种特殊情况。在任意一棵非空树中应满足:
- 有且仅有一个特定的称为根的结点。
- 当n > 1时,其余结点可分为m(m>0)个互不相交的有限集合T1,T2,···,Tm,其中每个集合本身又是一棵树,并且称为根结点的子树。
树的定义是一种递归的数据结构。其作为一种逻辑结构,同时也是一种分层结构,具有以下两个特点:
- 除根结点外的所有结点有且只有一个前驱结点。
- 树中所有结点可以有零个或多个后继结点。
4.1.2 基本术语
-
结点的度:树中一个结点的子结点的个数。
-
树的度:树中结点的最大度数。
-
分支结点:度大于0的结点,又称非终端结点。分支数就是该结点的度。
-
叶子结点:度为0的结点,又称终端结点。
-
结点的层次:从树根开始定义,根结点为第1层(有些教材 中将根结点定义为第0层),它的子结点为第2层,以此类推。
-
结点的深度:从根结点开始自顶向下逐层累加。
-
结点的高度:从叶子结点开始自底向上逐层累加。
-
树的高度(深度):树中结点的最大层数。
-
有序树和无序树:树中结点的子树从左到右是有次序的,不能交换,这样的树称为有序树,反之称为无序树。
-
路径:两个结点之间所经过的结点序列。由于树中分支是有向的,即从双亲结点指向孩子结点,所以树中的路径是自上而下的,同一双亲结点的两个孩子结点之间不存在路径。
-
路径长度:路径上所经过的边的个数。
-
树的路径长度:所有路径长度的和。
-
森林:m(m≥0)棵互不相交的树的集合。
4.1.3 树的性质
- 💡结点数 = 总度数 + 1(根节点)
- 度为m的树和m叉树的异同点
度为m的树 | m叉树 |
---|---|
任意节点的度 ≤ \leq ≤ m(最多m个孩子) | 任意结点的度 ≤ \leq ≤m(最多m个孩子) |
至少有一个结点度 = m(有m个孩子) | 允许所有结点的度都 < m |
一定是非空树,至少有m+1个结点 | 可以是空树 |
- 度为m的树中第i层上至多有mi-1个结点(i≥1)。
- 高度为h的m叉树至多有 m h − 1 m − 1 \frac{m^h-1}{m-1} m−1mh−1个结点。推导过程:S = mh-1 + mh-2 + ··· + m + 1 = (mh - 1) / (m - 1)
- 高度为h的m叉树至少有h个结点;高度为h、度为m的树至少有h+m-1个结点。
- 具有n个结点的m叉树的最小高度为 logm[n(m-1)+1](结果向上取整)。
- 树根结点没有前驱结点,其余每个结点有且仅有1个前驱结点;叶子结点没有后继结点,其余每个结点的后继结点树可以任意多个。
【注意】
- 层次、深度、高度未特别说明时都是从1开始。
4.2 二叉树的概念
4.2.1 二叉树的定义及其主要特性
1.二叉树的定义
二叉树是n(n≥0)个结点的有限集合。
- n = 0时为空二叉树。
- 由一个根结点和两个互不相交的左子树和右子树(左右子树可以为空)组成。左子树和右子树分别是一棵二叉树。
即使树中结点只有一棵子树,也要区分它是左子树还是右子树。(左右子树不能颠倒,二叉树是有序树)
二叉树与度为2的有序树的区别:
- 度为2的树至少有3个结点,而二叉树可以为空。
- 度为2的有序树的孩子结点的左右次序是相对另一孩子结点而言的,若某个结点只有一个孩子结点,则这个孩子结点就无须区分其左右次序;
而二叉树无论其孩子数是否为2,均需确定其左右次序,即二叉树的结点次序不是相对于另一结点而言,而是确定的。
2.特殊的二叉树
- 满二叉树:一棵高度为h,且含有2h-1个结点的二叉树称为满二叉树。==即树中的每层都含有最多的结点。==满二叉树是特殊的完全二叉树。
- 只有最后一层有叶子结点;
- 不存在度为1的结点;
- 按层序从1开始编号,结点i的左孩子为2i,有孩子为2i+1。
- 完全二叉树:设一个高度为h、有n个结点的二叉树,当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树。即第h-1层的结点中,右边的结点没有孩子结点,或仅有一个左孩子结点(这样的结点只有一个)。完全二叉树的特点如下:
- 若i≤ ⌊ n / 2 ⌋ \lfloor n/2 \rfloor ⌊n/2⌋,则结点i为分支结点,否则为叶子结点;
- 叶子结点只可能在层次最大的两层上出现;
- 💡若有度为1的结点,则只可能有一个,且该结点是由左孩子而无右孩子;
- 一旦出现某结点(编号为i)为叶子结点或只有左孩子结点,则编号大于i的结点均为叶子结点;
- 若n为奇数,则每个分支结点都有左孩子和右孩子;
若n为偶数,则编号最大的分支结点(编号为n/2)只有左孩子,没有右孩子。
- 二叉排序树:一棵二叉树或者是空二叉树,且具有如下性质:
- 左子树上所有结点的关键字均小于根结点的关键字;
- 右子树上所有结点的关键字均大于根结点的关键字;
- 左子树和右子树又各是一棵二叉排序树。
- 平衡二叉树:树上任一结点的左子树和右子树的深度之差不超过1。
3.二叉树的性质
- 💡非空二叉树上的叶子结点数等于度为2的结点数加1,即n0 = n2 + 1。
n = n0 + n1 + n2
n = B + 1,B为分支总数
B = n1+2n2 - 非空二叉树上第k层上至多有2k-1个结点(k≥1)。
- 高度为h的二叉树至多有2h-1个结点,高度为h的完全二叉树至少有2h-1个结点。
- 对完全二叉树按层序编号(根结点编号为1)。对于编号为i的结点:
- 若有双亲,则其双亲为 ⌊ i / 2 ⌋ \lfloor i/2 \rfloor ⌊i/2⌋;
- 若有左孩子,则左孩子为 2i;
- 若有右孩子,则有孩子为 2i+1;
- 结点i所在层次(深度)为 ⌊ log 2 i ⌋ + 1 \lfloor \log_2i \rfloor + 1 ⌊log2i⌋+1。
- 具有n个(n>0)结点的完全二叉树的高度为 ⌈ log 2 ( n + 1 ) ⌉ \lceil \log_2(n + 1) \rceil ⌈log2(n+1)⌉或 ⌊ log 2 n ⌋ + 1 \lfloor \log_2n \rfloor + 1 ⌊log2n⌋+1
4.2.2 二叉树的存储结构
1.顺序存储结构
二叉树的顺序存储是将完全二叉树上编号为i的结点元素存储在某个数组下标为i-1的分量中,然后通过一些方法确定结点在逻辑上的父子和兄弟关系。这种存储结构显然要从数组下标1开始存储树中的结点。
对于一般的二叉树,为了让数组下标能反映二叉树中结点之间的逻辑关系,只能添加一些并不存在的空结点,让其每个结点于完全二叉树上的结点相对照,再存储到一维数组的相应分量中。在最坏情况下,一个高度为h且只有h个结点的单支树需要占据2h-1个存储单元。
2.链式存储结构
顺序存储的空间利用率较低,二叉树一般都采用链式存储结构。链式结构是指用一个链表来存储一棵二叉树,二叉树中的每个结点用链表的一个链结点来存储。在二叉树中,结点结构通常包括若干数据域和若干指针域。二叉链表至少包含3个域:数据域data、左指针域lchild、右指针域rchild。在实际应用中,还可以增加某些指针域,如增加指向父结点的指针后,变为三叉链表的存储结构。
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild, *rchild; //左、右孩子指针
}BiTNode, *BiTree;
💡在含有n个结点的二叉链表中,含有n+1个空链域。
总的空指针数为2n0+n1,又n0 = n2+1,所以总的空指针为n0+n1+n2+1 = n+1。
【注意】
- 含有n个结点的二叉树的高度为 ⌊ log 2 n ⌋ + 1 \lfloor \log_2n \rfloor + 1 ⌊log2n⌋+1 ~ n,注意题目问的是完全二叉树还是二叉树。
4.3 二叉树的遍历和线索二叉树
4.3.1 二叉树的遍历
所谓二叉树的遍历,是指按某条搜索路径访问树中的每个结点,使得每个结点均被访问一次,而且仅被访问一次。
int treeDepth(BiTree T){
if(T == NULL)
return 0;
else{
int l = treeDepth(T->lchild);
int r = treeDepth(T->rchild);
//树的深度=Max(左子树深度,右子树深度)+1
return l > r ? l+1 : r+1;
}
1.先序(NLR)遍历
void preOrder(BiTree T){
if(T != NULL)
{
visit(T);
preOrder(T->lchild);
preOrder(T->rchild);
}
}
2.中序(LNR)遍历
void inOrder(BiTree T){
if(T != NULL)
{
inOrder(T->lchild);
visit(T);
inOrder(T->rchild);
}
}
3.后序(LRN)遍历
void postOrder(BiTree T){
if(T != NULL)
{
postOrder(T->lchild);
postOrder(T->rchild);
visit(T);
}
}
不论哪种遍历算法,每个结点都访问且仅访问一次,故时间复杂度为O(n)。在递归遍历中,递归工作栈的深度恰好为树的深度,所以在最坏情况下,二叉树是有n个结点且深度为n的单支树,遍历算法的空间复杂度为O(n)。
4.非递归的遍历算法
以中序遍历为例给出中序遍历的非递归算法
void inOrder2(BiTree T){
//二叉树中序遍历的非递归算法,算法需要借助一个栈
initStack(S);
BiTree p = T;
while(p || stackEmpty(S))
{
if(p)
{
push(S, p);
p = p->lchild;
}
else
{
pop(S, p);
visit(p);
p = p->rchild;
}
}
}
显然非递归算法的执行效率要高于递归算法。类似地,可以得到先序遍历和后续遍历的非递归算法,其中后序遍历的非递归算法较复杂。
5.层次遍历
层次遍历需要借助队列来实现。
void levelOrder(BiTree T){
LinkQueue Q;
InitQueue(&Q); //初始化辅助队列
BiTree p;
EnQueue(&Q, T); //将根结点入队
while(!IsEmpty(Q)){ //队列不空则循环
DeQueue(&Q, &p); //队头结点出队
if(p->lchild != NULL){
EnQueue(&Q, p->lchild); //左孩子入队
}
if(p->rchild != NULL){
EnQueue(&Q, p->rchild); //右孩子入队
}
}
}
6.由遍历序列构造二叉树
- 由二叉树的中序序列,以及先序序列或后序序列可以唯一地确定一棵二叉树。
- 由二叉树的层序序列和中序序列可以唯一地确定一个二叉树。
- 若只知道二叉树的先序序列和后序序列,则无法唯一确定一棵二叉树。
4.3.2 线索二叉树
1.线索二叉树的基本概念
传统的链式存储仅能体现一种父子关系,不能直接得到结点在遍历中的前驱或后继。我们发现在二叉链表表示的二叉树中存在大量的空指针,若利用这些空链域存放指向其直接前驱或后继的指针,则可以更方便地运用某些二叉树操作算法。引入线索二叉树是为了加快查找结点前驱和后继的速度。
💡在二叉树线索化时,通常规定:
1)若无左子树,令lchild指向其前驱结点;
2)若无右子树,令rchild指向其后继结点;
3)增加两个标志域,当tag = 0时表示当前指针域所指对象是左(右)孩子结点,当tag = 1时表示当前指针域所指对象是直接前驱(后继)。
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild, *rchild;
int ltag, rtag;
}ThreadNode, *ThreadTree;
以这种结点结构构成的二叉链表作为二叉树的存储结构,称为线索链表,其中指向结点前驱和后继的指针称为线索。加上线索的二叉树称为线索二叉树。对二叉树以某种次序遍历使其变为线索二叉树的过程称为线索化。
2.线索二叉树的构造
对二叉树的线索化,实质上就是遍历一次二叉树,只是在遍历的过程中,检查当前结点左右指针域是否为空,若为空,将它们改为指向前驱结点或后继结点的线索。
- 中序线索化
//全局变量pre,指向当前访问结点的前驱
ThreadNode * pre = NULL;
void CreateInThread(ThreadTree T){
pre = NULL
if(T != NULL){ //非空二叉树,线索化
InThread(&T, pre); //线索化二叉树
if (pre->rchild == NULL) //处理遍历的最后一个结点
pre->rtag = 1;
}
}
void InThread(ThreadTree &p, ThreadTree pre){
//中序遍历对二叉树线索化的递归算法,pre初始为空,即线索链表的第一个结点没有直接前驱
if(p != NULL){
InThread(p->lchild, pre); //递归,线索化左子树
if(p->lchild == NULL){
p->lchild = pre;
p->ltag = 1;
}
if(pre != NULL && pre->rchild == NULL){ //注意这里判断的条件是pre而非p
pre->rchild = p;
pre->rtag = 1;
}
pre = p; //标记当前结点称为刚刚访问过的结点(建立前驱结点的后继线索)
InThread(p->rchild, pre);
}//if(p!=NULL)
}
- 先序线索化
ThreadNode * pre = NULL;
void CreatePreThread(ThreadTree T){
pre = NULL
if(T != NULL){ //非空二叉树,线索化
PreThread(&T, pre); //线索化二叉树
if (pre->rchild == NULL) //处理遍历的最后一个结点
pre->rtag = 1;
}
}
void PreThread(ThreadTree &p, ThreadTree pre){
//中序遍历对二叉树线索化的递归算法,pre初始为空,即线索链表的第一个结点没有直接前驱
if(p != NULL){
if(p->lchild == NULL){
p->lchild = pre;
p->ltag = 1;
}
if(pre != NULL && pre->rchild == NULL){ //注意这里判断的条件是pre而非p
pre->rchild = p;
pre->rtag = 1;
}
pre = p; //标记当前结点称为刚刚访问过的结点(建立前驱结点的后继线索)
if(p->ltag == 0) //**先序线索化**时需要判断当前左指针是前驱线索还是左孩子!!!!!
PreThread(p->lchild, pre); //递归,线索化左子树
PreThread(p->rchild, pre);
}//if(p!=NULL)
}
有时为了方便,仿照线性表的存储结构,在二叉树的线索链表上也添加一个头结点,并令其lchild域的指针指向二叉树的根结点,令其lchild域的指针指向二叉树的根结点,令其rchild域的指针指向中序遍历时访问的最后一个;反之,令二叉树中序序列中的第一个结点的lchild域的指针和最后一个结点的rchild域的指针均指向头结点。这好比为二叉树建立了一个双线线索链表,既可以从第一个结点起顺后继进行遍历,又可以从最后一个结点起顺前驱进行遍历。
3.线索二叉树的遍历
3.1 中序线索二叉树中找中序后继
中序线索化二叉树主要是为访问运算服务的,这种遍历不需要借助栈从而实现二叉树遍历的非递归算法。
- 求中序线索二叉树中中序序列下的第一个结点
ThreadNode *FirstNode(ThreadNode *p){
while(p->ltag == 0)
p = p->lchild; //最左下结点,不一定是叶子结点
return p;
}
- 求中序线索二叉树中结点p在中序序列下的后继结点
ThreadNode *NextNode(ThreadNode *p){
if(p->rtag == 0)
return FirstNode(p->rchild);
else
return p->rchild; //rtag == 1直接返回后继线索
}
- 不含头结点的中序线索二叉树的中序遍历的算法
void InOrder(ThreadNode *T){
for(ThreadNode *p = FirstNode(T); p != NULL; p = NextNode(p))
visit(p);
}
3.2 中序线索二叉树中找中序前驱
//找到以P为根的子树中,最后一个被中序遍历的结点
ThreadNode * Lastnode(ThreadNode *){
//循环找到最右下结点(不一定是叶结点)
while(p->rtag == 0)
p = p->rchild;
return p;
}
//在中序线索二叉树中找到结点p的前驱结点
ThreadNode * Prenode(ThreadNode *p){
//左子树中最右下结点
if(p->ltag == 0)
return Lastnode(p->lchild);
else
return //ltag == 1直接返回前驱线索
}
//对中序线索二叉树进行逆向中序遍历
void RevInorder(ThreadNode *T){
for(ThreadNode *p = Lastnode(T); p != NULL; p = Prenode(p))
visit(p);
}
【注意】
- 后序遍历退回时访问根结点,从而可以找到某结点到其祖先结点的路径。
- 三种遍历方式中,访问左右子树的先后顺序不变,只是访问根结点的顺序不同,因此叶子结点的先后顺序完全相同。
- 一棵非空二叉树的先序遍历序列与后续遍历序列正好相反,则该二叉树只有一个叶子结点,即每层只有一个结点。
- 前序遍历时需要借助栈。因此前序序列和中序序列的关系相当于以前序序列为入栈次序,以中序序列为出栈次序。
- 二叉树是一种逻辑结构,但线索二叉树明确指出了其存储方式是加上线索后的链表结构,因此线索二叉树是一种存储(物理)结构。
- 二叉树在经过任意一种线索化后,其空链域的个数都为2。
- 后序线索二叉树不能有效解决二叉树后续后继的问题,只能按常规方法来查找。
- 结点的带权路径长度 = 从根结点到该结点之间的路径长度 * 该结点的权值
- 当static关键字用于代码块内部的变量的声明时,用于修改变量的存储类型,即从自动变量修改为静态变量,但变量的链接属性和作用域不受影响。用这种方式声明的变量在程序执行之前创建,并在程序的整个执行期间一直存在,而不是每次在代码块开始执行时创建,在代码块执行完毕后销毁。也就是说,它保持局部变量内容的持久。静态局部变量的生存期虽然为整个源程序,但其作用域仍与局部变量相同,即只能在定义该变量的函数内使用该变量。退出该函数后,尽管该变量还继续存在,但不能使用它。
4.4 树、森林
4.4.1 树的存储结构
1.双亲表示法
#define MAX_TREE_SIZE 100
typedef struct{
ElemType data;
int parent;
}PTNode;
typedef struct{
PTNode nodes[MAX_TREE_SIZE];
int n;
}PTree;
该存储结构利用了每个结点(根结点除外)只有唯一双亲的性质:
- 优点:可以很快得到每个结点的双亲结点。
- 缺点:求结点的孩子结点时需要遍历整个结构。
2.孩子表示法 (顺序+链式存储)
孩子表示法是将n个结点的数据和n个孩子链表的头指针组成一个顺序表,每个结点的孩子结点都用单链表链接起来形成一个线性结构,此时n个结点就有n个孩子链表(叶子结点的孩子链表为空表)。
这种存储方式寻找子女的操作非常直接,而寻找双亲的操作需要遍历n个结点中孩子链表指针域所指向的n个孩子链表。
struct CTNode{
int child; //孩子结点在数组中的位置
struct CTNode * next; //下一个孩子
};
typedef struct{
ElemType data;
struct CTNode * firstChild; //第一个孩子
}CTBox;
typedef struct{
CTBox nodes[MAX_TREE_SIZE];
int n, r; //结点数和根的位置
}CTree;
3.孩子兄弟表示法 (重点)
(左孩子右兄弟)又称二叉树表示法,即以二叉链表作为树的存储结构。
typedef struct CSNode{
ElemType data;
struct CSNode *firstchild, *nextsibling; //第一个孩子和右兄弟
}CSNode, *CStree;
- 优点:可以方便地实现树转换为二叉树的操作,易于查找结点的孩子。
- 缺点:从当前结点查找双亲结点麻烦。可以增设一个parent域指向其父结点从而解决。
4.4.2 树、森林与二叉树的转换
以二叉链表作为媒介可以导出树与二叉树的一个对应关系,即树、森林与二叉树的互相转换是唯一的。
树转换为二叉树:每个结点左指针指向它的第一个孩子结点,右指针指向它在书中的相邻兄弟结点,可表示为“左孩子右兄弟”。由于根结点没有兄弟,所以由树转换而得到的二叉树没有右子树。
森林转换为二叉树:先将森林中的每棵树转换为二叉树,将第n棵树作为第n-1棵树的右子树,直至n = 2.
二叉树转换为森林:若二叉树非空,则二叉树的根及其左子树为第一颗树的二叉树形式,二叉树的根的右子树又可视为一个由除第一棵树外的森林转换后的二叉树,以此类推直至最后产生一棵没有右子树的二叉树为止。
4.4.3 树和森林的遍历
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
1.树的遍历操作
1)先根遍历。若树非空,则先访问根结点,再按从左到右的顺序遍历根结点的每棵子树。其访问顺序与这棵树对应二叉树的先序遍历顺序相同。
2)后根遍历。若树非空,则按从左到右的顺序遍历根结点的每棵子树,之后再访问根结点。其访问顺序与这棵树对应二叉树的中序遍历顺序相同。
3)层次遍历。
2.森林的遍历操作
1)先序遍历森林。若森林非空:
- 访问森林中第一棵树的根结点;
- 先序遍历第一棵树中根结点的子树森林;
- 先序遍历除去第一棵树之后的树构成的森林。
其访问顺序与森林对应二叉树的先序遍历顺序相同。
2)中序遍历森林。若森林非空:
- 中序遍历森林中第一棵树的根结点的子树森林;
- 访问第一棵树的根结点;
- 中序遍历除去第一棵树之后剩余的树构成的森林。
其访问顺序与这棵树对应二叉树的中序遍历顺序相同。
💡森林和树的遍历都可以采用二叉树的遍历算法来实现。
4.4.4 并查集
并查集的应用是检查一个图中是否有环。在图中选出的一条之前未检索的边,如果边的端点已经包含在集合内,则证明图中存在环路。
并查集是一种集合表示,支持以下3中操作:
union(S, Root1, Root2)
:把集合S中的子集合Root2并入子集合Root1。要求Root1和Root2互不相交,否则不执行合并。find(S, x)
:查找集合S中单元素x所在的子集合,并返回该子集合的名字(即子树的根结点)。initial(S)
:将集合S中的每个元素都初始化为只有一个单元素的子集合。
通常用树(森林)的双亲表示法作为并查集的存储结构,每个子集合以一棵树表示。所有表示子集合的树,构成表示全集和的森林,存放在双亲表示数组内。通常用数组元素的下标代表元素名,用根结点的下标代表子集合名,根结点的双亲结点为负数。
1)并查集S的初始化
void initial(int S[], int depth[]){
for(int i = 0; i < size; ++i){
S[i] = -1; //每个元素自成一个单元素子集合,每个子集合的数组值为-1
rank[i] = 0; //每棵树的深度置为0
}
}
2)在并查集S中查找并返回包含元素x的树的根
int findRoot(int S[], int x){
while(S[x] >= 0) //循环寻找x的根
x = s[x];
return x; //根的s[]小于0(具体是当前集合所包含元素的个数的相反数。)
}
压缩路径——Find操作,先找到根结点,再将查找路径上所有结点都挂到根结点下。从而节约此后Find操作的时间。
int Find(int S[], int x){
int root = x;
while(S[root] >= 0)
root = S[root]; //循环找到根
while(x != root){ //压缩路径
int t = S[x]; //t指向x的父结点
S[x] = root; //x直接挂到根结点下
x = t;
}
return root; //返回根结点编号
}
3)求两个不相交子集合的并集
void Union(int S[], int Root1, int Root2){
if(Root1 == Root2)
return;
if(S[Root2] > S[Root1]) { //Root2结点数更少(注意这里是负值)
S[Root1] += S[Root2]; //累加结点总数
S[Root2] = Root1; //小树合并到大树
}
else {
S[Root2] += S[Root1]; //累加结点总数
S[Root1] = Root2; //小树合并到大树
}
}
#define VERTICES 6
int main(){
int S[VERTICES] = {0};
int edges[6][2] = {{0,1}, {1,2}, {1,3},
{2,4}, {3,4}, {2,5}};
int rank[VERTICES] = {0};
int x, y, root1, root2;
initial(S, depth);
for(int i = 0; i < VERTICES; ++i){
x = edges[i][0];
y = edges[i][1];
root1 = findRoot(S, x);
root2 = findRoot(S, y);
if(unionTrees(S, rank, root1, root2) == 0)
{
printf("Cycle detected!\n");
exit(0);
}
}
printf("No cycle found!\n");
return 0;
}
4.5 树与二叉树的应用
4.5.1 二叉排序树
1.二叉排序树的定义
BST,又称二叉查找树。二叉排序数或者是一棵空树,或者是一棵具有下列特性的非空二叉树:
- 若左子树非空,则左子树上所有结点关键字值均小于根结点的关键字值;
- 若右子树非空,则右子树上所有结点关键字值均大于根结点的关键字值;
- 左、右子树本身也分别是一棵二叉排序树。
对二叉排序树进行中序遍历,可以得到一个递增的有序序列。
2.二叉排序树的查找
二叉排序树的查找是从根结点开始,沿某个分支逐层向下进行比较的过程。若二叉排序树非空,则将给定值与根结点的关键字比较,若相等,则查找成功;若不等,则当根结点的关键字值大于给定关键字值时,在根结点的左子树中查找,否则在根结点的右子树中查找。显然,这是一个递归的过程。
二叉排序树的非递归查找如下:
BiTNode * BSTSearch(BiTree T, ElemType key, BiTNode *&p){
//查找函数返回值指向关键字值未key的结点指针,若不存在,返回NULL
p = NULL;
while(T != NULL && key != T->data)
{
p = T;
if(key < T->data)
{
T = T->lchild;
}
else
{
T = T->rchild;
}
}
return T;
}
3.二叉排序树的插入
若原二叉排序树为空,则直接插入结点;否则,若关键字k小于根结点关键字,则插入左子树,若关键字k大于根结点关键字,则插入右子树。
int BSTInsert(BiTree &T, ElemType k){
if(T == NULL)
{
T = (BSTNode *)malloc(sizeof(BSTNode));
T->key = k;
T->lchild = T->rchild = NULL;
return 1;
}
else if(k == T->key)
{
return 0;
}
else if(k < T->key)
{
return BSTInsert(T->lchild, k);
}
else
{
return BSTInsert(T->rchild, k);
}
}
插入的新结点一定是某个叶子结点。
4.二叉排序树的构造
void creatBST(BiTree &T, ElemType str[], int n){
//用关键字数组str[]建立一个二叉排序树
T = NULL;
int i = 0;
while(i < n)
{
BSTInsert(T, str[i++]);
}
}
5.二叉排序树的删除
1)若被删除结点z时叶子结点,则直接删除,不会破坏二叉排序树的性质。
2)若结点z只有一棵左子树或右子树,则让z的子树成为z父结点的子树,替代z的位置。
3)若结点z有左、右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。
6.二叉排序树的查找效率分析
若二叉排序树是一棵单支树(类似于有序的单链表),则其平均查找长度和单链表相同,为O(n)。若二叉排序树左、右子树高度差的绝对值不超过1,则这样的二叉排序树成为平衡二叉树。它的平均查找长度达到O(log2n).
从查找过程看,二叉排序树与二分查找相似。就平均时间性能而言,二叉排序树上的查找和二分查找差不多。但二分查找的判定树唯一,而二叉排序树的查找不唯一,相同关键字其插入顺序不同可能生成不同的二叉排序树。
就维护表的有序性而言,二叉排序树无需移动结点,只需修改指针即可完成插入和删除操作,平均执行时间是O(log2n)。二分查找的对象时有序顺序表,若有插入和删除结点的操作,所花的代价是O(n)。当有序表是静态查找表时,宜用顺序表作为其存储结构,而采用二分查找实现其查找操作;若有序表是动态查找表,则应选择二叉排序树作为其逻辑结构。
4.5.2 平衡二叉树
1.平衡二叉树的定义
定义结点左子树与右子树的高度差为该结点的平衡因子。
AVL,平衡二叉树是一棵空树,或者是具有下列性质的二叉树:它的左子树和右子树都是平衡二叉树,且左子树和右子树的高度差的绝对值不超过1。
2.平衡二叉树的插入
每当在二叉排序树中插入(或删除)一个结点时,首先检查插入路径上的结点是否因为此次操作而导致了不平衡。若导致了不平衡,则先找到插入路径上离插入结点最近的平衡因子的绝对值大于1的结点A,再对以A为根的子树,在保持二叉排序树特性的前提下,调整各结点的位置关系,使之重新达到平衡。
注意每次调整的对象都是最小不平衡子树,即以插入路径上离插入结点最近的平衡因子的绝对值大于1的结点作为根的子树。最小不平衡子树不一定是单支树。
最小不平衡子树调整规则:
-
若新插入的结点是该子树根结点左孩子的左子树,或者是该子树根结点右孩子的右子树,则直接旋转即可;
-
若新插入的结点是该子树根结点左孩子的右子树,先左旋转后右旋转,
-
若新插入的结点是该子树根结点右孩子的左子树,先右旋转后左旋转。
调整后的平衡树与调整前的平衡树中序序列一致。
3.平衡二叉树的删除
-
删除结点(方法同“二叉排序树”);
- 若删除的结点是叶子,直接删。
- 若删除的结点只有一个子树,用子树顶替删除位置。
- 若删除的结点有两棵子树,用前驱(或后继)结点顶替,并转换为对前驱(或后继)结点的删除。
-
顺着被删除结点的祖先结点,找到最小不平衡子树,若找不到则直接结束;
-
找到最小不平衡子树下,高度最高的儿子结点和孙子结点;
-
根据孙子结点的位置,调整平衡
- 孙子在LL:儿子右单旋
- 孙子在RR:儿子左单旋
- 孙子在LR:孙子先左旋,再右旋
- 孙子在RL:孙子先右旋,再左旋
-
如果不平衡向上传导,返回步骤2
- 对最小不平衡子树的旋转可能导致树变矮(高度降低),从而导致上层祖先不平衡(不平衡向上传递)
💡平衡二叉树删除操作时间复杂度为O(log2n)。
4.平衡二叉树的查找
含有n个结点的平衡二叉树的最大深度为O(log2n),因此平衡二叉树的平均查找长度为O(log2n).
4.5.3 哈夫曼树和哈夫曼编码
1.哈夫曼树的定义
从树根结点到任意结点的路径长度(经过的边数)与该结点上权值的乘积,称为该结点的带权路径长度。树中所有叶子结点的带权路径长度之和称为该树的带权路径长度。记为
W
P
L
=
∑
i
=
1
n
w
i
l
i
WPL = \sum_{i=1}^nw_il_i
WPL=i=1∑nwili
在含有n个带权叶子结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。
2.哈夫曼树的构造
对给定n个权值分别为w1, w2, … , wn的结点,通过哈夫曼算法可以构造出最优二叉树:
1)将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F;
2)构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和;
3)从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
4)重复步骤2)和3)直至F中只剩下一棵树为止。
哈夫曼树具有如下特点:
- 每个初始节点最终都称为叶子节点,且权值越小的结点到根结点的路径长度越大;
- 构造过程中共新建了n-1个结点(双分支结点),因此哈夫曼树中的结点总数为2n-1;
- 哈夫曼树中不存在度为1的结点。
3.哈夫曼编码
固定长度编码:若对每个字符用同样长度的二进制位表示,则称这种编码方式为固定长度编码。
可变长度编码:若允许对不同字符用不等长的二进制位表示,则这种方式称为可变长度编码。
前缀编码:若没有一个编码时另一个编码的前缀,则称这样的编码为前缀编码。对前缀编码的解码很简单,因为没有一个码是其他码的前缀。
哈夫曼编码正是这样一种可变长度的前缀编码,被广泛应用于数据压缩。
0和1究竟是表示左子树还是表示右子树并没有明确规定。因此,左、右结点的顺序是任意的,所以构造出的哈夫曼树并不唯一,但各哈夫曼树的带权路径长度相同且为最优。
【题型】
- 关于关键字序列中,不能构成二叉排序树中一条查找路径的是?
- 若从第三个数开始都在这个数的前两个数之间,则构成一条查找路径;
- 若从第三个数开始,要么比前两个数都大,要么比前两个数都小,则构成一条查找路径。
【注意】
-
构造一棵具有n个结点的二叉排序树时,最理想情况下是一棵完全二叉树,而非平衡二叉树。
-
平衡二叉树结点数的递推公式为:
n0 = 0; n1 = 1; n2 = 2;
nh = nh-1 + nh-2 + 1; //左子树 + 右子树 + 根结点
-
调整平衡二叉树时一定调整的是最小不平衡子树。
-
度为m的哈夫曼树应只有度为0和度为m的结点。
-
平衡二叉树不一定左孩子结点<根结点<右孩子结点,只需要按照某种规则使得左右子树的高度差不超过1即可。
-
大根堆是完全二叉树,它要求根结点关键字值既大于等于左孩子关键字值,又大于等于右孩子关键字值。
4.5.4 红黑树
1.红黑树的相关概念
- 红黑树是二叉排序树。
- 与普通BST相比:
- 每个结点或是红色,或是黑色的。
- 根节点是黑色的。
- 叶节点(外部结点、NULL结点、失败结点)均是黑色的。
- 不存在两个相邻的红结点(即红结点的父结点和孩子结点均是黑色的)。
- 对每个结点,从该结点到任一叶结点的简单路径上,所含黑结点数目相同。
- 口诀:左根右,根叶黑,不红红,黑路同。
- 结点的黑高:从某结点出发(不含该结点)到达任一叶结点的路径上黑结点总数。
- 从根结点到叶结点的最长路径不大于最短路径的2倍。
- 有n个内部结点的红黑树高度 h ≤ 2 log 2 ( n + 1 ) h \leq 2\log_2(n+1) h≤2log2(n+1)
struct RBnode{ //红黑树的结点定义
int key; //关键字的值
RBnode * parent; //父结点指针
RBnode * lChild; //左孩子指针
RBnode * rChild; //右孩子指针
int color; //结点颜色,如:可用0/1表示黑/红,也可使用枚举型enum表示颜色
};
2.红黑树的插入
- 先查找,确定插入位置(原理同二叉排序树),插入新结点。
- 新结点是根——染为黑色。
- 新结点非根——染为红色。
- 若插入新结点后依然满足红黑树定义,则插入结束。
- 若插入新结点后不满足红黑树定义,需要调整(如何调整需要看新结点叔叔结点的颜色),使其重新满足红黑树定义:
- 黑叔:旋转+染色。
- 红叔:叔叔结点、父亲结点、爷爷结点变色;将爷爷结点视为新结点,返回步骤2。
思考:根结点黑高为h的红黑树,内部结点数(关键字)至少有多少个?
回答:内部结点数最少的情况——总共h层黑结点的满树形态。
结论:若根结点黑高为h,内部结点数(关键字)最少有2h-1个。
3.红黑树的删除
- 红黑树删除操作的时间复杂度= O ( log 2 n ) O(\log_2n) O(log2n)。
- 在红黑树中删除结点的处理方式和“二叉排序树的删除”一样。
- 按照2删除结点后,可能破坏红黑树特性,此时需要调整结点颜色、位置,使其再次满足“红黑树特性”。