5

精读《算法 - 二叉树》

 3 years ago
source link: https://zhuanlan.zhihu.com/p/386660927
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

精读《算法 - 二叉树》

前端开发话题下的优秀答主

二叉树是一种数据结构,并且拥有种类复杂的分支,本文作为入门篇,只介绍一些基本二叉树的题型,像二叉搜索树等等不在此篇介绍。

二叉树其实是链表的升级版,即链表同时拥有两个 Next 指针,就变成了二叉树。

二叉树可以根据一些特性,比如搜索二叉树,将查找的时间复杂度降低为 logn,而且堆这种数据结构,也是一种特殊的二叉树,可以以 O(1) 的时间复杂度查找最大值或者最小值。所以二叉树的变种很多,都可以很好的解决具体场景的问题。

精读

要入门二叉树,就必须理解二叉树的三种遍历策略,分别是:前序遍历、中序遍历、后序遍历,这些都属于深度优先遍历。

所谓前中后,就是访问节点值在什么时机,其余时机按先左后右访问子节点。比如前序遍历,就是先访问值,再访问左右;后续遍历就是先访问左右,再访问值;中序遍历就是左,值,右。

用递归方式遍历树非常简单:

function visitTree(node: TreeNode) {
  // 三选一:前序遍历
  // console.log(node.val)
  visitTree(node.left)
  // 三选一:中序遍历
  // console.log(node.val)
  visitTree(node.right)
  // 三选一:后序遍历
  // console.log(node.val)
}

当然题目需要我们巧妙利用二叉树三种遍历的特性来解题,比如重建二叉树。

重建二叉树

重建二叉树是一道中等题,题目如下:

输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。
例如
前序遍历 preorder = [3,9,20,15,7]
中序遍历 inorder = [9,3,15,20,7]

先给你二叉树前序与中序遍历结果,让你重建二叉树,这种逆向思维的题目就难了不少。

仔细观察遍历特性可以看出,我们也许能推测出一些关键节点的位置,再通过数组切割递归一下就能解题。

前序遍历第一个访问的一定是根节点,因此 3 一定是根节点,然后我们在中序遍历找到 3,这样 左边就是所有左子树的中序遍历结果,右边就是所有右子树的中序遍历结果,我们只要再找到 左子树的前序遍历结果与右子树的前序遍历结果,就可以递归了,终止条件是左或右子树只有一个值,那样就代表叶子节点。

那么怎么找左右子树的前序遍历呢?上面例子中,我们找到了 3 的左右子树的中序遍历结果,由于前序遍历优先访问左子树,因此我们数一下中序遍历中,3 左边的数量,只有一个 9,那么我们从前序遍历的 3,9,20,15,73 之后推一位,那么 9 就是左子树前序遍历结果,9 后面的 20,15,7 就是柚子树的前序遍历结果。

最后只要递归一下就能解题了,我们将输入不断拆解为左右子树的的输入,直到达到终止条件。

解决此题的关键是,不仅要直到如何写前中后序遍历,还要知道前序遍历第一个节点是根节点,后序遍历最后一个节点是根节点,中序遍历以根节点为中心,左右分别是其左右子树,这几个重要延伸特征。

说完了反向,我们说正向,即递归一颗二叉树。

其实二叉树除了递归,还有一种常见的遍历方法是利用栈进行广度优先遍历,典型题目有从上到下打印二叉树。

从上到下打印二叉树

从上到下打印二叉树是一道简单题,题目如下:

从上到下按层打印二叉树,同一层的节点按从左到右的顺序打印,每一层打印到一行。

这道题要求从左到右顺序打印,完全遵循广度优先遍历,我们可以在二叉树递归时,先不要急着读取值,而是按照左、中、右,遇到左右子树节点,就推入栈的末尾,利用 while 语句不断循环,直到栈空为止。

利用展开时追加到栈尾,并不断循环处理栈元素的方式非常优雅,而且符合栈的特性。

当然如果题目要求倒序打印,你就可以以 右、中、左 的顺序进行处理。

接下来看看深度优先遍历,典型题目是二叉树的深度。

二叉树的深度

二叉树的深度是一道简单题,题目如下:

输入一棵二叉树的根节点,求该树的深度。从根节点到叶节点依次经过的节点(含根、叶节点)形成树的一条路径,最长路径的长度为树的深度。

由于二叉树有多种分支,在遍历前,我们并不知道哪条路线是最深的,所以必须利用递归尝试。

我们可以转换一下思路,用函数式语义方式来理解。假设我们有了这样一个函数 deep 来求二叉树深度,那么这个函数内容是什么呢?二叉树只可能存在左右子树,所以 deep 必然是左右子树的最大深度的最大值 +1(它自己)。

而求左右子树深度可以复用 deep 函数形成递归,我们只需要考虑边界情况,即访问节点不存在时,返回深度 0 即可,因此代码如下:

function deep(node: TreeNode) {
  if (!node) return 0
  return Math.max(deep(node.left), deep(node.right)) + 1
}

从这可以看出,二叉树一般能用比较优雅的递归函数解决,如果你的解题思路不包含递归,往往就不是最优雅的解法。

类似优雅的题目还有,平衡二叉树。

平衡二叉树

平衡二叉树是一道简单题,题目如下:

输入一棵二叉树的根节点,判断该树是不是平衡二叉树。如果某二叉树中任意节点的左右子树的深度相差不超过 1,那么它就是一棵平衡二叉树。

同理,我们设函数 isBalance 就是答案函数,那么一个平衡二叉树的特征,必然是其左右子树也是平衡的,所以可以写成:

function isBalance(node: TreeNode) {
  if (root == null) return true
  return isBalance(node.left) && isBalance(node.right)
}

但是哪里不对,左右子树平衡还不够啊,万一左右子树之间深度相差超过 1 就坏了,所以还要求一下左右子树的深度,我们复用上题的函数 deep,整理一下如下:

function isBalance(node: TreeNode) {
  if (root == null) return true
  return isBalance(root.left) && isBalance(root.right) &&
    Math.abs(deep(root.left) - deep(root.right)) < 2
}

这道题提醒我们,不是所有递归都能完美写成仅自己调用自己的模式,不同题目要辅以其他函数,要敏锐的察觉到还缺少哪些条件。

还有一种递归,不是简单的函数自身递归自身,而是要构造出另一个函数进行递归,原因是递归参数不同。典型的题目有对称的二叉树。

对称的二叉树

对称的二叉树是一道简单题,题目如下:

请实现一个函数,用来判断一棵二叉树是不是对称的。如果一棵二叉树和它的镜像一样,那么它是对称的。

我们要注意,一颗二叉树的镜像比较特殊,比如最左节点与最右节点互为镜像,但它们的父节点并不相同,因此 isSymmetric(tree) 这样的参数是无法子递归的,我们必须拆解为左右子树作为参数,让它们进行相等判断,在传参时,将父级不同,但互为镜像的左右节点传入即可。

所以我们必须起一个新函数 isSymmetricNew(left, right),将 left.leftright.right 对比,将 left.rightright.left 对比即可。

具体代码就不写了,然后注意一下边界情况即可。

这道题的重点是,由于镜像的关系,并不拥有相同的父节点,因此必须用一个新参数的函数进行递归。

那如果这道题反过来呢?要求构造一个二叉树镜像呢?

二叉树的镜像

二叉树的镜像是一道简单题,题目如下:

请完成一个函数,输入一个二叉树,该函数输出它的镜像。

判断镜像比较容易,但构造镜像就要想一想了:

例如输入:
     4
   /   \
  2     7
 / \   / \
1   3 6   9

镜像输出:
     4
   /   \
  7     2
 / \   / \
9   6 3   1

观察发现,其实镜像可以理解为左右子树互换,同时 其各子树的左右子树再递归互换,这就构成了一个递归:

function mirrorTree(node: TreeNode) {
  if (node === null) return null

  const left = mirrorTree(node.left)
  const right = mirrorTree(node.right)
  node.left = right
  node.right = left
  return node
}

我们要从下到上,因此先生成递归好的左右子树,再进行当前节点的互换,最后返回根节点即可。

接下来介绍一些有一定难度的经典题。

二叉树的最近公共祖先

二叉树的最近公共祖先是一道中等题,题目如下:

给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。

题目很简短,也很明确,就是寻找最近的公共祖先。显然,根节点是所有节点的公共祖先,但不一定是最近的。

我们还是用递归,先考虑特殊情况:如果任意节点等于当前节点,那么当前节点一定就是最近公共祖先,因为另一个节点一定在其子节点中。

然后,利用递归思想思考,假设我们利用 lowestCommonAncestor 函数分别找到左右子节点的最近公共祖先会怎样?

function lowestCommonAncestor(node, a, b) {
  const left = lowestCommonAncestor(node.left)
  const right = lowestCommonAncestor(node.right)
}

如果左右节点都找不到,说明只可能当前节点是最近公共子节点:

if (!left && !right) return node

如果左节点找不到,则右节点就是答案,否则相反:

if (!left) return right
return left

这里巧妙利用了函数语义进行结果判断。

二叉树的右视图

二叉树的右视图是一道中等题,题目如下:

给定一棵二叉树,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。

想象一束光照,从二叉树右侧向左照射,自上而下读取即是答案。

其实这道题可以认为是一道融合题。右侧的光束可以认为是分层照射的,那么当我们用广度优先算法遍历时,对于每一层,都找到最后一个节点打印,并且按顺序打印就是最终答案。

有一道二叉树的题目,是根据树的深度,按照广度优先遍历打印成二维数组,记录树的深度其实也有巧妙办法,即在栈尾追加元素时,增加一个深度 key,那么访问时自然就可以读到深度值。

完全二叉树的节点个数

完全二叉树的节点个数是一道中等题,题目如下:

给你一棵 完全二叉树 的根节点 root ,求出该树的节点个数。
完全二叉树 的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1 ~ 2^h 个节点。

用递归解决这道题的话,关键要分几种情况探讨完全二叉树。

由于最底层可能没有填满,但最底层一定有节点,而且是按照从左到右填的,那么递归遍历左节点就可以获取树的最大深度,通过最大深度我们可以快速计算出节点个树,前提是二叉树必须是满的。

但最底层节点可能不满,那怎么办呢?分情况即可,首先,如果一直按照 node.right....right 递归获得右侧节点深度,发现和最大深度相同,那么就是一个满二叉树,直接计算出结果即可。

我们再看 node.right...left 的深度如果等于最大深度,说明 node.left 也就是左子树是个满二叉树,可以通过数学公式 2^n-1 快速算出节点个树。

如果不等于最大深度呢?则说明右子树深度减 1 是满二叉树,也可以通过数学公式快速计算节点个数,再通过递归计算另一边即可。

总结

从题目中可以感受到,二叉树的解题魅力在于递归,二叉树问题中,我们可以同时追求优雅与答案。

讨论地址是:精读《算法 - 二叉树》· Issue #331 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

v2-d46b608443dd2afa3dea21b96236fa06_720w.jpg

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK