一、树的定义和基本术语
1.1树的定义
树是由一个根结点(树的根结点),在根结点的基础上,再加上若干根结点(子树的根结点)所形成的数据结构。(一棵树有且仅有一个根结点,这里要分清我所说的根节点含义)。
我来讲一下我这样定义一棵树的原因:我们假设一个树包含两个子树,左子树包含两个子树且这两个子树有且仅有一个结点,右子树有且仅有一个结点。那么此时我们是不是可以把这棵树想象成一个根结点上连着两个根结点,然后左子树根结点上又连着两个子树的根结点。
这样以此类推下来,我们不难发现树的定义是递归的,这样根结点上连着子树的根结点,不就是递归的表示方法吗。
1.2基本术语
1.2.1祖先、子孙、双亲、孩子、兄弟
祖先:在一棵树中,从任意一个结点A沿着树的边(路径唯一)向下走若干结点遇到结点K,那么A就是K的祖先,K是A的子孙。且结点A在沿着这唯一的路径走遇到K之前所遇到的所有结点都是K的祖先。那么我们可以得知K的双亲也可以说是K的祖先。双亲:A沿着这条路径走遇到的K的前一个结点B就是K的双亲,K的双亲也是与K直接相连的结点。那么孩子的定义也出现了。孩子:K就是B的孩子。兄弟:如果B还有另一个孩子H,那么K和H就是兄弟。
1.2.2树的度、分支结点、叶节点
说到树的度我们要先说结点的度,判断一个结点的度只需要看这个结点有几个孩子(分支)就好,一个孩子度为1,2个孩子度为2。如果只有一个孩子那么这个结点的度为0,此时我们称这个结点为叶结点,也叫做终端结点。那么我们也就可以知道,度不为0的结点就称为分支结点。树的度就是:树的所有结点中度最大的结点的度就是树的度。
1.2.3树的深度、高度和层次
树的层次:一棵树中根结点为第一层,它的子结点为第二层,以此类推。树的深度:一棵树中从根结点出发向下看一共有多少层,树的深度就是多少。树的高度:从叶节点出发向上看共有多少层,它的高度就是多少。
1.2.4有序树和无序树
这里用阿拉伯数字举例:一串有序的阿拉伯数字,按照树的层次遍历顺序,一次将数字安放到结点上,此时树呈现有序的状态。如果反转其中的任意结点,那么此时就是无序树。
1.2.5路径和路径长度
两个结点之间经过的(·结点序列·)就是路径。路径上经过的边的个数就是路径长度。
二、树的性质(重点)
2.1树的总结点数=所有结点的度数值和+1。
那么这个结论是怎么来的呢?我们可以从度的定义下手,度是指一个结点连了几个孩子。那树的总结点数是不是就可以转化为这个树中一共有多少个孩子。然而此时根结点并没有被算在内,因为根结点没有父节点,也就谈不上根结点是谁的孩子,所以还要额外加上一个根结点。
2.2树的度和m叉树的区别
树的度我们讲过,树的度是一棵树中度最大结点的度。那么也就意味着,这颗树中至少要有一个结点的度等于树的度。
而m叉树不同,一个根结点所连接的子树可以是空树。那么也就是说,只有一个根结点的树也可以被称为m叉树。甚至空树也可以被称为m叉树。但是每个结点的度不能大于m。
2.3度为m的树中第 i 层以及m叉树的第 i 层最多有m ^ ( i - 1 )个结点。
我们需要思考这个最多如何才能达到呢?性质里第 i 层结点越多,是不是证明他们的双亲节点越多呢?那是不是也就代表着,只要让树的每一层都达到最多,那么第 i 层结点数也就可以达到最大值。那么为了让每一层结点都达到最多,那么就需要让单个结点达到最多,也就是结点的度。那么也就是第一层只有一个根结点为1,第二层为 m(度)个结点,第三层为 m^2(度再乘上度)个结点,以此类推下去,第 i 层就有 m ^ ( i - 1 ) 个结点。不难发现,这是一个首项为 1 公比为 m 的 *等比数列*。那么 m 叉树的分析方法同理,但是还是要注意度为 m 和 m 叉树的区别。
那么高度为 h 的 m 叉树最多有多少个结点?前面我们说过这是一个等比数列,所以只需要把前面的第 i 层换成 h 即可,那么等比数列的前 h 项和就可以算出( m ^ h - 1 ) / ( m - 1 )。
2.4高度为 h 的 m 叉树以及度为 m 的树结点数至少分别为 h 和 h + m + 1。
这里只需要思考一下 m 度和 m 叉树的区别即可分析得出。看到这里需要自己回忆。
2.5具有n个结点的m叉树最小高度:[Logm(n(m-1)+1)]向上取整。
三、二叉树的定义和基本术语(考研重点)
3.1二叉树的定义
每个结点最多有两个子树的树(空树也是二叉树),且每个结点所连接的子树单独拿出来也是二叉树的树被称为二叉树。
3.2几个特殊的二叉树
3.2.1满二叉树
满二叉树是一种仅由度为0和度为2的结点构成的二叉树,特点是只有最后一层有叶结点,且分支结点的度都是2。满二叉树还有一个特性,如果我们从1开始编号一下这颗树,那么第 i 个结点的左孩子编号为 2i 右孩子编号为 2i + 1,且如果给出孩子结点编号,求双亲编号,如果给出左孩子只需要除以二即可,如果给出右孩子,那么就需要除以二后向下取整。
3.2.2完全二叉树
完全二叉树如果从 1 开始按层次编号,那么如果完全二叉树能和满二叉树的编号一一对应,那么这棵树就可以被称为完全二叉树。可以说完全二叉树就是满二叉树去掉几个编号最大的结点变成的。(这里可以参考数据结构2024版第125页的图)完全二叉树的第一个特性是叶子结点只存在于最后两层。第二个特性是只存在1个度为1的结点。第三个特性与满二叉树相同。第四个特性:如果一个完全二叉树中含有n个结点,那么n/2向下取整以前的结点就是分支结点。n/2向下取整以后的结点就是叶子结点。(完全二叉树还有一个重要特性,如果存在的那个度为1的结点,那么这个结点的孩子只能是左孩子,不能是右孩子)。
3.2.3二叉排序树
二叉排序树是由一个连着两个孩子的带有编号的根结点,且这两个孩子中,左孩子的编号小于根结点,右孩子的编号大于根结点。既然二叉排序树有这样的性质,那么如果我们想要寻找一个特定编号的结点,就可以利用类似折半查找法的方法来查找,大大减少时间复杂度。插入同理。
3.2.4平衡二叉树
根结点的左子树和右子树的深度之差不超过1的树叫做平衡二叉树。
四、二叉树的性质
1.非空二叉树上的叶结点数等于度为二的结点数加1。
证明:一棵树共有n个结点,而树的结点数又等于树的总度数加1。那么就得到了度为1的结点数+度为2的结点数×2+1 = n。而度为1的结点数+度为2的结点+度为0的结点数=n(二叉树中只存在度不大于2的结点)。两式相减就可以得出此结论。
2.非空二叉树的第 i 层最多有2 ^ ( i - 1 )个结点。前面m叉树有说过。
3.高度为h的二叉树最多有2 ^ h - 1个结点。前面也讲过。
4.完全二叉树的高度为log2(n+1)向上取整 或 log2n向下取整+1
证明1:我们已知一颗高度为h的二叉树最多有2 ^ h - 1个结点(满二叉树),那我们设结点数为n,那么 n <= 2 ^ h - 1。然后两边取对数即可。(此种情况为第一个极端:高为h的二叉树为满) 此处还需要让n > 2 ^ ( h - 1 ) - 1然后即可判断出结果向上取整还是向下取整。(此处的2 ^ ( h - 1 ) - 1是高度为 h - 1的满二叉树)
证明2:我们已知一颗高度为h的二叉树至少有2 ^ ( h - 1)个结点(此结论可以利用求一个高度为 h - 1的满二叉树的结点数再+1求得)那么同理 n >= 2 ^ (h - 1)两边取对数即可。此处的取整方式参考证明1中的方法。
5.如果给出完全二叉树的结点数 n 即可求出度为0的结点数以及度为2的结点数
证明:我们已知度为0的结点=度为2的结点+1,那么也就代表着度为0的结点+度为2的结点是一个奇数,且一个完全二叉树最多只能有1个度为1的结点。那么如果这个结点数 n 为奇数那么就意味没有度为1的结点,那么就可以求出另外两者。偶数则有1个度为1的结点求法相同。
五、二叉树的存储结构
5.1顺序存储
创建一个结构体包含每个结点的数据元素以及一个判断结点是否为空的变量。然后再定义一个数组arr来存储这些二叉树的结点。
首先我们先讲编好号的完全二叉树。那么我们知道数组的首元素下标为0,为了使下标与完全二叉树的节点编号一致,那么就需要从数组的第二个元素也就是下标为1的位置开始存储,这样就可以直接完成一些基本操作,例如寻找第 i 个结点的左孩子,那么就对应数组下标的 2i。
可如果这是一个普通的二叉树呢?我们如何完成找到第 i 个结点的左孩子或者右孩子或者父节点这种基本操作呢?如果只是普通的为这个二叉树进行编号,显然是做不到完全二叉树顺序存储的基本操作的。那么我们就可以令这个普通的二叉树的编号与其对应的完全二叉树的编号一一对应,然后存放在数组中的时候也按照下标对应编号的形式来存储不就能解决这个问题了吗。
但是这会发生一个问题,一个数组开辟的空间在内存中是连续的,如果我们这样用数组来存放普通二叉树。那么就会发生数组中两个元素之间有很多空位置,会造成空间浪费。那么如何解决这种空间浪费呢?显然链式存储就可以做到这一点。
5.2链式存储
首先我们创建一个结构体,其中包含了结点的数据,以及指向结点左孩子的指针以及指向结点右孩子的指针(结构体的自引用,typedef重命名时不能匿名结构体类型)。然后利用malloc函数开辟一个结点的空间,用指针指向这个空间。如果我们想在树中再加入一个结点只需要再malloc一块新的结点空间并让根结点的左孩子指针或者右孩子指针指向新的结点即可。
这种存储方式显然想要找到一个结点的孩子结点很容易,但是找到这个结点的父结点就很难,需要从头遍历一遍。解决方式也很简单,只需要在创建的结构体中加上一个新的指向这个结点的父结点的指针即可。
六、二叉树的遍历
遍历是指从某一个结点出发,访问这个数据结构中的全部结点
6.1先序遍历
先序遍历是从根结点出发先访问左子树再访问右子树,也就是根左右,那么根据树的递归特性,他们的子树也符合根左右的遍历顺序
6.2中序遍历
中序遍历是从左子树出发先访问根结点再访问右子树,也就是左根右,那么根据树的递归特性,他们的子树也符合左根右的遍历顺序
6.3后序遍历
后序遍历是从左子树出发先访问右子树再访问根结点,也就是左右根,那么根据树的递归特性,他们的子树也符合左右根的遍历顺序
那么这里还需要思考,如果某个结点为空怎么遍历?只需要将这个结点用空结点给补上,然后按照遍历方式来遍历,然后将遍历序列中的空结点去掉即可,这里熟练之后可以直接写。
遍历的代码实现只需要写一个递归函数即可,这里以先序遍历举例:写一个遍历函数,第一句代码访问树的根结点,第二句需要调用这个遍历函数访问左子树,第三句代码还需要再次调用这个代码访问右子树。
6.4层次遍历(补充队列那一章)
层次遍历需要我们利用队列的性质,首先访问二叉树的根结点A,根结点如果有左右孩子BC那么就让A出队,然后让BC入队,然后访问B,如果B有左右孩子DE那么就让B出队,让DE入队,然后访问C,如果C没有子结点,那么直接出队,这样类推下去,即可得到ABCDE。
注:二叉树的遍历有一个很常见的考点,如何通过两种遍历方式来确定唯一的二叉树?
这里的突破口就在与先序遍历和后序遍历以及层次遍历,因为先序遍历与层次遍历的根结点一定在第一个位置,后序遍历的根结点一定在最后一个位置,然后就可以找到中序遍历中的根结点,根结点左方是左子树,右方是右子树。那么哪两种遍历方式能确定唯一的二叉树也就显而易见了。也就是如果能找到根结点是谁,再配合中序遍历即可确定唯一的二叉树。
七、线索二叉树
7.1线索化二叉树
首先我们下一个定义,我们按照某一种遍历方式来遍历一颗二叉树,然后就可以得到一串结点的值。这里假设我们按照某一种遍历方式得到了一串值ABCDEFG。那么我们将其想象成线性表。这里A是B的前驱结点,C是B的后继结点。这里要注意:这里的前驱后继结点指的并不是树中的前驱后继结点。而是这个线性表的前驱结点和后继结点。也就是前驱和 * 序前驱是不一样的,需要好好思考辨别。
而先线索二叉树的作用就是快速找到一个二叉树遍历后的前驱以及后继。首先我们来看,如果不用线索二叉树,如何找到一个结点的前驱。
这里以中序遍历举例
首先我们需要一个指针p来指向我们所要寻找的结点,再用一个指针q来指向中序遍历的第一个结点,再用一个指针pre来和q指向同一个位置,然后让q开始中序遍历的顺序,如果q指针与p指针指向同一个位置,那么就让pre也向前遍历,当 q = p 时,此时不需要在令pre指针向前,此时的pre指针指向的就是p的前驱,这样就找到了p的前驱。显然这种方式的时间复杂度是很高的。
而线索二叉树就可以不用这种繁琐的从头遍历。首先我们要知道线索二叉树是怎么来的。
我们在链式存储一棵二叉树的时候,二叉树的每个结点都有一个左指针和右指针,那么这样创建好一棵树之后,所有的叶子结点的左孩子指针和右孩子指针都为空NULL,而分支结点的左孩子指针和右孩子指针有可能不为空。
那么我们是不是可以利用一下这些空指针呢?我们让左孩子空指针指向该结点的中序前驱,右孩子空指针指向该结点的中序后继即可,但是这样就有一个问题,分支结点的左孩子指针和右孩子指针是指向他们的子结点的,所以我们还需要在结点结构体中引入两个变量 ltag = 0 和 rtag = 0 在链式创建好一棵树之后,将这棵树线索化的时候就可以进行判断,如果这个左孩子指针是线索指针那么就令 ltag = 1。这样就可以区分出每个结点中的指针是否为线索指针。在线索化的过程也需要用到p指针和pre指针,这里的遍历方式我不再做赘述。
7.2在线索二叉树中寻找前驱后继
7.2.1中序遍历
在中序遍历中,给给定一个结点寻找他的前驱后继,第一种情况,如果这个结点的孩子指针已经被线索化,那么该结点的孩子指针就对应着这个结点的前驱后继。第二种情况,如果这个结点的孩子指针没有被线索化,如果想要找到这个结点的前驱结点,那么这种情况下如果孩子指针没有被线索化,那么就说明这个结点一定有左孩子,那么根据中序遍历的左根右,只需要看这个结点的左子树的中序遍历的最后一个结点就是这个结点的前驱。后继同理。
7.2.2先序遍历
第一种情况与中序遍历相同。但是第二种情况,如果想要找到一个分支结点的前驱就有一点麻烦了。因为先序遍历的顺序为根左右,我们想找的这个结点的左右孩子都不是这个结点的前驱。那么此时就需要用到我们前面所讲的三叉链表,在结构体中新定义一个父指针,这样就可以找到父节点,然后再找这个结点的前驱结点。这里根据先序的顺序就可以推出,我不做赘述。
7.2.3后序遍历
第一种情况与中序遍历相同。但是第二种情况,如果想要找到一个分支结点的后继就有一点麻烦了。因为后序遍历的顺序为左右根,想找的结点的左右孩子均不是他的后继,处理方式与先序遍历的方式相同。找到父节点再根据后序遍历的顺序来找后继即可。
八、树的存储结构
8.1双亲表示法(顺序存储)
双亲表示法就是令每个结点结构体中再加入一个线索变量,用来找到这个结点的双亲。比如,我们层次遍历一棵树,就得到了一个从根结点到最后一个结点的字符串。那么现在我们将这些字符串存放在一个数组中。那么此时我们只需要在定义结点的结构体中再加入每个指针的父节点在数组中的下标即可,然后由于根结点没有父节点,那么就需要令根结点的线索变量为-1即可。有没有发现,这种存储方式有点像我在线性表中讲到的静态链表?
那么如果想要增加一个结点,只需要在我们开辟的数组中再加入一个结点,并利用线索变量来调整这个结点所要放置的位置。删除一个结点同理。但是这里需要注意,如果我们删除的结点不是叶子结点,而是分支结点,那么就需要将分支下的所有结点全部删除。
这里的双亲表示法,与二叉树的顺序存储虽然都是顺序存储,但是前者要进行查找指定结点的孩子只能从根结点开始遍历,因为双亲表示法只有指向父节点的“指针”。但是二叉树的顺序存储虽然比较浪费内存空间,但是可以快速查找一个结点的孩子结点。因为二叉树的顺序存储的结点编号不仅表示了结点在数组中存放位置,还表示了结点与结点之间的逻辑结构。
8.2孩子表示法
孩子表示法是父结点中存放指向他的第一个孩子结点的指针。
8.3孩子双亲表示法(链式存储)(重点)
孩子兄弟表示法就是一种纯链式存储的方式来存放一棵树。孩子兄弟表示法就是将任意一棵树构建成相应的二叉树的形式,底层原理是从根结点出发将一个结点的兄弟放在二叉树中这个结点的右孩子位置,将这个结点的孩子放在这个结点在二叉树中的左孩子位置。这里语言无法形象描述孩子兄弟表示法的转换方式,需要看书上画的图。
如何代码实现孩子双亲表示法:首先我们要创建一个结构体,结构体中包含结点的数据域以及指向这个结点左孩子的指针(用来存放树的第一个孩子),还有右指针(用来存放这个结点的第一个兄弟)。这样就可以将任意一棵树转化为二叉树的形式。然后就可以用二叉树的存储结构来存储这棵树,这样就能方便很多。
8.4森林和二叉树的转化
如果你真的去实践了一下一棵树如何转化为二叉树,就会发现,任意一棵树转化为二叉树之后,根结点都是没有右孩子的。因为一棵树只能有一个根结点,而根结点是没有兄弟的。但是森林不同,森林中有很多棵树,而每棵树都有根结点,那么森林转化为二叉树,只需要注意,每棵树的根结点都是兄弟关系,然后再根据树的转化方式来转化森林即可。
九、树和森林的遍历
9.1树的遍历
9.1.1先根遍历
先根遍历是先访问树的根结点,再对树的子树进行先根遍历(从左到右)(递归)。这样遍历下来的效果其实等同于对这棵树对应的二叉树进行先序遍历。
9.1.2后根遍历
后根遍历是先对每棵子树进行后根遍历,最后再访问根结点(递归)。这样遍历下来的效果等同于对这棵树对应的二叉树进行中序遍历。这里要注意,不是后序遍历,是中序遍历!
9.2森林的遍历
9.2.1先序遍历
森林的先序遍历就是依次对森林中的每棵树进行先根遍历,这里的效果等同于对森林对应的二叉树进行先序遍历。
9.2.2中序遍历
森林的中序遍历就是对森林中的每棵树依次进行后根遍历,效果等同于对森林对应的二叉树进行中序遍历。
十、哈夫曼树
10.1基本术语
10.1.1结点的权
结点的权其实就是字面意思,就是结点在树中所占的权重。这个结点在树中越重要,那么结点的权值就越大。
10.1.2带权路径长度
结点的带权路径长度就是结点的权值×从根结点到这个结点的路径长度(经过的边数)。这里拿我们中国的铁路进行举例。比如我们想利用权值来表示两个地点的举例。假如我想从芜湖到达南京再到达上海,其中从芜湖出发要经过两条铁路,那么此时我们给上海附上一定的权值,就可以利用带权路径长度来表示从芜湖到上海的举例。
10.1.3树的带权路径长度
树的带权路径长度=这棵树的所有(·叶子结点·)的带权路径长度的总和。
10.1.4哈夫曼树
哈夫曼树就是存在若干权值各不相同的叶子结点,我们以最优的形式来存放这些叶子结点。来使我们树的带权路径长度达到最小。
这里问题就来了,我们如何才能找到这种最优的形式呢?这里加入我们有五个叶子结点,权值分别为1、3、5、6、8。首先我们让1、3作为兄弟,然后构建一个结点,来作为这两个叶子结点的父节点,这个父节点的权值=1+3。然后我们把这个父节点当成叶子结点与剩下的三个叶子结点放在一起,重新在这4个结点中寻找权值最小的两个结点。这里就是权值为4,和5为最小,那么就需要以同样的方式来存放这两个结点。这样类推下去,就可以构建出哈夫曼树。
注意哈夫曼树还有几个性质:
1.哈夫曼树不存在度为1的结点:这个性质我们可以反推,假如哈夫曼树中存在度为 1 的结点,就拿上面的1、3、5、6、8举例,如果存在度为1的结点,那么我们令这颗哈夫曼树的最底层的父节点度为1,那么原本存放1和3的位置,现在只能存放1,而另一个就不能在哈夫曼树的最底层,而要向上存放,但是哈夫曼树本着权值越小越向下层存的特点,这样存放显然违背了哈夫曼树的存放特点,故反推不成立。
哈夫曼树的应用
电报的摩斯密码就可以看成使哈夫曼树的应用,举一个简单的例子,我们要用1和0这两个数字来表示A,B,C,D这四个字母,而这四个字母在单词中出现的频率不同,加入A的频率为10%,B的频率为8%,C的频率为80%,D的频率为2%。那么如果我们直接用0和1表示这四个字母,就需要每个字母都用两个数字,A为00,B为01,C为10,D为11。但是如果给出了这四个字母的出现频率,那么可不可以让出现频率小的字母的表示方法更复杂一些,出现频率较大的字母的表示方式更简单一些呢?
方法很简单,我们令C(出现频率最大的字母)用单独的一个0来表示,A用10表示,B用111表示,D用110来表示。
这里要注意我的编码设计方式,我们将这四个字母以任意顺序,任意长度,用代码传递信息,得到的1和0的数组,翻译成字母后是唯一的。也就是说,在一串数字中,这些字母的编码是唯一的。这里举一个反例,如果我们设计C用0表示,A用01表示,B用011表示,D用000表示。那么如果我给出一个数组00001,那么这个数组翻译过来的答案将不唯一,有可能是CCCA,也有可能是CDA。那么如何避免这种情况呢?
方法也很简单,只要我们保证在设计编码的时候,让任意字母的编码都不是其余任意字母编目的前缀即可。也就是如果C用0表示,那么ABD编码的第一个数字就不能是0。
十一、并查集
并查集正如它的名字一样,这种数据结构所要完成的基本操作就是合并以及查找。比如给定任意一个森林,那么这个森林就可以看成是一个并查集。
11.1查找
给定两个结点,想要判断这两个结点在不在一个子集里(同一棵树里),那么只需要找到这两个结点的根结点,然后判断这两个根结点是不是同一个即可。那么这里找双亲的操作,在创建森林的时候,应该使用双亲表示法更加合理。
11.2合并
现在给定两个结点,让这两个结点所在的树合并到一起,那么只需要先找到这两棵树的根结点,然后让两个根结点连在一起即可。
11.3并查集的优化
我们都知道并查集的主要操作就在并和查上。合并操作要通过一个结点查找到这个结点所在树的根结点,而查找是直接查找这个结点所在树的根结点。不难发现,合并和查找都涉及到查找操作。那么我们是不是只需要优化查找的操作即可。
那么如何优化查找操作呢?在查找过程中,我们通过一个结点向上查找,那么树如果越高,查找起来是不是时间复杂度越高?那么我们只需要想个办法,让树不那么高即可。
第一种办法就是合并的时候,尽量让高度较低的树成为高度较高的树的子集。这样就能保证合并之后的树的高度与合并前的树的高度不发生变化。
第二种办法就是在查找的过程中,给定一个结点,将向上查找所遇到的所有结点全部连到根结点上。这样也能最大限度的缩短树的高度。
学完查找后,学到了树的更深层概念,由于查找的内容大部分都来自于树这种数据结构,所以我将查找部分的内容增加到了树中。接下来是关于查找的内容:
查找:
在讲查找之前我们需要知道一个概念叫做平均查找长度(ASL),平均查找长度分为成功查找的平均查找长度和失败的平均查找长度,其公式为:
成功:ASL=查找成功的每个结点需要对比的关键字次数×查找该结点的概率
失败:ASL=查找失败的空结点所需要对比的关键字次数×查找到该空结点位置的概率
概率大小如果题中未说默认为1/总值。
一、二叉排序树(BST)
二叉排序树就是根结点的值大于左子树小于右子树,而每个子树又递归的满足这个条件。其他的内容没什么好讲的,都比较简单,需要说的就是如何删除BST中的结点。
1.删除的结点为叶结点:就直接删除即可,不会影响BST的内容。
2.删除的结点为分支结点:如果直接删除该分支结点,那么分支结点下的子树就缺少了分支结点,那么我们就需要用一个结点来补充我们所删除的位置。
第一种情况:删除的分支结点只有左子树或者右子树,那么直接让左子树或者右子树的根结点连过去即可。
第二种情况:删除的分支结点既有左子树又有右子树,那么此时我们不管是用左子树的根结点还是右子树的根结点来补充,另一棵子树都可能没法再连上去。那么此时我们有两种方法来解决这个问题。
(1):用删除的结点的中序前驱结点来顶替他的位置。这个很好理解,按照BST的性质来看,他的中序前驱是这个子树中值仅次于删除结点值的结点,那么让中序前驱来顶替就满足了BST的左子树<根结点<右子树。
(2):用删除结点的中序后继结点来顶替他的位置。这个类似于第一种方法,最后也能满足BST的左子树<根结点<右子树性质。
二、平衡二叉树(AVL)
平衡二叉树也很好理解,就是根结点的|左子树高度-右子树高度|<1。的树称为平衡二叉树AVL。
查找的过程中,可能会经历找不到的时候,此时我们需要将平衡二叉树中缺失的结点插入到平衡二叉树中,那么此时就会出现问题,插入结点的时候很有可能破坏AVL原本的平衡。那么此时我们就需要寻找一种方法来重新使AVL平衡。
在插入的过程中破坏了AVL的平衡有4种破坏方式,我们要根据这四种破坏方式来对症下药,重新使AVL平衡起来。
(1)插入的位置位于最小不平衡子树的右子树的右边(RR)
这种情况需要我们对最小不平衡子树进行左旋操作。
(2)插入的位置位于最小不平衡子树的左子树的左边(RR)
这种情况需要我们对最小不平衡子树进行右旋操作。
(3)插入的位置位于最小不平衡子树的右子树的左边(RL)
这种情况需要我们对最小不平衡子树先进行右旋操作再进行左旋操作。
(4)插入的位置位于最小不平衡子树的左子树的右边(LR)
这种情况需要我们对最小不平衡子树先进行左旋操作再进行右旋操作。
注:这四种方法其实在插入完毕后可以根据树的情况自己大致猜出来应该如何操作,不必死记硬背
那么如何去寻找最小不平衡子树呢?:根据我们插入的结点位置向上寻找,第一个不满足平衡二叉树的子树就是最小不平衡子树。
我们明白了这些操作后,还是要回归到正题,查找的根本还是要知道ASL如何计算。
那根据我们的平衡二叉树的性质,我们就可以从树高与最小结点数的关系入手,我们已知高度为0的平衡二叉树结点数n0=0;那么递推下去:n1=1,n2=2,n3=4。
根据数学归纳法我们就可以得到这样的公式:
n(h)=n(h-1)+n(h-2)+1
前面讲到了在平衡二叉树中插入一个结点导致的不平衡应该如何来操作,下面我们将删除。
删除的话,如果删除的结点为叶子结点,那么与插入一样,根据树的最小不平衡子树的形状来判断左旋还是右旋。但是有一点需要注意,删除叶子结点后,我们如果通过这种方式只能让我们的最小不平衡子树重新平衡,可是最小不平衡子树重新平衡后,这个子树可能会变矮,那就会导致整棵树中出现一个更大的最小不平衡子树。那么我们只需要再次进行一遍平衡操作即可。
如果删除的结点为分支结点,那么就类比二叉排序树,使其中序前驱或者中序后继来顶替这个位置,而根结点的中序前驱和中序后继都为叶子结点,或者只有左子树或右子树的结点。这样一来直接顶替即可,然后再使树平衡。
三、红黑树
红黑树的性质其实很简单,红黑树首先是一个二叉排序树(BST)。满足左子树<根结点<右子树的性质。红黑树中各个结点有黑色有红色,而红黑树特有的几个性质就是基于这些颜色不同的结点来展开的。
1.根结点的颜色是黑色的。
2.红色结点不能与红色结点成为父子关系。
3.叶结点的颜色是黑色的(红黑树中叶结点是空结点,简称空叶结点)。
4.在同一棵红黑树中从根结点到达任何叶结点所经过的黑色结点个数都是相同的。
红黑树的查找与二叉排序树类似。
性质说完了,那么我们就要基于红黑树的性质来对红黑树进行一些操作了。这个过程真的很痛苦,性质明明那么简单,但是操作起来却这么费脑子,就好像刚学会1+1就让我去参加高考一样
首先就是红黑树的插入:
红黑树也有自己特有的性质,如果插入一个结点就很有可能会破坏红黑树原本的性质,一般都是破坏(不红红)这个性质。
首先我们有一颗红黑树,给了我们一个结点,让我们插入这棵红黑树中,那么为了确保黑路同这个性质,我们插入的结点只能是红色。如果没有破坏红黑树的特性,那么直接插入即可,如果破坏了不红红的特性。那么我们就需要进行一些操作。
第一步,首先我们要看插入结点的叔叔(父节点的兄弟结点)的颜色。
第二步:如果叔叔的颜色是黑色的,那么就要判断插入的新结点在他爷结点的什么位置,如果是在爷结点的左子树的左边(LL),那么就需要将其爷节点为根结点的子树右旋,然后爷爷和父亲的颜色互换;如果是在爷结点的右子树的右边(RR),那么就需要将其爷节点为根结点的子树左旋,然后父亲和爷爷颜色互换;如果是在爷结点左子树的右边(LR),那么就要先让父节点左旋,再让爷结点右旋,然后父节点和爷结点也颜色互换;最后一种情况,如果是在爷结点右子树的左边(RL),那么就要先让父节点右旋,再让爷结点左旋,然后父亲和爷爷的颜色互换。
第三步:如果第二步的叔叔颜色为红色,那就要让叔叔、父亲和爷爷的颜色交换,然后再判断是否再次违反不红红,如果违反重复第二步和第三步。
四、B树
B树说白了就是多叉查找树,类似于二叉排序树,只不过B树有多个分支而已。查找方式也是类似于二叉排序树,既然每个结点有多个分支,那么根据排序树每个关键字都有两个分支的特点,那么也就说明每个结点都有分支数-1的关键字个数。此外,B树的所有叶结点都在同一层。
知道了B数的逻辑结构,那么如何保证B数的查找效率呢?显而易见,查找效率一般都是与树高挂钩的。一个n阶B树,我们要保证每个结点中都至少包含n/2向上取整个数的分支,也就是包含n/2-1个关键字。
讲完了B树的基本性质,就要说说B树的查找相关的操作了。
1.插入:我们只需要像二叉排序树(BST)那样去查找这个要插入关键字应该插入的位置,然后直接插入即可,但是要保证B树的逻辑结构不被破坏,如果我们插入关键字后,结点中的关键字溢出了,那么我们就要将该结点分裂成两个结点,同时保持二叉排序树的特点。
2.删除:删除一个叶结点的关键字,如果不影响B树的特点,那么直接删除即可。但是如果删除一个关键字后,导致该结点中关键字个数小于m/2向上取整-1。我们通常有两种解决办法。第一种:如果该结点的兄弟结点中关键字个数够用,那么从他兄弟那里取一个关键字来补充我们缺失关键字的结点。然后根据B树的特点,再对关键字进行调整即可。第二种:如果他的兄弟关键字不够用借了,那么我们就需要向该结点的父节点来借用关键字,那么如果父节点也不够借,我们直接借即可,然后让父节点与父节点的兄弟结点合并即可。
如果删除的关键字在分支结点上:我们需要参考平衡二叉树的特点,用此关键字的前驱或者后继来顶替该结点位置,这样就又把问题转化为了删除一个叶结点的关键字。
五、B+树
B+树在逻辑结构上与分块查找很类似,B+树的每个结点都有至少两个分支,而每个结点都包含一个分支,且分支中的关键字全都不大于此关键字,且包含此关键字。这里就是B树和B+树的区别了,因为B树的分支数比关键字数多1,而B+数分支数与关键字数相等。
B树与B+树在逻辑上虽然有这样的区别。但是他们之间最大的区别在于:在实际应用中,B树的每个关键字都包含着各种信息,而B+树却只有叶结点的关键字才包含信息。什么意思呢?就是说,比如我们要查找一个员工的信息,在B树中只需要查找到此员工对应的关键字即可找到。但是在B+树中,我们找到此员工对应的关键字还不够,最终我们要找到B+树叶子结点中,此员工的关键字,才能找到此员工对应的信息。
这样乍一看B树的查找效率完爆B+树,但是事实却不是这样的。
首先我们要知道B树和B+树的特点,需要我们的操作系统将整棵树都放到内存中才能使用这个树的各种性质,那么B树的每个关键字中都包含一个信息,但是B+树只有叶结点才包含信息。我们的关键字是存放在磁盘中的,一个磁盘的空间是有限的,如果存放B树的关键字,那么关键字连着的信息也要存进去,而信息的大小远大于关键字,那么可能磁盘的空间就不够用了。但是由于B+树的所有分支结点都不包含信息,那么就可以在有限的磁盘空间中存放更多的关键字信息。
六、哈希表
散列查找这种查找方式是建立在哈希表之上的,那么如何建立一个哈希表呢?首先给定100个毫不相干的数,然后创建一个哈希函数,例如n%10,这样就可以将这100个10进制数数通过哈希函数的转换锁定在0~9的范围。然后建立的哈希表表长大于10即可。
知道了哈希表的定义之后,我们会发现100个数被分配到表长为10的表中,如果哈希函数创建不恰当,那表中的某一个位置可能会存放很多数。所以这里要给出一个规定:哈希函数的取模,一定要是素数,也就是除了1和他本身没有其他可以整除的数。这样就有效避免了冗余的情况,但是也不绝对,具体问题具体分析。可能有些时候素数不是最好的选择。
除了通过对素数取模的方法来减少冲突,那么还有没有其他方法来减少冲突呢?
我们可以用开放定址法来减少冲突。前面说过,可能某一个表格内存放多个数据,但是又有部分表格中没有数据,那么此时就可以应用开放定址法。所谓开放定址法就是开放其他的表格,可以让原本不属于该表格的数据存放在该表格中。
1.开放定址法-线性探测法
第一种方法线性探测法很简单,当冲突第一次发生时,至于要向哈希表的后面继续遍历,直到找到空位置,然后就可以储存进去,原理也很简单,只需要对原来的H(key)加上冲突的次数然后再对表长取模即可,对表长取模的含义就是循环遍历哈希表,而H(key)加上冲突的次数代表着向后挪动1个位置。
这种方法真的不太行,因为将冲突的元素,堆积在发生冲突的表格后方。查找效率依然很差
2.开放定址法-平方探测法
平方探测法就显得高级许多,如果一个表格中有多个数据发生冲突,那么就用正整数的平方加上正负号的方式,来从此表格开始,向左右两个方向的表格填充发生冲突的数据,比如第一个发生冲突的数据放在举例此表格距离为1的位置,第二个放在距离为-1的位置,第三个放在举例为4的位置······
开放定址法由于存放冲突的元素的位置不是连续的,所以可以有效避免线性探测法使冲突元素发生堆积。