数据结构篇(三):
本章让我们来讲讲数据结构中,树这一部分。注意树分三次来讲,此时为第一讲。
文章目录
1.首先让我们简单了解下什么是树
首先,数据结构中的树和我们生活中见到的树不同,它是一棵“倒立”的树,即树的根在最顶上,叶子在最下面。它反映的是一种层次关系
如上:
比如以你的D盘为起源,即为根。
D盘目录下的文件夹(如上述学习资料文件夹等)就称为是D盘衍生出来的分支,即子树。
这些文件夹下包含的新的文件夹或者文件也就是当前这些文件夹衍生出来的分支,是相当于上一子树的子树。
而那些无法再形成分支,即无法包含其他文件的的文件(比如LOL.exe这些文件)就是相应的树的叶。
那么为什么我们要用树来解决例如上述的层次问题呢?
原因在于树的高效性。在谈高效性之前,首先让我们对比下不同方式的查找、删除等操作,以此来体现树的高效性。
2.查找操作
首先,查找是根据某个给定的关键字K,从集合R中找到关键字与K相同的记录。
查找又分成静态查找和动态查找。
静态查找:集合的记录是固定的,没有插入和删除操作,只有操作。
动态查找:集合的记录是动态变化的,除了查找还可能发生插入和删除。
(1).让我们先来看看数组顺序查找怎么操作的
放出顺序查找(静态查找)代码:
int SequentialSearch(List Tbl,ElementType k)
{
int i;
Tbl->Element[0] = K;
for(i = Tbl->Length;Tbl->Element!=K;i--);
return i;
}
typedef struct LNode *List;
struct LNode
{
ElementType Element[Maxsize];
int Length;
}
如上,我们建立了一个数组,并构建了相应的结构来指向它。其中,结构题包括两个变量,一个变量指向数组的头,一个变量为数组长度。
上述代码中,还用到了一个特别的知识——哨兵。
什么叫哨兵呢?正常我们在查找中的判断一般有两个部分,一部分判断值是否相等,另一个条件判断是否到数组边界(数组下标),哨兵的作用,就是在边界设定一个值(即结束或开始的那点数组元素对应的值),这样就不用判断下标,而是继续判断值是否相等,就可以执行相应的操作了。
虽然顺序查找我们生活中常常使用,但是它的复杂度很轻易的就可以看出来是,0->n(由数组元素的个数决定),显然一个复杂度为O(n)的方法,在面临数组元素过大时,会花费很多的时间。那么面临这种元素数量很庞大的查找时,我们有什么好办法可以解决吗?
答案:二分查找。
(2).二分查找
用个例子来给大家介绍以下二分查找:
还记的我们以前见过的有线电话吗?
这种有线电话的通讯方式需要一根根电线杆间的电话线进行信号的传输,假如我从上海打电话到杭州突然打不通了(某两根电线杆间的电话线断了),此时两地间距离有150公里,每50米有一个电线杆,此时电线杆的数量已经是个可怕的数量,如果用顺序查找的方式查找哪两根电线杆间的信号线断了,此时查找次数的多少就真的是看运气问题了。
运气好,在前几个电线杆间就找到了断掉的信号线,运气不好查个几十几百条电线杆,查到工人累坏了都找不到断掉的信号线。
这种时候,依靠顺序查找肯定无法解决我们问题了,这时就可以用我们的二分查找。
二分查找在上述问题中的应用如下:
我们首先在上海到杭州的半中间打电话,此时就可以知道电话线是断在前半段还是后半段,知道电话线断在哪半段以后(假设这里断在前半段),我们就进入前半段的中间,再次打电话,判断线断在前半段的哪个位置,一直以此类推,就可以不断缩小查找的范围,迅速准确的查找到断掉的信号线位置。
再举个例子:
我们想要通过二分查找找到值为444的元素(数组由13个元素组成,并且从小到大排序)。
首先我们先对数组的中心值mid进行判断,此时444>100,即大于数组的中心值,所以我们把目光放在后面的数组元素,即8-13号数组元素
随后我们再将mid放在后半段的中心
即10号数组元素 321,因为321<444,所以我们还要将目光向后转,在最后的三个元素11 12 13号元素中查找,这样很简单的就能找到我们所需要的查找的元素。
int BinarySearch(List Tbl,ElemntType K)
{
int left,right,mid,Nofound = -1;
left = 1;
right = Tbl->length;
while(left<=right)
{
mid = (left+right)/2;
if(K<Tbl->Element[mid]) right = mid-1;
if(K>Tbl->Element[mid]) left = mid+1;
else return mid;
}
return NotFound;
}
观察上述例子你可能发现了,二分查找效率高的前提是提前对数组进行了有序化的处理,也就是提前就知道了查找的顺序为如何。
这时,聪明的你可能已经注意到了,上述这些不断二分的模型正好满足我们构成树的条件。
上述问题可构成下述的树:
如图,我们的查找问题刚好形成了层次结构,即一棵查找判定树,其中黄色部分是上述查找444问题的路线。此时树可以发现,树的复杂度和二分查找的一样,都是log2n,比顺序查找效率高出好多。
由上我们就发现了树结构的高效率怎么体现的。
(3).树
Tree(树):n(n>=0)个结点构成的有限集合。
当n = 0时,称为空树;
对于任一一颗非空树,它具备如下性质:
1.树中有一个称为“根”的特殊结点,用r表示
2.其余结点可以分为m个互不相交的有限集,其中每一个结点又是一棵树,称为原来树的子树。
树的一些常用术语:
1.不再形成分支的结点叫做叶节点。
2.结点的子树个数称为结点的度。
3.树中所有结点中最大的度数为树的度。
4.有子树的结点是其子树的父节点。
5.有父节点的结点称为其父节点的子结点。
6.同一父节点的结点为兄弟节点。
由图,我们可以看到,上图树的根为8,随后3和10分别为8的分支,即子树,1和6是3的分支,即子树,而1以后就没有形成分支了,那么1就形成了叶结点(还有4、7、13)。
同时我们还要学会怎么区分树和非树。
让我们来分别一下,第一、三张图不是树,为什么呢?
因为除了根节点,每个结点仅有一个父节点。
第二张图不是树,为什么呢?
因为子树不相交的。
除此以外,我们还要记住,一个N个结点的树都有N-1条边。树是保证结点联通的最小的一种连接方式。
代码的实现:
这里我们要先明确一下,树能不能用数组跟链表实现呢?
首先看看数组,如果用数组做链表,想想就比较难,虽然数据可以按顺序存储起来,但是我们没办法区分每个结点对应的子树和父节点是谁。
综上,所以我们一般用链表来实现,接下来我们看看链表怎么操作:
如上图的树,我们一般用下面这种方式的链表实现:
即由结构来定义每个结点定义:
看图中的A,它的分支有3个(B、C、D)即子树,所以它结构中包含三个指针域。分别指向B、C、D,剩余结点以此类推。
但是,上述结构有一个很大的问题,即每个结点指针域的个数是不一样的。
对此情况,我们首先想到的就是将每个结点的指针域个数统一,但是这种方法经常会造成许多空间的浪费。所以这种方法我们一般不取用,那么我们如何解决这种问题呢?
答案为兄弟节点。
兄弟节点:
如图,一个结点中左边为第一个孩子,右边为它的兄弟结点。
例如,我们将把如上的树结构转变为下述结构:
如图A为根结点,所以它的兄弟节点为空,B为它的第一个孩子。
(4).二叉树
一个有穷点的集合。
这个集合为空。
若不为空,则它是由根结点和称之为左子树和右子树的两个不相交的二叉树组成,二叉树的子树有左右之分。
斜二叉树:
完美二叉树:
除了最底下的叶节点没有儿子,其余结点都有
完全二叉树:
即只有最底下的叶节点缺失一部分,且不破坏原来树结点的顺序。
如下:
注意如下情况不是完全二叉树::
如上,因为9结点应该是D的子树,但是此时变为E的子树了,树的结点顺序被打乱了,所以不是完全二叉树。
一个二叉树第i层的最大结点数为:2^(i-1)个。
深度为K的二叉树最大结点数为2^k-1个。
对任何非空二叉树T,若n0表示叶结点的个数、n2是度为2的非叶结点个数,那么两者满足关系n0= n2 +1。
比如此时n0 = 4,n1 = 2,n2 = 3,得出:
n0 = n2+1
接下来给大家讲解下如下函数:
Boolean IsEmpty(BinTree BT);//判断是否为空
void Traversal(BinTree Bt);//遍历
BinTree CreatBinTree();//创建一个二叉树
这里跟大家顺便提一下,二叉树遍历的操作分四种:
1.先序,根、左子树、右子树
2.中序,左子树、根、右子树
3.后序,左子树、右子树、根
4.层次遍历,从上到下,从左到右
(5).二叉树的存储结构
1.顺序存储结构
我们先来考虑一下,数组是否可以帮助我们实现顺序存储结构,回顾下之前学过的二叉树知识,我们不难发现,有一个树的结构我们特别好用数组实现——完全二叉树。
我们根据上图,顺序排列树的各个元素。
由此图,我们很神奇的发现,我们很容易可以找到每个结点的父节点。当一个结点的序号为i,那么它的父节点序号为[i/2];
并且结点左孩子的序号为2i,(若2i<=n,否则没有左孩子);
结点右孩子的序号为2i+1,(若2i+1<=n,否则没有右孩子);
比如说上图中只有9个结点,但是你5结点的左孩子为10>9,所以5结点就没有孩子结点。
对于一般的二叉树,我们可能左缺一个结点,右缺一个结点,此时我们可以将其补全,形成一个完全二叉树,相应补全的位置留下一个空位。
例如:
上图是一张普通的二叉树。
此时将其补全,
列出对应的列表:
如上,我们就可以实现一般二叉树用数组实现。
但是别高兴的太早了,这种方法在补全的同时也相应的浪费了许多的空间资源,所以这种方法还是不大好用,所以我们一般还是采取链表存储。
2.链表实现树的存储
代码:
typedef struct TreeNode *BinTree;
typedef BinTree Positon;
struct TreeNode
{
ElementType Data;
BinTree Left;
BinTree Right;
}
如上述代码,我们就构建了如图所示的结点:
其中没有儿子的地方我们直接指向Null就好。
3.二叉树的遍历
还记得我们提过的树的遍历方式嘛?
先序、中序、后序、层次遍历这四种,接下来以此对其代码的实现进行讲解:
先让我们来看看先序遍历:
遍历的过程 1.访问根节点 2.先序遍历其左子树 3.先序遍历其右子树
void PreOrderTraversal(BinTree BT)
{
if(BT)
{
printf("%d",BT->Data); //访问根节点
PreOrderTraversal(BT->Left); //访问左节点
PreOrderTraversal(BT->Right); //访问右节点
}
}
树结点访问的走向如下:
上图遍历的顺序为:A(BDFE)(CGHI)(左边遍历完了再来右边)
中序遍历:
遍历过程为 1.中序遍历其左子树 2.访问根结点 3.中序遍历其右子树
代码如下;
void InOrderTraversal(BinTree BT)
{
if(BT)
{
InOrderTraversal(BT->Left);
printf("%d",BT->Data);
InOrderTraversal(BT->Right);
}
}
树节点访问走向如下:
上图遍历顺序为:(DBEF)A(GHCI).
后序遍历:
遍历过程为 1.后序遍历其左子树 2.后序遍历其右子树 3.访问根结点
代码如下:
void PostOrderTraversal(BinTree BT)
{
if(BT)
{
InOrderTraversal(BT->Left);
InOrderTraversal(BT->Right);
printf("%d",BT->Data);
}
}
树节点访问走向如下:
上图遍历顺序为:(DEFB)(HGIC)A.
注意无论是先序、中序、后续遍历,在进入新的带有孩子结点的结点时,都要根据对应遍历的规则进行遍历(乱的话多看看图就懂啦),它们访问结点的路线一样,只是访问各个结点的时机不同。
上述的遍历方法的实质其实都是堆栈。
此时有的人可能就要问了,那我们能不能直接用堆栈实现遍历呢?
接下来就让我们谈谈这个问题。
堆栈实现非递归的遍历:
我们用中序遍历讲解:
如图,第一次碰到根结点A时,因为中序需要将左边的所有元素遍历完后,再来printf,但是我们不能第一次碰到A不能进行printf操作,但是如果不能printf操作的话我们怎么知道它又回到A点了呢?此时就体现了堆栈的原理。
让我们以左半边为例:
如图,我们碰到A时先遍历到最左边,即到D点,此时形成第一个堆栈内结构,随后将遍历完左边后因为D没有子结点,所以再回到B点,并将遍历过的元素抛出来,此时就形成了第二个堆栈内的图,因为此时B有右结点,所以我们按照中序遍历的顺序继续向右边遍历下去,形成第三个堆栈,最后我们遍历完右边回到A点,并将遍历过的元素抛出来,就形成了最后一个堆栈。
上述操作总结一下就是:
1.遇到一个结点,就把他压栈,并去遍历它的左子树。
2.当左子树遍历结束后,从栈顶弹出这个结点并访问它
3.然后按其右指针再去中序遍历右子树
程序实现如下:
void InOrderTraversal(BinTree BT)
{
BinTree T = BT;
Stack S = CreatStack(MaxSize); //创建堆栈
while(T||!IsEmpty)
{
while(T)
{
Push(S,T);
T = T->Left; //向左遍历并压入堆栈
}
if(!IsEmpty(S)) //判断是否为空
{
T = Pop(S);
printf("%5d",T->Data); //进行右遍历
T = T->Right;
}
}
}
层次遍历:
首先在讲层次遍历之前,先让我们考虑下,遍历操作到底是干什么?
我们对二叉树遍历,实际上就是将树二维的线性结构转换为一维的线性序列的过程。这个过程中我们常常遇到一个问题,一个结点与它关联的有两个结点,你访问了一个结点,那另一个怎么办,它自己本身怎么办?
所以我们要用一种方法将它本身还有其右儿子保存起来,这个方法常常是堆栈或者队列。
接下来讲讲用队列保存结点的右儿子:
操作可以总结为 遍历从根结点开始,首先将根节点入队,然后开始执行循环,结点出队,访问该结点,其左右儿子入队。
首先先把根结点放到队列中去,如下:
将A抛出后,输入A的左右儿子BC:
抛出B,然受输入B的左右儿子:
再把C抛出来,把C的左右儿子输入进去:
就这样以此类推,实现队列按层次遍历的一种方法。
操作总结如下:
1.从队列中取出一个元素
2.访问该元素所指结点
3.若该元素所指结点的左右孩子结点非空,则将其左右孩子结点的指针顺序入队。
void LevelOrderTraversal(BinTree BT)
{
Queue Q;
BinTree T;
if(!BT) return; //如果是空树则直接返回
Q = creatQueue(MaxSize) //创建并初始化队列
AddQ(Q,BT) //根结点放到队列里去
while(!IsEmptyQ(Q))
{
T = DeleteQ(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);
preOrderPrintLeaves(BT->Left);
preOrderPrintLeaves(BT->Right);
}
}
上述代码,通过不断循环嵌套函数本身(每个结点就是一次函数),就可以不断遍历结点。也许比较难理解,这里多看几遍就好。
求二叉树的高度:
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;
}
一样的,每个结点执行一次函数,每次执行的的结果先是进入左孩子结点或者右孩子结点,同时在进入的过程中,高度加1,就实现了每一条分子高度的计算。
二元运算表示树及其遍历:
如图结构如上,非根结点存放计算符号,叶结点存放数据。
先序遍历得到前缀表达式:++a × b c×+ d e f g
中序遍历得到中缀表达式: a+ b *×c +d ×e+ f × g
后序遍历得到后缀表达式:ab c × +d e × f +g × +
观察上式,我们发现中序遍历存在误差,为什么呢?
因为中缀表达式会受到运算优先级的影响。
那么我们怎么解决这个问题呢?
加括号,左子树开始时加左括号,结束时加右括号。
以上就是树第一章的全部内容。
遇到困难时不要抱怨,既然改变不了过去,那么就努力改变未来。大家加油!
(1条消息) 数据结构篇(一):_风声向寂的博客-CSDN博客
(1条消息) 数据结构篇(二):_风声向寂的博客-CSDN博客