Practice 2025.6.1—— 二叉树进阶面试题(2)

article/2025/8/13 2:03:12

文章目录

  • 二叉树进阶面试题(2)
    • Leetcode_144 二叉树的前序遍历(使用非递归)
    • Leetcode_94 二叉树的中序遍历(使用非递归)
    • Leetcode_145 二叉树的后序遍历(使用非递归)

二叉树进阶面试题(2)

本篇文章将继续进行二叉树的进阶面试题的讲解,其中,本部分将重点针对于二叉树的前序遍历、中序遍历、后序遍历如何非递归遍历进行讲解。

之所以要这么晚才来将非递归的前中后序遍历是因为,使用c语言进行递归转非递归是很困难的。因为递归过程涉及到回退。非递归的本质就是通过循环的逻辑来模拟实现递归,这个过程中有很多方法。

但是今天我们的三种遍历当时都将使用栈这个数据结构进行实现。如果使用c语言,那我们还得自行手撕一个栈的数据结构出来,这是很麻烦的。而且栈的一些接口的使用也不是那么的方便。但是c++就不一样了,我们可以使用STL库里面的stack

Leetcode_144 二叉树的前序遍历(使用非递归)

原题:https://leetcode.cn/problems/binary-tree-preorder-traversal/description/

我们先来将前序遍历的非递归。

前序遍历的过程可以总结为一句话:根 左子树 右子树,然后对于子树而言,又是遵循这个规则。使用递归去实现就很好理解了,但是我们现在需要的是非递归。

在这里插入图片描述
假设现在有这么一个树,我们应该如何进行非递归的前序遍历呢?
我们非递归的逻辑本质上就是模拟递归,我们来看看递归的代码:

void preorderTraversal_R(TreeNode* root){if(root == nullptr) return;cout << root->val << endl;preorderTraversal_R(root->left);preorderTraversal_R(root->right);
}

我们把每个节点当作一个子树的根节点:
我们发现,在递归的调用过程中,碰到一个根节点,就打印一次,然后先不断地往左子树去递归深入。然后直到左子树为空的时候,这个时候就已经没办法再递归深入了,就只能回退到上一层深入左子树的栈帧位置,然后再调用到右子树位置,右子树继续左深入和回退。

我们就是要使用非递归的逻辑来模仿这个过程,但是会面临着一个问题就是:
如何找到回退的位置?递归是通过函数调用函数,回退栈帧实现的。我们现在不使用递归,如何能够回退到上一层栈帧呢?这个时候就要借助数据结构stack了。

假设就是上面那个树:
在这里插入图片描述
这里的逻辑没有画完,但是已经足够展现出二叉树的遍历逻辑了。

但是这里只是展现出了大致逻辑,其实在写代码的时候还会面临很多问题,这时候我们就先看代码实现,然后再来讲细节:

class Solution {
public:vector<int> preorderTraversal(TreeNode* root) {vector<int> v;if(root == nullptr) return v;stack<TreeNode*> st;st.push(root);v.push_back(root->val);while(!st.empty()){TreeNode* top = st.top();//深入左子树while(top->left){st.push(top->left);v.push_back(top->left->val);top = top->left;}//找到右子树(一定要找到)while(!st.empty()){top = st.top();st.pop();if(top->right){st.push(top->right);v.push_back(top->right->val);break;}}}return v;}
};

这里其实深入遍历左子树是很简单的,从栈顶节点开始,不断地往左边走,然后把节点进行入栈并且插入到数组里面(插入到数组相当于访问)。

直到左边为空,说明需要回退到上一个根节点然后将右子树的根进行入栈,然后重复上面的操作。正常来讲,直接把右子树的根入栈就好了。
但就是因为右子树可能为空,如果按照我们代码这里的逻辑,如果只判断一次右子树为空不入栈是会出问题的:

比如这个树:
在这里插入图片描述
top走到4就没办法再深入了,这个时候就需要把右子树插入。但是4的右子树为空。这个时候如果只判断一次就不判断了,4会出栈,然后没有右子树的入栈。这个时候会回退到新的栈顶节点2。问题就来了,2是被访问过的,又没有出栈。那回退到2的时候按照这个逻辑走,又会不断地深入到左边。这是肯定不行的。所以为什么我们上面的代码是需要进行循环的逻辑去找到右子树插入。因为那些没有右子树的都需要被过滤掉。

但是回退找右子树的过程中,很可能一直回退到整个树的根节点都没有右子树,这个时候就没办法再回退了,这个时候对应的就是栈为空。所以我们是需要特殊判断的。


当然,这里再提供一个方法,这个方法会简单一些:

既然说先序遍历的过程一定是:根 左子树 右子树,那么我们是否可以像层序遍历的逻辑去走:
先把根节点入栈。取栈顶节点,访问后出栈,然后把出栈节点的右节点先入栈,再入栈左节点。然后又是取栈顶节点,再出栈,再入栈其右左节点…

我们发现这个是可行的,而且代码写起来也是非常的简单:

class Solution {
public:vector<int> preorderTraversal(TreeNode* root) {vector<int> v;if(root == nullptr) return v;stack<TreeNode*> st;st.push(root);while(!st.empty()){TreeNode* STtop = st.top();v.push_back(STtop->val);st.pop();if(STtop->right != nullptr)st.push(STtop->right);if(STtop->left != nullptr)st.push(STtop->left);}return v;}
};

经过验证,发现这样确实可行,而且思路更简单,也更好想到。
只需要注意右节点要先入栈。因为栈是后进先出,所以左节点后入栈,先出栈,也就意味着左节点先被访问。

Leetcode_94 二叉树的中序遍历(使用非递归)

原题:https://leetcode.cn/problems/binary-tree-inorder-traversal/

中序遍历也就一句话:左子树 根 右子树
中序遍历其实和前序遍历是很像的。我们只需要稍微改一点代码就可以了。为什么这么说呢?

我们可以看到,前序遍历和中序遍历都要做的一件事情是,不断地左子树递归深入。中序遍历这么做的原因是本身就要先访问左子树。前序遍历这么做的是因为访问一个根节点后再左递归深入。这两个遍历的方式的最大区别就是在于:节点的访问时机。

前序遍历是遍历到一个节点,就先进行访问,然后再左递归深入。我们可以这么理解,前序遍历是把每一个节点当作根节点,在前序遍历的方式下,就需要对根节点先进行访问。

但是中序遍历肯定是不能边入栈边访问的。中序遍历的时机应该是回退的时候再来访问。这样子就是可以看做成把每个节点当成左节点。在中序的条件下,左节点应该先被访问。

其余的细节其实都是很相似的,回退的过程中也是一定要找到右子树进行入栈的,要不然也会出现前面前序遍历中讲到的问题的,我们来看看代码:

class Solution {
public:vector<int> inorderTraversal(TreeNode* root) {vector<int> v;if(root == nullptr) return v;stack<TreeNode*> st;st.push(root);while(!st.empty()){TreeNode* top = st.top();while(top->left){st.push(top->left);//不在这里访问top = top->left;}while(!st.empty()){top = st.top();st.pop();//访问时机v.push_back(top->val);if(top->right){st.push(top->right);break;}}}return v;}
};

但是这里是不能直接使用前序的第二个方法的。因为第二种方法是根据前序遍历的先根性来进行设计的。那个方法访问完根节点后就要马上出栈,再入栈右左节点的。这点需要注意。

但是针对于中序遍历的特性,也可以提出另一种写法:

class Solution {
public:vector<int> inorderTraversal(TreeNode* root) {stack<TreeNode*> st;vector<int> v;TreeNode* cur = root;while(cur){st.push(cur);cur = cur->left;}while(!st.empty()){TreeNode* top = st.top();st.pop();v.push_back(top->val);TreeNode* rightmove = top->right;while(rightmove){st.push(rightmove);rightmove = rightmove->left;}}return v;}
};

这个方法的思路就是,先把左路节点全部入栈。回退的时候访问节点,然后再把右子树的左路节点再入栈。这是根据中序遍历的特性设计的一种方案。

Leetcode_145 二叉树的后序遍历(使用非递归)

原题:·https://leetcode.cn/problems/binary-tree-postorder-traversal/

这个部分我们来看看后序遍历如何进行非递归的改造。后序遍历相对于前序和中序来说是会更复杂一点的,思路可以说是和前中序遍历根本就不一样。


后序遍历是不能像前中序遍历那样,先对单边的节点进行深入的。递归逻辑下这么做可以是因为函数栈帧会自动回退回去。但是这里我们如果是模拟后序遍历的过程,如果先单边地深入,这势必会出现一个问题。

就是一个树的左子树要想跳到右子树范围,是需要先回退到根节点然后再走到右子树的。由于这个过程是后序遍历,访问节点只能在回退的时候访问。那回退的时候再跳到右子树去,那么跟那个被访问的节点的关系就是根和右子树了。这肯定是不行的。

所以这里的逻辑应该是,根节点先入栈,但是并不进行访问。然后再把根节点的右子树和左子树入栈(如果有)。这个方式就很像前序遍历的方法2,区别就是根节点不会先访问后出栈。

我们来试着走一下这个逻辑:

首先,按照上面讲的逻辑先入栈。
在这里插入图片描述
先入栈1,然后右左只有有一个不为空的就得入栈。然后再取栈顶节点进行上述操作。入栈到4后,发现4的右左都是空。这个时候就简单了,直接出栈并访问。

然后取栈顶节点,继续执行上述讲到的入栈操作。
在这里插入图片描述

入栈到6后,左右为空,6出栈访问。再取栈顶节点7,也是一样,左右为空,出栈访问:
在这里插入图片描述
这个时候我们发现,栈顶节点又是5了。如果我们还是简单地执行上述的逻辑,那是会出问题的。因为5这个节点的左右子树已经被访问过了,按道理这一次回退到5的时候是应该直接访问5的。但是我们现在最大的问题就是,如何判断一个节点的左右子树已经被访问过了呢?这是一定要判断的,这里如果不进行判断,5的左右子树又会被重复访问,造成死循环了。

这个时候,我们只要能够解决掉这个判断节点的左右子树是否被访问的逻辑,那么这题就能够被解决了,但是本题的难度也就是在这个逻辑的处理上。

接下来,我们需要一起探索一下,如何找到这个判断逻辑。


第一种方法:
我们可以使用一个栈,记录着每一个节点的状态。如果一个节点的左右子树都没有被访问过,那就记录这个节点的状态为0。访问过了就记录为1。这样子再回退到某个节点的时候,就可以很轻松的判断出是否被访问过了。

我们可以使用两个栈,一个存放的就是节点,另一个存放的就是每个节点的对应的状态。如果一个节点出栈了,那他对应的状态也必须出栈。

我们来看看代码:

class Solution {
public:vector<int> postorderTraversal(TreeNode* root) {vector<int> v;if (root == nullptr) return v;stack<TreeNode*> st;stack<int> Status;st.push(root);Status.push(0);while (!st.empty()) {while (Status.top() == 0 && (st.top()->left || st.top()->right)) {Status.top() = 1;TreeNode* STTop = st.top();if (STTop->right) {st.push(STTop->right);Status.push(0);}if (STTop->left) {st.push(STTop->left);Status.push(0);}}v.push_back(st.top()->val);st.pop();Status.pop();}return v;}
};

根节点入栈的时候,状态必须是0。因为此时只入栈了根节点,并没有把根节点的右左节点入栈,那状态必须是0。

然后开始执行逻辑:
只要栈不为空,就不断地取栈顶节点出来判断,如果状态是0,就可以判断右左子树是否为空。只要有一个不为空,那就将不为空的子树根节点入栈,并且将对应的节点的状态设置成1。如果两个都为空,这个时候就没有右左子树需要入栈,直接访问即可(插入到数组中)。

如果是回退到某个有子树的节点,但是状态为1,这个时候也是没有办法再进入循环的。也会直接访问。这个时候就很好的解决了判断节点是否被访问过的逻辑了。


第二种方法:

但是第一种方法是要使用两个栈进行操作的,而且最重要的问题是,等于是要多开一倍的空间。而且管控节点的栈的时候,必须管控状态的那个栈。一旦有遗漏就很容易出错了。

我们能不能想出一种办法,只需要一个变量来记录状态呢?根据这个状态我们就可以判断是否要对某个节点的左右子树进行入栈。

这是可以的,我们可以按照后序遍历的顺序记录上一个被访问的节点:
在这里插入图片描述
比如当前这个状态,已经访问了4 6 7三个节点,这个时候是第二次回到5这个节点了。我们这个时候急需判断是否还需要对它进行左右子树的入栈。

如果我们按照后序遍历的顺序,依次记录着当前栈顶节点是某个节点时,访问到的上一个节点。这个时候就很轻松了。我们发现此时上一个访问的节点是7,是5的右子树。所以可以直接判断这是5不需要再进行左右子树的插入的。然后后面的逻辑也是一样的。

为什么可以这么做呢?
我们先来看这个树的后序遍历的结果:[4,6,7,5,2,9,8,3,1]

我们现在是能够保证我们的逻辑走出来的序列一定是后续的,只是需要判断是否需要入栈右左子树。在这个前提下,节点被访问的时机一定是节点处在栈顶的时候。
然后我们再来仔细地看看这个序列,我们发现,除了根节点,其余被访问的节点的前一个被访问的节点必然是它的左孩子或者右孩子。

所以当每次节点出栈(被访问)的时候,我们都进行记录。那么就可以很轻松完成判断逻辑了。

class Solution {
public:vector<int> postorderTraversal(TreeNode* root) {vector<int> v;if(root == nullptr) return v;stack<TreeNode*> st;st.push(root);TreeNode* LastNode = nullptr;while(!st.empty()){TreeNode* top = st.top();while((LastNode != top->right && LastNode != top->left)&& (top->left || top->right)){if(top->right) st.push(top->right);if(top->left) st.push(top->left);top = st.top(); }LastNode = st.top();v.push_back(top->val);st.pop();}return v;}
};

很多人会直接写出这样的代码,但是发现是有问题的。

原因就在于对于LastNode的处理,如果我们设置LastNode为空,我们来看看这棵树是否能得到正确的序列:
在这里插入图片描述
这个树,按照代码逻辑走,会发现输出的序列只有一个1。

这就是因为设置了LastNode为空的初始条件。然后在判断是否需要入栈右左子树的时候,发现LastNode为空正好和1的左节点(nullptr)匹配上了,这个时候循环条件的判断为false,这是不会进入循环的。

但是很明显,这个情况只会出现在根节点的左右子树为空的情况下才会有这个问题。因为在栈顶节点为其它的节点的时候,LastNode必然是上一个被访问的节点,不会为空。

所以只需要处理一下开头的时候出现这个问题即可。该如何做呢?
我们可以直接把LastNode的值设置为根节点root。这是没有任何影响的。虽然栈顶节点为根节点的时候,上一个访问的节点确实是空。但是我们的逻辑是,只有出栈被访问的时候,才会修改LastNode的值。

一般来说,根节点入栈后并不是马上要被访问的。真正需要通过这个LastNode来判断是否入栈右左节点的地方是第一次回退到有孩子,但是孩子又被访问过的节点的地方。这个时候状态才会真正派上用场。

就算树只有一个根节点,那也不怕,虽然上一个节点记录的是本身,但是左右孩子都是空,仍然不会进入循环。所以这个修改是可行的:

class Solution {
public:vector<int> postorderTraversal(TreeNode* root) {vector<int> v;if(root == nullptr) return v;stack<TreeNode*> st;st.push(root);TreeNode* LastNode = root;while(!st.empty()){TreeNode* top = st.top();while((LastNode != top->right && LastNode != top->left)&& (top->left || top->right)){if(top->right) st.push(top->right);if(top->left) st.push(top->left);top = st.top(); }LastNode = st.top();v.push_back(top->val);st.pop();}return v;}
};

至此,我们就完成了二叉树后序遍历的非递归修改了。


http://www.hkcw.cn/article/JPyFbGlcvI.shtml

相关文章

DOCKER使用记录

1、拉取镜像 直接使用docker pull <image>&#xff0c;大概率会出现下面的报错信息&#xff1a; (base) jetsonyahboom:~$ docker pull ubuntu:18.04 Error response from daemon: Get "https://registry-1.docker.io/v2/": net/http: request canceled while …

Vert.x学习笔记-EventLoop与Context的关系

Vert.x学习笔记 1. EventLoop 的核心作用2. Context 的核心作用3. EventLoop 与 Context 的关系1. 事件循环&#xff08;EventLoop&#xff09;的核心职责2. 上下文&#xff08;Context&#xff09;的核心职责3. 事件循环与上下文的关系&#xff08;1&#xff09;一对一绑定&am…

LTSPICE仿真电路:(三十一)HOWLAND电流源

1.HOWLAND电流源 推导过程&#xff1a;这个运放是正负反馈都存在&#xff0c;但负反馈是大于正反馈的&#xff0c;因正反馈多出一个Rload&#xff0c;所以可以使用虚短续断&#xff0c;运放的U等于U-&#xff0c;负反馈处得出Uout与U-的关系&#xff0c;再利用正相端节点电流算…

LLaMA-Factory - 批量推理(inference)的脚本

scripts/vllm_infer.py 是 LLaMA-Factory 团队用于批量推理&#xff08;inference&#xff09;的脚本&#xff0c;基于 vLLM 引擎&#xff0c;支持高效的并行推理。它可以对一个数据集批量生成模型输出&#xff0c;并保存为 JSONL 文件&#xff0c;适合大规模评测和自动化测试。…

引擎下线缺陷检测系统ENAgent

引擎下线缺陷检测系统ENAgent采用信号处理技术以及人工智能技术对引擎生产线下线的各种引擎在生产线上进行缺陷实时检测&#xff0c;通过振动信号、声纹信号等信号融合集成&#xff0c;在线实时判断其是否存在缺陷以及进行故障诊断。ENAgent系统采用全Python语言&#xff0c;以…

【量化交易学习】布林线(BOLL)指标

目录 1. 布林线&#xff08;BOLL&#xff09;指标定义与构成1.1 定义1.2 布林线的构成 2. BOLL&#xff08;布林线&#xff09;的应用场景3. BOLL指标的研判标准3.1 BOLL指标中的上、中、下轨线的意义3.2 BOLL指标中的上、中、下轨线之间的关系3.3 K线和布林线上、中、下轨之间…

ArcGIS Pro 创建渔网格网过大,只有几个格网的解决方案

之前用ArcGIS Pro创建渔网的时候&#xff0c;发现创建出来格网过大&#xff0c;只有几个格网。 后来查阅资料&#xff0c;发现是坐标不对&#xff0c;导致设置格网大小时单位为度&#xff0c;而不是米&#xff0c;因此需要进行坐标系转换&#xff0c;网上有很多资料讲了ArcGIS …

java27

1.IO流 FileOutPutStream字节输出流基本用法&#xff1a; 一次性写入一个字符串的内容&#xff1a; 注意&#xff1a;\r或者\n表示把普通的r或者n的字符转义成回车的意思&#xff0c;所以不需要\\ FileInputStream字节输入流基本用法 -1在ASCII码里面对应的符号&#xff1a; 不…

Windows设置之RDP文件用户密码

1、远程桌面另存为rdp文件 2、编辑rdp文件&#xff0c;添加用户名密码信息 username:s:<用户名> password 51:b:<加密后的密码> 3、<加密后的密码>通过PowerShell命令或者 ("<密码>" | ConvertTo-SecureString -AsPlainText -Force) | Conve…

day025-网络基础-DNS与ARP协议

文章目录 1. DNS服务1.1 DNS解析流程1.2 查看本地DNS缓存&#xff08;Windows&#xff09;1.3 查看详细DNS解析流程1.4 排查DNS故障 2. 面试题&#xff1a;用户访问网站过程2.1 抓包 3. ARP协议3.1 命令查看3.2 抓包 4. ICMP协议4.1 禁用ping 5. 踩坑记录1. **为什么解析域名时…

SEO长尾关键词优化实战

内容概要 在长尾关键词优化实践中&#xff0c;系统化的策略设计与执行路径直接影响流量获取效率与转化质量。本文从行业竞争度评估切入&#xff0c;通过多维指标拆解目标关键词的流量价值与操作成本&#xff0c;帮助从业者建立科学的资源分配模型&#xff1b;同时聚焦用户搜索…

复杂业务场景下 JSON 规范设计:Map<String,Object>快速开发 与 ResponseEntity精细化控制HTTP 的本质区别与应用场景解析

Moudle 1 Json使用示例 在企业开发中&#xff0c;构造 JSON 格式数据的方式需兼顾 可读性、兼容性、安全性和开发效率&#xff0c;以下是几种常用方式及适用场景&#xff1a; 一、直接使用 Map / 对象转换&#xff08;简单场景&#xff09; 通过 键值对集合&#xff08;如 M…

Oracle正则表达式学习

目录 一、正则表达简介 二、REGEXP_LIKE(x,匹配项) 三、REGEXP_INSTR 四、REGEXP_SUBSTR 五、REGEXP_REPLACE 一、正则表达简介 相关网址&#xff1a; https://cloud.tencent.com/developer/article/1456428 https://www.cnblogs.com/lxl57610/p/8227599.html https://…

【HW系列】—溯源与定位—Windows入侵排查

文章目录 实在不会手动&#xff0c;打开一个杀毒软件&#xff0c;全盘扫描一、入侵排查思路与流程1. 常见应急响应事件分类2. 入侵排查流程图 二、Windows账户安全排查&#xff08;D盾&#xff0c;微步沙箱&#xff09;1. 正常账户 vs 黑客账户区别2. 自动账户排查3. 隐藏账户与…

【机器学习基础】机器学习入门核心:Jaccard相似度 (Jaccard Index) 和 Pearson相似度 (Pearson Correlation)

机器学习入门核心&#xff1a;Jaccard相似度 &#xff08;Jaccard Index&#xff09; 和 Pearson相似度 &#xff08;Pearson Correlation&#xff09; 一、算法逻辑Jaccard相似度 (Jaccard Index)**Pearson相似度 (Pearson Correlation)** 二、算法原理与数学推导1. Jaccard相…

CVE-2021-28164源码分析与漏洞复现

漏洞概述 漏洞名称&#xff1a;Jetty 路径解析逻辑漏洞导致 WEB-INF 敏感信息泄露 漏洞编号&#xff1a;CVE-2021-28164 CVSS 评分&#xff1a;7.5 影响版本&#xff1a;Jetty 9.4.37 - 9.4.38 修复版本&#xff1a;Jetty ≥ 9.4.39 漏洞类型&#xff1a;路径遍历/信息泄露 C…

微软常用运行库合集(VisualC++)2025.04.22

软件下载 【名称】&#xff1a;微软常用运行库合集(Visual C)2024.11.07 【大小】&#xff1a;76 .7MB 【语言】&#xff1a;简体中文 【安装环境】&#xff1a;Win7/Win8/Win10/Win11 【迅雷网盘下载】&#xff1a; 链接&#xff1a;https://pan.xunlei.com/s/VOCJ3CDOT6HEhQN…

CATANet:面向轻量级图像超分辨率的高效内容感知令牌聚合方法

摘要 基于Transformer的方法在图像超分辨率&#xff08;SR&#xff09;等底层视觉任务中展现出了令人印象深刻的性能。然而&#xff0c;其计算复杂度随空间分辨率呈二次方增长。一系列研究试图通过将低分辨率&#xff08;LR&#xff09;图像划分为局部窗口、轴向条纹或膨胀窗口…

十四、【测试执行篇】让测试跑起来:API 接口测试执行器设计与实现 (后端执行逻辑)

[TOC](【测试执行篇】让测试跑起来&#xff1a;API 接口测试执行器设计与实现 (后端执行逻辑)) 前言 测试执行是测试平台的核心价值所在。一个好的测试执行器需要能够&#xff1a; 准确解析测试用例&#xff1a; 正确理解用例中定义的请求参数和断言条件。可靠地发送请求&am…

Linux环境基础开发工具->make/Makefile

引入&#xff1a;make/Makefile是什么&#xff1f; 前面我们知道了vim负责编辑代码&#xff0c;gcc负责编译代码&#xff0c;而make/Makefile则负责的是自动化编译&#xff01; Makefile是一个文件&#xff0c;make是一条指令 我们在Makefile文件中进行编辑&#xff0c;让哪些…