文章目录
- 数据结构
-
- 什么是置换选择排序?置换选择排序是用来干啥的?
- 什么是最佳归并树?最佳归并树是用来干什么的?
- 什么是败者树?败者树是用来干什么的?
- 什么是十字链表?什么是多重邻接表?二者有啥区别?
- 迪杰斯特拉算法:用贪心策略求解图中单源最短路径问题
- 弗洛伊德算法:用动态规划思想求解图中任意两点之间的最短路径
- [弗洛伊德算法 vs 迪杰斯特拉算法](#弗洛伊德算法 vs 迪杰斯特拉算法)
- 堆排序
- B树和B+树
- 红黑树
- 哈夫曼树与哈夫曼编码
数据结构
什么是置换选择排序?置换选择排序是用来干啥的?
我们都知道,归并排序是将若干个有序的子数组合并成一个更大的有序数组。那现在问题就来了,我一般手里拿到的往往是一大坨乱序的数据,我现在要对它进行归并,那归并需要的那些有序的子数组是怎么得到的呢?(初始归并段是如何获得的?)
在外部排序基本方法中,我们给出了构建初始归并段的基本方法,思路也很简单,就是从单个元素作为一个归并段开始,不断进行二路归并,不断地扩大基本归并段的容量


这种方法思路简单,但是存在着一些问题,问题就在于它归并段的数量实在是太多了,我们知道在上面的归并方法中,主要采用的是二路归并,你每增加两个归并段,在第一轮中就要增加一趟归并。在外部排序中,归并可是和IO密切相关的,你每多一趟归并,就要多若干次IO。而IO次数正是我们性能的最大阻碍,为了减少归并过程中的归并段数,我们才提出了置换选择排序的方法。这种方法以增加基本归并段中元素的个数为代价,减少了归并过程中的归并段数,有效地提高了外部排序的效率
那置换选择排序的归并段是怎么生成的呢?他咋就能减少IO次数呢?
看下面的例子



什么是最佳归并树?最佳归并树是用来干什么的?
看下面的例子,假如说一共有四个归并段,长度分别是2、3、5、100(每个归并段内部都是有序的)。我现在要把他们合并成一个大的有序数组,但是我的机器最多只能支持三路归并,也就是说我没办法把这四个归并段一口气合完(这种情况很常见,因为实际场景中你往往可以需要将上百个归并段合并成一个大的归并段,这时候你直接用百路归并显然是不现实的)
在这种情况下,我们只能通过多次三路归并来实现我们的目的,那问题就来了,既然是多次三路归并,就肯定要分组,确定哪三个在一组里面。那是不是怎么分都一样呢?显然不是
在具体举例子之前,我们需要先来复习一个简单的知识点:
在某次三路归并过程中,归并段中元素的个数分别是 2 3 5 ,请问这次归并过程中总共需要比较多少次(时间复杂度)?
3 路归并的总比较次数公式(针对 k 个段合并为 1 个段的场景):总比较次数 =(总元素数 - 1)×(k-1)
代入数据计算:
- 总元素数 = 10,k=3;
- 总比较次数 =(10-1)×(3-1)= 9×2 = 18 次
了解了这个之后,我们再来看下面的例子



可以看到,这三个例子中构建的归并树是不同的,总比较次数也是不同的。那对于计算机专业的同学来说,肯定是要求最优解,即我构建一棵怎样的归并树,能够使得归并的总比较次数最小呢?
答案就是,构建一棵哈夫曼树!假如说你计算机能支持k路归并,那你就构建一棵k阶的哈夫曼树。
这时候有的同学就会问,哈夫曼树是一棵在贪心策略下最优带权路径长度(WPL)最小的二叉树,但我想要的是让归并的比较次数最少呀,这俩一样吗?
在归并树中,元素的大小其实就是二叉树的权重,你再去看看比较次数的计算公式,发现比较次数与权重是成线性关系的,所以基本上就可以近似看做是一样的。
另外值得注意的是,为啥要补虚0节点呢?因为如果节点数目不足以构成一个严格的k叉树,那它的WPL不可能是最小的,为了让我们构建的二叉树WPL最小,我们才要补充若干个虚0节点,让节点的数量足以构成一棵严格的k叉树
什么是败者树?败者树是用来干什么的?
败者树是用来优化多路归并过程中每一轮的比较次数的。看下面的例子:
假设我们有 4个有序子序列(即4路归并),需要合并成一个整体有序的序列:
- 子序列1:[1, 5, 9]
- 子序列2:[2, 6, 10]
- 子序列3:[3, 7, 11]
- 子序列4:[4, 8, 12]
对于上面的4路归并而言,每个子序列都是有序的(每个子序列的第一个元素都是当前子序列中最小的元素),我们需要将这4个有序的子序列合成一个更大的有序序列,因此我们每次归并时都需要从4个子序列各自的第一个元素中选出一个最小的元素。
在引入败者树之前,我们正常的思路肯定是写一个for循环遍历去寻找最小元素,时间复杂度就是O(n)。引入败者树之后,我们的时间复杂度却可以达到O(log n),即我们只需要比较log n次,就可以从n个元素中选出一个最小的元素
那败者树是怎么做到这个效果的呢?可以参考下面这张图

首先我们要根据初始归并段,构建败者树。 败者树构建好之后,其顶部记录的,就是值最小的归并段的段号(图中为3号归并段)
在4路归并排序中,我们需要先从4个归并段中输出它们各自最小的元素(有序数组的第一个元素),然后再将这四个元素进行比较,选出一个最小的元素,然后将这个元素插入到我们排序输出的有序队列中。
假如说这个元素出自3号归并段,这个元素被挑走之后,3号归并段就要立即输出一个新的最小元素(前面那个元素走过之后,剩余元素里最小的),其余三个归并段输出的最小元素不变,紧接着我们就要再将这四个新元素进行比较,选出一个最小的元素,然后将这个元素插入到我们排序输出的有序队列中。
上面我们说的过程,不管是有没有败者树,都是归并排序要经历的。那败者树在哪里起了作用呢?就是在从4个元素里面选一个最小的元素这里起了作用。
正常情况下我们选最小元素,就是写个for循环去遍历,比较次数的时间复杂度就是 O ( n ) O(n) O(n),其中n是归并端的个数。而引入败者树这种数据结构之后,我们再选最小元素,比较次数的时间复杂度就是 O ( l o g 2 n ) O(log_2n) O(log2n),那为啥会这样呢?
还是看下面图,3号段的新元素5输出到败者树中之后,首先要和其父节点a[3]指向的归并段(也就是4号归并段)中的最小元素2进行比较,这俩一比发现,4号段的最小元素2更小一些,因此4号段的最小元素2升上去,3号段的最小元素5留在当前节点(a[3]以前记录的归并段号是4,现在改成了3)。
4号段升上去之后,要和a[2]中记录的归并段(也就是2号归并段)中的最小元素3进行比较,这俩一比发现,还是4号段的最小元素2更小一些,因此4号段的最小元素2继续升上去,成为冠军节点(a[0]中记录的归并段号改为4),2号段的最小元素3留在当前节点(a[2]以前记录的归并段号是2,现在仍然是2)。

通过上面的举例我们发现,引入败者树之后,我们只比了两次,就选出了冠军(4个归并段输出的4个元素中,最小的元素),比较次数就是 l o g 2 4 = 2 log_2 4=2 log24=2
什么是十字链表?什么是多重邻接表?二者有啥区别?
我们知道,图这种数据结构的基本存储方法有邻接矩阵和邻接表两种。十字链表和多重邻接表就可以看成是邻接表的变种,其中十字链表用来存储有向图,多重邻接表用来存储无向图
十字链表介绍
十字链表头结点由3个数据域组成:
| data | firstIn | firstOut |
|---|
① data 存储顶点相关的信息。
② firstIn 指向入边表中的第一个边结点。
③ firstOut 指向出边表中的第一个边结点。
十字链表边结点由4个或5个数据域组成:
| tailVex | headVex | hLink | tLink | info(可选) |
|---|
① headVex 指弧头(边终点)在顶点表中的下标。即邻接矩阵元素 edges[i][j] 的下标 j(列标)。
② tailVex 指弧尾(边起点)在顶点表中的下标。即邻接矩阵元素 edges[i][j] 的下标 i(行标)
③ hLink 指弧头(终点)为顶点 headVex 的下一条边。即邻接矩阵中所在列的下一个元素。
④ tLink 指弧尾(起点)为顶点 tailVex 的下一条边,即邻接矩阵中所在行的下一个元素。
⑤ info 数据域可有可无,存储边的相关信息。对于带权有向图,可以用 info 存放边的权值。

多重邻接表介绍
邻接多重表头结点由2个数据域组成:
| data | firstEdge |
|---|
① data 存储顶点相关信息。
② firstEdge 指向第一条和该顶点相连的边。
邻接多重表边结点由4~6个数据域组成:
| mark(可选) | iVer | iLink | jVer | jLink | info(可选) |
|---|
① mark数据域可有可无,可以用来标记该边结点是否被访问。
② iVer和jVer:分别存储一条边的两个顶点在顶点表(数组)中的下标。
③ iLink指和顶点iVer相连的下一条边。
④ jLink指和顶点jVer相连的下一条边。
⑤ info数据域可有可无,存储边的相关信息。对于带权有向图,可以用info存放边的权值。

迪杰斯特拉算法:用贪心策略求解图中单源最短路径问题
对于这个算法,我们直接举一个例子,就理解了。看下面这张图,我现在要你求下图中V1顶点到图中其余各顶点的最短路径

看下面这张表,在第一轮中,v2到v1的距离最小,因此我们把v2考虑进来,在第二轮中允许v1通过v2到达其他节点。在第二轮中,v4到v1的距离最小(v2已经选过了,因此不再考虑),那么我们把v4考虑进来,在第三轮中允许v1经过v2和v4到达其他节点,以此类推

弗洛伊德算法:用动态规划思想求解图中任意两点之间的最短路径
弗洛伊德算法的核心逻辑是:对于图中的任意两个顶点 i 和 j,最短路径有两种可能:
- 不经过任何中间顶点,直接从
i到j; - 经过某个中间顶点
k,即路径为i → ... → k → ... → j。
在第一轮中,我们求图中任意两点之间的最短路径时,不允许一点通过其他节点到达另一点,也就是说要不然直达,要不然距离就是无穷大
在第二轮我们求图中任意两点之间的最短路径时,我们允许一点经过顶点v1,到达另一点,然后依次更新任意两点间的最短路径
在第三轮我们求图中任意两点之间的最短路径时,我们允许一点经过顶点v1和v2,到达另一点,然后依次更新任意两点间的最短路径
以此类推,到最后,我们就可以求出图中允许绕过任一点的两点之间的最短路径
动态规划状态定义
设图中有 n 个顶点(编号为 0 到 n-1),定义状态 dp[k][i][j] 表示:允许经过前 k 个中间顶点时,顶点 i 到顶点 j 的最短路径长度。
根据动态规划的"状态转移"思想,状态更新公式为:
dp[k][i][j] = min( dp[k-1][i][j], dp[k-1][i][k] + dp[k-1][k][j] )
dp[k-1][i][j]:不允许经过第k个顶点时的原有最短路径;dp[k-1][i][k] + dp[k-1][k][j]:经过第k个顶点的新路径(i到k的最短路径 +k到j的最短路径);- 取两者的最小值,作为经过前
k个顶点时的最短路径。
空间优化
原始的三维数组 dp[k][i][j] 空间复杂度为 O(n³),但观察状态转移公式可发现:dp[k][...] 仅依赖于 dp[k-1][...],因此可将三维数组压缩为二维数组 dist[i][j],直接在原数组上更新,空间复杂度优化为 O(n²)。
优化后的状态转移公式简化为:
dist[i][j] = min( dist[i][j], dist[i][k] + dist[k][j] )
弗洛伊德算法的适用场景与限制
- 弗洛伊德算法常用于求解有向图或无向图中所有顶点间的最短路径;
- 图中可包含负权边(但不能包含"负权回路",否则最短路径长度会无限小,无法收敛)
- 弗洛伊德算法常用于顶点数量
n较小的场景(因时间复杂度为O(n³),n过大会导致效率极低)
代码实现(Python)
以下代码实现弗洛伊德算法,包含路径长度计算和路径还原(通过 path 矩阵记录中间顶点):
python
INF = float('inf')
def floyd_warshall(n, edges):
"""
参数:
n: 顶点数量(顶点编号0~n-1)
edges: 边列表,格式为 [(i, j, weight), ...]
返回:
dist: 最短路径长度矩阵,dist[i][j]表示i到j的最短距离
path: 路径记录矩阵,path[i][j]表示i到j的最短路径中经过的中间顶点
"""
# 1. 初始化dist和path矩阵
dist = [[INF] * n for _ in range(n)]
path = [[-1] * n for _ in range(n)] # -1表示无中间顶点
# 顶点到自身距离为0
for i in range(n):
dist[i][i] = 0
# 初始化边的权值
for i, j, w in edges:
dist[i][j] = w
path[i][j] = -1 # 直接路径无中间顶点
# 2. 弗洛伊德核心:遍历中间顶点k,更新所有i,j
for k in range(n):
for i in range(n):
for j in range(n):
# 若i→k和k→j可达,且路径更短,则更新
if dist[i][k] != INF and dist[k][j] != INF and dist[i][j] > dist[i][k] + dist[k][j]:
dist[i][j] = dist[i][k] + dist[k][j]
path[i][j] = k # 记录中间顶点k
return dist, path
# 3. 路径还原函数(递归)
def get_path(path, i, j):
"""还原i到j的最短路径"""
k = path[i][j]
if k == -1: # 无中间顶点,直接返回[i,j]
return [i, j] if i != j else [i]
# 递归获取i→k和k→j的路径,合并(去重k)
return get_path(path, i, k)[:-1] + get_path(path, k, j)
# ------------------- 测试 -------------------
if __name__ == "__main__":
n = 3 # 3个顶点
edges = [
(0, 1, 2),
(1, 2, 3),
(0, 2, 5),
(2, 1, -1)
]
dist, path = floyd_warshall(n, edges)
# 输出最短路径长度矩阵
print("最短路径长度矩阵:")
for row in dist:
print([f"{x:2d}" if x != INF else "∞" for x in row])
# 输出具体路径(如0→2)
i, j = 0, 2
print(f"\n{i}到{j}的最短路径:", get_path(path, i, j))
print(f"{i}到{j}的最短距离:", dist[i][j])
代码输出
最短路径长度矩阵:
['0 ', '2 ', '5 ']
['∞', '0 ', '3 ']
['∞', '-1', '0 ']
0到2的最短路径: [0, 2]
0到2的最短距离: 5
弗洛伊德算法复杂度分析
| 复杂度类型 | 具体值 | 说明 |
|---|---|---|
| 时间复杂度 | O(n³) |
需三层循环(k、i、j 各遍历 n 次) |
| 空间复杂度 | O(n²) |
存储 dist 矩阵和 path 矩阵(各 n×n) |
弗洛伊德算法 vs 迪杰斯特拉算法
两者均用于求解最短路径,但适用场景差异较大,对比如下:
| 对比维度 | 弗洛伊德算法 | 迪杰斯特拉算法 |
|---|---|---|
| 求解目标 | 所有顶点间的最短路径(APSP) | 单个源点到所有其他顶点的最短路径(SSSP) |
| 时间复杂度 | O(n³) |
邻接矩阵:O(n²);邻接表+优先队列:O(m log n)(m 为边数) |
| 负权边处理 | 支持(但不支持负权回路) | 不支持(仅适用于非负权边) |
| 空间复杂度 | O(n²) |
邻接矩阵:O(n²);邻接表:O(n+m) |
| 适用场景 | 顶点数少的图(n<1000)、需全源路径 |
顶点数多的图、仅需单源路径、非负权图 |
综上,弗洛伊德算法是一种全源最短路径算法,适合小规模图场景,需要考虑负权边的图也能用;
若图规模较大或仅需单源路径,建议选择迪杰斯特拉算法
若图规模较大,且仅需单元路径,但是图中有负权边,建议使用贝尔曼-福特算法(支持负权边的单源算法)
堆排序
什么是大根堆?什么是小根堆?
- 大根堆的根节点值最大,小跟堆的根节点值最小
- 大根堆中任意一个节点的值都要比其子节点的值要大
- 小根堆中任意一个节点的值都要比其子节点的值要小
如何利用堆来进行排序?
下面我们以大根堆为例,说明堆排序的步骤
- 构建大根堆:将无序数组转化为满足"父节点 ≥ 子节点"的完全二叉树(这是排序的基础);
- 提取堆顶元素:将堆顶(最大值)与数组末尾元素交换,此时数组末尾就是排序好的最大值;
- 调整堆结构:交换后堆顶元素可能破坏大根堆性质,需对"剩余未排序部分"重新调整为大根堆;
- 重复循环:重复步骤2-3,直到所有元素排序完成(未排序部分的长度从 n 缩减到 1)。
如何用堆排序从1000个元素中选出最大的十个元素?
我们首先用前10个元素构建一个大小为10的小根堆。将第11个元素与堆顶元素进行比较
- 如果第11个元素大于堆顶元素,则将堆顶元素置换为第11个元素,然后执行向下调整算法
- 如果第11个元素小于等于堆顶元素,则无事发生
对第11 ~ 第1000号元素都执行上述流程,执行完毕之后,堆中的10个元素就是这1000个数中最大的十个元素
B树和B+树
B树的定义
定义一棵「m阶」B树,满足以下规则:
- 每个节点最多有
m-1个关键字(数据索引),最多有m个子节点(因此m至少为2) - 根节点最少有 1 个关键字、2 个子节点(空树除外)
- B树中的分支节点最少有
⌈m/2⌉ - 1个关键字、⌈m/2⌉个子树 - 每个节点的关键字按升序排列,当前节点的第i个子节点的所有关键字都小于该节点的第i个关键字,当前节点的第i+1个子节点的所有关键字都大于该节点的第i个关键字
- 所有叶子节点在同一层(平衡特性,保证树高最小);
- 非叶子节点和叶子节点都存储数据(关键字+对应记录的地址/值)
B+树的定义
定义一棵「m阶」B+树,满足以下规则:
- 每个节点最多有
m个关键字(数据索引),最多有m个子树 - 如果B+树的根节点不是叶子节点(即其有子节点),那么根节点中的关键字个数至少为2。
- B+树的分支节点最少有
⌈m/2⌉个关键字、⌈m/2⌉个子节点 - 所有非叶子节点的关键字,都是叶子节点关键字的"索引副本"(即非叶子节点的关键字一定在叶子节点中存在);
- 叶子节点:存储「全部关键字+对应数据(或数据地址)」,且叶子节点之间通过双向链表串联(按关键字升序排列)
- 叶子节点在同一层(平衡特性),且链表串联后支持"范围查询"。
B数和B+树的区别是什么?
- 存储结构方面
- B树的所有节点都存储数据;
- B+树仅叶子节点存储数据,非叶子节点只存索引,且叶子节点通过链表串联。
- 节点中的关键字个数与该节点的子树个数之间的关系
- B树节点的子树个数永远等于关键字个数+1
- B+树中节点的子树个数和关键字个数永远相等
- 根节点中的关键字个数
- 如果根节点是叶节点,那根节点中的关键字个数可以为0(B树和B+树都是一样的)
- 如果根节点下面有子树
- 对于m阶B树而言,根节点中的关键字个数最少是1,最大是m-1
- 对于m阶B+树而言,根节点中的关键字个数最少是2,最大是m
- 分支节点内的关键字个数
- 对于m阶B树而言,分支节点中的关键字个数最少是
⌈m/2⌉-1,最大是m-1 - 对于m阶B+树而言,分支节点中的关键字个数最少是
⌈m/2⌉,最大是m
- 对于m阶B树而言,分支节点中的关键字个数最少是
B树的查找效率
B树的查找包含两个基本操作:
- 将B树的某个节点读入内存
- 在内存中对该节点的关键字有序数组进行折半查找
以在下图所示的B树中查找42这个元素为例

查找开始时,首先计算机会将B树的根节点从磁盘读取到内存中。读到内存中之后,再读取根节点中的关键字22,通过比较发现22<42 (这一过程的本质就是在关键字序列中折半查找42),因此我们紧接着要将根节点的右子节点从磁盘读入内存
读到内存中之后,再读取该节点中的关键字,通过比较发现36<42<45,因此我们要将该节点的第二个子节点从磁盘读入内存
读到内存中之后,再读取该节点中的关键字,在这些关键字中查到了42,由此我们完成了查询
通过上面这个例子我们就能看出,B树的查找效率,其实本质上就取决于要查找的节点在B树的哪一层,如果是在第三层,那么查找该元素,就需要进行三次磁盘IO
(由于B树常存储在磁盘上,则前一查找操作是在磁盘上进行的,而后一查找操作是在内存中进行的,即在磁盘上找到目标结点后,先将结点信息读入内存,然后再采用顺序查找法或折半查找法。因此,在磁盘上进行查找的次数即目标结点在B树上的层次数,决定了B树的查找效率。)
我们考虑最坏的情况,那每查找一次B树,就需要进行 h 次磁盘IO(h是B树的层高),那一棵关键字总数为n的m阶B树,它有多高呢?
B树的最小高度与最大高度
在展开讨论之前,我们还要首先明确B树的高度定义,确定是否包括最下面的外部节点(这里我们明确是不包括的)
由上一节得知,B树中的查找效率取决于查找所需的磁盘存取次数。而磁盘存取次数,则一定不会超过B树的高度。因此限制B树查找效率的最重要的因素,就是B树的高度。因此我们总是希望,让B树的高度尽可能的小,即B树越矮越好,越胖越好。
那B树怎样才能矮胖矮胖的呢?答案很简单,那就是让m阶B树每个节点中关键字的数量都是m-1
此时对于一棵包含 n n n个关键字、高度为 h h h、阶数为 m m m的B树,其最多能容纳的关键字的个数就是
( m − 1 ) ( 1 + m + m 2 + ⋯ + m h − 1 ) = m h − 1 (m-1)(1 + m + m^2 + \dots + m^{h-1}) = m^h - 1 (m−1)(1+m+m2+⋯+mh−1)=mh−1
因此我们可以得出下面的关系式
n < m h − 1 n<m^h - 1 n<mh−1
不等式变形
h ≥ log m ( n + 1 ) h \geq \log_m(n + 1) h≥logm(n+1)
最终我们就可以求出,一棵包含 n n n个关键字、阶数为 m m m的B树的最小层高
h m i n = ⌈ log m ( n + 1 ) ⌉ h_{min} = \lceil \log_m(n + 1)\rceil hmin=⌈logm(n+1)⌉
大多数工程师们关心的,都是B树怎样才能尽可能矮,而对于数学家来说,他们不仅要关心B树高度的最小值,还要关心B树高度的最大值
如何求一棵包含 n n n个关键字、阶数为 m m m的B树的最大层高呢?
显然,要让B树的高度最大,那就要让每个节点中的关键字个数最少。根节点中关键字个数最少为1,分支节点中关键字的个数最少为 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil-1 ⌈m/2⌉−1,由此我们可以推出,当B树的高度最大时
- 第一层只有1个结点;
- 第二层只有2个结点;
- 第三层只有 2 ⌈ m / 2 ⌉ 2\lceil m/2 \rceil 2⌈m/2⌉个结点
- ......
- 第 h h h层有 2 ( ⌈ m / 2 ⌉ ) h − 2 2(\lceil m/2 \rceil)^{h-2} 2(⌈m/2⌉)h−2个结点
- 第 h + 1 h+1 h+1层有 2 ( ⌈ m / 2 ⌉ ) h − 1 2(\lceil m/2 \rceil)^{h-1} 2(⌈m/2⌉)h−1个结点
- 第 h + 1 h+1 h+1层是不包含任何信息的叶结点。
- 对于任意一棵关键字个数为 n n n的B树,叶结点(查找不成功的结点)的数量都是 n + 1 n+1 n+1
显然,随着层高 h h h的增加,第 h + 1 h+1 h+1层的叶节点数量 2 ( ⌈ m / 2 ⌉ ) h − 1 2(\lceil m/2 \rceil)^{h-1} 2(⌈m/2⌉)h−1也在增加,到最后一层时一定有:
n + 1 = 2 ( ⌈ m / 2 ⌉ ) h − 1 n + 1=2(\lceil m/2 \rceil)^{h-1} n+1=2(⌈m/2⌉)h−1
此时 h = log ⌈ m / 2 ⌉ ( ( n + 1 ) / 2 ) + 1 h = \log_{\lceil m/2 \rceil}((n + 1)/2) + 1 h=log⌈m/2⌉((n+1)/2)+1
在尽可能高的情况下讨论,求出的h的值,肯定就是h的最大值
现在我们就得出了一棵包含 n n n个关键字、阶数为 m m m的B树的高度的范围
⌈ log m ( n + 1 ) ⌉ ≤ h ≤ ⌊ log ⌈ m / 2 ⌉ n + 1 2 ⌋ + 1 \boxed{ \left\lceil \log_m (n+1) \right\rceil \;\le\; h \;\le\; \left\lfloor \log_{\lceil m/2\rceil}\frac{n+1}{2} \right\rfloor+1 } ⌈logm(n+1)⌉≤h≤⌊log⌈m/2⌉2n+1⌋+1
其中 ⌈ m / 2 ⌉ \lceil m/2\rceil ⌈m/2⌉ 是每个非根结点的最少孩子数。
- 左边给的是"最矮"的情况:每个结点都装满(有最多 m m m 个孩子)。
- 右边给的是"最高"的情况:每个非根结点都只有最少的 ⌈ m / 2 ⌉ \lceil m/2\rceil ⌈m/2⌉ 个孩子。
B树的插入
-
定位。
- 利用前述的B树查找算法,确定该关键字应该插入到B树最底层的哪个节点中
- 注意,这里最底层指的是内部节点最底层,该节点里面是有关键字的
-
插入。
- 由于B树中每个非根结点的关键字个数都在 [ ⌈ m / 2 ⌉ − 1 , m − 1 ] [\lceil m/2 \rceil - 1, m - 1] [⌈m/2⌉−1,m−1]之间,而插入会使得B树中某个节点的关键字个数增加,如果插入前该节点的关键字个数已经达到了上限值m-1,此时插入就会使得关键字个数超过我们规定的上限,这是不符合规定的
- 对于这种情况,我们就需要对B树做一些处理,使得处理之后的B树重新符合m阶的规定。那怎么处理呢?答案就是将那个超上限的节点分裂成两个节点。如何分呢?请看下面
- 分裂节点时,我们会以中间位置------第 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉个元素为界,将节点中的关键字分为三部分
- 中间往左部分包含的关键字放在原结点中
- 中间往右部分包含的关键字放到新结点中
- 而中间位置 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉的结点则会插入原结点的父结点中。
- 若中间元素往上插入时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致B树高度增1。
B树的删除
-
由于B树中每个非根结点的关键字个数都在 [ ⌈ m / 2 ⌉ − 1 , m − 1 ] [\lceil m/2 \rceil - 1, m - 1] [⌈m/2⌉−1,m−1]之间,而删除操作会使得B树中某个节点的关键字个数减少,如果插入前该节点的关键字个数已经达到了下限值 [ ⌈ m / 2 ⌉ − 1 [\lceil m/2 \rceil - 1 [⌈m/2⌉−1,此时再删除就会使得关键字个数低于我们规定的下限,这就不符合m阶B树的规定了
-
对于这种情况,我们就需要对B树做一些处理,使得处理之后的B树重新符合m阶的规定。那怎么处理呢?答案就是问旁边的兄弟节点借一个关键字
-
问题有来了,如果旁边兄弟节点的关键字比较富裕,借完之后依然大于等于 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil-1 ⌈m/2⌉−1,那就没问题。但是如果旁边的兄弟节点的关键字刚刚达到了下限 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil-1 ⌈m/2⌉−1,这时候你再问他借一个,你是够了,但他又不够了,这咋办呢?
-
对于这种情况,我们就不要再问你那个刚刚及格的兄弟再借了,我们的处理方案是,让你和你兄弟,以及父节点中你俩之间夹着的那个关键字,你仨合并成一个节点
红黑树
哈夫曼树与哈夫曼编码
什么是哈夫曼树?
给定带权叶子结点个数n,哈夫曼树是这n个带权叶子结点构成的所有二叉树中,带权路径长度(WPL)最短的二叉树

哈夫曼树的性质
如果给定哈夫曼树的带权叶子结点个数为n,那么哈夫曼树的非叶子节点数是 n-1,总节点数是2n-1
证明:
对于任意一棵二叉树,设其叶子结点(度为0的节点)个数为n0,其度为1的节点的个数为n1,度为2的节点的个数为n2
由于树的节点总数=所有节点的度数之和+1
我们有: n 0 + n 1 + n 2 = 1 ∗ n 1 + 2 ∗ n 2 + 1 n0+n1+n2=1*n1+2*n2+1 n0+n1+n2=1∗n1+2∗n2+1
化简得: n 0 = n 2 + 1 n0=n2+1 n0=n2+1
即对任意一棵二叉树而言,度为0的节点的数量总比度为2的节点数量多一个
已知哈夫曼树的带权叶子结点个数为n,那么哈夫曼树的度为2的非叶结点总数就是n-1,由于哈夫曼树没有度为1的节点,因此哈夫曼树的节点总数就是2n-1
如何构造哈夫曼树?
思路很简单,每次都从待选节点中,选出权值最小的两个节点,让它们成为兄弟节点,他们父节点的权值就是这俩兄弟节点的权值之和

上面说的哈夫曼树,我们都默认是2叉树,但是实际问题中,往往在更高阶的树中也会遇到类似的问题,为了解决这些问题,我们自然而然的就引入了m叉哈夫曼树,其定义就是在n个带权叶子结点构成的所有m叉树中,带权路径长度(WPL)最短的m叉树
但是这里有一个问题,看下面的例子:

在上面这个例子中,这四个节点无论怎么拼,都没法构成一棵严格的m叉哈夫曼树。因此我们要意识到,一棵严格的m叉树的叶子节点个数是有规律的,并不是任意的。那有什么规律呢?
对于一棵m叉哈夫曼树,由于其度为1...m-1的节点的个数均为0,因此我们设其叶子结点(度为0的节点)个数为 n 0 n_0 n0,其度为m的节点的个数为 n m n_m nm
由于树的节点总数=所有节点的度数之和+1
我们有: n 0 + n m = m ∗ n m + 1 n_0+n_m=m*n_m+1 n0+nm=m∗nm+1
化简得: n 0 = ( m − 1 ) ∗ n m + 1 n_0=(m-1)*n_m+1 n0=(m−1)∗nm+1
即对一棵m叉哈夫曼树而言,其叶子节点的数量总是 (m-1)的整数倍+1 。换句话说,m叉哈夫曼树的叶子节点的数量-1 总能被m-1 整除,即 ( n 0 − 1 ) m o d ( m − 1 ) = 0 (n_0-1)mod(m-1)=0 (n0−1)mod(m−1)=0
那现在问题又来了,如果我给你的带权叶子节点总数n,它并不满足上面的等式,这就意味着它们无法构成一棵严格的m叉哈夫曼树。我就没法通过构造m叉哈夫曼树,去求最短的WPL。这种情况应该怎么办呢?
答案也很简单,那就是我们可以补上若干个权值为0的带权叶节点,使得带权叶节点的总数恰好能够满足上面的等式,然后我们就可以通过构造m叉哈夫曼树的方法,去求最短的WPL了
什么是哈夫曼编码?
哈夫曼编码是一种前缀编码(在前缀编码中,没有任何一个编码是其余编码的前缀)
我们知道,在一次通信过程中,可能会出现若干种不同的码元,这些码元有的出现频率高,有的出现频率低。因此我们在进行编码的时候,往往希望把出现频率高的码元的编码长度搞得短一些(这样省空间),这其实也可以抽象为一个求最短WPL的问题,因此它当然也就能用哈夫曼树来解决
那哈夫曼编码究竟是怎么编码的呢?看下面的问题示例:
若一个文件 f f f由字符集: { A , B , C , D , E , F } \{A,B,C,D,E,F\} {A,B,C,D,E,F}组成,每个字符在文件中出现的次数分别为: { 17 , 20 , 72 , 66 , 39 , 25 } \{17,20,72,66,39,25\} {17,20,72,66,39,25},请你对这个字符集进行哈夫曼编码
