目录
- LeetCode #257:Binary Tree Paths 二叉树的所有路径
- 深度优先搜索
- 广度优先搜索
- LeetCode #404:Sum of Left Leaves 左叶子之和
- 深度优先搜索
- 广度优先搜索
- LeetCode #199:Binary Tree Right Side View 二叉树的右视图
- 广度优先搜索
- 深度优先搜索
- LeetCode #513:Find Bottom Left Tree Value 找树左下角值
- 深度优先搜索
- 广度优先搜索
- LeetCode #112:Path Sum 路径总和
- 深度优先搜索
- 广度优先搜索
- LeetCode #617:Merge Two Binary Trees 合并二叉树
- 深度优先搜索
- 广度优先搜索
- LeetCode #236:Lowest Common Ancestor of a Binary Tree 二叉树的最近公共祖先
本系列文章仅是 GitHub 大神 @halfrost 的刷题笔记 《LeetCode Cookbook》的提纲以及示例、题集的 C++转化。原书请自行下载学习。
本篇文章涉及新手应该优先刷的几道经典二叉树综合算法题。
LeetCode #257:Binary Tree Paths 二叉树的所有路径
给定一个二叉树的根节点 root
,按任意顺序,返回所有从根节点到叶子节点的路径。
深度优先搜索
类似于二叉树的最大深度问题,从根节点递归到叶子节点,记录路径上的节点,并更新维护路径。每次都是先从根节点开始,先递归左子树, 再递归右子树,自顶向下,考虑采用前序遍历的方式遍历二叉树。
对于这一问题,我们采用二维字符串数组来存储每一条路径,如果当前节点不是叶子节点,则在当前的路径末尾添加该节点,并继续递归遍历该节点的每一个子节点;如果当前节点是叶子节点,则在当前路径末尾添加该节点,就得到了一条从根节点到叶子节点的路径,将该路径加入到答案即可。
我们需要一个辅助函数 construct_paths()
来反复操作字符串数组 paths
以正确地遍历左右子树:
class Solution {
public:
vector<string> binaryTreePaths(TreeNode* root) {
vector<string> paths;
construct_paths(root, "", paths);
return paths;
}
private:
void construct_paths(TreeNode* root, string path, vector<string>& paths) {
if (root != nullptr) {
path += to_string(root->val);
if (root->left == nullptr && root->right == nullptr) paths.push_back(path); //当前节点是叶子节点
else {
path += "->"; //当前节点不是叶子节点,继续递归遍历
construct_paths(root->left, path, paths);
construct_paths(root->right, path, paths);
}
}
}
};
在这一算法中,每个节点会被访问一次且只会被访问一次,每一次会对 path
变量进行拷贝构造,时间代价为
O
(
n
)
\ O(n)
O(n),总体的时间复杂度为
O
(
n
2
)
\ O(n^2)
O(n2) 。
在最坏情况下,当二叉树中每个节点只有一个孩子节点时,即整棵二叉树呈一个链状,此时递归的层数为
n
n
n ,此时每一层的 path
变量的空间代价的总和为
O
(
∑
i
=
1
n
i
)
=
O
(
n
2
)
O\left(\sum^{n}_{i=1}i\right) = O(n^2)
O(∑i=1ni)=O(n2),即该算法的空间复杂度。
广度优先搜索
我们维护两个队列,分别存储节点以及根到该节点的路径,在每步迭代中,我们取出队首节点,如果它是叶子节点,则将它对应的路径加入到答案中;如果它不是叶子节点,则将它的所有子节点加入到队列的末尾,直到队列为空。
class Solution {
public:
vector<string> binaryTreePaths(TreeNode* root) {
vector<string> paths;
if (root == nullptr) return paths;
queue<TreeNode*> node_queue; //存储待处理的节点
queue<string> path_queue; //存储到达当前节点的路径
node_queue.push(root); //将根节点加入节点队列
path_queue.push(to_string(root->val)); //将根节点的值转换为字符串,并加入路径队列
while (!node_queue.empty()) {
TreeNode* node = node_queue.front();
string path = path_queue.front();
node_queue.pop();
path_queue.pop();
if (node->left == nullptr && node->right == nullptr) paths.push_back(path);
else {
//如果左子节点非叶子节点,则将左子节点加入节点队列,并更新路径
if (node->left != nullptr) {
node_queue.push(node->left);
path_queue.push(path + "->" + to_string(node->left->val));
}
//如果右子节点非叶子节点,则将右子节点加入节点队列,并更新路径
if (node->right != nullptr) {
node_queue.push(node->right);
path_queue.push(path + "->" + to_string(node->right->val));
}
}
}
return paths;
}
};
LeetCode #404:Sum of Left Leaves 左叶子之和
给定二叉树的根节点 root
,返回所有左叶子之和。
非常简单,遍历整棵树:
- 如果遍历的当前节点的左孩子是一个叶子节点,则左孩子的值累加入结果。
- 如果遍历的当前节点的左孩子不是叶子节点,则继续遍历。
深度优先搜索
class Solution {
public:
int sumOfLeftLeaves(TreeNode* root) {
if (root == nullptr) return 0;
//如果当前节点的左孩子存在,并且左孩子为叶子节点,则将左孩子的值加入res
if (root->left != nullptr && root->left->left == nullptr && root->left->right == nullptr) res += root->left->val;
//递归遍历左子树和右子树
sumOfLeftLeaves(root->left);
sumOfLeftLeaves(root->right);
return res;
}
private:
int res = 0;
};
该算法的时间复杂度和空间复杂度均为 O ( n ) O(n) O(n)。
广度优先搜索
class Solution {
public:
int sumOfLeftLeaves(TreeNode* root) {
if (!root) return 0;
queue<TreeNode*> q;
q.push(root);
int ans = 0;
while (!q.empty()) {
TreeNode* node = q.front();
q.pop();
if (node->left)
if (isLeafNode(node->left)) ans += node->left->val;
else q.push(node->left);
if (node->right)
if (!isLeafNode(node->right)) q.push(node->right);
}
return ans;
}
private:
bool isLeafNode(TreeNode* node) {
return !node->left && !node->right;
}
};
LeetCode #199:Binary Tree Right Side View 二叉树的右视图
给定一个二叉树的根节点 root
,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。
所谓右视图,其实就是每一层最右边的节点,涉及到“层”的概念,直接利用层次遍历也就是广度优先搜索算法来解决。对应到具体操作上,对于每一层的节点从左到右遍历,保存每层最后一个遍历的节点即可。
广度优先搜索
使用队列保存每一层的所有节点,把队列里的所有节点弹出队列,如果当前节点是当前层
的最后一个节点,入 res
。
class Solution {
public:
vector<int> rightSideView(TreeNode* root) {
vector<int> res;
if (root == nullptr) return res;
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
int size = q.size();
for (int i = 0; i < size; i++) {
TreeNode* node = q.front();
q.pop();
//如果当前节点是该层的最后一个节点,则将其值添加到结果中
if (i == size - 1) res.push_back(node->val);
//将当前节点的左右子节点添加到队列中
if (node->left != nullptr) q.push(node->left);
if (node->right != nullptr) q.push(node->right);
}
}
return res;
}
};
该算法的时间复杂度与空间复杂度均为 O ( n ) O(n) O(n)。
深度优先搜索
层次遍历对于每一层,均为从左到右遍历,而我们可以换个方向,从右向左遍历, 这样对于每一层来说,遍历的第一个节点就是所谓右视图的子节点。 因此,我们可以先遍历右子树,再遍历左子树。
class Solution {
public:
vector<int> rightSideView(TreeNode* root) {
vector<int> res;
if (root == nullptr) return res;
level(root, 1, res);
return res;
}
private:
void level(TreeNode* root, int depth, vector<int>& res) {
//如果当前节点的深度还未在 res 中出现(每层仅有一个节点)
//这意味着当前节点是该层第一个被访问的,将当前节点的值添加到 res 中
if (res.size() < depth) res.push_back(root->val);
//遍历右子树
if (root->right != nullptr) level(root->right, depth + 1, res);
//遍历左子树
if (root->left != nullptr) level(root->left, depth + 1, res);
}
};
LeetCode #513:Find Bottom Left Tree Value 找树左下角值
给定一个二叉树的根节点 root
,请找出该二叉树最底层最左边节点的值。
所谓“最底层”,便是二叉树最大深度,最大深度那一层必然是最后一层,其次则是“最左边”。
深度优先搜索
我们可以自顶向下,从根节点递归到叶子节点,对于每一个节点,先判断是否为叶子节点,再按前序遍历左右子树。
我们需要维护两个深度,一个是当前的遍历到的最大深度 maxDepth
,另一个则是当前节点所处的深度 leftDepth
。如果当前节点是叶子节点,且 leftDepth > maxDepth
的时候,证明当前遍历的节点是新的一层最先被遍历的节点1,更新 maxDepth
和当前的结果 res
。
class Solution {
private:
int maxDepth = -1;
int res = -1;
void leftValue(TreeNode* root, int leftDepth) {
if (root == nullptr) return;
//如果当前节点是叶子节点
if (root->left == nullptr && root->right == nullptr) {
//当前叶子节点的深度大于之前保存的最大深度
//此时更新最大深度,更新结果值
if (leftDepth > maxDepth) {
maxDepth = leftDepth;
res = root->val;
}
}
//递归左子树
leftValue(root->left, leftDepth + 1);
//递归右子树
leftValue(root->right, leftDepth + 1);
}
public:
int findBottomLeftValue(TreeNode* root) {
leftValue(root, 0);
return res;
}
};
该算法的时间复杂度与空间复杂度均为 O ( n ) O(n) O(n)。
广度优先搜索
我们也可以自左向右,根节点开始,一层一层地遍历二叉树,那么最后一层的第一个节点便是我们希望得到的结果,即所谓层序遍历。我们依然采用经典的队列进行遍历,使用队列保存每一层的节点,第 1 个出队列的节点值保存(即该层最左边的值),把队列里的所有节点出队列,然后把这些出去节点各自的子节点入队列。用 depth
维护每一层。
class Solution {
public:
int findBottomLeftValue(TreeNode* root) {
queue<TreeNode*> q;
q.push(root);
int res = 0;
while (!q.empty()) {
int n = q.size();
for (int i = 0; i < n; i++) {
TreeNode* node = q.front();
q.pop();
//存储每一层的第一个元素
if (i == 0) res = node->val;
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
return res;
}
};
LeetCode #112:Path Sum 路径总和
给定二叉树的根节点 root
和一个表示目标的整数 targetSum
。
判断该树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和 targetSum
。存在返回 true
,否则返回 false
。
深度优先搜索
依然采用自顶向下的思路,以前序遍历的方式,每次先判断当前的节点,再递归左子树,最后是右子树。具体的解法如下:
- 判断当前节点是否为叶子节点,如果是,则判断当前叶子节点的值是否为
targetSum
减去之前路径上节点值。 - 递归左子树。
- 递归右子树。
class Solution {
public:
bool hasPathSum(TreeNode* root, int targetSum) {
if (root == nullptr) return false;
//如果当前节点为叶子节点,且叶子节点的值等于减去该路径之前节点的值,返回 true
if (root->left == nullptr && root->right == nullptr && root->val == targetSum) return true;
//递归左子树
bool leftPath = hasPathSum(root->left, targetSum - root->val);
//递归右子树
bool rightPath = hasPathSum(root->right, targetSum - root->val);
//返回左子树或右子树的结果
return leftPath || rightPath;
}
};
该算法的时间复杂度和空间复杂度均为 O ( n ) O(n) O(n)。
广度优先搜索
我们使用两个队列,分别存储将要遍历的节点,以及根节点到这些节点的路径和,以防止重复计算。
class Solution {
public:
bool hasPathSum(TreeNode *root, int targetSum) {
if (root == nullptr) return false;
//定义两个队列,一个用来存储节点,一个用来存储从根节点到当前节点的路径和
queue<TreeNode *> que_node;
queue<int> que_val;
//将根节点和根节点的值入队
que_node.push(root);
que_val.push(root->val);
while (!que_node.empty()) {
//取出当前节点和当前路径和
TreeNode *node = que_node.front();
int currSum = que_val.front();
que_node.pop();
que_val.pop();
//如果当前节点是叶子节点
if (node->left == nullptr && node->right == nullptr) {
//如果当前路径和等于给定的和,则返回ntrue
if (currSum == targetSum) return true;
continue;
}
//如果左子节点不为空,则将左子节点与左子节点路径和入队
if (node->left != nullptr) {
que_node.push(node->left);
que_val.push(node->left->val + currSum);
}
//如果右子节点不为空,则将右子节点与右子节点路径和入队
if (node->right != nullptr) {
que_node.push(node->right);
que_val.push(node->right->val + currSum);
}
}
//如果遍历完所有路径都没有找到符合条件的路径,则返回 false
return false;
}
};
LeetCode #617:Merge Two Binary Trees 合并二叉树
给定两棵二叉树 root1
和 root2
,合并成一棵新的二叉树,合并规则为:
如果两个节点重叠,将两个节点的值相加作为合并后节点的新值,否则,不为 null
的节点将直接作为新二叉树的节点。
大致可以分为两种情况讨论:
- 如果两棵树对应位置上都有节点,则新节点的值为两个节点的值相加。
- 如果两棵树对应位置上只有一个节点有值,则新节点的值就为该节点的值。
只需要遍历二叉树,保证遵守如上两条规则即可。
深度优先搜索
对于每一层来说,重新创建一个节点 root
存储 root1->val + root2->val
,那么,之后 root
的左子树就是合并 root1
左子树和 root2
左子树之后的左子树,root
的右子树就是合并 root1
右子树和 root2
右子树之后的右子树,依然是经典的前序遍历。
class Solution {
public:
TreeNode* mergeTrees(TreeNode* root1, TreeNode* root2) {
// base case
if (root1 == nullptr) return root2;
if (root2 == nullptr) return root1;
//如果均存在节点,创建一个新的节点存储合并后的值
TreeNode* root = new TreeNode(root1->val + root2->val);
//递归合并左子树
root->left = mergeTrees(root1->left, root2->left);
//递归合并右子树
root->right = mergeTrees(root1->right, root2->right);
return root;
}
};
设 root1
的节点数为
n
n
n,root2
的节点数为
m
m
m,该算法的时间复杂度为
O
(
min
(
n
,
m
)
)
O(\min (n,m))
O(min(n,m)),空间复杂度也为
O
(
min
(
n
,
m
)
)
O(\min (n,m))
O(min(n,m))。
广度优先搜索
我们维护两个队列,队列 qMerge
存储合并后的树节点,q
则是 root1
和 root2
的节点。
先创建一个新的根节点 root
,其值为 root1->val + root2->val
,同时初始化两个队列,将 root
入 qMerge
队列,将 root1
和 root2
入队列 q
。此时队列不为空,将 qMerge
队首元素出队列,同时将 q
两个队首元素出队列 。
若两棵树的当前节点都存在左孩子,则直接合并二者之和,为新的节点的值,同时将新节点的左孩子入 qMerge
队列,将两棵树节点的左孩子入 q
队列。以此类推,对于两棵树当前节点的右孩子同理并继续弹出队列,反复遍历,直到队列为空。
class Solution {
public:
TreeNode* mergeTrees(TreeNode* root1, TreeNode* root2) {
// base case
if (root1 == nullptr) return root2;
if (root2 == nullptr) return root1;
//如果都存在节点,创建一个新的节点存储合并后的值
TreeNode* root = new TreeNode(root1->val + root2->val);
//初始化队列
queue<TreeNode*> qMerge;
queue<TreeNode*> q;
qMerge.push(root);
q.push(root1);
q.push(root2);
while (!q.empty()) {
//从队列中取出当前节点
TreeNode* node = qMerge.front();
qMerge.pop();
TreeNode* node1 = q.front();
q.pop();
TreeNode* node2 = q.front();
q.pop();
//若两棵树的左孩子都存在
if (node1->left != nullptr || node2->left != nullptr) {
if (node1->left != nullptr && node2->left != nullptr) {
TreeNode* leftMerge = new TreeNode(node1->left->val + node2->left->val);
node->left = leftMerge;
qMerge.push(leftMerge);
q.push(node1->left);
q.push(node2->left);
}
else if (node1->left != nullptr) node->left = node1->left; //若只有一棵树存在左孩子,直接赋值
else if (node2->left != nullptr) node->left = node2->left;
}
//若两棵树的右孩子都在
if (node1->right != nullptr || node2->right != nullptr) {
if (node1->right != nullptr && node2->right != nullptr) {
TreeNode* rightMerge = new TreeNode(node1->right->val + node2->right->val);
node->right = rightMerge;
qMerge.push(rightMerge);
q.push(node1->right);
q.push(node2->right);
}
else if (node1->right != nullptr) node->right = node1->right; //若只有一棵树存在右孩子,直接赋值
else if (node2->right != nullptr) node->right = node2->right;
}
}
return root;
}
};
LeetCode #236:Lowest Common Ancestor of a Binary Tree 二叉树的最近公共祖先
给定一个二叉树,找到该树中两个指定节点的最近公共祖先。
最近公共祖先:对于有根树 T
的两个节点 p
、q
,最近公共祖先表示为一个节点 x
,满足 x
是 p
、q
的最先且 x
的深度尽可能地大(一个节点也可以是它自己的祖先)。
所谓祖先,其实就是从根节点到当前节点所经过的所有节点。我们用 Git 来引出「最近公共祖先」(LCA)这一经典问题。
git pull
默认使用 merge
方式将远端修改拉取到本地,如果加上参数而使用 git pull -r
这一命令,就会使用 rebase
方式拉取。这两者最重要的区别是,merge
方式合并的分支会看到很多「分叉」,而 rebase
方式合并的分支就是一条直线。但无论哪种方式,如果存在冲突,Git 都会检测出来并让用户手动解决冲突。
以 rebase
命令为例,如图,在 dev
分支上执行 git rebase master
,dev
就会接到 master
分支之上:
这个过程中,Git 先找到这两条分支的最近公共祖先 LCA
,然后从 master
节点开始,重演 LCA
到 dev
的commit
的修改。如果这些修改和 LCA
到 master
的 commit
有冲突,就会提示用户手动解决冲突,最后的结果就是把 dev
的分支完全接到 master
上面。因此,关键在于寻找 LCA
算法的实现。
我们先实现一个简单的算法:给出一棵没有重复元素的二叉树根节点 root
和一个目标值 val
,写一个函数以寻找树中值为 val
的节点。
TreeNode* find(TreeNode* root, int val) {
// base case
if (root == nullptr) return nullptr;
//检查 root->val 是否为所求节点
if (root->val == val) return root;
//若 root 不是目标节点,则递归左子树
TreeNode* left = find(root->left, val);
if (left != nullptr) return left;
//左子树未找到,递归右子树
TreeNode* right = find(root->right, val);
if (right != nullptr) return right;
return nullptr;
}
我们对这个函数进行改动。先修改一下 return
的位置:
TreeNode* find(TreeNode* root, int val) {
if (root == nullptr) return null;
//前序位置
if (root->val == val) return root;
// root 不是目标节点,去左右子树寻找
TreeNode* left = find(root->left, val);
TreeNode* right = find(root->right, val);
//确定左右子树对应目标节点的位置
return left != nullptr ? left : right;
}
可以实现目的,但是这段代码即使能够在左子树找到目标节点,它还是会去右子树找一圈,实际运行的效率会降低。
更进一步地,对 root->val
的判断从前序位置移动到后序位置:
TreeNode* find(TreeNode* root, int val) {
if (root == null) return null;
//先去左右子树寻找
TreeNode* left = find(root->left, val);
TreeNode* right = find(root->right, val);
//后序位置,检查 root 是否为目标节点
if (root->val == val) return root;
// root 非目标节点,再去查看哪边的子树找到了
return left != nullptr ? left : right;
}
这段代码相当于先去左右子树找,最后才检查 root
,依然可以实现目的,但是这种写法必然会遍历二叉树的每一个节点,效率会进一步降低。
对于之前的算法,在前序位置就检查 root
,如果输入的二叉树根节点的值恰好就是目标值 val
,那么函数直接返回了,其他的节点根本不用搜索;但如果在后序位置判断,那么就算根节点就是目标节点,也要去左右子树遍历完所有节点才能判断出来。
此时,如果我们不再寻找值为 val
的单一节点,而是两个值为 val1
和 val2
的节点,仿照这一写法,代码如下:
TreeNode* find(TreeNode* root, int val1, int val2) {
// base case
if (root == nullptr) return nullptr;
//前序位置,检查 root 是否为目标值
if (root->val == val1 || root->val == val2) return root;
//寻找左右子树
TreeNode* left = find(root->left, val1, val2);
TreeNode* right = find(root->right, val1, val2);
//后序位置,已经知道左右子树是否存在目标值
return left != nullptr ? left : right;
}
这一写法有些奇怪,而且也存在其他解法。但是我们可以利用 find()
方法来解决 LCA 问题。
对于本题,如果节点 node
为 p
和 q
的最近公共祖先,那么会有 3 种情况:
p
和q
分别在节点node
的左右子树中。node
即为节点p
,q
在节点p
的左子树或右子树中。node
即为节点q
,p
在节点q
的左子树或者右子树中。
两个节点的最近公共祖先其实就是这两个节点向根节点的「延长线」的交汇点,那么对于任意一个节点,如果一个节点能够在它的左右子树中分别找到 p
和 q
,则该节点为 LCA
节点。
这就要用到之前实现的 find()
方法了,只需在后序位置添加一个判断逻辑,即可解决本题:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
return find(root, p->val, q->val);
}
//在二叉树中寻找 val1 和 val2 的最近公共祖先节点
TreeNode* find(TreeNode* root, int val1, int val2) {
if (root == nullptr) return nullptr;
//前序位置
if (root->val == val1 || root->val == val2) return root; //如果遇到目标值,直接返回
TreeNode* left = find(root->left, val1, val2);
TreeNode* right = find(root->right, val1, val2);
//后序位置,已经知道左右子树是否存在目标值
if (left != nullptr && right != nullptr) return root; //当前节点是 LCA 节点
return left != nullptr ? left : right;
}
在 find()
方法的后序位置,如果发现 left
和 right
都非空,就说明当前节点是 LCA
节点,即解决了第一种情况;在 find()
方法的前序位置,如果找到一个值为 val1
或 val2
的节点则直接返回,恰好解决了第二、三种情况。
基于 p
和 q
一定存在于二叉树中这一重要推断,即便遇到 q
就直接返回,根本没遍历到 p
,也依然可以断定 p
在 q
底下,q
就是 LCA
节点。
该算法的时间复杂度为 O ( n ) O(n) O(n),空间复杂度也为 O ( n ) O(n) O(n)。
我们优先进行的是左子树的遍历,所以它肯定是当前层最左边的节点。 ↩︎