二叉树基础
二叉树理论基础,这里直接贴出来代码随想录的地址先,然后再对自己认为比较重要的地方回顾一遍。
-
首先是二叉树,二叉树是一种数据结构,其中注意一下完全二叉树:除了最底层的结点没有填满,底层以上的结点都是满的,并且没有填满的底层结点一定是其父节点的左孩子。
-
当二叉树的结点储存数据时,他就变成了二叉搜索树(Binary Search Tree),一颗二叉搜索树是有序的,他有以下规则:
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值
- 它的左、右子树也分别为二叉排序树
注意是左子树和右子树全都大于或小于根节点的值,而不是一个左孩子或右孩子。
-
二叉搜索树不一定是满的,而且最小深度的叶子结点和最大深度的叶子结点之间的差值没有要求,这里就引出了平衡二叉搜索树也就是AVL。AVL有以下性质:他的左右两个子树之间的高度差不大于1,并且两个子树都是平衡二叉树。贴上卡哥的图:
C++中map、set、multimap,multiset的底层实现都是平衡二叉搜索树,所以map、set的增删操作时间时间复杂度是logn。其实严格来说是红黑树,也是AVL的一种,性能上来说AVL更适合查找,红黑树更适合增删。(之前看的红黑树都忘得差不多了,具体感兴趣的小伙伴可以看一下三太子的这篇文章:硬核图解面试最怕的红黑树)。
二叉树的遍历方式
遍历方式分为两种:深度优先遍历和广度优先遍历。
- 深度优先遍历(DFS)
深度优先就是从根节点开始,一直向下遍历,直到遇到叶子结点。分为三种顺序:前序,中序和后序遍历。遍历的方法分为:迭代和递归。
还是贴出卡哥的图吧,前中后表示的是中间结点遍历时的顺序。
- 广度优先遍历(BFS)
广度优先就是一层一层的遍历,比如上图二叉树的遍历为: 5 4 6 1 2 7 8。很好理解,广度优先遍历时一般是借助queue
队列来遍历。
最后说一下二叉树再c++中的定义:
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};
二叉树的递归遍历
递归遍历首先要确定递归三要素:
- 递归函数的输入输出
- 递归的终止条件
- 递归的函数体
首先,递归遍历的过程中需要把遍历的元素存入数组中,所以输入参数有一个数组,遍历时自然有一个结点,所以输入有两个参数;递归逻辑中不需要使用返回值作为递归条件,所以函数类型为void
。其次,当输入结点为NULL
时,本层的递归结束,所以终止条件为if(cur == NULL)
。最后就是递归的逻辑了。
递归是深度优先遍历的一种,所以有前中后序三种顺序,以前序为例:
前序遍历中结点的顺序为:中左右,所以在写递归逻辑时,首先需要将当前结点存入数组再去遍历他的左右孩子。
走到叶子结点时,它是没有子结点的,所以叶子结点可以看成是左右孩子为NULL
的子树中的中间结点,存入数组后遍历NULL
的结点就会跳出这一层的递归。
前序遍历的递归函数如下:
void traversal(TreeNode* cur , vector<int>& ans){
if(cur == NULL) return;
ans.push_back(cur->val);
traversal(cur->left , ans);
traversal(cur->right , ans);
}
中序遍历的结点顺序为:左中右。我的理解是当遍历一个结点时,先去找他的左孩子,直到当前结点为叶子节点时,他就是和他的父节点构成的子树中的“左”,而他是与两个NULL
结点组成的子树中的中,空结点自然不会被输入到数组中,所以跳出递归时,他被输入到数组中。当叶子结点跳出递归时,处理“中”,直接输入进数组,再去遍历右孩子。
所以中序遍历的递归函数如下:
void traversal(TreeNode* cur , vector<int>& ans){
if(cur == NULL) return;
traversal(cur->left , ans);
ans.push_back(cur->val);
traversal(cur->right , ans);
}
代码只是改变了顺序,还是要加深一下理解。
最后就是后序遍历了,同理:
void traversal(TreeNode* cur , vector<int>& ans){
if(cur == NULL) return;
traversal(cur->left , ans);
traversal(cur->right , ans);
ans.push_back(cur->val);
最后一步处理中间结点。
需要注意的是:递归函数中的数组需要传入地址vector<int>& ans
。
二叉树的迭代遍历
二叉树的迭代遍历需要使用额外容器来存放结点,额外的容器使用stack
。
在写迭代之前,首先使用栈来模拟一下遍历的顺序,搞清楚何时入栈何时出栈,剩下的就是细节的处理,比如说对空结点的判断。
-
前序
首先处理中结点,处理完中结点之后,将其左右孩子压入栈中,需要注意一下顺序,因为栈是先进后出的,所以入栈时应该先压入右孩子,再压入左孩子。代码如下:vector<int> preorderTraversal(TreeNode* root) { stack<TreeNode*> st; vector<int> ans; if(root != NULL) st.push(root); while(!st.empty()){ TreeNode* cur = st.top(); st.pop(); ans.push_back(cur->val); if(cur->right) st.push(cur->right); if(cur->left) st.push(cur->left); } return ans; }
主要还是模拟的过程。
-
后序
后序采用一个比较巧的方式,只要将前序中左右孩子的入栈顺序交换,最后反转一下结果数组即可。
代码如下: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; }
-
中序
中序和前序不一样,因为遍历的结点和需要处理输出的结点不是同一个。中序是先找到底层结点之后再开始处理输出,那么当cur
为空时,说明已经遍历到了底层,这时候再开始处理输出。
代码如下:vector<int> inorderTraversal(TreeNode* root) { stack<TreeNode*> st; vector<int> ans; TreeNode* cur = root; while(cur != NULL || !st.empty()){ if(cur != NULL){ //左 st.push(cur); cur = cur->left; }else{ //中 cur = st.top(); st.pop(); ans.push_back(cur->val); cur = cur->right; //右 } } return ans; }
当
cur == NULL
时,此时栈顶就是需要处理的结点。while循环中的cur != NULL
是为了防止根节点的左子树为NULL
的情况,此时栈中为空,但是cur
保存的是右孩子。 -
迭代的统一写法
迭代的前中后序因为遍历的顺序和处理结点的顺序不同导致写法有差异,所以改变写法使其变成像递归一样只需要改变顺序即可。这里的思路是:当遇到需要处理的结点时,暂时不处理,而是在栈中插入一个NULL
给他标记上,再继续遍历后续结点;当栈弹出,cur == NULL
时,再处理结点。
统一写法下的中序迭代遍历代码:vector<int> inorderTraversal(TreeNode* root) { vector<int> ans; stack<TreeNode*> st; if(root != NULL) st.push(root); while(!st.empty()){ TreeNode* cur = st.top(); if(cur != NULL){ st.pop(); if(cur->right) st.push(cur->right); //右 st.push(cur); //中 st.push(NULL); if(cur->left) st.push(cur->left); //左 }else{ st.pop(); ans.push_back(st.top()->val); st.pop(); } } return ans; }
有几点需要注意:
- 可以看到入栈的顺序不是左中右而是右中左
- 第一个pop操作应该在
if(cur != NULL)
里面,否则会造成读取空指针错误的情况 - 这样写的顺序会先把二叉树整个遍历一遍,才开始处理结点,这样也符合中序遍历的逻辑。
统一写法下的后序迭代遍历代码:
vector<int> inorderTraversal(TreeNode* root) { vector<int> ans; stack<TreeNode*> st; if(root != NULL) st.push(root); while(!st.empty()){ TreeNode* cur = st.top(); if(cur != NULL){ st.pop(); st.push(cur); //中 st.push(NULL); if(cur->right) st.push(cur->right); //右 if(cur->left) st.push(cur->left); //左 }else{ st.pop(); cur = st.top(); st.pop(); result.push_back(cur->val); } } return ans; }
这里入栈的顺序是中右左,目的就是把中结点压在最里面最后处理。
统一写法下的前序迭代遍历代码:vector<int> preorderTraversal(TreeNode* root) { vector<int> ans; stack<TreeNode*> st; if(root != NULL) st.push(root); while(!st.empty()){ TreeNode* cur = st.top(); if(cur != NULL){ st.pop(); if(cur->right) st.push(cur->right); if(cur->left) st.push(cur->left); st.push(cur); st.push(NULL); }else{ st.pop(); ans.push_back(st.top()->val); st.pop(); } } return ans;
同样,这里的入栈顺序不是中左右,而是右左中。
迭代的统一遍历一定要先模拟入栈和出栈!!
否则很容易就绕晕了。