目录
一、二叉树理论基础
树的基本定义
树是 n ( n > 0 ) n(n>0) n(n>0) 个结点的有限集合, n = 0 n=0 n=0 时称为空树;
有且仅有一个结点被称为根 ( r o o t ) (root) (root) ,每个结点可以有若干个子结点(子树),没有子结点的结点被称为 叶子结点
树的基本术语
- 节点的度 ( d e g r e e ) (degree) (degree):节点拥有的子树个数成为节点的度;
- 树的度:树的度是树内各节点度的最大值;
- 层次:节点的层次从根开始定义,根为第一层,根的孩子为第二层树中任意节点的层次 = = = 它的双亲层次 + 1 +1 +1
- 高度:树中节点的最大层次成为树的高度;
- 森林:是 m ( m ≥ 0 ) m(m\ge0) m(m≥0) 颗互不相交的树的集合;对任意一棵树而言,其子树组成森林。
二叉树的定义
对于一棵树 T T T ,树中每个结点都含有 T l , T r T_l, T_r Tl,Tr 两棵子树(可以为空树),则该树称为二叉树
二叉树的基本性质
- 二叉树的第 i i i 层有 2 ( i − 1 ) 2^{(i-1)} 2(i−1) 个结点
- 深度为 k k k 的二叉树至多有 2 k − 1 2^k-1 2k−1 个结点
- 任何一棵二叉树,若叶子数为
n
0
n_0
n0,度为
2
2
2 的节点数为
n
2
n_2
n2 , 则 :
n 0 = n 2 + 1 n_0 = n_2 + 1 n0=n2+1 - 具有 n n n 个结点的完全二叉树的深度为 [ l o g 2 n ] + 1 [log2 n]+1 [log2n]+1 (不大于 x x x 的最大整数)
- 如果有一棵
n
n
n 个结点的完全二叉树,则对任一结点
i
i
i :
- 若 i = 1 i = 1 i=1,则i是二叉树的根,无双亲;若 i > 1 i>1 i>1, 则其双亲是结点 [ i / 2 ] [i/2] [i/2]
- 如果 2 i > n 2i > n 2i>n ,则结点 i i i 为叶子结点,无左孩子;否则,其左孩子是结点 2 i 2i 2i;
- 如果 2 i + 1 > n 2i + 1 > n 2i+1>n, 则结点 i i i 无右孩子;否则,右孩子是结点 2 i + 1 2i+1 2i+1;
二叉树的存储方式(物理结构)
1. 顺序存储
按照层次遍历的顺序,将结点存储在数组中
2. 链式存储
结点与结点间通过指针相互关联;
链式存储的C++代码如下:
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};
二、二叉树的深度优先遍历
二叉树的结点遍历方式主要可分为深度优先遍历与广度优先遍历(层次遍历);而深度优先遍历按照遍历方法可分为递归和迭代两种,按照结点遍历的顺序可分为前序、中序、后序三种方法。
递归遍历(最基础的遍历方法)
对于任何问题的递归法,重要的是确定递归的终止条件、递归操作主体、函数传参与返回值,从而编写出完整的递归函数;
前序遍历(中左右)
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); // 中
}
迭代遍历(进阶版)
通过栈数据结构的学习我们可以得知,递归的实现是需要依靠栈进行操作的,那么我们实际上可以自己构建一个栈,用迭代的方式实现和递归一样的效果,但具体的思路和递归有所区别;
前序遍历
由于前序遍历的顺序为“中左右”,中间的双亲结点最先遍历输出,那么就很方便我们使用栈进行遍历,弹出一个结点直接存入要输出的数组中,并将他的右孩子、左孩子依次入栈;先右后左是为了保证中左右的输出顺序;
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
stack<TreeNode*> nodes_stack;
vector<int> nodelist;
if(root){
nodes_stack.push(root); //根结点入栈
}
while(!nodes_stack.empty()){
TreeNode* node = nodes_stack.top();
nodelist.push_back(node->val); //双亲结点输出,即中
nodes_stack.pop();
if(node->right) nodes_stack.push(node->right); //右入栈
if(node->left) nodes_stack.push(node->left); //左入栈
}
return nodelist;
}
};
后序遍历
后续遍历的输出顺序为“左右中”,注意到双亲结点最后输出,那么如果我们将顺序倒过来“中右左”,发现和前序遍历可以实现同样的思路,只需要将中间遍历时的左右孩子入栈顺序颠倒一下,并在程序最后将输出的数组进行翻转即可;
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;
}
};
中序遍历
中序遍历与前序后序较为不同,因为无法通过简单的顺序反转使得中间结点最先遍历;由于我们最先用指针或栈就是中间结点,因此中间结点无法最先遍历意味着:访问结点和遍历输出结点不能同时实现。因此我们需要对操作进行一些调整;
笔者对于中序遍历代码的理解为:使用栈优先存入所有的最左边的结点(根结点及所有左孩子),而后利用二叉树的性质对结点依次输出,达到遍历的目的;
由于中序遍历是优先输出左结点,那么第一个结点为从根结点出发一直寻找左孩子,直到找到第一个叶子结点,我们将寻找过程中访问到的结点入栈,便于后续操作:
while(node || !nodes_stack.empty()){
if(node){
nodes_stack.push(node);
node = node->left;
}
找到叶子结点以后,由于叶子结点的左右孩子都为空,我们可以以此为判定条件输出结点,即node == NULL
时,输出当前栈顶的结点:
if(node==NULL){
node = nodes_stack.top();
nodes_stack.pop();
tralist.push_back(node->val);
}
要维持迭代就不能保持node
不变,因此在输出结点以后需要对node
值进行修改;而我们一路访问过来实际上只有双亲和左孩子结点(左、中),这些在后续迭代过程中会按照左中的顺序弹出,缺少了右孩子的遍历,因此我们在弹出结点的时候,访问该结点的右孩子,就可以保证左中右的结点按照顺序输出:
node = node->right;
完整流程图如下:
对于中序遍历代码的理解,笔者认为是应用了二叉树的性质:
第一遍所有左孩子和根结点入栈时,实际上是存储了左、中的子树,而结点的输出实际上是依靠对当前访问节点是否为空的判断,即:
if(node==NULL)
这个方法可行之处恰恰在于二叉树的空子树数量和结点数量的关系,我们可以作简单的数学推导:
用 n 0 , 1 , 2 n_{0, 1, 2} n0,1,2 分别代表度为 0 , 1 , 2 0, 1, 2 0,1,2 的结点个数, n n n 为总结点个数, N N U L L N_{NULL} NNULL 为空子树的个数;
我们已知二叉树中的结点个数为: n = n 2 + n 1 + n 0 n = n_2+n_1+n_0 n=n2+n1+n0;
若将二叉树看做图,图中结点和边的关系满足:边数 = 结点数 + 1,即:
n = 2 ∗ n 2 + n 1 − 1 n=2*n_2+n_1-1 n=2∗n2+n1−1
联立上述两式可得二叉树定理: n 2 = n 0 + 1 n_2=n_0+1 n2=n0+1
二叉树中的空子树个数可表示为: N N U L L = 2 ∗ n 0 + n 1 N_{NULL}=2*n_0+n_1 NNULL=2∗n0+n1
结合上面的式子可以联立求出:
N N U L L = n + 1 N_{NULL}=n+1 NNULL=n+1
即对任意二叉树,空子树个数为结点个数+1,因而依靠访问空子树时输出,一定可以达到遍历到全部结点的目的。
完整C++代码如下:
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
vector<int> tralist;
stack<TreeNode*> nodes_stack;
TreeNode* node = root;
while(node || !nodes_stack.empty()){
if(node){
nodes_stack.push(node);
node = node->left;
}
else{
node = nodes_stack.top();
nodes_stack.pop();
tralist.push_back(node->val);
node = node->right;
}
}
return tralist;
}
};
二叉树统一规格迭代方法
在上文的迭代遍历中,我们编写中序遍历的方法与前后序差别很大,不便于统一的贯通理解;本部分将记录三种顺序的遍历统一规格的迭代方法。
基础思路来源于,笔者自己在编写中序遍历代码时,想象如果可以在函数中操作二叉树(删除遍历过的结点)或对二叉树结点作标记(更改二叉树结点的定义,加上一个bool
值判断是否遍历过,用来控制输出),就可以达到统一三种迭代遍历的代码风格了。
统一迭代的思路其实就是做标记判断是否遍历输出,但是标记会当作元素直接入栈,当访问到NULL
时才对栈中结点进行输出;
代码如下:
中序
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> st;
if (root != NULL) st.push(root);
while (!st.empty()) {
TreeNode* node = st.top();
if (node != NULL) {
st.pop(); // 将该节点弹出,避免重复操作,下面再将右中左节点添加到栈中
if (node->right) st.push(node->right); // 添加右节点(空节点不入栈)
st.push(node); // 添加中节点
st.push(NULL); // 中节点访问过,但是还没有处理,加入空节点做为标记。
if (node->left) st.push(node->left); // 添加左节点(空节点不入栈)
} else { // 只有遇到空节点的时候,才将下一个节点放进结果集
st.pop(); // 将空节点弹出
node = st.top(); // 重新取出栈中元素
st.pop();
result.push_back(node->val); // 加入到结果集
}
}
return result;
}
};
前序
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> st;
if (root != NULL) st.push(root);
while (!st.empty()) {
TreeNode* node = st.top();
if (node != NULL) {
st.pop();
if (node->right) st.push(node->right); // 右
if (node->left) st.push(node->left); // 左
st.push(node); // 中
st.push(NULL);
} else {
st.pop();
node = st.top();
st.pop();
result.push_back(node->val);
}
}
return result;
}
};
后序
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> st;
if (root != NULL) st.push(root);
while (!st.empty()) {
TreeNode* node = st.top();
if (node != NULL) {
st.pop();
st.push(node); // 中
st.push(NULL);
if (node->right) st.push(node->right); // 右
if (node->left) st.push(node->left); // 左
} else {
st.pop();
node = st.top();
st.pop();
result.push_back(node->val);
}
}
return result;
}
};
三、二叉树的广度优先遍历(层次遍历)
上文提到的深度优先遍历采用递归的思想,在使用迭代实现时则采用了递归实现的数据结构——栈;层次遍历满足广度优先搜索的特性,则更适合使用先入先出的队列结构进行实现;
实现思路为:在遍历输出本层元素的同时,将本层元素的所有孩子入队,那么当本层元素遍历完以后,下一层的所有元素刚好全部入队,如此循环即可;
需要注意的一点即是,在循环输出单层元素时,需要提前记录本层的结点个数来控制循环遍历的次数,因为在遍历过程中队列中的结点个数会变化。
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++) {
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);
}
return result;
}
};
总结
本日内容主要是对二叉树数据结构的基本理论以及遍历方式的复习,题目并不算难,在于打好基本功。
文章图片来源:代码随想录 (https://programmercarl.com/)