提示:DDU,供自己复习使用。欢迎大家前来讨论~
第六章 二叉树part01
了解二叉树的类型、存储机制、遍历方法和基本定义
一、二叉树理论基础
1.1 二叉树的种类
在解题过程中二叉树有两种主要的形式:满二叉树和完全二叉树。
满二叉树
满二叉树:如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。
这棵二叉树为满二叉树,也可以说深度为k,有 2 k − 1 2^k-1 2k−1个节点的二叉树。
完全二叉树
什么是完全二叉树?
完全二叉树的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层(h从1开始),则该层包含 1~ 2^(h-1) 个节点。
优先级队列本质上是一种特殊的数据结构——堆。这种堆结构实际上是一棵完全二叉树,它不仅保持了完全二叉树的特性,同时保证父子节点的顺序关系。
二叉搜索树
前面介绍的树,都没有数值的,而二叉搜索树是有数值的了,二叉搜索树是一个有序树。
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉排序树
下面这两棵树都是搜索树.
平衡二叉搜索树
平衡二叉搜索树:又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
最后一棵 不是平衡二叉树,因为它的左右两个子树的高度差的绝对值超过了1。
在C++编程语言中,map
、set
、multimap
和multiset
这些容器的底层实现基于==平衡二叉搜索树==,这确保了它们在执行插入和删除操作时,能够以对数级的时间复杂度
O
(
l
o
g
n
)
O(logn)
O(logn)高效完成。这里特别指出,我们讨论的并不包括unordered_map
和unordered_set
,因为它们是基于哈希表的实现,其性能特点与平衡二叉搜索树有所不同。
对于一个包含n个节点的二叉搜索树,如果它是平衡的,那么其高度大约是logn。这是因为每向下移动一层,树的宽度大约增加一倍,所以从根节点到任何叶节点的路径长度大约是logn。
相比之下,哈希表通过哈希函数将键直接映射到数组的索引上,理想情况下可以提供O(1)O(1)的查找、插入和删除时间复杂度。但哈希表在处理大量冲突时性能可能会下降。
卡哥建议:“所以大家使用自己熟悉的编程语言写算法,一定要知道常用的容器底层都是如何实现的,最基本的就是map、set等等,否则自己写的代码,自己对其性能分析都分析不清楚!”
1.2 二叉树的存储方式
二叉树可以链式存储,也可以顺序存储。
那么链式存储方式就用指针, 顺序存储的方式就是用数组。
顾名思义就是顺序存储的元素在内存是连续分布的,而链式存储则是通过指针把分布在各个地址的节点串联一起。
链式存储如图:
链式存储是大家很熟悉的一种方式,那么我们来看看如何顺序存储呢?
其实就是用数组来存储二叉树,顺序存储的方式如图:
用数组来存储二叉树如何遍历的呢?
如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2。
但是用链式表示的二叉树,更有利于我们理解,所以一般我们都是用链式存储二叉树。
所以大家要了解,用数组依然可以表示二叉树。
1.3 二叉树的遍历方式
二叉树主要有两种遍历方式:
- 深度优先遍历:先往深走,遇到叶子节点再往回走。
- 广度优先遍历:一层一层的去遍历。
这两种遍历是图论中最基本的两种遍历方式,后面在介绍图论的时候 还会介绍到。
那么从深度优先遍历和广度优先遍历进一步拓展,才有如下遍历方式:
- 深度优先遍历
- 前序遍历(递归法,迭代法)
- 中序遍历(递归法,迭代法)
- 后序遍历(递归法,迭代法)
- 广度优先遍历
- 层次遍历(迭代法)
再说一说二叉树中深度优先和广度优先遍历实现方式,我们做二叉树相关题目,经常会使用递归的方式来实现深度优先遍历,也就是实现前中后序遍历,使用递归是比较方便的。
之前我们讲栈与队列的时候,就说过栈其实就是递归的一种实现结构,也就说前中后序遍历的逻辑其实都是可以借助栈使用递归的方式来实现的。
同时,我们还提到了**广度优先搜索(BFS)**通常使用队列来实现,这是因为队列的先进先出(FIFO)特性非常适合逐层遍历二叉树的需要。这种逐层遍历的方式,正是队列结构所擅长的。
1.4 二叉树的定义
二叉树有两种存储方式顺序存储,和链式存储,顺序存储就是用数组来存,这个定义没啥可说的,下面重点看链式存储的二叉树节点的定义方式。
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};
可以发现二叉树的定义和链表的定义是差不多的,相对于链表 ,二叉树的节点里多了一个指针, 有两个指针,指向左右孩子。
知识补充:
在C++中,成员初始化列表是构造函数的一部分,它允许你在构造函数体执行之前直接初始化类的非静态成员变量。成员初始化列表的使用可以提高效率,特别是对于不可复制或不可移动的对象(如指针、引用等)的初始化。
使用成员初始化列表来初始化一个对象的成员变量是一个简单而直接的过程。
步骤:
- 定义类和成员变量:首先,你需要有一个类定义,其中包含你想要初始化的成员变量。
- 编写构造函数:在类中定义一个或多个构造函数。
- 使用冒号和初始化列表:在构造函数的参数列表之后,使用冒号
:
来开始成员初始化列表。 - 指定成员变量和初始值:在成员初始化列表中,指定每个成员变量和它的初始值。成员变量的名称后面跟着括号,括号内是该成员变量的初始值。
- 编写构造函数体:可选地,在成员初始化列表之后,你可以编写构造函数的函数体,以包含任何需要在对象创建后立即执行的代码。
class Point {
public:
int x;
int y;
// 使用成员初始化列表初始化成员变量
Point(int xValue, int yValue) : x(xValue), y(yValue) {
// 构造函数体可以为空,或者包含其他初始化后的操作
}
};
在这个示例中,Point
类的构造函数接受两个参数xValue
和yValue
。这些参数直接用于初始化成员变量x
和y
。成员初始化列表: x(xValue), y(yValue)
确保了x
和y
在进入构造函数体之前就已经被初始化。
注意事项:
- 成员初始化列表是在调用构造函数之前执行的(即花括号
{}
内的代码),因此在成员初始化列表中不能使用this
指针。 - 如果成员变量的类型没有默认构造函数,或者你想要避免不必要的复制,使用成员初始化列表是必要的。
- 对于常量成员变量或引用成员变量,必须使用成员初始化列表进行初始化。
使用成员初始化列表可以提高代码的效率和清晰度,特别是在处理复杂的对象初始化时。
使用场景:
当你需要对成员变量进行初始化,而这些成员变量的类型不能通过简单的赋值进行初始化时,成员初始化列表就显得尤为重要。
二、递归遍历 (必须掌握)
对递归不成体系,没有方法论,每次写递归算法 ,都是靠玄学来写代码,代码能不能编过都靠运气。通过简单题目把方法论确定下来,有了方法论,后面才能应付复杂的递归。
递归算法的三个要素:
- 确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
- 确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
- 确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。
下面以前序遍历为例:
- 确定递归函数的参数和返回值:递归函数需要两个参数,一个是当前遍历的节点
cur
(类型为TreeNode*
),另一个是一个引用vec
,指向一个vector<int>
,用来存储遍历过程中访问的节点的值。由于递归函数的目的是填充这个向量,而不是返回一个值,所以函数的返回类型是void
。
void traversal(TreeNode* cur, vector<int>& vec)
- 确定终止条件:递归需要有一个结束条件,否则会无限递归下去。这里的终止条件是当前遍历的节点为空(即
cur == NULL
),如果节点为空,递归就会停止。
if (cur == NULL) return;
- 确定单层递归的逻辑:在前序遍历中,我们首先访问根节点(“中”),然后递归地遍历左子树(“左”),最后递归地遍历右子树(“右”)。在递归的每一步中,我们首先将当前节点的值添加到
vec
中,然后递归地对左子节点和右子节点进行相同的操作。
vec.push_back(cur->val); // 中
traversal(cur->left, vec); // 左
traversal(cur->right, vec); // 右
完整的代码:
//前序遍历
class Solution {
public:
void traversal(TreeNode* cur, vector<int>& vec) {
if (cur == NULL) return;
vec.push_back(cur->val); // 中
traversal(cur->left, vec); // 左
traversal(cur->right, vec); // 右
}
vector<int> preorderTraversal(TreeNode* root) {
vector<int> result;
traversal(root, result);
return result;
}
};
//中序遍历
void traversal(TreeNode* cur, vector<int>& vec) {
if (cur == NULL) return;
traversal(cur->left, vec); // 左
vec.push_back(cur->val); // 中
traversal(cur->right, vec); // 右
}
//后序遍历
void traversal(TreeNode* cur, vector<int>& vec) {
if (cur == NULL) return;
traversal(cur->left, vec); // 左
traversal(cur->right, vec); // 右
vec.push_back(cur->val); // 中
}
代码示例中,preorderTraversal
是一个公共成员函数,它提供了一个接口供外部代码调用,以执行前序遍历操作。而traversal
是一个私有成员函数,用于实现递归遍历的逻辑。通过这种方式,Solution
类隐藏了递归实现的细节,只暴露了一个简单的公共接口给外部使用,这符合面向对象设计的原则。
三、二叉树的迭代遍历(非递归)
为什么可以用迭代法(非递归的方式)来实现二叉树的前后中序遍历呢?
前序遍历(迭代法)
前序遍历是中左右,每次先处理的是中间节点,那么先将根节点放入栈中,然后将右孩子加入栈,再加入左孩子。=》这样出栈的时候才是中左右的顺序
代码如下(代码中空结点不入栈):
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
stack<TreeNode*> st;
vector<int> result;
if (root == NULL) return result;
st.push(root);
while (!st.empty()) {
TreeNode* node = st.top(); // 中
st.pop();
result.push_back(node->val);
if (node->right) st.push(node->right); // 右(空节点不入栈)
if (node->left) st.push(node->left); // 左(空节点不入栈)
}
return result;
}
};
中序遍历(迭代法)
在迭代的过程中,其实我们有两个操作:
- 处理:将元素放进result数组中
- 访问:遍历节点
前序遍历的代码不能直接用于中序遍历,因为前序遍历的访问和处理顺序是“中左右”,而中序遍历的访问顺序是“左右中”,这导致访问和处理节点的时机不一致,需要使用栈来在迭代法中序遍历中正确地处理节点元素。
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> st;
TreeNode* cur = root;
while (cur != NULL || !st.empty()) {
if (cur != NULL) { // 指针来访问节点,访问到最底层
st.push(cur); // 将访问的节点放进栈
cur = cur->left; // 左
} else {
cur = st.top(); // 从栈里弹出的数据,就是要处理的数据(放进result数组里的数据)
st.pop();
result.push_back(cur->val); // 中
cur = cur->right; // 右
}
}
return result;
}
};
后序遍历(迭代法)
先序遍历是中左右,后序遍历是左右中,那么我们只需要调整一下先序遍历的代码顺序,就变成中右左的遍历顺序,然后再反转result数组,输出的结果顺序就是左右中(即后序遍历的顺序)了,如下图:
所以后序遍历只需要前序遍历的代码稍作修改就可以了,代码如下:
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
stack<TreeNode*> st;
vector<int> result;
if (root == NULL) return result;
st.push(root);
while (!st.empty()) {
TreeNode* node = st.top();
st.pop();
result.push_back(node->val);
if (node->left) st.push(node->left); // 相对于前序遍历,这更改一下入栈顺序 (空节点不入栈)
if (node->right) st.push(node->right); // 空节点不入栈
}
reverse(result.begin(), result.end()); // 将结果反转之后就是左右中的顺序了
return result;
}
};
层序遍历(迭代法)
层序遍历一个二叉树。就是从左到右一层一层的去遍历二叉树。这种遍历的方式和我们之前讲过的都不太一样。
需要借用一个辅助数据结构即队列来实现,队列先进先出,符合一层一层遍历的逻辑,而用栈先进后出适合模拟深度优先遍历也就是递归的逻辑。
而这种层序遍历方式就是图论中的广度优先遍历,只不过我们应用在二叉树上。
c++代码如下:
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
queue<TreeNode*> que;
if (root != NULL) que.push(root);
vector<vector<int>> result;
while (!que.empty()) {
int size = que.size();
vector<int> vec;
// 这里一定要使用固定大小size,不要使用que.size(),因为que.size是不断变化的,队列中的元素一直在变化。
for (int i = 0; i < size; i++) {//while(size--)
TreeNode* node = que.front();
que.pop();
vec.push_back(node->val);
if (node->left) que.push(node->left);
if (node->right) que.push(node->right);
}
result.push_back(vec);
//如果 result 是一个 vector<int>,vec 也是一个 vector<int>,那么 result.push_back(vec);
//将会把 vec 中的所有整数元素追加到 result 的末尾。
//如果 vec 是一个单一的整数,那么 push_back 将会把这一个整数添加到 result 的末尾。
}
return result;
}
};
# 递归法
class Solution {
public:
void order(TreeNode* cur, vector<vector<int>>& result, int depth)
{
if (cur == nullptr) return;
if (result.size() == depth) result.push_back(vector<int>());
result[depth].push_back(cur->val);
order(cur->left, result, depth + 1);
order(cur->right, result, depth + 1);
}
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>> result;
int depth = 0;
order(root, result, depth);
return result;
}
};
四、题目
题目一:144. 二叉树的前序遍历
细节:
vector<int>& result`和`vector<int> result
问题在于travelsal
函数中的参数vector<int> result
是一个值传递参数,而不是引用传递。这意味着每次调用travelsal
函数时,都会创建result
的一个新副本,而不是操作原始向量。由于递归调用中的每个result
都是独立的副本,所以对result
所做的更改不会反映到上一层递归调用的result
中。
代码如下:
class Solution {
public:
void travelsal(TreeNode* root, vector<int>& result) {
if (root == NULL)
return;
result.push_back(root->val);
travelsal(root->left, result);
travelsal(root->right, result);
}
vector<int> preorderTraversal(TreeNode* root) {
vector<int> result;
travelsal(root, result);
return result;
}
};
- 时间复杂度: push和empty为O(1), pop和peek为O(n)
- 空间复杂度: O(n)
题目二:145. 二叉树的后序遍历
class Solution {
public:
void traversal(TreeNode* root, vector<int>& result) {
if (root == NULL)
return;
traversal(root->left, result);
traversal(root->right, result);
result.push_back(root->val);
}
vector<int> postorderTraversal(TreeNode* root) {
vector<int> result;
traversal(root, result);
return result;
}
};
题目三:94. 二叉树的中序遍历
class Solution {
public:
void traversal(TreeNode* root, vector<int>& result) {
if (root == NULL)
return;
traversal(root->left, result);
result.push_back(root->val);
traversal(root->right, result);
}
vector<int> inorderTraversal(TreeNode* root) {
vector<int> result;
traversal(root, result);
return result;
}
};
- 时间复杂度: O(n)
- 空间复杂度: O(n)
总结
- 二叉树的理论基础。
- 二叉树的遍历方式:前中后序遍历 =》(深度优先遍历),层序遍历=》(广度优先遍历)
- 迭代遍历与层序遍历:与递归遍历相对,迭代遍历通常使用栈或队列来实现,而层序遍历则是一种典型的广度优先遍历(BFS),它按照从上到下、从左到右的顺序逐层访问树的所有节点。这种方法通常使用队列来实现,确保按层访问。