一、引子
在之前对数组、链表等线性数据结构 的讨论中,我们见证了线性结构对顺序关联、单一前驱/后继场景的高效处理能力------
快速掌握Java线性数据结构:数组、链表、栈与队列【算法必备】
概率型数据结构 ------ 布隆过滤器:快速掌握Java概率型数据结构:布隆过滤器
但当我们将视角转向更贴近真实世界的复杂问题时,线性结构的"线性思维"便显得捉襟见肘:
- 如何高效表达社交网络中"你好友的好友"这种多对多关联?
- 如何组织文件系统里"目录嵌套子目录"的层级关系?
- 如何实现任务调度中"优先处理高优先级任务"的动态排序?
这些场景的核心矛盾在于:数据元素间存在非连续、多维度 的关联------而这正是非线性数据结构的"主场"。
与线性结构中"元素排成一条直线"的组织方式不同,非线性结构通过层级、网络或优先级将元素连接,能更自然地映射真实世界的复杂关系,同时在特定操作(如层级查询、关联遍历、动态优先级调整)中实现远超线性结构的效率。
本文将聚焦Java中数据结构体系中核心的非线性家族 ,围绕**树结构*、堆结构 、图结构展开,揭开非线性数据结构的核心面纱。
二、树(Tree)
2.1 树的基本概念与术语
树(Tree)是层级化非线性结构 的典型代表,其核心特征是"无环+单一根节点+唯一父节点"。我们用公司组织架构直观映射树的核心术语:
- 根节点(Root):公司CEO(唯一无上级的节点);
- 父节点(Parent):部门经理是普通员工的父节点(上级);
- 子节点(Child):普通员工是部门经理的子节点(下属);
- 叶子节点(Leaf):无下属的基层员工(无子节点);
- 深度(Depth):节点到根的路径长度(CEO深度为0,部门经理深度为1);
- 高度(Height):节点到最远叶子的路径长度(基层员工高度为0,CEO高度为2);
- 子树(Subtree):技术部经理及其下属构成一棵独立子树。
2.2 二叉树:树结构的基础单元
二叉树是树的特殊形式------每个节点最多有2个子节点(左子节点、右子节点)。它是后续平衡树、堆等结构的基础,需重点掌握两类特殊二叉树:
- 完全二叉树:按层序填充(从左到右),最后一层可不满(如数组存储的堆结构);
- 满二叉树:所有节点的子节点数为0或2(无"独生子"节点)。
2.2.1 二叉树的遍历
遍历是树结构的核心操作,目的是按规则访问所有节点。常见遍历方式及场景:
遍历方式 | 顺序规则 | 场景举例 |
---|---|---|
前序遍历 | 根→左→右 | 打印文件路径(根目录→子目录→文件) |
中序遍历 | 左→根→右 | 二叉搜索树(BST)升序输出(左<根<右) |
后序遍历 | 左→右→根 | 计算文件大小(先算子文件,再累加父目录) |
层序遍历 | 按深度逐层 | 广度优先搜索(BFS)、层级统计 |
代码实现(Java):以二叉树节点类为基础:
java
// 二叉树节点类
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int val) { this.val = val; }
}
// 前序遍历(迭代版,避免递归栈溢出)
public void preorderIterative(TreeNode root) {
if (root == null) return;
Stack<TreeNode> stack = new Stack<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
System.out.print(node.val + " "); // 访问根
// 右子节点先入栈(栈是LIFO,保证左子节点先处理)
if (node.right != null) stack.push(node.right);
if (node.left != null) stack.push(node.left);
}
}
// 层序遍历(队列版,广度优先)
public void levelOrder(TreeNode root) {
if (root == null) return;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
int levelSize = queue.size(); // 当前层节点数
for (int i = 0; i < levelSize; i++) {
TreeNode node = queue.poll();
System.out.print(node.val + " ");
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
}
}
2.3 平衡二叉树
普通二叉树的致命缺陷是易退化 ------若插入有序数据(如1→2→3→4),会退化成链表 (高度=节点数),此时查询复杂度从O(log n)退化到O(n)。平衡二叉树通过旋转操作维护树的高度,保证性能稳定。
2.3.1 AVL树
AVL树是严格平衡二叉搜索树 ,要求每个节点的左右子树高度差(平衡因子)绝对值≤1。插入/删除时通过旋转修复平衡:
- 右旋转:解决左子树过高(如节点A的左子节点B有左子节点C);
- 左旋转:解决右子树过高;
- 左右旋转:左子节点的右子树过高(先左旋左子节点,再右旋当前节点);
- 右左旋转:右子节点的左子树过高(先右旋右子节点,再左旋当前节点)。
代码片段(右旋转):
java
// AVL节点(扩展TreeNode,增加height属性)
class AVLNode extends TreeNode {
int height;
AVLNode(int val) { super(val); this.height = 1; }
}
// 右旋转:修复左子树过高的失衡节点root
private AVLNode rightRotate(AVLNode root) {
AVLNode leftChild = (AVLNode) root.left; // B节点
AVLNode rightOfLeft = (AVLNode) leftChild.right; // B的右子节点
// 核心旋转:B成为新根,A成为B的右子节点
leftChild.right = root;
root.left = rightOfLeft;
// 更新高度(先更新A,再更新B)
updateHeight(root);
updateHeight(leftChild);
return leftChild; // 返回新根节点B
}
// 更新节点高度(基于左右子节点高度)
private void updateHeight(AVLNode node) {
node.height = 1 + Math.max(getHeight(node.left), getHeight(node.right));
}
2.3.2 红黑树
AVL树的严格平衡导致维护成本高 (插入/删除最多需2次旋转)。红黑树通过5条规则实现"近似平衡",降低维护成本:
- 节点是红或黑;
- 根节点是黑;
- 叶子(NIL)是黑;
- 红节点的子节点必为黑(无连续红节点);
- 从根到叶子的所有路径,黑节点数相同(黑高一致)。
红黑树的核心优势 :插入/删除仅需1~2次旋转,适用于内存动态排序场景 (如Java的TreeMap
、TreeSet
,Linux进程调度)。
2.4 多路平衡树
二叉树的单键+两子节点 结构适合内存,但不适合磁盘 ------磁盘IO是块级操作(每次读4KB/8KB),二叉树高度过高(100万节点约20层)会导致20次IO,延迟达200ms(机械硬盘IO延迟约10ms)。
多路平衡树通过多键+多子节点减少树高,适配磁盘批量IO。常见类型:
2.4.1 B+树:数据库索引的首选
B+树是B-树的变种 ,专为范围查询优化,是MySQL InnoDB引擎的核心索引结构。与B-树的区别:
- 非叶子节点仅存索引:所有数据存于叶子节点;
- 叶子节点链表化:叶子节点按键顺序连成双向链表,范围查询只需遍历链表;
- 查询路径稳定:所有查询必须走到叶子节点,保证性能一致。
B+树的优势:
- 范围查询高效(如
id BETWEEN 100 AND 200
,找到起始叶子后遍历链表); - 树高极低(t=1000时,10亿数据仅3层,IO次数≤3)。
2.4.2 LSM树
B+树的写操作需随机IO (修改索引节点),而随机IO性能远低于顺序IO(SSD顺序写500MB/s,随机写仅50MB/s)。LSM树(日志结构合并树)通过"先写内存,再写磁盘"优化写性能,结构分为三层:
- MemTable:内存中的有序结构(如跳表),接收新数据;
- SSTable:磁盘上的有序不可变文件(顺序写生成);
- WAL:内存数据的持久化日志(防止崩溃丢失)。
写流程:
- 数据写入WAL(持久化);
- 数据插入MemTable(内存有序,写性能高);
- MemTable满时,Flush到磁盘生成SSTable(顺序写);
- 后台Compaction合并小SSTable(删除过期数据,减少文件数)。
适用场景:写密集的分布式存储(如HBase、Cassandra)。
2.5 树结构总结:选择指南
树类型 | 平衡类型 | 核心优势 | 适用场景 |
---|---|---|---|
AVL树 | 严格平衡 | 查询速度快 | 静态字典、查询密集场景 |
红黑树 | 近似平衡 | 动态维护成本低 | 内存动态排序(TreeMap、TreeSet) |
B+树 | 多路平衡 | 范围查询高效、适配磁盘 | 数据库索引(MySQL InnoDB) |
LSM树 | 写优化 | 高写入性能 | 分布式存储(HBase、Cassandra) |
三、堆(Heap)
3.1 堆的基本概念
堆是完全二叉树实现的优先队列,核心性质:
- 堆序性:大顶堆(父节点≥子节点)、小顶堆(父节点≤子节点);
- 完全二叉树:按层序填充,适合数组存储(节点i的左子节点为2i+1,父节点为(i-1)/2)。
3.2 核心操作:上浮与下沉
堆的所有操作围绕维护堆序性展开,核心是两个操作:
3.2.1 上浮(Swim):插入元素的"上升"
插入新元素时,将元素放到数组末尾,向上比较父节点------若新元素比父节点大(大顶堆)或小(小顶堆),则交换位置,直到满足堆序性。
3.2.2 下沉(Sink):删除根元素的"下降"
删除根元素(优先级最高)时,将数组最后一个元素放到根位置,向下比较子节点------大顶堆选左右子节点中的较大者交换,小顶堆选较小者交换,直到满足堆序性。
代码实现(大顶堆):
java
class MaxHeap {
private int[] heap;
private int size;
private int capacity;
public MaxHeap(int capacity) {
this.capacity = capacity;
this.heap = new int[capacity];
}
// 插入元素(上浮调整)
public void insert(int value) {
if (size == capacity) throw new IllegalStateException("堆已满");
heap[size] = value;
swim(size++); // 上浮新元素
}
// 删除并返回最大值(下沉调整)
public int extractMax() {
if (size == 0) throw new IllegalStateException("堆为空");
int max = heap[0];
heap[0] = heap[--size]; // 最后一个元素移到根
sink(0); // 下沉根元素
return max;
}
// 上浮:调整索引index的元素
private void swim(int index) {
while (index > 0) {
int parent = (index - 1) / 2;
if (heap[index] > heap[parent]) { // 大顶堆:子>父则交换
swap(index, parent);
index = parent;
} else break;
}
}
// 下沉:调整索引index的元素
private void sink(int index) {
while (true) {
int left = 2 * index + 1; // 左子节点
int right = 2 * index + 2; // 右子节点
int largest = index;
// 找到左右子节点中的较大者
if (left < size && heap[left] > heap[largest]) largest = left;
if (right < size && heap[right] > heap[largest]) largest = right;
if (largest == index) break; // 无需调整
swap(index, largest);
index = largest;
}
}
private void swap(int i, int j) {
int temp = heap[i];
heap[i] = heap[j];
heap[j] = temp;
}
}
3.3 适用场景
- 任务调度:小顶堆存储任务(优先级高的任务值小),优先处理根元素;
- TOP-K问题:找最大的K个数(用小顶堆,遍历数组时替换堆顶的小数);
- 流数据处理:实时计算热搜榜(维护小顶堆,更新流数据中的TOP-K)。
四、图(Graph)
4.1 图的基本概念
图(Graph)是多对多关联的非线性结构,记为G=(V,E),其中V是顶点集,E是边集。核心术语:
- 有向图:边有方向(如A→B表示A到B的关系);
- 无向图:边无方向(如A-B等价于A→B和B→A);
- 权重:边的数值属性(如路径长度、社交亲密度);
- DAG:有向无环图(如任务依赖关系)。
4.2 图的存储方式
图的存储需平衡空间与访问效率,常见方式:
4.2.1 邻接矩阵
用二维数组 存储:matrix[i][j]
表示顶点i到j的边。
- 优势:查询边是否存在(O(1))、计算度数(O(n))高效;
- 局限:空间复杂度O(n²),适合** dense 图**(边数多)。
4.2.2 邻接表
用数组+链表存储:数组元素对应顶点的邻接链表。
- 优势:空间复杂度O(n+e),适合** sparse 图**(边数少);
- 局限:查询边需遍历链表(O(degree(v)))。
4.3 核心操作:遍历与最短路径
4.3.1 图的遍历
- DFS(深度优先搜索):优先访问顶点的未访问子顶点,用栈或递归实现(如社交网络的"好友的好友"推荐);
- BFS(广度优先搜索):优先访问顶点的所有邻接顶点,用队列实现(如最短路径查询)。
4.3.2 最短路径:Dijkstra算法
Dijkstra算法用于有向加权图 的单源最短路径 (从起点到所有顶点的最短路径),核心思想是"贪心+优先队列":
- 维护距离数组
dist[]
:dist[v]
表示起点到v的当前最短距离(初始为无穷大,起点为0); - 用小顶堆存储待处理顶点(按距离排序);
- 每次取出距离最小的顶点u,遍历其邻接顶点v,更新
dist[v]
(若dist[u]+weight(u→v) < dist[v]
)。
代码实现(Dijkstra算法)
我们用邻接表 存储图(适配稀疏图),用小顶堆维护待处理顶点(按距离排序),以下是Java实现:
java
// 边的结构:存储目标顶点与权重
class Edge {
int toVertex; // 目标顶点ID
int weight; // 边的权重(如路径长度、时间)
Edge(int toVertex, int weight) {
this.toVertex = toVertex;
this.weight = weight;
}
}
// Dijkstra算法:计算从start顶点到所有顶点的最短距离
public int[] dijkstra(Map<Integer, List<Edge>> adjacencyList, int start) {
// 1. 初始化距离数组:dist[v]表示start到v的当前最短距离(初始为无穷大)
int vertexCount = adjacencyList.size();
int[] dist = new int[vertexCount];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[start] = 0; // 起点到自身的距离为0
// 2. 小顶堆:存储(当前距离,顶点ID),按距离升序排序(优先处理近的顶点)
PriorityQueue<int[]> minHeap = new PriorityQueue<>(Comparator.comparingInt(a -> a[0]));
minHeap.offer(new int[]{0, start}); // 起点入堆
// 3. 贪心迭代:每次处理距离最近的顶点
while (!minHeap.isEmpty()) {
int[] current = minHeap.poll();
int currentDist = current[0]; // 当前顶点到start的距离
int currentVertex = current[1]; // 当前顶点ID
// 跳过旧记录:若堆中存储的距离大于已确定的最短距离,说明该记录无效
if (currentDist > dist[currentVertex]) continue;
// 遍历当前顶点的所有邻接边,尝试"松弛"(更新邻接顶点的距离)
List<Edge> edges = adjacencyList.get(currentVertex);
for (Edge edge : edges) {
int nextVertex = edge.toVertex;
int edgeWeight = edge.weight;
// 松弛条件:start→currentVertex→nextVertex的路径,比start→nextVertex的当前路径更短
if (dist[currentVertex] != Integer.MAX_VALUE &&
dist[nextVertex] > dist[currentVertex] + edgeWeight) {
dist[nextVertex] = dist[currentVertex] + edgeWeight; // 更新最短距离
minHeap.offer(new int[]{dist[nextVertex], nextVertex}); // 新距离入堆
}
}
}
return dist; // 返回start到所有顶点的最短距离数组
}
代码关键说明
- 距离数组初始化 :用
Integer.MAX_VALUE
表示"无穷远"(未访问状态),起点距离设为0。 - 优先队列的作用 :小顶堆按距离排序,保证每次处理当前离起点最近的顶点(贪心策略的核心)。
- 跳过旧记录:堆中可能存在同一顶点的多条记录(比如顶点A的距离先被记录为10,后更新为5),旧记录(距离10)无需处理,直接跳过。
- 松弛操作:算法的核心步骤------若通过当前顶点能找到更短的路径,则更新邻接顶点的距离。
时间复杂度分析
假设图的顶点数为n
,边数为e
:
- 优先队列的插入/弹出操作时间为
O(log n)
; - 每条边最多被处理一次(入堆一次),总边处理时间为
O(e log n)
; - 整体时间复杂度为
O(e log n)
,适用于大规模稀疏图(如地图导航的百万级顶点)。
适用场景与局限
- 适用场景:地图导航(如高德地图的最短驾车路线)、网络路由(如OSPF协议的最短路径计算)、物流配送的最优路径规划。
- 局限 :无法处理负权边------贪心策略会提前锁定"最短距离",若存在负权边(如"反向行驶奖励"),后续可能出现更短路径,但已被忽略。
4.3.3 其他常见最短路径算法
针对Dijkstra的局限,以下算法可处理特殊场景:
- Bellman-Ford算法 :能处理负权边 ,甚至检测负权环 (如"循环借贷导致总债务减少"的无效场景)。时间复杂度
O(n*e)
,适用于小规模图。 - SPFA算法 :Bellman-Ford的队列优化版,平均时间复杂度
O(e)
,是工程中处理负权边的常用选择。 - Floyd-Warshall算法 :多源最短路径 解法(计算所有顶点对的最短路径),用动态规划实现,时间复杂度
O(n³)
,适用于顶点数少的场景(如<1000的图)。
4.4 图结构的总结
图是多对多关联场景的"终极工具",其核心价值是映射真实世界的复杂关系。选择图的存储与算法时,需关注以下维度:
场景 | 推荐存储方式 | 推荐算法 |
---|---|---|
社交网络(稀疏图) | 邻接表 | DFS(好友推荐)、BFS(最短关系链) |
地图导航(加权图) | 邻接表 | Dijkstra(最短路径) |
任务依赖(DAG) | 邻接表 | 拓扑排序(Kahn算法) |
负权边场景(如金融) | 邻接表 | SPFA/Bellman-Ford |
多源最短路径(小规模) | 邻接矩阵 | Floyd-Warshall |
五、非线性数据结构的综合对比与选择框架
从核心特征、关键操作效率、适用场景三个维度做综合对比:
结构类型 | 核心特征 | 关键操作时间复杂度 | 典型适用场景 |
---|---|---|---|
红黑树 | 近似平衡二叉树 | 插入/删除/查询:O(log n) | 内存动态排序(TreeMap、TreeSet) |
B+树 | 多路平衡树,叶子链表化 | 查询/范围查询:O(log n) | 数据库索引(MySQL InnoDB) |
LSM树 | 日志结构,顺序写优化 | 写:O(1),读:O(log n) | 分布式存储(HBase、Cassandra) |
堆 | 完全二叉树,优先队列 | 插入/删除顶:O(log n) | 任务调度、TOP-K问题 |
图 | 多对多关联 | 遍历:O(n+e),最短路径:O(e log n) | 社交网络、路径规划、任务依赖 |
5.1 选择的核心原则
- 场景匹配:若需"层级关系"选树,"优先顺序"选堆,"多对多关联"选图;
- 存储介质:内存用红黑树,磁盘用B+树/LSM树;
- 操作侧重:写密集选LSM树,读密集选B+树,动态排序选红黑树;
- 数据规模:大规模数据选O(log n)复杂度的结构(如B+树、红黑树),小规模选简单结构(如Floyd-Warshall)。
六、总结
非线性数据结构是解决复杂问题 的"工具库"------树用层级映射组织架构,堆用优先队列调度任务,图用关联网络规划路径。它们的价值不仅是"优化算法效率",更是将真实问题抽象为数据结构的思维方式:
- 当看到"数据库索引"时,能想到B+树的"范围查询+磁盘IO优化";
- 当用到
TreeMap
时,能理解红黑树的"动态平衡+O(log n)操作"; - 当用高德地图导航时,能意识到Dijkstra的"贪心+优先队列"在背后运行。
分析问题时:
- 先分析数据的关联类型(线性/层级/多对多);
- 根据关联类型选择数据结构(数组/树/图);
- 根据操作需求(读/写/遍历)选择具体实现(红黑树/B+树/Dijkstra)。
只有深度掌握数据结构,才能构建出高效、优雅的算法解决方案。