1、树与树的表示
客观世界中许多事物存在层次关系
分层次组织在管理上具有更高的效率!
数据管理的基本操作之一:查找(根据某个给定关键字K,从集合R 中找出关键字与K 相同的记录)。一个自然的问题就是,如何实现有效率的查找?
静态查找——方法一:顺序查找(时间复杂度O(n))
1
2
3
4
5
6
7
8
9
|
int
SequentialSearch(StaticTable * Tbl, ElementType K)
{
// 在表Tbl[1]~Tbl[n] 中查找关键字为K的数据元素
int
i;
Tabl->Element[0] = K;
// 建立哨兵
for
(i = Tbl->Length; Tbl->Element[i] != K; i--)
;
return
i;
// 查找成功返回所在单元下标;不成功返回0
}
|
静态查找——方法二:二分查找(时间复杂度O(logn))
二分查找的启示?
二分查找判定树:
树(Tree):n(n ≥ 0)个结点构成的有限集合。
当n = 0 时,称为空树。
对于任一颗非空树(n > 0),它具备以下性质:
树与非树?
树的一些基本术语:
为可节省空间,最常用的表示树的方法是儿子-兄弟表示法。
2、二叉树及存储结构
二叉树T:一个有穷的结点集合
这个集合可以为空
若不为空,则它是由根结点和称为其左子树TL和右子树TR的两个不相交的二叉树组成
斜二叉树(Skewed Binary Tree)、完美二叉树(Perfect Binary Tree)/满二叉树(Full Binary Tree)、完全二叉树(Complete Binary Tree)
这里重点介绍下CBT:有n 个结点的二叉树,对树中结点按从上至下、从左至右顺序进行编号,编号为i (1 ≤ i ≤ n)结点与满二叉树中编号为i 结点在二叉树中位置相同
重要操作:
常用的遍历方法有:
顺序存储结构
依完全二叉树的形式存储:按从上到下、从左到右顺序存储。
n 个结点的完全二叉树的节点父子关系:
应当注意的一点是:一般二叉树也可以采用这种结构,但会造成空间浪费
链表存储
1
2
3
4
5
6
7
|
typedef
struct
TreeNode *BinTree;
typedef
BinTree Position;
struct
TreeNode{
ElementType Data;
BinTree Left;
BinTree Right;
}
|
3、二叉树的遍历
先序遍历:访问根结点;先序遍历其左子树;先序遍历其右子树
1
2
3
4
5
6
7
8
9
|
void
PreOrderTraversal(BinTree BT)
{
if
(BT)
{
printf
(
"%d"
, BT->data);
PreOrderTraversal(BT->Left);
PreOrderTraversal(BT->Right);
}
}
|
中序遍历:中序遍历其左子树;访问根结点;中序遍历其右子树
1
2
3
4
5
6
7
8
9
|
void
InOrderTraversal(BinTree BT)
{
if
(BT)
{
InOrderTraversal(BT->Left);
printf
(
"%d"
, BT->Data);
InOrderTraversal(BT->Right);
}
}
|
后序遍历:后续遍历其左子树;后续遍历其右子树;访问根结点
1
2
3
4
5
6
7
8
9
|
void
PostOrderTraversal(BinTree BT)
{
if
(BT)
{
PostOrderTraversal(BT->Left);
PostOrderTraversal(BT->Right);
printf
(
"%d"
, BT->Data);
}
}
|
附注:先序、中序和后序遍历过程:遍历过程经过结点的路线一样,只是访问各结点的时机不同。下图在从入口到出口的曲线上用ⓧ、★和△三种符号分别标记出了先序、中序和后序访问各结点的时刻
先序:当曲线第一次经过一个结点时,就列出这个结点;中序:当曲线第一次经过一个树叶时,就列出这个树叶,当曲线第二次经过一个内点时就列出这个内点;后序:当曲线最后一次经过一个结点而返回这个结点的父亲时,就列出这个结点。
非递归遍历算法实现的基本思路:使用堆栈。我们以中序遍历的非递归算法为例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
void
InOrderTraversal(BinTree BT)
{
BinTree T = BT;
Stack S = CreateStack(MaxSize);
// 创建并初始化堆栈
while
(T || !IsEmpty(S))
{
while
(T)
// 一直向左并将沿途结点压入堆栈
{
Push(S, T);
T = T->Left;
}
if
(!IsEmpty(S))
// 不是必须的,因为while入口处已经判断过了
{
T = Pop(S);
// 结点弹出堆栈
printf
(
"%5d"
, T->Data);
// (访问)打印结点
T = T->Right;
// 转向右子树
}
}
}
|
注意到先序的非递归算法只要在中序非递归算法的基础上做一下调整就好了:printf语句放到Push操作之前。而后续遍历就比较繁琐了,因为当指针T指向一个结点时,不能马上对它进行访问,而要先遍历它的左子树,因而要将此结点的地址进栈保存。当其左子树遍历完毕之后,再次搜索到该结点时(退栈),还不能对它访问,还需要遍历它的右子树,所以,再一次将此结点的地址进栈保存。为了区别同一结点的两次进栈,需要引入一个标志变量,比如flag为0表示该结点暂不访问,为1表示该结点可以访问。
层序遍历基本过程:先根结点入队,然后:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
void
LevelOrderTraversal(BinTree BT)
{
Queue Q;
BinTree T;
if
(!BT)
// 若是空树直接返回
return
;
Q = CreateQueue(MaxSize);
// 创建并初始化队列Q
AddQ(Q, BT);
while
(!IsEmpty(Q))
{
T = Delete(Q);
printf
(
"%d\n"
, T->Data);
// 访问取出队列的结点
if
(T->Left)
AddQ(Q, T->Left);
if
(T->Right)
AddQ(Q, T->Right);
}
}
|
输出二叉树中的叶子结点
在二叉树的遍历算法中增加检测结点的"左右子树是否都为空"
1
2
3
4
5
6
7
8
9
10
|
void
PreOrderPrintLeaves(BinTree BT)
{
if
(BT)
{
if
(!BT->Left && !BT->Right)
printf
(
"%d"
, BT->Data);
PerOrderPrintLeaves(BT->Left);
PerOrderPrintLeaves(BT->Right);
}
}
|
求二叉树的高度
需要注意到Height = Max(HL, HR) + 1
1
2
3
4
5
6
7
8
9
10
11
12
13
|
int
PostOrderGetHeight(BinTree BT)
{
int
HL, HR, MaxH;
if
(BT)
{
HL = PostOrderGetHeight(BT->Left);
// 求左子树的深度
HR = PostOrderGetHeight(BT->Right);
// 求右子树的深度
MaxH = (HL > HR) ? HL : HR;
// 取左右子树中较大的深度
return
MaxH + 1;
// 返回树的深度
}
else
return
0;
// 空树深度为0
}
|
由先序和中序遍历序列来确定一颗二叉树
类似地,后序和中序遍历序列也可以确定一颗二叉树
4、二叉搜索树
先来回顾一下之前提到的查找问题(静态查找与动态查找),针对动态查找,数据如何组织?
二叉搜索树(BST,Binary Search Tree),也称二叉排序树或二叉查找树:一颗二叉树,可以为空;如果不为空,满足以下性质:
二叉搜索树的查找操作:Find
若X小于根结点键值,只需在左子树中继续搜索
若X大于根结点的键值,在右子树中继续进行搜索
若两者比较结果相等,搜索完成,返回指向此结点的指针。
1
2
3
4
5
6
7
8
9
10
11
|
Positon Find(ElementType X, BinTree BST)
{
if
(!BST)
return
NULL;
// 查找失败
if
(X > BST->Data)
return
Find(X, BST->Right);
// 在右子树中继续查找
else
if
return
Find(X, BST->Left);
// 在左子树中继续查找
else
// X == BST->Data
return
BST;
// 查找成功,返回结点的地址
}
|
上面程序中的两处递归调用都是尾递归,因此可以方便的改写为迭代函数,以便提高执行效率(注意到,查找的效率取决于树的高度)
1
2
3
4
5
6
7
8
9
10
11
12
13
|
Position IterFind(ElementType X, BinTree BST)
{
while
(BST)
{
if
(X > BST->Data)
BST = BST->Right;
// 向右子树中移动,继续查找
else
if
(X < BST->Data)
BST = BST->Left;
// 向左子树中移动,继续查找
else
// X == BST->Data
return
BST;
// 查找成功,返回结点的地址
}
return
NULL;
// 查找失败
}
|
查找最大和最小元素
只需注意到以下事实:
查找最小元素的递归函数
1
2
3
4
5
6
7
8
9
|
Postion FindMin(BinTree BST)
{
if
(!BST)
return
NULL;
// 空的二叉搜索树,返回NULL
else
if
(!BST->Left)
return
BST;
// 找到最左叶结点并返回
else
return
FindMin(BST->Left);
// 沿左分支继续查找
}
|
查找最大元素的迭代函数
1
2
3
4
5
6
7
|
Position FindMax(BinTree BST)
{
if
(BST)
while
(BST->Right)
BST = BST->Right;
// 沿右分支继续查找,直到最右结点
return
BST;
}
|
二叉搜索树的插入
关键是要找到元素应该插入的位置,可以采用与Find类似的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
BinTree Insert(ElementType X, BinTree BST)
{
if
(!BST)
{
// 若原树为空,生成并返回一个结点的二叉搜索树
BST =
malloc
(
sizeof
(
struct
TreeNode));
BST->Data = X;
BST->Left = BST->Right = NULL;
}
else
// 开始找要插入元素的位置
{
if
(X < BST->Data)
BST->Left = Insert(X, BST->Left);
// 递归插入左子树
else
if
(X > BST->Data)
BST->Right = Insert(X, BST->Right);
// 递归插入右子树
// else X已经存在,什么都不做
}
return
BST;
}
|
关于上面的代码,多说一点,就是关于递归调用返回的时候需要赋值给左子树或右子树,这在大多数赋值的情况下显得多余(就像是说,把当前树的左子树赋值给它的左子树),但是它是必须的,因为在插入元素的时候我们需要知道它的父结点的左指针或右指针。我们也可以消除不必要的赋值,但是它是以增加逻辑判断为代价的,还不如原先的方式显得清晰、美观。
二叉搜索树的删除
要考虑三种情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
BinTree Delete(ElementType X, BinTree BST)
{
Position Tmp;
if
(!BST)
printf
(
"要删除的元素未找到"
);
else
if
(X < BST->Data)
BST->Left = Delete(X, BST->Left);
// 左子树递归删除
else
if
(X > BST->Data)
BST->Right = Delete(X, BST->Right);
// 右子树递归删除
else
// 找到要删除的结点
{
if
(BST->Left && BST->Right)
// 被删除结点有左右两个子结点
{
Tmp = FindMin(BST->Right);
// 在右子树中找最小的元素填充删除结点
BST->Data = Tmp->Data;
BST->Right = Delete(BST->Data, BST->Right);
// 在删除结点的右子树中删除最小元素
}
else
// 被删除结点有一个或无子结点
{
Tmp = BST;
if
(!BST->Left)
// 有右孩子或无子结点
BST = BST->Right;
else
if
(!BST->Right)
// 有左孩子或无子结点
BST = BST->Left;
free
(Tmp);
}
}
return
BST;
}
|
5、平衡二叉树
搜索树结点不同插入次序,将导致不同的深度和平均查找程度,这促使二叉树"平衡"这个概念的出现。二叉树平衡与否的度量由"平衡因子"(Balance Factor,简称BF:BF(T) = HL - HR,其中HL和HR分别为T的左、右子树的高度)来决定。
平衡二叉树(Balanced Binary Tree)(AVL树):
空树,或者任一结点左、右子树高度差的绝对值不超过1,即|BF(T)| ≤ 1
我们之所以想要二叉树在一定程度上达到平衡,就是奔着它的效率去的,那么很自然的一个问题是:平衡二叉树的高度能达到log2n吗?
设nh 为高度为h 的平衡二叉树的最少结点数。结点数最少时:nh = nh-1 + nh-2 + 1。
可以看到,其形式非常类似于斐波那契数列。我们结合初始条件n0 = 1,n1 = 2不难得出nh = Fh+2 - 1。于是我们可以说h = O(log2n)。通俗的说就是,给定结点数为n 的AVL树的最大高度为O(log2n)。
AVL树的调整分为四种情况,分别为左单旋、右单旋、左右双旋、右左双旋。值得注意的一点是:有时候插入元素即便不需要调整结构,也可能需要重新计算一些平衡因子。
何老师给的图很好,简洁明了的表达了需要调整的情况并且给出了具体调整的方法:
下面是程序实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
|
typedef
struct
AVLTreeNode *AVLTree;
typedef
struct
AVLTreeNode{
ElementType Data;
AVLTree Left;
AVLTree Right;
int
Height;
};
AVLTree AVL_Insertion(ELementType X, AVLTree T)
{
// 将X插入AVL树中,并且返回调整后的AVL树
if
(!T)
// 若插入空树,则新建一个包含一个结点的树
{
T = (AVLTree)
malloc
(
sizeof
(
struct
AVLTreeNode));
T->Data = X;
T->Height = 0;
T->Left = T->Right = NULL;
}
// if(插入空树)结束
else
if
(X < T->Data)
// 插入T的左子树
{
T->Left = AVL_Insertion(X, T->Left);
if
(GetHeight(T->Left) - GetHeight(T->Right) == 2)
{
// 需要左旋
if
(X < T->Left->Data)
T = SingleLeftRotation(T);
// 左单旋
else
T = DoubleLeftRightRotation(T);
// 左-右双旋
}
}
// else if(插入左子树)结束
else
if
(X > T->Data)
// 插入T的右子树
{
T->Right = AVL_Insertion(X, T->Right);
if
(GetHeight(T->Left) - GetHeight(T->Right) == -2)
{
// 需要右旋
if
(X > T->Right->Data)
T = SingleRightRotation(T);
// 右单旋
else
T = DoubleRightLeftRotation(T);
// 右-左双旋
}
}
//else if(插入右子树)结束
// else X == T->Data, 无须插入
T->Height = Max(GetHeight(T->Left), GetHeight(T->Right)) + 1;
// 更新树高
return
T;
}
AVLTree SingleLeftRotation(AVLTree A)
{
// 注意:A必须有一个左子结点B
// 将A与B做如图所示的左单旋,更新A与B的高度,返回新的根结点B
AVLTree B = A->Left;
A->Left = B->Right;
B->Right = A;
A->Height = Max(GetHeight(A->Left), GetHeight(A->Right)) + 1;
B->Height = Max(GetHeight(B->Left), A->Height) + 1;
return
B;
}
AVLTree DoubleLeftRightRotation(AVLTree A)
{
// 注意:A必须有一个左子结点B,且B必须有一个右子结点C
// 将A、B与C做如图所示的两次单旋,返回新的根结点C
A->Left = SingleRightRotation(A->Left);
// 将B与C做右单旋,C被返回
return
SingleLeftRotationO(A);
// 将A与C做左单旋,C被返回
}
|
6、堆
优先队列(Priority Queue):特殊的"队列",取出元素的顺序是依照元素的优先权(关键字)的大小,而不是元素进入队列的先后顺序。
问题:如何组织优先队列?
对于堆来说,主要就是两个操作,插入和删除,而无论是一般的数组、链表,还是有序的数组、链表其中至少有一个操作是需要O(n) 的时间来完成的。可以考虑能否采用二叉树存储结构?如果采用这种存储结构的话,我们更应该关注插入还是删除操作?树结点顺序怎么安排?树结构怎样?
堆的两个特性:
以最大堆为例,其主要操作有:
最大堆的创建
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
typedef
struct
HeapStruct *MaxHeap;
struct
HeapStruct{
ElementType *Elements;
// 存储堆元素的数组
int
Size;
// 堆的当前元素个数
int
Capacity;
// 堆的最大容量
}
MaxHeap Create(
int
MaxSize)
{
// 创建容量为MaxSize的空的最大堆
MaxHeap H =
malloc
(
sizeof
(
struct
HeapStruct));
H->Elements =
malloc
((MaxSize+1) *
sizeof
(ElementType));
H->Size = 0;
H->Capacity = MaxSize;
H->Elements[0] = MaxData;
// 定义"哨兵"为大于堆中所有可能元素的值,便于以后更快操作
return
H;
}
|
注意到,把MaxData换成小于堆中所有元素的MinData,同样适用于创建最小堆。
最大堆的插入
思路:首先默认插入位置在完全二叉树的下一个位置,通过向下过滤结点的方式,从其父结点到根结点的有序序列中寻找合适的位置进行插入操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
void
Insert(MaxHeap H, ElementType item)
{
// 将元素item插入最大堆H,其中H->Elements[0]已经定义为哨兵
int
i;
if
(IsFull(H))
{
printf
(
"最大堆已满"
);
return
;
}
i = ++H->Size;
// i指向插入后堆中的最后一个元素的位置
for
(; H->Elements[i/2] < item; i /= 2)
H->Elements[i] = H->Elements[i/2];
// 向下过滤结点,这种方式比交换数据来得快
H->Elements[i] = item;
// 将item插入
}
|
上述代码中,H->Elements[0]是哨兵元素,它不小于堆中的最大元素,控制循环结束。时间复杂度O(logN)。
最大堆的删除
思路:取出根结点(最大值),同时删除它,方法就是用堆中的最后一个元素代替之(和插入操作一样,这里的代替只是形式上方便理解的说辞,实际上我们只是用一个临时变量保存其值而已,这比真实的替代更省时),但是其位置不一定正确,因此需要从根结点开始向上过滤下层结点。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
ElementType DeleteMax(MaxHeap H)
{
// 从最大堆H中取出键值为最大的元素,并删除一个结点
int
Parent, Child;
ElementType MaxItem, temp;
if
(IsEmpty(H))
{
printf
(
"最大堆已为空"
);
return
;
}
MaxItem = H->Elements[1];
// 取出根结点最大值
// 用最大堆中最后一个元素从根结点开始向上过滤下层结点
|