文章目录
617. 合并二叉树
简 单 \color{#00AF9B}{简单} 简单
给定两个二叉树,想象当你将它们中的一个覆盖到另一个上时,两个二叉树的一些节点便会重叠。
你需要将他们合并为一个新的二叉树。合并的规则是如果两个节点重叠,那么将他们的值相加作为节点合并后的新值,否则不为 NULL 的节点将直接作为新二叉树的节点。
示例 1:
输入:
Tree 1 Tree 2
1 2
/ \ / \
3 2 1 3
/ \ \
5 4 7
输出:
合并后的树:
3
/ \
4 5
/ \ \
5 4 7
注意: 合并必须从两个树的根节点开始。
前言
测试用例应写作树的前序遍历,如例1中Tree 1
和Tree 2
:
- Tree 1: [1,3,2,5]
- Tree 2: [2,1,3,null,4,null,7]
合并前2棵二叉树的对应结点一共有3种情况:
- 合并前对应结点均为空,则合并后的对应结点也为空。
- 合并前只有一个为空,则合并后结点的值是非空结点的值。
- 合并前2个结点都不为空,则合并后结点的值是2个结点的值求和。
方法1:深度优先遍历(递归)
对一个节点进行合并之后,还要对该节点的左右子树分别进行合并,这是一个递归的过程。于是我们普通地递归深度优先遍历二叉树即可。
class Solution
{
public:
TreeNode *mergeTrees(TreeNode *p1, TreeNode *p2)
{
if (!p1)
return p2;
if (!p2)
return p1;
TreeNode *merged = new TreeNode(p1->val + p2->val);
merged->left = mergeTrees(p1->left, p2->left);
merged->right = mergeTrees(p1->right, p2->right);
return merged;
}
};
复杂度分析
-
时间复杂度:O(min(m,n))。其中 m 和 n 分别是两个二叉树的节点个数。假设某次合并时,有一个结点为空,则合并时将非空结点的整个分支都链接到了新树上,该分支下的结点都没有被遍历。因此这里是取最小值。
-
空间复杂度:O(min(m,n))。同上,递归调用的层数最多不会超过较小的二叉树的最大高度。最坏情况下,二叉树的高度等于结点数。
参考结果
Accepted
182/182 cases passed (40 ms)
Your runtime beats 40.09 % of cpp submissions
Your memory usage beats 24.81 % of cpp submissions (32.1 MB)
方法1优化:解决方法1中存在的隐患(选择性理解)
对地址和指针懂一点、或者说敏感一点的小伙伴来说应该能马上看出来方法1中存在的问题。
对于只有一个结点为空时,方法1将整个分支直接连接到了新树上,这导致新树的某些结点是原来2棵树上的结点。一旦原树上的结点发生了变化,新树的对应结点也跟着发生变化;若原树结点被释放,则新树的对应结点也被释放了。
如果说我们选择考虑这个问题,那我们对于整个分支上的结点都要重新构造。
解决方法的代码也不难想到。我们这道题本身就是创建一个新树,树中结点的值是2棵树合并出来的。那么我们对于分支也是一个道理:创建一个新分支,分支结点的值是一颗树和一颗空树合并出来的(nullptr)。所以创建分支的代码和合并树的代码一样。只是把另一边换成了空树。
如果在面试笔试的过程中遇到了这种问题,建议问一下考官。如果说不用考虑这个问题,那就直接连上吧。
class Solution
{
public:
TreeNode *mergeTrees(TreeNode *p1, TreeNode *p2)
{
if (!p1 && !p2)
return nullptr;
else if (p1 && !p2)
{
TreeNode *branchroot = new TreeNode(p1->val);
branchroot->left = mergeTrees(p1->left, nullptr);
branchroot->right = mergeTrees(p1->right, nullptr);
return branchroot;
}
else if (!p1 && p2)
{
TreeNode *branchroot = new TreeNode(p2->val);
branchroot->left = mergeTrees(p2->left, nullptr);
branchroot->right = mergeTrees(p2->right, nullptr);
return branchroot;
}
else
{
TreeNode *merged = new TreeNode(p1->val + p2->val);
merged->left = mergeTrees(p1->left, p2->left);
merged->right = mergeTrees(p1->right, p2->right);
return merged;
}
}
};
复杂度分析
-
时间复杂度:O(C(m,n)+I(m)+I(n))。其中 m 和 n 分别是两个二叉树的节点个数;C(m,n)是两棵树的均不为空的对应结点;I(m)和I(n)分别为对方树上为空,而自身不为空的对应结点。由于我们要遍历所有结点,首先大家都有的结点要合并起来;其次在对方没有结点的位置,自己的结点需要重构一次。
-
空间复杂度:O(C(m,n)+I(m)+I(n))。虽然答案树不算在空间复杂度内,而且空间复杂度仍不变,但实际消耗当然会多出新树的空间。
参考结果
Accepted
182/182 cases passed (52 ms)
Your runtime beats 9.22 % of cpp submissions
Your memory usage beats 5.04 % of cpp submissions (34 MB)
方法2:深度优先遍历(非递归)
我们要想将递归具象为栈,那我们必须把原来2棵树,和新树的对应结点存入栈中。我们可以定义一个结构体StackElement
:
struct StackElement
{
TreeNode *p, *p1, *p2;
StackElement(TreeNode *p, TreeNode *p1, TreeNode *p2) : p(p), p1(p1), p2(p2) {}
};
如果有一个结点非空,我们可以选择直接将整个分支连接上去(即p->left=p1->left
或p->right=p1->right
或p->left=p2->left
等),也可以重构整个分支(详见上文方法1优化)。
本方法中选择了重构所有的结点。可以从代码中给p->left
和p->right
赋值的地方看出,每次都是使用了new
关键字。
#include <stack>
using namespace std;
struct StackElement
{
TreeNode *p, *p1, *p2;
StackElement(TreeNode *p, TreeNode *p1, TreeNode *p2) : p(p), p1(p1), p2(p2) {}
};
class Solution
{
public:
TreeNode *mergeTrees(TreeNode *root1, TreeNode *root2)
{
if (!root1 && !root2)
return nullptr;
TreeNode *root = new TreeNode();
stack<StackElement> stk;
stk.push({root, root1, root2});
while (!stk.empty())
{
auto [p, p1, p2] = stk.top();
stk.pop();
p->val = (p1 ? p1->val : 0) + (p2 ? p2->val : 0);
if (p1 && p1->left || p2 && p2->left)
{
p->left = new TreeNode();
stk.emplace(p->left, p1 && p1->left ? p1->left : nullptr, p2 && p2->left ? p2->left : nullptr);
}
if (p1 && p1->right || p2 && p2->right)
{
p->right = new TreeNode();
stk.emplace(p->right, p1 && p1->right ? p1->right : nullptr, p2 && p2->right ? p2->right : nullptr);
}
}
return root;
}
};
复杂度分析
-
时间复杂度:O(C(m,n)+I(m)+I(n))。其中 m 和 n 分别是两个二叉树的节点个数;C(m,n)是两棵树的均不为空的对应结点;I(m)和I(n)分别为对方树上为空,而自身不为空的对应结点。由于我们要遍历所有结点,首先大家都有的结点要合并起来;其次在对方没有结点的位置,自己的结点需要重构一次。
-
空间复杂度:O(C(m,n)+I(m)+I(n))。同上。
参考结果
Accepted
182/182 cases passed (44 ms)
Your runtime beats 20.82 % of cpp submissions
Your memory usage beats 8.46 % of cpp submissions (32.8 MB)
方法3:广度优先搜索
我们直接将方法2的代码中的栈替换成队列即可。深度和广度的区别就体现在栈和队列上,而那些区别已经帮我们实现好了。当然你应该或多或少懂一点数据结构并了解栈和队列了。
摆烂不写了。就把方法2里的栈换成队列。
深度和广度的时间、空间复杂度同理。
116. 填充每个节点的下一个右侧节点指针
中 等 \color{#FFB800}{中等} 中等
给定一个 完美二叉树 ,其所有叶子节点都在同一层,每个父节点都有两个子节点。二叉树定义如下:
struct Node { int val; Node *left; Node *right; Node *next; }
填充它的每个 next 指针,让这个指针指向其下一个右侧节点。如果找不到下一个右侧节点,则将 next 指针设置为
NULL
。
初始状态下,所有 next 指针都被设置为NULL
。
进阶:
- 你只能使用常量级额外空间。
- 使用递归解题也符合要求,本题中递归程序占用的栈空间不算做额外的空间复杂度。
示例:
输入:root = [1,2,3,4,5,6,7]
输出:[1,#,2,3,#,4,5,6,7,#]
解释:给定二叉树如图 A 所示,你的函数应该填充它的每个 next 指针,以指向其下一个右侧节点,如图 B 所示。序列化的输出按层序遍历排列,同一层节点由 next 指针连接,'#' 标志着每一层的结束。
提示:
- 树中节点的数量少于
4096
-1000 <= node.val <= 1000
方法1:层次遍历
题目的意思是让我们在树的每一层上附着一个链表,表头为这一层最左侧的结点。因此层次遍历是我们的首选。
层次遍历是广度优先搜索的变种。它与广度优先的区别在于:广度优先每次循环选择一个结点,扩展它的孩子结点;层次遍历每次循环选择一层的结点,然后扩展它们的孩子结点,这样就能保证每次循环内从队列中拿出来的结点是属于同一层的。
基于层次遍历,我们每从队列中拿出一个结点后,将该结点的next
指针指向队首的元素即可。
因为我们从队列中拿出一个结点后,要把它的左右孩子入队,这样一来我们就不能通过队空来终止“从队列中拿结点”这个循环。所以每次循环开始时,我们需要一个变量n
记录当前队列中存在多少元素,也就是这一层有多少元素,这样我们就能知道循环n
次然后结束。
由于上述循环是对每一层进行操作,因此我们需要外层循环来“指定哪一层”,外层循环的终止条件即为队空。因为内层循环结束后,会向队列添加2倍于上一层的结点;直至遍历到最后一层时,结点均无孩子结点,没有元素入栈,此时即为整棵树遍历完成。
#include <queue>
using namespace std;
class Solution
{
public:
Node *connect(Node *root)
{
if (!root)
return nullptr;
root->next = nullptr;
queue<Node *> que;
que.push(root);
while (!que.empty())
{
size_t n = que.size();
for (size_t i = 0; i < n; i++)
{
Node *p = que.front();
que.pop();
if (i < n - 1)
{
p->next = que.front();
}
if (p->left)
{
que.push(p->left);
}
if (p->right)
{
que.push(p->right);
}
}
}
return root;
}
};
复杂度分析
- 时间复杂度:O(n)。每个结点最多被遍历一次。
- 空间复杂度:O(n)。完美二叉树的最后一层有n/2个结点,因此队列长度最大为n/2。因此空间复杂度为O(n/2)=O(n)。
参考结果
Accepted
58/58 cases passed (12 ms)
Your runtime beats 99.02 % of cpp submissions
Your memory usage beats 47.48 % of cpp submissions (16.6 MB)
方法2:使用上一层已建立的next指针
建立next
指针有两种情况:
- 第一种情况是一个父节点下的两个左右孩子之间,左孩子的
next
需要指向右孩子。这一步只要我们选定父节点即可,parent->left->next=parent->right
。 - 第二种情况是一个父节点的右孩子的
next
需要指向右边父节点的左孩子,这种情况不能直接连接,因为我们既要知道父节点的位置,又要知道父节点的next
。
至此,我们的目标已经明了了。我们建立某一层需要知道上一层的next
情况;要建立上一层的next
情况需要知道再上一层。那么我们只要把第一层的next
建立起来,之后我们就能用第i
层的链表去对第i+1
层的结点进行操作。因此上述第二种情况的代码即为:parent->right->next=parent->next->left
。
每一层里面,我们需要从最左侧开始,只要拿到最左边的结点,就可以从该结点开始往右走,同时对下一层结点进行操作。因此内层循环我们要遍历这一层的链表。
同时,最左边的结点又是上一层最左侧结点的左孩子。因此,外层循环也明了了,当最左侧结点,即这一层的链表头结点head
不为空时代表循环还未结束,还有下一层要去;每次循环结束后head=head->left
,前往下一层。
#include <queue>
using namespace std;
class Solution
{
public:
Node *connect(Node *root)
{
if (!root)
return nullptr;
root->next = nullptr;
Node *head = root;
while (head->left)
{
Node *p = head;
while (p)
{
p->left->next = p->right;
if (head->next)
{
p->right->next = p->next->left;
}
p = p->next;
}
head = head->left;
}
return root;
}
};
复杂度分析
- 时间复杂度:O(n)。每个结点最多被遍历一次。
- 空间复杂度:O(1)。我们只需要常量空间存储若干变量,不需要存储额外的结点。
参考结果
Accepted
58/58 cases passed (20 ms)
Your runtime beats 70.81 % of cpp submissions
Your memory usage beats 75.46 % of cpp submissions (16.4 MB)