一篇质量非常高的关于二叉树遍历的帖子,转帖自http://noalgo.info/832.html

二叉树遍历(递归、非递归、Morris遍历)

二叉树遍历是二叉树中最基本的问题,其实现的方法非常多,有简单粗暴但容易爆栈的递归算法,还有稍微高级的使用栈模拟递归的非递归算法,另外还有不用栈而且只需要常数空间和线性时间的神奇Morris遍历算法,本文将对这些算法进行讲解和实现。

 

递归算法

二叉树节点使用以下数据结构进行表示,包括关键字、左儿子、右儿子属性和一个带默认参数的构造函数。
struct成员的默认属性为public,于是可以直接访问。

struct Node
{
	int val;
	Node *left, *right;
	Node(int v = 0, Node *l = NULL, Node *r = NULL) : val(v), left(l), right(r) {}
};

二叉树的递归算法非常简单,设置好递归出口之后,根据遍历的顺序,对当前节点的左右子递归调用自身即可。其前序、中序、后序遍历的代码如下。

void preorder1(Node *root) //递归前序遍历
{
	if (root == NULL) return;
	printf("%d ", root->val);
	preorder1(root->left);
	preorder1(root->right);
}

void inorder1(Node *root) //递归中序遍历
{
	if (root == NULL) return;
	inorder1(root->left);
	printf("%d ", root->val);
	inorder1(root->right);
}

void postorder1(Node *root) //递归后序遍历
{
	if (root == NULL) return;
	postorder1(root->left);
	postorder1(root->right);
	printf("%d ", root->val);
}

栈模拟非递归算法

递归算法的本质是利用函数的调用栈进行,实际上我们可以自行使用栈来进行模拟,这样的算法空间复杂度为O(h),h为二叉树的高度。

前序遍历

首先把根节点入栈,然后在每次循环中执行以下操作:

  • 此时栈顶元素即为当前的根节点,弹出并打印当前的根节点。
  • 把当前根节点的右儿子和左儿子分别入栈(注意是右儿子先入栈左儿子后入栈,这样的话下次出栈的元素才是左儿子,这样才符合前序遍历的顺序要求:根节点->左儿子->右儿子)。

下面是代码实现。

void preorder2(Node *root)//非递归前序遍历
{
	if (root == NULL) return;

	stack<Node *> stk;
	stk.push(root);
	while (!stk.empty())
	{
		Node *p = stk.top(); stk.pop();
		printf("%d ", p->val);
		if (p->right) stk.push(p->right);
		if (p->left) stk.push(p->left);
	}
}

后序遍历

因为后序遍历的顺序是:左子树->右子树->根节点,于是我们在前序遍历的代码中,当访问完当前节点后,先把当前节点的左子树入栈,再把右子树入栈,这样最终得到的顺序为:根节点->右子树->左子树,刚好是后序遍历倒过来的版本,于是把这个结果做一次翻转即为真正的后序遍历。而翻转可以通过使用另外一个栈简单完成,这样的代价是需要两个栈,但就复杂度而言,空间复杂度仍然是O(h)。

void postorder2(Node *root)//非递归后序遍历
{
	if (root == NULL) return;

	stack<Node *> stk, stk2;
	stk.push(root);
	while (!stk.empty())
	{
		Node *p = stk.top(); stk.pop();
		stk2.push(p);
		if (p->left) stk.push(p->left);
		if (p->right) stk.push(p->right);
	}
	while(!stk2.empty())
	{
		printf("%d ", stk2.top()->val);
		stk2.pop();
	}
}

中序遍历

中序遍历稍微复杂,使用一个指针p指向下一个待访问的节点,p初始化为根节点。在每次循环中执行以下操作:

  • 如果p非空,则把p入栈,p变为p的左儿子。
  • 如果p为空,说明已经向左走到尽头了,弹出当前栈顶元素,进行访问,并把p更新为其右儿子。

下面是代码实现。

void inorder2(Node *root)//非递归中序遍历
{
	stack<Node *> stk;
	Node *p = root;
	while (p != NULL || !stk.empty())
	{
		if (p != NULL)
			stk.push(p), p = p->left;
		else
		{
			p = stk.top(); stk.pop();
			printf("%d ", p->val);
			p = p->right;
		}
	}
}

Morris遍历

Morris遍历的神奇之处在于它是非递归的算法,但并不需要额外的O(h)的空间,而且复杂度仍然是线性的。这样的算法最关键的问题是当访问完一棵子树后,如何回到其对于的根节点再继续访问右子树呢?Morris是通过修改二叉树某些节点的指针来做到的。

中序遍历

按照定义,在中序遍历中,对于一棵以root为根的二叉树,当访问完root的前驱节点后,需要回到root节点进行访问,然后再到root的右儿子进行访问。于是,我们可以每次访问到一棵子树时,找到它的前驱节点,把前驱节点的右儿子变为当前的根节点root,这样当遍历完前驱节点后,可以顺着这个右儿子回到根节点root。

但问题是修改了该前驱节点的右儿子后什么时候再改回来呢?

  • 当第一次访问以root为根的子树时,找到它的前驱pre,此时pre的右儿子必定为空,于是把这个右儿子设置为root,以便以后根据这个指针回到root节点。
  • 当第二次回到以root为根的子树时,再找到它的前驱pre,此时pre的右儿子已经被设置成了当前的root,这时把该右儿子重新设置成NULL,然后继续进行root的右儿子的遍历。于是完成了指针的修改。

在这样的情景下,寻找当前节点的前驱节点时,不仅需要判断其是否有右儿子,而且还要判断右儿子是否为当前的root节点,跟普通情况下的寻址前驱节点稍微多了一个条件。

由于在每次遍历一个节点的时候都需要寻找其前驱节点,而寻找前驱节点的时间一般与树的高度相关,这样看上去算法的复杂度应该为O(nlogn)才对。但由于其只需要对有左儿子的节点才寻找前驱,于是所有寻找前驱时走过的路加起来至多为一棵树的节点数,例如在下文的例子中,只需要对以下节点寻找前驱:

  • 节点4:寻找路径为:2-3
  • 节点2:寻找路径为:1
  • 节点6:寻找路径为:5

于是寻找前驱加上遍历的运算量之和至多为2*n,n为节点个数,于是算法的复杂度为仍然为O(n)。

其实现代码如下:

void inorder3(Node *root)//Morris中序遍历
{
	Node *p = root;
	while (p != NULL)
	{
		if (p->left == NULL)
			printf("%d ", p->val), p = p->right;
		else
		{
			Node *pre = p->left;
			while (pre->right != NULL && pre->right != p)
				pre = pre->right;

			if (pre->right == NULL) //第一次访问,修改pre的右儿子
				pre->right = p, p = p->left;
			else                    //第二次访问,改回pre的右儿子
				pre->right = NULL, printf("%d ", p->val), p = p->right;
		}
	}
}

前序遍历

前序遍历和中序遍历类似,只是在遍历过程中访问节点的顺序稍有不同。即在第一次访问一棵子树时,就要先对根节点进行访问,于是printf输出语句被放到了if判断中第一次访问的分支中。

其代码如下:

void preorder3(Node *root)//Morris前序遍历
{
	Node *p = root;
	while (p != NULL)
	{
		if (p->left == NULL)
			printf("%d ", p->val), p = p->right;
		else
		{
			Node *pre = p->left;
			while (pre->right != NULL && pre->right != p)
				pre = pre->right;
			
			if (pre->right == NULL) //第一次访问,修改pre的右儿子
				pre->right = p, printf("%d ", p->val), p = p->left;
			else                    //第二次访问,改回pre的右儿子
				pre->right = NULL, p = p->right;
		}
	}
}

后序遍历

后序遍历稍微复杂,但其遍历的基本顺序也是和前/中序遍历类似,只是在打印的时候做了一个翻转。考虑下文例子中的后序遍历结果:1 3 2 5 7 6 4。其可以这样进行拆分并进行解释:

  • 1:最左下角的结果节点
  • 3 2:节点2、3的倒序
  • 5:右儿子的最左下角的节点
  • 7 6 4:右边一列节点4、6、7的倒序

于是我们可以在中序遍历过程中,当第二次访问到一个节点时,把它的左儿子到它的前驱节点的路径上的节点进行翻转打印,即可得到后序遍历的结果。但这样的话根节点到最右下角那一列会访问不到,增加一个辅助节点作为新的根节点,把原有根节点作为其左儿子即可。

其实现代码如下:

void reverse(Node *p1, Node *p2)//使用right指针翻转p1到p2节点
{
	if (p1 == p2) return;

	Node *pre = p1, *p = p1->right;
	while (true)
	{
		Node *tem = p->right;
		p->right = pre;
		if (p == p2) break;
		pre = p, p = tem;
	}
}

void print(Node *p1, Node *p2)//逆序打印p1到p2节点
{
	reverse(p1, p2);
	for (Node *p = p2; ; p = p->right)
	{
		printf("%d ", p->val);
		if (p == p1) break;
	}
	reverse(p2, p1);
}

void postorder3(Node *root)//Morris后序遍历
{
	Node dummy(-1, root, NULL), *p = &dummy;
	while (p != NULL)
	{
		if (p->left == NULL)
			p = p->right;
		else
		{
			Node *pre = p->left;
			while (pre->right != NULL && pre->right != p)
				pre = pre->right;

			if (pre->right == NULL)
				pre->right = p, p = p->left;
			else
				pre->right = NULL, print(p->left, pre), p = p->right;
		}
	}
}

代码测试

在下面的主函数中,我们对以下简单的二叉树进行测试。

     4
   /   \
  2     6
 / \   / \
1   3 5   7

主函数代码如下:

#include <cstdio>
#include <stack>
using namespace std;

int main()
{
	Node a1(1), a3(3), a5(5), a7(7);
	Node a2(2, &a1, &a3), a6(6, &a5, &a7);
	Node a4(4, &a2, &a6);

	preorder1(&a4); printf("\n"); //4 2 1 3 6 5 7
	preorder2(&a4); printf("\n"); //4 2 1 3 6 5 7
	preorder3(&a4); printf("\n"); //4 2 1 3 6 5 7
	printf("\n"); 

	inorder1(&a4); printf("\n"); //1 2 3 4 5 6 7
	inorder2(&a4); printf("\n"); //1 2 3 4 5 6 7
	inorder3(&a4); printf("\n"); //1 2 3 4 5 6 7
	printf("\n"); 

	postorder1(&a4); printf("\n"); //1 3 2 5 7 6 4
	postorder2(&a4); printf("\n"); //1 3 2 5 7 6 4
	postorder3(&a4); printf("\n"); //1 3 2 5 7 6 4
}
 
posted @ 2016-02-14 04:59 BaroC 阅读(1141) 评论(0) 推荐(0)
摘要: 主要是编程过程中遇到的一些问题和解决,大多数都是来自于Matlab文档。随时总结:Vectorization向量化是Matlab作为一种专注数值、矩阵计算的语言,和其他语言的一个差别。这也是Matlab能够高效进行矩阵运算的基础。Matlab的理念就是,数据存入矩阵和向量,使用向量化的运算来减少编程... 阅读全文
posted @ 2015-11-05 16:11 BaroC 阅读(253) 评论(0) 推荐(0)
摘要: 本文从公式上表述了欧几里得距离、曼哈顿距离、切比雪夫距离记忆闵可夫斯基距离之间的关系。一般而言,定义一个距离函数 d(x,y), 需要满足下面几个准则:1) d(x,x) = 0// 到自己的距离为02) d(x,y) >= 0// 距离非负3) d(x,y) = d(y,x) // 对称性: 如果... 阅读全文
posted @ 2015-05-10 20:42 BaroC 阅读(1767) 评论(0) 推荐(0)
摘要: 使用matlab过程中经常会出现内存不足的问题,这里转载一篇来自http://blog.csdn.net/xiaojidan2011/article/details/8089532 的博文,解决这一问题。 做图像处理的,对matlab这工具软件一定不会陌生,他的高集成度为图像处理提供了很大的方便,但... 阅读全文
posted @ 2015-05-05 22:08 BaroC 阅读(8229) 评论(0) 推荐(0)
摘要: 学习音乐自动标注过程中设计了有关分类型模型和生成型模型的东西,特地查了相关资料,在这里汇总。http://blog.sina.com.cn/s/blog_a18c98e50101058u.html新宇教你机器学习之Generativevsdiscriminativemodels在机器学习中,经常会提... 阅读全文
posted @ 2015-03-07 23:35 BaroC 阅读(1121) 评论(0) 推荐(1)
摘要: 偶然间看到向量求导,发现自己竟然没有什么印象了,从网上扒来这篇总结,稍作修改贴在这里。原文:http://hujianjust.blog.163.com/blog/static/7245507220108138818616/矩阵(向量)求导1. 矩阵Y对标量x求导: 相当于每个元素求导数后转置一下,... 阅读全文
posted @ 2015-03-04 16:42 BaroC 阅读(1221) 评论(0) 推荐(0)
摘要: PCA: PCA的具有2个功能,一是维数约简(可以加快算法的训练速度,减小内存消耗等),一是数据的可视化。 PCA并不是线性回归,因为线性回归是保证得到的函数是y值方面误差最小,而PCA是保证得到的函数到所降的维度上的误差最小。另外线性回归是通过x值来预测y值,而PCA中是将所有的x样本都同... 阅读全文
posted @ 2015-02-27 16:36 BaroC 阅读(314) 评论(0) 推荐(0)
摘要: 最近学习音乐自动标注的过程中,看到了有关使用MFCC提取音频特征的内容,特地在网上找到资料,学习了一下相关内容。此笔记大部分内容摘自博文 http://blog.csdn.net/zouxy09/article/details/9156785 有小部分标注和批改时我自己加上的,以便今后查阅。语音信号... 阅读全文
posted @ 2015-02-14 21:24 BaroC 阅读(62247) 评论(3) 推荐(5)
摘要: OPNET作为专业级网络仿真软件,其强大的功能使其在多个领域有广泛的应用。“越强大的软件,安装越闹心”,OPNET就是这样一款安装和运行的都很闹心的软件,这里简单转载和记录我安装OPNET和使用中的问题。OPNET安装我是在有vs2012的win7系统下安装的OPNET14.5。虽然VS2012是C... 阅读全文
posted @ 2014-12-10 17:01 BaroC 阅读(8944) 评论(0) 推荐(0)
摘要: 本文来源:http://www.eoeandroid.com/thread-274556-1-1.html另附经典教程网址 :http://wuyuans.com/2012/05/github-simple-tutorial/Git是一个分布式的版本控制系统,最初由Linus Torvalds编写,... 阅读全文
posted @ 2014-10-31 21:51 BaroC 阅读(432) 评论(0) 推荐(0)
点击右上角即可分享
微信分享提示