简述二叉树
二叉树是每个节点最多两个分支的树形结构。遍历方式可以从上向下逐层遍历,先访问离根最近的节点,称为广度优先遍历,也可以从根节点开始,向最远的节点遍历,称为深度优先遍历,此外,再根据根节点的访问先后,分为前序遍历,中序遍历,后序遍历。
其实,只要记住如何访问根节点就可以了。
-
访问离根最近的节点:广度优先遍历
-
访问离根最远的节点:深度优先遍历
- 先访问根节点,再访问左右子树:前序遍历
- 中间访问根节点,即先左子树、根节点、右子树:中序遍历
- 最后访问根节点:即先访问左右子树,再访问根节点:后续遍历
所以,理清了访问顺序之后,二叉树的遍历和重建就不难理解了。
我发现除了二叉树的遍历需要用迭代写外,其余的题目都是用递归去写的。因为可能递归做最基础的节点访问太简单了,换个位置就可以了。那就先说说递归的写法吧。
下文先讲深度优先遍历的写法。
递归
前序遍历
题目描述
完整代码
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {number[]}
*/
const traverse = (root, res) => {
if(!root) return null;
// 前序遍历的位置: 根左右
res.push(root.val);
traverse(root.left, res);
traverse(root.right, res);
}
var preorderTraversal = function(root) {
let res = []
traverse(root, res)
return res;
};
中序遍历
题目描述
完整代码
const traverse = (root, res) => {
if(!root) return null;
traverse(root.left, res);
// 中序遍历的位置: 左根右
res.push(root.val);
traverse(root.right, res);
}
var preorderTraversal = function(root) {
let res = []
traverse(root, res)
return res;
};
后序遍历
题目描述
根据经验,后续遍历就是把 res.push(root.val 搬到 traverse(root.right, res)的后面就可以了。
完整代码
const traverse = (root, res) => {
if(!root) return null;
traverse(root.left, res);
traverse(root.right, res);
// 后序遍历的位置: 左右根
res.push(root.val);
}
var preorderTraversal = function(root) {
let res = []
traverse(root, res)
return res;
};
迭代
迭代的写法需要用到栈,递归可以看作是对我们隐藏了栈,但是实际上函数调用也是通过栈去完成的。访问位置也是由位置决定的。
前序遍历(先根,后左右)
完整代码
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {number[]}
*/
var preorderTraversal = function(root) {
let result = [];
let stack = [];
let cur = root;
while(cur || stack.length){
while(cur){
// 把当前节点(根节点)放到结果中
result.push(cur.val);
// 把当前节点压入栈中,并遍历它的左子树,依次添加到栈中
stack.push(cur);
cur = cur.left;
}
// 节点出栈,再将右节点入栈
cur = stack.pop();
cur = cur.right;
}
return res;
};
步骤拆解
-
我们需要定义三个变量:
result、stack、curlet result = []用于存放结果let stack = []额外需要的栈,用于改变节点的访问顺序,即让最后出栈的顺序变成根左右,所以入栈的时候顺序就要变成右左根。let cur = root, 用于当前节点的访问。
-
遍历之前,我们需要先检查一下传入的树的根节点是否存在,并且因为
stack之后会一直入栈,当它栈内元素为空的时候,循环停止。
while(cur || stack.length){
// ...
}
-
开始遍历啦!
- 如果当前
cur存在,则先把根节点存在result中,然后将当前节点压入栈中,并将当前节点的左节点全部压入栈中
while(cur || stack.length){ while(cur){ // 第一次循环的时候,cur = root, 因此直接将根节点先 push 到 result 中 result.push(cur.val); // 将当前节点压入栈中 stack.push(cur); // 将当前节点的所有左节点压入栈中 cur = cur.left; } //... }-
此时,根节点和它的左子树都压入栈中了,然后出栈,查看当前节点的右子树,
while(cur || stack.length){ //... // 没有左子树之后,将当前节点出栈,并查看当前节点的右子树,如果有,则继续压入栈 cur = stack.pop(); cur = cur.right; }
- 如果当前
中序遍历(左中右,根中间遍历,左先,右最后)
其实可以理解成对于棵二叉树,把根节点和左子树一把梭哈入栈,栈里的元素一个一个出栈,这个时候,当前出栈的元素没有左子树了,它自己就是当前小树的根节点,把它放入结果中,然后看看该元素有没有右子树,没有的话出栈,否则继续入栈。
步骤拆解
-
遍历的时候先把根节点和所有左子树的节点入栈
-
再让节点一个一个出栈,并且将当前节点的值存入结果中,查看当前节点的右子树,如果没有节点,则继续出栈,否则也将其压入栈中。
完整代码
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {number[]}
*/
var inorderTraversal = function(root) {
let result = [];
let stack = [];
let cur = root;
while(cur || stack.length){
// 先把根节点和所有左子树都压入栈中
while(cur){
stack.push(cur);
cur = cur.left;
}
// 出栈,当前节点就是离根节点最远的左子树的节点,这时候可以出栈了,把当前值添加到结果中,检查当前节点是否有右子树,没有则继续出栈,有的话将右节点入栈。让出栈顺序为 左、根、右
cur = stack.pop();
result.push(cur.val);
cur = cur.right;
}
return result;
};
后序遍历(左右中,先左右,后根节点)
后续遍历和前面的写法差不多,唯一需要注意的是需要一个变量保存前一个节点,因为对于一颗树来说,根和左节点先被访问到,而我们结果是需要输出顺序是先左右节点,后根节点,所以我们需要标记一下这个节点是否被访问过。
var postorderTraversal = function(root) {
let stack = [];
let result = [];
let prev = null;
let cur = root;
while(cur || stack.length){
// 先从根节点开始,到左数遍历完为止,全部压入栈。
while(cur){
stack.push(cur);
cur = cur.left;
}
// 获取当前节点
cur = stack[stack.length - 1];
// 判断右节点是否存在,且不是前一个节点,表示这个节点有右子树,且没有被遍历过
if(cur.right && cur.right != prev){
cur = cur.right;
}else{
// cur 节点没有右子树且它的右子树已经遍历过,就可以遍历这个节点,于是出栈并遍历它
stack.pop();
result.push(cur.val);
prev = cur;
cur = null;
}
}
return result;
};
迭代遍历总结
迭代和递归其实很类似,只不过递归的写法面试的时候没有难度,面试官也不会考,但是迭代的写法稍微难一点,但是其实其实只要按照下面步骤做也可以轻易写出来。
- 迭代需要搞清楚循环种植条件,需要一个辅助栈,需要一个保存结果的数组
let stack = [];
let result = [];
let cur = root;
while(cur || stack.length){
// ...
}
-
搞清楚什么时候执行
result.push(cur.val), 后续遍历还需要保存前一个节点,用于判断该节点的右子树是否访问过。- 前序遍历
- 中序遍历
let stack = []; let result = []; let cur = root; while(cur || stack.length){ while(cur){ // 前序遍历的位置 // result.push(cur.val); stack.push(cur); cur = cur.left; } cur = stsack.pop(); // 中序遍历的位置 // result.push(cur.val); cur = cur.right; }- 后续遍历
let stack = []; let result = []; let prev = null; let cur = root; while(cur || stack.length){ while(cur){ stack.push(cur); cur = cur.left; } cur = stack[stack.length - 1] if(cur.right && cur.right != prev){ cur = cur.right; }else { stack.pop(); // 后序遍历的位置 result.push(cur.val); prev = cur; cur = null; } }
复杂度分析
无论迭代还是递归,它们复杂度都是一样的。
时间复杂度: O(n)
空间复杂度:O(h)h为二叉树的深度。二叉树中深度 h 的最小值为 log(n+1),最大值为 n