二叉树基础理论
二叉树(Binary Tree)是一种树形数据结构,其中每个节点最多有两个子节点,通常称为左子节点和右子节点。二叉树的结构特点使其在计算机科学中有广泛的应用,尤其在数据存储、检索和排序等领域。
基本概念:
- 节点(Node):每个节点(Node)包含一个值,称为节点值(或数据),以及指向其左、右子节点的引用(或指针)。
- 根节点(Root Node): 二叉树的最顶层节点,没有父节点。
- 子节点(Child Node): 某个节点的直接下级节点。每个节点最多有两个子节点,称为左子节点和右子节点。
- 父节点(Parent Node): 某个节点的直接上级节点。
- 叶子节点(Leaf Node): 没有子节点的节点,也就是左右子节点都为null的节点。
- 子树(Subtree): 任意节点及其所有后代节点构成的树。
- 深度(Depth): 从根节点到某个节点的路径长度。
- 高度(Height): 从某个节点到叶子节点的最长路径长度。
二叉树的分类
在解题过程中二叉树有两种主要的形式:满二叉树和完全二叉树。
满二叉树
如果一棵二叉树只有 度 为0的结点和 度 为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。
二叉树的节点的度是指一个节点所拥有的子节点的数量。由于每个节点最多只能有两个子节点,因此节点的度只能是0、1或2。度为0的节点即 没有子节点的节点,通常称为叶子节点。
最底下一层的节点的度为0 ,其他的节点的度全为2,这棵二叉树为满二叉树,也可以说深度为k,有2^k-1个节点的二叉树。
完全二叉树
在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层(h从1开始),则该层包含 1~ 2^(h-1) 个节点。
满二叉树一定是完全二叉树
二叉搜索树
前面介绍的树,都没有数值的,而二叉搜索树是有数值的了,二叉搜索树是一个有序树。
若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
它的左、右子树也分别为二叉排序树,
二叉搜索树也称为二叉排序树(Binary Search Tree, BST),或者二叉查找树
平衡二叉搜索树
平衡二叉搜索树:又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
最后一棵 不是平衡二叉树,因为它的左右两个子树的高度差的绝对值超过了1。
C++中map、set、multimap,multiset的底层实现都是平衡二叉搜索树,所以map、set的增删操作时间时间复杂度是logn,
而unordered_map、unordered_set,unordered_map、unordered_set底层实现是哈希表。
二叉树的存储方式
二叉树可以链式存储,也可以顺序存储。那么链式存储方式就用指针, 顺序存储的方式就是用数组。
顺序存储的元素在内存是连续分布的,而链式存储则是通过指针把分布在各个地址的节点串联一起。
链式存储如图:
顺序存储方式如下图:
用数组来存储二叉树如何遍历的呢?如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2。
但是用链式表示的二叉树,更有利于我们理解,所以一般我们都是用链式存储二叉树。但是用数组依然可以表示二叉树。
二叉树的遍历方式
二叉树主要有两种遍历方式:
- 深度优先遍历:先往深走,遇到叶子节点再往回走。
- 广度优先遍历:一层一层的去遍历。
从深度优先遍历和广度优先遍历进一步拓展,才有如下遍历方式:
深度优先遍历
- 前序遍历(递归法,迭代法)
- 中序遍历(递归法,迭代法)
- 后序遍历(递归法,迭代法)
广度优先遍历
- 层次遍历(迭代法)
在深度优先遍历中:有三个顺序,前中后序遍历,这里前中后,其实指的就是中间节点的遍历顺序,只要记住 前中后序指的就是中间节点的位置就可以了。
看如下中间节点的顺序,就可以发现,中间节点的顺序就是所谓的遍历方式
前序遍历:中左右
中序遍历:左中右
后序遍历:左右中
二叉树中深度优先和广度优先遍历实现方式,经常会使用递归的方式来实现深度优先遍历,也就是实现前中后序遍历,使用递归是比较方便的。
之前讲栈与队列的时候,就说过栈其实就是递归的一种实现结构,也就说前中后序遍历的逻辑其实都是可以借助栈使用递归的方式来实现的。
而广度优先遍历的实现一般使用队列来实现,这也是队列先进先出的特点所决定的,因为需要先进先出的结构,才能一层一层的来遍历二叉树。
二叉树的定义
节点的定义
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(NULL), right(NULL) {} //构造函数,使用初始化列表为成员赋初值
};
在定义节点结构体的时候会定义一个构造函数,传入一个值作为节点的值,将两个指针赋为空,
二叉树的定义和链表是差不多的,相对于链表 ,二叉树的节点里多了一个指针, 有两个指针,指向左右孩子。
二叉树的定义
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
// 二叉树节点定义
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
// 构建二叉树
TreeNode* constructTree(const vector<int>& root) {
if (root.empty()) return nullptr;
TreeNode* rootNode = new TreeNode(root[0]);
queue<TreeNode*> q;
q.push(rootNode);
int i = 1;
while (!q.empty() && i < root.size()) {
TreeNode* current = q.front();
q.pop();
// 添加左子节点
if (i < root.size()) {
current->left = new TreeNode(root[i++]);
q.push(current->left);
}
// 添加右子节点
if (i < root.size()) {
current->right = new TreeNode(root[i++]);
q.push(current->right);
}
}
return rootNode;
}
二叉树的递归遍历
递归算法的三个要素:
- 确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
- 确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
- 确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。
前序遍历
- 确定递归函数的参数和返回值:因为要打印出前序遍历节点的数值,所以参数里需要传入vector来放节点的数值,除了这一点就不需要再处理什么数据了也不需要有返回值,所以递归函数返回类型就是void,代码如下:
void traversal(TreeNode* cur, vector<int>& vec)
- 确定终止条件:在递归的过程中,如何算是递归结束了呢,当然是当前遍历的节点是空了,那么本层递归就要结束了,所以如果当前遍历的这个节点是空,就直接return,代码如下:
if (cur == NULL) return;
- 确定单层递归的逻辑:前序遍历是中左右的顺序,所以在单层递归的逻辑,是要先取中节点的数值,代码如下:
vec.push_back(cur->val); // 中
traversal(cur->left, vec); // 左
traversal(cur->right, vec); // 右
前序遍历、中序遍历、后序遍历的区别就是上面取中节点语句(push_back语句)相对其他两行代码的位置,
- 中序遍历
traversal(cur->left, vec); // 左
vec.push_back(cur->val); // 中
traversal(cur->right, vec); // 右
- 后序遍历
traversal(cur->left, vec); // 左
traversal(cur->right, vec); // 右
vec.push_back(cur->val); // 中
LeetCode实现代码如下
class Solution {
public:
void traversal(TreeNode* cur,vector<int>& vec)
{
if(cur == nullptr)
{
return;
}
vec.push_back(cur->val);
traversal(cur->left,vec);
traversal(cur->right,vec);
}
vector<int> preorderTraversal(TreeNode* root) {
vector<int>vec;
traversal(root,vec);
return vec;
}
};
LeetCode中的代码给出的root数组,例如root = [1, 5, 2, 3, 8, 7, 4, 6],这实际上是一个层序遍历(或广度优先遍历)的顺序列表。如果用这个顺序构建一个二叉树,每个元素按照列表顺序填充二叉树,从左到右逐层填充。
只不过LeetCode中省略了二叉树的构建代码,
递归过程详解
root = [1, 5, 2, 3, 8, 7, 4, 6],以 后序遍历为例,其具体的递归过程如下如所示:
整体代码(包括二叉树的构建和遍历)
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
// 二叉树节点定义
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
// 构建二叉树
TreeNode* constructTree(const vector<int>& root) {
if (root.empty()) return nullptr;
TreeNode* rootNode = new TreeNode(root[0]);
queue<TreeNode*> q;
q.push(rootNode);
int i = 1;
while (!q.empty() && i < root.size()) {
TreeNode* current = q.front();
q.pop();
// 添加左子节点
if (i < root.size()) {
current->left = new TreeNode(root[i++]);
q.push(current->left);
}
// 添加右子节点
if (i < root.size()) {
current->right = new TreeNode(root[i++]);
q.push(current->right);
}
}
return rootNode;
}
// 后序遍历
void postorderTraversal(TreeNode* node, vector<int>& result) {
if (node == nullptr) return;
// 递归访问左子树
postorderTraversal(node->left, result);
// 递归访问右子树
postorderTraversal(node->right, result);
// 访问根节点
result.push_back(node->val);
}
// 主函数
int main() {
// 给定的二叉树层序列表
vector<int> root = {1, 5, 2, 3, 8, 7, 4, 6};
// 构建二叉树
TreeNode* rootNode = constructTree(root);
// 获取后序遍历结果
vector<int> result;
postorderTraversal(rootNode, result);
// 输出后序遍历结果
cout << "Postorder Traversal: ";
for (int val : result) {
cout << val << " ";
}
cout << endl;
return 0;
}
二叉树的迭代遍历
前序遍历迭代实现
前序遍历的顺序是中左右,每次先处理的是中间节点,那么先将根节点放入栈中,然后将右孩子加入栈,再加入左孩子。
因为栈的特性是先进后出,我们要先处理左节点,所有就将左节点最后放入
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> 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;
}
};
中序遍历迭代法实现
给定一个二叉树,必定从根节点开始访问,先访问的是根节点,但是先处理的(将节点的值插入数组)却不是根节点,中序遍历的顺序是 左中右,先处理的是左节点,但是先访问到的是根节点
中序遍历需要用到一个指针来遍历二叉树,一个栈,一个动态数组vector,栈用来记录指针指向的节点,
代码逻辑:
- 新建一个动态数组存储结果,新建一个栈(以二叉树节点为元素),新建一个指针初始化指向根节点
- 进入while循环,循环条件为指针不为空或栈不为空
- 循环中加入一个if条件判断,判断指针是否不为空,如果不为空,那就向栈中加入元素,并且指针指向当前节点的左子节点
- 如果为空,那就将指针初始化为栈顶元素,并将栈顶元素弹出,然后将指针指向的节点的值插入vector中,指针指向右子节点
代码如下:
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;
}
};