文章目录
树
基本概念
树(tree)的定义
- 树是一个n个结点的有限集(n>=0)
- 空树:n=0
- 非空树:
T
=
{
r
,
𝑇
1
,
𝑇
2
,
…
,
𝑇
𝑚
}
T=\{r, 𝑇_1, 𝑇_2 ,…, 𝑇_𝑚\}
T={r,T1,T2,…,Tm}
r r r:T的根(root)结点,有且只有一个
m m m:树的分支数
𝑇 1 , 𝑇 2 , … , 𝑇 𝑚 𝑇_1, 𝑇_2 ,…, 𝑇_𝑚 T1,T2,…,Tm:除r之外,其他结点构成的互不相交的m(m>0)个子集合,其中每一个子集合本身也是一棵树,并被称为根r的子树(subtree)
每棵子树的根结点有且仅有一个直接前趋(即它的上层结点),可以有0-多个直接后继(即它的下层结点)
逻辑表示方法
圆括号表示法,也称为广义表表示法
使用括号将集合层次与包含层次关系显示出来
例如:A( B(E,F(K,L)), C(G), D(H,I,J(M)) )
广义表内容看上一篇:面试复习——数据结构(六):数组和广义表
树形表示法
用一个圆圈表示一个结点,圆圈内的符号代表该结点的数据信息,结点之间的关系通过连线表示
文氏图表示法/嵌套集合表示法
每棵树对应一个圆圈,圆圈内包含结点和子树的圆圈,同一个根结点下的各子树对应的圆圈是不能相交的
目录结构表示法
术语
结点:包含数据元素及若干指向其子树的分支(指针)
结点的孩子(child):若结点的子树非空,结点子树的根即为该结点的孩子
双亲(parent):若结点有孩子,那么该结点就是孩子结点的双亲
兄弟(sibling):同一双亲结点的孩子互称为兄弟
堂兄弟:双亲在同一层的结点
结点的祖先(ancestor):从根结点到该结点所经分支上的所有结点
结点的子孙(descendant):某一结点的孩子以及这些孩子的孩子都是该结点的子孙;以某结点为根的子树中任一结点都是该结点的子孙
结点的度(degree):结点所拥有的子树的数量
- 树的度:树中各个结点的度的最大值,通常将度为m的树称为m叉树/m次树
- 叶子结点(leaf):度为零的结点,终端结点
- 分支结点:度大于零的结点,非终端结点
树的宽度:统计树中每一层的结点数量,取最大的数量作为树的宽度
结点之间的路径:由从一个结点到另一个结点的所径分支和结点组成
- 从根到结点的路径:由从根到该结点所经分支和结点构成
结点的层次/深度:规定根结点为第一层,其孩
子结点的层次等于它的层次加一,若某结点在i层,则其子树的根就在第i+1层
结点的高度:叶结点的高度为1,非叶结点的高度等于它的孩子结点高度中的大值加1
树的深度:树中(叶子)结点的最大层次
树的高度:根结点的高度
树的高度=树的深度
有序树:树中结点的各棵子树 T0, T1, …是有次序的(不能互换)
无序树
满m叉树/满m次树:除叶子结点外,其余结点度数均为m
完全m叉树/完全m次树:按照满m次树的层序编号后,最高层连续缺少编号最大的若干个结点,但至少有一个结点
森林:森林是m棵树的集合(m≥0)
存储结构
二叉树及其存储表示
二叉树的遍历
二叉树定义 Binary tree
一种树型结构,它的特点是每个结点至多只有两棵子树,并且,二叉树的子树有左右之分,其次序不能任意颠倒
二叉树性质
性质1 :若二叉树结点的层次从 1 开始, 则在二叉树的第 i 层(i≥1)最多有 𝟐 ( 𝒊 − 𝟏 ) 𝟐^{(𝒊−𝟏)} 2(i−1) 个结点
性质2:深度为 k ( k ≥ 1 ) k( k≥1 ) k(k≥1)的二叉树最少有 k k k 个结点,最多有 2 𝑘 − 1 2^{𝑘 -1} 2k−1个结点
因为每一层最少要有1个结点,因此,最少结点数为 k
借助性质1,最多结点个数为 2 0 + 2 1 + 2 2 … + 2 ( 𝑘 − 1 ) 2^0+2^1+2^2…+2^{(𝑘−1)} 20+21+22…+2(k−1),用求等比级数前k项和的公式得到: 2 𝑘 − 1 2^{𝑘−1} 2k−1
性质3:对任何一棵二叉树,如果其叶结点有 n 0 n_0 n0个,度为 2 的非叶结点有n_2个, 则有: 𝒏 𝟎 = 𝒏 𝟐 + 1 𝒏_𝟎 = 𝒏_𝟐 + 1 n0=n2+1
设:度为 1 的结点有 n 1 n_1 n1个,总结点数为n, 总分支数为e,那么根据二叉树的定义,有:
n = n 0 + n 1 + n 2 n = n_0 + n_1 + n_2 n=n0+n1+n2, e = n − 1 = 2 n 2 + n 1 e = n-1 = 2 n_2 + n_1 e=n−1=2n2+n1
因此,有 2 n 2 + n 1 = n 0 + n 1 + n 2 − 1 2 n_2 + n_1 = n_0 + n_1 + n_2 -1 2n2+n1=n0+n1+n2−1, n 2 = n 0 − 1 n_2 = n_0 -1 n2=n0−1, n 0 = n 2 + 1 n_0 = n_2 +1 n0=n2+1
性质:二叉树的分支数等于二叉树中所有结点的度的总和
满二叉树 full binary tree
满二叉树是一棵深度为
k
k
k且有
2
k
−
1
2^{k-1}
2k−1个结点的二叉树
完全二叉树 complete binary tree
完全二叉树中所含的 n 个结点和满二叉树中编号为 1 至 n 的结点一一对应
满二叉树一定是完全二叉树
性质4:具有 n (n≥0) 个结点的完全二叉树的深度为
⌊
𝐥
𝐨
𝐠
𝟐
𝒏
⌋
+
1
⌊𝐥𝐨𝐠_𝟐𝒏 ⌋ + 1
⌊log2n⌋+1
性质5 :若对含 n 个结点的完全二叉树从上到下且从左至右进行 1 至 n 的编号,则对完全二叉树中任意一个编号为 i 的结点:
- 若 i=1,则该结点是二叉树的根,无双亲;否则,编号为 ⌊ i ∕ 2 ⌋ ⌊i∕2⌋ ⌊i∕2⌋的结点为其双亲结点
- 若 2i>n,则该结点无左孩子,否则,编号为 2i 的结点为其左孩子结点
- 若 2i+1>n,则该结点无右孩子结点,否则,编号为2i+1 的结点为其右孩子结点
存储结构
顺序存储
二叉树的顺序存储结构
#define MAX_TREE_SIZE 100
// 二叉树的最大结点数
typedef TElemType SqBiTree[MAX_TREE_SIZE];
// 0号单元存储根结点
SqBiTree bt;
链式存储
二叉树的链式存储结构
- 二叉链表
- 三叉链表
- 线索链表
二叉链表——只指孩子
typedef struct BiTNode {
// 结点结构
TElemType data;
struct BiTNode *lchild, *rchild;
// 左右孩子指针
} BiTree;
三叉链表——指向孩子和父节点
typedef struct TriTNode {
// 结点结构
TElemType data;
struct TriTNode *lchild, *rchild; //左右孩子指针
struct TriTNode *parent; //双亲指针
} TriTree;
双亲链表——只指父节点并记住自己是左子结点还是右子结点
Typedef struct BPTNode{
TElemType data;
int *parent;
Char LRTag;
}BPTNode;
Typedef struct BPTTree{
BPTNode nodes[MAX_TREE_SIZE];
int num_node;
int root;
}BPTree;
遍历二叉树
顺着某一条搜索路径巡访二叉树中的结点,使得每个结点均被访问一次,而且仅被访问一次
- 先左后右的遍历
设访问根结点记作 D,遍历根的左子树记作 L,遍历根的右子树记作 R,则按照访问根结点的先后,可能的遍历次序有先序 (DLR)、中序(LDR)、 后序(LRD) - 先右后左的遍历
- 先上后下的按层次遍历
先序遍历 Preorder Traversal
若二叉树为空,则空操作
否则
访问根结点
先序遍历左子树
先序遍历右子树
对n个结点的二叉树,遍历操作的时间复杂度为O(n)
Status PreorderTraverse (BiTree *T, Status(*Visit)(TElemType e)) {
if (T) {
if (Visit(T->data)) // 访问结点
if (PreorderTraverse (T->lchild, Visit)) //遍历左子树
if (PreorderTraverse (T->rchild, Visit)) //遍历右子树
return OK;
return ERROR;
}
else return OK;
}
中序遍历 (Inorder Traversal)
若二叉树为空,则空操作
否则,
中序遍历左子树
访问根结点
中序遍历右子树
利用栈实现中序遍历的非递归算法1
void Inorder_iter1(BiTree *T, Status (*Visit)(TElemType e)) {
SqStack S; InitStack(&S);
BiTree *p; p = T;
while (p || ! StackEmpty(&S)){
if (p) {Push(&S, p); p= p->lchild;}
//该子树沿途结点进栈
else { Pop (&S, &p); //退栈
if (!Visit (p->data)) return ERROR; //访问
p = p->rchild; //遍历指针进到右孩子
}
} //while
return OK;
}
利用栈实现中序遍历的非递归算法2
void Inorder_iter2( BiTree *T, Status(*Visit)(TElemType e) ){
SqStack S; InitStack(&S);
BiTNode *p; p = T; Push(&S,p);
while (!IsStackEmpty(&S)){
while(GetTop(&S,&p) && p)
Push(&S,p->lchild); //左子树入栈
Pop(&S,&p); //空指针退栈
if(!IsStackEmpty(&S)){
Pop(&S,&p); //退栈
if(!Visit(p->data)) return ERROR;//访问
Push(&S,p->rchild); //遍历右子树
} //if
}//while
return OK;
}
后序遍历 (Postorder Traversal)
若二叉树为空,则空操作
否则,
后序遍历左子树
后序遍历右子树
访问根结点
创建二叉树
BiTree *CreateBiTree() {
//按先序输入二叉树中结点的值即字符,空格或Z
//表示空树,构造二叉链表表示的二叉树
scanf("%c",&x);
if (x==‘Z’) bt = NULL;
else {
bt=(BiTree *)malloc(sizeof(BiTree));
if(!bt) return NULL;
bt->data=x; //生成根结点
bt->lchild =CreateBiTree(); //构造左子树
bt->rchild =CreateBiTree(); //构造右子树
return bt;
}// CreateBiTree
统计叶子结点个数
int CountLeaf(BiTree *T) { //实现
int countNum=0;
if (T) {
countNum=CountLeaf (T->lchild);
if(!T->lchild && !T->rchild) countNum++;
countNum += CountLeaf (T->rchild);
}
return countNum;
}
Num=CountLeaf(t); //调用
求树的深度
int GetDepth(BiTree *T) { //实现
int depthLeft=0,depthRight=0,depth=0;
if(T){
depthLeft=GetDepth(T->lchild);
depthRight=GetDepth(T->rchild);
depth=(depthLeft>depthRight?depthLeft:depthRight)+1;
}
else depth=0;
return depth;
}
dep=GetDepth(t);//调用
线索二叉树 Threaded binary tree(二叉树变种)
为了方便地找到二叉树指定结点在某种线性序列中的直接前驱和直接后继,设计了线索二叉树
- 线索:指向该线性序列中数据元素“前驱”和“后继”的指针(不是单纯地指孩子和父结点)
- 线索二叉树:包含线索的二叉树
- 二叉树的线索化:将某种遍历顺序下的前驱、后继关系(线索)记在树的存储结构中
n个结点的二叉链表必定存在n+1个空链域
因为n个结点有2n个指针;除了根结点,其他结点(共n-1)都有一个双亲结点,即有指针从双亲指向自己,故,剩下的空链域为2n-(n-1)=n+1
以这n+1个lchild 和 rchild 的空闲指针用作 pred 指针和 succ 指针,并增设两个标志 ltag 和 rtag,指明指针是指示孩子还是前驱还是后继线索
typedef enum { Link, Thread } PointerThr;
//Link==0,表示是指针,
//Thread==1,表示是线索
typedef struct BiThrNod {
TElemType data;
struct BiThrNode *lchild, *rchild; //左右指针
PointerThr LTag, RTag; //左右标志
} BiThrNode, *BiThrTree;
在线索链表上进行中序遍历/非递归算法
不需要栈!
void InorderTraverse_Thr(BiThrTree T, void (*Visit)(TElemType e)) { //T指向头结点
p = T->lchild; // p指向根结点
while ( p != T ) { // 空树或遍历结束时,p==T
while (p->LTag==Link) p = p->lchild; // 最左的结点
if (!Visit(p->data) ) //访问其左子树为空的结点
return ERROR;
while (p->RTag==Thread && p->rchild!=T){ //无右子树
p = p->rchild; Visit(p->data);// 访问后继结点
}
p = p->rchild; // p进至其右子树根
}
} // InorderTraverse_Thr
建立线索二叉树
在中序遍历过程中修改结点的左、右指针域,以保存当前访问结点的“前驱”和“后继”信息
遍历过程中,附设指针pre,并始终保持指针pre指向当前访问的、指针p所指结点的前驱
Status InorderThreading( BiThrTree &Thrt, BiThrTree T ) { //Thrt 指向头结点
//中序遍历二叉树T,并将其中序线索化
Thrt->LTag= LINK; Thrt->Rtag= Thread;
Thrt->rchild = Thrt;//指向自己
if (!T) Thrt->lchild = Thrt; //指向自己
else { Thrt->lchild = T; pre = Thrt;
InThreading(T);
pre->rchild = Thrt; pre-Rtag = Thread;
Thrt->rchild = pre; }
return OK;
} // InorderThreading
void InThreading(BiThrTree p){
if (p) { InThreading(p->lchild);
if (!p->lchild) { p->LTag = Thread;
p->lchild = pre;}
if (!pre->rchild) { pre-Rtag = Thread;
pre->rchild = p;}
pre = p;
InThreading(p->rchild);
}
}//InThreading
中序线索二叉树的基本操作
后序线索二叉树的基本操作
Huffman树(二叉树变种)
Huffman树/最优二叉树:一颗有 n个叶子结点的 二叉树,其叶子结点的权重分别是{𝑤_0, 𝑤_1, …., 𝑤_(𝑛−1)} ,并且其带权路径长度(WPL值)达到最小
在Huffman树中,权值越大的结点离根越近
用贪心算法构造Huffman树
性质:Huffman树中没有度为1的结点,树中任意非叶子结点都有2个孩子,这类树又称为正则或严格二叉树(regular/strict binary tree)
一棵有n个叶子结点的Huffman树共有2n-1个结点
用三叉静态链表表示Huffman树
typedef struct{
char data;
int weight;
int parent, lchild, rchild;
} HTNode;
typedef struct{
HTNode elem[MAXNum];
int num, root;
//num:叶结点数,root:根
}
Huffman树的应用——最优判定树
判定树是一棵二叉树,叶子结点是比较结果,内结点是比较过程,叶子结点所带权值是概率
最优判定树(Optimal decision tree):利用Huffman树,可以在构造判定树(决策树)时让平均判定(比较)次数达到最小
Huffman树的应用——Huffman编码
typedef struct {
unsigned int weight;
unsigned int parent,
lchild, rchild;
} HTNode, *HuffmanTree;
typedef char * *HuffmanCode;
void HuffmanCoding(
HuffmanTree &HT, HuffmanCode &HC, int *w, int n) {
//w存放n个字符的权值,构造Huffman树HT和编码HC
m = 2*n-1;
HT = (HuffmanTree)malloc((m+1)*sizeof(HTNode));
for (p=HT, i=1; i<=n; ++i, ++p, ++w) *p={*w, 0, 0, 0};
for (; i<=m; ++i, ++p) *p = {0, 0, 0, 0};
for (i=n+1; i<=m; ++i) { //建Huffman树
Select(HT, i-1, s1, s2);
//在HT[1..i-1]选择parent为0、权重最小的两个结点,
//其序号为s1和s2
HT[s1].parent = i; HT[s2].parent = i;
HT[i].lchild = s1; HT[i].rchild = s2;
HT[i].weight = HT[s1].weight+HT[s2].weight;
}
//两种方法获得Huffman编码… …
//方法1:从叶子到根逆向求每个 字符的Huffman编码
HC= (HuffmanCode)malloc( (n+1)*sizeof(char *));
//分配存放n个字符编码的头指针向量
cd = (char *)malloc (n*sizeof(char)); //存放 编码
cd[n-1] = “\0”;
for (i=1; i<=n; ++i){ //逐个字符求Huffman编码
start = n-1;
for (c= i, f=HT[i].parent; f!=0; c=f, f=HT[f].parent)
//从叶子到根逆向求编码
if (HT[f].lchild == c) cd[--start] = “0”;
else cd[--start] = “1”; //
//为第i个字符的编码分配空间
HC[i] = (char *)malloc((n-start)*sizeof(char));
strcpy(HC[i], &cd[stat]);//从cd复制编码到HC
}
free(cd);
}//HuffmanCoding
//方法2:无栈非递归遍历Huffman树,求Huffman编码
HC= (HuffmanCode)malloc( (n+1)*sizeof(char *));
p = m; cdlen = 0;
for (i=1; i<=m; ++i) HT[i].weight = 0; //用作结点状态标志
while (p) { //从根出发,遍历Huffman树
if (HT[p].weight ==0) { HP[p].weight = 1; //向左,访问左结点
if (HP[p].lchild != 0){p = HT[p].lchild; cd[cdlen++] = “0”;}
else if (HT[p].rchild == 0){ //到达叶子结点,登记该结点的编码
HC[p] = (char *)malloc((cdlen+1)*sizeof(char));
cd[cdlen] = “\0”; strcpy(HC[p], cd); }
}
else if (HT[p].weight == 1) { HT[p].weight = 2; //向右,访问由结点
if (HT[p].rchild != 0) { p = HT[p].rchild;
cd[cdlen++] = “1”; }
} else { // HT[p].weight == 2,该结点的左右孩子都访问过了
HT[p].weight = 0; p = HT[p].parent; --cdlen;//退回父节点
}//else
}//while
树
树的存储表示,树的性质
- 双亲表示法
- 孩子表示法(链表)
- 孩子-兄弟表示法
将二叉树转换成树
树的遍历
深度优先遍历
- 先根次序遍历:若树不空,则先访问根节点,然后依次先根遍历各棵子树
树的先根遍历结果与其对应二叉树表示的先序遍历结果相同
- 后根次序遍历:若树不空,则先依次后根遍历各棵子树,然后访问根节点
树的后根遍历结果与其对应二叉树表示的中序遍历结果相同
广度优先遍历/层次遍历
树的应用:四皇后问题
void Queens(int chess[],int i){ int j,k;
if(i==CHESSBOARD_SIZE){ … … //输出棋盘的当前布局
return; }
for(j=0;j<CHESSBOARD_SIZE;j++){
chess[i]=j; //在第 i 行第 j 列放置一个棋子
for(k=0; k<i && (chess[i]!=chess[k] &&
i-chess[i] !=k-chess[k] && i+chess[i]!=k+chess[k]);
k++);
if (k==i) //当前布局合法
Queens(chess,i+1);
else //当前布局不合法
chess[i]= -1; //移去第 i 行第 j 列的棋子
}
}
森林
遍历
PS:关于贪心算法 Greedy algorithm
贪心算法(又称贪婪算法)是指,在对问题求解时,总是做出在当前看来是最好的选择
贪心算法并不从整体最优加以考虑,它所做出的解决方案是在某种意义上的局部最优解
贪心算法不是对所有问题都能得到整体最优解,但对相当广范围的许多问题是能产生整体最优解的,或者是整体最优解的近似解
例子:Huffman树,(图的)最小生成树,(图的)最短路径
PS:关于回溯算法 Backtracking algorithm
回溯算法是一种“穷举”法,也叫试探法,是一种系统地搜索问题的解的方法
用回溯算法解决问题的一般步骤:
- 针对所给问题,定义问题的解空间,它包含问题的所有(最优)解
- 将解空间看成一颗树结构,使得能用回溯法方便地搜索整个解空间
- 以深度优先的方式搜索解空间,并且在搜索过程中用约束函数剪枝解空间,避免无效搜索
问题的解空间通常是在搜索问题解的过程中动态产生的,这是回溯算法的一个重要特性
例子:迷宫寻路,八皇后
PS:关于分治算法 Divide-and-conquer algorithm
用分而治之算法解决问题的一般步骤:
- 把一个规模较大的问题分成两个或多个较小的与原问题相似的子问题,
- 首先对子问题进行求解,
- 然后把各个子问题的解合并起来,得出整个问题的解
特点:分解问题并组合解
例子:汉诺塔,广义表,快速排序,归并排序