目录
- 0 引言
- 1 递归遍历
- 1.1 前序遍历
- 1.2 后序遍历
- 1.3 中序遍历
- 2 迭代遍历
- 2.1 前序和后序
- 2.2 中序
- 🙋♂️ 作者:海码007
- 📜 专栏:算法专栏
- 💥 标题:算法刷题Day14 | 二叉树理论、递归遍历、迭代遍历、统一迭代
- ❣️ 寄语:书到用时方恨少,事非经过不知难!
0 引言
继续加油!
1 递归遍历
- 🎈 文档讲解
- 🎈 视频讲解
- 🎈 做题状态:简单
写好递归的三大步骤:
- 确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数,并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
- 确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
- 确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。(对于树的遍历可以直接考虑只有三个节点的满二叉树的情况)
1.1 前序遍历
class Solution {
public:
void recursion(TreeNode* root, vector<int>& data)
{
// 确定参数和返回值
// 确定递归终止条件
// 确定单层遍历逻辑,也就是最简单的二叉树遍历的逻辑
if (root == nullptr) return;
data.push_back(root->val);
recursion(root->left, data);
recursion(root->right, data);
}
vector<int> preorderTraversal(TreeNode* root) {
vector<int> result;
recursion(root, result);
return result;
}
};
1.2 后序遍历
前中后遍历的递归写法很类似,只需要将数据保存的顺序放到最后就行。
void recursion(TreeNode* root, vector<int>& data)
{
// 确定参数和返回值
// 确定递归终止条件
// 确定单层遍历逻辑,也就是最简单的二叉树遍历的逻辑
if (root == nullptr) return;
recursion(root->left, data);
recursion(root->right, data);
data.push_back(root->val);
}
1.3 中序遍历
void recursion(TreeNode* root, vector<int>& data)
{
// 确定参数和返回值
// 确定递归终止条件
// 确定单层遍历逻辑,也就是最简单的二叉树遍历的逻辑
if (root == nullptr) return;
recursion(root->left, data);
data.push_back(root->val);
recursion(root->right, data);
}
2 迭代遍历
- 🎈 文档讲解
- 🎈 视频讲解
- 🎈 做题状态:
1. 递归的底层实现
通常情况下,递归的底层实现会利用函数调用栈。每次递归调用都会在函数调用栈上创建一个新的栈帧,用于存储该次函数调用的局部变量、参数值和返回地址等信息。当递归调用结束时,对应的栈帧会被销毁,控制权返回到上一级调用的位置。
这种递归调用过程会导致函数调用栈的深度不断增加,直到达到系统所能容纳的最大深度(通常由系统栈的大小限制),如果递归的深度超过了这个限制,就会发生栈溢出错误。
因此,在实际编程中,需要注意控制递归的深度,尤其是在处理大规模数据或者递归深度较深的情况下,以避免栈溢出的问题。
2. 迭代是什么?为什么叫做迭代的遍历方式?
在计算机科学中,“迭代”(iteration)通常指的是通过重复的过程来逐步接近所需的结果。在二叉树的迭代遍历中,“迭代"指的是使用循环而不是递归来遍历树的节点。这种方法通常会利用栈或队列等数据结构来模拟递归过程中的函数调用栈
,以便在不使用递归函数调用的情况下实现遍历。因此,虽然我们仍然按照树的结构遍历节点,但我们没有使用递归函数调用,而是通过在循环中模拟这些调用来完成遍历。因此,这种遍历方式被称为"迭代遍历”。
3. 迭代就是模拟递归的底层实现
迭代方法通常会模拟递归的底层实现,但是使用显式的数据结构(比如栈或队列)来管理状态,而不是依赖系统的函数调用栈。通过手动管理状态,迭代方法能够避免递归调用带来的额外开销,例如函数调用的内存消耗和栈溢出的风险。
迭代法通常需要使用循环结构来模拟递归调用的过程,并且需要适当地更新状态以确保迭代的终止条件得以满足。虽然这可能会增加代码的复杂性,但通常可以提高算法的效率和可控性。
总之,迭代法可以视为一种更灵活、更可控的方式来实现递归算法,尤其是在需要处理大规模数据或者避免栈溢出的情况下。
2.1 前序和后序
循环内处理的是三个节点,根节点、右节点、左节点。
初始化栈的时候是将根节点入栈。然后循环内是先将栈顶元素弹出(弹出之前别忘记先将值保存到一个变量中,不然我们就访问不到左右节点了),然后判断刚才弹出节点的右子树是否为空,不为空则将右节点入栈,再判断左节点是否为空,不为空则左节点入栈。然后到了下一个循环,下一个循环就是现将栈顶节点弹出,然后判断其左右节点。
循环终止条件:栈为空。
下面是前序迭代的代码
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
// 使用栈容器,模拟递归调用的函数栈
vector<int> result;
if (root == nullptr) return result;
stack<TreeNode*> node;
node.push(root);
while(!node.empty())
{
// 栈顶数据存下来,然后弹出栈
result.push_back(node.top()->val);
TreeNode* temp = node.top();
node.pop();
// 然后判断刚才弹出的栈顶元素的右子树是否为空,若不为空,则压栈
if(temp->right != nullptr)
{
node.push(temp->right);
}
// 然后判断刚才弹出的栈顶元素的左子树是否为空,若不为空,则压栈
if(temp->left != nullptr)
{
node.push(temp->left);
}
}
return result;
}
};
总结:循环内所做的事情:先将栈顶元素弹出,然后判断栈顶元素是否有右节点,如果有则压栈,再去判断栈顶元素的左节点是否为空,不为空则压栈。
疑惑点:前序遍历是 中左右,为什么压栈顺序是 中右左,因为栈是先进后出的容器,所以需要将左右调换次序。
前序是 中左右,如果将循环内的压栈顺序该一下就可以得到 中右左,然后再将数组进行翻转就可以得到 左右中,也就是后序遍历的次序。
后序遍历代码如下:
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
// 使用栈容器,模拟递归调用的函数栈
vector<int> result;
if (root == nullptr) return result;
stack<TreeNode*> node;
node.push(root);
while(!node.empty())
{
// 栈顶数据存下来,然后弹出栈
result.push_back(node.top()->val);
TreeNode* temp = node.top();
node.pop();
// 然后判断刚才弹出的栈顶元素的左子树是否为空,若不为空,则压栈
if(temp->left != nullptr)
{
node.push(temp->left);
}
// 然后判断刚才弹出的栈顶元素的右子树是否为空,若不为空,则压栈
if(temp->right != nullptr)
{
node.push(temp->right);
}
}
reverse(result.begin(), result.end());
return result;
}
};
2.2 中序
中序遍历的迭代不同通过前序的简单调换顺序来实现。
分析一下为什么刚刚写的前序遍历的代码,不能和中序遍历通用呢,因为前序遍历的顺序是中左右,先访问的元素是中间节点,要处理的元素也是中间节点,所以刚刚才能写出相对简洁的代码,因为要访问的元素和要处理的元素顺序是一致的,都是中间节点。
那么再看看中序遍历,中序遍历是左中右,先访问的是二叉树顶部的节点,然后一层一层向下访问,直到到达树左面的最底部,再开始处理节点(也就是在把节点的数值放进result数组中),这就造成了处理顺序和访问顺序是不一致的。
那么在使用迭代法写中序遍历,就需要借用指针的遍历来帮助访问节点,栈则用来处理节点上的元素。