C++加餐课-二叉树:进阶算法

在数据结构初阶部分已经讲了常见的一些经典二叉树相关的算法题题目,二叉树部分难度还是有的,所以一些不适合用C语言实现的,和一些难度略大一些的算法题(二叉树非递归等),我们就放到这里用C++进行讲解。

1. 606. 根据二叉树创建字符串 - 力扣(LeetCode)

【链接】

https://leetcode.cn/problems/construct-string-from-binary-tree/

【思想】

这个题本身难度不大,就是一个前序遍历转成字符串,把子树用括号包起来,空树的情况特殊处理一下。

这个题目就不适合用c语言去实现,c实现符数组开多大就是麻烦事,要么就得开很大,要么就需要频繁地扩容。

用C++string的+=就方便了很多。

【代码】

cpp 复制代码
//Definition for a binary tree node.
struct TreeNode {
	int val;
	TreeNode* left;
	TreeNode* right;
	TreeNode() : val(0), left(nullptr), right(nullptr) {}
	TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
	TreeNode(int x, TreeNode* left, TreeNode* right) : val(x), left(left), right(right) {}
};
class Solution {
public:
	// 走前序遍历二叉树转换
	string tree2str(TreeNode* root) 
	{
		if (root == nullptr)
			return "";
		string ret = to_string(root->val);
		// 1、左右都为空,要省略括号
		// 2、右为空,要省略括号
		// 3、左为空,右不为空,不能省略括号
		// 左边和右边有一个为空,左边必须有括号
		if (root->left || root->right)
		{
			ret += '(';
			ret += tree2str(root->left);
			ret += ')';
		}
		if (root->right)
		{
			ret += '(';
			ret += tree2str(root->right);
			ret += ')';
		}
		return ret;
	}
};

【课堂演示】

题目描述:

示例说明:

规则:把子树用一个括号括起来------如果只有左子树,那右子树的括号可以省略。

如果只有右子树也省略,就区分不出来两种结构了,无法形成唯一映射。


思路:前序遍历二叉树,遍历的每个结点+=到string里面去,然后在遍历子树之前,加上左括号,遍历完子树,加上右括号。

需要注意的是空括号的问题。


【代码实现】

to_string能实现整型转字符串。

说明:函数名中的to常常谐写为2。

插入根之后,就是插入左子树、插入右子树。


下面就是空括号的处理:

  • 只有左子树:右子树的括号可以省略;
  • 只有右子树:左右子树都得保留括号;
  • 左右子树都无:都不用保留括号。
  • 左右子树都有:没有空括号。

2. 二叉树的层序遍历

【链****接】

102. 二叉树的层序遍历 - 力扣(LeetCode)

107. 二叉树的层序遍历 II - 力扣(LeetCode)

【思想】

层序遍历输出本身难度不大:利用队列先进先出的性质来辅助完成层序遍历。

  • 首先把根结点存入队列,第一层完毕;
  • 上一层每个结点出队列,输出到目标位置(控制台打印、某个容器);
  • 上一层每个结点出队列都带入下一层它自己的子结点;

永远出队头元素,就正好是层序遍历的顺序。

层序遍历就实现了,这个我们数据结构二叉树阶段已经讲过了。


这里这个题目的麻烦的点在于------需要把每层的结点单独存放到一个数组里面,最后返回一个二维数组。

C语言来搞,一方面动态开辟空间很麻烦,另一方面返回二维数组也很麻烦。

C语言还要手搓一个队列。

【核心思想】

在我们的辅助队列中,同时可能会有两层的数据,怎么区分一层的数据什么时候出完了呢?

我们得知道一层的数据出完了,才好关闭这一层的数据接收,开启下一层的数组接收。


我们可以在层序遍历过程中,增加一个levelSize,记录每层的数据个数:

  • 树不为空的情况下,第1层levelSize=1。
  • 当第1层出完了,第2层全都进队列了,此时整个队列的size就是第2层的数据个数。
  • 以此类推,假设levelSize为第n层的数据个数,因为层序遍历思想为当前层结点出队列,带入下一层结点(也就是子结点),循环控制第n层数据出完了,那么第n+1结点都进队列了,队列size,就是下一层的levelSize。

107这二个题目,思路跟上题102一样,二维数组逆置一下就可以得到结果。

【代码】

cpp 复制代码
class Solution {
public:
	vector<vector<int>> levelOrder(TreeNode* root) {
		vector<vector<int>> vv;
		queue<TreeNode*> q;
		int levelSize = 0;
		if (root)
		{
			q.push(root);
			levelSize = 1;
		}
		while (!q.empty())
		{
			vector<int> v;
			// 控制⼀层出完
			while (levelSize--)
			{
				TreeNode* front = q.front();
				q.pop();
				v.push_back(front->val);
				if (front->left)
					q.push(front->left);
				if (front->right)
					q.push(front->right);
			}
			// 当前层出完了,下⼀层都进队列了,队列size就是下⼀层数据个数
			levelSize = q.size();
			// 获取到每⼀层数据放到⼆维数组中
			vv.push_back(v);
		}
		return vv;
	}
};

【课堂演示】

【思路1】多开一个变量levelsize存储每层的结点个数

......

【思路2】多开一个队列,存储每个结点的层数

已经有了一个辅助队列进行每层数据的带入带出,再设计第2个辅助队列来存储层数。

【总体步骤】

  • 每个结点数据从辅助队列1输出的时候,它对应的层数也从辅助队列2输出。
  • 每个结点数据从辅助队列1输出的时候,它的两个孩子会先进入辅助队列1。
  • 每个结点数据从辅助队列1尾插的时候,它对应的层数(父层数+1)也会尾插入辅助队列2。

每个数据从辅助队列1中输出的时候,根据它的层数,就能知道它应该放到二维数组的第几行。

例如第一层的根结点就放到二维数组的第0行。


3结点从队列1输出的时候,带走队列2中它的层数,同时带入9和20两个结点已经他们的层数。

每个元素输出的时候,都能根据和它一起出来的层数判断,应该插入到二维数组的第几行。


显然思路1更优,这里选择思路1进行实现。


【代码实现】

基本框架:

核心操作:

外层循环的条件也可以设置为:while (levelSize > 0)。

表示队列中还有下一层的结点。


【层序顺序 vs 层序逆序】


3. 236. 二叉树的最近公共祖先 - 力扣(LeetCode)

【链接】

236. 二叉树的最近公共祖先 - 力扣(LeetCode)

【样例解释】

结点 5 和结点 1 的最近公共祖先是结点 3 。

结点 5 和结点 4 的最近公共祖先是结点 5 。因为根据定义最近公共祖先结点可以为结点本身

【思想】

【思路1】仔细观察一下,两个结点,最近公共祖先的特征就是一个结点在最近公共祖先的左边,一个结点在最近公共祖先的右边。

比如6和4的公共祖先有5和3,但是只有最近公共祖先5满足6在左边,4在右边。

**【思路2】**如果能求出两个结点到根的路径,那么就可以转换为链表相交问题。

如:6到根3的路径为6->5->3,4到根3的路径为4->2->5->3,那么看做两个链表找交点,交点5就是最近公共祖先。

【代码】

cpp 复制代码
class Solution {
public:
	// 查找x是否在树中
	bool IsInTree(TreeNode* root, TreeNode* x)
	{
		if (root == nullptr)
			return false;
		return root == x
			|| IsInTree(root->left, x)
			|| IsInTree(root->right, x);
	}
	TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
		if (root == NULL)
			return NULL;
		if (root == p || root == q)
		{
			return root;
		}
		// 这⾥要注意,这⾥的命名⾮常关键,命名好了,代码可读性⼤⼤增强
		bool pInLeft, pInRight, qInLeft, qInRight;
		// 题⽬中有说明p和q⼀定是树中的结点
		// p不在左树就在右树
		pInLeft = IsInTree(root->left, p);
		pInRight = !pInLeft;
		// q不在左树就在右树
		qInLeft = IsInTree(root->left, q);
		qInRight = !qInLeft;
		// ⼀个在左,⼀个在右,那么root就是最近公共祖先
		// 都在左,递归去左树查找
		// 都在右,递归去右树查找
		if ((pInLeft && qInRight) || (qInLeft && pInRight))
		{
			return root;
		}
		else if (pInLeft && qInLeft)
		{
			return lowestCommonAncestor(root->left, p, q);
		}
		else if (pInRight && qInRight)
		{
			return lowestCommonAncestor(root->right, p, q);
		}
		// 虽然执行逻辑不会走到这里,但是如果不写这个代码,语法逻辑过不去(相当于语法上走到这没返回值)
		// 编译器会报语法错误。
		assert(false);
		return NULL;
	}
};
class Solution {
public:
	bool GetPath(TreeNode* root, TreeNode* x, stack<TreeNode*>& path)
	{
		if (root == nullptr)
			return false;
		// 前序遍历的思路,找x结点的路径
		// 遇到root结点先push入栈,因为root就算不是x,但是root可能是根->x路径中一个分支结点
			path.push(root);
		if (root == x)
			return true;
		if (GetPath(root->left, x, path))
			return true;
		if (GetPath(root->right, x, path))
			return true;
		// 如果左右⼦树都没有x,那么说明上⾯⼊栈的root不是根->x路径中⼀个分⽀结点
		// 所以要pop出栈,回退,继续去其他分⽀路径进⾏查找
		path.pop();
		return false;
	}
	TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
		stack<TreeNode*> pPath, qPath;
		GetPath(root, p, pPath);
		GetPath(root, q, qPath);
		// 模拟链表相交,两个路径找交点
		// ⻓的先⾛差距步,再⼀起⾛找交点
		while (pPath.size() != qPath.size())
		{
			if (pPath.size() > qPath.size())
				pPath.pop();
			else
				qPath.pop();
		}
		while (pPath.top() != qPath.top())
		{
			pPath.pop();
			qPath.pop();
		}
		return pPath.top();
	}
};

【课堂演示】

祖先:从当前结点到根结点路径上的所有结点都是当前结点的祖先。

最近公共祖先:这道题规定自己可以被认为是自己的祖先之一。

由于这道题的给的二叉树是二叉链,只有左右孩子指针,没有父指针,所以这道题相对比较难做。


【规律总结】

  • 一般情况:两个结点分别属于最近公共祖先的左右子树。
    (只有最近公共祖先结点满足这一点)
  • 特殊情况:4属于5的子树,5就是4、5这两个结点的最近公共祖先。

【方法1】
  • 那就可以从根开始,找两个结点的最近公共祖先。
  • 如果一个在左树,一个在右树,那当前结点就是最近公共祖先。
  • 如果都在左树,那最近公共祖先结点也应该在当前节点的左边,往左迭代。
  • 如果都在右树,......

【方法1-代码实现】

基本框架:

核心逻辑:

  • 【注意1】用bool变量接收"判断函数"的返回值,这样在条件语句的括号里面就不需要多次调用"判断函数"。
  • 【注意2】bool变量的命名、bool变量赋值的技巧。

题目中有说明p和q一定是树中的结点,p不在当前结点的左树就在右树,所以pInR可以直接对pInL取非。

再把is_in_tree完善一下:

空树则结点x一定不在树root中。


【测试】

编译错误,不是运行错误,表示存在语法问题。

【描述】非void函数没有在所有可能的返回路径中,设置return。

编译器不知道代码的运行逻辑,它不知道一定不会走到最后。

它只知道检查语法逻辑,每一条可能的返回路径都应该设置return。

力扣后台的编译器对语法检查比较严格,VS的编译器就只有警告。


或者把最后一个else if替换成else。


当前这种写法的效率比较低------存在大量的重复查找。

最坏的情况下(退化成链表):O(N^2)

最好的情况下(完全二叉树):O(N*lgN)


搜索二叉树就快了,查找在左树还是右树,只需要比较值,比cur大在右树,比cur小在左树。


【方法2】

如果能给出p、q的祖先路径,那这道题就转换成"链表相交找第一个交点-OJ题"了

三叉链天生就有祖宗路径,但是二叉链怎么找祖宗路径呢?

前序遍历+辅助栈,可以获取节点x的祖宗路径。

【图示1】

【说明1------前序遍历】

遇到3,不是结点x,先入栈。(不是结点x,但有可能是祖宗路径上的结点)

遇到5,不是结点x,先入栈。

遇到6,是结点x,入栈。

结束。


怎么结束?

只要找到了,6向5递归返回true,5向3递归返回true,3向外递归返回true。

不遍历其他结点了。


本质就是一个前序遍历查找的过程,只是用一个辅助栈记录下查找路径。


【图示2】

【说明2------前序遍历】

遇到3,不是结点x,先入栈。(不是结点x,但有可能是祖宗路径上的结点)

遇到5,不是结点x,先入栈。

遇到6,不是结点x,先入栈。

遇到6的左子树------NULL,返回false,6的左边确定找不到。

遇到6的右子树------NULL,返回false,6的右边确定找不到。

6树确定找不到,6出栈,5的左子树返回false。

遇到2,不是结点x,先入栈。

......

遇到4,是结点x,入栈。

结束。


如果4也不是q,那一路返回false,把4、2、5都pop掉,继续去3的右子树找。

这里实际上是一个深度优先遍历。


【效率分析】O(N)

  • 查找p的路径------O(N)
  • 查找q的路径------O(N)
  • 查找路径交点,路径存储在两个栈内------长的先走,一样长后一起走------O(N)

【方法2-代码实现】

获取祖先路径------这道题的核心操作。

深度遍历的前序查找,顺便用栈记录查找路径。

然后找最近公共祖先:

第一个while循环是比较size不相等,就继续循环。

第二个while循环是比较top元素不相等,就继续循环。


【效率比较】

方法1:

改成后序,效率能提高一点,但代码写出来偏晦涩。


方法2:


4. LCR 155. 将二叉搜索树转化为排序的双向链表 - 力扣

【链接】

LCR 155. 将二叉搜索树转化为排序的双向链表 - 力扣(LeetCode)


二叉搜索树:左子树都比cur小,右子树都比cur大。

要求:

  • 就地完成转换,不能创建新结点(中序遍历,尾插出一个链表),所以还比较麻烦。
  • 左孩子指针:充当前驱指针;
  • 右孩子指针:充当后继指针;

【思想】

搜索二叉树走中序是有序的,本题目要求原地修改,也就是不能创建新的结点。

【思路1】

  • 中序遍历搜索二叉树,输出序列是有序的。
  • 将二叉树的结点指针放到一个vector中,再把前后结点的链接关系进行修改。

这个思路最简单,但是需要消耗O(N)的空间复杂度。

【思路2】

  • 中序遍历搜索二叉树,遍历序列是有序的。(不输出)
  • 遍历过程中直接修改连接关系------左指针为前驱、右指针为后继指针。
    • 记录一个cur和prev,cur为当前中序遍历到的结点,prev为上一个中序遍历的结点;
    • cur->left指向prev,cur->right无法指向中序下一个,因为不知道中序下一个是谁,但是这一步能确定prev->right指向cur;
    • 也就是说每个结点的左是在中遍历到当前结点时修改指向前驱的,但是当前结点的右,是在遍历到下一个结点时,修改指向后继的。

当前结点无法确定自己的后继,但是一定确定的是上一个结点的后继是当前结点。

1的前驱(left_ptr)最后才能确定。

【代码】

cpp 复制代码
class Node {
public:
	int val;
	Node* left;
	Node* right;

	Node() {}

	Node(int _val) {
		val = _val;
		left = NULL;
		right = NULL;
	}

	Node(int _val, Node* _left, Node* _right) {
		val = _val;
		left = _left;
		right = _right;
	}
};
class Solution {
public:

	void InOrderConvert(Node* cur, Node*& prev)
	{
		if (cur == nullptr)
			return;

		// 中序遍历
		InOrderConvert(cur->left, prev);

		// 当前结点的左,指向前⼀个结点
		cur->left = prev;

		// 前⼀个结点的右,指向当前结点
		if (prev)
			prev->right = cur;

		prev = cur;
		InOrderConvert(cur->right, prev);
	}
	Node* treeToDoublyList(Node* root) {
		if (root == nullptr)
			return nullptr;
		Node* prev = nullptr;
		InOrderConvert(root, prev);
		// 从根开始往左走,找到第一个结点
		Node* head = root;
		while (head->left)
		{
			head = head->left;
		}
		// head为第一个结点,prev是最后一个结点
		// 题目要求为循环链表,进行一些链接
		head->left = prev;
		prev->right = head;
		return head;
	}
};

【代码逻辑】

首先是中序遍历的过程中修改链接的逻辑:

然后是头和尾的处理(双向链表):

5. 从遍历序列中构造二叉树

105. 从前序与中序遍历序列构造二叉树 - 力扣(LeetCode)

106. 从中序与后序遍历序列构造二叉树 - 力扣(LeetCode)

【思想】

105题的问题,前序的方式构建树,前序确定当前构建树的根,根分割中序的左子树和右子树,再分别递归构建左子树和右子树。

106的题思想跟105题类似,不过是后序倒着确定根,再分别递归构造右子树和左子树。


【代码】

cpp 复制代码
class Solution {
public:
	TreeNode* _buildTree(vector<int>& preorder, vector<int>& inorder, int&
		prei, int inbegin, int inend) {
		if (inbegin > inend)
			return nullptr;
		// 前序确定根
		TreeNode* root = new TreeNode(preorder[prei++]);
		// 根分割中序左右⼦区间
		int rooti = inbegin;
		while (rooti <= inend)
		{
			if (inorder[rooti] == root->val)
				break;
			else
				rooti++;
		}
		// 递归左右⼦区间,递归构建左右⼦树
		// [inbegin, rooti-1] rooti [rooti+1, inend]
		root->left = _buildTree(preorder, inorder, prei, inbegin, rooti - 1);
		root->right = _buildTree(preorder, inorder, prei, rooti + 1, inend);
		return root;
	}
	TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
		int i = 0;
		TreeNode* root = _buildTree(preorder, inorder, i, 0, inorder.size() - 1);
		return root;
	}
};


5.1 前序 + 中序

之前能通过前序直接构建二叉树,因为把空节点用#标记了。

如果去掉#标记,那前序一样的序列,可能的二叉树结构不止一种:

那中序里面的唯一的3就一定是根了。

整体上走的前序构建二叉树的逻辑:先构建根,再构建左子树,最后构建右子树。


【图示分析】

前序确定:根3。

中序确定:左子树9,右子树15、20、7

前序确定:右子树的根20。

中序确定:右子树的左子树15、右子树的右子树7。


9、15、7再往下没有区间了,往下都是空。



【代码实现】

当前函数的参数太少,无法直接用于递归。需要写一个递归子函数。

【参数说明】

  • prei:使用前序序列构建二叉树,prei表示前序序列中的下标;
  • inbegin:中序区间左;
  • inend:中序区间右;

前序序列下标位置的元素作为根,在中序序列区间[begin,end]内找根,利用根分隔出左右子树。

分隔出来的左右子树,也是中序序列里的两段区间------传给两个递归子函数处理。


在递归里面希望prei一直跟着走,所以给成引用,外面必须传变量,不能传常量。


第3步构建左子树右子树,可以先在外面判断区间没问题,再构建。

也可以直接丢给下一层调用的build构建,在下一层调用的build里面判断区间不存在之后递归返回


【说明】构建完左树,前序序列中prei已经++到右树根的位置了。

然后在中序右子树区间中:

  • 找这个根,构建右子树根结点(最后要返回这个结点)。
  • 利用这个根分隔出左右子树,构建右子树的左右子树。

上面的写法是创建根节点的时候就prei++,然后通过新创建的root拿到根的数据。

下面这种写法也是可以的:


5.2 后序 + 中序

前序序列:第一个元素是根;

后序序列:最后一个元素是根;

还是需要使用前序构建法(有2点差异):

  • 倒着走;
  • 先构建根,再构建右子树,最后构建左子树;

首先确定根------3。

3往左走一位,是右子树的根,先创建右子树。


6. 二叉树的非递归遍历

144. 二叉树的前序遍历 - 力扣(LeetCode)

94. 二叉树的中序遍历 - 力扣(LeetCode)

145. 二叉树的后序遍历 - 力扣(LeetCode)



【非递归迭代实现思想】

要迭代非递归实现二叉树前序遍历,首先还是要借助递归的类似的思想,只是需要把结点存在栈中,方便类似递归回退时取父路径结点。

跟这里不同的是,这里把一棵二叉树分为两个部分:

  1. 先访问左路结点。
  2. 再访问左路结点的右子树。

这里访问右子树要以循环从栈依次取出这些结点,循环子问题的思想访问左路结点的右子树。


中序和后序跟前序的思路完全一致,只是前序先访问根还是后访问根的问题。

后序稍微麻烦一些,因为后序遍历的顺序是左子树 右子树 根,当取到左路结点的右子树时,需要想办法标记判断右子树是否访问过了,如果访问过了,就直接访问根;如果没访问过,则需要先访问右子树。

【代码】

cpp 复制代码
class Solution {
public:
	vector<int> preorderTraversal(TreeNode* root) {
		stack<TreeNode*> s;
		vector<int> v;
		TreeNode* cur = root;
		while (cur || !s.empty())
		{
			// 访问⼀颗树的开始
			// 1、访问左路结点,左路结点⼊栈
			while (cur)
			{
				v.push_back(cur->val);
				s.push(cur);
				cur = cur->left;
			}
			// 2、从栈中依次访问左路结点的右子树
			TreeNode* top = s.top();
			s.pop();
			// 循环子问题的方式,去访问左路结点的右子树 --
			cur = top->right;
		}
		return v;
	}
};
class Solution {
public:
	vector<int> inorderTraversal(TreeNode* root) {
		stack<TreeNode*> st;
		TreeNode* cur = root;
		vector<int> v;
		while (cur || !st.empty())
		{
			// 访问⼀颗树的开始
			// 1、左路结点⼊栈
			while (cur)
			{
				st.push(cur);
				cur = cur->left;
			}
			// 访问问左路结点 和 左路结点的右⼦树
			TreeNode* top = st.top();
			st.pop();
			v.push_back(top->val);
			// 循环⼦问题⽅式访问右⼦树
			cur = top->right;
		}
		return v;
	}
};
class Solution {
public:
	vector<int> postorderTraversal(TreeNode* root) {
		TreeNode* cur = root;
		stack<TreeNode*> s;
		vector<int> v;
		TreeNode* prev = nullptr;
		while (cur || !s.empty())
		{
			// 1、访问⼀颗树的开始
			while (cur)
			{
				s.push(cur);
				cur = cur->left;
			}
			TreeNode* top = s.top();
			// top结点的右为空 或者 上⼀个访问结点等于他的右孩⼦
			// 那么说明(空)不⽤访问 或者 (不为空)右⼦树已经访问过了
			// 那么说明当前结点左右⼦树都访问过了,可以访问当前结点了
			if (top->right == nullptr || top->right == prev)
			{
				s.pop();
				v.push_back(top->val);
				prev = top;
			}
			else
			{
				// 右⼦树不为空,且没有访问,循环⼦问题⽅式右⼦树
				cur = top->right;
			}
		}
		return v;
	}
};

【课堂演示】

6.1 二叉树的前序遍历

递归是有缺陷的------如果二叉树的深度比较深,那递归遍历有栈溢出的风险。

对于有栈溢出风险的递归,都要求掌握非递归。


简单一点的递归改非递归,直接变成循环结构就可以了。

比如:斐波那契数列。

稍微复杂的递归,就需要借助"栈"这个数据结构------数据结构在堆上,比栈(内存)大很多。

现在就要去看递归的过程中,栈帧里面存储了什么,那非递归的时候辅助栈也存对应的。


前序遍历一棵树,先访问左路结点:

递归返回,再依次访问左路结点的右子树。

每个右子树,又可以看作子问题处理。


【图示分析】

  1. 先访问左路结点
  • 左路结点全部规划到前序序列
  • 同时,左路结点全部进入辅助栈
  1. 再依次访问每个左路结点的右子树

【取出2】再访问2的右树

2的右子树,再先访问右子树的左路结点(只有7)------规划到前序序列 + 全入栈。

再去访问每个左路结点(只有7)的右子树。

【取出7】访问7的右子树,空树,不用访问。

这样7的右子树、2的右子树都访问完了,再去访问4的右子树。

【取出4】访问4的右子树

4的右子树,再先访问右子树的左路结点(5、6)------规划到前序序列 + 全入栈。

再去访问每个左路结点(5、6)的右子树。

【取出6】......

【取出5】......

【取出1】......

栈里面:都是等待访问右子树的左路结点。

栈空了:所有结点都访问完了。


  • 访问左路结点的时候,规划到前序序列里面 + 辅助栈里面。
  • 访问左路结点x的右子树的时候,取出栈顶x节点,带入它的右子树的所有左路结点。

【代码实现】


结合代码再来看看图示:

先是一路访问左路结点------4、2......

内循环一路访问左路结点直到cur为空,然后需要依次访问左路结点的右子树。

【取出2】让cur指向2的右子树,继续下一次外循环。

先是一路访问左路结点------7......

内循环一路访问左路结点直到cur为空,然后需要依次访问左路结点的右子树。

【取出7】让cur指向7的右子树,继续下一次外循环。(栈内还有4,外循环不会停止)

7的右子树,内循环无左路结点可访问,直接取栈顶。

【取出4】让cur指向4的右子树,继续下一次外循环。

先是一路访问左路结点------5、6......

内循环一路访问左路结点直到cur为空,然后需要依次访问左路结点的右子树。

【取出6】让cur指向6的右子树,继续下一次外循环。

6的右子树为空,下一层外循环会直接取出5。

【取出5】让cur指向5的右子树,继续下一次外循环。

先是一路访问左路结点------1......

内循环一路访问左路结点直到cur为空,然后需要依次访问左路结点的右子树。

【取出1】让cur指向1的右子树,继续下一次外循环。

下一层外循环会直接判断外循环结束------cur指向空,辅助栈也空了。


  • 外循环:一路访问左路结点直到cur为空,然后依次访问辅助栈内的左路结点的右子树。
  • 内循环:一路访问左路结点直到cur为空。

6.2 二叉树的中序遍历

中序的非递归跟前序完全类似。

前序是:根------>左子树 (根--->左子树(......)--->右子树(......))------>右子树(......)

根到左子树之后,不能立刻到右子树,因为左子树还要走:根->左子树->右子树。

所以就要先把每个左路结点放到栈帧里,一直等到左子树访问完,再回到栈帧里的时候去访问右子树。

回退到栈帧要访问右子树的时候,意味着它的左子树已经访问完了。

前序非递归的实现就是:访问的时候把左路结点给入栈(数据结构)。


中序非递归:只入栈,不访问(输出到中序序列)------左子树访问完了才能访问根。

什么时候左子树就访问完了?

一直到cur为空,左子树就访问完了。然后访问根 + 访问右子树。

取出2,访问2(输出到中序序列),同时去访问2的右子树。

访问2的右子树:一路只把左路结点入栈,但不访问。

把结点从栈里面取出来的时候,访问它并访问它的右子树。

取出7,访问7的右子树(cur),cur为空,但外循环不终止------栈内还有4.

取出4,访问4和4的右子树。

......


入栈的时候不访问,从栈内取出来,回溯到本结点之后,再访问。

从递归的角度看:

  • 前序:边访问,边建立栈帧,等回溯的时候再访问右子树。
  • 中序:一路下去先只建立栈帧,等回溯的时候再访问本结点 + 访问右子树。

6.3 二叉树的后序遍历

后序的非递归跟前面类似,但比前面略复杂,会遇到一些问题。

大思路不变:把一棵树分成左路结点 + 左路结点的右子树。

不同的地方:访问根的时机不一样------左子树、右子树完了才是根的访问。


先沿左路结点走下去------不能访问,只入栈。

当我们把一个结点从栈里取到的时候,代表它的左子树已经访问完了。

就像递归往回溯的时候,回来拿这个结点。

此时还不能访问2------即不能把2弹出去。

这是第一次从栈里摸到2,还要先访问2的右子树。

右树也是沿左路结点访问下去------只入栈,不访问。

走完左路,又从栈里拿7,第一次拿7不能拿,第一次拿7的目的是引入7的右子树。

但是7的右树为空,所以这里可以直接第一次拿7就把7拿走,然后访问7(输出到后序序列)。


访问完7,又取栈顶,第二次拿2这个结点。

【问】此时2的右子树不为空,怎么知道2的右子树有没有访问过?

搞一个flag变量,第一次拿到之前2为false,拿到2之后改true,这样第2次来拿看到的就是true。

但是如果7下面还有右子树,那第一次拿7看到的flag就是true(因为2已经拿过一次了)。

这样不行。

那每个结点都搞一个自己的flag变量。

这样可以,但是把问题搞复杂了,而且还需要建立在能够修改原生二叉树结构的基础之上。


来看看目前的代码:

只要右子树不为空,就去访问右子树,而不区分右子树是不是已经访问过了,这会导致死循环,程序执行超时。


后序:左子树------>右子树------>根。

也就是说,任何一棵树,最后一个访问的结点一定是它的根。

  • 右子树没访问过:上一个访问的结点是左子树的根;(cur的左孩子)
  • 右子树访问完:上一个访问的结点是右子树的根;(cur的右孩子)

所以只需要记录下上一个访问的结点,就能知道当前结点的右子树有没有访问过。

不能取vector里面的最后一个数据------只存了值,而这里要比较节点相等需要用到结点的指针。


【答】看访问2之前,访问的是哪一个结点。

每个访问的结点都可以看作是根,现在要访问的2是根,2之前访问的,是它左子树或右子树的根。

  • 若是它左子树的根,说明下一个要访问的根还不是2。
  • 若是它右子树的根,说明下一个要访问的根就是2。

【代码实现】

右不为空且没访问过,就要走循环子问题。

【顿悟】取的栈顶,其实是最下面的一个左路结点。

走了if分支,那cur没变,还是为空,下一次外循环进来就直接取栈顶(先不pop)......


【最后梳理一遍流程】

先沿左路结点走到空,入栈------4、2。

再取栈顶(先不pop)是2,发现右子树没访问过,去访问右子树。

先沿左路结点走到空,入栈------7。

再取栈顶(先不pop)是7,发现右子树为空,直接走if分支,直接访问7,进入下一次外循环。

下一次外循环cur为空,直接取栈顶2,右子树访问过,还是走if分支,访问2,进入下一次外循环。

下一次外循环cur为空,直接取栈顶4,4的右子树没访问过,不能直接访问4,走4的右子树。

先沿左路结点走到空,入栈------5、6。

再取栈顶6,右孩子为空,可以直接访问,直接访问7,进入下一次外循环。

下一次外循环cur为空,直接取栈顶5,右子树没访问过,走else分支,5的右子树。

先沿左路结点走到空,入栈------1。

再取栈顶1,右孩子为空,可以直接访问,直接走if分支,直接访问1,进入下一次外循环。

下一次外循环cur为空,直接取栈顶5,右子树访问过,还是走if分支,访问5,进入下一次外循环。

下一次外循环cur为空,直接取栈顶4,右子树访问过,还是走if分支,访问4,进入下一次外循环。

下一次外循环cur为空,栈为空,取不了栈顶,后序遍历结束。

只有右不为空且右子树没访问过,才会更新cur。


【总结】

  • 优先理解前序的非递归,才好理解中序、后序的非递归。
  • 重点掌握后序(面试爱考后序),但是都要掌握。

无论是前序、中序、后序,本质都是根据递归过程当中,栈帧的建立情况来搞的。

现在不用递归,不用栈帧(空间太小),而是在堆上(空间大),就需要借助"栈"(数据结构)。


相关推荐
迷你可可小生2 小时前
面经学习(二)
学习·算法
Q741_1472 小时前
设计模式之装饰器模式 理论总结 C++代码实战
c++·设计模式·装饰器模式
郝学胜-神的一滴2 小时前
ReLU激活函数全解析:从原理到实战,解锁深度学习核心激活单元
人工智能·pytorch·python·深度学习·算法
AGV算法笔记2 小时前
最新感知算法论文分析:RaCFormer 如何提升雷达相机 3D 目标检测性能?
数码相机·算法·3d·自动驾驶·机器人视觉·3d目标检测·感知算法
脱氧核糖核酸__2 小时前
LeetCode热题100——54.螺旋矩阵(题解+答案+要点)
c++·算法·leetcode·矩阵
!停2 小时前
C++入门STL容器string底层剖析
开发语言·c++
lxh01132 小时前
电话号码的字母组合
java·javascript·算法
爱学习的小可爱卢2 小时前
算法—Java Map 核心方法与实战场景指南
java·开发语言·算法
WWZZ20252 小时前
Sim2Sim理论与实践3:深度强化学习
人工智能·算法·机器人·深度强化学习·具身智能·四足·人形