考研408笔记——数据结构

文章目录

数据结构

什么是置换选择排序?置换选择排序是用来干啥的?

我们都知道,归并排序是将若干个有序的子数组合并成一个更大的有序数组。那现在问题就来了,我一般手里拿到的往往是一大坨乱序的数据,我现在要对它进行归并,那归并需要的那些有序的子数组是怎么得到的呢?(初始归并段是如何获得的?)

在外部排序基本方法中,我们给出了构建初始归并段的基本方法,思路也很简单,就是从单个元素作为一个归并段开始,不断进行二路归并,不断地扩大基本归并段的容量
在这里插入图片描述
在这里插入图片描述

这种方法思路简单,但是存在着一些问题,问题就在于它归并段的数量实在是太多了,我们知道在上面的归并方法中,主要采用的是二路归并,你每增加两个归并段,在第一轮中就要增加一趟归并。在外部排序中,归并可是和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个数据域组成:

datafirstInfirstOut

① data 存储顶点相关的信息。
② firstIn 指向入边表中的第一个边结点。
③ firstOut 指向出边表中的第一个边结点。

十字链表边结点由4个或5个数据域组成:

tailVexheadVexhLinktLinkinfo(可选)

① headVex 指弧头(边终点)在顶点表中的下标。即邻接矩阵元素 edges[i][j] 的下标 j(列标)。
② tailVex 指弧尾(边起点)在顶点表中的下标。即邻接矩阵元素 edges[i][j] 的下标 i(行标)
③ hLink 指弧头(终点)为顶点 headVex 的下一条边。即邻接矩阵中所在列的下一个元素。
④ tLink 指弧尾(起点)为顶点 tailVex 的下一条边,即邻接矩阵中所在行的下一个元素。
⑤ info 数据域可有可无,存储边的相关信息。对于带权有向图,可以用 info 存放边的权值。

在这里插入图片描述

多重邻接表介绍

邻接多重表头结点由2个数据域组成:

datafirstEdge

① data 存储顶点相关信息。
② firstEdge 指向第一条和该顶点相连的边。

邻接多重表边结点由4~6个数据域组成:

mark(可选)iVeriLinkjVerjLinkinfo(可选)

① mark数据域可有可无,可以用来标记该边结点是否被访问。
② iVer和jVer:分别存储一条边的两个顶点在顶点表(数组)中的下标。
③ iLink指和顶点iVer相连的下一条边。
④ jLink指和顶点jVer相连的下一条边。
⑤ info数据域可有可无,存储边的相关信息。对于带权有向图,可以用info存放边的权值。
在这里插入图片描述

迪杰斯特拉算法:用贪心策略求解图中单源最短路径问题

对于这个算法,我们直接举一个例子,就理解了。看下面这张图,我现在要你求下图中V1顶点到图中其余各顶点的最短路径
在这里插入图片描述
看下面这张表,在第一轮中,v2到v1的距离最小,因此我们把v2考虑进来,在第二轮中允许v1通过v2到达其他节点。在第二轮中,v4到v1的距离最小(v2已经选过了,因此不再考虑),那么我们把v4考虑进来,在第三轮中允许v1经过v2和v4到达其他节点,以此类推
在这里插入图片描述

最小生成树(MST)

什么是最小生成树呢?

首先我们要了解啥叫生成树。所谓的生成树就是,从一个无向联通图中选n-1条边,把一个图的n个顶点联通起来(也可以理解成,只留下图中的n-1条边,使得该图仍然是一个连通图)

  • 对于任意一个无向连通图,其中的n个顶点也是联通的
  • 生成树也属于无向连通图,但是生成树是用最少的边把这n个顶点联通起来。一般的连通图内部往往是有环路的,而生成树中没有环路

好了,那啥叫最小生成树呢?我们都知道,从一个无向联通图中选n-1条边,把一个图的n个顶点联通起来,这样的选法往往不止一种,即同一个连通图的生成树往往不止一棵。所谓的最小生成树,就是这些生成树中,边权值之和最小的生成树

怎么求最小生成树?

主要是用两种方法,一种叫Prim算法,还一种叫Kruskal算法,下面我们就来一一介绍

Prim算法

Prim算法的思路很简单,首先我们选一个初始顶点,然后从所有与这个顶点相连的边中,选一个权值最小的边,连同他对端的顶点一起,加入我们的ret集合中。

下一次我们再选时,就从所有与这个集合相连的边中,再选一个权值最小的边,连同他对端的顶点一起,加入我们的ret集合中。

不断重复上面流程,直到所有的顶点都加入到我们的ret集合中,这时候的ret集合就是我们要求的最小生成树

Kruskal算法

这个算法的思想也很经典,就是每次从图中都选一个权值最小的边以及其两端的顶点加入我们的ret集合(要求必须至少有一个新顶点加入,如果一个边的权值是当前剩余边中最小的,但是它两端的顶点全都已经在ret集合中了,那这个边是不能选的)

不断重复上面的流程,直到所有的顶点都加入到我们的ret集合中,这时候的ret集合就是我们要求的最小生成树

弗洛伊德算法:用动态规划思想求解图中任意两点之间的最短路径

弗洛伊德算法的核心逻辑是:对于图中的任意两个顶点 ij,最短路径有两种可能:

  • 不经过任何中间顶点,直接从 ij
  • 经过某个中间顶点 k,即路径为 i → ... → k → ... → j

在第一轮中,我们求图中任意两点之间的最短路径时,不允许一点通过其他节点到达另一点,也就是说要不然直达,要不然距离就是无穷大

在第二轮我们求图中任意两点之间的最短路径时,我们允许一点经过顶点v1,到达另一点,然后依次更新任意两点间的最短路径

在第三轮我们求图中任意两点之间的最短路径时,我们允许一点经过顶点v1和v2,到达另一点,然后依次更新任意两点间的最短路径

以此类推,到最后,我们就可以求出图中允许绕过任一点的两点之间的最短路径

动态规划状态定义
设图中有 n 个顶点(编号为 0n-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 个顶点的新路径(ik 的最短路径 + kj 的最短路径);
  • 取两者的最小值,作为经过前 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 矩阵记录中间顶点):

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³)需三层循环(kij 各遍历 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)、需全源路径顶点数多的图、仅需单源路径、非负权图

综上,弗洛伊德算法是一种全源最短路径算法,适合小规模图场景,需要考虑负权边的图也能用;

若图规模较大或仅需单源路径,建议选择迪杰斯特拉算法

若图规模较大,且仅需单元路径,但是图中有负权边,建议使用贝尔曼-福特算法(支持负权边的单源算法)

图的遍历

说起图的遍历,我们都知道,基本的遍历方法就是BFS和DFS,思路也很简单,BFS的访问路径跟波一样,一圈一圈的,而DFS的访问路径就是一直钻到底。思路我们大概都理解,但是这俩算法具体是咋实现的呢?我们下面就来简单看一下

DFS算法实现

🧩 C++ DFS(用递归实现)

#include <iostream>
#include <vector>
using namespace std;

vector<vector<int>> adj;
vector<bool> visited;

void dfs(int u) {
    visited[u] = true;
    cout << u << " ";   // 访问当前节点

    for (int v : adj[u]) {
        if (!visited[v]) {
            dfs(v);     // 深度递归
        }
    }
}

int main() {
    adj = {{}, {2,3}, {4,5}, {}, {}, {}};
    visited = vector<bool>(6, false);

    cout << "DFS: ";
    dfs(1);
    cout << endl;
}

⭐ 输出结果:

DFS: 1 2 4 5 3

⭐ DFS 运行方式(递归栈):

dfs(1)
 ├── dfs(2)
 │     ├── dfs(4)
 │     └── dfs(5)
 └── dfs(3)

特点:走深,不回头,不看其他兄弟节点。


BFS算法实现

🧩 C++ BFS(用队列实现)

#include <iostream>
#include <vector>
#include <queue>
using namespace std;

vector<vector<int>> adj;
vector<bool> visited;

void bfs(int start) {
    queue<int> q;
    visited[start] = true;
    q.push(start);

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        cout << u << " ";

        for (int v : adj[u]) {
            if (!visited[v]) {
                visited[v] = true;
                q.push(v);
            }
        }
    }
}

int main() {
    adj = {{}, {2,3}, {4,5}, {}, {}, {}};
    visited = vector<bool>(6, false);

    cout << "BFS: ";
    bfs(1);
    cout << endl;
}

⭐ 输出结果:

BFS: 1 2 3 4 5

⭐ BFS 运行方式(队列 FIFO):

队列: [1]
取1 → 加入2,3 → 队列: [2,3]
取2 → 加入4,5 → 队列: [3,4,5]
取3 → ...
取4 → ...
取5 → ...

特点:一层一层向外扩散。


⚔️ DFS vs BFS 对比

🎯 用一句最简单的话总结

DFS = 自己深入解决,走不下去再回头(栈思想)

BFS = 先把自己附近所有人处理,再处理他们的下一圈(队列思想)

堆排序

什么是大根堆?什么是小根堆?

  1. 大根堆的根节点值最大,小跟堆的根节点值最小
  2. 大根堆中任意一个节点的值都要比其子节点的值要大
  3. 小根堆中任意一个节点的值都要比其子节点的值要小

如何利用堆来进行排序?

下面我们以大根堆为例,说明堆排序的步骤

  1. 构建大根堆:将无序数组转化为满足“父节点 ≥ 子节点”的完全二叉树(这是排序的基础);
  2. 提取堆顶元素:将堆顶(最大值)与数组末尾元素交换,此时数组末尾就是排序好的最大值;
  3. 调整堆结构:交换后堆顶元素可能破坏大根堆性质,需对“剩余未排序部分”重新调整为大根堆;
  4. 重复循环:重复步骤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+树的区别是什么?

  1. 存储结构方面
    • B树的所有节点都存储数据;
    • B+树仅叶子节点存储数据,非叶子节点只存索引,且叶子节点通过链表串联。
  2. 节点中的关键字个数与该节点的子树个数之间的关系
    • B树节点的子树个数永远等于关键字个数+1
    • B+树中节点的子树个数和关键字个数永远相等
  3. 根节点中的关键字个数
    • 如果根节点是叶节点,那根节点中的关键字个数可以为0(B树和B+树都是一样的)
    • 如果根节点下面有子树
      • 对于m阶B树而言,根节点中的关键字个数最少是1,最大是m-1
      • 对于m阶B+树而言,根节点中的关键字个数最少是2,最大是m
  4. 分支节点内的关键字个数
    • 对于m阶B树而言,分支节点中的关键字个数最少是 ⌈m/2⌉-1 ,最大是m-1
    • 对于m阶B+树而言,分支节点中的关键字个数最少是 ⌈m/2⌉ ,最大是m

B树的查找效率

B树的查找包含两个基本操作:

  1. 将B树的某个节点读入内存
  2. 在内存中对该节点的关键字有序数组进行折半查找

以在下图所示的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 (m1)(1+m+m2++mh1)=mh1

因此我们可以得出下面的关系式

n < m h − 1 n<m^h - 1 n<mh1

不等式变形
h ≥ log ⁡ m ( n + 1 ) h \geq \log_m(n + 1) hlogm(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/21,由此我们可以推出,当B树的高度最大时

  1. 第一层只有1个结点;
  2. 第二层只有2个结点;
  3. 第三层只有 2 ⌈ m / 2 ⌉ 2\lceil m/2 \rceil 2m/2个结点
  4. ……
  5. h h h层有 2 ( ⌈ m / 2 ⌉ ) h − 2 2(\lceil m/2 \rceil)^{h-2} 2(⌈m/2)h2个结点
  6. h + 1 h+1 h+1层有 2 ( ⌈ m / 2 ⌉ ) h − 1 2(\lceil m/2 \rceil)^{h-1} 2(⌈m/2)h1个结点
    • 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)h1也在增加,到最后一层时一定有:

n + 1 = 2 ( ⌈ m / 2 ⌉ ) h − 1 n + 1=2(\lceil m/2 \rceil)^{h-1} n+1=2(⌈m/2)h1

此时 h = log ⁡ ⌈ m / 2 ⌉ ( ( n + 1 ) / 2 ) + 1 h = \log_{\lceil m/2 \rceil}((n + 1)/2) + 1 h=logm/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)hlogm/22n+1+1

其中 ⌈ m / 2 ⌉ \lceil m/2\rceil m/2 是每个非根结点的最少孩子数

  • 左边给的是“最矮”的情况:每个结点都装满(有最多 m m m 个孩子)。
  • 右边给的是“最高”的情况:每个非根结点都只有最少的 ⌈ m / 2 ⌉ \lceil m/2\rceil m/2 个孩子。

B树的插入

  1. 定位。

    • 利用前述的B树查找算法,确定该关键字应该插入到B树最底层的哪个节点中
    • 注意,这里最底层指的是内部节点最底层,该节点里面是有关键字的
  2. 插入

    • 由于B树中每个非根结点的关键字个数都在 [ ⌈ m / 2 ⌉ − 1 , m − 1 ] [\lceil m/2 \rceil - 1, m - 1] [⌈m/21,m1]之间,而插入会使得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/21,m1]之间,而删除操作会使得B树中某个节点的关键字个数减少,如果插入前该节点的关键字个数已经达到了下限值 [ ⌈ m / 2 ⌉ − 1 [\lceil m/2 \rceil - 1 [⌈m/21,此时再删除就会使得关键字个数低于我们规定的下限,这就不符合m阶B树的规定了

  • 对于这种情况,我们就需要对B树做一些处理,使得处理之后的B树重新符合m阶的规定。那怎么处理呢?答案就是问旁边的兄弟节点借一个关键字

  • 问题有来了,如果旁边兄弟节点的关键字比较富裕,借完之后依然大于等于 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil-1 m/21,那就没问题。但是如果旁边的兄弟节点的关键字刚刚达到了下限 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil-1 m/21,这时候你再问他借一个,你是够了,但他又不够了,这咋办呢?

  • 对于这种情况,我们就不要再问你那个刚刚及格的兄弟再借了,我们的处理方案是,让你和你兄弟,以及父节点中你俩之间夹着的那个关键字,你仨合并成一个节点

经典例题

在这里插入图片描述

这题主要是第四个叙述,一般情况下,我们会将某个新的关键字插入在B树的某个叶节点中,但是如果插入进去之后,该叶节点的关键字个数超过上限了,那么我们就要将该叶节点进行分裂,如果新插入进来的这个关键字,恰好就是中间那个关键字,那它将会被插入到父节点中,因此最终不会位于叶节点中,所以第四个是错的

快问快答

B树中是否允许有两个相同的关键字?
一般不允许

B树的叶节点是不是都在同一层?
是的

线索二叉树

二叉排序树

什么是二叉排序树?

二叉排序树也称二叉查找树(Binary Search Tree,BST)
我们把具有下列特性的二叉树成为二叉排序树:
1)左子树上所有结点的值均小于根结点的值。
2)右子树上所有结点的值均大于根结点的值。
3)左、右子树也分别是一棵二叉排序树

二叉排序树的插入

基本的插入方法大家肯定都知道,就强调一条,新插入的节点一定是叶节点,是查找路径是最后一个访问的叶节点的孩子

二叉排序树的删除

  1. 若被删除结点是叶结点,则直接删除
  2. 若结点只有一棵左子树或右子树,则让的子树成为父结点的子树,替代的位置
  3. 若结点z有左、右两棵子树,则令它的直接后继(或直接前驱)替代,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。

平衡二叉树

二叉搜索树的形状与关键字的插入顺序密切相关,比如下图中,同样的一组关键字,不同的插入顺序,就会形成两棵截然不同的二叉树
在这里插入图片描述
而显然我们通过观察可以知道,右边那棵树的查找效率是不如左边的,我们在查找的时候往往希望BST矮胖矮胖的。平衡二叉树就是在BST的基础上做了一些限制,让BST尽可能矮胖矮胖的

什么是平衡二叉树?

说了半天,到底什么是平衡二叉树呢?如果一个BST内任意一个节点的平衡因子都小于1,那么我们就称这棵BST为平衡二叉树(AVL树)。

  • 平衡因子大家应该都知道啥意思吧,就是这个节点的左子树和右子树高度差的绝对值

平衡二叉树的插入

  1. 按照BST的插入步骤,先确定这个新节点应该插到BST的什么位置,再插进去
  2. 插进去之后检查有没有平衡因子大于1的分支节点(我们称其为问题节点)
    • 如果没有,则完成插入
    • 如果且只有一个,则对以问题节点为根节点的子树进行调整,使得调整之后的子树是一颗平衡二叉树
    • 如果插入之后平衡因子大于1的节点(我们称之为问题节点)有且不止一个,则找到距离插入位置最近的那个问题节点,对以该节点为根节点的子树进行平衡性调整,使得调整之后的子树是一棵平衡二叉树(我们也称之为,对最小不平衡子树进行调整),然后再对距离这棵刚刚调整好的AVL树根节点最近的问题节点进行递归调整,直到所有的问题节点都被处理完为止
平衡二叉树的删除
  1. 首先依然是按照BST的节点删除步骤
    • 若被删除结点是叶结点,则直接删除
    • 若结点只有一棵左子树或右子树,则让的子树成为父结点的子树,替代的位置
    • 若结点z有左、右两棵子树,则令它的直接后继(或直接前驱)替代,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。
  2. 删完之后检查有没有节点的平衡因子大于1
    • 如果没有,则删除完毕
    • 如果有一个,则对以问题节点为根节点的子树进行调整,使得调整之后的子树是一颗平衡二叉树
    • 如果有多个,则递归对最小不平衡子树进行调整
平衡二叉树的调整

前面我们一直说,通过调整,将一棵非平衡二叉树转化为平衡二叉树,但是一直没说具体咋调整,具体的调整方法主要有四种

  1. 左单旋(如果是往右孩子的右子树中插入,我们就用左单旋来调整)
  2. 右单旋(如果是往左孩子的左子树中插入,我们就用右单旋来调整)
  3. 先左后右旋(如果是往左孩子的右子树中插入,我们就用先左后右旋来调整)
  4. 先右后左璇(如果是往右孩子的左子树中插入,我们就用先右后左璇来调整)

红黑树

树、森林、二叉树之间的转换

如何将一棵树转化成二叉树?

我们一般采用的方法叫 “左孩子右兄弟法”,即在原来的树中

  1. 如果A节点是B节点的左孩子,那转化后的二叉树中,A节点还是B节点的左孩子
  2. 如果A节点是B节点的右兄弟,那转化后的二叉树中,A节点是B节点的右孩子

如果将一片森林转化成一棵二叉树?

  1. 首先先将森林中所有的树,都转化成二叉树。
  2. 将第二棵二叉树的根节点,作为第一棵二叉树的右孩子,再将第三棵二叉树的根节点,作为第二棵二叉树的右孩子,以此类推

如何遍历一棵树?如何遍历一片森林?

树的遍历方式只有先根遍历和后根遍历(这个很好理解我就不多说了)
而森林的遍历方式也有两种:先序遍历和中序遍历。这个可能初学者一开始不太理解,下面我们就来好好解释一下

森林的先序遍历
  1. 访问森林中第一棵树的根节点
  2. 先序遍历第一棵树中根节点的子树森林(在第一棵树中如果我们把根节点去掉,其实他也会形成一片森林,本步骤就是在访问完一棵树的根节点之后,递归先序遍历这片森林)
  3. 先序遍历除去第一棵树之后其余树构成的森林
森林的中序遍历

这个其实就是把先序遍历中三个步骤换个顺序

  1. 中序遍历第一棵树中根节点的子树森林
  2. 访问森林中第一棵树的根节点
  3. 中序遍历除去第一棵树之后其余树构成的森林
将对树和森林的遍历转化成对二叉树的遍历

假如说题目要我们对某一片森林进行先序遍历,我们可以:

  1. 先将这片森林转化成二叉树
  2. 对转化后的二叉树进行先序遍历

同理,假如说题目要我们对某一片森林进行中序遍历,我们可以:

  1. 先将这片森林转化成二叉树
  2. 对转化后的二叉树进行中序遍历

假如说题目要我们对某一棵树进行后根遍历,我们可以:

  1. 先将这棵树转化成二叉树
  2. 对转化后的二叉树进行中序遍历

完整的转化对应关系如下图

树的遍历森林的遍历二叉树的遍历
先根遍历先序遍历先序遍历
后根遍历中序遍历中序遍历

哈夫曼树与哈夫曼编码

什么是哈夫曼树?

给定带权叶子结点个数n,哈夫曼树是这n个带权叶子结点构成的所有二叉树中,带权路径长度(WPL)最短的二叉树

在这里插入图片描述

WPL怎么算?

我们用一个例子来演示。下面是一道我每次做都会做错的题目

在由6个字符组成的字符集S中,各个字符出现的频次分别为3,4,5,6,8,10,求为S构造的哈夫曼编码的加权平均长度

首先我们要构造出哈夫曼树
在这里插入图片描述
然后我们就开始数了,8和10这俩节点的编码长度是2,其余4个节点的编码长度是3,因此:
W P L = 2 ∗ 2 / 6 + 3 ∗ 4 / 6 = 8 / 3 = 2.67 WPL=2*2/6+3*4/6=8/3=2.67 WPL=22/6+34/6=8/3=2.67

你要是这么算,那就大错特错了(我每次都这么算错的,这题我至少做过三遍,每次都是这么错的)

正确的算法如下
在这里插入图片描述

哈夫曼树的性质

如果给定哈夫曼树的带权叶子结点个数为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=1n1+2n2+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=mnm+1

化简得: n 0 = ( m − 1 ) ∗ n m + 1 n_0=(m-1)*n_m+1 n0=(m1)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 (n01)mod(m1)=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},请你对这个字符集进行哈夫曼编码

在这里插入图片描述

哈希表

哈希表处理冲突的方法

1. 开放定址

我们用一个例子来理解,假如说我们哈希函数是 H ( k e y ) = ( k e y ) m o d ( 5 ) H(key)=(key) mod (5) H(key)=(key)mod(5)

现在哈希表的大小是5,那么按理说, a r r [ 1 ] arr[1] arr[1]中存放的,只能是1、6、11、16…,反正肯定是不能存10的(因为10应该映射到 a r r [ 0 ] arr[0] arr[0]中)

但是开放定制的意思就是,如果10在映射时,发现 a r r [ 0 ] arr[0] arr[0]里面已经有数了,并且 a r r [ 1 ] arr[1] arr[1]里面没数,这时候我们就允许10存放在 a r r [ 1 ] arr[1] arr[1]

当我们映射发生冲突时,我们就需要在哈希表中找一个空余的位置,把新插入进来的元素放进去。那这个空余的位置怎么找呢?

1. 线性探测法

有的人说,从头开始一个个找,这就是我们的线性探测法。线性探测法规定,当发生冲突时,从哈希表中当前位置开始往后顺序查找空闲位置,如果到数组最后还没找到,那就从头开始接着找,将我们的数据放到线性探测出来的第一个空闲位置中

2. 平方探测法

假如说当前映射的哈希表位置是H(key),但此时该位置已经有元素了(发生冲突了),如果我们使用平方探测法,那么接下来我们就会查找数组下标为H(key)-1和H(key)+1的位置有没有元素

如果这俩都没有,那么我们再去查数组下标为H(key)-4和H(key)+4的位置有没有

如果还没有,那么我们再去查数组下标为H(key)-9和H(key)+9的位置有没有,以此类推

看到这可能有人会有问题,如果你这个哈希表数组总长度就只有5,那哪来的下标为H(key)+9的位置?没错,因此我们要在这个运算之后再模上一个数组的长度,总结来说就是

H i = ( H ( k e y ) + d i ) % m , d i = ± 1 2 , ± 2 2 , ± 3 2 . . . H_i=(H(key)+d_i) \% m,d_i=±1^2,±2^2,±3^2... Hi=(H(key)+di)%mdi=±12,±22,±32...

3. 双散列法
前面我们给出了这个式子,
H i = ( H ( k e y ) + d i ) % m H_i=(H(key)+d_i) \% m Hi=(H(key)+di)%m
其中 d i d_i di表示第 i i i次冲突发生后,下一次寻找空余位置的偏移量,在双散列法中, d i d_i di的计算公式如下

d i = i ∗ H 2 ( k e y ) d_i=i*H_2(key) di=iH2(key)
其中 i i i是寻找空闲位置过程中发生冲突的次数, H 2 H_2 H2是专门用于计算冲突偏移量的哈希函数

4. 伪随机序列法

H i = ( H ( k e y ) + d i ) % m H_i=(H(key)+d_i) \% m Hi=(H(key)+di)%m

其中 d i d_i di表示第 i i i次冲突发生后,下一次寻找空余位置的偏移量,在伪随机序列法中, d i d_i di是一个随机值

2. 拉链法

在这里插入图片描述
这个看图就很直接

平均查找长度

堆排序

什么是堆?

如果一棵完全二叉树中任何一个节点的关键字都比他子节点的关键字要大,我们就可以把这棵完全二叉树称之为一个大根堆

同理,如果一棵完全二叉树中任何一个节点的关键字都比他子节点的关键字要小,我们就可以把这棵完全二叉树称之为一个小根堆

堆的插入

往堆中插入一个元素,一般分为两个步骤:

  1. 把新元素放到堆的最后一个位置(完全二叉树的末尾)
  2. 向上调整(sift up / percolate up / heapify up)
    • 如果新插入的节点比它的父节点大(最大堆),就与父节点交换,直到堆结构恢复为止。

堆的删除

堆的删除一般指的是输出堆顶的元素,步骤如下

  1. 将堆顶元素A与完全二叉树的末尾元素B交换位置
  2. 输出A
  3. 对B执行向下调整算法

排序算法

哪些排序算法是不稳定的?

  1. 快排
  2. 堆排
  3. 希尔排序
  4. 简单选择排序

哪些排序算法是稳定的?

  1. 归并排序
  2. 基数排序
  3. 冒泡排序
  4. 直接插入排序

哪些排序算法的时间复杂度是O(log n)

1, 快排
2. 堆排
3. 归并排序

哪些排序算法的空间复杂度不是O(1)?

快排空间复杂度是O(log n)
归并排序的空间复杂度是O(n)
基数排序的空间复杂度是O®
其余排序算法的时间复杂度都是O(1),没错,堆排序和希尔排序也是O(1)

哪些排序算法的时间复杂度是O(n^2)

  1. 直接插入排序
  2. 简单选择排序
  3. 冒泡排序

基数排序的时间复杂度为什么是O(d(n+r))?

来看下面的例子
在这里插入图片描述
首先我们对原始数据进行第一趟分配和收集
在这里插入图片描述
然后我们再对第一趟收集得到的数据进行第二趟分配和收集
在这里插入图片描述
最后我们再对第二趟收集得到的数据进行第三趟分配和收集

在这里插入图片描述
上面就是一个基数排序的简单演示。

基数排序的空间复杂度被表述为O(d(n+r))

  • 其中的d,就是基数排序的趟数,在上面的例子中,d=3
  • 其中的r,就是每趟分配过程中,分配桶的个数,在上面的例子中,r=10

由于每趟分配时,我们都要遍历r个桶和n个元素,因此每一趟的时间复杂度就是O(n+r),基数排序一共要排d趟,因此总的时间复杂度就是O(d(n+r))

posted @ 2025-11-18 20:41  烧冻鸡翅QAQ  阅读(56)  评论(0)    收藏  举报  来源