前言
前言:这是作为一个正在学习的前端开发者整理一下最近写的题,这文章是我对二叉树算法的浅显的理解,希望可以让你在看完文章之后对常见的二叉树操作有一定的了解,文中列举了我觉得比较经典的一些题目。有不对的地方欢迎指出。😮😮😮
本文首发于我的博客 :https://alicekeke.github.io/2020/03/06/binaryTree/
树的基本概念
树的定义:是一类重要的非线性数据结构,是以分支关系定义的层次结构。每个结点有零个或多个子结点;没有父结点的结点称为根结点;每一个非根结点有且只有一个父结点;除了根结点外,每个子结点可以分为多个不相交的子树
一些常见的名词解释:
- 节点的度:一个节点含有的子树的个数称为该节点的度
- 叶子结点:没有子节点的节点
- 结点层次:从根开始定义起,根为第一层,根的孩子为第二层,以此类推
- 树的深度:树中结点的最大层次数称为树的深度或高度
二叉树
二叉树特点
二叉树是每个节点最多拥有两个子节点,左子树和右子树是有顺序的不能任意颠倒。
满二叉树:在一棵二叉树中。如果所有分支结点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树。
完全二叉树:完全二叉树是由满二叉树而引出来的,若设二叉树的深度为h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数(即1~h-1层为一个满二叉树),第 h 层所有的结点都连续集中在最左边,这就是完全二叉树
求解二叉树的题目,离不开递归,递归的算法框架,是很多常用算法框架的雏形;大部分算法技巧,本质上都是树的遍历问题。
关于递归
什么是递归
递归:无限调用自身这个函数,每次调用总会改动一个关键变量,直到这个关键变量达到边界的时候,不再调用。
简单来说:就是函数不断调用函数本身,就叫递归
上一张我发过朋友圈的图
递归递归,先递过去,再归回来
就想这样,一个大的问题(复习)拆成很多子问题来解决,子问题不会又拆成子子问题…,(递)好不容易终于最小最小的那个子问题被解决了,这个最小最小的问题被解决,得到返回值,返回值又解决了上一层子问题,上一层的返回值又解决了上上一层的问题… 依次类推,直到最开始的问题被解决(归)。
所以,所以我们应该关心的是每一层递归要解决什么问题?🤔问题被解决后应当有什么返回值?🤔和当前整个递归的退出条件。
递归的误区
我刚开始刷递归的题,摆脱不了用线性的for、while(true)去思考,想这一层函数做了什么,它调用完自身后的下一层函数又做了什么…这样很容易蒙 (ಥ_ಥ)),你都写递归了还把函数拆开来if else一步步迭代,这不是适得其反嘛,(/□\)。我们其实不用关注整个递归要走多少遍,每一遍都得出什么结果。其实既然递归是给每一层做一样的事情,就不需要把自己绕进这个递归的圈里去,*我们只需要关注一级递归的解决过程即可。**。
什么情况用递归?
通常情况下,能被递归解决的问题具有以下两个特点:
- 一个问题可以分解成具有相同解决思路的子问题,子子问题,换句话说这些问题都能调用同一个函数
- 经过层层分解的子问题最后一定是有一个不能再分解的固定值的(即终止条件),如果没有的话,就无穷无尽地分解子问题了,没有出口,死循环必定无解。
在求解递归问题之前,必须通过以上两个特点验证是否能用递归求解。
通过判断后,我们就可以使用递归的解题模板。
递归三部曲
像我上面说的,递归之前先思考三连:每一层递归要解决什么问题?问题被解决后应当有什么返回值?当前整个递归的退出条件?
总结出这个递归的基本套路
- 找整个递归的终止条件:递归应该在什么时候结束?
- 找返回值:应该给上一级什么返回值?
- 本级递归应该做什么:在这一级递归中,应该完成什么任务?
套用这个三步曲,我们可以解决一些常见的递归问题:
比如经典的斐波那契数列,f[n]=f[n-1]+f[n-2]。n-1是传入参数n的前一个,n-2是最后传入参数n的前两个数字,我们每次都要让f(n-1)和f(n-2)相加得出返回值,直到找到出口f(1)f(2) 返回1。套用模板明确每一步干什么:
- 递归的结束条件?f[1]=1,f[2]=1,就是那个最小粒度可求解的子问题,
- 该层给上一级返回什么信息?return f(n-1)+f(n-2)
- 本级递归应该完成什么任务?每层的任务都是相同的,即计算返回f[n-1]+f[n-2]。
这样就可以很简单的写出斐波那契数列的递归解法
1 | function fn(n){ |
相信看到这,你心里多多少少对递归有个大概的了解了。
在刷题时,根据数据结构的不同:线性或非线性,有不同的遍历数据的方法:线性对应循环和迭代 for(while), true/false , 非线性就要用到递归,你会发现只要的涉及递归的问题,都是树的问题,就算他的数据结构不是树,也能抽象为树。
先从最简单的题开始了解二叉树和递归吧。
二叉树的各种姿势
树的节点那么多,但树的节点结构都是一样的,无论多少个节点,我们可以对一个节点干的事也是一样的,如图:
结点的数据结构如下
1 | function TreeNode(val) { |
根据递归设计出二叉树算法的普适方法,明确一个结点要做的事情,剩下的交给这个通用方法。
基本上所有的树都可以抽象成这样
1 | var traverse = function(root) { |
树的遍历有很明显的分治的思想,因为很常见这样的代码👇
1 | traverse (root.left) //看左边 |
二叉树的所有问题都可以被分成看左边(左树)和看右边(右树)两种情况,逐个解决。
类似于我们常见的二分(low,high)也是像这样把问题分成一半一半来求解。直到被分割的规模缩小到容易解决。
一些常见的二叉树算法题
先来一个最简单的
怎样把二叉树的所有结点加一
1 | var plusOne= function(root) { |
就是这样对左右树轮到的节点做一样的加一操作,树空(null)退出循环。
比较两个树是否相等
1 | var isSameTree = function(p, q) { |
这个树的相等性判断不需要改变树的value,只需要把所有让树不相等的情况列出,排除返回false,最后返回一个boolean值即可。
二叉树的镜像
套用解题模板:
- 找终止条件。 什么情况下递归结束?树为空(null),递归结束。
- 找返回值。 应该返回什么?返回当前交换左右子树后的节点
- 本级递归应该做什么。 交换后重置当前的左右子树,即调用同一个交换函数,返回交换后的节点,重置原二叉树。
1
2
3
4
5
6
7
8
9
10
11
12
13var mirrorTree = function(root) {
// 交换当前节点的左右节点,再递归的交换当前节点的左节点,递归的交换当前节点的右节点 null返回
if(!root) return null
//交换结点
let temp = root.left
root.left = root.right
root.right = temp
// 重复对左右子树做一样的操作,并重置root
root.left = mirrorTree(root.left)
root.right = mirrorTree(root.right)
return root
};
求二叉树最大深度
套用解题模板:
- 找终止条件。 什么情况下递归结束?树为空(null),递归结束。
- 找返回值。 应该返回什么?题目求的是树的最大深度,我们需要根据每一层遍历到的深度+1,返回左右子树的深度最大值。
- 本级递归应该做什么。 首先,还是强调要走出之前的思维误区,每个节点就三个可操作的值:root、root.left、root.right,其中根据第二步,left和right分别记录的是root的左右子树的最大深度。我们在每一次遍历到新的节点更新这个值,然后再返回left和right中较大的那个深度即可。
1 | var maxDepth = function(root) { |
这个时候我们发现,上面这两题都只有一个返回值,并且这个返回值在遍历节点的过程中一直在更新,如果二叉树涉及到全局的变量,比如最短、最优,不止判断当前单个的二叉树节点,而是判断全局二叉树是否满足某条件,return一个返回值明显不够的,现在该怎么办呢?
借助helpFunction
helpFunction是将功能独立出来的一个函数,在题目较为复杂时,一个返回值不能满足我们的需求,我们需要多一些变量来记录当前的值,来保证这个值能在相对于全局而固定,不会在每一次遍历结点时被更新。这个时候就需要helpFunction来增加参数列表,定义返回值接口,在返回值中携带更多有用的信息。
这个概念类似于函数式编程中的组合参数,它能将一个函数调用的输出路由到另一个函数的调用上,然后一直进行下去。是一种声明式抽象,以获取更多的信息。
验证平衡二叉树
最典型的是104题 验证平衡二叉树(Balanced Binary Tree简称AVL)
平衡二叉树的定义:
一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过1。
这个时候我们光记录当前的高度已经不够了,因为它不仅仅要求我们当前左右子树高度差不超过1,整个树的高度差都不能超过1。
所以我们至少需要两个返回值:
- 当前结点高度
- 当前结点是否是平衡二叉树
如果当前结点不是平衡二叉树,那剩下的还遍历个啥 = = ,直接返回false就行了
1 | var isBalanced = function (root) { |
这里用到的height
函数就是helpFunction。 helpFunction根据你具体要做的事命名。宏观上它的作用就是提供更多有用参数的辅助函数。
为了理解helpFunction的作用,我们来看更多的实现场景:
二叉树的层次遍历
题目描述:给定一个二叉树,返回其按层次遍历的节点值。 (即逐层地,从左到右访问所有节点)。
例如
3
/ /
9 20
/ /
15 7
返回[ [3],[9,20], [15,7] ]
这里很明显需要借助helpFunction,因为我们最后返回的肯定是一个数组,但当前遍历是按层次遍历,节点自身只能操作自己的左右子树,所以必然需要一个函数来记录当前层次的节点,保存为一维数组;记录当前的层数与总层数比较,来向下进级。
1 | //二叉树的层次遍历 |
判断二叉搜索树(BST)
二叉搜索树(简称BST)是很常用的二叉树,其定义是:一个二叉树中,任意节点的值要大于左子树所有结点的值,且要小于等于右边子树所有结点的值:
如图就是应该符合定义的BST
和AVL树同理,当前结点root要做的不仅仅是左右结点比较,而是整个左子树和右子树所有结点进行比较,单单一个返回值肯定不够了,这个时候就要设计一个helpFunction
1 | var isValidBST = function (root) { |
小结
看到这里,你应该学会以下几个技巧
- 二叉树算法的原则,把当前节点要做的事做好,其他交给递归框架,不用当前节点操心
- 如果当前节点会对下面的子节点有整体影响,可以通过helpFunction辅助函数设计返回值接口,携带更多的参数传递信息。
- BST和AVL树的验证方法
引申BFS 和 DFS
前文中我说过:任何非线性的数据结构,涉及到递归的问题,都是树的问题,就算他的数据结构不是树,也能抽象为树。接下来,我们就稍稍讨论下按深度优先和广度优先求解图的问题,同样的思想,同样的方法。
深度优先策略
首先明确下概念
深度优先搜索算法(Depth First Search,简称DFS):一种用于遍历或搜索树或图的算法。 沿着树的深度遍历树的节点,尽可能深的搜索树的分支。当节点v的所在边都己被探寻过或者在搜寻时结点不满足条件,搜索将回溯到发现节点v的那条边的起始节点。整个进程反复进行直到所有节点都被访问为止。属于盲目搜索,最糟糕的情况算法时间复杂度为O(!n)。
回溯法:是一种选优搜索法,又称为试探法
关键在于,不合适就退回上一步
回溯法常见的解题模板
1 | result = [] |
但与此同时,最坏的情况下,回溯的时间复杂度会变大,所以要用到剪枝来提速。剪枝就是在搜索过程中利用过滤条件来剪去完全不用考虑(已经判断这条路走下去得不到最优解)的搜索路径,从而避免了一些不必要的搜索。剪枝这个形容非常的形象,想象一下,春天来了 花匠大叔会把树向下长的枝干全部剪掉,留下向上的。这就是剪枝。
如果具体问解的个数,问最优、最先、最短,一般来讲用dp来做,问所有的解的集合 就用DFS
DFS模板
1 | result: 所有结果集 |
举一道medium难度的dfs题做例子
所有合法括号集合
题目描述:设计一种算法,打印n对括号的所有合法的(例如,开闭一一对应)组合。且解集不能包含重复的子集。
例如,给出 n = 3,生成结果为:
[ “((()))”, “(()())”, “(())()”, “()(())”,”()()()” ]
这题很明显要返回所有有效括号的解的集合,helpFuncton (题解中的DFS)要返回的返回值接口包括(当前剩余的左括号,剩余的右括号,当前加入合法的左/右括号,结果的集合)。并且只有两种情况,加入当前list或者不加.
1 | function dfs(left, right, list, result){ |
这题因为给出了终止条件,即n对括号,凑满了就不用再判断,所以不需要记录判断当前visited。并且这里在判断valid时用到剪枝的思想:因为有些DFS是把所有情况的解都产生之后,在判断退出条件的时候再判断是否valid;但我们在加入当前list之前就判断左右括号是否valid,满足才放进result,这样就保证在长度(n)合适退出循环的时候,拿到的result都是有效的解。
广度优先策略
BFS: 从根节点出发,沿着树的宽度,每次都访问同一层的节点,若同一层都访问完,再访问下一层,若所有的节点均被访问,则算法终止,最后BFS找到的路径即为最短路径。
BFS相对于DFS,DFS是一条路走到底,没有先后顺序,BFS按层级遍历,好记录当前遍历层级,更利于求最优路径。
腐烂的橘子
题目描述:在给定的网格中,可能有三个值:0 代表空单元格; 1 代表新鲜橘子; 2 代表腐烂的橘子。
每分钟,任何与腐烂的橘子(在 4 个正方向上)相邻的新鲜橘子都会腐烂。返回直到单元格中没有新鲜橘子为止所必须经过的最小分钟数。
腐烂的橘子应该是N皇后问题的简易版,这里不需要返回解集,求最小time,并且每个橘子都朝着每层的四个方向腐烂,层级优先,考虑BFS
没有用到递归。plan:枚举 + BFS
解读一下题目,映射到BFS的概念上:
- 四个方向相邻 ——> 访问同一层节点
- 污染四个方向上的节点 ——> 对每个坏橘为基进行BFS
- 最短路径 ——> 最短腐烂时间
还是原来的那个模板,
1 | result: 所有结果集 |
list记录当前坏橘的坐标和最后需要返回的经过时间,二维坐标记录当前要前进的方向
1 | var orangesRotting = function(grid) { |
我好想回学校宿舍拿我数据结构的书,书上很多笔记和图例,可我困在家里出不去😥😥😥
总结
- 看完本文你将会了解递归、分治、剪枝、回溯、枚举、DFS、BFS等基本算法思想的基本思想及使用。
- 掌握常见二叉树题型的解法即思路。
- 文章很长,一万多字加图文,大家可以点个赞收藏一下,马了慢慢看。
- 在社区潜水这么久,很感谢掘金上的大佬们的技术分享,一路走来帮助我这个还在学校的小白学到了很多,也希望自己能写出好的文章回馈社区,总结自己最近学到的东西能帮助到一起学习的掘友们。掘金大佬如云,我这样的小白发文也是诚惶诚恐。如果我写的有啥不对的欢迎大家在评论区告诉我呀,期待和大家一起交流~🤣🤣🤣