数据结构与算法-3非线性结构
概述
非线性结构是数据结构中用于表达一对多或多对多关系的数据组织方式。相比线性结构(数组、链表),非线性结构能够更自然地描述层次关系、依赖关系、网络关系等复杂场景。
本章节涵盖四种核心非线性结构:
| 结构 | 核心特征 | 典型应用 |
|---|---|---|
| 哈希表 | 键值对映射,O(1) 查找 | 缓存、字典、唯一性判断 |
| 树 | 层次结构,一对多关系 | 文件系统、数据库索引、表达式解析 |
| 堆 | 完全二叉树,堆序性 | 优先级队列、Top-k 问题 |
| 图 | 多对多关系 | 社交网络、路径规划、依赖关系 |
一、哈希表
1.1 基本概念
哈希表(Hash Table)通过哈希函数将键(Key)映射到数组中的特定位置,实现高效的键值对存储与查找。
核心术语:
- 哈希函数:将任意键转换为数组下标的函数
- 负载因子(Load Factor):已存元素数 / 哈希表容量,影响哈希冲突概率和扩容决策
- 扩容阈值:当负载因子超过阈值时,触发哈希表扩容并重新哈希
常用哈希函数:
- 除留余数法:
hash(key) = key % m - 乘法散列:
hash(key) = floor(m * (key * A % 1)),其中 A ≈ 0.618
1.2 哈希冲突解决方案
当两个不同的键映射到相同位置时,发生哈希冲突。常见解决方案:
| 方案 | 原理 | 特点 |
|---|---|---|
| 拉链法(链地址法) | 冲突位置用链表存储所有冲突元素 | 实现简单,适合频繁插入删除 |
| 开放寻址法 | 探测其他空位置存放冲突元素 | 适合低负载因子,缓存友好 |
生日悖论:在哈希表中,即使负载因子较低,冲突概率也比直觉预期高。
1.3 哈希表的实现
基于数组+拉链法的实现:
|
|
1.4 经典应用
- 字典实现:Python
dict、JavaHashMap - 数据去重:利用哈希表的 O(1) 查找判断元素是否已存在
- 两数之和:LC 1 等题目的高效解法
- LRU/LFU 缓存:结合链表与哈希表实现 O(1) 访问和淘汰
二、树
2.1 基本概念
树(Tree)是一种非线性数据结构,由 n 个节点组成具有层次关系的集合(n ≥ 1),形状像一棵倒挂的树——根朝上,叶朝下。
|
|
树的特征:
- 有且仅有一个根节点
- 除根节点外,每个节点有且仅有一个父节点
- 不存在环(无回路)
基本术语:
| 术语 | 解释 | 示例 |
|---|---|---|
| 节点的度 | 一个节点拥有的子节点数量 | A、B 的度为 2;C 的度为 1 |
| 树的度 | 树中所有节点度的最大值 | 2 |
| 叶子节点 | 度 = 0 的节点 | G、H、E、I |
| 兄弟节点 | 具有相同父节点的节点 | B、C;D、E |
| 节点的深度 | 根到该节点的路径长度 | D 的深度为 2 |
| 节点的高度 | 该节点到叶子节点的最长路径长度 | D 的高度为 1 |
| 树的深度/高度 | 根节点的深度/高度 | 3 |
2.2 二叉树
二叉树是每个节点最多有两个子节点(左子节点、右子节点)的树。
四种特殊二叉树:
| 类型 | 定义 | 特征 |
|---|---|---|
| 完美二叉树 | 所有层全满 | 每层节点数 = 2^(层数-1) |
| 完全二叉树 | 最后一层左对齐,前面的层全满 | 可用数组紧凑存储 |
| 完满二叉树 | 所有非叶子节点都有两个子节点 | 无度为 1 的节点 |
| 平衡二叉树 | 任意节点左右子树高度差 ≤ 1 | 搜索效率稳定 |
二叉树性质:
1、在二叉树的第 i 层上至多有 $2^(i-1)$ 个结点(i>0)
2、深度为 k 的二叉树至多有 $2^k - 1$ 个结点(k>0),满二叉树
3、对于任意一棵二叉树,如果其 叶结点数 为 N0,而 度数为 2 的结点数 为 N2,则 $N0=N2+1$
利用 总结点数 可得方程 $N_{0} + N_{1} + N_{2} = 2N_{2} + N_{1} + 1$
4、具有 n 个结点的完全二叉树的深度必为 $log_{2}(n+1)$
5、对于完全二叉树,若从上至下、从左至右编号,则编号为 i 的结点,其左孩子编号必为 2i,其右孩子编号必为 2i+1,其父结点的编号必为 i/2(i = 1 时为根,除外)
2.3 特殊二叉树
二叉搜索树(BST)
左子树所有节点值 < 根节点值 < 右子树所有节点值。
特性:
- 中序遍历得到有序序列
- 查找、插入、删除时间复杂度 O(log n)(平衡时)
- 退化为链表时复杂度退化为 O(n)
平衡二叉搜索树(AVL)
严格平衡的 BST,任意节点左右子树高度差 ≤ 1。
- 通过旋转操作(LL、RR、LR、RL)维持平衡
- 查找效率高,但插入/删除维护成本大
红黑树
近似平衡的 BST,通过颜色和旋转维持平衡。
红黑树性质:
- 节点分为红色和黑色
- 根节点是黑色
- 叶子节点(NIL)都是黑色
- 红色节点的子节点必须是黑色(红色不连续)
- 任意节点到其叶子节点的路径包含相同数目的黑色节点
为什么需要红黑树:
- AVL 树要求严格平衡,插入/删除时调整代价大
- 红黑树近似平衡,插入/删除性能更稳定
- 适合频繁插入删除的场景(Java TreeMap、Linux 内存管理)
B 树与 B+ 树
B 树:多叉平衡搜索树,每个节点有多个子节点和关键字。
B+ 树(数据库索引常用):
- 非叶子节点不存储数据,只存储索引
- 所有数据都存储在叶子节点
- 叶子节点通过链表连接,支持范围查询
- 多叉结构降低树的高度,减少磁盘 IO
B+ 树是在二叉搜索树的基础上进行了改造
树中的节点并不存储数据本身,而是只是作为索引
每个叶子节点串在一条链表上,链表中的数据是从小到大有序的
改造之后,如果我们要求某个区间的数据
我们只需要拿区间的 起始值,在树中进行查找
当查找到某个叶子节点之后,我们再 顺着链表往后遍历,直到链表中的结点数据值大于区间的终止值为止
所有遍历到的数据,就是符合区间值的所有数据
通过为数据创建索引,如果索引保存在 内存 中
由于内存访问的速度非常快,所以查询的效率高
但是如果要为几千万、上亿的数据构建索引,占用的内存会非常多
一般索引存储在 硬盘 中,而非内存中
该方案减少了内存消耗,但是在数据查找的过程中,需要读取磁盘中的索引,因此数据查询效率就相应降低很多
因为内存的访问速度是纳秒级别的,而磁盘访问的速度仅是毫秒级别的
读取同样大小的数据,从磁盘中读取花费的时间,是从内存中读取所花费时间的上万倍,甚至几十万倍
如果把树存储在硬盘中,那么每个节点的读取(或者访问),都对应一次磁盘 IO 操作
树的高度 就等于每次查询数据时 磁盘 IO 操作的次数
由于磁盘 IO 操作非常耗时,所以我们优化的重点就是尽量减少磁盘 IO 操作,也就是尽量降低树的高度
如果我们把索引构建成 m 叉树,高度是不是比二叉树要小呢?
例如:给 16 个数据构建二叉树索引,树的高度是 4,查找一个数据,就需要 4 个磁盘 IO 操作(如果根节点存储在内存中,其他结点存储在磁盘中)
如果对 16 个数据构建五叉树索引,那高度只有 2,查找一个数据,对应只需要 2 次磁盘操作
如果 m 叉树中的 m 是 100,那对一亿个数据构建索引,树的高度也只是 3,最多只要 3 次磁盘 IO 就能获取到数据
磁盘 IO 操作减少,在此耗时也减少,查找数据的效率也就提高了
-
应用场景
关系型数据库中常用 B+ 树作为索引,这是为什么呢?
根据某个值查找数据,比如 select _ from user where id=1234
根据区间值来查找某些数据,比如 select _ from user where id > 1234 and id < 2345
为了提高查询效率,需要使用索引而对于索引的性能要求,主要考察 执行效率和存储空间
考虑以下数据结构:-
哈希表
哈希表的查询性能很好,时间复杂度是 O(1)
但是不能支持 按照区间快速查找数据 -
平衡二叉查找树
尽管平衡二叉查找树查询的性能也很高,时间复杂度是 O(logn)
但是仍然不足以支持按照区间快速查找数据 -
跳表
跳表是在 链表 之上加上 多层索引 构成的
它支持快速地插入、查找、删除数据,对应的时间复杂度是 O(logn),并且跳表也支持按照区间快速地查找数据
我们只需要定位到区间起点值对应在链表中的结点,然后从这个结点开始,顺序遍历链表,直到区间终点对应的结点为止,这期间遍历得到的数据就是满足区间值的数据
实际上,数据库索引所用到的数据结构跟跳表非常相似,叫作 B+ 树
不过,它是通过二叉查找树演化过来的,而非跳表 -
LSM 树
在关系型数据库中,通常使用 B+ 树作为索引
B+ 树的叶子节点一般都存储在磁盘中,每次插入的新数据都需要随机写入磁盘,而随机写入的性能非常慢
如果是一个日志系统,每秒钟要写入上千条甚至上万条数据,这样会使得系统性能急剧下降
操作系统对磁盘的读写是以块为单位的,我们能否 以块为单位写入,而不是每次插入一个数据都要随机写入磁盘呢?
这样是不是就可以大幅度减少写入操作了呢?
LSM 树(Log Structured Merge Trees)
当数据写入时,延迟写磁盘,将数据先存放在内存中的树里,进行常规的存储和查询
当内存中的树持续变大达到阈值时,启动树合并(Merge Trees),批量地以块为单位写入磁盘的树中
并且用预写日志 WAL 技术(Write AheadLog,预写日志技术)保证内存数据,在系统崩溃后可以被恢复
数据采取类似日志追加写的方式写入(Log Structured)磁盘,以顺序写的方式提高写入效率
因此,LSM 树 至少需要由两棵树组成,一棵是存储在内存中较小的 C0 树,另一棵是存储在磁盘中较大的 C1 树
红黑树
平衡二叉树要求树中任意一个节点的左右子树的高度相差不能大于 1
完全二叉树、满二叉树其实都是平衡二叉树
但是非完全二叉树也有可能是平衡二叉树
整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况
这样就能让整棵树的 高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些
-
为什么需要红黑树
红黑树的英文是“Red-Black Tree”,简称 R-B Tree
它是一种不严格的平衡二叉查找树红黑树的特点:
一类被标记为黑色,一类被标记为红色
根节点是黑色的
每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据
任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的
每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点AVL 树是一种高度平衡的二叉树,所以查找的效率非常高
但是,其为了维持这种高度的平衡,就要付出更多的代价。每次插入、删除都要做调整,就比较复杂、耗时
所以,对于有 频繁的插入、删除操作 的数据集合,使用 AVL 树的代价就有点高了
红黑树只是做到了 近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比 AVL 树要低红黑树的插入、删除、查找各种操作性能都比较稳定,更适合在工程中应用
-
红黑树平衡调整
https://www.cnblogs.com/LiaHon/p/11203229.html
通过一系列的旋转和重新着色操作来保持树的平衡
这些操作确保了树的高度大致为 $\log_2(n+1)$,其中 $n$ 是树中节点的数量-
左旋和右旋
用于调整节点位置的操作
它们不改变树的逻辑结构,但会改变树的物理结构-
左旋
如果一个节点 $h$ 的右子节点 $x$ 存在,那么 $h$ 左旋后
$x$ 将成为 $h$ 的父节点,$h$ 将成为 $x$ 的左子节点,$x$ 的左子节点将成为 $h$ 的右子节点 -
右旋
与左旋相反,如果一个节点 $h$ 的左子节点 $x$ 存在,那么 $h$ 右旋后
$x$ 将成为 $h$ 的父节点,$h$ 将成为 $x$ 的右子节点,$x$ 的右子节点将成为 $h$ 的左子节点
-
-
变色
涉及改变节点的颜色,红黑树中节点的颜色可以是红色或黑色
变色操作通常与其他操作结合使用,以保持红黑树的性质 -
插入后平衡调整
当向红黑树中插入一个新节点时,新节点默认被着色为红色
插入后,可能需要进行一系列的旋转和变色操作来维持红黑树的性质 -
删除后平衡调整
删除操作可能会违反红黑树的性质,特别是当删除的节点是黑色时
调整可能包括将兄弟节点的颜色变为黑色,将父节点的颜色变为红色,然后递归地对树进行调整,直到树重新满足红黑树的性质
-
哈夫曼树
带权路径最短的二叉树,用于数据压缩。
- 频率高的字符使用较短的编码
- 编码前缀唯一,避免歧义
2.4 树的遍历
递归实现
|
|
迭代实现
|
|
Morris 遍历:利用叶子节点的空指针实现 O(1) 空间的遍历。
2.5 并查集
并查集(Union-Find)用于处理集合合并与查询问题。
|
|
应用:Kruskal 最小生成树、朋友圈问题、岛屿数量等。
三、堆
3.1 基本概念
堆(Heap)是一种近似完全二叉树的数组结构,满足堆序性:
- 大顶堆:父节点值 ≥ 子节点值
- 小顶堆:父节点值 ≤ 子节点值
为什么用数组存储:
- 完全二叉树结构紧凑,无需存储指针
- 通过下标计算父子关系:
parent = (i-1)//2,left = 2*i+1,right = 2*i+2
3.2 堆的基本操作
|
|
3.3 堆排序
|
|
时间复杂度:O(n log n);空间复杂度:O(1)
3.4 堆的典型应用
| 应用 | 场景 | 解决方案 |
|---|---|---|
| Top-k 问题 | 找出最大的 k 个元素 | 小顶堆维护 k 个元素 |
| 中位数维护 | 动态数据流中求中位数 | 大顶堆 + 小顶堆 |
| 优先级队列 | 按优先级调度任务 | 直接使用堆结构 |
四、图
4.1 基本概念
图(Graph)由**顶点(Vertex)和边(Edge)**组成,用于表达多对多关系。
图的分类:
| 分类维度 | 类型 | 说明 |
|---|---|---|
| 方向 | 有向图 / 无向图 | 边是否有方向 |
| 连通性 | 连通图 / 非连通图 | 任意两点是否连通 |
| 权重 | 有权图 / 无权图 | 边是否带有权重 |
| 稠密度 | 稠密图 / 稀疏图 | 边数相对顶点数的大小 |
4.2 图的存储
|
|
| 存储方式 | 空间复杂度 | 查边复杂度 | 适用场景 |
|---|---|---|---|
| 邻接矩阵 | O(V²) | O(1) | 稠密图 |
| 邻接表 | O(V+E) | O(degree) | 稀疏图 |
4.3 图的遍历
|
|
DFS 与 BFS 的选择:
- DFS:递归实现简单,适合探索所有路径、拓扑排序、连通分量
- BFS:最短路径、分层遍历、资源有限搜索
4.4 最短路径算法
| 算法 | 时间复杂度 | 适用场景 |
|---|---|---|
| BFS | O(V+E) | 无权图最短路径 |
| Dijkstra | O((V+E) log V) | 非负权图 |
| Bellman-Ford | O(VE) | 含负权边,可检测负环 |
| Floyd | O(V³) | 全源最短路径 |
Dijkstra 算法:
|
|
4.5 最小生成树
| 算法 | 策略 | 时间复杂度 |
|---|---|---|
| Prim | 从点出发,逐步扩展切分 | O((V+E) log V) |
| Kruskal | 从边出发,按权重从小到大选边 | O(E log E) |
Kruskal 算法(并查集实现):
|
|
4.6 拓扑排序
用于有向无环图(DAG),得到满足依赖关系的线性顺序。
|
|
应用场景:课程安排、任务调度、依赖解析。
拓展与后续学习建议
-
深入进阶方向
- 学习 LRU/LFU 缓存的实现,理解哈希表与双向链表的高效结合
- 掌握 Trie(字典树/前缀树),用于字符串高效存储与搜索
- 研究线段树与树状数组,处理区间查询问题
-
算法练习建议
- LeetCode Hot 100 中关于树、图、堆的题目
- 重点练习:二叉树遍历序列化、BFS/DFS 应用、最小生成树
- 刷题时注意理解问题本质与数据结构选择的关系
-
工程实践视角
- 了解 Redis 跳表实现(Sorted Set 底层)
- 理解数据库索引选择 B+ 树而非 B 树的原因
- 研究 LSM 树在 LevelDB、RocksDB 中的应用
- 关注分布式系统中的哈希一致性(Consistent Hashing)算法