参考:浙大数据结构(陈越、何钦铭)课件
1、树与树的表示
客观世界中许多事物存在层次关系
分层次组织在管理上具有更高的效率!
数据管理的基本操作之一:查找(根据某个给定关键字K,从集合R 中找出关键字与K 相同的记录)。一个自然的问题就是,如何实现有效率的查找?
静态查找——方法一:顺序查找(时间复杂度O(n))
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 个结点的完全二叉树的节点父子关系:
应当注意的一点是:一般二叉树也可以采用这种结构,但会造成空间浪费
链表存储
typedef struct TreeNode *BinTree; typedef BinTree Position; struct TreeNode{ ElementType Data; BinTree Left; BinTree Right; }
3、二叉树的遍历
先序遍历:访问根结点;先序遍历其左子树;先序遍历其右子树
void PreOrderTraversal(BinTree BT) { if(BT) { printf("%d", BT->data); PreOrderTraversal(BT->Left); PreOrderTraversal(BT->Right); } }
中序遍历:中序遍历其左子树;访问根结点;中序遍历其右子树
void InOrderTraversal(BinTree BT) { if(BT) { InOrderTraversal(BT->Left); printf("%d", BT->Data); InOrderTraversal(BT->Right); } }
后序遍历:后续遍历其左子树;后续遍历其右子树;访问根结点
void PostOrderTraversal(BinTree BT) { if(BT) { PostOrderTraversal(BT->Left); PostOrderTraversal(BT->Right); printf("%d", BT->Data); } }
附注:先序、中序和后序遍历过程:遍历过程经过结点的路线一样,只是访问各结点的时机不同。下图在从入口到出口的曲线上用ⓧ、★和△三种符号分别标记出了先序、中序和后序访问各结点的时刻
先序:当曲线第一次经过一个结点时,就列出这个结点;中序:当曲线第一次经过一个树叶时,就列出这个树叶,当曲线第二次经过一个内点时就列出这个内点;后序:当曲线最后一次经过一个结点而返回这个结点的父亲时,就列出这个结点。
非递归遍历算法实现的基本思路:使用堆栈。我们以中序遍历的非递归算法为例:
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表示该结点可以访问。
层序遍历基本过程:先根结点入队,然后:
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); } }
输出二叉树中的叶子结点
在二叉树的遍历算法中增加检测结点的"左右子树是否都为空"
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
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大于根结点的键值,在右子树中继续进行搜索
若两者比较结果相等,搜索完成,返回指向此结点的指针。
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; // 查找成功,返回结点的地址 }
上面程序中的两处递归调用都是尾递归,因此可以方便的改写为迭代函数,以便提高执行效率(注意到,查找的效率取决于树的高度)
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; // 查找失败 }
查找最大和最小元素
只需注意到以下事实:
查找最小元素的递归函数
Postion FindMin(BinTree BST) { if(!BST) return NULL; // 空的二叉搜索树,返回NULL else if(!BST->Left) return BST; // 找到最左叶结点并返回 else return FindMin(BST->Left); // 沿左分支继续查找 }
查找最大元素的迭代函数
Position FindMax(BinTree BST) { if(BST) while(BST->Right) BST = BST->Right; // 沿右分支继续查找,直到最右结点 return BST; }
二叉搜索树的插入
关键是要找到元素应该插入的位置,可以采用与Find类似的方法
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; }
关于上面的代码,多说一点,就是关于递归调用返回的时候需要赋值给左子树或右子树,这在大多数赋值的情况下显得多余(就像是说,把当前树的左子树赋值给它的左子树),但是它是必须的,因为在插入元素的时候我们需要知道它的父结点的左指针或右指针。我们也可以消除不必要的赋值,但是它是以增加逻辑判断为代价的,还不如原先的方式显得清晰、美观。
二叉搜索树的删除
要考虑三种情况
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树的调整分为四种情况,分别为左单旋、右单旋、左右双旋、右左双旋。值得注意的一点是:有时候插入元素即便不需要调整结构,也可能需要重新计算一些平衡因子。
何老师给的图很好,简洁明了的表达了需要调整的情况并且给出了具体调整的方法:
下面是程序实现:
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) 的时间来完成的。可以考虑能否采用二叉树存储结构?如果采用这种存储结构的话,我们更应该关注插入还是删除操作?树结点顺序怎么安排?树结构怎样?
堆的两个特性:
以最大堆为例,其主要操作有:
最大堆的创建
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,同样适用于创建最小堆。
最大堆的插入
思路:首先默认插入位置在完全二叉树的下一个位置,通过向下过滤结点的方式,从其父结点到根结点的有序序列中寻找合适的位置进行插入操作
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)。
最大堆的删除
思路:取出根结点(最大值),同时删除它,方法就是用堆中的最后一个元素代替之(和插入操作一样,这里的代替只是形式上方便理解的说辞,实际上我们只是用一个临时变量保存其值而已,这比真实的替代更省时),但是其位置不一定正确,因此需要从根结点开始向上过滤下层结点。
ElementType DeleteMax(MaxHeap H) { // 从最大堆H中取出键值为最大的元素,并删除一个结点 int Parent, Child; ElementType MaxItem, temp; if(IsEmpty(H)) { printf("最大堆已为空"); return; } MaxItem = H->Elements[1]; // 取出根结点最大值 // 用最大堆中最后一个元素从根结点开始向上过滤下层结点 temp = H->Elements[H->Size--]; for(Parent = 1; Parent*2 <= H->Size; Parent = Child) { Child = Parent*2; if((Child != H->Size) && (H->Elements[Child] < H->Elements[Child+1])) Child++; // Child指向左右子结点中的较大者 if(temp >= H->Elements[Child]) break; else // 移动temp元素到下一层 H->Elements[Parent] = H->Elements[Child]; } H->Elements[Parent] = temp; return MaxItem; }
最大堆的建立
建立最大堆:将已经存在的N个元素按最大堆的要求存放在一个一维数组中
重点说下方法二,从完全二叉树的倒数第二层开始调整,因为其左、右子树只有一个结点,本身构成了一个堆,因此可以用过滤的方式以当前层为根,将根放到合适的位置。经过一轮调整,可以从倒数第三层开始(其左、右子树仍然各自构成一个堆),续行此法,直到完全二叉树的Root放置到合适的位置为止。
可以证明(由每层的结点数和该层的最多交换次数找出一般规律,利用错位相消可解出闭形式)这种方法的时间复杂度是线性的,即T(N) = O(N)。
7、哈夫曼树与哈夫曼编码
我们需要先引入一个概念——带权路径长度(WPL):
设二叉树有n个叶子结点,每个叶子结点带有权值Wk,从根结点到每个叶子结点的长度为Lk,则每个叶子结点的带权路径长度之和就是WPL = Σ(k=1~n)WkLk
最优二叉树或哈夫曼树就是WPL最小的二叉树,因此哈夫曼树说白了就是根据结点不同的查找频率构造的最有效的搜索树。
基本思路:每次把权值最小的两颗二叉树合并,把两者的和作为当前树新的权值,再取两个权值最小的二叉树合并,续行此法,直至结点取空。
下面是时间复杂度为O(NlogN)的做法:
typedef struct TreeNode *HuffmanTree; struct TreeNode{ int Weight; HuffmanTree Left, Right; } HuffmanTree Huffman(MinHeap H) { // 假设H->Size个权值已经存在H->Elements[]->Weight里 int i; HuffmanTree T; BuildMinHeap(H); // 将H->Elements[]按权值调整为最小堆 for(i = 1; i < H->Size; i++) // 做H->Size-1次合并 { T = malloc(sizeof(struct TreeNode)); // 建立新结点 T->Left = DeleteMin(H); // 从最小堆中删除一个结点,作为新T的左子结点 T->Right = DeleteMin(H); // 从最小堆中删除一个结点,作为新T的右子结点 T->Weight = T->Left->Weight + T->Right->Weight; // 计算新权值 Insert(H, T); // 将新T值插入最小堆 } T = DeleteMin(H); return T; }
答案是肯定的,比如对于一组权值{1,2,3,3},不同构的两颗哈夫曼树如下:
容易算出二者的WPL值均为18,之所以出现这样的结果是因为3个权值为3的结点合并的时机不同导致的。
考虑这样一个问题:用位串来编码英语字母表里的字母(不区分大小写),可以用长度为5的位串来表示每个字母,这样用来编码数据的总数是5乘以文本中的字符数,有没有可能找出这些字母的编码方案,使得在编码数据时使用的位更少?若可能,那么就可以节省存储空间。
哈夫曼编码作为一种不等长的编码形式,一个需要解决的关键性问题就是如何避免二义性。为了保证无二义地解码,我们采用前缀码——任何字符的编码都不是另一个字符编码的前缀。用二叉树进行编码:左右分支0、1;字符只在叶节点上。
8、集合及运算
更加简便的方法是采用数组存储形式,数组中有两个域:Data和Parent。其中Parent为负数表示根结点,非负数表示双亲结点的下标。数组中每个元素的类型描述如下:
typedef struct{ ElementType Data; int Parent; }SetType;
查找某个元素所在的集合(用根结点表示)
int Find(SetType S[], ElementType X) { // 在数组中查找值为X的元素所属的集合 // MaxSize是全局变量,为数组S的最大长度 int i; for(i = 0; i < MaxSize && S[i].Data != X; i++) ; if(i >= MaxSize) return -1; // 未找到X,返回-1 for(; S[i].Parent >= 0; i = S[i].Parent) ; return i; // 找到X所属集合,返回数根结点在数组S中的下标 }
集合的并运算
void Union(SetType S[], ElementType X1, ElementType X2) { int Root1, Root2; Root1 = Find(S, X1); Root2 = Find(S, X2); if(Root1 != Root2) S[Root2].Parent = Root1; }
注意到上面的Union操作可能导致的一个问题就是树倾斜问题严重,导致Find操作低效,因此一个自然的想法,就是把小的集合合并到大的集合中,为此可以为数据结构增加一个域代表该集合的元素个数,但是这是没有必要的,因为除了根结点外,其他的结点无需保存集合元素个数,这样一个更好的方法便是:将集合的根结点的Parent设置为当前集合元素个数的负数。这样的话,合并的时候只需要取其绝对值参与运算即可。
说完了Union的优化,我们来考虑一下Find操作是否可以优化,答案是肯定的,就是所谓的路径压缩,每次查找一个结点的时候,将其查找路径上的全部结点直接指向其父节点。后面我会写一篇PAT树部分的习题解答,关于这两个操作优化后的具体实现可以在里面找到。
(END_XPJIANG)