这道题不仅考察二叉树的遍历,更是理解回溯算法(Backtracking) 的绝佳练手题。本文的思路参考了**《代码随想录》**,希望能用最清晰的逻辑和保姆级的代码注释,和我一起把这道题彻底拿下!
一、 题目描述
给定一个二叉树的根节点 root,按 任意顺序 ,返回所有从根节点到叶子节点的路径。
叶子节点是指没有子节点的节点。
示例 1:
输入:root = [1,2,3,null,5]
输出:["1->2->5","1->3"]
示例 2:
输入:root = [1]
输出:["1"]
二、思路分析
要求出所有从根节点到叶子节点的路径,我们要思考三个核心问题:
-
用什么遍历方式? 既然要找"路径",肯定要从根节点顺着往下走,所以我们必须采用 前序遍历(中-左-右)。只有先处理父节点(中),才能把沿途的节点按顺序记录下来。
-
如何记录和收集路径?
在往下遍历的过程中,我们需要用一个结构(比如数组或列表
path)把走过的节点记录下来。当遇到叶子节点 (即left和right都是空)时,说明找到了一条完整的到底路径。此时,我们将path里的节点按要求拼接成字符串,并放入最终的结果集result中。 -
为什么要回溯?
当我们走到叶子节点,记录完一条路径后,怎么去走另一条路径呢?我们需要退回 到上一个分叉口(父节点),然后再去遍历它的另一棵子树。这个"退回"的过程,在代码层面就体现为:把
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 的列表操作非常灵活,我们可以利用 append 和 pop 完美复刻 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) :str为空,len = 0。sprintf(str + 0, "%d->", 1)从头写入"1->",返回 3 个字符长度。此时len更新为0 + 3 = 3。 -
第二轮 (
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):-
当前层
pathLen = 1(数组里存着[1])。 -
走向左子树 :调用
traversal(cur->left, path, pathLen, ...)。注意,传进去的pathLen是1。 -
左子树内部 :执行
path[pathLen++] = 2。此时数组的第1个位置被填入了2(数组变成[1, 2]),并且左子树那一层的局部变量pathLen变成了2。 -
左子树递归结束,返回当前层 :神奇的事情发生了!因为是按值传递,虽然左子树把自己的
pathLen变成了2,但当前层的pathLen依然是1! -
走向右子树 :调用
traversal(cur->right, path, pathLen, ...),传进去的pathLen依然是1! -
右子树内部 :执行
path[pathLen++] = 3。此时它会直接把刚才存左孩子2的位置(索引1)无情覆盖掉 !数组变成了[1, 3]。
-
-
总结 :因为我们用
pathLen当作栈顶指针,按值传递保证了"返回上一层时,栈顶指针自动恢复"。既然栈顶指针恢复了,下一次再进栈的新数据就会自然而然地覆盖掉原来由于走错路(上一条支路)而写下的旧数据。我们根本不需要去擦除(pop)旧数据,只要把指针拨回来,新数据直接覆盖上去,这就是最高级的隐式回溯!
结尾
感谢卡哥无私奉献精彩的教学视频
贴上本题代码随想录的网址
力扣链接
