二叉树的三种迭代遍历(无栈版本)-- 我在马克思主义课上的一些巧思

前情提要

事情是这样的:本人近日跟着邓俊辉的网课学习数据结构(不知道有没有听过的,讲得真的很好),在学习到二叉树的三种遍历,也就是先序、中序、后序这三种时,对课堂上提供的算法有些不满:三种算法全都采用引入一个辅助栈的方法来确定节点的遍历顺序。

这样的算法在我的眼中,不够优雅。老师在二叉树遍历的第一节课就说过,随着二叉树的规模、深度增大,递归遍历版本会出现"函数调用栈溢出"的问题。现在优化为了迭代版本,却依然没有解决这个问题。

于是我就带着这个问题,走向了马克思主义的教室(本人开学以来第一次见到这老师,其他时间都是在寝室自学,今天听说有期中考试才来的)。课上实在无聊,我口袋里又正好有一张草稿纸。闲来无事,在上面画了一棵树。

历史上也不乏这样的时刻--灵感总是在不经意间涌现。我把目光从整体的递归,或者说迭代策略,转向了局部,节点与节点之间的逻辑联系。于是发现了一种基于父指针的,不需要辅助栈的迭代遍历策略。这样的发现不能说不令人欣喜,于是写下我的第一篇博客,以作纪念。

算法思路与实现

我的算法基于父指针来维系节点之间的逻辑联系。这大概是其唯一的缺点。但是,耗费与栈相当的空间(对于已经提供父指针的情景,额外的空间是O(1)),不仅能够解决栈溢出的问题,而且在形式上,三种遍历算法高度统一,我自认为算是个优雅且优秀的算法。

先序遍历:V->L->R

先从先序遍历开始吧,我们有一棵二叉树,如图:

思路是这样的,从根节点开始。在任意时刻,我们先遍历当前节点,然后面临的无非这几种情况:

复制代码
当前节点有左孩子,那就继续去遍历左孩子。
当前节点没有左孩子(左孩子为空),但是有右孩子,那就去遍历右孩子。
当前节点既没有左孩子,也没有右孩子(叶子节点),那就要回溯了。

回溯到什么时候呢?回溯时也无非两种情况,我们不妨讨论一下,使得你的思路更加清晰:

一、回溯时,自己是父节点的"非独生"左孩子。这很好理解,也就是这个父节点的左子树遍历完了嘛,那就接着去遍历他的右子树

二、回溯时,自己是父节点的"独生"左孩子,或是右孩子。也很好理解吧,那这个父节点的左右子树就全部遍历完了!我们就跳过这个父节点,继续向上回溯,直到它是父节点的"非独生"左孩子。

现在我们已经讨论完了遍历中所有的情况,那循环什么时候退出呢?答案是"根节点的左右子树被遍历完"。也就是说,当前节点重新回到根节点(父节点为空)时,退出循环,遍历结束。

下面给出代码(自认为完美,欢迎大佬来找bug):

scss 复制代码
template<typename T, typename VST>
void traverse(bin_node<T>* node, VST& visit)
{
	if (node == nullptr) return;//边界情况考虑

	while (true)
	{
		visit(node);//先序遍历
		if (node->get_left_child() != nullptr) node = node->get_left_child();//如果左孩子存在,则继续深入
		else if (node->get_right_child() != nullptr) node = node->get_right_child();//如果没有左孩子,但是有右孩子,则转向右孩子

		//如果为叶节点,开始回溯
		else
		{
			while (true)
			{
				if (node->get_parent() == nullptr)break;//如果追溯到根节点,说明整棵树遍历完了,退出循环
				if (node == node->get_parent()->get_left_child())//如果是父节点的左孩子,讨论是否"独生"
				{
					if (node->get_parent()->get_right_child() == nullptr);//如果独生,那就继续回溯
					else break;//如果非独生,就退出循环,去遍历父节点的右子树
				}
				else;//如果是父节点的右孩子,继续回溯

				node = node->get_parent();
			}
			if (node->get_parent() == nullptr) return;//如果追溯到根节点的上方,说明已经遍历完了
			node = node->get_parent()->get_right_child();//如果不是根节点,转向右孩子
		}
	}
}

中序遍历

思路是一样的,只不过这里要等到节点的左子树遍历完再遍历该节点。

我们也分情况讨论。从根节点开始。在任意时刻,我们先不遍历当前节点,面临的无非这几种情况:

复制代码
当前节点有左孩子,那就转向左孩子
当前节点没有左孩子,但是有右孩子。那就到了这个节点被遍历的时候了。它被遍历之后,要转向他的右孩子。
当前节点为叶节点,那就开始回溯

回溯的时候呢,也无非下面几种情况:

一、当前节点为左孩子,那就说明他的父节点的左子树已经遍历完了,遍历他的父节点。如果这个父节点有右孩子呢,就去遍历,如果没有那就继续向上回溯。

二、当前节点为右孩子,那说明它的父节点是不用遍历的,向上回溯。

(写到这里,不得不感叹一句:这写文章和在脑子里想真不是一回事啊。脑子里的思路透明如水晶,一到想要写下来时却找不着东南西北。)

scss 复制代码
template<typename T, typename VST>
void my_traverse_middle(bin_node<T>* node, VST& visit)
{
	if (node == nullptr) return;//边界情况

	while (true)
	{
		if (node->get_left_child() != nullptr) node = node->get_left_child();//如果左孩子存在,继续深入
		else//如果没有左孩子,那就遍历当前节点
		{
			visit(node);
			if (node->get_right_child() != nullptr) node = node->get_right_child();//如果有右孩子,那就转向右孩子

			else//没有的话,也就是叶节点,向上回溯,直到当前节点为"非独生"左孩子
			{
				while (true)
				{
					if (node->get_parent() == nullptr)break;//如果追溯到根节点,说明整棵树遍历完了,退出循环
					if (node == node->get_parent()->get_left_child())//如果是父节点的左孩子,就遍历父节点,再讨论是否"独生"
					{
						visit(node->get_parent());
						if (node->get_parent()->get_right_child() != nullptr) break;//如果非独生,就去遍历父节点的右子树
						else;//如果独生,继续回溯
					}
					else;//如果是父节点的右孩子,继续回溯

					node = node->get_parent();
				}
				if (node->get_parent() == nullptr) return;//如果追溯到根节点的上方,说明遍历完了
				node = node->get_parent()->get_right_child();
			}
		}
	}
}

后序遍历

对于后序遍历,我们理解这样一个事实:对于任意一棵(子)树,我们总是先去遍历最左边的叶子

从根节点开始,我们向下遍历,无非这几种情况:

复制代码
当前节点有左孩子,那就继续向左
如果当前孩子没有左孩子,那就尝试向右
如果当前节点为叶子节点,那就遍历,并开始回溯

我们接着讨论回溯时的情况

一、当前孩子为"非独生"左孩子,那就去遍历他的父节点的右子树

二、当前节点为"独生"左孩子,或者当前节点为右孩子,说明到了它的父节点被遍历的时候了

下面给出这个算法的实现:

scss 复制代码
template<typename T, typename VST>
void traverse(bin_node<T>* node, VST& visit)
{
	if (node == nullptr) return;//边界情况

	while (true)
	{
		if (node->get_left_child() != nullptr)node = node->get_left_child();//如果有左孩子,就向左
		else if (node->get_right_child() != nullptr)node = node->get_right_child();//如果没有左孩子,但是有右孩子,就向右

		else //都没有,说明是叶节点,开始回溯
		{
			visit(node);
			while (true)
			{
				if (node->get_parent() == nullptr)break;//如果追溯到根节点,说明整棵树遍历完了,退出循环
				if (node == node->get_parent()->get_left_child())//如果是父节点的左孩子,讨论是否"独生"
				{
					if (node->get_parent()->get_right_child() == nullptr)visit(node->get_parent());//如果独生,说明父节点左右子树均遍历完,到了他被遍历的时候
					else break;//如果非独生,就退出循环,去遍历父节点的右子树
				}
				else//如果是父节点的右孩子,那就遍历父节点,继续回溯
				{
					visit(node->get_parent());
				}

				node = node->get_parent();
			}
                        if (node->get_parent() == nullptr) return;//如果追溯到根节点的上方,说明遍历完了
                        node = node->get_parent()->get_right_child();
		}

	}
}

总结

我认为这个算法是有价值的

首先,空间上:对于自带父指针的树,他的额外空间复杂度是O(1),就算是算上父指针也不过O(n),还能避免栈溢出的问题。

形式上,不难看出,三种遍历算法的逻辑和代码实现都是高度统一的。

时间上,省去了入栈出栈的操作,代价是每个非叶子节点都要被node"经过"两次。总体上,时间复杂度对比传统的辅助栈方法是小优,仍然是O(n)

当然也有缺点,首先是这个父指针的事吧,我跟着课堂上实现的二叉树版本是自带父指针的,我也不知道这个父指针的泛用性咋样。还有就是代码逻辑对比传统的辅助栈方法确实不那么简明,但我觉得还好。(不知道看到这里的你看懂了吗?)

事实上,对于层次遍历,我也自己推导出了"无队列版本",但为了保证这篇博客内容的统一性,就不放在这里了。有兴趣的朋友可以私信交流。

相关推荐
胖咕噜的稞达鸭1 小时前
进程状态,孤儿进程僵尸进程,Linux真实调度算法,进程切换
linux·运维·算法
RTC老炮2 小时前
webrtc降噪-WienerFilter源码分析与算法原理
算法·webrtc
hweiyu002 小时前
数据结构:数组
数据结构·算法
无限进步_2 小时前
C语言单向链表实现详解:从基础操作到完整测试
c语言·开发语言·数据结构·c++·算法·链表·visual studio
初夏睡觉2 小时前
循环比赛日程表 题解
数据结构·c++·算法
派大星爱吃鱼3 小时前
素数检验方法
算法
Greedy Alg3 小时前
LeetCode 72. 编辑距离(中等)
算法
xinxingrs3 小时前
贪心算法、动态规划以及相关应用(python)
笔记·python·学习·算法·贪心算法·动态规划
秋邱4 小时前
驾驭数据洪流:Python如何赋能您的数据思维与决策飞跃
jvm·算法·云原生·oracle·eureka·数据分析·推荐算法