目录
- 二叉树的实现
- LeetCode #144:Binary Tree Preorder Traversal 二叉树的前序遍历
- 递归解法
- 「遍历」思路
- 「分而治之」思路
- 更多例子:求二叉树最大深度
- 迭代解法
- Morris 遍历
- LeetCode #94:Binary Tree Inorder Traversal 二叉树的中序遍历
- 迭代解法
- Morris 遍历
- LeetCode #145:Binary Tree Postorder Traversal 二叉树的后序遍历
- 迭代解法
- Morris 遍历
本系列文章仅是 GitHub 大神 @halfrost 的刷题笔记 《LeetCode Cookbook》的提纲以及示例、题集的 C++转化。原书请自行下载学习。
本篇文章涉及新手应该优先刷的几道经典二叉树递归遍历算法题。
二叉树的实现
最常见的二叉树就是类似链表的链式存储结构,每个二叉树节点有指向左右子节点的指针,这种方式比较简单直观。
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
这样就可以构建一棵二叉树了:
TreeNode* root = new TreeNode(1);
root->left = new TreeNode(2);
root->right = new TreeNode(3);
root->left->left = new TreeNode(4);
root->right->left = new TreeNode(5);
root->right->right = new TreeNode(6);
//构建出来的二叉树是这样的:
// 1
// / \
// 2 3
// / / \
// 4 5 6
所谓顺序(递归)遍历,即前、中、后序遍历,就是在二叉树遍历框架下的不同位置添加代码:
//二叉树的遍历框架
void traverse(TreeNode* root) {
if (root == nullptr) return;
//前序位置
traverse(root->left);
//中序位置
traverse(root->right);
//后序位置
}
前序位置的代码会在进入节点时执行;中序位置的代码会在左子树遍历完成后、遍历右子树之前执行;后序位置的代码会在左右子树遍历完成后执行。
❗️用哈希表表示二叉树比较常见:在一般的算法题中,我们可能会把问题抽象成二叉树结构,但我们并不需要真的用 TreeNode
创建一棵二叉树出来,而是直接用类似哈希表的结构来表示二叉树:
1 -> [2, 3]
2 -> [4]
3 -> [5, 6]
LeetCode #144:Binary Tree Preorder Traversal 二叉树的前序遍历
给你二叉树的根节点 root
,返回它节点值的前序遍历。
二叉树算法有两大思维模式:
- 「遍历」:是否可以通过遍历一遍二叉树得到答案? 如果可以,用一个
traverse()
函数配合外部变量来实现。 - 「分而治之」:是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案? 如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值。
无论何种思维模式,我们都需要思考——如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前 / 中 / 后序位置)做? 我们不需要考虑其他节点,递归函数会在所有节点上执行相同的操作。我们使用 traverse()
函数对二叉树进行遍历,实际上与对数组、链表等线性数据结构的遍历没有什么本质的区别,只要是递归形式的遍历,都可以有前序位置和后序位置,分别在递归之前和递归之后。
所谓前序位置,就是刚进入一个节点(元素)的时候,后序位置就是即将离开一个节点(元素)的时候,那么进一步,把代码写在不同位置,代码执行的时机也不同。
每个节点都有唯一属于自己的前中后序位置,从而我们可以知道,前中后序是遍历二叉树过程中处理每一个节点的三个特殊时间点。相应地,多叉树节点可能有很多子节点,会多次切换子树去遍历,对于每个节点没有唯一的中序位置。
因此,二叉树的所有问题,就是通过在前中后序位置注入巧妙的代码逻辑从而实现自己的目的,我们只需要单独思考每一个节点应该做什么,其他的则抛给二叉树遍历框架,递归会在所有节点上做相同的操作。
递归解法
「遍历」思路
对于前序遍历,我们先找出重复的子问题:先取根节点,再遍历左子树,最后遍历右子树。因此,我们需要利用辅助函数 traverse()
来分别遍历左右子树,以及引入外部变量 res
用于存储遍历结果;同时,我们必须保证递归可以被终止,base case
即没有子节点可供遍历,也就是 root == nullptr
。
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
traverse(root);
return res;
}
private:
vector<int> res;
void traverse(TreeNode* root) {
if (root == nullptr) return;
//前序位置
res.push_back(root->val);
traverse(root->left);
traverse(root->right);
}
};
每个节点均被遍历了一次,该算法的时间复杂度为 O ( n ) \ O(n) O(n) ,额外维护了一个数组,空间复杂度为 O ( n ) \ O(n) O(n) 。
「分而治之」思路
如果我们不使用额外的函数方法以及任何外部变量,单纯地递归调用 preorderTraversal()
来完成二叉树的前序遍历,那么可以分解问题为:一棵二叉树的前序遍历结果 = 根节点 + 左子树的前序遍历结果 + 右子树的前序遍历结果。
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
vector<int> res;
if (root == nullptr) return res;
//前序遍历的结果,root->val 在第一个
res.push_back(root->val);
//插入左子树的前序遍历结果
vector<int> left = preorderTraversal(root->left);
res.insert(res.end(), left.begin(), left.end());
//插入右子树的前序遍历结果
vector<int> right = preorderTraversal(root->right);
res.insert(res.end(), right.begin(), right.end());
return res;
}
};
而中序、后序遍历的递归解法原理一致,只需更改主要代码逻辑所处的位置即可。
更多例子:求二叉树最大深度
给定一个二叉树 root
,返回其最大深度。
二叉树的最大深度是指从根节点到最远叶子节点的最长路径上的节点数。
我们采用「遍历」二叉树的思路,显然需要遍历一遍二叉树,用一个外部变量记录每个节点所在的深度,取最大值就可以得到最大深度。由于前序位置是进入一个节点的时候,后序位置是离开一个节点的时候,depth
记录当前递归到的节点深度,我们把 traverse
理解为在二叉树上游走的一个指针,所以需要在前序位置增加 depth
,在后序位置减小 depth
。
至于对 res
的更新,放到前中后序位置均可,只要保证在进入节点之后、离开节点之前(即 depth
自增之后、自减之前)。
class Solution {
public:
int maxDepth(TreeNode* root) {
traverse(root);
return res;
}
private:
int depth = 0, res = 0; //分别记录遍历到的节点的深度、最大深度
void traverse(TreeNode* root) {
if (root == nullptr) return;
//前序位置
depth++;
if (root->left == nullptr && root->right == nullptr) res = max(res, depth); //到达叶子节点,更新最大深度
traverse(root->left);
traverse(root->right);
//后序位置,回溯,深度减少
depth--;
}
};
一棵二叉树的最大深度可以通过子树的最大深度推导出来,这就是「分而治之」思路计算答案的思路——首先利用递归函数的定义算出左右子树的最大深度,然后推出原树的最大深度,主要逻辑则集中放在后序位置。
class Solution {
public:
int maxDepth(TreeNode* root) {
if (root == nullptr) return 0;
//利用定义,计算左右子树的最大深度
int leftMax = maxDepth(root->left);
int rightMax = maxDepth(root->right);
//整棵树的最大深度等于左右子树的最大深度取最大值,再加上根节点本身
return max(leftMax, rightMax) + 1;
}
};
迭代解法
这种解法与递归方法是等价的,递归隐式地维护了一个栈,而迭代则显式地将这个栈模拟出来。具体思路如下:
- 初始化一个栈,定义一个二叉树节点
node
,初始化为根节点root
。 - 当栈或
node
节点不为空时: -
- 访问左子树:将
node
的值加入到结果集res
中,并将node
压入栈stk
中,然后node
指向其左子节点。这个过程一直持续到node
为空,即没有左子节点为止。
- 访问左子树:将
-
- 转向右子树:从栈中弹出一个节点(最近被遍历完左子树的节点),将
node
设置为栈顶元素(即最近被弹出的节点)的右子节点,开始遍历右子树。
- 转向右子树:从栈中弹出一个节点(最近被遍历完左子树的节点),将
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
vector<int> res;
if (root == nullptr) return res;
stack<TreeNode*> stk;
TreeNode* node = root;
while (!stk.empty() || node != nullptr) {
while (node != nullptr) {
res.emplace_back(node->val);
stk.emplace(node);
node = node->left;
}
node = stk.top();
stk.pop();
node = node->right;
}
return res;
}
};
该算法时间复杂度与空间复杂度均为 O ( n ) \ O(n) O(n) 。在遍历过程中,这一解法始终先访问当前节点的值,然后遍历左子树,最后通过栈的帮助下转而遍历右子树。这种方式避免了递归带来的栈溢出风险,特别是在处理非常深的树的时候。
不过,我们可以简化一下代码逻辑,每次递归都是先将根节点放入栈,然后右子树,最后左子树:
- 初始化维护一个栈,将根节点入栈。
- 当栈不为空时:
-
- 弹出栈顶元素
node
,将节点值加入结果数组中。
- 弹出栈顶元素
-
- 若
node
的右子树不为空,右子树入栈。
- 若
-
- 若
node
的左子树不为空,左子树入栈。
- 若
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
vector<int> res;
if (root == nullptr) return res;
stack<TreeNode*> stk;
stk.push(root);
while (!stk.empty()) {
TreeNode* node = stk.top();
stk.pop();
//访问节点
res.push_back(node->val);
//由于栈是后进先出的,所以需要先压入右子树,再压入左子树
if (node->right != nullptr) stk.push(node->right);
if (node->left != nullptr) stk.push(node->left);
}
return res;
}
};
优化后的代码时间复杂度、空间复杂度与上一迭代解法一致。
Morris 遍历
有一种巧妙的方法可以在线性时间内,只占用常数空间来实现前序遍历。其核心思想是利用树的大量空闲指针,实现空间开销的极限缩减。实现思路如下:
- 新建临时节点,令该节点为
root
。 - 若当前节点的左子节点为空,将当前节点加入答案,并遍历当前节点的右子节点。
- 若当前节点的左子节点不为空,在当前节点的左子树中找到当前节点在中序遍历下的前驱节点:
-
- 若前驱节点的右子节点为空,则将前驱节点的右子节点设置为当前节点,将当前节点加入答案,并将前驱节点的右子节点更新为当前节点,当前节点更新为当前节点的左子节点。
-
- 若前驱节点的右子节点为当前节点,将它的右子节点重新设为空。当前节点更新为当前节点的右子节点。
- 重复步骤 2 和步骤 3,直到遍历结束。
class Solution {
public:
vector<int> preorderTraversal(TreeNode *root) {
vector<int> res;
if (root == nullptr) return res;
TreeNode *p1 = root, *p2 = nullptr; // p1是当前遍历到的节点,p2 是 p1 的左子节点或者用于Morris遍历的临时右子节点
while (p1 != nullptr) {
p2 = p1->left;
if (p2 != nullptr) {
while (p2->right != nullptr && p2->right != p1) p2 = p2->right; //找到 p1 左子树中的最右节点
//如果最右节点的右指针为空,则进行 Morris 遍历的特定操作
if (p2->right == nullptr) {
res.emplace_back(p1->val);
p2->right = p1;
p1 = p1->left;
continue;
} else p2->right = nullptr; //如果最右节点的右指针已经指向 p1,说明之前已经访问过p1的左子树了,此时需要恢复树的原状,即将最右节点的右指针置为空
} else res.emplace_back(p1->val); //如果 p1 没有左子节点,则直接访问 p1,并转向 p1 的右子树
//转向 p1 的右子树
p1 = p1->right;
}
return res;
}
};
LeetCode #94:Binary Tree Inorder Traversal 二叉树的中序遍历
递归解法只需要更改核心代码位置即可,不再赘述。
迭代解法
在前序遍历中,遍历节点的顺序和处理节点的顺序是一致的;而在中序遍历中,访问节点的顺序和处理节点的顺序是不一致的,并且,处理节点是在遍历完左子树之后。总而言之,这一算法应该从根节点开始,一层层地遍历,从左子树最左子节点开始处理节点。
具体思路如下:
- 初始化一个空栈。
- 当根节点或栈不为空时,从根节点开始:
-
- 若当前节点有左子树,一直遍历左子树,每次将当前节点压入栈中。
-
- 若当前节点无左子树,从栈中弹出该节点,尝试访问该节点的右子树。
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
vector<int> res;
if (root == nullptr) return res;
stack<TreeNode*> stk;
while (stk.size() > 0 || root != nullptr) { //当栈不为空或当前节点不为空时,继续遍历
//如果当前节点不为空,则尽可能地向左子树深入
if (root != nullptr) {
stk.push(root);
root = root->left;
}
//如果当前节点为空,说明已经到达最左端的节点,需要回溯
else {
TreeNode* cur = stk.top();
stk.pop();
res.push_back(cur->val);
root = cur->right;
}
}
return res;
}
};
Morris 遍历
Morris 遍历算法本身就是为中序遍历的非递归实现而设计,但也可以添加额外的逻辑实现前序、后序遍历。其主要利用了叶子节点的空指针来建立临时线索(Thread ),以便在遍历过程中无需使用栈或递归即可恢复节点的中序前驱,在不使用栈或递归的情况下实现二叉树的遍历。线索化将这些空指针指向中序遍历中的前驱节点或后继节点。线索提供了在遍历过程中前进或回溯的路径信息。
具体来说,在 Morris 中序遍历中,对于当前遍历到的节点:
- 如果存在左子节点,则找到该左子树中的最右节点(即中序遍历的前驱节点)。
- 如果该最右节点的右指针为空,则将其右指针指向当前节点,这样就建立了一条从中序前驱节点到当前节点的线索。
- 当再次访问到这个最右节点时(通过其右指针,即线索),此时已经遍历完了当前节点的左子树,于是将这条线索删除(即将最右节点的右指针置为空),并继续遍历过程。
通过这种方式,Morris 遍历能够在不使用额外空间的情况下,通过线索在树中前进和回溯,从而实现中序遍历(以及前序和后序遍历的变体)。线索的创建和删除是 Morris 遍历的核心操作,它们允许算法在遍历过程中动态地构建和销毁临时路径,从而避免了使用显式的栈或递归栈。
Morris 遍历是一种非破坏性的遍历方法,它只会在遍历过程中暂时改变树的结构,在遍历完成后,通过删除所有创建的线索,从而恢复二叉树的原始结构。
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
vector<int> res;
TreeNode* curr = root;
while (curr != nullptr) {
//如果当前节点的左子树为空,则直接访问当前节点并移至右子树
if (curr->left == nullptr) {
res.push_back(curr->val);
curr = curr->right;
} else {
//否则,找到当前节点左子树中的最右节点(即中序前驱)
TreeNode* predecessor = curr->left;
while (predecessor->right != nullptr && predecessor->right != curr) predecessor = predecessor->right;
//如果前驱节点的右指针为空,则建立线索
if (predecessor->right == nullptr) {
predecessor->right = curr;
curr = curr->left;
} else {
//如果前驱节点的右指针已指向当前节点,说明左子树已遍历完
//恢复线索,并访问当前节点,移至右子树
predecessor->right = nullptr;
res.push_back(curr->val);
curr = curr->right;
}
}
}
return res;
}
};
LeetCode #145:Binary Tree Postorder Traversal 二叉树的后序遍历
递归解法只需要更改核心代码位置即可,不再赘述。
迭代解法
后序遍历的顺序为:左子树 -> 右子树 -> 根节点,根节点需要被处理 3 次。具体思路如下:
- 初始化一个空栈。
- 当根节点或者栈不为空时,从根节点开始:
-
- 每次将当前节点压入栈中,若当前节点存在左子树,就遍历左子树,否则就遍历右子树。
-
- 若当前节点左子树、右子树均不存在,则从栈中弹出该节点。如果当前节点是上一个节点(即弹出该节点后的栈顶元素)的左节点,尝试访问上个节点的右子树;如果不是,那当前栈的栈顶元素继续弹出。
这一算法并不是标准的后序遍历迭代方法,并没有显式地保证在访问根节点之前,其左右子树都已经被访问过,而是通过栈的状态和遍历的顺序来隐含地实现这一点的。
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
vector<int> res;
stack<TreeNode*> stk;
while (!stk.empty() || root != nullptr) {
//尽可能地向左子树深入,将沿途的节点压入栈中
while (root != nullptr) {
stk.push(root);
if (root->left != nullptr) root = root->left; //如果存在左子树,则继续向左
else root = root->right; //如果没有左子树,但可能有右子树,尝试转向右子树
}
root = stk.top();
stk.pop();
res.push_back(root->val);
//检查栈是否为空,以及栈顶元素的左子节点是否是刚刚弹出的节点
if (!stk.empty() && stk.top()->left == root) root = stk.top()->right;
//如果不是,或者栈为空,说明没有更多的子树需要处理
//将 root 设为 nullptr,以便外层循环可以检查栈是否还有未处理的节点
else root = nullptr;
}
return res;
}
};
要严格地实现二叉树的后序遍历,我们需要一个额外的指针 prev
来跟踪最近访问的节点,以便判断何时可以安全地访问并处理当前节点。
在这个实现中,当遍历到最左端的节点时,该节点会被压入栈中,然后转向处理其右子节点(如果存在):
- 如果右子节点不存在,或者右子节点就是最近访问过的节点
prev
,那么当前节点(栈顶元素)就可以被安全地处理(即将其值添加到结果向量中),并将其标记为已访问(通过更新prev
)。 - 如果右子节点存在且未被访问过,则当前节点会再次被压入栈中,以便稍后再次访问其右子节点。
- 这个过程会一直重复,直到遍历完树中的所有节点。最终,
res
数组将包含树的后序遍历结果。
class Solution {
public:
vector<int> postorderTraversal(TreeNode *root) {
vector<int> res;
if (root == nullptr) return res;
stack<TreeNode *> stk;
TreeNode *prev = nullptr;
while (root != nullptr || !stk.empty()) {
//尽可能地向左子树深入,并将沿途的节点压入栈中
while (root != nullptr) {
stk.emplace(root);
root = root->left;
}
//当无法再向左深入时,弹出栈顶元素,并检查其右子树
root = stk.top();
stk.pop();
// 如果当前节点的右子树为空,或者右子树已经被访问过(即 prev 指向右子节点),则可以安全地访问当前节点的值,并将其添加到结果数组中
if (root->right == nullptr || root->right == prev) {
res.emplace_back(root->val);
prev = root;
root = nullptr;
//如果右子树存在且未被访问,则将当前节点重新压入栈中并转向右子节点进行遍历
} else {
stk.emplace(root);
root = root->right;
}
}
return res;
}
};
Morris 遍历
- 新建临时节点,令该节点为
root
。 - 如果当前节点的左子节点为空,则遍历当前节点的右子节点。
- 如果当前节点的左子节点不为空,在当前节点的左子树中找到当前节点在中序遍历下的前驱节点:
-
- 如果前驱节点的右子节点为空,将前驱节点的右子节点设置为当前节点,当前节点更新为当前节点的左子节点。
-
- 如果前驱节点的右子节点为当前节点,将它的右子节点重新设置为空,倒序输出从当前节点的左子节点到该前驱节点这条路径上的所有节点;当前节点更新为当前节点的右子节点。
- 重复步骤 2 和步骤 3,直到遍历结束。
class Solution {
public:
vector<int> postorderTraversal(TreeNode *root) {
vector<int> res;
if (root == nullptr) return res;
TreeNode *p1 = root, *p2 = nullptr; // p1 用于遍历树,p2 用于寻找 p1 的左子树中的最右节点
while (p1 != nullptr) {
p2 = p1->left;
if (p2 != nullptr) {
//寻找 p1 在左子树中的后继节点(即左子树的最右节点)
while (p2->right != nullptr && p2->right != p1) p2 = p2->right;
if (p2->right == nullptr) { //如果 p2 的最右节点为空,说明 p1 是第一次访问
//将 p1 的左子树的最右节点指向 p1,建立临时线索
p2->right = p1;
p1 = p1->left;
continue;
} else { //如果 p2 的最右节点已经指向 p1,说明 p1 的左子树和右子树都已经被访问过
//恢复树的结构,移除临时线索
p2->right = nullptr;
//添加 p1 的左子树到结果中
addPath(res, p1->left);
}
}
//如果没有左子树或左子树已经处理完,转向右子树
p1 = p1->right;
}
//循环结束后,根节点及其右子树还未被处理
//通过 addPath() 添加根节点及其右子树
addPath(res, root);
return res;
}
private:
void addPath(vector<int> &vec, TreeNode *node) {
//辅助函数,用于将一条路径(从右向左)添加到结果 vector 中
int count = 0; //记录添加的节点数
while (node != nullptr) {
++count;
vec.emplace_back(node->val); //从右向左添加节点值
node = node->right; //遍历右子树
}
//反转刚才添加的节点,以恢复后序遍历的顺序
reverse(vec.end() - count, vec.end());
}
};