【LeetCode 257】二叉树的所有路径(回溯法/深度优先遍历)- Python/C/C++详细题解

这道题不仅考察二叉树的遍历,更是理解回溯算法(Backtracking) 的绝佳练手题。本文的思路参考了**《代码随想录》**,希望能用最清晰的逻辑和保姆级的代码注释,和我一起把这道题彻底拿下!

一、 题目描述

给定一个二叉树的根节点 root,按 任意顺序 ,返回所有从根节点到叶子节点的路径。

叶子节点是指没有子节点的节点。

示例 1:

输入:root = [1,2,3,null,5]

输出:["1->2->5","1->3"]

示例 2:

输入:root = [1]

输出:["1"]


二、思路分析

要求出所有从根节点到叶子节点的路径,我们要思考三个核心问题:

  1. 用什么遍历方式? 既然要找"路径",肯定要从根节点顺着往下走,所以我们必须采用 前序遍历(中-左-右)。只有先处理父节点(中),才能把沿途的节点按顺序记录下来。

  2. 如何记录和收集路径?

    在往下遍历的过程中,我们需要用一个结构(比如数组或列表 path)把走过的节点记录下来。当遇到叶子节点 (即 leftright 都是空)时,说明找到了一条完整的到底路径。此时,我们将 path 里的节点按要求拼接成字符串,并放入最终的结果集 result 中。

  3. 为什么要回溯?

    当我们走到叶子节点,记录完一条路径后,怎么去走另一条路径呢?我们需要退回 到上一个分叉口(父节点),然后再去遍历它的另一棵子树。这个"退回"的过程,在代码层面就体现为:把 path 中最后加入的那个节点弹出来。这就是回溯


三、代码实现(保姆级逐行注释)

下面提供 C++、C 和 Python 三个版本的实现,均采用标准的回溯写法,每行都加上了详细注释,保证小白也能看懂!

1. C++ 版本

在 C++ 中,我们用 vector<int> 来存储当前的路径 path,用 vector<string> 存储最终结果 result。这种写法把遍历和回溯的动作分得非常清晰。

cpp 复制代码
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 * int val;
 * TreeNode *left;
 * TreeNode *right;
 * TreeNode() : val(0), left(nullptr), right(nullptr) {}
 * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
private:
    // 递归遍历函数:cur代表当前处理的节点,path记录当前走过的路径
    // result用来存放最终所有的路径字符串
    void traversal(TreeNode* cur, vector<int>& path, vector<string>& result) {
        // 中:将当前遍历到的节点的值加入到路径数组 path 中
        path.push_back(cur->val); 
        
        // 判断当前节点是否为叶子节点(即左右子节点均为空)
        if (cur->left == NULL && cur->right == NULL) { 
            string sPath; // 定义一个字符串,用于拼接当前收集到的整条路径
            // 遍历 path 数组中除了最后一个节点之外的所有节点
            for (int i = 0; i < path.size() - 1; i++) {
                // 将节点值转换为字符串,并拼接上 "->" 箭头
                sPath += to_string(path[i]) + "->"; 
            }
            // 拼接最后一个节点的值,注意末尾不需要加 "->"
            sPath += to_string(path[path.size() - 1]); 
            // 将拼接好的整条路径字符串加入到最终的结果集 result 中
            result.push_back(sPath); 
            return; // 当前叶子节点处理完毕,结束当前层的递归函数,返回上一层
        }
        
        if (cur->left) { // 如果当前节点存在左子节点
            // 递归调用,向左子树方向继续深度优先遍历
            traversal(cur->left, path, result); 
            // 回溯:遍历完左子树的路径后,需要把刚加进去的左子节点从 path 中弹出
            // 以便去遍历右子树
            path.pop_back(); 
        }
        if (cur->right) { // 如果当前节点存在右子节点
            // 递归调用,向右子树方向继续深度优先遍历
            traversal(cur->right, path, result); 
            // 回溯:遍历完右子树后,同样需要把右子节点弹出,恢复到当前节点的状态
            path.pop_back(); 
        }
    }

public:
    vector<string> binaryTreePaths(TreeNode* root) {
        vector<string> result; // 定义结果集,用于保存所有满足条件的路径字符串
        vector<int> path;      // 定义路径数组,用于在递归过程中临时记录经过的节点值
        if (root == NULL) return result; // 如果传入的是一棵空树,直接返回空的结果集
        traversal(root, path, result); // 从根节点开始,调用递归函数进行遍历和路径收集
        return result; // 遍历结束后,返回最终收集到的所有路径列表
    }
};

2. C 语言版本

C 语言处理字符串和动态数组相对麻烦,我们需要自己管理内存。巧妙之处在于:我们可以通过按值传递记录路径长度的 pathLen 变量,来隐式地完成回溯。

objectivec 复制代码
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 * int val;
 * struct TreeNode *left;
 * struct TreeNode *right;
 * };
 */
#include <stdio.h>
#include <stdlib.h>

// 递归遍历函数:cur是当前节点,path是记录路径值的数组,pathLen是当前路径的节点数,result是结果集,returnSize记录最终结果集的条数
void traversal(struct TreeNode* cur, int* path, int pathLen, char** result, int* returnSize) {
    // 中:将当前节点的值存入 path 数组,同时让记录路径长度的 pathLen 自增 1
    path[pathLen++] = cur->val; 
    
    // 判断是否到达叶子节点(即当前节点的左右孩子都为空)
    if (cur->left == NULL && cur->right == NULL) {
        // 为当前完整的路径字符串动态分配内存空间(保守假设长度不超过1000)
        char* str = (char*)malloc(sizeof(char) * 1000); 
        int len = 0; // 记录当前字符串拼接到的位置(偏移量)
        // 遍历 path 数组中除了最后一个节点外的所有节点
        for (int i = 0; i < pathLen - 1; i++) {
            // 使用 sprintf 将整数和箭头格式化写入字符串,并累加返回的字符长度更新偏移量
            len += sprintf(str + len, "%d->", path[i]); 
        }
        // 单独拼接最后一个叶子节点的值,不带箭头
        sprintf(str + len, "%d", path[pathLen - 1]); 
        // 将拼好的字符串指针存入结果集数组,同时将结果集的总量 returnSize 自增 1
        result[(*returnSize)++] = str; 
        return; // 遇到叶子节点,当前支路处理完毕,返回上一层
    }
    
    if (cur->left) { // 如果当前节点左子树不为空
        // 递归遍历左子树。
        traversal(cur->left, path, pathLen, result, returnSize);
    }
    if (cur->right) { // 如果当前节点右子树不为空
        // 递归遍历右子树。
        traversal(cur->right, path, pathLen, result, returnSize);
    }
}

char** binaryTreePaths(struct TreeNode* root, int* returnSize) {
    *returnSize = 0; // 初始化返回的路径总条数为 0
    if (root == NULL) return NULL; // 如果是一棵空树,无路径可找,直接返回 NULL
    // 为结果集二维字符数组分配内存(假设最多1000条路径)
    char** result = (char**)malloc(sizeof(char*) * 1000); 
    // 定义一个足够大的局部整型数组,用来充当记录当前路径的栈
    int path[1000]; 
    // 初始调用递归函数,从根节点开始找,当前路径长度为 0
    traversal(root, path, 0, result, returnSize); 
    // 返回包含所有路径字符串的二维数组指针
    return result; 
}

3. Python 版本

Python 的列表操作非常灵活,我们可以利用 appendpop 完美复刻 C++ 的回溯逻辑,同时利用 join 函数极大地简化字符串的拼接过程。

python 复制代码
# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def binaryTreePaths(self, root: Optional[TreeNode]) -> List[str]:
        result = [] # 定义一个空列表 result,用来存储所有最终拼接好的路径字符串
        path = []   # 定义一个空列表 path,用来在递归过程中动态充当栈,记录经过的每一个节点的值
        if not root: # 判断如果根节点为空(即传入了一棵空树)
            return result # 直接返回空的 result 列表
        self.traversal(root, path, result) # 从根节点开始,调用递归函数进行深度优先遍历
        return result # 遍历完成后,返回装满完整路径的 result 列表

    def traversal(self, cur: TreeNode, path: List[str], result: List[str]):
        path.append(str(cur.val)) # 中:将当前节点的值转换成字符串类型,并追加到 path 列表的末尾
        
        # 判断当前节点是否为叶子节点(即左子树和右子树都为空)
        if not cur.left and not cur.right:
            # 如果是叶子节点,说明找到了一条完整的路径。
            # 利用 python 字符串的 join 方法,以 '->' 为连接符将 path 列表中的字符串元素高效地拼接起来,并加入结果集
            result.append('->'.join(path)) 
            return # 当前叶子节点处理完毕,结束当前递归函数的执行,直接返回上一层
            
        if cur.left: # 如果当前节点拥有左子树
            self.traversal(cur.left, path, result) # 递归调用自身,向左子树继续探索
            path.pop() # 回溯:左子树的所有路径都探索完毕并返回后,将 path 列表最后加入的那个左子节点弹出,恢复之前的状态
            
        if cur.right: # 如果当前节点拥有右子树
            self.traversal(cur.right, path, result) # 递归调用自身,向右子树继续探索
            path.pop() # 回溯:右子树探索完毕后,同样将最后加入的那个右子节点弹出,恢复到当前节点原本的状态

四、 详细复杂度推导

很多同学会背复杂度,但不知道怎么推导来的。这里详细说明一下:

设二叉树的节点总数为 N。

1. 时间复杂度:O(N^2)

大家可能会疑惑,为什么遍历全部节点的时间复杂度不是 O(N) 呢?

  • 遍历节点本身:我们确实只对每个节点访问了一次,这一部分的耗时是 O(N)。

  • 路径拼接的额外开销 :当遇到叶子节点时,我们需要把 path 数组里的数字拼成字符串。

    • 最坏情况分析:考虑一种极端形状的二叉树------它长得像一把梳子,每一层都有一个叶子节点,且整棵树的高度接近 N。此时我们会有 O(N) 个叶子节点,且每条路径的长度也接近 O(N)。每次拼接字符串都需要拷贝一遍路径上的所有字符,所以总的字符串拷贝时间累加起来会达到 O(N^2) 的量级。
  • 结论:总时间复杂度由遍历操作和字符串拷贝操作共同决定,取最大量级,因此为 O(N^2)。

2. 空间复杂度:O(N)

  • 递归调用栈:在最坏情况下(二叉树退化成一条直线的链表),递归深度会达到 N,此时系统函数调用栈占用 O(N) 的空间。

  • 路径数组/列表 path:记录当前路径的容器在最坏情况下也需要存储 N 个节点,占用 O(N) 的空间。

  • 结论 :两者最大都是 O(N) 级别,因此整体空间复杂度为 O(N)。(注意:通常我们计算空间复杂度时不将返回的最终 result 数组计算在内,只计算辅助空间)。


C语言硬核小课堂(面试加分项!)

在上述 C 语言的代码实现中,有两处极其精妙的底层操作,如果你能弄懂它们,说明你的 C 语言功底已经相当扎实了!

痛点 1:len += sprintf(str + len, "%d->", path[i]); 到底在干嘛?

很多初学者看到这里会发懵:str 明明是个字符串数组/指针,为什么要加上 len

核心揭秘:利用"指针偏移"实现字符串的连续追加。

  • 背景sprintf 的默认行为是覆盖写入 。如果你每次都写 sprintf(str, ...),那么新写入的字符永远会从字符串的第 0 个位置开始覆盖,导致你最后只能得到最后一个数字。

  • int sprintf(char *str, const char *format, ...) 发送格式化输出到 str 所指向的字符串。

  • 拆解

    • str 是指向这块内存起始位置(索引 0)的指针。

    • len 记录的是目前为止已经成功写入了多少个字符(也就是当前字符串的长度)。

    • str + len 的作用是:将"写字的笔尖"向后移动 len 个字节,定位到上一次刚写完的尾巴处

    • sprintf 执行成功后,会返回它这一次刚刚写入了几个字符

  • 脑内推演(假设 path[1, 2, 5]

    1. 第一轮 (1)str 为空,len = 0sprintf(str + 0, "%d->", 1) 从头写入 "1->",返回 3 个字符长度。此时 len 更新为 0 + 3 = 3

    2. 第二轮 (2) :指针向后偏移 3 个字节!sprintf(str + 3, "%d->", 2) 接着上一次的尾巴写入 "2->",返回 3。此时 len 更新为 3 + 3 = 6。字符串变成了 "1->2->"

  • 总结 :通过不断累加 len 并进行指针偏移,我们硬生生用 sprintf 砸出了类似 Java 中 StringBuilder.append() 的高效追加效果!


痛点 2:path[pathLen++] = cur->val; 为什么能"隐式回溯"?

在 C++ 和 Python 的代码中,我们每次递归完左子树,都需要老老实实地调用 pop_back()pop() 把加进去的节点弹出来。为什么 C 语言版本里却没有看到任何 pop 操作?

核心揭秘:按值传递(Pass by Value) + 覆盖写入。

  • 局部变量的魔法 :在 C 语言的 traversal 函数参数中,pathLen按值传递 的。这意味着每一次进入新的递归层,系统都会为当前的 pathLen 复制一个局部的副本。

  • 脑内推演(假设当前在节点 1,准备遍历左孩子 2 和右孩子 3

    1. 当前层 pathLen = 1(数组里存着 [1])。

    2. 走向左子树 :调用 traversal(cur->left, path, pathLen, ...)。注意,传进去的 pathLen1

    3. 左子树内部 :执行 path[pathLen++] = 2。此时数组的第 1 个位置被填入了 2(数组变成 [1, 2]),并且左子树那一层的局部变量 pathLen 变成了 2

    4. 左子树递归结束,返回当前层 :神奇的事情发生了!因为是按值传递,虽然左子树把自己的 pathLen 变成了 2,但当前层的 pathLen 依然是 1

    5. 走向右子树 :调用 traversal(cur->right, path, pathLen, ...),传进去的 pathLen 依然是 1

    6. 右子树内部 :执行 path[pathLen++] = 3。此时它会直接把刚才存左孩子 2 的位置(索引 1无情覆盖掉 !数组变成了 [1, 3]

  • 总结 :因为我们用 pathLen 当作栈顶指针,按值传递保证了"返回上一层时,栈顶指针自动恢复"。既然栈顶指针恢复了,下一次再进栈的新数据就会自然而然地覆盖掉原来由于走错路(上一条支路)而写下的旧数据。我们根本不需要去擦除(pop)旧数据,只要把指针拨回来,新数据直接覆盖上去,这就是最高级的隐式回溯!

结尾

感谢卡哥无私奉献精彩的教学视频

贴上本题代码随想录的网址

257. 二叉树的所有路径 | 代码随想录

力扣链接

257. 二叉树的所有路径 - 力扣(LeetCode)

相关推荐
x_xbx2 小时前
LeetCode:148. 排序链表
算法·leetcode·链表
Darkwanderor2 小时前
三分算法的简单应用
c++·算法·三分法·三分算法
2401_831920742 小时前
分布式系统安全通信
开发语言·c++·算法
2401_877274243 小时前
从匿名管道到 Master-Slave 进程池:Linux 进程间通信深度实践
linux·服务器·c++
m0_488633323 小时前
C语言中枚举类型变量的定义、赋值及使用方法全解析
c语言·枚举类型·实例分析·变量定义·赋值使用
李昊哲小课3 小时前
第1章-PySide6 基础认知与环境配置
python·pyqt·pyside
老鱼说AI3 小时前
大规模并发处理器程序设计(PMPP)讲解(CUDA架构):第四期:计算架构与调度
c语言·深度学习·算法·架构·cuda
汉克老师3 小时前
GESP5级C++考试语法知识(八、链表(三)循环链表)
c++·约瑟夫问题·循环链表·gesp5级·gesp五级
阿贵---3 小时前
C++中的RAII技术深入
开发语言·c++·算法