数据结构与算法——二叉树(三) 学习笔记

二、遍历

求解问题的策略:不要从轮子造起,要善于利用以前的成果。将二叉树这种半线性结构转化为此前研究有素的线性结构——遍历
遍历:按照事先约定的某种次序,对树中的每个节点都恰好访问一次
任何一个局部的子树,可分为树根V、左子树L和右子树R三部分。只要根节点的访问和左右子树的遍历次序能够明确确定,那么在整体上就必然导致一个明确的线性次序。
按惯例左兄弟L优先于右兄弟R,根据对根节点V在三者中的不同的遍历次序,可分为:先序遍历(preorder,VLR)中序遍历(inorder,LVR)后序遍历(postorder,LRV)

图取自清华大学《数据结构(C++语言版)》

在这里插入图片描述

2.1 递归版遍历

递归版便利思路简单明确,分为三部分:对节点访问,遍历左子树,遍历右子树。根据对节点访问的顺序不同,有以下三种递归遍历算法:

2.1.1 递归先序遍历

代码取自清华大学《数据结构(C++语言版)》

template <typename T, typename VST> //元素类型、操作器
void travPre_R(BinNodePosi(T) x, VST& visit) { //二叉树先序遍历算法(递归版)
	if (!x) return;
	visit(x->data);
	travPre_R(x->lc, visit);
	travPre_R(x->rc, visit);
}

2.1.2 递归中序遍历

代码取自清华大学《数据结构(C++语言版)》

template <typename T, typename VST> //元素类型、操作器
void travIn_R(BinNodePosi(T) x, VST& visit) { //二叉树中序遍历算法(递归版)
	if (!x) return;
	travIn_R(x->lc, visit);
	visit(x->data);
	travIn_R(x->rc, visit);
}

2.1.3 递归后序遍历

代码取自清华大学《数据结构(C++语言版)》

template <typename T, typename VST> //元素类型、操作器
void travPost_R(BinNodePosi(T) x, VST& visit) { //二叉树后序遍历算法(递归版)
	if (!x) return;
	travPost_R(x->lc, visit);
	travPost_R(x->rc, visit);
	visit(x->data);
}

2.2 迭代先序遍历

2.2.1 版本1

根据消除尾递归的一般性方法,将递归版改写为迭代版:由于尾递归并未在返回时执行其他的操作,所示实质上只是为了执行完尾递归语句前的操作后跳入下一实例。这样其实和每次迭代执行各实例没有区别,很容易修改为迭代版:
直截了当易理解版:尾部改为:x=x+1,进入下次迭代操作x
借助一个栈版本:起初将树根放入栈中。每次运行完后将下一实例存入栈中push(x->next),下次迭代开始时再出栈x=pop(),并执行操作。迭代结束判断依据:每一次迭代操作完成后栈是否为空

代码取自清华大学《数据结构(C++语言版)》

template <typename T, typename VST> //元素类型、操作器
void travPre_I1(BinNodePosi(T) x, VST& visit) { //二叉树先序遍历算法(迭代版#1)
	Stack<BinNodePosi(T)> S; //辅助栈
	if (x)
		S.push(x); //根节点入栈
	while (!S.empty()) //在栈变空之前反复循环
	{
		x = S.pop(); visit(x->data); //弹出并访问当前节点,其非空孩子的入栈次序为先右后左
		if (HasRChild(*x)) S.push(x->rc);
		if (HasLChild(*x)) S.push(x->lc);
	}
}

注意这里每个节点都是在被弹出栈的时刻才进行操作,由于左孩子L先右孩子R后,而栈是后入先出(LIFO)的,所以结尾左右两个孩子的入栈的顺序是反过来的!!!

2.2.2 版本2

版本1是通过将尾递归改为迭代的方式实现的。这种思路并不容易推广到非尾递归的场合,比如在中序或后序遍历中,至少有一个递归方向严格地不属于尾递归。因此采用以下这种更为通用的方法。

图摘自清华大学《数据结构(C++语言版)》

在这里插入图片描述
该迭代算法分为两个步骤:

  1. 自顶而下地依次访问左侧链(left branch)上的沿途节点,并将其右孩子入辅助栈S
  2. 行进至左侧链末尾后,自底而上地依次出栈并遍历这些节点上的每一棵右子树

代码摘自清华大学《数据结构(C++语言版)》

//从当前节点出发,沿左分支不断深入,直至没有左分支的节点;沿途节点遇到后立即访问
template <typename T, typename VST> //元素类型、操作器
static void visitAlongLeftBranch(BinNodePosi(T) x, VST& visit, Stack<BinNodePosi(T)>& S) {
	while (x) {
		visit(x->data); //访问当前节点
		S.push(x->rc); //右孩子入栈暂存(可优化:通过判断,避免空的右孩子入栈)
		x = x->lc;  //沿左分支深入一层
	}
}

template <typename T, typename VST> //元素类型、操作器
void travPre_I2(BinNodePosi(T) x, VST& visit) { //二叉树先序遍历算法(迭代版#2)
	Stack<BinNodePosi(T)> S; //辅助栈
	while (true) {
		visitAlongLeftBranch(x, visit, S); //从当前节点出发,逐批访问
		if (S.empty()) break; //直到栈空
		x = S.pop(); //弹出下一批的起点
	}
}

辅助函数:visitAlongLeftBranch(),从根节点起,沿着左侧链left branch,自顶而下访问最左侧通路沿途的各个节点,并将各个节点的右孩子入栈,逆序记录最左侧通路上的节点右孩子,以便确定其对应右子树自底而上的遍历次序。
主算法:建立辅助栈。反复调用visitAlongLeftBranch(),每步迭代都弹出当前栈顶,并以其为子树根节点,进入下一次迭代后继续调用visitAlongLeftBranch()。
注意主算法终止的位置:每次调用visitAlongLeftBranch()处理后,对栈是否变空进行判断,栈空则算法结束退出。

2.3 迭代中序遍历

2.3.1 版本1

依旧,将数的整体结构分为左侧链和左侧链上节点的右子树

图摘自清华大学《数据结构(C++语言版)》

在这里插入图片描述

分为若干个步骤:

  1. 沿着左侧链向下行进,并将沿途节点入栈(先不访问,只入栈)
  2. 行进至左侧链末尾后,弹出栈顶并进行访问
  3. 将控制权交给当前节点的右子树,下次迭代对右子树进行遍历。右子树用同样迭代过程遍历完毕后,控制权才会回到上一层左侧链的节点。

左侧链有多少个节点就分多少个阶段。每个阶段均为:访问左侧链节点、遍历右子树

注意,迭代中序遍历首先被访问的是左侧链的末端

代码摘自清华大学《数据结构(C++语言版)》

template <typename T> //从当前节点出发,沿左分支不断深入,直至没有左分支的节点
static void goAlongLeftBranch(BinNodePosi(T) x, Stack<BinNodePosi(T)>& S) {
	while (x) { S.push(x); x = x->lc; } //当前节点入栈后随即向左侧分支深入,迭代直到无左孩子
}

template <typename T, typename VST> //元素类型、操作器
void travIn_I1(BinNodePosi(T) x, VST& visit) { //二叉树中序遍历算法(迭代版#1)
	Stack<BinNodePosi(T)> S; //辅助栈
	while (true) {
		goAlongLeftBranch(x, S); //从当前节点出发,逐批入栈
		if (S.empty()) break; //直至所有节点处理完毕
		x = S.pop(); visit(x->data); //弹出栈顶节点并访问之
		x = x->rc; //转向右子树
	}
}

辅助函数:goAlongLeftBranch(),在当前的节点x沿着左侧链下行,并将左侧链上的节点入栈,沿左侧链深入至左侧链底端。(注意,这里辅助函数只将左侧链节点入栈,但不进行访问,因为是中序遍历,注意与前面先序遍历的辅助函数功能作对比)

主算法:建立辅助栈。调用goAlongLeftBranch()函数,从当前节点出发,将左侧链节点入栈。深入至左侧链底端后,将栈顶左侧链节点弹出并访问,然后将控制权交给其右孩子,下次迭代对其右子树进行又一次遍历
注意主算法终止的位置:每次调用goAlongLeftBranch()函数后,对栈是否为空进行判断,若栈为空则算法终止。

复杂度:分摊分析

从结构上来看,似乎是两个while循环嵌套,其中外循环总共需要执行O(n)步,因为需要对每个节点访问一次;而内循环最坏情况下也会达到O(n)(如左侧链长度达到了n/4,n/2等)。然而事实上,所有左侧链的累计长度也只是O(n),即内层循环(每个节点入栈至多一次,总共O(n))每一步的分摊复杂度只是O(1),所以算法的复杂度仍为O(n)

2.3.2 直接后继及其定位

与所有遍历一样,中序遍历的实质功能也可理解为,为所有节点赋予一个次序,从而将半线性的二叉树转化为线性结构。于是一旦指定了遍历策略,即可与向量和列表一样,在二叉树的节点之间定义前驱与后继关系。其中没有前驱(后继)的节点称作首(末)节点。

分两类情况:

  1. 若当前节点t有右孩子,则其直接后继s必是其右子树中的最小节点。此时只需转入右子树,并沿该子树的最左侧链朝下深入,直至右子树中最靠左(最小)的节点,便是其直接后继s

图摘自清华大学《数据结构(C++语言版)》慕课电子讲义

在这里插入图片描述

  1. 若当前节点t没有右子树,则若其后继存在,必为该节点的某个祖先,具体地说是在沿着parent指针向上的过程中第一次向右的祖先(即将当前节点纳入其左子树的最低祖先)。此时首先沿右侧通路(parent指针)不断朝左上方上升,当不能继续前进时,再朝右上方移动一步即为其后继s(可能是NULL)。

图摘自清华大学《数据结构(C++语言版)》慕课电子讲义

在这里插入图片描述

2.3.3 版本2

观察版本1其实可以看出来,辅助函数的运行条件和主函数其他部分的运行条件是完全互斥的,即若当前节点满足辅助函数的运行条件,则绝对不会立即对该节点进行后续的操作;同理,每次(碰到空孩子)时,辅助函数便不再运行,而是主算法中的其他操作开始运行。因此,可改写版本1至如下:

代码摘自清华大学《数据结构(C++语言版)》

template <typename T, typename VST> //元素类型、操作器
void travIn_I2(BinNodePosi(T) x, VST& visit) { //二叉树中序遍历算法(迭代版#2)
	Stack<BinNodePosi(T)> S; //辅助栈
	while (true)
		if (x)
		{
			S.push(x); //根节点进栈
			x = x->lc; //深入遍历左子树
		}
		else if (!S.empty())
		{
			x = S.pop(); //尚未访问的最低祖先节点退栈
			visit(x->data); //访问该祖先节点
			x = x->rc; //遍历祖先的右子树
		}
		else
			break; //遍历完成
}

版本2只不过是版本1的等价形式,但借助它可便捷地设计和实现以下版本3

2.3.4 版本3

以上的迭代式遍历算法都需使用辅助栈,尽管这对遍历算法的渐进时间复杂度没有实质影响,但所需辅助空间的规模将线性正比于二叉树的高度,在最坏情况下与节点总数相当

图摘自清华大学《数据结构(C++语言版)》

在这里插入图片描述
这里相当于将原辅助栈替换为一个标志位
backtrack。每当抵达一个节点,借助该标志即可判断此前是否刚做过一次自下而上的回溯。若不是,则按照中序遍历的策略优先遍历左子树。反之,若刚发生过一次回溯,则意味着当前节点的左子树已经遍历完毕(或等效地,左子树为空),于是便可访问当前节点,然后再深入其右子树继续遍历。
每个节点被访问之后,都应转向其在遍历序列中的直接后继。按照以上的分析,通过检查右子树是否为空,即可在两种情况间做出判断:该后继要么在当前节点的右子树(若该子树非空)中,要么(当右子树为空时)是其某一祖先。如图5.21所示,后一情
况即所谓的回溯。
注意,由succ()返回的直接后继可能是NULL,此时意味着已经遍历至中序遍历意义下的末节点,于是遍历完成。

代码摘自清华大学《数据结构(C++语言版)》

template <typename T, typename VST> //元素类型、操作器
void travIn_I3(BinNodePosi(T) x, VST& visit) { //二叉树中序遍历算法(迭代版#3,无需辅助栈)
	bool backtrack = false; //前一步是否刚从左子树回溯——省去栈,仅O(1)辅助空间
	while (true)
		if (!backtrack && HasLChild(*x)) //若有左子树且不是刚刚回溯,则
			x = x->lc; //深入遍历左子树
		else	//否则——无左子树或刚刚回溯(相当于无左子树)
		{
			visit(x->data); //访问该节点
			if (HasRChild(*x))	//若其右子树非空,则
			{
				x = x->rc; //深入右子树继续遍历
				backtrack = false; //并关闭回溯标志
			}
			else	//若右子树空,则
			{
				if (!(x = x->succ())) break; //回溯(含抵达末节点时的退出返回)
				backtrack = true; //并设置回溯标志
			}
		}
}

该版本无需使用任何结构,总体仅需O(1)的辅助空间,属于就地算法。当然,因需要反复调用succ(),时间效率有所倒退

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值