前言
最近作者正在疯狂刷算法,刷到了二叉树部分,所以本文来分享一下二叉树部分的基础知识,本文关于二叉树的理论基础部分较多,重在介绍二叉树的定义,分类、特性,以及二叉树的遍历方式的实现,后续会更新文章介绍二叉树的算法应用题,让我们开始吧!
注意:本文内容代码演示方式全部是以JS的方式,会针对JS的算法实现做特别的介绍(如涉及到闭包、函数方式等),所以本文内容主要针对JS的学习者。
二叉树的定义
二叉树是一种树形数据结构,每个节点最多有两个子节点,分别称为左子节点(left child) 和右子节点(right child),由一个根节点和两个互不相交的子树(左子树和右子树)组成的结构是二叉树。
注意:空树(没有节点的树)也是二叉树
二叉树的分类
对于一般的算法题来讲,我们可能会遇到以下的二叉树分类
满二叉树
满二叉树:如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。
完全二叉树
完全二叉树的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。完全二叉树不容易区分,下面给出几个例子,助于我们区分
二叉搜索树
前面介绍的树,都没有数值的,而二叉搜索树是有数值的了,二叉搜索树是一个有序树。
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉排序树
下面这两棵树都是搜索树
平衡二叉搜索树
平衡二叉搜索树:又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
二叉树的存储方式
实际上,二叉树既可以用数组存储也可以用链表存储,但是在一般的算法题内,咱们都是用链表存储的,所以下面就详细探讨链表存储的实现方式。
JS实现:
function TreeNode(val, left, right) {
this.val = (val===undefined ? 0 : val)
this.left = (left===undefined ? null : left)
this.right = (right===undefined ? null : right)
}
我们可以看到,JS是通过构造函数实现的,我们要构造一个二叉树可以通过下面两种方式
//方法1
const root = new TreeNode(1);
let lchild = new TreeNode(2);
let rchild = new TreeNode(3);
root.left = lchild;
root.right = rchild;
//方法2:更直观的创建方式:直接通过构造函数参数绑定子节点(推荐)
const root = new TreeNode(
1, // val
new TreeNode(2), // left
new TreeNode(3) // right
);
二叉树的遍历方式
现在来到本文的重点以及难点:二叉树的遍历,二叉树的遍历分为如下方式:
-
深度优先遍历(栈或递归实现)
- 前序遍历(递归法,迭代法)
- 中序遍历(递归法,迭代法)
- 后序遍历(递归法,迭代法)
-
广度优先遍历(队列实现)
- 层次遍历(迭代法)
对于深度优先遍历,咱们有时会搞不清前中后三种顺序的遍历的区别,这里可以用一个技巧记忆理解:
“前、中、后”指得就是 中间节点的位置!!!
看如下中间节点的顺序,就可以发现这一点。
- 前序遍历:中左右
- 中序遍历:左中右
- 后序遍历:左右中
递归实现前中后序遍历
我们首先使用递归的方式来实现深度优先遍历,也就是前中后序的遍历。
前序遍历
var preorderTraversal = function(root) {
let res = [];
const dfs = function (root) {
if (root === null) return;
//先序遍历所以从父节点开始
res.push(root.val);
//递归左子树
dfs(root.left);
//递归右子树
dfs(root.right);
}
//只使用一个参数 使用闭包进行存储结果
dfs(root);
return res;
}
这里dfs函数体里面的内容大家应该都能看懂,就是传统的递归嘛。
这里着重给大家讲解一下这里闭包的体现。
闭包体现在哪里?
在这段代码中
res是在preorderTraversal函数作用域内声明的变量。dfs函数内部访问了res,即使dfs是在preorderTraversal外部调用的(递归调用时),它仍然能访问res。dfs函数“记住”了res,这就是闭包的体现。
var preorderTraversal = function(root){
let res = []; // 外部作用域的变量
const dfs = function(root) {
if (root === null) return;
res.push(root.val); // 内部函数访问外部变量 res
dfs(root.left);
dfs(root.right);
};
dfs(root); // 调用 dfs
return res; // 返回 res
}
这里解释一下为什么dfs是在preorderTraversal外部调用,虽然在词法上看是在内部定义并调用的。
实际上,第一次dfs是在内部调用的,之后的每一次递归调用都是在外部调用,请看递归调用的执行上下文。
-
首次调用
dfs(root): 发生在preorderTraversal函数内部,(在首次调用后,preorderTraversal函数本身已经暂停。) -
递归调用
dfs(root.left)和dfs(root.right): 这些调用虽然是递归,但每次调用都会生成新的dfs函数执行上下文。每个执行上下文中没有自己的res,但通过作用域链,它们都能访问到preorderTraversal作用域中的res。
总结:当 dfs 在 preorderTraversal 内部被定义时,它已经捕获了外部作用域(即 preorderTraversal 的作用域)中的变量 res。
无论 dfs 是首次调用还是递归调用,它始终通过闭包访问同一个 res。
无闭包写法?
不使用闭包的话,必须将res作为参数传递,不依赖闭包
var preorderTraversal = function(root) {
const res = [];
dfs(root, res); // 显式传递 res
return res;
};
function dfs(node, res) {
if (node === null) return;
res.push(node.val);
dfs(node.left, res); // 每次递归都要传递 res
dfs(node.right, res);
}
你会发现,还是使用闭包更方便优雅
中序遍历、后序遍历
中序遍历和后续遍历其实和前序遍历三者可以互相改写,只需要换下位置就行。
中序遍历
var inorderTraversal = function(root) {
let res = [];
const dfs = function (root) {
if (root === null) {
return;
}
dfs(root.left);
res.push(root.val);
dfs(root.right);
}
dfs(root);
return res;
}
后序遍历
var postorderTraversal = function(root) {
let res = [];
const dfs = function (root) {
if (root === null) {
return;
}
dfs(root.left);
dfs(root.right);
res.push(root.val);
}
dfs(root);
return res;
}
栈实现前中后序遍历
由于递归实际上是利用了计算机的调用栈来管理函数的调用和返回,所以所有可以用递归实现的算法都可以用栈实现。
对于遍历二叉树,我们手动维护一个栈结构来进行。
在用栈模拟递归的过程中,我们需要访问节点(遍历节点入栈),我们还需要处理节点(将元素放入结果集),如果用栈实现的话,我们需要区分当前节点是访问的节点还是处理的节点,所以我们需要一个标记,把访问的节点和处理的节点都放入栈中,但是要对处理的节点做标记!
这里我给一个在面试中更容易写出来的方法,boolean标记法:
一个 boolean 值跟随每个节点,false (默认值) 表示需要为该节点和它的左右儿子安排在栈中的位次,true 表示该节点的位次之前已经安排过了,可以收割节点了。
下面给出中序遍历的代码:
var postorderTraversal = function(root) {
const result = [];
const stack = [];
if (root !== null) {
stack.push({ node: root, visited: false }); // 初始节点入栈,标记未访问
}
while (stack.length > 0) {
const { node, visited } = stack.pop(); // 弹出栈顶元素
if (visited) {
// 已经被访问过,可以收割节点了
result.push(node.val);
} else {
// 未访问的节点,按"右中左"顺序重新入栈(中序遍历实际顺序是"左中右")
// 右子节点先入栈(后处理)
if (node.right !== null) {
stack.push({ node: node.right, visited: false });
}
stack.push({ node, visited: true }); // 当前节点标记为已访问
// 左子节点后入栈(先处理)
if (node.left !== null) {
stack.push({ node: node.left, visited: false });
}
}
}
return result;
};
这套代码很统一,因此前序和后序遍历也能直接用,只需要更换下中节点入栈的顺序即可
前序遍历
var preorderTraversal = function(root) {
const result = [];
const stack = [];
if (root !== null) {
stack.push({ node: root, visited: false });
}
while (stack.length > 0) {
const { node, visited } = stack.pop();
if (visited) {
result.push(node.val);
} else {
// 前序遍历顺序:根-左-右,所以入栈顺序是右-左-根(因为栈是后进先出)
if (node.right !== null) {
stack.push({ node: node.right, visited: false });
}
if (node.left !== null) {
stack.push({ node: node.left, visited: false });
}
stack.push({ node, visited: true });
}
}
return result;
};
后序遍历
var postorderTraversal = function(root) {
const result = [];
const stack = [];
if (root !== null) {
stack.push({ node: root, visited: false });
}
while (stack.length > 0) {
const { node, visited } = stack.pop();
if (visited) {
result.push(node.val);
} else {
// 后序遍历顺序:左-右-根,所以入栈顺序是根-右-左
stack.push({ node, visited: true });
if (node.right !== null) {
stack.push({ node: node.right, visited: false });
}
if (node.left !== null) {
stack.push({ node: node.left, visited: false });
}
}
}
return result;
};
用队列实现层序遍历
以下是用队列实现层序遍历的代码,其实这套代码和我们图论里面的广度优先搜索是很相似的
var levelOrder = function(root) {
//二叉树的层序遍历
let res = [], queue = [];
queue.push(root);
if(root === null) {
return res;
}
while(queue.length !== 0) {
// 记录当前层级节点数
let size = queue.length;
//存放每一层的节点
let curLevel = [];
for(let i = 0;i < size; i++) {
let node = queue.shift();
curLevel.push(node.val);
// 存放当前层下一层的节点
node.left && queue.push(node.left);
node.right && queue.push(node.right);
}
//把每一层的结果放到结果数组
res.push(curLevel);
}
return res;
};
下面通过图片给大家捋一遍过程,咱们初始的二叉树如下
按照步骤 获取当前队列size后,进行size次操作,将当前队列里的元素移出队,并且当前队列的所有元素放入curLevel数组中,待size操作结束后,再将curLevel数组放入result数组中。
遍历到第一层
遍历到第二层
遍历到第三层
这时queue为空了,所以咱们就会跳出while(queue.length !== 0)这个循环,至此层序遍历结束。
我想你需要注意以下两点:
-
在整个过程中,我们的队列可以用数组模拟,因为在JS中,数组有一个很好用的方法
'.shift()',可以从数组中删除第一个元素,并返回该元素的值,这个操作可以模拟队列中的移除队头并返回队头的值。 -
代码中的
node.right && queue.push(node.right);是逻辑与(&&) 的写法,如果&&左边的为真,则执行右边的,如果&&左边为假,则直接返回node.right(这里返回值没人接受,所以可以无视),不会执行右边的push操作。
总结
这篇文章主要是对二叉树在算法中的一些基础理论知识讲解了一遍,以后我们解决二叉树的问题,总是离不开以上的二叉树基础,俗话说的好,磨刀不误砍柴功,我相信只有牢牢掌握好了二叉树在算法上的基础理论知识,以后才能解决更复杂的二叉树的算法问题!
🌇结尾
本文部分内容参考程序员卡尔的:代码随想录
感谢你看到最后,最后再说两点~
①如果你持有不同的看法,欢迎你在文章下方进行留言、评论。
②如果对你有帮助,或者你认可的话,欢迎给个小点赞,支持一下~
我是3Katrina,一个热爱编程的大三学生
(文章内容仅供学习参考,如有侵权,非常抱歉,请立即联系作者删除。)
作者:3Katrina
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。