二叉树深度优先搜索:从递归到剪枝六大高频题解析
二叉树深度优先搜索:从递归到剪枝六大高频题解析
深度优先遍历(DFS,全称为 Depth First Traversal),是我们树或者图这样的数据结构中常⽤的⼀种遍历算法。这个算法会尽可能深的搜索树或者图的分⽀,直到⼀条路径上的所有节点都被遍历完毕,然后再回溯到上⼀层,继续找⼀条路遍历。
在⼆叉树中,常⻅的深度优先遍历为:前序遍历、中序遍历以及后序遍历。因为树的定义本⾝就是递归定义,因此采⽤递归的⽅法去实现树的三种遍历不仅容易理解⽽且代码很简洁。
并且前中后序三种遍历的唯⼀区别就是访问根节点的时机不同,在做题的时候,选择⼀个适当的遍历顺序,对于算法的理解是⾮常有帮助的。
计算布尔二叉树的值
题目链接:
计算布尔二叉树的值
要点:
- 递归评估逻辑:根据节点值(0/1/2/3)判断逻辑运算类型(直接返回、OR、AND)。
- 递归终止条件:叶子节点直接返回其值,非叶子节点递归计算左右子树结果。
- 逻辑运算符映射:节点值
2
对应 OR,3
对应 AND。
老师代码:
class Solution
{
public:
bool evaluateTree(TreeNode* root)
{
if(root->left == nullptr) return root->val == 0 ? false : true;
bool left = evaluateTree(root->left);
bool right = evaluateTree(root->right);
return root->val == 2 ? left | right : left & right;
}
}
老师思路:
我的代码:
/**
* 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 {
public:
bool evaluateTree(TreeNode* root) {
if(root->left == nullptr) return root->val;
bool cl = evaluateTree(root->left);
bool cr = evaluateTree(root->right);
if(root->val == 2) return cl || cr;
else return cl && cr;
}
};
我的笔记:
- 代码通过递归分解问题:
- 叶子节点直接返回其布尔值;
- 非叶子节点递归计算左右子树结果,再根据节点值进行逻辑运算。
- 优化点:直接使用
||
和&&
简化逻辑判断,避免冗余条件分支。
求根节点到叶节点数字之和
题目链接:
求根节点到叶节点数字之和
要点:
- 前序遍历传值:将父节点的累加值
presum
通过presum * 10 + root->val
传递给子节点。 - 叶子节点处理:到达叶子节点时,将
presum
加入最终结果。 - 结果累加方式:通过递归返回值累加(老师代码)或引用参数传递(用户代码)
老师代码:
class Solution
{
public:
int sumNumbers(TreeNode* root)
{
return dfs(root, 0);
}
int dfs(TreeNode* root, int presum)
{
presum = presum * 10 + root->val;
if(root->left == nullptr && root->right == nullptr)
return presum;
int ret = 0;
if(root->left) ret += dfs(root->left, presum);
if(root->right) ret += dfs(root->right, presum);
return ret;
}
}
老师思路:
前序遍历按照根节点、左⼦树、右⼦树的顺序遍历⼆叉树的所有节点,通常⽤于⼦节点的状态依赖于⽗节点状态的题⽬。
算法思路:
在前序遍历的过程中,我们可以往左右⼦树传递信息,并且在回溯时得到左右⼦树的返回值。
递归函数可以帮我们完成两件事:
- 将⽗节点的数字与当前节点的信息整合到⼀起,计算出当前节点的数字,然后传递到下⼀层进⾏递归;
- 当遇到叶⼦节点的时候,就不再向下传递信息,⽽是将整合的结果向上⼀直回溯到根节点。在递归结束时,根节点需要返回的值也就被更新为了整棵树的数字和。
算法流程:递归函数设计:int dfs(TreeNode* root, int num)
-
返回值:当前⼦树计算的结果(数字和);
-
参数 num:递归过程中往下传递的信息(⽗节点的数字);
-
函数作⽤:整合⽗节点的信息与当前节点的信息计算当前节点数字,并向下传递,在回溯时返回当前⼦树(当前节点作为⼦树根节点)数字和。
递归函数流程:
- 当遇到空节点的时候,说明这条路从根节点开始没有分⽀,返回 0;
- 结合⽗节点传下的信息以及当前节点的 val,计算出当前节点数字 sum;
- 如果当前结点是叶⼦节点,直接返回整合后的结果 sum;
- 如果当前结点不是叶⼦节点,将 sum 传到左右⼦树中去,得到左右⼦树中节点路径的数字和,然后相加后返回结果。
我的代码:
/**
* 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 {
public:
int sumNumbers(TreeNode* root) {
if(root->left == nullptr && root->right == nullptr) return root->val;
int ret = 0;
dfs(root, 0, ret);
return ret;
}
void dfs(TreeNode* root, int sum, int& ret)
{
sum = sum * 10 + root->val;
if(root->left == nullptr && root->right == nullptr)
{
ret += sum;
return;
}
if(root->left) dfs(root->left, sum, ret);
if(root->right) dfs(root->right, sum, ret);
}
};
我的思路:
- 引用传参陷阱:若在多线程环境下,引用参数可能导致竞态条件,返回值方式更安全。
- 代码对比:老师代码的时间复杂度相同,但空间复杂度更低(无额外参数传递)
我的笔记:
- 老师的思路是使用递归函数的返回值,在每一次递归之后+=一下
ret
- 我的想法是用一个引用作为函数参数进行传参,并将他作为返回值,我觉得这种思路也是可以的
二叉树剪枝
题目链接:
二叉树剪枝
要点:
- 后序遍历处理:先递归处理左右子树,再判断当前节点是否需要剪枝。
- 剪枝条件:当前节点为叶子且值为0时,删除节点并返回
nullptr
。 - 内存管理:删除节点后需置空指针,避免野指针问题(用户代码未处理)。
老师代码:
class Solution
{
public:
TreeNode* pruneTree(TreeNode* root)
{
if(root == nullptr) return nullptr;
root->left = pruneTree(root->left);
root->right = pruneTree(root->right);
if(root->left == nullptr && root->right == nullptr && root->val == 0)
{
delete root; // 防⽌内泄漏
root = nullptr;
}
return root;
}
}
老师思路:
后序遍历按照左⼦树、右⼦树、根节点的顺序遍历⼆叉树的所有节点,通常⽤于⽗节点的状态依赖于⼦节点状态的题⽬。
-
算法思路:
如果我们选择从上往下删除,我们需要收集左右⼦树的信息,这可能导致代码编写相对困难。然⽽,通过观察我们可以发现,如果我们先删除最底部的叶⼦节点,然后再处理删除后的节点,最终的结果并不会受到影响。
因此,我们可以采⽤后序遍历的⽅式来解决这个问题。在后序遍历中,我们先处理左⼦树,然后处理右⼦树,最后再处理当前节点。在处理当前节点时,我们可以判断其是否为叶⼦节点且其值是否为 0,如果满⾜条件,我们可以删除当前节点。
-
需要注意的是,在删除叶⼦节点时,其⽗节点很可能会成为新的叶⼦节点。因此,在处理完⼦节点后,我们仍然需要处理当前节点。这也是为什么选择后序遍历的原因(后序遍历⾸先遍历到的⼀定是叶⼦节点)。
-
通过使⽤后序遍历,我们可以逐步删除叶⼦节点,并且保证删除后的节点仍然满⾜删除操作的要求。这样,我们可以较为⽅便地实现删除操作,⽽不会影响最终的结果。
-
若在处理结束后所有叶⼦节点的值均为 1,则所有⼦树均包含 1,此时可以返回。
我的代码:
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) :o val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNde *right) : val(x), left(left), right(right) {}
* };
*/
class Solution
{
public:
TreeNode* pruneTree(TreeNode* root)
{
return dfs(root);
}
TreeNode* dfs(TreeNode* root)
{
if(root->left == nullptr && root->right == nullptr)//访问到叶子结点
{
if(root->val == 0) return nullptr;//如果父节点为0返回空指针
else return root;//否则返回自身
}
TreeNode* t1 = nullptr, *t2 = nullptr;//必须要先初始化为空指针
//后序遍历
if(root->left) t1 = dfs(root->left);
if(root->right) t2 = dfs(root->right);
//更新当前节点指向的左右子树
root->left = t1;
root->right = t2;
//判断当前节点是否需要剪枝
if(t1 == nullptr && t2 == nullptr)
{
if(root->val == 0) return nullptr;
else return root;
}
else
return root;
}
};
我的思路:
- 用户代码通过后序遍历更新左右子树指针,但未释放内存,存在泄漏风险;
- 老师代码在删除节点时调用
delete
,并置空指针,代码更规范。 - 关键点:后序遍历确保子节点状态已确定,再处理当前节点。
我的笔记:
- 我的代码没有防止内存泄漏,要注意
验证二叉搜索树
题目链接:
验证二叉搜索树
要点:
- 中序遍历特性:二叉搜索树的中序遍历结果严格递增。
- 全局变量记录前驱:通过
long prev
保存中序遍历的前驱节点值。 - 剪枝优化:若当前节点不满足递增,直接返回
false
终止递归。
老师代码:
class Solution
{
long prev = LONG_MIN;
public:
bool isValidBST(TreeNode* root)
{
if(root == nullptr) return true;
bool left = isValidBST(root->left);
// 剪枝
if(left == false) return false;
bool cur = false;
if(root->val > prev)
cur = true;
// 剪枝
if(cur == false) return false;
prev = root->val;
bool right = isValidBST(root->right);
return left && right && cur;
}
}
老师思路:
如果⼀棵树是⼆叉搜索树,那么它的中序遍历的结果⼀定是⼀个严格递增的序列。因此,我们可以初始化⼀个⽆穷⼩的全区变量,⽤来记录中序遍历过程中的前驱结点。那么就可以在中序遍历的过程中,先判断是否和前驱结点构成递增序列,然后修改前驱结点为当前结点,传⼊下⼀层的递归中。
-
初始化⼀个全局的变量 prev,⽤来记录中序遍历过程中的前驱结点的 val;
-
中序遍历的递归函数中:
a. 设置递归出⼝:root == nullptr 的时候,返回 true;
b. 先递归判断左⼦树是否是⼆叉搜索树,⽤ retleft 标记;
c. 然后判断当前结点是否满⾜⼆叉搜索树的性质,⽤ retcur 标记:
- 如果当前结点的 val ⼤于 prev,说明满⾜条件,retcur 改为 true;
- 如果当前结点的 val ⼩于等于 prev,说明不满⾜条件,retcur 改为 false;
d. 最后递归判断右⼦树是否是⼆叉搜索树,⽤ retright 标记; 3. 只有当 retleft、 retcur 和 retright 都是 true 的时候,才返回 true。
我的代码:
/**
* 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 {
public:
bool isValidBST(TreeNode* root)
{
return dfs(root, root->val, root->val);
}
bool dfs(TreeNode* root, int& min, int& max)
{
//如果为叶子结点,最大值和最小值都是它本身
if(root->left == nullptr && root->right == nullptr)
{
min = max = root->val;
return true;
}
//后序遍历,初始化一定要给一个值,就是它本身
bool b1 = true, b2 = true;
int lmin = root->val, lmax = root->val, rmin = root->val, rmax = root->val;
if(root->left)
{
b1 = dfs(root->left, lmin, lmax);
if(lmax >= root->val) return false;//左子树的最大值大于当前节点,不是搜索二叉树
}
if(root->right)
{
b2 = dfs(root->right, rmin, rmax);
if(rmin <= root->val) return false;//右子树的最小值小于当前节点,不是搜索二叉树
}
//更新当前搜索树的最大值和最小值
min = lmin, max = rmax;
return b1 && b2;
}
};
解法二:
/**
* 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 {
public:
long prev = LONG_MIN;
bool isValidBST(TreeNode* root)
{
if(root->left == nullptr && root->right == nullptr)
{
if(root->val > prev)
{
prev = root->val;
return true;
}
else return false;
}
int b1 = true, b2 = true;
if(root->left) b1 = isValidBST(root->left);//判断左子树
if(!b1) return false;//剪枝,无需再递归后面的子树了
if(root->val <= prev) return false;//判断当前节点是否满足
prev = root->val;
if(root->right) b2 = isValidBST(root->right);//判断右子树
return b2;
}
};
我的思路:
- 使用引用来记录递归中的最大值和最小值,
- 用户代码尝试通过子树的最小/最大值验证,但实现复杂且易出错;
- 老师代码利用中序遍历特性,代码简洁且时间复杂度最优(O(n))。
- 关键技巧:全局变量
prev
简化递归参数传递。
我的笔记:
-
老师用到的方法:二叉搜索树的中序遍历的结果,是一个有序的序列
-
边界值处理:使用
LONG_MIN
避免INT_MIN
的边界问题。 -
递归展开图:画图中序遍历过程,理解
prev
的更新逻辑
小技巧
-
全局变量的优势:如果在递归中能够想到使用全局变量,此时我们就不需要处理递归中的参数和返回值,能简化递归的过程
-
回溯和剪枝
回溯在递归中经常出现,当我们完成子树的递归之后返回上一级的递归时就会出现回溯
剪枝其实是一个加快搜索的过程:当我们发现一个递归(二叉树)的分支绝对不可能出现我们想要的结果的时候,我们就不需要再对那个分支进行递归了,这就是剪枝
二叉搜索树中第K小的元素
题目链接:
二叉搜索树中第K小的元素
要点:
- 中序遍历有序性:直接按顺序遍历到第k个节点。
- 全局计数器剪枝:通过
count
递减计数,找到第k小元素后终止递归。 - 时间复杂度优化:O(k) 时间复杂度,无需遍历整棵树。
老师代码:
class Solution
{
int count;
int ret;
public:
int kthSmallest(TreeNode* root, int k)
{
count = k;
dfs(root);
return ret;
}
void dfs(TreeNode* root)
{
if(root == nullptr || count == 0) return;
dfs(root->left);
count--;
if(count == 0) ret = root->val;
dfs(root->right);
}
}
老师思路:
我们可以根据中序遍历的过程,只需扫描前 k 个结点即可。 因此,我们可以创建⼀个全局的计数器 count,将其初始化为 k,每遍历⼀个节点就将 count–。直到某次递归的时候,count 的值等于 1,说明此时的结点就是我们要找的结果。
递归函数流程(中序遍历):
-
递归出⼝:空节点直接返回 -1,说明没有找到;
-
去左⼦树上查找结果,记为 retleft:
a. 如果 retleft == -1,说明没找到,继续执⾏下⾯逻辑;
b. 如果 retleft != -1,说明找到了,直接返回结果,⽆需执⾏下⾯代码(剪枝);
-
如果左⼦树没找到,判断当前结点是否符合:
a. 如果符合,直接返回结果
如果当前结点不符合,去右⼦树上寻找结果
我的代码:
/**
* 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 {
int _k = 0;
int _num = 0;
int ret = 0;
public:
int kthSmallest(TreeNode* root, int k) {
_k = k;
dfs(root);
return ret;
}
void dfs(TreeNode* root)
{
if(root == nullptr) return;
dfs(root->left);
_num++;
if(_k == _num)
{
ret = root->val;
return;
}
dfs(root->right);
}
};
我的思路:
写的挺差的
我的笔记:
- 用户代码使用
_num
计数,但未及时剪枝,可能遍历多余节点; - 老师代码在
count == 0
时直接终止递归,效率更高。 - 关键点:中序遍历结合剪枝是解决TopK问题的经典方法。
二叉树的所有路径
题目链接:
二叉树的所有路径
要点:
- 回溯法生成路径:在递归过程中维护当前路径字符串,到达叶子节点时保存路径。
- 路径拼接优化:通过参数传递当前路径,避免频繁字符串操作(老师代码更高效)。
- 恢复现场:递归返回时需移除当前节点值,保持路径状态一致(用户代码未实现)
老师代码:
class Solution
{
public:
vector<string> ret; // 记录结果
vector<string> binaryTreePaths(TreeNode* root)
{
string path;
if(root == nullptr) return ret;
dfs(root, path);
return ret;
}
void dfs(TreeNode* root, string path)
{
path += to_string(root->val);
if(root->left == nullptr && root->right == nullptr)
{
ret.push_back(path);
return;
}
path += "->";
if(root->left) dfs(root->left, path);
if(root->right) dfs(root->right, path);
}
};
老师思路:
使⽤深度优先遍历(DFS)求解。
路径以字符串形式存储,从根节点开始遍历,每次遍历时将当前节点的值加⼊到路径中,
如果该节点为叶⼦节点,将路径存储到结果中。
否则,将 “->” 加⼊到路径中并递归遍历该节点的左右⼦树。
定义⼀个结果数组,进⾏递归。
递归具体实现⽅法如下:
- 如果当前节点不为空,就将当前节点的值加⼊路径 path 中,否则直接返回;
- 判断当前节点是否为叶⼦节点,如果是,则将当前路径加⼊到所有路径的存储数组 paths 中;
- 否则,将当前节点值加上 “->” 作为路径的分隔符,继续递归遍历当前节点的左右⼦节点。
- 返回结果数组。
- 特别地,我们可以只使⽤⼀个字符串存储每个状态的字符串,在递归回溯的过程中,需要将路径中的当前节点移除,以回到上⼀个节点。
具体实现⽅法如下:
-
定义⼀个结果数组和⼀个路径数组。
-
从根节点开始递归,递归函数的参数为当前节点、结果数组和路径数组。
a. 如果当前节点为空,返回。
b. 将当前节点的值加⼊到路径数组中。
c. 如果当前节点为叶⼦节点,将路径数组中的所有元素拼接成字符串,并将该字符串存储到结果数组中。
d. 递归遍历当前节点的左⼦树。
e. 递归遍历当前节点的右⼦树。
f. 回溯,将路径数组中的最后⼀个元素移除,以返回到上⼀个节点。
-
返回结果数组。
我的代码:
/**
* 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 {
public:
vector<string> binaryTreePaths(TreeNode* root)
{
vector<string> vs;
dfs(root, {}, vs);
return vs;
}
void dfs(TreeNode* root, string s, vector<string>& vs)
{
if(root->left == nullptr && root->right == nullptr)
{
s += to_string(root->val);
vs.push_back(s);
return;
}
s += to_string(root->val);
s += "->";
if(root->left) dfs(root->left, s, vs);
if(root->right) dfs(root->right, s, vs);
}
};
我的笔记:
- 值传递 vs 引用传递:值传递自动隔离状态,引用传递需显式回溯。
- 时间复杂度:O(n),但字符串拼接可能带来 O(n²) 时间(可用
std::stringstream
优化)。
小技巧
在这个题中我们需要明白回溯中的一个概念:恢复现场
老师在将这个题的时候说如果我们把路径字符path
串定义为全局变量就一定要恢复现场,因为当我们递归子问题之后回到上一级递归函数的时候如果路径字符串path
是全局变量就会改变它的值,这样在传入下一个子问题的递归函数的时候就会出现多了字符串的情况
最好的解决办法就是把路径字符串定义在函数头作为一个函数形参进行传递,这样就避免了复杂的恢复现场的操作