1、树和二叉树的定义
\qquad
树型结构是一种非线性的数据结构,节点之间是一种一对多的关系,节点之间有分枝,节点之间具有层次关系。
\qquad
树(Tree) 的定义:树是n(
n
≥
0
n\geq0
n≥0)个结点的有限集合。若
n
=
0
n=0
n=0,则称之为空树;若
n
>
0
n>0
n>0,并且有且仅有一个特定的节点,称之为根(Root) 结点;其余结点可以分为m
(
m
≥
0
)
(m\geq0)
(m≥0)个互不相交的有限集
T
1
,
T
2
,
.
.
.
,
T
m
T_1,T_2,...,T_m
T1,T2,...,Tm,其中每一个子集本文又是一个树,称之为根的子树(SubTree)。
1.1 树的基本术语
\qquad
节点的度:结点拥有的子树的个数。树的度: 树内各结点的度的最大值。叶子节点 :度为0的结点,也叫终端结点。分枝节点 :度不为0的节点叫做分枝节点,根节点以外的分枝节点称为内部结点。结点的子树的根称为该结点点的孩子,该结点称为孩子的双亲。结点的祖先:从根节点到该结点所经分枝上的所有的结点;节点的子孙:以某节点为根的子树中的任意一个结点。
\qquad
树的深度为树中结点的最大层数。
\qquad
树中结点的各个子树从左至右是有次序的(最左边是第一个孩子),则称之为有序树;树中节点的各个子树之间是没有次序的,则称之为无序树。
\qquad
森林是m(
m
≥
0
m\geq0
m≥0)棵互不相交的树的集合。如果把某一棵树的根节点删除,那这个数就变成了森林。一棵树也可以看成一个特殊的森林。给森林中的各个子树添加一个双亲节点,则森林就可以变成一棵树。
1.2 树型结构和线性结构的比较
1.3 二叉树的定义
\qquad
普通树(多叉树)若不转化为二叉树,则运算很难实现;二叉树的结构最简单,规律性很强;可以证明,所有树都能转化成为二叉树,二叉树在应用中起着非常重要的作用,因为对二叉的许多操作算法简单,而任何树都可以与二叉树进行转化,这样就解决了树的存储结构及其运算中存在的复杂性。
\qquad
二叉树是n
(
n
≥
0
)
(n\geq0)
(n≥0)个结点的有限集,它或者是空集,或者由一个根节点以及两棵互不相交的分别称作这个根的左子树和右子树的二叉树组成。
1.4 二叉树的特点
\qquad
二叉树每个结点最多有两个孩子,二叉树中不存在度大于2的节点;
\qquad
子树有左右之分,其次序不能颠倒;
\qquad
二叉树可以是空集,根可以有空的左子树或者空的右子树
\qquad
二叉树不是树的特殊情况,他们是两个概念。二叉树和树的主要区别是二叉树是有次序的,二叉树节点的子树要区分左子树和右子树,即使只有一棵子树也要进行区分,说明它是左子树还是右子树;当树的结点只有一个孩子时,就无需区分它是左还是右的次序。
\qquad
二叉树的5种基本形态:
1.5 二叉树的抽象类型定义
1.6 二叉树的性质
\qquad
性质1: 在二叉树的第i层上最多有
2
i
−
1
(
i
≥
1
)
2^{i-1}(i\geq1)
2i−1(i≥1)个节点。
\qquad
性质2: 深度为k的二叉树至多有
2
k
−
1
(
k
≥
1
)
2^k-1(k\geq1)
2k−1(k≥1)个结点,最少有k个结点。
\qquad
性质3: 对任意一颗二叉树
T
T
T,如果其叶子数为
n
0
n_0
n0,度为2的节点数为
n
2
n_2
n2,则
n
0
=
n
2
+
1
n_0=n_2+1
n0=n2+1,即叶子节点的数目是度为2的结点的数量再加1。
满二叉树: 一颗深度为k的二叉树,若其结点数量为
2
k
−
1
2^k-1
2k−1个,则称其为满二叉树。
满二叉树的特点: 每一层的结点数都是最大结点数,即每层都满;叶子结点全部在最底部;满二叉树在同样深度的二叉树中结点个数最多;满二叉树在同样深度的二叉树中叶子结点个数最多。
完全二叉树: 深度为k的具有n个结点的二叉树,当且仅当其每一个结点都与深度为k的满二叉树中编号为1~n的结点一一对应时,称之为完全二叉树。
完全二叉树性质: 满二叉树一定是完全二叉树。 在 满二叉树中,从最后一个结点开始,连续去掉人一个结点,得到的是一颗完全二叉树。完全二叉树的叶子结点只可能分布在层次最大的两层上;对任意一个结点,如果其右子树的最大层次为i,则其左子树的最大层次必定为i或者i+1。
\qquad
性质4: 具有n个结点的完全二叉树的深度为
⌊
l
o
g
2
n
⌋
+
1
\lfloor log_2n \rfloor+1
⌊log2n⌋+1。
\qquad
性质5: 如果对一颗有n个结点的完全二叉树,对其进行从上到下,从左到右的编号,则对于任意一个结点有:
\qquad
如果
i
=
1
i=1
i=1,则结点i是二叉树的根,无双亲;如果
i
>
1
i>1
i>1,则其双亲节点时
⌊
i
/
2
⌋
\lfloor i/2 \rfloor
⌊i/2⌋
\qquad
如果
2
i
>
n
2i>n
2i>n,则结点i为叶子结点,无左孩子;否则,其左孩子是节点
2
i
2i
2i
\qquad
如果
2
i
+
1
>
n
2i+1>n
2i+1>n,则结点i为叶子结点,无右孩子;否则,其右孩子结点是
2
i
+
1
2i+1
2i+1
2 、二叉树的存储结构
2.1 二叉树的顺序存储
\qquad 按照满二叉树的结点层次进行编号,依次存放二叉树中的数据元素。
#define MAXTSIZE 100
typedef TElemType SqBiTree[MAXTSIZE];
SqBiTree bt;
\qquad
二叉树顺序存储的缺陷:顺序存储的可用空间固定;对于右单支树而言,顺序存储会造成较多的存储空间的浪费,存储密度过低的问题。结点之间的关系蕴含在其存储位置之中,浪费空间,适于存满二叉树和完全二叉树。
2.2 二叉树的链式存储结构
2.2.1 二叉链表存储结构
typedef struct BiNode
{
TElemType data;
struct BiNode *lChild, *rChild;
}*BiTree;
\qquad
在n个结点的二叉链表中,有
n
+
1
n+1
n+1个空指针域。因为在有n个节点的二叉链表中,必定有
2
n
2n
2n个链域。另外,除根节点以外,每个结点有且仅有一个双亲,所以只会有
n
−
1
n-1
n−1个结点的链域存放指针,指向非空的子女结点。所以空指针域的数量可以计算为:
2
n
−
(
n
−
1
)
=
n
+
1
2n-(n-1)=n+1
2n−(n−1)=n+1个。
2.2.2 三叉链表存储结构
typedef struct TriTNode
{
TElemType data;
struct TriTNode *lChild, *parent, *rChild;
}*TriTree;
3、遍历二叉树和线索二叉树
3.1 二叉树的遍历
\qquad
遍历定义: 顺着某一条搜索路径寻访二叉树中的结点,使得每个结点均被访问一次,而且仅被访问一次,又称为周游。这种访问不破坏原来的数据结构。
\qquad
遍历目的: 得到树中所有结点的一个线性排列;
\qquad
遍历用途: 它是树结构插入,删除,修改,查找和排序运算的前提,是二叉树一切运算的基础和核心。
\qquad
遍历方法: 前序遍历(根-左子树-右子树);中序遍历(左子树-根-右子树);后序遍历(左子树-右子树-根) 。
根据遍历序列确定二叉树: 若二叉树中各个结点的值均不相同,则二叉树结点的前序遍历,中序遍历和后序遍历序列都是唯一的。由二叉树的前序和中序序列,或者由二叉树的后序和中序序列可以确定唯一一颗二叉树。
通过前序和中序遍历确定二叉树的示意图如下所示:
通过后序和中序遍历确定二叉树的示意图如下所示:
前序遍历实现:
void PreOrderTraverse(BiTree T)
{
if (BiTree == nullptr) return;
visit(T); //访问根节点
PreOrderTraverse(T->lChild);//递归遍历左子树
PreOrderTraverse(T->rChild);//递归遍历右子树
}
\qquad
先序遍历递归调用的过程如下图所示:
中序遍历实现:
void InOrderTraverse(BiTree T)
{
if (BiTree == nullptr) return;
InOrderTraverse(T->lChild);//递归遍历左子树
visit(T); //访问根节点
InOrderTraverse(T->rChild);//递归遍历右子树
}
后序遍历实现:
void PostOrderTraverse(BiTree T)
{
if (BiTree == nullptr) return;
PostOrderTraverse(T->lChild);//递归遍历左子树
visit(T); //访问根节点
PostOrderTraverse(T->rChild);//递归遍历右子树
}
\qquad
从上述实现代码可知,三种遍历算法如果去掉visit(T)
语句,三种算法时完全相同的,这三种算法的访问路径是相同的,只是访问的时机不同,二叉树中每个结点访问的次数都是3次。
\qquad
当第1次经过结点时访问=前序遍历;第2次经过时访问=中序遍历;第3次经过时访问=后序遍历。
\qquad
递归算法的时间效率:
O
(
n
)
O(n)
O(n),每个节点只访问一次;空间效率:
O
(
n
)
O(n)
O(n),因为最坏情况下栈占用的最大辅助空间是n个。
3.2 中序遍历的非递归算法
\qquad
二叉树中序遍历非递归算法的关键之处在于,在中序遍历过某个节点的整个左子树之后,怎样找到该节点的根以及右子树。
基本思想:
\qquad
建立一个栈;根结点进栈,遍历左子树;根结点出栈,输出根结点,之后遍历右子树。
void InOrderTraverse(BiTree root)
{
BiTree p = root;
stack<BiTree> s;//构建一个栈
while(p || !s.empty())
{
if(p)
{
s.push(p); //根结点入栈
p = p->lChild;//指向左子树
}
else
{
BiTree t = s.top();
cout << t->data <<endl; //打印根结点
p = t->rChild; //指向右子树
s.pop(); //根结点出栈
}
}
}
3.3 二叉树的层次遍历算法
\qquad
对于一颗二叉树,从根结点开始,从上到下,从左到右的顺序访问每一个结点。每一个结点仅仅访问一次。
算法思路:
\qquad
使用一个队列;将根节点进队;队不空时进行循环,从队列中出队一个结点*p,访问节点p;若p有左,右孩子结点,将左孩子结点和右孩子结点进队。
void LevelOrder(BTree root)
{
BTree p = root;
queue<BTree> que;
que.push(p);
while(!que.empty()) //队不空时进行循环
{
BTree t = que.front();
cout << t->data << endl; //访问根节点
if(t->lChild) que.push(t->lChild);//有左孩子时将左孩子入队
if(t->rChild) que.push(t->rChild);//有右孩子时将右孩子入队
}
}
\qquad 还可以用两个栈结构来模仿队列实现二叉树的层序遍历,代码如下所示:
void LevelOrder(BTree root)
{
BTree p = root;
stack<BTree> s1, s2;
s1.push(root);
while(!s1.empty() || !s2.empty())
{
while(!s1.empty())
{
BTree t = s1.top();
s2.push(t);
s1.pop();
}
while(!s2.empty())
{
BTree t = s2.top();
s1.push(t->lChild);
s1.push(t->rChild);
cout << t->data<<endl;
s2.pop();
}
}
}
3.4 二叉树遍历算法的应用
\qquad 当只有一个二叉树的前序遍历序列时,二叉树是不唯一的,所以需要在前序遍历序列中添加空结点的值,如abc##de#g##f###,这样表示出来的二叉树就是唯一的。这样可以根据带空值的二叉树前序遍历序列来构建二叉树:
void CreatBiTree(BTree root)
{
char ch = '';
cin >> ch;
if(ch == '#') root = nullptr;
else
{
root = new BTree; //新建一个结点
root->data = ch;
CreatBiTree(root->lChild);//构建左子树
CreatBiTree(root->rChild);//构建右子树
}
}
复制二叉树:
\qquad
如果是空树,则递归结束;否则,申请新的结点空间,复制根结点,之后递归复制左子树,之后递归复制右子树。
void Copy(BiTree root, BiTree newT)
{
if(root == nullptr)
{
newT = nullptr;
return;
}
else
{
newT = new BiTree;
newT->data = root->data;
Copy(root->lChild, newT->lChild);
Copy(root->rChild, newT->rChild);
}
}
计算二叉树的深度
\qquad
如果为空树,则深度为0;否则,递归计算左子树的深度记为m,递归计算右子树的深度记为n,二叉树的深度则为m与n的较大值再加1。
int m = 0, n = 0;//分别记录左右子树的深度
int Depth(BiTree root)
{
if(root == nullptr) return 0;
else
{
m = Depth(root->lChild);
n = Depth(root->rChild);
if(m > n) return m + 1;
else return n + 1;
}
}
计算二叉树中结点的总数
\qquad
如果是空树,则结点个数为0;否则,结点个数为左子树的结点个数+右子树的结点个数再加1。
int NodeCount(BiTree root)
{
if(root == nullptr)
return 0;
else
return NodeCount(root->lChild) + NodeCount(root->rChild) + 1;
}
计算二叉树中叶子结点的总数
int LeafCount(BiTree root)
{
if(root==nullptr) return 0;
else if(root->lChild == nullptr && root->rChild == nullptr) return 1;
else return leafCount(root->lChild) + leafCount(root->rChild);
}
3.5 线索二叉树
\qquad
利用二叉链表中的空指针域,如果某个结点的左孩子为空,则将空的左孩子的指针域改为指向其前驱;如果某个结点的右孩子为空,则将空的右孩子指针域改为指向其后继,这种改变指向的指针称为线索。加上了线索的二叉树称为线索二叉树(Threaded Binary Tree)。对二叉树按照某种遍历次序使其变为线索二叉树的过程称为线索化。
\qquad
为了区分左指针和右指针到底是指向孩子的指针还是指向前驱/后继的指针,对二叉链表的每个结点增设两个标志域ltag和rtag,并规定,ltag=0时,左指针指向左孩子;ltag=1时,左指针指向前驱;右标志域同样进行这样的规定。
typedef struct BiThrNode
{//线索二叉树的结点结构
int data; //数据域
int ltag, rtag; //标志域
struct BiThrNode *lChild, *rChild;//指针域
}*BiTheTree;
增设了头结点的线索二叉树如下所示:
4、树和森林
\qquad 森林:是m(m ≥ 0 \geq0 ≥0)棵互不相交的树的集合。
4.1 树的存储结构
\qquad
双亲表示法:定义结构数组,存放树的结点,每个结点含有两个域:数据域:存放结点本身信息;双亲域:指示本结点的双亲结点在数组中的位置。双亲表示法的示意图如下图所示:
\qquad
双亲表示法的特点是:找某个子结点的双亲容易,但是找某个根结点的孩子比较难,需要遍历整个数组。双亲表示法的数据结构表示如下所示:
typedef strruct PTNode
{//结点结构
TElemType data;
int parent; //双亲位置域
}PTNode;
#define MAX_TREE_SIZE 100
typedef struct
{//树结构
PTNode nodes[MAX_TREE_SIZE ];
int r, n; //根结点的位置和节点的个数
}PTree;
\qquad
孩子链表法:把每个结点的孩子结点排列起来,看成是一个线性表,用单链表存储,则n个节点有n个孩子链表(叶子结点的孩子链表为空表)。而n个头指针又组成一个线性表,用顺序表(含有n个元素的结构数组)存储。孩子链表法的示意图如下所示:
\qquad
孩子链表法的数据结构表示如下所示:
typedef struct CTNode
{//孩子结点结构
int child; //孩子结点所在的下标
struct CTNode *next;
}*ChildPtr;
typedef struct
{//双亲结点结构
TElemType data;
ChildPtr firstChild;//孩子链表头指针
}CTBox;
#define MAX_TREE_SIZE 100
typedef struct
{
CTBox nodes[MAX_TREE_SIZE];
int n, r;//结点数量和根结点的位置
};
\qquad
孩子链表法找孩子容易,但是孩子找双亲比较难,所以可以给双亲结点结构中添加一个双亲的下标记录,这样结合双亲表示法和孩子链表法形成:带双亲的孩子链表,找双亲和找孩子都比较容易,示意图如下所示:
\qquad
孩子兄弟表示法:也叫二叉树表示法或者二叉链表表示法。这种方法使用二叉链表作为树的存储结构,链表中的每个结点的两个指针域分别指向其第一个孩子结点和下一个兄弟结点。
typedef struct CSNode
{
ElemType data;
struct CSNode *firstChild, *nextSilbing;
}* CSTree;
\qquad
孩子兄弟表示法的示意图如下所示:
\qquad
这种表示方法找孩子和兄弟比较容易,找双亲比较困难。
4.2 森林与二叉树
\qquad
将树转化为二叉树进行处理,利用二叉树的算法来实现对树的操作。由于树和二叉树都可以用二叉链表作为存储结构,则以二叉链表作为媒介可以导出树与二叉树之间的一个对应关系。
\qquad
利用存储结构作为媒介,可以找到二叉树和树之间的对应关系,如下图所示:
\qquad
从上图可以发现规律:一棵树某个结点的兄弟节点转化成二叉树之后变成这个节点的右孩子结点;而一棵树某个结点的第一个孩子结点转化成二叉树中的左孩子结点。
\qquad
将树转化成二叉树的方法:首先加线:在兄弟之间加一条线;之后抹线:对每个结点,除了其左孩子之外,去除其与其余孩子之间的关系;最后旋转:以树的根结点为轴心,将整棵树顺时针旋转45°。将树转化为二叉树的示意图如下图所示:
\qquad
将二叉树转化为树的方法:首先加线:若p结点是双亲结点的左孩子,则将p的右孩子,右孩子的右孩子… …沿着分支找到所有的右孩子,都与p的双亲用线连起来;之后抹线:抹掉原二叉树中双亲与右孩子之间的连线;最后调整:将结点按层次排序,形成树结构。二叉树转化成树的示意图如下所示:
\qquad
将森林转化为二叉树的方法(二叉树与多棵树之间的关系):首先将各棵树分别转化成二叉树;之后将每棵树的根结点用线相连;之后以第一棵树的根结点为二叉树的根,再以根结点为轴心,顺时针旋转,构成二叉树的树型结构。森林转化为二叉树的示意图如下图所示:
\qquad
将二叉树转化为森林的方法:首先抹线,将二叉树中根结点与其右孩子连线,及沿右分枝搜索到的所有右孩子之间的连线全部抹掉,使之变成孤立的二叉树;之后还原,将孤立的二叉树还原成树。二叉树转化为森林的示意图如下图所示:
4.3 树的遍历(三种方式)
\qquad
先根遍历:若树不空,则先访问根结点,然后依次先根遍历各个子树;后根遍历:若树不空,则先依次后根遍历各棵子树,然后访问根结点;层次遍历:若树不空,则自上而下,自左至右访问树中的每个结点。树的遍历示意图如下图所示:
4.4 森林的遍历
\qquad
首先将森林看成由三部分组成,第一部分是第一棵树的根结点;第二部分是第一棵树的子树森林;第三部分是其他树构成的森林。这样先序遍历的遍历顺序为:第一部分,第二部分,第三部分,即一次从左至右对森林中的每一棵树进行先根遍历。中序遍历的顺序为:第二部分,第一部分,第三部分,即依次从左至右对森林中的每一棵树进行后根遍历。森林遍历的示意图如下所示:
5、哈夫曼树
\qquad
首先通过一个引入案例来了解引入哈夫曼树:如下图所示的两个不同的判别树,不同树在相同输入情况下的计算次数有很大差距,怎样构造一个判别树使得判断的次数最少,效率最高,这就是构造哈夫曼树的过程,哈夫曼树也叫最优二叉树。
5.1 哈夫曼树的基本概念
\qquad
路径:从树中的一个结点到另一个结点之间的分支构成这两个结点之间的路径。
\qquad
路径长度:两个结点之间路径上的分支数量;路径长度示意图如下图所示:
\qquad
树的路径长度:从树根 到每一个结点的路径长度之和,记作
T
L
TL
TL,包含结点数目相同的二叉树的路径长度不一定相同,在结点数目相同的二叉树中,完全二叉树是路径长度最短的二叉树。但是路径长度最短的不一定是完全二叉树。
\qquad
权重:将树中的结点赋值一个有着某种意义的数值,则这个数值称为该结点的权重。
\qquad
结点的带权重路径长度:从根节点到该节点之间的路径长度与该节点的权重的乘积。
\qquad
树的带权重路径长度:树中所有叶子节点的带权路径长度之和。
\qquad
哈夫曼树:带权路径长度最短的树叫做哈夫曼树,也叫最优树;注意带权路径长度最短是在“度相同”的树中比较而得的结果,因此有最优二叉树(带权路径长度最短的二叉树),最优三叉树等。满二叉树不一定是哈夫曼二叉树。哈夫曼树中权重越大的叶子结点离根结点越近。具有相同权重结点的哈夫曼树不是唯一的。
5.2 哈夫曼树的构造算法
\qquad
因为哈夫曼树中权重越大的叶子节点离根结点越近,所以可以使用贪心算法,构造哈夫曼树是首先选择权重小的叶子节点。哈夫曼树算法的流程如下所示:
\qquad
哈夫曼树中只有度为0和度为2的结点,没有度为1的结点。
\qquad
包含n个叶子结点的哈夫曼树中共有2n-1个结点,因为包含n棵树的森林要经过n-1次合并才能形成哈夫曼树,共产生n-1个新的结点。下图展示了哈夫曼树的构造过程:
\qquad
哈夫曼算法的实现:
\qquad
结点类型定义:
typedef struct
{//哈夫曼树结点定义
int weight; //结点权重
int parent, lCh, rCh; //双亲结点,左孩子结点,右孩子结点
}HTNode, *HuffmanTree;
//哈夫曼树构造算法
void CreateHuffmanTree(HuffmanTree HT, int n)
{
if(n<=1)return;
int m = 2*n-1;
HT = new HTNode[m+1];
//初始化数组
for(int i = 1; i <= m; ++i)
{//初始化都为根结点
HT[i].lCh = 0;
HT[i].rCh = 0;
HT[i].parent= 0;
}
for(int i = 1; i <=m; ++i) cin >> HT[i].weight;//初始化权重
for(i = n+1; i <=m; ++i)
{
//在HT[k](1<=k<=i-1)中选择两个双亲域为0,同时权重值最小的结点并返回它们在HT中
//的需要s1和s2
Selece(HT,i-1,s1,s2);
HT[s1].parent=i;
HT[s2].parent=i;
HT[i].lCh=s1;
HT[i].lCh=s2;
HT[i].weight = HT[s1].weight+HT[s2].weight;
}
}
5.3 哈夫曼编码
\qquad
在远程通讯中,要将待传字符转换成由二进制组成的字符串,如下图所示:
\qquad
若使用等长的编码,则会比较浪费存储空间;若将编码设计为长度不等的二进制编码,即让待传字符串中出现次数较多的字符采用尽可能短的编码,则转换的二进制字符串便可能尽可能少。但是这样处理会出现重码,从而导致解码混乱:
\qquad
所以设计不等长编码的关键在于,使得任一字符的编码都不是另一个字符编码的前缀 – 前缀码。哈夫曼编码要解决的问题就是:设计使得电文长度最短的前缀码。哈夫曼编码规则如下所示:
\qquad
哈夫曼编码的示意图如下图所示:
\qquad
寻找哈夫曼编码的代码如下所示: