1、二叉树理论基础
1.1 二叉树分类
满二叉树,也可以说深度为k,有2k-1个节点的二叉树
完全二叉树,若最底层为第 h 层(h从1开始),则该层包含 1~ 2h-1 个节点
优先级队列其实是一个堆,堆就是一棵完全二叉树,同时保证父子节点的顺序关系
二叉搜索树是有数值的了,二叉搜索树是一个有序树
若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
它的左、右子树也分别为二叉排序树
平衡二叉搜索树:又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树
最后一棵 不是平衡二叉树,因为它的左右两个子树的高度差的绝对值超过了1
C++中map、set、multimap,multiset的底层实现都是平衡二叉搜索树,所以map、set的增删操作时间时间复杂度是logn,注意我这里没有说unordered_map、unordered_set,unordered_map、unordered_set底层实现是哈希表
1.2 二叉树的存储方式
二叉树可以链式存储,也可以顺序存储
那么链式存储方式就用指针, 顺序存储的方式就是用数组
数组来存储二叉树如何遍历:如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2
1.3 二叉树的遍历
二叉树主要有两种遍历方式:
深度优先遍历:先往深走,遇到叶子节点再往回走
广度优先遍历:一层一层的去遍历
这两种遍历是图论中最基本的两种遍历方式
深度优先遍历
前序遍历(递归法,迭代法)
中序遍历(递归法,迭代法)
后序遍历(递归法,迭代法)
前中后序指的就是中间节点的位置
前序遍历:中左右
中序遍历:左中右
后序遍历:左右中
广度优先遍历
层次遍历(迭代法)
之前我们讲栈与队列的时候,就说过栈其实就是递归的一种实现结构,也就说前中后序遍历的逻辑其实都是可以借助栈使用非递归的方式来实现的
而广度优先遍历的实现一般使用队列来实现,这也是队列先进先出的特点所决定的,因为需要先进先出的结构,才能一层一层的来遍历二叉树
1.4 二叉树定义
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};
2、二叉树的递归遍历
2.1 递归前序遍历(144)
第一遍代码
只要注意加入结果数组与进行递归的顺序
class Solution {
public:
vector<int> result;
void preOrder(TreeNode* root) {
if(root == nullptr) {
return;
}
result.push_back(root->val);
preOrder(root->left);
preOrder(root->right);
}
vector<int> preorderTraversal(TreeNode* root) {
preOrder(root);
return result;
}
};
2.2 递归后序遍历(145)
第一遍代码
class Solution {
public:
vector<int> result;
void postOrder(TreeNode* root) {
if(root == nullptr) {
return;
}
postOrder(root->left);
postOrder(root->right);
result.push_back(root->val);
}
vector<int> postorderTraversal(TreeNode* root) {
postOrder(root);
return result;
}
};
2.3 递归中序遍历(94)
第一遍代码
class Solution {
public:
vector<int> result;
void inOrder(TreeNode* root) {
if(root == nullptr) {
return;
}
inOrder(root->left);
result.push_back(root->val);
inOrder(root->right);
}
vector<int> inorderTraversal(TreeNode* root) {
inOrder(root);
return result;
}
};
2.4 递归写法总结
每次写递归,都按照这三要素来写,可以保证大家写出正确的递归算法!
1、确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型
2、确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出
3、确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程
以下以前序遍历为例:
1、确定递归函数的参数和返回值:因为要打印出前序遍历节点的数值,所以参数里需要传入vector来放节点的数值,除了这一点就不需要再处理什么数据了也不需要有返回值,所以递归函数返回类型就是void,代码如下:
void traversal(TreeNode* cur, vector<int>& vec)
2、确定终止条件:在递归的过程中,如何算是递归结束了呢,当然是当前遍历的节点是空了,那么本层递归就要结束了,所以如果当前遍历的这个节点是空,就直接return,代码如下:
if (cur == NULL) return;
3、确定单层递归的逻辑:前序遍历是中左右的循序,所以在单层递归的逻辑,是要先取中节点的数值,代码如下:
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;
}
};
3、二叉树的非统一迭代法遍历
3.1 迭代法前序遍历(144)
前序遍历是中左右,每次先处理的是中间节点,那么先将根节点放入栈中,然后将右孩子加入栈,再加入左孩子,为啥孩子节点加入顺序是左右?因为其实是从栈中弹出的记录入result,为了保证弹出的顺序为左右,入栈的顺序为右左
但为啥中还是在第一个?因为中弹出来了才进左右,只考虑仅有一个左中右节点的二叉树会比较明白
stack里面是可以放指针的
对于前序遍历来说,不管是 递归实现 还是 迭代法实现,都是由中间节点 引出 左右子树,处理完 左子树,然后 再处理右子树
第一遍代码
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
vector<int> result;
if(root == nullptr) return result;//因为下面要push root,所以要确保root不为0
stack<TreeNode*> mystack;
mystack.push(root);
while(!mystack.empty()) {
result.push_back(mystack.top()->val);
TreeNode* cur = mystack.top();
mystack.pop();
if(cur->right != nullptr) mystack.push(cur->right);
//因为其实是从栈中弹出的记录入result,为了保证弹出的顺序为左右,入栈的顺序为右左
//但为啥中还是在第一个?因为中弹出来了才进左右,只考虑仅有一个左中右节点的二叉树会比较明白
if(cur->left != nullptr) mystack.push(cur->left);
}
return result;
}
};
为什么可以用迭代法(非递归的方式)来实现二叉树的前后中序遍历呢?
递归的实现就是:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因
此时大家应该知道我们用栈也可以是实现二叉树的前后中序遍历了
但是其实迭代法与具体递归中的栈使用并不一致,会根据不同遍历简化
对于中序遍历和后续遍历,前序遍历的思路无法复制,因为前序遍历是以中间节点的弹出并记录中间节点为中心的
3.2 迭代法中序遍历(94)
第一遍代码
没有处理好 左子树第二次遍历头结点时不能再加入了
因为头节点会被遍历到两次,所以 第二次遍历到的时候 左子树都不能再加了,再处理就重复了
对于中序遍历来说,不管是 递归实现 还是 迭代法实现,都是由中间节点 引出 左子树,处理完 整个左子树,再处理 中间节点,再由中间节点引出右子树,然后 再整体处理右子树
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
stack<TreeNode*> mystack;
vector<int> res;
if (root != nullptr) mystack.push(root);
TreeNode* tmp2 = nullptr;
while (!mystack.empty()) {
TreeNode* tmp = mystack.top();
while (tmp->left != nullptr && tmp->left != tmp2) { // 想要让左子树第二次遍历头结点时不能再加入了
mystack.push(tmp->left);
tmp = tmp->left;
// tmp2 = tmp; 不行,想1,2,3都为左子树的情况
}
tmp = mystack.top();
mystack.pop();
// tmp2 = tmp; 不行,想3为根,1为3左子树,2为1右子树;第二次遍历3的时候,1还是会便历
res.push_back(tmp->val);
if (tmp->right != nullptr) {
mystack.push(tmp->right);
}
}
return res;
}
};
处理 左子树第二次遍历头结点时不能再加入了,使用一个set容器记录 已经完成处理的左子树,往左遍历 需要确定左子树未被处理过。通过
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
stack<TreeNode*> mystack;
vector<int> res;
set<TreeNode*> myset;
if (root != nullptr) mystack.push(root);
while (!mystack.empty()) {
TreeNode* tmp = mystack.top();
while (tmp->left != nullptr && myset.find(tmp->left) == myset.end()) { // 左子树第二次遍历头结点时不能再加入了
mystack.push(tmp->left);
tmp = tmp->left;
}
tmp = mystack.top();
mystack.pop();
myset.insert(tmp);
res.push_back(tmp->val);
if (tmp->right != nullptr) {
mystack.push(tmp->right);
}
}
return res;
}
};
为了解释清楚,我说明一下在迭代的过程中,其实我们有两个操作:
1、处理:将元素放进result数组中
2、访问:遍历节点
分析一下为什么刚刚写的前序遍历的代码,不能和中序遍历通用呢,因为前序遍历的顺序是中左右,先访问的元素是中间节点,要处理的元素也是中间节点,所以刚刚才能写出相对简洁的代码,因为要访问的元素和要处理的元素顺序是一致的,都是中间节点
那么再看看中序遍历,中序遍历是左中右,先访问的是二叉树顶部的节点,然后一层一层向下访问,直到到达树左面的最底部,再开始处理节点(也就是在把节点的数值放进result数组中),这就造成了处理顺序和访问顺序是不一致的(即虽然不停向左访问,但是并没有同步放在result数组里,要等到一直遍历到最底部才开始放)
那么在使用迭代法写中序遍历,就需要借用指针的遍历来帮助访问节点,栈则用来处理节点上的元素
可以看出,中序遍历的迭代法和前序遍历一样,跟递归使用栈的方法有很大不同,比递归使用栈的方法简单,而后序遍历和前序遍历的迭代法思路又不一样
注意while里面的条件,不能光做向左的循环,应该是向左向右的总条件,每次循环挪一步
同时,这个总条件不光需要考虑栈不为空,还需要考虑当前指针不为空(根节点万一还有右子树)
这里的逻辑是只要指针不为空就一路向左,直到空return,回到空之前的上一个节点(对应递归实现第一句,回到上一个节点是通过设置参数为->left实现的)(即从栈里取出上一个元素压入结果数组)
然后访问那个节点(对应递归实现的第二句)往右,重复之前的操作(递归第三句),如果右为空,return(对应递归其中一层的执行结束)
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
//指针遍历,栈来处理准备弹出
stack<TreeNode*> tra;
vector<int> result;
if(root == nullptr) {
return result;
}
//tra.push(root);不能再压入了,不然结合后面while里面的压了两次
TreeNode* cur = root;
while(!tra.empty() || cur != nullptr) {
//注意while里面的条件,不能光做向左的循环,应该是向左向右的总条件,每次循环挪一步
//同时,这个总条件不光需要栈不为空,还需要当前指针不为空(根节点万一还有右子树)
//这里的逻辑是只要指针不为空就一路向左,直到空return,回到空之前的上一个节点(对应递归实现第一句,回到上一个节点是通过设置参数为->left实现的)(即从栈里取出上一个元素压入结果数组)
//然后访问那个节点(对应递归实现的第二句)往右,重复之前的操作(递归第三句),如果右为空,return(对应递归其中一层的执行结束)
if(cur != nullptr) {
tra.push(cur);
cur = cur->left;
}
else {
if(!tra.empty()) {
cur = tra.top();//对应回到递归上一层(每一层的分层可以以处理过该层的左子树为标准)
tra.pop();
result.push_back(cur->val);
cur = cur->right;//这里要走两步
}
}
}
return result;
}
};
3.3 迭代法后序遍历(145)
再来看后序遍历,先序遍历是中左右,后续遍历是左右中,那么我们只需要调整一下先序遍历的代码顺序,就变成中右左的遍历顺序,然后在反转result数组,输出的结果顺序就是左右中了,如下图:
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
vector<int> result;
if(root == nullptr) return result;//因为下面要push root,所以要确保root不为0
stack<TreeNode*> mystack;
mystack.push(root);
while(!mystack.empty()) {
TreeNode* cur = mystack.top();
result.push_back(cur->val);
mystack.pop();
if(cur->left != nullptr) mystack.push(cur->left);
if(cur->right != nullptr) mystack.push(cur->right);
}
reverse(result.begin(), result.end());
return result;
}
};
4、二叉树的统一迭代法(标记法)遍历
发现迭代法实现的先中后序,其实风格也不是那么统一,除了先序和后序,有关联,中序完全就是另一个风格了,一会用栈遍历,一会又用指针来遍历
使用非统一迭代法实现中序遍历中使用栈的话,无法同时解决访问节点(遍历节点)和处理节点(将元素放进结果集)不一致的情况
我们就将访问的节点放入栈中,把要处理的节点也放入栈中但是要做标记
如何标记呢,就是要处理的节点放入栈之后,紧接着放入一个空指针作为标记。 这种方法也可以叫做标记法
其他都和后序遍历的非迭代法一致,主要是注意栈的理解,栈的弹出顺序要与目标一致,只不过对于需要处理的中间节点加以标记
4.1 统一迭代法的前序遍历(144)
看了总思路后实现代码
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> mystack;
if(root == nullptr) {
return result;
}
mystack.push(root);
while(!mystack.empty()) {
TreeNode* cur = mystack.top();
mystack.pop();
if(cur != nullptr) {
if(cur->right != nullptr) mystack.push(cur->right);
if(cur->left != nullptr) mystack.push(cur->left);
mystack.push(cur);
mystack.push(nullptr);//只有第二次进栈的时候才处理,要标记一下与第一次进栈区分开来
}
else {
result.push_back(mystack.top()->val);
mystack.pop();
}
}
return result;
}
};
将访问的节点直接加入到栈中,但如果是处理的节点则后面放入一个空节点, 这样只有空节点弹出的时候,才将下一个节点放进结果集
4.2 统一迭代法的中序遍历(94)
看了总思路后实现代码
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> mystack;
if(root == nullptr) {
return result;
}
mystack.push(root);
while(!mystack.empty()) {
TreeNode* cur = mystack.top();
mystack.pop();
if(cur != nullptr) {
if(cur->right != nullptr) mystack.push(cur->right);
mystack.push(cur);
mystack.push(nullptr);
if(cur->left != nullptr) mystack.push(cur->left);
}
else {
result.push_back(mystack.top()->val);
mystack.pop();
}
}
return result;
}
};
4.3 统一迭代法的后序遍历(145)
同样的想法,就是 压入空指针 以及 节点的时机不同
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
vector<int> res;
stack<TreeNode*> mystack;
if (root) mystack.push(root);
while (!mystack.empty()) {
TreeNode* cur = mystack.top();
mystack.pop();
if (cur) {
mystack.push(cur);
mystack.push(nullptr);
if (cur->right) mystack.push(cur->right);
if (cur->left) mystack.push(cur->left);
}
else {
res.push_back(mystack.top()->val);
mystack.pop();
}
}
return res;
}
};