别再迷路了!带你走遍二叉树的每一条“羊肠小道”(257. 二叉树的所有路径)


别再迷路了!带你走遍二叉树的每一条"羊肠小道"

嘿,各位热爱代码的朋友们!我是你们的老朋友,一个总在代码世界里探险的开发者。今天,我想和大家分享一个我曾经遇到的"迷路"问题,以及我是如何通过理解二叉树的遍历,最终画出了一张完美的"寻路地图"。

我遇到了什么问题?

想象一下,我在开发一个配置管理系统。在这个系统里,所有的配置项都以树形结构存储。比如,一个应用的配置可能是这样的:

yaml 复制代码
Application
├── FeatureA
│   ├── Enabled: true
│   ├── Timeout: 5000ms
├── FeatureB
│   └── Enabled: false
└── Database
    ├── Host: 127.0.0.1
    └── Port: 3306

为了方便诊断和审计,我需要一个功能:一键导出所有生效的配置路径 。也就是说,从根节点 Application 出发,一直走到最末端的配置项(叶子节点),并把整条路径记录下来。对于上面的例子,我希望能得到类似这样的输出:

  • "Application->FeatureA->Enabled"
  • "Application->FeatureA->Timeout"
  • "Application->FeatureB->Enabled"
  • "Application->Database->Host"
  • "Application->Database->Port"

这不就是一道活生生的算法题吗!这正是我遇到的 257. 二叉树的所有路径 的现实翻版。问题是,如何才能不重不漏、高效地找出所有路径呢?🤔

我是如何用"深度优先搜索(DFS)"绘制寻路图的

刚开始,我有点懵。树的结构千变万化,我该怎么确保每条小路都走到头,而且走完一条还能准确地退回到岔路口,去走另一条呢?答案就是经典的深度优先搜索(DFS),也就是我们常说的"一条路走到黑,不撞南墙不回头"。

初版实现:递归大法好!

DFS 和递归简直是天作之合。我的思路是这样的:

  1. 定义一个向导(递归函数) :这个向导 dfs(当前位置, 当前已走过的路) 的任务就是从 当前位置 继续往下走。
  2. 记录足迹 :每到一个新的节点,就把它加入到 当前已走过的路 里。
  3. 判断终点 :如果 当前位置 是一个终点(没有子节点的叶子节点),太棒了!我们找到了一条完整路径,赶紧把它记在小本本(结果列表)上。
  4. 继续探索与回溯 :如果不是终点,那就让向导分别去它的左、右两个方向继续探索。最关键的一步是回溯,当向导从一个方向(比如左子树)探索回来后,得把脚印擦掉(从路径中移除最后加入的节点),这样才能干干净净地去探索另一个方向(右子树),保证路径不错乱。

为了高效地"记录"和"擦除"路径,我没有用普通的 String(因为每次 + 都会创建新对象,太浪费了),而是选择了 StringBuilder。它的 setLength() 方法简直是回溯的神器!

java 复制代码
 /*
 * 思路:深度优先搜索(DFS)+ 递归 + 回溯。
 * 定义一个递归函数,在进入一个节点时将节点值加入当前路径,在到达叶子节点时将完整路径存入结果列表。
 * 离开一个节点(即其所有子树都已访问完毕)时,需要将该节点从当前路径中移除,以便为访问其兄弟节点做准备,这个过程称为"回溯"。
 *
 * 时间复杂度:O(N*H),其中 N 是节点数,H 是树的高度。在最坏情况下(链状树),H=N,复杂度为 O(N^2)。
 * 每个节点访问一次。当到达叶子节点时,需要构建一个长度为路径长度(即树的高度 H)的字符串。总时间与所有路径长度之和成正比。
 *
 * 空间复杂度:O(H),递归调用的最大深度即为树的高度 H。结果列表不计入空间复杂度。
 */


class Solution {
    public List<String> binaryTreePaths(TreeNode root) {
        List<String> result = new ArrayList<>();
        // 准备好"小本本"和一支可以擦写的"笔"(StringBuilder)
        dfs(root, new StringBuilder(), result);
        return result;
    }

    private void dfs(TreeNode node, StringBuilder pathBuilder, List<String> result) {
        if (node == null) return;

        // 记录一下进来前的路有多长,方便待会儿"原路返回"
        int lenBeforeAction = pathBuilder.length();
      
        // 走到了新位置,记下足迹
        if (lenBeforeAction > 0) {
            pathBuilder.append("->");
        }
        pathBuilder.append(node.val);

        // 到达了路的尽头(叶子节点)吗?
        if (node.left == null && node.right == null) {
            result.add(pathBuilder.toString()); // 是的!赶紧记录下来!
        } else {
            // 还没到头,继续往下走
            dfs(node.left, pathBuilder, result);
            dfs(node.right, pathBuilder, result);
        }
      
        // 🌟关键的回溯!擦掉刚才的足迹,回到上一个岔路口。
        pathBuilder.setLength(lenBeforeAction);
    }
}

这段代码运行得非常完美,优雅地解决了我的问题!每一次 dfs 调用就像是派出了一个探险家,pathBuilder 是他携带的地图,result 是所有探险家最终带回来的宝藏地图集。

另辟蹊径:迭代实现DFS和BFS

虽然递归很美,但在某些极端场景下(比如树特别深),可能会导致"栈溢出"。作为一名经验丰富的开发者,我们当然要准备好B计划:迭代实现

1. DFS迭代版 (用栈模拟递归)

为了避免递归可能带来的栈溢出风险(虽然在本题中不存在),并展示一种非递归的解法,我们可以使用自己的栈。栈中不仅要存储待访问的节点,还要存储到达该节点的路径。因此,我们通常会使用两个栈:一个存 TreeNode,另一个存对应的路径 String。或者用一个栈存一个自定义的 PairState 对象。

算法流程如下:

  1. 创建两个栈:nodeStackpathStack

  2. 将根节点 root 和其值的字符串形式压入两个栈。

  3. 循环直到 nodeStack 为空:

    • 从两个栈中弹出 currentNodecurrentPath
    • 检查 currentNode 是否为叶子节点。如果是,将 currentPath 添加到结果列表中。
    • 如果不是,将其非空的子节点(通常先右后左,以保证先处理左子树)和更新后的路径压入栈中。
java 复制代码
/*
 * 思路:深度优先搜索(DFS)+ 迭代 + 栈。
 * 使用两个栈,一个存储节点,另一个存储从根到该节点的路径字符串。
 * 遍历时,从栈中弹出一个节点及其路径,如果是叶子节点则记录路径。
 * 如果不是,则将其子节点和更新后的路径(当前路径 + "->" + 子节点值)压入栈中。
 * 先压右子节点再压左子节点,可以保证栈的后进先出特性使得左子树被优先访问,与递归顺序一致。
 *
 * 时间复杂度:O(N*H)。与递归版本类似,每个节点访问一次,但每次扩展路径都会创建一个新的字符串对象,
 * 这可能比 StringBuilder 效率低。总时间和所有路径长度之和成正比。
 *
 * 空间复杂度:O(N*H)。栈中最多可能存储 N/2 个节点(在完全二叉树的最后一层)。
 * 更重要的是,栈中存储的路径字符串占据了主要空间,在最坏的链状树情况下,空间是 1+2+...+N,即 O(N^2)。
 * 平均情况下是 O(N*logN)。
 */

class Solution {
    public List<String> binaryTreePaths(TreeNode root) {
        List<String> result = new ArrayList<>();
        if (root == null) {
            return result;
        }

        // 使用 java.util.Stack,一个后进先出(LIFO)的集合。
        Stack<TreeNode> nodeStack = new Stack<>();
        Stack<String> pathStack = new Stack<>();

        // 初始化,将根节点和初始路径压栈。
        nodeStack.push(root);
        // String.valueOf() 是一个安全的将对象(包括null)转为字符串的方法。
        // 这里用于将节点值转为路径的起始部分。
        pathStack.push(String.valueOf(root.val));

        while (!nodeStack.isEmpty()) {
            // isEmpty() 检查栈是否为空,是循环的终止条件。
            TreeNode currentNode = nodeStack.pop(); // pop() 弹出并返回栈顶元素。
            String currentPath = pathStack.pop();

            // 判断是否为叶子节点。
            if (currentNode.left == null && currentNode.right == null) {
                result.add(currentPath);
            }

            // 先处理右子节点,再处理左子节点。
            // 因为栈是后进先出,这样能保证我们先访问左子树。
            if (currentNode.right != null) {
                nodeStack.push(currentNode.right);
                pathStack.push(currentPath + "->" + currentNode.right.val);
            }
            if (currentNode.left != null) {
                nodeStack.push(currentNode.left);
                pathStack.push(currentPath + "->" + currentNode.left.val);
            }
        }
        return result;
    }
}

2. BFS迭代版 (用队列实现)

使用广度优先搜索(BFS)的迭代实现,借助一个队列(Queue)。

虽然DFS更自然,但BFS同样可以解决问题。BFS按层级遍历树,我们也需要用队列来存储待访问的节点和对应的路径。

算法流程如下:

  1. 创建两个队列:nodeQueuepathQueue

  2. 将根节点 root 和其值的字符串形式加入队列。

  3. 循环直到 nodeQueue 为空:

    • 从两个队列中出队 currentNodecurrentPath
    • 检查 currentNode 是否为叶子节点。如果是,将 currentPath 添加到结果列表中。
    • 如果不是,将其非空的子节点(先左后右)和更新后的路径加入队列。
java 复制代码
/*
* 思路:广度优先搜索(BFS)+ 迭代 + 队列。
* 使用两个队列,一个存储节点,另一个存储从根到该节点的路径。BFS按层遍历树。
* 遍历时,从队列中取出一个节点及其路径,如果是叶子节点则记录路径。
* 如果不是,则将其子节点(通常先左后右)和更新后的路径加入队尾。
* 时间复杂度:O(N*H)。与迭代DFS版本分析类似。
* 空间复杂度:O(N*H)。在最坏情况下(完全二叉树),队列宽度最大为 N/2。
* 存储在队列中的路径字符串总长度占据主要空间,最坏情况下也是 O(N^2)。

*/

public List<String> binaryTreePaths_BFS_Iterative(TreeNode root) {
    List<String> result = new ArrayList<>();
    if (root == null) return result;

    Queue<TreeNode> nodeQueue = new LinkedList<>();

    Queue<String> pathQueue = new LinkedList<>();
  
    nodeQueue.offer(root);
    pathQueue.offer(String.valueOf(root.val));

    while (!nodeQueue.isEmpty()) {
        TreeNode curr = nodeQueue.poll();
        String path = pathQueue.poll();

        if (curr.left == null && curr.right == null) {
            result.add(path);
        }

        // BFS 顺序无所谓,但通常是先左后右
        if (curr.left != null) {
            nodeQueue.offer(curr.left);
            pathQueue.offer(path + "->" + curr.left.val);
        }
        if (curr.right != null) {
            nodeQueue.offer(curr.right);
            pathQueue.offer(path + "->" + curr.right.val);
        }
    }
    return result;
}

💡 一个踩坑经验 :迭代法虽然避免了递归的栈深度风险,但它的空间效率其实更低 !因为递归法通过回溯巧妙地复用了StringBuilder,而迭代法需要为栈或队列中的每个中间节点都保存一份完整的路径字符串,当树很大时,这会消耗大量内存。这是面试中非常重要的一个对比点!

解法对比

特性 解法1 (DFS递归) 解法2 (DFS迭代) 解法3 (BFS迭代)
核心思想 深度优先,递归实现 深度优先,迭代实现 广度优先,迭代实现
数据结构 系统调用栈 用户自定义栈 (Stack) 用户自定义队列 (Queue)
遍历顺序 先深入一条路径到底 与递归版相同,先深入 按层级,从上到下,从左到右
代码简洁度 最高,逻辑最直观 较高,需要手动管理栈 较高,需要手动管理队列
空间效率 路径构建空间最高效(用StringBuilder回溯)。递归栈空间 O(H)。 路径存储空间开销大,栈空间O(N*H)。 路径存储空间开销大,队列空间O(N*H)。
适用场景 问题本身具有递归结构,且深度不大。 需要避免递归,或栈深度可能很大时。 需要按层级处理问题时(如找最短路径)。

举一反三,触类旁通

掌握了这种遍历+路径记录的思想,很多问题都迎刃而解了:

  1. 文件系统扫描:列出电脑某个文件夹下所有文件的绝对路径,这不就是一模一样的问题吗?文件夹是节点,文件是叶子节点。
  2. UI视图层级:在App开发中,你想获取某个按钮在整个视图层级中的路径,用于自动化测试或埋点分析,就可以用这个方法。
  3. 组合问题:在算法中,很多求解所有组合、所有排列的问题,其本质都是在一棵"决策树"上进行DFS遍历。比如,求一个数组的所有子集,你也可以想象成在每个数字面前,你都有"选"或"不选"两条路可走。

更多练手机会

如果你对这类树的遍历问题意犹未尽,强烈推荐下面几道LeetCode的"亲戚"题目:

希望我这次从真实项目出发的分享,能让你对树的遍历有更深入、更形象的理解。下次当你再面对树形结构时,愿你心中有图,脚下有路!😉

相关推荐
uzong2 小时前
技术故障复盘模版
后端
GetcharZp2 小时前
基于 Dify + 通义千问的多模态大模型 搭建发票识别 Agent
后端·llm·agent
桦说编程2 小时前
Java 中如何创建不可变类型
java·后端·函数式编程
IT毕设实战小研3 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
wyiyiyi3 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
阿华的代码王国4 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
Jimmy4 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
AntBlack5 小时前
不当韭菜V1.1 :增强能力 ,辅助构建自己的交易规则
后端·python·pyqt
bobz9655 小时前
pip install 已经不再安全
后端
寻月隐君5 小时前
硬核实战:从零到一,用 Rust 和 Axum 构建高性能聊天服务后端
后端·rust·github