快速掌握Java非线性数据结构:树(二叉树、平衡二叉树、多路平衡树)、堆、图【算法必备】

一、引子


在之前对数组、链表等线性数据结构 的讨论中,我们见证了线性结构对顺序关联、单一前驱/后继场景的高效处理能力------
快速掌握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条规则实现"近似平衡",降低维护成本:

  1. 节点是红或黑;
  2. 根节点是黑;
  3. 叶子(NIL)是黑;
  4. 红节点的子节点必为黑(无连续红节点);
  5. 从根到叶子的所有路径,黑节点数相同(黑高一致)。

红黑树的核心优势 :插入/删除仅需1~2次旋转,适用于内存动态排序场景 (如Java的TreeMapTreeSet,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树(日志结构合并树)通过"先写内存,再写磁盘"优化写性能,结构分为三层:

  1. MemTable:内存中的有序结构(如跳表),接收新数据;
  2. SSTable:磁盘上的有序不可变文件(顺序写生成);
  3. WAL:内存数据的持久化日志(防止崩溃丢失)。

写流程

  1. 数据写入WAL(持久化);
  2. 数据插入MemTable(内存有序,写性能高);
  3. MemTable满时,Flush到磁盘生成SSTable(顺序写);
  4. 后台Compaction合并小SSTable(删除过期数据,减少文件数)。

适用场景:写密集的分布式存储(如HBase、Cassandra)。

2.5 树结构总结:选择指南

树类型 平衡类型 核心优势 适用场景
AVL树 严格平衡 查询速度快 静态字典、查询密集场景
红黑树 近似平衡 动态维护成本低 内存动态排序(TreeMap、TreeSet)
B+树 多路平衡 范围查询高效、适配磁盘 数据库索引(MySQL InnoDB)
LSM树 写优化 高写入性能 分布式存储(HBase、Cassandra)

三、堆(Heap)

3.1 堆的基本概念

堆是完全二叉树实现的优先队列,核心性质:

  1. 堆序性:大顶堆(父节点≥子节点)、小顶堆(父节点≤子节点);
  2. 完全二叉树:按层序填充,适合数组存储(节点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算法用于有向加权图单源最短路径 (从起点到所有顶点的最短路径),核心思想是"贪心+优先队列":

  1. 维护距离数组 dist[]dist[v]表示起点到v的当前最短距离(初始为无穷大,起点为0);
  2. 小顶堆存储待处理顶点(按距离排序);
  3. 每次取出距离最小的顶点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到所有顶点的最短距离数组
}
代码关键说明
  1. 距离数组初始化 :用Integer.MAX_VALUE表示"无穷远"(未访问状态),起点距离设为0。
  2. 优先队列的作用 :小顶堆按距离排序,保证每次处理当前离起点最近的顶点(贪心策略的核心)。
  3. 跳过旧记录:堆中可能存在同一顶点的多条记录(比如顶点A的距离先被记录为10,后更新为5),旧记录(距离10)无需处理,直接跳过。
  4. 松弛操作:算法的核心步骤------若通过当前顶点能找到更短的路径,则更新邻接顶点的距离。
时间复杂度分析

假设图的顶点数为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 选择的核心原则

  1. 场景匹配:若需"层级关系"选树,"优先顺序"选堆,"多对多关联"选图;
  2. 存储介质:内存用红黑树,磁盘用B+树/LSM树;
  3. 操作侧重:写密集选LSM树,读密集选B+树,动态排序选红黑树;
  4. 数据规模:大规模数据选O(log n)复杂度的结构(如B+树、红黑树),小规模选简单结构(如Floyd-Warshall)。

六、总结

非线性数据结构是解决复杂问题 的"工具库"------树用层级映射组织架构,堆用优先队列调度任务,图用关联网络规划路径。它们的价值不仅是"优化算法效率",更是将真实问题抽象为数据结构的思维方式

  • 当看到"数据库索引"时,能想到B+树的"范围查询+磁盘IO优化";
  • 当用到TreeMap时,能理解红黑树的"动态平衡+O(log n)操作";
  • 当用高德地图导航时,能意识到Dijkstra的"贪心+优先队列"在背后运行。

分析问题时:

  1. 先分析数据的关联类型(线性/层级/多对多);
  2. 根据关联类型选择数据结构(数组/树/图);
  3. 根据操作需求(读/写/遍历)选择具体实现(红黑树/B+树/Dijkstra)。

只有深度掌握数据结构,才能构建出高效、优雅的算法解决方案。