简介
二叉树是算法与数据结构中的重要一环,本文介绍二叉树的相关知识,包括二叉树的构建、二叉树的遍历等。二叉树的存储可以采用链式存储或顺序存储。在链式存储中,使用结构体表示;在顺序存储中,使用数组表示。
0. 结构体定义
struct TreeNode
{
int val; // 值
TreeNode *left; // 左孩子
TreeNode *right; // 右孩子
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} // 初始化列表
};
根据上述结构体,使用初始化列表初始化一个二叉树节点:
TreeNode* node = new TreeNode(-1);
1. 二叉树的构建和遍历
在算法与数据结构中,针对二叉树的操作分为构建和遍历。在介绍构造之前,首先介绍二叉树的三种基本遍历方式。对于一个只包含三个节点的二叉树来说:
在上图中,节点 A 称为节点 B 和节点 C 的父节点,节点 B 称为节点 A 的左孩子节点,节点 C 称为节点 A 的右孩子节点。二叉树的三种基本遍历方式由父节点与其子节点的遍历顺序决定:ABC 的遍历顺序为先序遍历,BAC 为中序遍历,BCA 为后序遍历。
上面以三个节点为例说明了二叉树的遍历方式,扩展到多个节点时类似:
先序遍历为 324516,中序遍历为 425316,后序遍历为 452613。二叉树的三种遍历可以以递归或迭代的方式完成:
// 二叉树的先序遍历
void preVisitBiTree(TreeNode* root)
{
if (root == nullptr)
{
return;
}
visit(root);
preVisitBiTree(root->left);
preVisitBiTree(root->right);
}
// 二叉树的中序遍历
void inVisitBiTree(TreeNode* root)
{
if (root == nullptr)
{
return;
}
inVisitBiTree(root->left);
visit(root);
inVisitBiTree(root->right);
}
// 二叉树的后序遍历
void postVisitBiTree(TreeNode* root)
{
if (root == nullptr)
{
return;
}
postVisitBiTree(root->left);
postVisitBiTree(root->right);
visit(root);
}
三种递归遍历对应了三种迭代遍历的方式,使用栈来模拟递归即可:
// 二叉树的先序遍历
void preVisitBiTree(TreeNode* root)
{
if (root == nullptr)
{
return;
}
stack<TreeNode*> stk;
stk.push(root);
while (!stk.empty())
{
TreeNode* t = stk.top();
stk.pop();
visit(t);
if (root->right != nullptr)
{
stk.push(root->right);
}
if (root->left != nullptr)
{
stk.push(root->left);
}
}
}
// 二叉树的中序遍历
void inVisitBiTree(TreeNode* root)
{
if (root == nullptr)
{
return;
}
stack<TreeNode*> stk;
while (!stk.empty() || root != nullptr)
{
while (root != nullptr)
{
stk.push(root);
root = root->left;
}
if (!stk.empty())
{
root = stk.top();
stk.pop();
visit(root);
root = root->right;
}
}
}
// 二叉树的后序遍历
void postVisitBiTree(TreeNode* root)
{
if (root == nullptr)
{
return;
}
stack<TreeNode*> stk1;
stack<TreeNode*> stk2;
stk1.push(root);
while (!stk1.empty())
{
TreeNode* t = stk1.top();
stk1.pop();
stk2.push(t);
if (t->left != nullptr)
{
stk1.push(t->left);
}
if (t->right != nullptr)
{
stk2.push(t->right);
}
}
while (!stk2.empty())
{
TreeNode* t = stk2.top();
stk2.pop();
visit(t);
}
}
在二叉树的迭代遍历中,后序遍历为交换先序遍历左右孩子的入栈顺序并逆序。
同样地,二叉树的构建也有对应以上三种遍历遍历方式的构建构建顺序。以先序构建为例(以 -1 表示空节点):
void createBiTree(TreeNode*& root)
{
int num;
cin >> num;
if (num == -1)
{
root = nullptr;
}
else
{
root = new TreeNode(ch);
createBiTree(root->left);
createBiTree(root->right);
}
}
在访问二叉树的层级属性时可借助队列实现二叉树的层序遍历:
void levelVisitBiTree(TreeNode* root)
{
if (root == nullptr)
{
return;
}
queue<TreeNode*> q;
q.push(root);
while (!q.empty())
{
TreeNode* t = q.front();
q.pop();
visit(t);
if (t->left != nullptr)
{
q.push(t->left);
}
if (t->right != nullptr)
{
q.push(t->right);
}
}
}
3. 二叉树的高级操作
3.1 二叉树的递归
在二叉树的操作中,基本上是基于递归实现。二叉树的递归分为三大步骤:寻找递归出口,确定返回值和递归遍历左右子树。其中,二叉树遍历递归出口一般为空节点或叶节点;通常根据题目要求确定当前递归的返回值;采用先序遍历、中序遍历或后序遍历递归遍历左右子树。
3.2 二叉树的深度
题目来源:二叉树的最大深度
二叉树的最大深度指二叉树的根节点到最远叶节点路径上的节点数。当遍历到空节点时返回零,表示当前节点的深度为零;题目求的是二叉树的最大深度,所以当前递归应该返回最大深度。对于当前层递归来说,当前节点对应的最大深度等于左右子树的最大深度加上根节点;三种遍历方式均可。
int maxDepth(TreeNode* root)
{
// 递归出口
if (root == nullptr)
{
return 0;
}
// 递归求左右子树的最大深度
int depth_l = maxDepth(root->left);
int depth_r = maxDepth(root->right);
// 返回当前节点对应的最大深度等于左右子树的最大深度加上根节点
return max(l, r) + 1;
}
题目来源:二叉树的最小深度
二叉树的最小深度指二叉树的根节点到最近叶节点路径上的节点数。当遍历到空节点时返回零,表示当前节点的深度为零;题目求的是二叉树的最小深度,所以当前递归应该返回最小深度。与求最大深度不同,由于题目求的是到叶子节点的距离,如果当前节点的某一子树的深度不为零(该节点不是叶子节点),则最小深度由不为空的子树决定(叶节点在该子树上)。对应地递归条件加上叶子节点返回一的情况。当前节点的左右子树均不为空时,返回最小子树深度加上根节点;三种遍历方式均可。
所以该题的关键是找到递归结束的条件:空节点,叶子节点,当前节点的某一子树为空。
int minDepth(TreeNode* root)
{
if (root == nullptr)
{
return 0;
}
// 叶子节点
if (root->left == nullptr && root->right == nullptr)
{
return 1;
}
// 递归求左右子树的最小深度
int depth_l = minDepth(root->left);
int depth_r = minDepth(root->right);
// 如果当前节点的某一子树为空
if (depth_l == 0 || depth_r == 0)
{
return depth_l + depth_r + 1;
}
// 返回当前节点对应的最小深度等于左右子树的最小深度加上根节点
return min(depth_l, depth_r) + 1;
}
3.3 二叉树的路径
二叉树的路径问题分为三大类:根节点开始到叶子节点的路径,这也是最简单的路径,即直接在叶子节点开始判断是否结束递归;根节点开始到非叶子节点的路径,在中间节点判断是否满足递归结束条件;任意节点开始到任意节点结束的路径,枚举所有起点和终点,有时候可能会用到双递归。
题目来源:二叉树的所有路径
题目求从根节点开始到叶子节点的所有路径,返回一个一维数组,路径上的节点用右箭头拼接起来形成一个字符串。根据上一节,递归出口有两类:遇到空节点时直接返回;遇到叶子节点时添加结果;由于直接在变量中添加结果,所以不需要在当前递归中返回结果;三种遍历方式均可。
在二叉树的递归中,有时候需要定义辅助函数来协助结果的获取,主要分为两种情况:包含临时变量(如本题中的临时路径)和同时处理二叉树的左右子树。
void helper(TreeNode* root, string path, vector<string>& ans)
{
// 递归出口,遇到空节点
if (root == nullptr)
{
return;
}
// 添加当前节点到路径
path = path + to_string(root->val);
// 递归出口,遇到叶子节点
if (root->left == nullptr && root->right == nullptr)
{
ans.push_back(path);
return;
}
// 添加连接节点的箭头
path = path + "->";
// 递归地处理左右子树
helper(root->left, path, ans);
helper(root->right, path, ans);
}
vector<string> binaryTreePaths(TreeNode* root)
{
// 存放结果的变量
vector<string> ans;
helper(root, "", ans);
return ans;
}
题目来源:二叉树中和为某一值的路径
在上一题目的基础上,本题求路径上节点值得和为给定值的路径,返回一个二维数组表示各路径上的节点。处理方法和上一题非常类似,在递归过程中实时维护目标值即可。如果到达叶子节点时维护的目标值为零,则找到一条可行的路径。
void helper(TreeNode* root, int& target, vector<int>& path, vector<vector<int>>& path)
{
// 递归出口,遇到空节点
if (root == nullptr)
{
return;
}
// 添加当前节点到路径
path.push_back(root->val);
// 实时维护目标值
target = target - root->val;
// 到达叶子节点时的目标值为零
if (target == 0 && root->left == nullptr && root->right == nullptr)
{
ans.push_back(path);
}
// 递归地处理左右子树
helper(root->left, target, path, ans);
helper(root->right, target, path, ans);
// 恢复相关值
target = target + root->val;
path.pop_back();
}
vector<vector<int>> pathSum(TreeNode* root, int target)
{
vector<vector<int>> ans;
vector<int> path;
helper(root, target, path, ans);
return ans;
}
4. 总结
二叉树的大多数算法都是以二叉树的遍历为基础的。由于二叉树的不同遍历方式具有不同的特点,往往可以应用到不同场景。二叉树的先序、中序、后序遍历的顺序是从上到下,再从下到上反复此过程直至结点访问完成,采用非递归实现是需要借助栈。主要应用场景为求二叉树的深度、打印路径等。二叉树的层次遍历以二叉树的每层为单位进行遍历,实现需要借助队列。主要应用场景为求二叉树每层的结点数、二叉树的宽度等。