二叉树路径问题一直是面试中的常客,而 LeetCode 257(二叉树的所有路径) 更是理解 回溯算法(Backtracking) 的绝佳入门题。本文将从遍历方式的选择、路径记录与回溯的本质入手,结合 Python、C++、C 三种语言的保姆级注释代码,带你彻底吃透这道经典题目。无论你偏好 TypeScript、Java 还是 JavaScript,回溯的核心思想都是相通的。
一、题目速览:从根到叶的完整轨迹
给定一棵二叉树的根节点 root,我们需要返回所有从根节点到叶子节点的路径。叶子节点即左右子节点均为空的节点,路径格式通常为 "1->2->3"。本题不要求路径顺序,但要求完整性。
示例回顾:
- 示例 1:输入
[1,2,3,null,5],输出["1->2->5", "1->3"]。输入:root = [1,2,3,null,5]
输出:["1->2->5","1->3"]
- 示例 2:输入
[1],输出["1"]。输入:root = [1]
输出:["1"]
二、核心思路:为什么是前序遍历 + 回溯?
解决这道题,我们需要回答三个关键问题:
- 用什么遍历方式? 寻找路径必须从根节点出发,自上而下地记录。因此,我们选择 前序遍历(中-左-右)。只有先处理当前节点(中),才能确保路径顺序正确。无论是 Java 还是 Go,递归实现都遵循这一原则。
- 如何记录路径? 在遍历过程中,用一个容器(如数组或列表
path)存储经过的节点值。当遇到叶子节点(即left和right均为空)时,将path中的节点拼接成字符串,加入结果集result。 - 为什么要回溯? 当一条路径遍历完毕,我们需要返回上一个分叉点,探索另一条分支。这个“返回”动作在代码中体现为:将
path中最后加入的节点弹出。这正是回溯的核心——撤销选择,回到上一状态。
理解关键: 回溯不是一个新的算法,而是深度优先遍历(DFS)在路径搜索中的一种策略。它的本质是状态重置,确保每条路径的独立性。
三、多语言实现:保姆级注释版
下面提供三种主流语言的实现,均采用标准的回溯写法,并附有详细的逐行注释。代码风格清晰,便于对比学习。
1. C++ 版本:显式回溯,逻辑分明
在 C++ 中,我们使用 vector<int> 存储当前路径 path,用 vector<string> 存储最终结果 result。这种写法将遍历与回溯的动作分离得十分清晰,推荐初学者掌握。
/**
* 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& path, vector& 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 binaryTreePaths(TreeNode* root) {
vector result; // 定义结果集,用于保存所有满足条件的路径字符串
vector path; // 定义路径数组,用于在递归过程中临时记录经过的节点值
if (root == NULL) return result; // 如果传入的是一棵空树,直接返回空的结果集
traversal(root, path, result); // 从根节点开始,调用递归函数进行遍历和路径收集
return result; // 遍历结束后,返回最终收集到的所有路径列表
}
};
2. C 语言版本:指针偏移与隐式回溯
C 语言需要手动管理内存,但我们可以通过按值传递路径长度变量 pathLen 来隐式完成回溯,这体现了 C 语言底层控制的精妙之处。
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* struct TreeNode *left;
* struct TreeNode *right;
* };
*/
#include
#include
// 递归遍历函数: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 函数则极大简化了字符串拼接。对于使用 TypeScript 或 JavaScript 的开发者,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() # 回溯:右子树探索完毕后,同样将最后加入的那个右子节点弹出,恢复到当前节点原本的状态
四、复杂度深度剖析:从 O(N) 到 O(N²)
很多同学只背结论,不知其所以然。让我们详细推导一下:
设二叉树节点总数为 N。
1. 时间复杂度:O(N²)
- 遍历节点本身: 每个节点仅访问一次,这部分耗时是 O(N)。
- 路径拼接开销: 遇到叶子节点时,需要将
path数组中的数字拼接为字符串。最坏情况下(如树退化成“梳子”状),叶子节点数量为 O(N),每条路径长度也为 O(N)。每次拼接字符串都需要拷贝路径上的所有字符,累计耗时达到 O(N²)。 - 结论: 总时间复杂度由遍历与拼接共同决定,取最大量级,即为 O(N²)。
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的默认行为是覆盖写入。每次从第 0 个位置开始写,会覆盖之前的内容。 - 拆解:
sprintf(str, ...)是指向内存起始位置的指针;str记录当前已写入的字符数;len将“笔尖”向后移动str + len个字节,定位到上次写入的末尾。len返回本次写入的字符数。 - 脑内推演: 假设
sprintf是path:- 第一轮:
[1, 2, 5]为空,1从头写入,返回 3,str更新为 3。 - 第二轮:指针偏移 3 字节,
len = 0接着写入,返回 3,sprintf(str + 0, "%d->", 1)更新为 6。字符串变为"1->"。
- 第一轮:
- 总结: 通过累加
len并偏移指针,我们硬生生用 C 语言实现了类似 Java 中StringBuilder的高效追加效果!
痛点 2:0 + 3 = 3 为何能“隐式回溯”?
在 C++ 和 Python 中,我们需要显式调用 2 或 sprintf(str + 3, "%d->", 2) 弹出节点。为什么 C 语言版本里没有看到任何弹出操作?
核心揭秘:按值传递 + 覆盖写入。
- 局部变量的魔法: 在
"2->"函数参数中,len是按值传递的。每次递归调用都会为当前层复制一个局部副本。 - 脑内推演: 假设当前在节点
3 + 3 = 6(数组存着"1->2->"),准备遍历左孩子len和右孩子sprintf:- 走向左子树:传入
StringBuilder.append()(值为 1)。左子树内写入path[pathLen++] = cur->val;(数组变为pop_back()),其局部变量变为 2。 - 返回当前层:由于按值传递,当前层的
pop()依然是 1! - 走向右子树:传入
pop(值为 1)。右子树内写入traversal,直接覆盖索引 1 处的旧数据(数组变为pathLen)。
- 走向左子树:传入
- 总结: 按值传递保证了“返回上一层时,栈顶指针自动恢复”。新数据会自然覆盖旧数据,无需显式 pop,这就是最高级的隐式回溯!
六、总结与扩展
通过 LeetCode 257,我们不仅掌握了二叉树的前序遍历,更深刻理解了回溯算法的本质:选择-递归-撤销选择。这种思想在组合问题、排列问题、子集问题中同样适用。建议对比学习 Python、C++、C 三种实现,体会不同语言在内存管理上的差异。
[AFFILIATE_SLOT_1]
最后,感谢《代码随想录》卡哥的精彩教学。相关参考链接:257. 二叉树的所有路径 - 代码随想录 | 力扣题目链接
[AFFILIATE_SLOT_2]
浙公网安备 33010602011771号