【前言】
二叉树是树形数据结构的最基本形态,不仅作为考研数据结构的重点,不少企业的算法题也围绕此结构展开,所以充分理解二叉树的性质是很有必要的。而想要在二叉树结构上获取信息,我们必须熟练如何遍历二叉树,它是二叉树运算的核心。遍历二叉树的目的是:得到二叉树中所有结点的一个线性序列。本文会给出最常见的几种二叉树遍历方式的原理讲解和代码实现,希望对读者朋友们有所帮助。在此声明本文也是我通过学习左程云老师的算法课程所总结的的学习笔记,与左神的思路高度重合。话不多说,我们直入正题!
在讲述以下遍历二叉树的方式之前,我们先给出二叉树的递归定义,这是后文代码实现的依据。
//二叉树的递归定义
class TreeNode{
public:
int val;//结点的值
TreeNode *left;//左孩子结点
TreeNode *right;//右孩子结点
TreeNode():val(0),left(nullptr),right(nullptr){}//默认构造函数
TreeNode(int x):val(x),left(nullptr),right(nullptr){}//有参构造函数1
TreeNode(int x,TreeNode *left,TreeNode *right):val(x),left(left),right(right){}//有参构造函数2
};
【二叉树的递归遍历-深度优先搜索】
【二叉树的递归序列】
结合上面的递归定义来看,遍历二叉树的核心点在于:从每个结点出发,依次访问二叉树中三个“组成部分”的信息。即根结点,左子树和右子树。由于对每个结点都是同样的操作,我们很容易想到用递归来实现。我们看下面图例:
如图给出了一棵高度为3的满二叉树。我们规定先遍历左子树,再遍历右子树,遇到空结点就返回。当根结点不为空时递归执行,每当来到某个结点我们便进行记录。我们便会得到如下的一个线性序列:1 2 4 4 4 2 5 5 5 1 3 6 6 6 3 7 7 7 3 1
我们将这个线性序列称为二叉树的递归序列。不难看出,在这个递归序列中,每一个结点都会出现3次。观察整个遍历过程,有一种“一条道走到黑,撞南墙才回头”的感觉,这就是深度优先搜索(DFS)的思想。实际上,二叉树的先序遍历序列,中序遍历序列,后序遍历序列都是由二叉树的递归序列产生的!
【二叉树的先序遍历】
在介绍先序遍历之前,先引入一道leetcode题目:
先序遍历,又称先根遍历,它的遍历过程是:
①若二叉树为空,则返回
②访问根结点
③先序遍历左子树
④先序遍历右子树
我们结合下面图例来看:
我们先访问根结点1本身。之后开始遍历1的左子树(由2,4,5构成,我们记这颗左子树为A(图中红色虚线圈圈起来的位置))显然,A树的“根结点”是2,我们访问结点2本身。之后开始遍历2的左子树(由4,5构成,我们记这棵左子树为B(图中红色实线圈圈起来的位置))显然,B树的“根结点”是4,我们访问结点4本身。之后我们开始遍历4的左子树,发现左子树为空!于是我们转向遍历4的右子树,访问结点5本身。不难发现结点5已经是叶子结点,左右子树均为空。此时我们开始逐级返回。到结点2,发现结点2的右子树也为空,于是开始遍历根结点1的右子树。右半边与之同理,篇幅有限不再赘述。由此我们便能得到一个序列:1 2 4 5 3 6 8 9 7
我们称这个序列为二叉树的先序序列。
结合上文的介绍,我们可以写出该二叉树的递归序列为:
1 2 4 4 5 5 5 4 2 2 1 3 6 8 8 8 6 9 9 9 6 3 7 7 7 3 1
仔细观察我们不难发现,在递归序列中我们取第一次出现的元素,便得到该二叉树的先序序列。
【代码实现与注释解析】
//二叉树的先序遍历(递归实现)
void Preorder(TreeNode *root,vector<int>& res){
if(root==nullptr){
return ;
}
res.push_back(root->val);//先处理根结点
Preorder(root->left,res);//再遍历左子树
Preorder(root->right,res);//最后遍历右子树
}
vector<int> PreorderTraversal(TreeNode *root){
vector<int> res;
Preorder(root,res);
return res;
}
注:所有递归方法都可以通过迭代的方式来实现!二叉树的遍历也是如此。递归的本质其实是利用了系统给我们提供的函数调用栈,由操作系统自动为我们执行压栈。而迭代方式需要我们手动压栈。在这里引出一个概念:所有的树都是可以被“边界”分解的。
如图,红色线画出的就是这棵二叉树的“左边界”。
【二叉树的中序遍历】
在介绍中序遍历之前,先引入一道leetcode题目
中序遍历,又称中根遍历,它的遍历过程是:
①若二叉树为空,则返回
②中序遍历左子树
③访问根结点
④中序遍历右子树
我们结合下面图例来看:
我们从根结点1出发。先遍历1的左子树(即图中A部分),来到结点2;再遍历结点2的左子树(即图中B部分);来到结点4,遍历4的左子树,发现4的左子树为空,于是访问结点4本身,再遍历4的右子树,来到结点5;结点5左子树为空,于是访问结点5本身,右子树也为空,于是返回到结点2。由于结点2的左子树已经遍历完毕,于是我们访问结点2本身。结点2的右子树为空,我们返回结点1。由于结点1的左子树已经遍历完毕,于是我们访问结点1本身。接下来我们开始遍历结点1的右子树。右半边与之同理,篇幅有限不再赘述。由此我们便能得到一个序列:4 5 2 1 8 6 9 3 7
我们称这个序列为二叉树的中序序列。
结合上文的介绍,我们可以写出该二叉树的递归序列为:
1 2 4 4 5 5 5 4 2 2 1 3 6 8 8 8 6 9 9 9 6 3 7 7 7 3 1
仔细观察我们不难发现,在递归序列中我们取第二次出现的元素,便得到该二叉树的中序序列。
【代码实现与注释解析】
//二叉树的中序遍历(递归实现)
void Midorder(TreeNode *root,vector<int>& res){
if(root==nullptr){
return ;
}
Midorder(root->left,res);//先遍历左子树
res.push_back(root->val);//再处理根结点
Midorder(root->right,res);//最后遍历右子树
}
vector<int> MidorderTraversal(TreeNode *root){
vector<int> res;
Midorder(root,res);
return res;
}
由前文介绍我们知道:二叉树是可以被其“左边界”分解的。我们结合下面图例来看:
首先,中序遍历的遍历顺序是“左根右”。对于每棵子树,我们将整棵子树的“左边界”全部压栈,在弹出结点时访问,对弹出结点的右子树重复该过程。也就是说,只要发现有“左边界”,就一股脑地全部压栈;弹出结点时,如果发现结点有右子树,再将右子树的“左边界”一股脑地压栈,重复这个过程。对于上图的例子:我们先将结点1,2,4压栈,弹出结点4并访问,发现结点4的左右子树均为空。弹出结点2并访问,此时发现结点2有右子树,于是将结点2右子树的“左边界”,也就是结点5,8,9压栈。结点5,8,9均无右子树,故依次弹出并访问结点9,8,5。右半边与之同理,我们便可以得到序列:4 2 9 8 5 1 6 11 10 3 7 正是这棵二叉树的中序序列!
//二叉树的中序遍历(非递归实现)
vector<int> Midorder1(TreeNode *root){
vector<int> res;
stack<TreeNode*> stk;
TreeNode* cur=root;
while(cur!=nullptr||!stk.empty()){
while(cur!=nullptr){
stk.push(cur);
cur=cur->left;//将左子树全部压栈
}
//当左子树为空时,开始出栈,并检查是否有右子树
TreeNode* node=stk.top();
stk.pop();
res.push_back(node->val);
cur=cur->right;
}
return res;
}
【二叉树的后序遍历】
在介绍后序遍历之前,先引入一道leetcode题目
后序遍历,又称后根遍历,它的遍历过程是:
①若二叉树为空,则返回
②后序遍历左子树
③后序遍历右子树
④访问根结点
我们结合下面图例来看:
我们从根结点1出发。先遍历1的左子树(即图中A部分),来到结点2;再遍历结点2的左子树(即图中B部分);来到结点4,遍历4的左子树,发现4的左子树为空,我们便开始遍历4的右子树;来到结点5,发现结点5的左右子树都为空,则访问结点5本身后返回。回到结点4,发现结点4的左右子树均遍历完毕,访问结点4本身后返回到结点2;遍历结点2的右子树,发现其右子树为空,于是访问结点2本身后返回到根结点1。之后我们开始遍历根结点1的右子树。右半边与之同理,篇幅有限不再赘述。由此我们便能得到一个序列:5 4 2 8 9 6 7 3 1
我们称这个序列为二叉树的后序序列。
结合上文的介绍,我们可以写出该二叉树的递归序列为:
1 2 4 4 5 5 5 4 2 2 1 3 6 8 8 8 6 9 9 9 6 3 7 7 7 3 1
仔细观察我们不难发现,在递归序列中我们取最后一次出现的元素,便得到该二叉树的后序序列。
【代码实现与注释解析】
//二叉树的后序遍历(递归实现)
void Postorder(TreeNode *root,vector<int>& res){
if(root==nullptr){
return ;
}
Postorder(root->left,res);//先遍历左子树
Postorder(root->right,res);//再遍历右子树
res.push_back(root->val);//最后处理根结点
}
vector<int> PostorderTraversal(TreeNode *root){
vector<int> res;
Postorder(root,res);
return res;
}
【二叉树的层次遍历-广度优先搜索】
在介绍层次遍历之前,先引入一道leetcode题目
由题意,我们需要逐层遍历二叉树,分别打印二叉树每一层(高度相同)的内容返回一个二维数组,这便是二叉树的层次遍历。层次遍历在更多情况下会使用到广度优先搜索(BFS)的思想。我们需要在一次执行过程中,输出若干个结点,而且要保持先后顺序。故考虑到借助队列来实现。它的遍历过程是:
①若二叉树为空,则返回
②将根结点入队
③队不为空则循环执行以下步骤:
(1)从队列中弹出一个结点,并访问
(2)若它有左孩子结点,则左孩子结点入队
(3)若它有右孩子结点,则右孩子结点入队
我们来看下面的图例:
假设我们遍历到了第二层(用curlevel来标记当前层),结点2和结点3在队列中(红色标记的结点我们视为暂未入队)队列大小为2。此时结点2,检查它的左右孩子,于是结点4,结点5依次入队;结点3出队,于是结点6,结点7依次入队。后面的过程同理。由此我们便能实现按层遍历二叉树。
【代码实现与注释解析】
//二叉树的层次遍历
vector<vector<int>> levelorder(TreeNode *root){
vector<vector<int>> res;
if(root==nullptr){
return ;
}
queue<TreeNode*> q;//用队列实现BFS
q.push(root);
while(!q.empty()){
vector<int> curlevel;//记录每一层的结点
for(int i=q.size();i>0;i--){
TreeNode *curNode=q.front();//记录队头元素
q.pop();//队头出队
curlevel.push_back(curNode->val);//存储出队元素的值
if(curNode->left!=nullptr){//先遍历左子树
q.push(curNode->left);
}
if(curNode->right!=nullptr){//再遍历右子树
q.push(curNode->right);
}
}
res.push_back(curlevel);
}
return res;
}
【二叉树的Morris遍历-二叉树的线索化】
【思路与图例讲解】
在前文所介绍的二叉树的递归遍历和层次遍历中,我们都需要用到额外的辅助空间来实现这个过程。如递归遍历中我们用到了栈结构,层次遍历中我们用到了队列结构。那么,我们如何能够在保持O(n)的时间复杂度的前提下,以空间复杂度O(1)的方式来实现二叉树的遍历呢?于是便引出了二叉树的Morris遍历。该算法是利用二叉树叶子结点的空闲指针,将二叉树线索化而实现的。也就是说,Morris遍历会暂时改变二叉树的结构。它的遍历过程是:
①假设来到当前结点cur(cur初始为根结点)
②若cur没有左孩子结点,cur向右移动(cur=cur->right)
③若cur有左孩子结点,找到左子树上最右的结点(MostRight)
(1)若MostRight的右指针指向空,将其指向cur(建立线索),再将cur向左移动(cur=cur->left)
(2)若MostRight的右指针指向cur,将其指向空(撤销线索),再将cur向右移动(cur=cur->right)
④cur为空时结束遍历
我们结合下面的图例来看,我们将cur到过的结点进行存储:
如图所示,cur首先来到根结点1。显然,根结点1的左孩子为结点2,所以我们找到根结点1左子树上最右边的结点,即结点5,记作MostRight。由于结点5的右指针指向空,我们便让这个右指针指向cur(即结点1),之后cur向左1移动,来到结点2;
如图所示,cur来到了结点2。显然,结点2的左孩子为结点4,所以我们找到结点2左子树上最右边的结点,即结点4本身,记作MostRight。由于结点4的右指针指向空,我们便让这个右指针指向cur(即结点4),之后cur向左移动,到结点4。又因为结点4没有左孩子,此时cur向右移动,回到结点2;
如图所示,cur又回到了结点2。显然结点2的左孩子为结点4,所以我们找到结点2左子树上最右边的结点,即结点4本身,记作MostRight,到这里看起来与上一步是完全一样的。但是此时结点4的右指针并非指向空,而是指向结点2。所以此时我们将结点4的右指针指回空(保持二叉树树的结构),同时将cur右移,cur来到结点5。后面的过程与之同理,篇幅有限将不再赘述。前文提到我们将cur到达过的结点进行标记存储,由此我们便可以得到这样一个序列:
1 2 4 2 5 1 3 6 3 7
我们将这个序列称为二叉树的Morris序列,结合上面的图例我们发现,如果一个结点有左子树,则它在Morris序列中可以出现两次,反之则只出现一次。与二叉树的递归遍历同理,我们依旧能够由二叉树的Morris序列推理出二叉树的先序,中序,后序序列,方法如下:
由Morris序列产生先序序列:
①如果该结点在Morris序列中只出现了一次,则直接取;
②如果该结点在Morris序列中出现了两次,则取第一次的。
由Morris序列产生中序序列:
①如果该结点在Morris序列中只出现了一次,则直接取;
②如果该结点在Morris序列中出现了两次,则取第二次的。
(难点)由Morris序列产生后序序列:
①如果该结点在Morris序列中只出现了一次,则什么也不做;
②如果该结点在Morris序列中出现了两次,对于第二次出现的,逆序访问其左子树的右边界。
③最后逆序访问整棵树的右边界。
我们结合下面实例来看后序遍历:
由上图,我们已经将这棵二叉树按“右边界”进行分解。结合之前的讲解,我们知道该二叉树的Morris序列为1 2 4 2 5 1 3 6 3 7 ,在这些节点中,4,5,6,7均只出现了一次,我们对此先不做任何处理。在其他出现了两次的结点中,我们观察第二次出现的结点。首先是结点2,于是我们逆序访问其左子树的右边界,即结点4;之后是结点1,我们逆序访问其左子树的有边界,即结点5,结点2;最后是结点3,我们重复上面的操作,即访问结点6,我们目前得到的序列为4 5 2 6。剩下的结点在Morris序列均只出现了一次,且正好为整棵二叉树的“右边界”。所以我们逆序访问整棵树的右边界。最终得到序列4 5 2 6 7 3 1,正是二叉树的后序序列。
那么,最后一个问题来了,我们如何逆序访问这个“右边界”呢?联想单链表的逆序操作。我们只需将每个结点的右指针指向其父结点即可,也可以利用栈。下面用代码实现采用Morris方法实现后序遍历,先序和中序同理,此处将不再展示。
【代码实现与注释解析】
//二叉树的Morris遍历(后序)
vector<int> MorrisPostorder(TreeNode *root){
vector<int> res;
TreeNode *cur=root;
TreeNode *MostRight=nullptr;
//利用栈来实现逆序访问
stack<TreeNode*> stk;
while (cur !=nullptr) {
MostRight = cur->left;
//找到左子树的最右结点
if (MostRight !=nullptr) {
while (MostRight->right !=nullptr && MostRight->right != cur) {
MostRight = MostRight->right;
}
if (MostRight->right ==nullptr) {//如果右指针为空,则建立线索
MostRight->right = cur;
stk.push(cur);
cur = cur->left;
continue;
} else {
MostRight->right =nullptr;//如果右指针不为空,则撤销线索
while (!stk.empty()) {
TreeNode *node = stk.top();
stk.pop();
res.push_back(node->val);
}
stk.push(cur);
}
}
cur = cur->right;//如果没有左孩子,则cur向右移动
}
return res;
}
以上便是二叉树常见的遍历方法。其中融合了广度优先搜索,深度优先搜索等重要思想,以及栈,队列等数据结构的使用。我是小高,一名非科班转码的大二学生,水平有限认知浅薄,有不当之处期待批评指正,我们一起成长!