【Java基础】二叉树遍历与红黑树的完美平衡艺术——从递归崩溃到自平衡的硬核拆解

二叉树遍历与红黑树的完美平衡艺术------从递归崩溃到自平衡的硬核拆解


一、面试真题引入

先说一个真实发生过的面试场景:

"用非递归写一个二叉树的中序遍历。"

"......我能用递归吗?"

"递归当然可以,但递归本质是系统栈帮你在做,你能自己用栈模拟吗?"

"......"

这个场景在很多大厂面试中反复上演。递归写法谁都会------三行代码的事。但面试官想看的是:你对递归底层机制的理解、你能否在不能用递归的场合(比如嵌入式、深度极大的树)自己写出来

更狠的追问还在后面:

"AVL 树和红黑树有什么区别?为什么 JDK 选了红黑树?"

这个问题直接考验你对数据结构设计的工程权衡有没有概念------不是背定义,是理解"为什么"。

本期读完,你将获得:

  • 四种遍历的非递归手写能力(面试手撕代码稳拿分)
  • 红黑树自平衡三步操作的一眼看懂
  • 一道大厂面试连环炮的标准答案
  • 一个可运行的公司组织架构建模案例

二、底层的时空解构与源码透视

2.1 树的分类:三句话讲清

类型 定义 关键性质
满二叉树 除叶子外每个节点都有两个子节点 深度 k 时节点数 = 2^k - 1
完全二叉树 从上到下、从左到右填满,最后一层可不满 适合数组存储,父节点下标 i 时左子 = 2i+1
二叉搜索树(BST) 左子树 < 根 < 右子树 中序遍历即升序序列

一句话记忆:满二叉树讲"对称",完全二叉树讲"紧凑",BST 讲"有序"。

2.2 四种遍历:非递归才是真功夫

先看一棵示例树:

复制代码
        1
       / \
      2   3
     / \   \
    4   5   6
前序遍历(NLR):根 → 左 → 右

递归版(人人会写):

java 复制代码
void preOrder(Node root) {
    if (root == null) return;
    System.out.print(root.val + " ");
    preOrder(root.left);
    preOrder(root.right);
}
// 输出:1 2 4 5 3 6

非递归版(手撕代码重点):

java 复制代码
void preOrderNR(Node root) {
    if (root == null) return;
    Deque<Node> stack = new ArrayDeque<>();
    stack.push(root);
    while (!stack.isEmpty()) {
        Node node = stack.pop();
        System.out.print(node.val + " ");
        if (node.right != null) stack.push(node.right); // 先右后左
        if (node.left != null)  stack.push(node.left);
    }
}
// 输出:1 2 4 5 3 6

记忆技巧:前序最简单------根入栈 → 弹出打印 → 右子入栈 → 左子入栈。为什么先右后左?因为栈是后进先出,我们要先处理左子。

中序遍历(LNR):左 → 根 → 右

递归版:

java 复制代码
void inOrder(Node root) {
    if (root == null) return;
    inOrder(root.left);
    System.out.print(root.val + " ");
    inOrder(root.right);
}
// 输出:4 2 5 1 3 6

非递归版:

java 复制代码
void inOrderNR(Node root) {
    Deque<Node> stack = new ArrayDeque<>();
    Node curr = root;
    while (curr != null || !stack.isEmpty()) {
        while (curr != null) {        // 一路向左,沿路压栈
            stack.push(curr);
            curr = curr.left;
        }
        curr = stack.pop();           // 左到头了,弹出打印
        System.out.print(curr.val + " ");
        curr = curr.right;            // 转向右子树
    }
}
// 输出:4 2 5 1 3 6

记忆技巧 :中序最经典------一路向左走到黑,弹出一个转向右

后序遍历(LRN):左 → 右 → 根

非递归版(最难的,用双栈法记):

java 复制代码
void postOrderNR(Node root) {
    if (root == null) return;
    Deque<Node> stack1 = new ArrayDeque<>();
    Deque<Node> stack2 = new ArrayDeque<>();
    stack1.push(root);
    while (!stack1.isEmpty()) {
        Node node = stack1.pop();
        stack2.push(node);                  // 模拟"根→右→左",反序入栈2
        if (node.left != null)  stack1.push(node.left);
        if (node.right != null) stack1.push(node.right);
    }
    while (!stack2.isEmpty()) {             // 反转得到真正的"左→右→根"
        System.out.print(stack2.pop().val + " ");
    }
}
// 输出:4 5 2 6 3 1

记忆技巧:后序 = 前序变体。前序是「根→左→右」,把左右顺序改为「根→右→左」入辅助栈,反转即得「左→右→根」。

层序遍历(BFS):逐层扫描
java 复制代码
void levelOrder(Node root) {
    if (root == null) return;
    Queue<Node> queue = new LinkedList<>();
    queue.offer(root);
    while (!queue.isEmpty()) {
        Node node = queue.poll();
        System.out.print(node.val + " ");
        if (node.left != null)  queue.offer(node.left);
        if (node.right != null) queue.offer(node.right);
    }
}
// 输出:1 2 3 4 5 6
四种遍历对比速记表
遍历 顺序 非递归核心结构 一句话
前序 根→左→右 栈(先右后左压) 根入栈,弹出即打
中序 左→根→右 栈(一路向左) 一路向左,弹出转右
后序 左→右→根 双栈(前序变体翻转) 前序左右换,反转得后序
层序 逐层 队列 出队打印,左右子入队

2.3 二叉搜索树(BST):为什么有序但会退化

BST 的核心规则:左 < 根 < 右。查找路径沿着这条规则一路向下,理想情况下 O(log n)。

但理想是理想,现实是:

复制代码
插入顺序:1 → 2 → 3 → 4 → 5 → 6

BST 变成了:
    1
     \
      2
       \
        3
         \
          4
           \
            5
             \
              6

退化成一条链,查找退化为 O(n)。这就是为什么需要"平衡"------红黑树登场。

2.4 红黑树五大性质与自平衡三步

红黑树是一种自平衡二叉搜索树,通过五条规则和三种操作维持近似平衡。

五大性质(必须全部满足)

# 性质 作用
1 节点非红即黑 用颜色标记平衡状态
2 根节点是黑色 保证树顶稳定
3 每个叶子(NIL)是黑色 统一空节点处理
4 红色节点的两个子节点必须是黑色 禁止连续红(防止路径失衡)
5 从任一节点到其每个叶子的路径上黑色节点数相同 核心:黑高一致保证平衡

性质 5 是灵魂:它保证了从根到叶子的最长路径不超过最短路径的 2 倍(最极端情况:全黑 vs 红黑交替),从而保证 O(log n)。

三种自平衡操作

操作 触发条件 效果
变色 父红叔红 父叔变黑,祖父变红,以祖父为当前节点继续向上修复
左旋 父红叔黑,且当前节点是右子 逆时针旋转,降低右子树高度
右旋 父红叔黑,且当前节点是左子 顺时针旋转,降低左子树高度

一句话总结自平衡流程:新插节点必红色 → 父黑则无事 → 父红则看叔叔 → 叔红就变色 → 叔黑就旋转(LR 型先左旋再右旋,RL 型先右旋再左旋)。

2.5 AVL 树 vs 红黑树:面试必问的工程权衡

维度 AVL 树 红黑树
平衡条件 严格:左右子树高度差 ≤ 1 宽松:最长路径 ≤ 2×最短路径
查询性能 略优(更平衡,树更矮) 略差(树稍高)
插入/删除代价 高(旋转次数多) 低(最多旋转 2~3 次)
适用场景 读多写少(数据库索引、字典) 读写均衡(Java HashMap/TreeMap、Linux CFS 调度器、epoll)

为什么 JDK 选红黑树? Java 的 HashMap/TreeMap 插入和删除操作极其频繁,AVL 树每次插入都可能触发多次旋转------红黑树用"不那么严格的平衡"换来了更高的写入吞吐量。这是一个经典的读写性能的工程权衡


三、"纯手工、零依赖"原创案例实战

案例:公司组织架构建模

假设一家公司的层级结构:CEO → VP(技术)/VP(市场) → 各 Director → 各 Manager。用树结构建模,支持三种操作:

  • 按层级打印组织架构(层序遍历)
  • 查找某个员工的所有下属(前序遍历子树)
  • 按汇报线打印(中序遍历,BST 模式下)
java 复制代码
// OrgTree.java ------ 公司组织架构建模,基于 JDK 17
import java.util.*;

public class OrgTree {

    static class Employee {
        String name;
        String title;       // CEO / VP / Director / Manager
        List<Employee> subordinates = new ArrayList<>();

        Employee(String name, String title) {
            this.name = name;
            this.title = title;
        }

        void addSubordinate(Employee e) {
            subordinates.add(e);
        }
    }

    // --- 层序遍历:按层级打印组织架构 ---
    public void printOrgChart(Employee root) {
        if (root == null) return;
        Queue<Employee> queue = new LinkedList<>();
        queue.offer(root);

        int level = 0;
        while (!queue.isEmpty()) {
            int levelSize = queue.size();
            System.out.println("\n===== Level " + level + " =====");
            for (int i = 0; i < levelSize; i++) {
                Employee emp = queue.poll();
                System.out.println("  [" + emp.title + "] " + emp.name);
                for (Employee sub : emp.subordinates) {
                    queue.offer(sub);
                }
            }
            level++;
        }
    }

    // --- 前序遍历:查找某员工的所有下属(递归版,清晰直观) ---
    public List<String> findAllSubordinates(Employee root, String targetName) {
        List<String> result = new ArrayList<>();
        findSubordinatesDFS(root, targetName, false, result);
        return result;
    }

    private boolean findSubordinatesDFS(Employee node, String target,
                                         boolean found, List<String> result) {
        if (node == null) return found;

        if (found) {
            result.add(node.name + " (" + node.title + ")");
        }
        if (node.name.equals(target)) {
            found = true;
        }
        for (Employee sub : node.subordinates) {
            found = findSubordinatesDFS(sub, target, found, result);
        }
        return found;
    }

    // --- 前序遍历非递归版:完整遍历(面试手撕代码常客) ---
    public void preOrderNR(Employee root) {
        if (root == null) return;
        Deque<Employee> stack = new ArrayDeque<>();
        stack.push(root);
        while (!stack.isEmpty()) {
            Employee emp = stack.pop();
            System.out.println("  " + emp.name + " - " + emp.title);
            // 子节点倒序压栈(先压最后一个,确保第一个子节点先弹出)
            List<Employee> subs = emp.subordinates;
            for (int i = subs.size() - 1; i >= 0; i--) {
                stack.push(subs.get(i));
            }
        }
    }

    // --- 构建示例公司 ---
    public static Employee buildDemoCompany() {
        Employee ceo = new Employee("张总", "CEO");

        Employee vpTech = new Employee("李VP", "VP-技术");
        Employee vpMarket = new Employee("王VP", "VP-市场");
        ceo.addSubordinate(vpTech);
        ceo.addSubordinate(vpMarket);

        Employee dirBackend = new Employee("赵导", "Director-后端");
        Employee dirFrontend = new Employee("钱导", "Director-前端");
        vpTech.addSubordinate(dirBackend);
        vpTech.addSubordinate(dirFrontend);

        Employee dirBrand = new Employee("孙导", "Director-品牌");
        vpMarket.addSubordinate(dirBrand);

        Employee mgrUser = new Employee("周经", "Manager-用户组");
        Employee mgrOrder = new Employee("吴经", "Manager-订单组");
        dirBackend.addSubordinate(mgrUser);
        dirBackend.addSubordinate(mgrOrder);

        Employee mgrUI = new Employee("郑经", "Manager-UI组");
        dirFrontend.addSubordinate(mgrUI);

        Employee mgrAd = new Employee("冯经", "Manager-投放组");
        dirBrand.addSubordinate(mgrAd);

        return ceo;
    }

    // --- main 演示 ---
    public static void main(String[] args) {
        Employee ceo = buildDemoCompany();
        OrgTree org = new OrgTree();

        System.out.println("=== 公司组织架构(层序遍历) ===");
        org.printOrgChart(ceo);

        System.out.println("\n=== 前序遍历(非递归) ===");
        org.preOrderNR(ceo);

        System.out.println("\n=== 查找「赵导」(Director-后端)的所有下属 ===");
        List<String> subs = org.findAllSubordinates(ceo, "赵导");
        subs.forEach(s -> System.out.println("  -> " + s));

        System.out.println("\n=== 查找「李VP」(VP-技术)的所有下属 ===");
        subs = org.findAllSubordinates(ceo, "李VP");
        subs.forEach(s -> System.out.println("  -> " + s));
    }
}

输出效果

复制代码
=== 公司组织架构(层序遍历) ===

===== Level 0 =====
  [CEO] 张总

===== Level 1 =====
  [VP-技术] 李VP
  [VP-市场] 王VP

===== Level 2 =====
  [Director-后端] 赵导
  [Director-前端] 钱导
  [Director-品牌] 孙导

===== Level 3 =====
  [Manager-用户组] 周经
  [Manager-订单组] 吴经
  [Manager-UI组] 郑经
  [Manager-投放组] 冯经

代码解析

  • printOrgChart() 正是层序遍历(BFS),每层结束时记录 levelSize,实现按级换行
  • findAllSubordinatesDFS() 是前序遍历变体------找到目标后,其子树所有节点都加入结果
  • preOrderNR() 是非递归前序遍历,用栈模拟递归,子节点倒序压栈保证遍历顺序正确

四、源码避坑指南

坑①:层序遍历忘了 levelSize

java 复制代码
// ❌ 错误写法:无法区分层级
while (!queue.isEmpty()) {
    Employee emp = queue.poll();
    System.out.println(emp.name);  // 全挤在一起,不知道谁在哪层
}

// ✅ 正确写法:每层先记录当前层节点数
int levelSize = queue.size();
for (int i = 0; i < levelSize; i++) {
    Employee emp = queue.poll();
    // ...
}

坑②:非递归后序遍历用单栈硬刚

后序遍历的非递归有单栈法(需要记录上次访问节点),逻辑极其绕。工程中推荐双栈法------思路清晰,面试时不容易翻车。上面代码已用双栈法实现。

坑③:红黑树插入忘了"以祖父为当前节点继续向上"

插入修复不是一步到位的。父红叔红变色后,祖父变成红色必须作为新的当前节点重新检查------因为祖父变红后可能与曾祖父产生连续红冲突。这是一个循环过程,不是 if-else。


五、大厂面试连环炮

面试官:"刚才你提到了树的遍历,你能用非递归实现一棵二叉树的中序遍历吗?"

回答要点 :栈模拟,一路向左走到黑,弹出打印,转向右子树。把上面 2.2 节的 inOrderNR() 写出来即可。关键说出 "时间复杂度 O(n),空间复杂度 O(h),h 为树高,最坏 O(n)"------这句话能让面试官知道你不只是背代码。


面试官:"层序遍历呢?如果要求按层输出呢?"

回答要点 :队列 + levelSize 计数。说清楚 queue.size()进入 for 循环前就取好值,因为循环内会动态增删队列。


面试官:"AVL 树和红黑树有什么区别?为什么 HashMap 选红黑树?"

标准答案

"AVL 树追求严格平衡(高度差 ≤ 1),查询性能略优但插入删除需多次旋转;红黑树用宽松的平衡条件(最长路径 ≤ 2×最短路径),牺牲少量查询性能换取更高的写入吞吐量。HashMap 的插入和删除频率极高,红黑树最多旋转 2~3 次就能恢复平衡,而 AVL 树可能沿路径一路旋转到根。这是一个读写性能的工程权衡------JDK 选择了更适合高频写入的红黑树。"

加分项:提到红黑树的典型应用不止 Java------Linux 的 CFS 调度器、epoll 的事件管理、nginx 的定时器,都用红黑树。这说明你的知识面不局限于 Java。


面试官:"红黑树的五大性质,能说全吗?"

回答要点 :①非红即黑 ②根黑 ③叶黑(NIL) ④不连续红 ⑤黑高一致。重点解释性质 5 为什么是灵魂------它保证了最长路径 ≤ 2×最短路径,从而保证 O(log n)。


面试官(终极追问):"如果我让你设计一个数据结构,读操作占 99%,写操作占 1%,你选 AVL 还是红黑树?"

答案:选 AVL。因为读多写少的场景,AVL 更平衡带来的查询优势能覆盖偶尔写入的旋转开销。但如果读写比变成 50:50,红黑树是更合理的选择。


六、通俗类比小结

走迷宫类比

  • 前/中/后序遍历像一个人走迷宫------选定方向,一条路走到黑,碰壁再回头(DFS / 深度优先)
  • 层序遍历像雷达扫描------以起点为圆心,一圈一圈往外扩展,不放过任何一个角落(BFS / 广度优先)
  • 红黑树像迷宫里有五条交通规则------不能连续两个红灯(性质 4),每条路红灯数一样(性质 5)------虽然多绕了一点,但保证不会彻底堵死

相关推荐
大大杰哥1 小时前
Vue2学习(3)--组件中的通信方式/组件之间的交互
java·前端·javascript
程序员zgh1 小时前
C++ 万能引用与完美转发
c语言·开发语言·c++·经验分享·学习
斯内普吖1 小时前
(开源)高校素拓分管理系统小程序实战指南 基于 Java + SpringBoot + uni-app + Vue + MySQL
java·spring boot·mysql·小程序·uni-app·开源
Chris-zz1 小时前
lua流程控制
开发语言·lua
yong99901 小时前
IMU 扩展卡尔曼滤波(EKF)姿态估计 — MATLAB 实现
开发语言·matlab
何以解忧,唯有..1 小时前
Go 语言运算符详解:从基础到实战
开发语言·后端·golang
兰令水1 小时前
leecodecode【面试150】【2026.6.15打卡-java版本】
java·算法·面试
是苏浙1 小时前
Java实现链表2
java·开发语言·数据结构
Orchestrator_me1 小时前
Centos7安装maven 3.9.11
java·maven