以下内容主要参考青岛大学王卓老师的数据结构与算法基础。https://www.bilibili.com/video/BV1nJ411V7bd?p=76&vd_source=f893624050c1893c80cd53fcdcf62cfe
树
树的定义
树(Tree)是n(n>=0)个结点的有限集。
若 n=0,称为空树;
若n>0,则它满足如下两个条件:
- 有且仅有一个特定的称为根的结点
- 其余结点可分为m (m >= 0)个互不相交的有限集T1,T2,T3,...,Tm,其中每一个集合本身又是一棵树,并称为根的子树。
树的基本术语
结点的度:一个结点含有子树的个数称为该结点的度
树的度:一棵树中,所有结点度的最大值称为树的度
树的深度:一棵树中结点的最大深度就是树的深度,也称为高度
父结点:若一个结点含有子结点,则这个结点称为其子结点的父结点
子结点:一个结点含有的子树的根结点称为该结点的子结点
结点的层次:从根结点开始,根结点为第一层,根的子结点为第二层,以此类推
兄弟结点:拥有共同父结点的结点互称为兄弟结点
叶子结点:度为零的结点就是叶子结点
祖先:从根到该结点所经分支上的所有结点;
子孙:以某结点为根的子树中任一结点都称为该结点的子孙。
森林:m颗互不相交的树构成的集合就是森林
二叉树
二叉树的定义
二叉树是结点的一个有限集合,该集合为空,或者是由一个根结点加上两棵称为左子树和右子树的二叉树组成。
特点:
- 每个结点最多有两棵子树,即二叉树不存在度大于2的结点。
- 子树有左右之分,其子树的次序不能颠倒。
- 二叉树可以是空集合,根可以有空的左子树或空的右子树
二叉树结点的子树要区分 左子树和右子树,即使一棵树也要进行区分,说明它是左子树还是右子树
树当结点只有一个孩子时,就无需区分它是左还是右的次序。
特殊形式的二叉树
满二叉树:如果二叉树中除了叶子结点,每个结点的度都为 2,则此二叉树称为满二叉树。
特点:
- 每一层上的结点数都是最大结点数(每层都满)
- 叶子结点全部在最底层
完全二叉树:如果二叉树中除去最后一层结点为满二叉树,且最后一层的结点依次从左到右分布,则此二叉树被称为完全二叉树。
特点:
- 叶子只能分布在层次最大的两层上
- 对任一结点,如果其右子树的最大层次为i,则其左子树的最大层次必须为 i 或 i+1。
二叉树的性质
二叉树的顺序存储
实现:按满二叉树的结点层次编号,依次存放二叉树中的数据元素。
#define MAXSIZE 100
Typedef TElem Type SqBiTree[MAXSIZE]
SqBiTree bt;
特点:
- 结点间关系蕴含在其存储位置中
- 浪费空间,适用于满二叉树和完全二叉树。
二叉树的链式存储
Typedef struct BiNode{
TElemType data;
struct BiNode *lchild,*rchild; //左右孩子指针
}BiNode, *BiTree;
二叉链表
在n个结点的二叉链表中,有 n+1 个空指针域
分析:必有 2n 个链域,除根结点外,每个结点有且仅有一个双亲,所以只会有 n-1 个结点的链域存放指针,指向非空子结点。
空指针数目= 2n - ( n-1 ) = n + 1
遍历二叉树
二叉树的遍历是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。
3种遍历方式:先序遍历、中序遍历、后序遍历
先序遍历:ABDHECFIG 中序遍历:HDBEAIFCG 后序遍历:HDEBIFGCA
注:由二叉树的先序序列和中序序列或中序序列和后序序列可以确定唯一一颗二叉树(必须有中序序列,才能唯一确定二叉树)
已知先序序列和后序序列,无法确定唯一二叉树
算法实现:(递归遍历)
//先序遍历
Status ProOrderTraverse(BiTree T){
if( T == NULL)
return OK; //空二叉树
else{
visit(T); //访问根结点
ProOrderTraverse(T->lchild); //递归遍历左子树
ProOrderTraverse(T->rchild); //递归遍历右子树
}
}
//中序遍历
Status ProOrderTraverse(BiTree T){
if( T == NULL)
return OK; //空二叉树
else{
ProOrderTraverse(T->lchild); //递归遍历左子树
visit(T); //访问根结点
ProOrderTraverse(T->rchild); //递归遍历右子树
}
}
//后序遍历
Status ProOrderTraverse(BiTree T){
if( T == NULL)
return OK; //空二叉树
else{
ProOrderTraverse(T->lchild); //递归遍历左子树
ProOrderTraverse(T->rchild); //递归遍历右子树
visit(T); //访问根结点
}
}
递归遍历的详细过程(一定要看懂里面的套娃逻辑)
三种算法的访问路径是相同的,只是访问的时机不同。
中序遍历非递归算法
二叉树中序遍历的非递归算法的关键:在中序遍历过某结点的整个左子树后,如何找到该结点的根以及右子树。
基本思想:
- 建立一个栈
- 根结点进栈,遍历左子树
- 根结点出栈,输出根结点,遍历右子树
算法实现:(非递归遍历)
Status InOrderTraverse(BiTree T){
ViTree p; InitStack(S);
p = T;
while( p || !StackEmpty(S)){
if(p){
Push(S,p);
p = p -> lchild;
}
else{
Pop(S,q);
printf("%c", q -> data);
p = q -> rchild;
}
}
return OK;
}
二叉树的层次遍历
层次遍历:对于一棵二叉树,从根结点开始,按从上到下、从左到右的顺序访问每一个结点。
算法设计思路: 使用一个队列
i . 将根结点进队;
ii. 队不空时循环:从队列中出列一个结点 *p,访问它;
- 若他又左孩子结点,将左孩子结点进队;
- 若它有右孩子结点,将右孩子结点进队。
算法实现:
使用队列类型定义如下:
typedef struct{
BTNode data[MAXSIZE]; //存放队列中的元素
int front, rear; //队头和队尾指针
}SqQueue; //顺序循环队列类型
二叉树层次遍历算法:
void LevelOrder(BTNode *b){
BTNode *p; SqQueue *qu;
InitQueue(qu); //初始化队列
enQueue(qu, b); //根结点指针进入队列
while( !QueueEmpty(qu)) //队不为空,则循环
{
deQueue(qu , p); //出队结点p
printf("%c", p->data); //访问结点p
if( p->lchild != NULL)
enQueue(qu, p->lchild); //有左孩子时将其入队
if( p->rchild != NULL)
enQueue(qu,p->rchild); //有右孩子时将其入队
}
}
二叉树遍历算法的应用
建立二叉树
Status CreateBiTree(BiTree &T){
scanf(&ch);
if( ch == "#" ) T = NULL;
else{
if(!(T = (BiNode *)malloc(sizeof(BiTNode))))
exit(OVERFLOW); //T = new BiTNode;
T -> data = ch; //生成根结点
CreateBiTree(T -> lchild); //构造左子树
CreateBiTree(T -> rchild); //构造右子树
}
return OK;
}
复制二叉树
如果是空树,递归结束
否则,申请新结点空间,复制根结点
- 递归复制左子树
- 递归复制右子树
int Copy( BiTree T, BiTree &New T)
{
if( T ==NULL ){
NewT = NULL;
return 0;
}
else{
NewT = new BiTNode;
NewT -> data = T ->data;
Copy( T ->lChild, NewT -> lchild);
Copy( T ->rChild, NewT -> rchild);
}
}
计算二叉树的深度
如果是空树,则深度为0;
否则,递归计算左子树的深度记为 m,递归计算右子树的深度记为 n,二叉树的深度则为m与n的较大者加1。
int Depth( BiTree T)
{
if( T == NULL)
return 0;
else{
m = Depth(T->lChild);
n = Depth(T->rChild);
if( m > n)
return( m+1 );
else
return ( n+1 );
}
}
计算二叉树结点总数
如果是空树,则结点个数为0;
否则,节点个数为左子树结点个数 + 右子树结点个数 +1
int NodeCount(BiTree T)
{
if(T == NULL)
return 0;
else
return NodeCount(T->lchild) + NodeCount(T->rchild) + 1;
}
线索二叉树
Why要研究线索二叉树???
当用二叉链表作为为叉树的存储结构时,可以很方便地找到某个结点的左右孩子;但一般情况下,无法直接找到该结点在某种遍历序列中的前驱和后继结点。
How 寻找特定遍历序列中二叉树结点的前驱和后继???
- 通过遍历查找------ 浪费时间
- 再增设前驱和后继指针域------ 增加存储(空间)负担
回顾上文,有 “在n个结点的二叉链表中,有 n+1 个空指针域” 结论。
利用二叉链表中的空指针域:
- 如果某结点的左孩子为空,则将空的左孩子指针域改成指向其前驱,
- 如果某结点的右孩子为空,则将空的左孩子指针域改成指向其后继。
------这种改变指向的指针称为“线索”。加上了线索的二叉树被称为线索二叉树。
对二叉树按照某种遍历次序使其变为线索二叉树的过程叫线索化。
为区分 lchild 和 lrchild 指针到底是指向孩子的指针,还是指向前驱或者后继的指针,对二叉链表中每个结点增设两个标志域 ltag 和 rtag ,并约定:
ltag = 0 lchild指向该结点的左孩子
ltag = 1 lchild指向该结点的前驱
rtag = 0 rchild指向该结点的右孩子
rtag = 1 rchild指向该结点的后继
这样,结点的结构为:
typedef struct BiThrNode{
int data;
int ltag, rtag;
struct BiThrNode *lchild, rchild;
}BiThrNode, *BiThrTree;
为了不让第一个结点的前驱和最后一个结点的后继指针悬空,增设一个头节点:
ltag = 0, lchild指向根节点
rtag = 1,rchild指向遍历序列中最后一个结点
遍历序列中第一个结点的 lc 域和最后一个结点的 rc 域都指向头节点。
树和森林
森林:是 m(m>=0)棵互不相交的树的集合。
树的存储结构
i. 双亲表示法
类型描述:
typedef struct PTNode{
TElemType data;
int parent; //双亲位置域
}PTNode;
树的结构
#define MAX_TREE_SIZE 100
typedef struct{
PTNode nodes[MAX_TREE_SIZE];
int r , n; //根节点的位置和结点个数
}PTree;
ii. 孩子链表
代码实现:
//孩子结点
typedef struct CTNode{
int child;
struct CTNode *next;
}*ChildPtr;
//双亲结点
typedef struct {
TelemType data;
ChildPtr firstchild; //孩子链表头指针
}CTBox;
//树结构
typedef struct {
CTBox nodes[MAX_TREE_SIZE];
int n , r; //结点数和根节点的位置
}CTree;
iii. 带双亲的孩子链表
vi. 孩子兄弟表示法(二叉树表示法、二叉链表表示法)
实现:用二叉链表作树的存储结构,链表中每个结点的两个指针域分别指向其第一个孩子结点和下一个孩子兄弟结点。
typedef struct CSNode{
ElemType data;
struct CSNode *firstchild, *nextsibling;
}CSNode, *CSTree;
树与二叉树的装化
将树转化为二叉树进行处理,利用二叉树的算法实现对树的操作。
由于树和二叉树都可以用二叉链表作存储结构,则以二叉链表作为媒介可以到处树与二叉树之间的一个对应关系。
i. 将树转换成二叉树
步骤:
(1) 旋转:在兄弟之间加一条线
(2) 抹线:对每个结点,除了其左孩子外,去除其与其余孩子之间的关系
(3) 旋转:以树的根节点为轴心,将整树顺时针旋转45°
树变二叉树:兄弟相连留长子
ii. 二叉树转换成树
步骤:
(1) 加线。若p结点是双亲结点的左孩子,则将p的右孩子、右孩子的右孩子......沿分支找 到所有右孩子,都与p的双亲用线连起来。
(2) 去线。抹掉原二叉树中双亲与其右孩子之间的连线。
(3) 调整。将节点按层次排列,形成树结构。
左孩右右连双亲,去掉原来右孩线
森林与二叉树的转化
i. 森林转换成二叉树
步骤:
(1) 将各棵树转换为二叉树
(2)将每棵树的根节点用线相连
(3) 以第一棵树的根结点作为二叉树的根,再以根节点为轴心,顺时针旋转,构成二叉树型结构。
树变二叉根相连
ii. 二叉树转换成森林
步骤:
(1) 抹线:将二叉树中根节点与其右孩子连线,及沿右分支搜索到的所有右孩子间连线全部抹掉,使之变成孤立的二叉树。
(2) 还原:将孤立的二叉树还原成树。
去掉全部右孩线,孤立二叉再还原
树和森林的遍历
i. 树的遍历
树的遍历也叫树的搜索,是指按照某种规则对树的节点进行一遍不重复的访问。按照不同的方式可以分为树的前序遍历、后序遍历和层序遍历。
先序遍历:若树不空,则先访问根节点,然后依次先根遍历各棵子树。
后序遍历:若树不空,则先依次后根遍历各棵子树,然后访问根节点。
层次遍历:若树不空,则自上而下、自左而右访问树中每个结点。
注:树的遍历没有中序遍历,二叉树才有
ii. 森林的遍历
将森林看作由三部分组成: (1) 森林中第一棵树的根节点。 (2) 森林中第一棵树的子树森 林。(3) 森林中其他树构成的森林。
先序遍历:
如果森林非空,则
(1) 访问森林中第一棵树的根节点;
(2) 先序遍历森林中第一棵树的子树森林
(3) 先序遍历森林中(第一棵树除外)其余树构成的森林。
即:依次从左到右对森林中的每一棵树进行先序遍历
中序遍历:
如果森林非空,则
(1) 中序遍历森林中第一棵树的子树森林;
(2) 访问森林中第一棵树的根节点;
(3) 中序遍历森林中(第一棵树除外)其余树构成的森林。
即:依次从左到右对森林中的每一棵树进行后序遍历
哈夫曼树的概念
路径:从树中一个结点到另一个结点之间的分支构成这两个结点间的路径。
结点的路径长度: 两结点间路径上的分支数。
树的路径长度:从树根到每一个结点的路径长度之和。记作:TL
结点数目相同的二叉树中,完全二叉树是路径长度最短的二叉树。
权:将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。
结点的带权路径长度:从根结点到该结点之间的路径长度与该结点的权的乘积。
树的带权路径长度:树中所有叶子结点的带权路径长度之和。
哈夫曼树:带权路径长度最短的二叉树,也被称为最优二叉树。
构造哈夫曼树
构建思路:
(1) 初始状态下共有n个结点,结点的权值分别是给定的n个数,将他们视作n棵只有根结点的树。
(2) 合并其中根结点权值最小的两棵树,生成这两棵树的父结点,权值为这两个根结点的权值之和,这样树的数量就减少了一个。
(3) 重复操作2,直到只剩下一棵树为止,这棵树就是哈夫曼树。
构造森林全是跟;选用两小造新树;删除两小添新人;重复2、3剩单根
包含 n 个叶子节点的哈夫曼树中共有 2n-1 个结点。(n 个结点合并 n-1 次,产生n-1 个结点,因此一共有 n + ( n - 1) = 2n - 1个结点)
哈夫曼树的结点的度数为 0 或 2,没有度为 1 的结点。
算法实现:
步骤:
(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]中选两个未被选过的weight最小的两个结点HT[s1]和HT[s2],s1、s2为两个最小结点的下标;
b.修改HT[s1]和HT[s2]的parent的值:HT[s1].parent = i; HT[s2].parent = i;
c.修改新产生的HT[i]:
HT[i].weight = HT[s1].weight + HT[s2].weight;
HT[i].lch =s1;
HT[i].rch = s2;
void CreatHuffmanTree( CreatHuffmanTree HT,int n)
{
if( n <=1 ) return;
m = 2 * n - 1; //数组共2n-1个元素
HT = new HTNode[m+1]; //0号单位未用,HT[m]表示根结点
for(i = 1 ; i <= m; ++i){ // 将2n-1 个元素的 lch、rch、parent 置0
HT[i].lch = 0;
HT[i].rch = 0;
HT[i].parent = 0;
}
for(i = 1; i <= n; ++i)
cin >> HT[i].weight; //输入前n个元素的weight值
//初始化结束,下面开始建立哈夫曼树
for(i = n+1; i <= m; i++) //合并产生n-1 个结点---构造哈尔曼树
{
Select(HT,i-1,s1,s2); //在HT[k](1<=k<=i-1)中选择两个双亲域为0
//且权值最小的结点,并返回他们在HT中序号s1和s2
HT[s1].parent = i;
HT[s2].parent = i; //表示从F中删除s1,s2
HT[i].lch = s1;
HT[i].rch = s2; //s1,s2分别作为i 的左右孩子
HT[i].weight = HT[s1].weight + HT[s2].weight; //i的权重为左右孩子权重之和
}
}