1.树知识体系搭建
2.树的基础知识
2.1 关于树结构
树形结构是非常重要的非线性结构,是以分支关系定义的一对多的层次结构。
树是n(n≥0)个结点的有限集。当n=0时,称为空树。在任意一棵非空树中应满足:
a.有且仅有一个特定的称为根的结点。
b.当n>1时,其余结点可分为m(m>0)个互不相交的有限集T,T2,…,Tm,其中每个集
合本身又是一棵树,并且称为根的子树。
树结构具有以下性质:
- 树的根结点没有前驱,除根结点外的所有结点有且只有一个前驱。
- 树中所有结点可以有零个或多个后继。
2.2 树的相关概念
结点:一个数据元素及其若干指向其子树的分支。
结点的度:结点所拥有的子树的棵数称为结点的度。
树的度:树中结点的度的最大值称为树的度。
叶子结点:树中度为0(向下没有分支)的结点称为叶子结点(或终端结点)。
非叶子结点:树中度不为0(问下有分支)的结点称为非叶子结点(或非终端结点或分支结点)。
除了根结点,分支结点也称为内部结点。
子结点:一个结点的子树的根称为该结点的孩子结点或子结点。
双亲结点或父节点:与子结点对应,子树的根。
兄弟结点:同一双亲结点的所有子结点互称为兄弟结点。
层次:规定树中根结点的层次为1,其众结点的层次等于其双亲结点的层次加1。
堂兄弟结点:在同一层上,但是双亲不同的所有结点互称为堂兄弟结点。
层次路径:从根结点开始,到达某结点p所经过的所有结点称为结点p的层次路径(有且只有一条)。
祖先结点:结点p的层次路径上的所有结点(p除外)称为p的祖先。
子孙结点:以某结点为根的子树中的任意结点称为该结点的子孙结点。
树的深度: 树中结点的最大层次值,又称为树的高度。
分支数=孩子数=度
2.3 树的主要性质
性质1:树中的结点个数等于所有结点的度之和加1。
性质2:对m度树,定义叶子结点个数为
n
0
n_0
n0,度为1的结点个数为
n
1
n_1
n1,… ,度为m的结点个数为
n
m
n_m
nm,
于是有
n
0
=
n
2
+
2
∗
n
3
+
3
∗
n
4
+
…
+
(
m
−
1
)
∗
n
m
+
1
n_0=n_2+2*n_3+3*n_4+…+(m-1)*n_m+1
n0=n2+2∗n3+3∗n4+…+(m−1)∗nm+1
性质3:在非空m度树中,第i层上至多有
m
i
−
1
m^{i -1}
mi−1个结点(i≥1)。
性质4:高度为h的m(m>1)度树,最多有
m
h
−
1
m
−
1
\frac{m^{h}-1}{m-1}
m−1mh−1个结点。
性质5:具有n个结点的m度树的最小高度是
log
m
[
n
∗
(
m
−
1
)
+
1
]
\log_m[n * (m -1) +1]
logm[n∗(m−1)+1]
2.4 树的表示方法
双亲表示法
孩子链表示法
孩子兄弟表示法
3.二叉树
3.1 定义及主要特性
二叉树的定义只需要把树的定义中m个分支改成最多两个分支就可以了。
3.1.1 二叉树的注意事项
- a.二叉树分为左、右子树,那么就意味着二叉树是有序树,它和二度树是不一样的。二度树可能不是有序树;但是二叉树一定是有序树。
- b.二叉树最多有两个分支,也就是二叉树的度最多是2,但是并不是说每个结点的度都是2。
3.1.2 二叉树的性质
性质1:树中的结点个数等于所有结点的度之和加1。
性质2:对任意一棵二叉树,定义叶子结点个数为
n
0
n_0
n0,度为2的结点个数为
n
2
n_2
n2,
n
0
=
n
2
+
1
n_0=n_2+1
n0=n2+1
性质3:在非空二叉树中,第i层上至多有
2
i
−
1
2^{i -1}
2i−1个结点(i≥1)。
性质4:高度为k的二叉树,最多有
2
k
−
1
{2^{k}-1}
2k−1个结点(k≥1)。
性质5:具有n个结点的二叉树的最小高度是
log
2
(
n
+
1
)
\log_2(n +1)
log2(n+1)
3.1.3 二叉树的分类
满二叉树、完全二叉树、二叉排序树、平衡二叉树、折半树
、堆。
- 满二叉树:满、一棵深度为k且有 2 k − 1 {2^{k}-1} 2k−1个结点的二义树称为满二义树。
- 完全二叉树:如果深度为k,由n个结点构成的二叉树,当且仅当其每个结点都与深度为k的满二叉树中编号从1到n的结点一一对应时,该二叉树称为完全二叉树。(对应位置、对应编号、不跳号)
3.1.4 满二叉树特点
- 每层上的结点数总是最大结点数。
- 所有的非叶子结点都有左、右子树。
- 对满二叉树的结点进行连续编号,若规定从根结点1开始,则按“自上而下、白左至右”的原则进行。
3.1.5 完全二叉树的性质
-
性质1:在非空一叉树,第i层上至多有 2 k − 1 2^{k-1} 2k−1个结点(i≥1)。
-
性质2:深度为k的二叉树至多有 2 k − 1 2^{k}-1 2k−1个结点(k≥1).
-
性质3:对任何一棵二叉树,若其叶子结点数为n,度为2的结点数为 n 2 n_2 n2,则 n 0 = n 2 十 1 n_0=n_2十1 n0=n2十1
-
性质4:树中的结点个数等于所有结点的度之和加1
-
性质5:深度为k的满二叉树中编号从1到n的前n个结点构成了一棵深度为k的完全二叉树,其中 2 k − 1 ≤ n ≤ 2 k − 1 2^{k-1}\le n \le2^k-1 2k−1≤n≤2k−1
-
性质6:n个结点的完全二义树深度为 ( l o g 2 n ) + 1 (log_2n)+1 (log2n)+1或 l o g 2 ( n + 1 ) log_2(n+1) log2(n+1)。
-
性质7:若完全二叉树的深度为k,则所有的叶子结点都出现在第k层或第k-1层。
-
性质8:对于任一结点,若其右子树的最大层次为1,则其左子树的最大层次为1或2。
-
性质9:若对一棵有n个结点的完全二叉树(深度为 ( l o g 2 n ) + 1 (log_2n)+1 (log2n)+1)的结点按层(从第1层到第 ( l o g 2 n ) + 1 (log_2n)+1 (log2n)+1层)序自左至右进行编号,则对于编号为1( 1 ≤ i ≤ n 1\le i \le n 1≤i≤n)的结点,有以下情形:
①若i=1,则结点i是二叉树的根,无双亲结点;否则,若i>1,则其双亲结点编号是[i/2」
②若2i>n,则结点i为叶子结点,无左孩子:否则,其左孩子结点编号是2i.
③若2i+1>n,则结点i无右孩子;否则,其右孩子结点编号是2i+1。 -
性质10:对于一棵完全二叉树,度为1的结点个数是1或0。
3.2 二叉树的存储
3.2.1 顺序结构存储
二叉树的顺序存储就是按照每个结点的编号作为数组下标存储到数组中;如果不满足完全二叉树,我们以通过补空标志,构建一棵完全二叉树。
在最坏的情况下,一个深度为k且只有k个结点的单支树需要长度为
2
k
−
1
2^{k-1}
2k−1的一维数组。此时是一棵单右支树。一般只有完全二叉树才采用顺序结构进行存储,一般的二叉树不适用于顺序结构。
#define MAX_SIZE 100
typedef int sqbitree[MAX_SIZE];
3.2.2 链式结构存储
因为二叉树有左、右两个子树,所以二叉树的链式结构的每个结点有3个域:一个数据域,两个分别指向左、右子结点的指针域。
3.3 二叉树的遍历
二叉树的遍历分为:先序遍历、中序遍历、后序遍历和层次遍历。
先序遍历、中序遍历、后序遍历需要使用到栈,而层次遍历需要用到队列。无论是哪种遍历,在遍历过程中,叶子结点的相对顺序是不变的。
3.3.1 基本概念
访问是指对结点做某种处理,如输出信息、修改结点的值,我们把访问结点的操作称为遍历,遍历二叉树是指按指定的规律对二叉树中的每个结点访问一次且仅访问一次。
若以L、D、R分别表示遍历左子树、遍历根节点和遍历右子树,则会有以下6种遍历方案:LDR、LRD、DLR、DRL、RLD、RDL。如果事先约定先左后右,则只有以下三种情况:DLR——先序(根)遍历、LDR——中序(根)遍历、LRD——后序(根)遍历。
3.3.2 先序遍历
先序遍历就是从二叉树的根结点出发,当第一次到达结点时就输出结点数据,按照先向左再向右的方向访问。若二叉树为空,则遍历结束。否则有以下情形:
a.访问根结点。
b.先序遍历左子树(递归调用本算法)。
c.先序遍历子树(涕归调用本算法)。
若采用递归进行遍历,则会调用系统栈进行遍历,具体代码如下:
//二叉树定义部分:
#include<stdio.h>
#include<stdbool.h>
typedef int ElementType;
typedef struct BTNode{
ElementType data;
struct BTNode * Lchild, *Rchild;
}BTNode;
void PreorderTraverse(BTNode * T){
//先序遍历,递归写法
//如果为空,则访问根结点
if(T == NULL) return;
printf("%d\n", T->data);
PreorderTraverse(T->Lchild);
PreorderTraverse(T->Rchild);
}
若采用用户栈,可以借助以下代码:
//定义需要用的类型及函数
#include<stdio.h>
#include<stdbool.h>
#define MAX_SIZE 100
typedef int ElementType;
typedef struct BTNode{
ElementType data;
struct BTNode * Lchild, *Rchild;
}BTNode;
typedef struct Stack{
//定义栈
//定义一个存放二叉树指针的数组
BTNode * data[MAX_SIZE];
int top;
}SqStack;
void Init_stack(SqStack * S){
S->top = 0;
}
int isEmpty(SqStack * S){
if(S->top == 0) return 1;
else return 0;
}
bool push(SqStack * S, BTNode * node){
if (S->top == MAX_SIZE) return false;
S->data[S->top] = node;
S->top++;
return true;
}
BTNode * pop(SqStack * S){
//出栈
if (S->top == 0) return NULL;
S->top--;
return S->data[S->top];
}
void PreorderTraverse_user(BTNode * root){
//二叉树遍历,调用用户栈
//用来暂存结点的栈
//BTNode * stack[100];
SqStack * stack;
Init_stack(stack);
// 新建一个工作结点,并指向根结点
BTNode * node = root;
// 当遍历到最后一个结点,若左右子树都为空,且栈也为空,则跳出循环。
//while (node != NULL || !stack.isEmpty()){
while (node != NULL || !isEmpty(stack)){
while(node != NULL){
printf("%d\n", node->data);
//暂存该结点
//stack.push(node);
push(stack, node);
node = node->Lchild;
}
//if (!stack.isEmpty()){
if (!isEmpty(stack)){
//左子树为空,再取出该元素,并获取其右子树
//node = stack.pop();
node = pop(stack);
node = node->Rchild;
}
}
}
3.3.3 中序遍历
中序遍历就是从二叉树的根结点出发,当第二次到达结点时输出结点数据,按照先向左再向右的方向访问。算法的递归定义是:若二叉树为空,则遍历结束:否则有以下情形:
a.中序遍历左子树(递归调用本算法)。
b.访回根结点。
c.中序遍历右子树(递归调用本算法)。
调用递归方法实现代码如下:
void InorderTraverse(BTNode * T){
// 中序遍历
if (T == NULL) return;
InorderTraverse(T->Lchild);
printf("%d", T->data);
InorderTraverse(T->Rchild);
}
调用用户栈,自定义方法如下:
void midorderTraversal(BTNode * root){
SqStack * stack;
Init_stack(stack);
BTNode * node = root;
while(node != NULL || !isEmpty(stack)){
while(node != NULL){
push(stack, node);
node = node->Lchild;
}
if (!isEmpty(stack)){
node = pop(stack);
printf("%d", node->data);
node = node ->Rchild;
}
}
}
3.3.4 后序遍历
后序遍历就是从二叉树的根结点出发,当第三次到达结点时就输出结点数据,按照先向左再向右的方向访问。算法的递归定义是:若二叉树为空,则遍历结束;否则有以下情形:
a.后序遍历左子树(递归调用本算法)。
b.后序遍右子树(递归调用木算法)。
c.访回根结点。
若使用系统栈,采用递归方法如下:
void postTraverse(BTNode * T){
//后续遍历
if (T == NULL) return;
postTraverse(T->Lchild);
postTraverse(T->Rchild);
printf("%d\n", T->data);
}
若采用用户栈,自定义方法如下:
void postTraversal(BTNode * root){
SqStack * stack;
BTNode * node = root;
BTNode * last = root;
while(node != NULL || !isEmpty(stack)){
while (node != NULL){
push(stack, node);
node =node->Lchild;
}
// 查看当前栈顶元素
//如果其右子树为空,或者右子树已经访问,则输出该结点的值
if (node->Rchild == NULL || node->Rchild == last){
printf("%d\n", node->data);
pop(stack);
last = node;
node = NULL;
}else{
node = node->Rchild;
}
}
}
3.3.5 层次遍历
层次遍历二叉树,是从根结点开始遍历,按层次次序“自上而下,从左至右”访问树中的各结点。为保证是按层次遍历,必须设置一个队列,初始化时为空。设T是指向根结点的指针变量,层次遍历非递归法是:若二叉树为空,则返回;否则,令p=T,p入队。
a.队首元素出队到p。
b.访问p所指向的结点。
c.将p所指向的结点的左、右子结点依次入队,直到队空为止。
实现代码如下:
void leverTraverse(BTNode * T){
// 定义一个队列来存储结点
BTNode * Queue[MAX_SIZE];
BTNode * p = T;
int front = rear = 0;
// 当结点部位空时,开始进出队列
if(p != NULL){
Queue[rear++] = p;
while(front < rear){
//当队列不为空,进行输出和访问。
p = Queue[front++];
printf("%d", p->data);
//有左结点,就将左节点放到队列中
if (p->Lchild != NULL)
Queue[front++] = p->Lchild;
if (p->Rchild != NULL)
Queue[front++] = p->Rchild;
}
}
}
3.3.6 二叉树的生成
以下三种组合可以唯一确定一棵二叉树:
- 先序遍历 + 中序遍历
- 后序遍历 + 中序遍历
- 层次遍历 + 中序遍历
3.4 线索二叉树
在二叉树的结点上加上线索的二叉树称为线索二叉树,对二叉树以某种遍历方式(如先序、中序、后序或层次等)进行遍历,使其变为线索二叉树的过程称为对二叉树进行线索化。
3.4.1 线索二叉树的标记方法
- 若结点有左孩子,则Lchild指向其左孩子:否则,指向其直接前驱。
- 若结点有右孩子,则Rchild指向其右孩子;否则,指向其直接后继。
线索二叉树的结构如下:
左孩子或直接前驱 | 是否无左孩子 | 数据 | 是否无右孩子 | 右孩子或直接后继 |
---|---|---|---|---|
Lchild | Ltag | data | Rtag | Rchild |
以Ltag为例,标记方法如下:
- Ltag标记为0,Lchild指向结点的左孩子
- Ltag标记为1,Lchild指向结点的直接前驱
3.4.2 线索二叉树的构造
先写出遍历序列,然后根据有无子树添加指向左、右子树的指针或指向直接前驱、直接后继的线索。
以下为线索二叉树的构建例子:
4. 树、森林
4.1 树和二叉树的转换
4.1.1 将树转为二叉树
对于一般的树,可以方便地转换成一棵唯一的二叉树与之对应。其详细步骤如下(孩子兄弟表示法):
- ①加虚线。在树的每层按从左至右的顺序在兄弟结点之间加虚线相连。
- ②去连线。除最左的第一个子结点之外,父结点与所有其他子结点的连线都去掉。
- ③旋转。将树顺时针旋转45度,原有的实线左斜。
- ④整型。将旋转后树中的所有虚线改为实线,并向右斜。
此处举一例子,如下:
4.1.2 二叉树转为树
二叉树转为树,步骤如下:
- ①加虚线。若某结点i是其父结点的左子树的根结点,则将该结点ⅰ的右孩子结点及沿右孩子链搜索到的所有的右孩子结点,与i结点的父结点之间加虚线相连。
- ②去连线。去掉二叉树中所有父结点与其右子结点之间的连线。
- ③规整化。将图中各结点按层次排列且将所有的虚线变成实线。逆时针转动45度。
举一例子,如下:
4.2 树的存储结构
4.3 森林与二叉树的转换
4.3.1 森林转为二叉树
森林转换成二叉树的转换步骤如下:
- ①将F={T,T2,…,T}中的每棵树转换成二叉树。
- ②按给出的森林中树的次序,从最后一棵二叉树开始,每棵二叉树作为前一棵二叉树的根结点的右子树,依次类推,则第一棵树的根结点就是转换后生成的二叉树的根结点。
以下是森林转为树的案例:
4.3.2 二叉树转为森林
二叉树转为森林的步骤如下:
①去连线。将二叉树的根结点与其右子结点,以及沿右子结点链方向的所有右子结点的连线全部去掉,得到若干棵孤立的二叉树,每棵树就是原来森林中的树依次对应的二叉树。
②二叉树的还原。将各棵孤立的二叉树按二叉树还原为树的方法还原成一般的树。
二叉树转为森林的例子如下:
4.4 树和森林的遍历
树的遍历有两种:先序遍历和后序遍历。
- 先序遍历:先访问根结点,然后依次先序遍历完每棵子树。树的先序遍历与将树转为二叉树后,对二叉树的先序遍历相同。
- 后序遍历:先依次后序遍历完每棵子树,然后访问根结点。树的后序遍历与将树转换成二叉树后,对二叉树的中序遍历相同。
5. 树与二叉树的应用
5.1 二叉排序树
5.2 平衡二叉树
5.3 哈夫曼树和哈弗曼编码
5.3.1 相关概念
- 结点路径:从树中一个结点到另一个结点之间的分支构成这两个结点之间的路径。
- 路径长度:结点路径上的分支数目称为路径长度。
- 树的路径长度:从树根到每个结点的路径长度之和。
- 结点的带权路径长度:从该结点到树的根结点之间的路径长度与结点的权(值)的乘积。其中,权(值)是各种开销、代价、频度等的抽象称呼。
- 树的带权路径长度:树中所有叶子结点的带权路径长度(WTL)之和,记作
W P L = ω 1 ι 1 + ω 2 ι 2 + . . . + ω n ι n = ∑ i = 1 n ω i ι i WPL = \omega_1\iota_1+\omega_2\iota_2+...+\omega_n\iota_n=\sum_{i=1}^{n}\omega_i\iota_i WPL=ω1ι1+ω2ι2+...+ωnιn=i=1∑nωiιi - 哈夫曼(Huffman)树:具有n个叶子结点(每个结点的权值为 ω 1 \omega_1 ω1)的二叉树不止一棵。但在所有的这些二叉树中,必定存在一棵WPL值最小的树,称这棵树为哈夫曼树(或称为最优树)。
以下为计算WPL的例子:
5.3.2 哈夫曼树的构建
构建要点:需要让编码长的路径长度长一些,编码短的路径长度短一些即可。
给定n个权值分别为
w
1
w_1
w1,
w
2
w_2
w2,…,
w
n
w_n
wn的结点,构造哈夫曼树的算法描述如下:
- 将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。
- 构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
- 从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
- 重复步骤2和3,直至F中只剩下一棵树为止。
例子:
如将w={8,3,4,6,5,5}构造成哈夫曼树,步骤图如下:
其WPL值如下:
W P L = 6 ∗ 2 + 3 ∗ 3 + 4 ∗ 3 + 8 ∗ 2 + 5 ∗ 3 + 5 ∗ 3 = 79 WPL=6*2+3*3+4*3+8*2+5*3+5*3=79 WPL=6∗2+3∗3+4∗3+8∗2+5∗3+5∗3=79
5.3.3 哈夫曼编码
若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码。
由哈夫曼树得到哈夫曼编码是很自然的过程。首先,将每个出现的字符当作一个独立的结点,其权值为它出现的频度(或次数),构造出对应的哈夫曼树。显然,所有字符结点都出现在叶结点中。我们可将字符的编码解释为从根至该字符的路径上边标记的序列,其中边标记为0表示“转向左孩子”,标记为1表示“转向右孩子”。
如何判断是不是哈夫曼编码?
- 方法1: 根据其是否符合前缀编码的定义
- 方法2:将哈夫曼树绘制出来,看是否符合
5.3.4 哈夫曼树的特点
- 哈夫曼树只有0度结点和2度结点。
- 哈夫曼树的WPL值最小。
- 哈夫曼树不唯一,因为哈夫曼树的左、右子树可以交换,但是W值唯一。
- 哈夫曼树本质上不属于二叉树,但通常我们认为哈夫曼树是二叉树。
- 哈夫曼树的上层结点的权值不小于下层结点的权值。
- 哈夫曼编码只讨论叶子的编码。
5.4 红黑树
5.5 并查集及其应用
并查集是一种非常精巧且实用的数据结构,它主要用于处理一些不相交集合的合并及查询向题。常见的用途有求连通子图、求最小生成树的Kruskal算法和求最近公共祖先。
在使用并查集时,首先会存任一组不相交的动态集合S={S1,s2,…,Sn},一般会使用一个整数表示集合中的一个元素。
并查集是一种简单的集合表示,它支持以下3种操作:
- Initial(S):将集合S中的每个元素都初始化为只有一个单元素的子集合。
- Union(S,Root1,Root2):把集合S中的子集合Root2并入子集合Root1。要求Root1和Root2互不相交,否则不执行合并。
- Find(S,x):查找集合S中单元素x所在的子集合,并返回该子集合的根结点。
通常用树(森林)的双亲表示作为并查集的存储结构,每个子集合以一棵树表示。所有表示子集合的树,构成表示全集合的森林,存放在双亲表示数组内。通常用数组元素的下标代表元素名,用根结点的下标代表子集合名,根结点的双亲结点为负数。