写给前端开发的树简介(js)

590 阅读6分钟

「这是我参与2022首次更文挑战的第4天,活动详情查看:2022首次更文挑战」。

为什么会有树这种数据结构?

单链表的访问操作太慢了,要访问中间或者后面的结点,必须从头结点一个一个往后面去访问。

image.png

这时双向链表出现了,它不仅有后继结点,还有前驱结点,让链表的访问更加快速。

image.png

这时有人突发奇想,为什么链表不能有多个next指针呢,让结点分叉出来,于是就出现了树这种数据结构。

image.png

所以树可以被理解为特殊化了的链表。

树的基本概念

数据结构中的树,是对现实世界中树的简化:把树根抽象为“根结点”,树枝抽象为“边”,树枝的两个端点抽象为“结点”,树叶抽象为“叶子结点”。抽象后的树结构如下:

image.png 把这棵抽象后的树颠倒一下,就得到了计算机中的树结构:

image.png

树有以下定义:

  • 树的层次计算规则:根结点所在的那一层记为第一层,其子结点所在的就是第二层,以此类推。
  • 结点和树的“高度”计算规则:叶子结点高度记为1,每向上一层高度就加1,逐层向上累加至目标结点时,所得到的的值就是目标结点的高度。树中结点的最大高度,称为“树的高度”。
  • “度”的概念:一个结点开叉出去多少个子树,被记为结点的“度”。比如我们上图中,根结点的“度”就是3。
  • “叶子结点”:叶子结点就是度为0的结点。在上图中,最后一层的结点的度全部为0,所以这一层的结点都是叶子结点。

二叉树

指树中节点的度不大于2的树,每个结点最多只有两个 next 指针

image.png

二叉树的代码实现

在 JS 中,二叉树使用对象来定义。它的结构分为三块:

  • 数据域
  • 左侧子结点(左子树根结点)的引用
  • 右侧子结点(右子树根结点)的引用
function TreeNode (val, left, right) {
  this.val = (val === undefined ? 0 : val)
  this.left = (left === undefined ? null : left)
  this.right = (right === undefined ? null : right)
}

const node = new TreeNode(1)

二叉搜索树

科学家们发明了树这种数据结构,本质是要解决实际问题,如果二叉树的两个指针指向的是很乱的数据,其实是不高效的数据结构,实际应用场景不多。

所以他们设计了二叉搜索树,让左子树比右子树的数据小,这样搜索起来会更加高效。

二叉搜索树,又称有序⼆叉树,排序⼆叉树,是指⼀棵空树或者具有下列性质的⼆叉树:

  1. 若任意节点的左⼦树不空,则左⼦树上所有结点的值均⼩于它的根结点的值;

  2. 若任意节点的右⼦树不空,则右⼦树上所有结点的值均⼤于它的根结点的值;

  3. 任意节点的左、右⼦树也分别为⼆叉查找树。

image.png

二叉搜索树查找的效率和二分查找是一样的,但插入和删除的效率比数组高。

操作数组二叉搜索树
查找O(logn)O(logn)
插入O(n)O(logn)
删除O(n)O(logn)

但二叉搜索树如果不平衡,最坏情况下访问、插入和排序的时间复杂度都是 O(n)。

实际上科学家们早就解决了这个问题,真实业务场景中,数据库用的是更好的数据结构,先贴出来。

image.png

leetcode 树之初体验

二叉树的中序遍历

题目描述:给定一个二叉树的根节点 root ,返回它的 中序 遍历。

  • 先序遍历:根结点 -> 左子树 -> 右子树
  • 中序遍历:左子树 -> 根结点 -> 右子树
  • 后序遍历:左子树 -> 右子树 -> 根结点

在这三种顺序中,根结点的遍历分别被安排在了首要位置、中间位置和最后位置。
所谓的“先序”、“中序”和“后序”,“先”、“中”、“后”其实就是指根结点的遍历时机。

代码实现

const inorderTraversal = function (root) {
  const res = []
  const inorder = (root) => {
    if (!root) {                    // 递归边界(基线条件)
      return false
    }
    inorder(root.left)              // 左子树 -> 根结点 -> 右子树
    res.push(root.val)
    inorder(root.right)
  }
  inorder(root)
  return res
}

时间复杂度:O(n)

空间复杂度:O(n),空间复杂度取决于递归的栈深度,而栈深度在二叉树为一条链的情况下会达到 O(n) 的级别。

相同的树

题目描述:给你两棵二叉树的根节点 p 和 q ,编写一个函数来检验这两棵树是否相同。
如果两个树在结构上相同,并且节点具有相同的值,则认为它们是相同的。

image.png

代码实现

const isSameTree = function (p, q) {
  if (!p && !q) {                       // pq 都为空,两棵树相等
    return true
  }
  if (!p || !q) {                       // pq 任意一个为空,两棵树不相等
    return false
  }
  if (p.val !== q.val) {                // pq 的 val 不相同,两棵树不相等
    return false
  }
  // 递归比较左子树和右子树
  return isSameTree(p.left, q.left) && isSameTree(p.right, q.right)
}

时间复杂度:O(min(m,n)),其中m 和 n 分别是两个二叉树的节点数。对两个二叉树同时进行深度优先搜索,只有当两个二叉树中的对应节点都不为空时才会访问到该节点,因此被访问到的节点数不会超过较小的二叉树的节点数。

空间复杂度:O(min(m,n)),其中 m 和n 分别是两个二叉树的节点数。空间复杂度取决于递归调用的层数,递归调用的层数不会超过较小的二叉树的最大高度,最坏情况下,二叉树的高度等于节点数。

对称二叉树

题目描述:给你一个二叉树的根节点 root , 检查它是否轴对称。

image.png

代码实现

const isSymmetric = function (root) {
  const isMirror = (l, r) => {
    if (!l && !r) {                    // l 和 r 都为空,是对称的
      return true
    }
    if (!l || !r) {                    // l 和 r 有一个为空,不对称
      return false
    }
    if (l.val !== r.val) {             // l 和 r 值不相等,不对称
      return false
    }
    // 递归判断左右子树
    return isMirror(l.left, r.right) && isMirror(l.right, r.left)
  }
  return isMirror(root, root)
}

时间复杂度:O(n)

空间复杂度:O(n)

二叉树的最大深度

题目描述:给定一个二叉树,找出其最大深度。
二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。

image.png

代码实现

const maxDepth = function (root) {
  if (!root) {                           // 根结点为空,直接返回 0
    return 0
  } else {
    const l = maxDepth(root.left)        // 递归拿到左子树和右子树的最大深度,取 max,最后加上根结点的1层
    const r = maxDepth(root.right)
    return Math.max(l, r) + 1
  }
}

时间复杂度:O(n)

空间复杂度:O(height),其中 height 表示二叉树的高度。

小结

  • 树不过是被特殊化了的链表而已,知道其诞生原因,就没什么难的。
  • 平时一般写的算法题都是二叉树或者二叉搜索树。
  • 真实的业务场景中,比如数据库,用的效率更高的树。

js 中树这个数据结构太重要了,DOM就是树形结构 ,平时开发的很多组件,比如菜单,下拉选择等也要支持树形结构,了解这些基本概念还是很有用的。