本篇文章采用 C++ 语言。
文章目录
1 二叉树的递归遍历算法
所谓二叉树的遍历,是指按某个既定的规则访问树中的每个结点,使得每个结点都只被访问一次。由二叉树的递归定义可以知道, 二叉树是由根结点、左子树和右子树三个基本单元组成的。如果限定了左子树的遍历在右子树之前,就能得到三种遍历情况,下面分别介绍这三种遍历方法。
在展开本节内容之前,先定义二叉树中的结点结构:
struct Node
{
int data; // 结点的数据
int layer; // 结点所在的层数
Node *lchild, *rchild; // lchild 指向做左孩子结点,rchild 指向右孩子结点
};
1.1 先序遍历的递归实现
若二叉树为空则什么也不做,否则:
- 访问根结点(此处的访问可以是任何操作)
- 先序递归遍历左子树
- 先序递归遍历右子树
void PreOrder(Node *root)
{
if (root == nullptr)
return;
Visit(root); // 访问根结点
Preorder(root->lchild); // 先序递归遍历左子树
Preorder(root->rchild); // 先序递归遍历右子树
}
1.2 中序遍历的递归实现
若二叉树为空则什么也不做,否则:
- 中递归遍历左子树
- 访问根结点(此处的访问可以是任何操作)
- 中序递归遍历右子树
void InOrder(Node *root)
{
if (root == nullptr)
return;
Inorder(root->lchild); // 中递归遍历左子树
Visit(root); // 访问根结点
Inorder(root->rchild); // 中序递归遍历右子树
}
1.3 后序遍历的递归实现
若二叉树为空则什么也不做,否则:
- 递归后序遍历左子树
- 递归后序遍历右子树
- 访问根结点(此处的访问可以是任何操作)
void InOrder(Node *root)
{
if (root == nullptr)
return;
InOrder(root->lchild); // 递归后序遍历左子树
Visit(root); // 访问根结点
InOrder(root->rchild); // 递归后序遍历右子树
}
由此也能看出这三种遍历算法的本质是一样的,不过是访问元素的顺序不同从而导致最后输出不同。但不管是那种遍历算法,每个结点都只访问一次,故时间复杂度是 O ( n ) O(n) O(n)。在递归遍历中,递归工作栈的栈深恰好为树的深度,所以在最坏情况下,如果二叉树是有 n 个结点且深度为 n 的单支树(即每一层只有一个结点),遍历算法的复杂度为 O ( n ) O(n) O(n)。
如图所示的二叉树表示下述表达式
a + b ∗ ( c − d ) − e / f a+b*(c - d)-e/f a+b∗(c−d)−e/f
如若对二叉树分别按三种方法遍历,便可得到三种表达式:
- 先序遍历
− + a ∗ b − c d / e f -+a*b-cd/ef −+a∗b−cd/ef
- 中序遍历:
a + b ∗ c − d − e / f a+b*c-d-e/f a+b∗c−d−e/f
- 后序遍历:
a b c d − ∗ + e f / − abcd-*+ef/- abcd−∗+ef/−
这三个表达式就是所谓的二叉树的前缀表达式(又名波兰式)、中缀表达式和后缀表达式(又名逆波兰式)。
2 二叉树的非递归遍历算法
2.1 中序遍历的非递归实现
算法主体通过一个循环来实现非递归的遍历。首先令结点 p 指向树的根结点,然后进入外循环。内循环检查结点 p 是否存在左孩子,如果存在就将左孩子入栈,然后令 p 指向其左孩子。重复以上步骤,直到 p 等于 nullptr(也就是说左孩子不存在)时,就从栈中弹出一个结点,令 p 指向它。访问结点 p,然后检查其是否有右孩子,有的话就将右孩子进栈,同时令 p 指向右孩子,随后进入下一次外循环。直到栈空结束外循环。
void InOrderNonrecur(Node *root)
{
stack<Node*> st; // 定义并初始化一个栈
Node *p = root;
while (!st.empty() || p != nullptr) // 栈不为空或 p 不为空时循环
{
while (p != nullptr) // 循环结束的条件是 p 指向了空结点,说明不存在左孩子
{
st.push(p); // 结点 p 入栈
p = p->lchild; // 令 p 指向左孩子
}
if (!st.empty()) // 如果栈不为空
{
p = st.top(); // 令 p 指向栈顶结点
st.pop(); // 弹出栈顶结点
Visit(p); // 访问结点 p
p = p->rchild; // 令 p 指向右孩子
st.push(p); // 结点 p 入栈
}
}
}
2.4 先序遍历的非递归实现
先序遍历与中序遍历的区别在于,先序遍历中是先访问根结点,再访问左右孩子结点。所以其非递归实现时,由于栈先进后出的特点,就需要先将右孩子入栈。
void PreOrderNonrecur(Node *root)
{
stack<Node*> st;
Node *p = root;
st.push(p);
while (!st.empty()) // 栈不空时循环
{
p = st.top(); // 令 p 指向栈顶结点
st.pop(); // 弹出栈顶结点
Visit(p); // 访问结点 p
if (p ->rchild != nullptr) // 如果右孩子存在,就将右孩子入栈
st.push(p->rchild);
if (p ->lchild != nullptr) // 如果左孩子存在,就将右孩子入栈
st.push(p->lchild);
}
}
2.3 后序遍历的非递归实现
后续遍历的特点在于,当结点的右孩子存在且被访问后,或者是右孩子为空时才能访问根结点。弹出某个结点时,必须分清上一个访问的结点是其左孩子还是右孩子。所以使用辅助指针 pre,其指向上一个访问过的结点。当然也可在结点中增加一个标志域,记录是否已被访问过。
void PostOrderNonrecur(Node *root)
{
stack<Node*> st;
Node *p = root, *pre = nullptr;
while (!st.empty() || p != nullptr)
{
if (p != nullptr) // 如果结点 p 不为空
{
st.push(p); // 结点 p 入栈
p = p->lchild; // 令 p 指向左孩子
}
else
{
p = st.top(); // 令 p 指向栈顶结点
if (p->rchild != nullptr && p->rchild != pre)
{ // 如果 p 的右孩子不为空且不是最近访问过的结点
p = p->rchild; // 令 p 指向右孩子
st.push(p); // 右孩子入栈
p = p->lchild; // 令 p 指向左孩子
}
else
{
st.pop(); // 弹出栈顶结点
Visit(p); // 访问结点 p
pre = p; // pre 指向最近访问过的结点,也就是 p
p = nullptr; // 结点访问完后,重置 p 指针,在下次循环时它就会在 else 语句中指向该结点的双亲结点
}
}
}
}
2.4层次遍历的非递归算法
如图所示,层次遍历即按照箭头所指方向,按照1,2,3,4的层次顺序,对二叉树中的各个结点进行访问。要进行层次遍历,需要借助一个队列。先将二叉树树根结点入队,然后出队,访问该结点,若它有左子树,则左子树入队;若它有右子树,则右子树入队。然后队首结点出队并访问,如此反复直到队列为空为止。层次遍历的算法比较简单,拿草稿纸动笔写写画画模拟一下就能懂,就不写太多注释了。
对应的遍历算法是:
void LayerOrder(Node *root)
{
queue<Node*> Q;
root->layer = 1; // 根结点的层数为1
Q.push(root); // 根结点入队
while (!Q.empty()) // 队不空时循环
{
Node *p = Q.front();
Q.pop(); // 队首结点出队
Visit(p); // 访问结点 p
if (p->lchild != nullptr)
{
p->lchild->layer = p->layer + 1; // 左孩子的层数为结点 p 的层数 + 1
Q.push(p->lchild); // 左孩子入队
}
if (p->rchild != nullptr)
{
p->rchild->layer = p->layer + 1; // 右孩子的层数为结点 p 的层数 + 1
Q.push(p->rchild); // 右孩子入队
}
}
}
线索二叉树及二叉树的线索化
基本概念
遍历二叉树是一个以一定规则将二叉树中结点排列成一个线性序列,得到二叉树中结点的各种遍历序列,其实质是对一个非线性结构进行线性化操作,从而使得每一个结点(除序首和序尾)都有且仅有一个直接前驱和直接后继。
传统的链式存储结构(即二叉链表)仅能体现出一种父子关系,即我们只能找到结点的左右孩子信息,而不能直接得到结点在任一序列中的前驱和后继信息,这种信息只有在遍历的动态过程中才能得到。
一个简单的方法是,利用二叉链表中存在的大量空指针,令它们指向其直接前驱或直接后继,这样就可以更方便地运用某些二叉树操作算法。引入线索二叉树的目的就是为了加快查找结点前驱和后继的速度。
在有n个结点的二叉树中,有
n
+
1
n + 1
n+1个空指针,这是因为每个叶结点有两个空指针,而每个度为1的结点有1个空指针,总的空指针数为
2
n
0
+
n
1
2n_0+n_1
2n0+n1,又有
n
0
=
n
2
+
1
n_0=n_2+1
n0=n2+1,所以总的空指针为
n
0
+
n
1
+
n
2
=
n
+
1
n_0+n_1+n_2=n+1
n0+n1+n2=n+1。
在构造线索二叉树时,要遵循这几个规则:
- 若无左子树,令lchild指向其前驱结点;
- 若无右子树,令rchild指向其后继结点;
- 增加两个标志域分别表明当前指针域所指对象是左(右)子结点还是指向直接前驱(后继)。
标志域为0是表示lchild域指向左(右)孩子,为1表示指向直接前驱(后继)。
线索二叉树的存储结构描述如下:
typedef struct ThreadBTNode
{
ElemType data; // 数据元素
struct ThreadNode *lchild; // 左、右孩子指针
struct ThreadNode *rchild;
int ltag, rtag;
} ThreadNode;
以这种结点结构构成的二叉链表作为二叉树的存储结构,叫做线索链表,其中指向结点前驱和后继的指针叫做线索。加上线索的二叉树称之为线索二叉树。对二叉树以某种次序遍历使其变为线索二叉树的过程称为线索化。
线索二叉树的构造(二叉树的线索化)
对二叉树的线索化,实质上就是遍历一次二叉树,只是在遍历的过程中,检查当前结点左、右指针域是否为空,若为空,将它们改为指向前驱结点或后继结点的线索。
1、通过中序遍历对二叉树线索化的递归算法如下:
void InThread(ThreadBTBode *p, ThreadBTNode *pre)
{
if (p != NULL)
{
InThread(p->lchild, pre); // 递归,线索化左子树
if (p->lchild == NULL) // 左子树为空,建立前驱线索
{
p->lchild = pre; // 指针域指向前驱
p->ltag = 1; // 标志域置1
}
// 在建立前驱线索时,即使前驱是NULL也可以使lchild指向它,所以不需要判空
// 但在建立后继线索时,如果前驱是NULL就会发生错误,所以需要先判空
// 之所以这样是因为遍历到某一结点时,它的后继结点是未知的
// 所以我们每次建立的后继线索是前驱结点的后继线索而不是当前结点的后继线索
if (pre != NULL && pre->rchild == NULL)
{ // 前驱不为空且右子树为空,建立前驱结点的后继线索
pre->rchild = p;
pre->rtag = 1;
}
pre = p; // 标记当前结点成为刚刚访问过的结点
InThread(p->rchild, pre); // 递归,线索化右子树
}
}
通过中序遍历建立中序线索二叉树的主过程算法如下:
void CreateInThread(ThreadBTNode *BT)
{
ThreadBTNode *pre = NULL;
if (BT != NULL) // 如果是非空二叉树就执行线索化
{
InThread(BT, pre); // 线索化
pre->rchild = NULL; // 处理遍历的最后一个结点
pre->rtag = 1; // 遍历的最后一个结点右子树一定为空,也即没有后继结点
}
}
2、理解了前序遍历对二叉树线索化的过程,通过前序遍历对二叉树线索化的递归算法也就很好理解了,算法如下:
void PreThread(ThreadBTNode *p, ThreadBTNode *pre)
{
if (p->lchild == NULL) // 左子树为空,建立前驱线索
{
p->lchild = pre; // 指针域指向前驱
p->ltag = 1; // 标志域置1
}
if (pre != NULL && pre->rchild == NULL)
{ // 前驱不为空且右子树为空,建立前驱结点的后继线索
pre->rchild = p;
pre->rtag = 1;
}
pre = p; // 标记当前结点成为刚刚访问过的结点
PreThread(p->lchild, pre); // 递归,线索化左子树
PreThread(p->rchild, pre); // 递归,线索化右子树
}
通过前序遍历建立前序线索二叉树的主过程算法如下:
void CreatePreThread(ThreadBTNode *BT)
{
ThreadBTNode pre = NULL;
if (T != NULL) // 如果是非空二叉树就执行线索化
{
PreThread(T, pre); // 线索化
pre->rchild = NULL; // 处理遍历的最后一个结点
pre->rtag = 1; // 遍历的最后一个结点右子树一定为空,也即没有后继结点
}
}
3、通过后序遍历对二叉树线索化的递归算法如下:
void PostThread(ThreadBTNode *p, ThreadBTNode *pre)
{
PostThread(p->lchild, pre); // 递归,线索化左子树
PostThread(p->rchild, pre); // 递归,线索化右子树
if (p->lchild == NULL) // 左子树为空,建立前驱线索
{
p->lchild = pre; // 指针域指向前驱
p->ltag = 1; // 标志域置1
}
if (pre != NULL && pre->rchild == NULL)
{ // 前驱不为空且右子树为空,建立前驱结点的后继线索
pre->rchild = p;
pre->rtag = 1;
}
pre = p; // 标记当前结点成为刚刚访问过的结点
}
通过前序遍历建立后序线索二叉树的主过程算法如下:
void CreatePostThread(ThreadBTNode T)
{
ThreadBTNode pre = NULL;
if (T != NULL) // 如果是非空二叉树就执行线索化
{
PostThread(T, pre); // 线索化
pre->rchild = NULL; // 处理遍历的最后一个结点
pre->rtag = 1; // 遍历的最后一个结点右子树一定为空,也即没有后继结点
}
}
要注意的是后序遍历建立线索二叉树后,得到的最后一个结点pre是根结点。
为了方便起见,仿照线性表的存储结构,在二叉树的线索链表上也添加一个头结点,并令其lchild域的指针指向二叉树的根结点,其rchild域的指针指向中序遍历时访问的最后一个结点;反之,令二叉树中序序列的第一个结点的lchild域指针和最后一个结点rchild域的指针均指向头结点。这好比为二叉树建立了一个双向线索链表,既可以从第一个结点起顺后继继续遍历,又可以从最后一个结点起顺前驱进行遍历。
由于篇幅过长,所以我将线索二叉树的遍历、以及前序线索二叉树中求前序前驱以及后续线索二叉树中求后序后继结点这两个问题放在了我的另一篇博客中:线索二叉树的遍历以及应用
先序遍历和中序遍历的联系
以下部分内容引用自博客 https://blog.csdn.net/u011240016/article/details/53055505。
还有一个小问题就是栈的亚特兰数与二叉树的先序中序遍历。因为二叉树的先序和中序遍历可以唯一的确定一棵树。根据二叉树先序遍历和中序遍历的递归算法中递归工作栈的状态变化得出:先序序列和中序序列的关系相当于以先序序列为入栈次序,以中序序列为出栈次数。所以对于n个不同的元素进栈,则出栈序列的情况有
1
n
+
1
C
2
n
n
\frac{1}{n+1}C^n_{2n}
n+11C2nn种。比如:
【2015年统考真题】先序序列为
a
,
b
,
c
,
d
a,b,c,d
a,b,c,d的不同二叉树的个数是(14)。
红色是容易漏掉的两种情况,需要特别注意。同时也要注意由于二叉树区分左右子树,所以只用先看一边的结果即可得到另一边的结果。
呼,终于总结完啦,还有不懂的地方欢迎在评论区留言哦~~~