前端算法 | 二叉树篇

175 阅读26分钟

本文是作者刷算法题之余,将刷题的经验分享出来,欢迎和我交流探讨。

(Easy) —— 二叉树的前序遍历

给你二叉树的根节点 root ,返回它节点值的 前序 **遍历。

 

示例 1:

输入: root = [1,null,2,3]
输出: [1,2,3]

示例 2:

输入: root = []
输出: []

示例 3:

输入: root = [1]
输出: [1]

示例 4:

输入: root = [1,2]
输出: [1,2]

示例 5:

输入: root = [1,null,2]
输出: [1,2]

 

提示:

  • 树中节点数目在范围 [0, 100] 内
  • -100 <= Node.val <= 100

 

进阶: 递归算法很简单,你可以通过迭代算法完成吗?


分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 二叉树
  • 先序遍历
  • 迭代法

  二叉树的前、中、后序遍历是二叉树经典题目,最简单的方式是 递归,前序遍历的顺序是 根——左——右,下面就来实现一下。

递归实现二叉树的前序遍历

/**
 * 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) {
  const res = [] // 结果数组,提到递归函数外面,不断填充
  function preorder(root, arr) {
    if (!root) return // 处理边界情况
    res.push(root.val)
    preorder(root.left)
    preorder(root.right)
  }
  preorder(root)
  return res
};

  题目的进阶解法,让我们不使用递归,使用 迭代 算法。递归 的过程其实是函数的调用栈,我们完全可以自己模拟一个栈结构来手动控制出栈的顺序实现前序遍历。

  我们按照这个流程可以得到前序遍历 根——左——右 的顺序:

  • 根节点入栈,获取 valpush 进结果数组,之后出栈
  • 根节点的右子树入栈
  • 根节点的左子树入栈
  • 出栈取出 val,此时左子树在栈顶,左子树先出栈
  • 循环这个过程
  • 栈顶元素到了叶子结点最左的元素,获取其 val,出栈
  • 下一个元素就是刚刚出栈结点父节点的右子树,获取其 val,出栈
  • 循环这个过程,直至清空栈,得到 val 顺序和前序遍历一致

控制出栈顺序实现前序遍历

/**
 * 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) {
  const res = [] // 结果数组
  if (!root) return res // 边界情况
  const stack = [] // 利用出栈顺序实现前序遍历
  stack.push(root) // 栈里先把根节点放进去,启动
  while (stack.length) {
    const cur = stack.pop() // 当前的“根结点”
    res.push(cur.val) // “根结点”出栈
    // 右子树先入栈
    if (cur.right) {
      stack.push(cur.right)
    }
    // 左子树后入栈,先出栈
    if (cur.left) {
      stack.push(cur.left)
    }
  }
  return res
}

总结

  • 对于 递归思路,注意 res 结果数组的设计思路,建议放到外边传入递归函数

  • 先序遍历 迭代法 的实现思路:

    • while 循环
    • 循环过程:根入栈出栈、右子树入栈、左子树入栈
  • 注意边界情况,根节点 为空的处理!

(Easy) —— 二叉树的后序遍历

给你一棵二叉树的根节点 root ,返回其节点值的 后序遍历

 

示例 1:

输入: root = [1,null,2,3]
输出: [3,2,1]

示例 2:

输入: root = []
输出: []

示例 3:

输入: root = [1]
输出: [1]

 

提示:

  • 树中节点的数目在范围 [0, 100] 内
  • -100 <= Node.val <= 100

 

进阶: 递归算法很简单,你可以通过迭代算法完成吗?


分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 二叉树
  • 后序遍历
  • 迭代法

  后序遍历的顺序是 左——右——根,先用最简单的递归方式来爽一下。

递归实现二叉树的后序遍历

/**
 * 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 postorderTraversal = function (root) {
  const res = [] // 结果数组
  function dfs(root, arr) {
    if (!root) return // 处理边界情况
    if (root.left) {
      dfs(root.left, arr)
    }
    if (root.right) {
      dfs(root.right, arr)
    }
    arr.push(root.val)
  }
  dfs(root, res)
  return res
}

  题目的进阶解法,让我们不使用递归,使用 迭代 算法。递归 的过程其实是函数的调用栈,我们完全可以自己模拟一个栈结构来手动控制出栈的顺序实现前序遍历。

  我们按照这个流程可以得到前序遍历 左——右——根 的顺序:

  • 根节点入栈,获取 valunshift 进结果数组,之后出栈
  • 根节点的左子树入栈
  • 根节点的右子树入栈
  • 出栈取出 val,此时右子树在栈顶,右子树先出栈
  • 循环这个过程
  • 栈顶元素到了最左叶子结点相邻的右叶子结点,获取其 val,出栈
  • 下一个元素就是刚刚出栈结点父节点的左子树,获取其 val,出栈
  • 循环这个过程,直至清空栈,得到 val 顺序和后序遍历一致

控制出栈顺序 + unshif结果数组 实现后序遍历

/**
 * 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 postorderTraversal = function (root) {
  const res = [] // 结果
  if (!root) return res // 处理边界情况
  const stack = [] // 栈
  stack.push(root) // 启动while循环
  while (stack.length) {
    const cur = stack.pop() // 获取弹出元素
    res.unshift(cur.val)
    if (cur.left) {
      stack.push(cur.left)
    }
    if (cur.right) {
      stack.push(cur.right)
    }
  }
  return res
} 

总结

  • 对于 递归思路,注意 res 结果数组的设计思路,建议放到外边传入递归函数

  • 后序遍历 迭代法 的实现思路:

    • while 循环
    • 循环过程:根入栈出栈、左子树入栈、右子树入栈,unshift 进结果数组
  • 注意边界情况,根节点 为空的处理!

(Easy) —— 二叉树的中序遍历

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

 

示例 1:

输入: root = [1,null,2,3]
输出: [1,3,2]

示例 2:

输入: root = []
输出: []

示例 3:

输入: root = [1]
输出: [1]

 

提示:

  • 树中节点数目在范围 [0, 100] 内
  • -100 <= Node.val <= 100

 

进阶: 递归算法很简单,你可以通过迭代算法完成吗?


分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 二叉树
  • 中序遍历
  • 迭代法

  后序遍历的顺序是 左——根——右,先用最简单的递归方式来爽一下。

递归实现二叉树的中序遍历

/**
 * 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) {
  const res = [] // 结果
  if (!root) return res // 边界情况
  const dfs = function (root, arr) {
    if (!root) return
    if (root.left) {
      dfs(root.left, arr)
    }
    arr.push(root.val)
    if (root.right) {
      dfs(root.right, arr)
    }
  }
  dfs(root, res)
  return res
}

  题目的进阶解法,让我们不使用递归,使用 迭代 算法。递归 的过程其实是函数的调用栈,我们完全可以自己模拟一个栈结构来手动控制出栈的顺序实现前序遍历。

  在我们不断寻找左子树的情况下,我们一定会经历他的父结点,此时记录这些父结点,在回溯的时候可以直接取到,之后再取这个父结点的右子树,不断循环这个过程,这个就是迭代法实现中序遍历的大概思路。

两次while循环 + 双条件 实现中序遍历

/**
 * 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) {
  const res = [] // 结果
  if (!root) return res // 边界情况(可以不写,为了防止出错还是写上吧)
  const stack = [] // 栈
  let cur = root // 游标
  while (cur || stack.length) {
    // cur专门用于给栈提供数据,有也会全部放到stack中,记录路径
    while (cur) {
      stack.push(cur) // 记录路径
      cur = cur.left // 最终cur = null,找到了最左边的叶子节点
    }
    cur = stack.pop() // 拿到最左叶子节点
    res.push(cur.val) // 开始放入结果数组
    // cur.right = null时,此时从stack中取父结点/祖先结点
    // cur.right 有值时,用于记录路径放到stack
    cur = cur.right
  }
  return res
}

  注意两层 while 循环的使用,来理下上面代码的思路:

  • 走左边的路径走到不能走,将完整的路径记录下来
  • 左边走不动了,根节点也能定位到了
  • 这时候还差右边的,此时正好可以拿到当前的"根节点",将游标放到"根节点"右边即可
  • 再从右结点一直往左走,双重 while 的意义就在这,确保所有的左路径都能走完而且优先走
  • 走不动了,退回到栈顶结点,也就是父结点/某个祖先结点,这时候 cur 记录的是叶子节点的值一定是 null,会"聪明"的往右面查找,直至把根节点的左子树走完
  • 重复上面的操作,把右子树也走完
  • 得到中序遍历结果

  简单总结一下 迭代法 中序遍历 的思路:

  • 1.首先先查找最左叶子结点,将查找路径记到 stack 里
  • 2.stack 出栈,指针指到右结点,两种情况:
    • 当前结点没有右子树,返回父级结点(就在栈顶)
    • 当前结点有右子树,查找该右子树

总结

  • 对于 递归思路,注意 res 结果数组的设计思路,建议放到外边传入递归函数

  • 中序遍历 迭代法 的实现思路:

    • 双重 while 循环,确保一路向左走到头
    • 代码比较明白,建议直接背下来!
  • 这道题边界情况可以不处理,但为了防止出错,还是写上吧

(Easy) —— 二叉树的层序遍历

给你二叉树的根节点 root ,返回其节点值的 层序遍历 。 (即逐层地,从左到右访问所有节点)。

 

示例 1:

输入: root = [3,9,20,null,null,15,7]
输出: [[3],[9,20],[15,7]]

示例 2:

输入: root = [1]
输出: [[1]]

示例 3:

输入: root = []
输出: []

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 二叉树
  • 层遍历

  这道题的思路比较简单。我们使用一个队列维护二叉树的所有结点,依次将根节点开始的每一层入队,在适当的时机将每一层的结点出队并记录即可。

队列实现层序遍历

/**
 * 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 levelOrder = function (root) {
  const res = []
  if (!root) return res // 边界情况
  const queue = [] // 初始化队列
  queue.push(root)
  while (queue.length) {
    const level = [] // 当前层
    const len = queue.length // 记录当前队列(也就是当前层)有多少元素
    for (let i = 0; i < len; i++) {
      const top = queue.shift() // 队头元素记录&出队
      level.push(top.val)
      // 如果队头元素有左子树,推入“下一层”的队列
      if (top.left) {
        queue.push(top.left)
      }
      // 如果队头元素有右子树,推入“下一层”的队列
      if (top.right) {
        queue.push(top.right)
      }
    }
    // 记录当前层的结果
    res.push(level)
  }
  return res
}

总结

  • 二叉树的层序遍历,使用队列辅助

(Easy) —— 翻转二叉树

给你一棵二叉树的根节点 root ,翻转这棵二叉树,并返回其根节点。

 

示例 1:

输入: root = [4,2,7,1,3,6,9]
输出: [4,7,2,9,6,3,1]

示例 2:

输入: root = [2,1,3]
输出: [2,3,1]

示例 3:

输入: root = []
输出: []

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 二叉树
  • 翻转

  翻转 即为左右左右的镜像对称,用递归的思路实现最简单,只需要实现所有结点的左右子树交换。

递归实现二叉树的翻转

/**
 * 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 {TreeNode}
 */
var invertTree = function (root) {
  // 处理初始化时root为空 和 叶子节点的情况
  if (!root) {
    return root
  }
  // 记录左右子树
  // 一定要都记录,这样可以满足非满二叉树的情况,确保左右子树可以交换
  let left = invertTree(root.left)
  let right = invertTree(root.right)
  root.left = right
  root.right = left
  return root // 返回上个函数调用栈
}

总结

  • 二叉树的翻转,使用递归最简单

(Easy) —— 二叉搜索树中的插入操作

给定二叉搜索树(BST)的根节点 root 和要插入树中的值 value ,将值插入二叉搜索树。 返回插入后二叉搜索树的根节点。 输入数据 保证 ,新值和原始二叉搜索树中的任意节点值都不同。

注意,可能存在多种有效的插入方式,只要树在插入后仍保持为二叉搜索树即可。 你可以返回 任意有效的结果 。

 

示例 1:

输入: root = [4,2,7,1,3], val = 5
输出: [4,2,7,1,3,5]
解释: 另一个满足题目要求可以通过的树是:

示例 2:

输入: root = [40,20,60,10,30,50,70], val = 25
输出: [40,20,60,10,30,50,70,null,null,25]

示例 3:

输入: root = [4,2,7,1,3,null,null,null,null,null,null], val = 5
输出: [4,2,7,1,3,5]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 二叉搜索树
  • 插入

  二叉搜索树插入 操作是基于 二叉搜索树 的查找的。

  二叉树的查找某个val = n 的逻辑

  • 从根节点出发
  • 如果当前结点 val < n,说明 n 在右子树里,往右子树里查找
  • 如果当前结点 val > n,说明 n 在左子树里,往左子树里查找
  • 如果当前结点 val = n,找到了
  • 如果当前结点为 null,说明已经不能再继续查找了,没有符合要求的结点

  二叉搜索树的插入逻辑: 当查找到某个为 null 的结点时,我们生成一个新的结点,替代这个不存在的结点即可

递归实现搜索二叉树的插入

/**
 * 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
 * @param {number} val
 * @return {TreeNode}
 */
var insertIntoBST = function (root, val) {
  // 走到头了,不能再继续搜索了,直接插入,不会破坏搜索二叉树的形态要求
  if (!root) {
    root = new TreeNode(val)
    return root
  }
  // 如果当前结点值 > val,说明目标结点应该在它的左子树里
  if (root.val > val) {
    // 修改左子树的形态
    root.left = insertIntoBST(root.left, val)
  }
  // 否则,说明目标结点应该在它的右子树里
  else {
    // 修改右子树的形态
    root.right = insertIntoBST(root.right, val)
  }
  return root // 返回修改后的根结点
}

总结

  • 搜索二叉树的插入, 套用 搜索二叉树的搜索,在叶子结点处一定能找到一个能插入的位置,创建一个新的结点插入即可

(Easy) —— 将有序数组转换为二叉搜索树

给你一个整数数组 nums ,其中元素已经按 升序 排列,请你将其转换为一棵 高度平衡 二叉搜索树。

高度平衡 二叉树是一棵满足「每个节点的左右两个子树的高度差的绝对值不超过 1 」的二叉树。

 

示例 1:

输入: nums = [-10,-3,0,5,9]
输出: [0,-3,9,-10,null,5]
解释: [0,-10,5,null,-3,null,9] 也将被视为正确答案:

示例 2:

输入: nums = [1,3]
输出: [3,1]
解释: [1,null,3][3,1] 都是高度平衡二叉搜索树。

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 二叉搜索树
  • 构建
  • 高度平衡

  题目给出的有序数组的顺序就相当于二叉搜索树的 中序遍历 序列,我们构建出的二叉搜索树就像是把数组从中间位置 “提起来” 了。

  再来考虑题目的另一个要求,高度平衡。我们分别考虑下数组 length奇数偶数 的情况。

  • 奇数:比如 [-10,-3,0,5,9]“提” 成二叉树是这样的

    image.png

  自动满足 高度平衡 的条件。

  • 偶数:比如 [-10,-3,0,5]“提” 成二叉树是这样的

  把 -3 作为根节点:

    image.png

  把 0 作为根节点也是类似的,高度差正好是 1,满足 高度平衡 的条件。

递归构建二叉搜索树(BST)

/**
 * 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 {number[]} nums
 * @return {TreeNode}
 */
var sortedArrayToBST = function (nums) {
  // 处理边界情况
  if (!nums.length) {
    return null
  }
  // 构建二叉搜索树
  const buildBST = function (low, high) {
    // 递归的边界,也就是叶子结点,它的 left/right 是null
    if (low > high) {
      return null
    }
    // 取索引中间值。不直接((low + high)/2)是因为可能会溢出,超出Infinity
    const mid = Math.floor(low + (high - low) / 2)
    const cur = new TreeNode(nums[mid]) // 趁热打铁,先把当前结点构建出来
    cur.left = buildBST(low, mid - 1) // 递归构建左子树
    cur.right = buildBST(mid + 1, high) // 递归构建右子树
    return cur // 将每次递归构建并关联好的的结点依次返回
  }
  const root = buildBST(0, nums.length - 1)
  return root
}

总结

  • 使用 递归 + 二分 的方法,可以帮助构建 BFS 二叉搜索树

(Easy) —— 平衡二叉树

给定一个二叉树,判断它是否是高度平衡的二叉树。

本题中,一棵高度平衡二叉树定义为:

一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过 1 。

 

示例 1:

输入: root = [3,9,20,null,null,15,7]
输出: true

示例 2:

输入: root = [1,2,2,3,3,null,null,4,4]
输出: false

示例 3:

输入: root = []
输出: true

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 二叉树
  • 是否平衡

  高度平衡二叉树 的要求是 一个二叉树 每个节点 的左右两个子树的高度差的绝对值不超过 1

  一看又是要使用 递归,在递归中要判断每个结点的左右子树的高度差,为了在查找时,一旦有结点不符合高度平衡的规则,尽快的将 false 的结果返回,我创建一个 flag, 用于维护当前递归的结点是否符合高度平衡要求。

递归计算每个结点左右子树高度差

/**
 * 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 {boolean}
 */
var isBalanced = function (root) {
  let flag = true // 标识,默认flag表示当前符合平衡二叉树,false表示不符合
  const dfs = function (root) {
    // 两种情况:
    // 1.空树/叶子结点,高度记录为0
    // 2.flag=false,表示不符合平衡二叉树,也要return,retrn什么值无所谓
    if (!root || !flag) {
      return 0
    }
    const left = dfs(root.left) // 计算左子树的高度
    const right = dfs(root.right) // 计算右子树的高度
    // 左右子树高度差相差大于1,不符合平衡二叉树,flag置为false,return
    if (Math.abs(left - right) > 1) {
      flag = false
      return
    }
    // 计算当前子树高度
    return Math.max(left, right) + 1
  }
  dfs(root)
  return flag
}

总结

  • 借助一个 flag 标识尽早的将结果返回
  • 建议将代码直接背下来

(Medium) —— 删除二叉搜索树中的节点

给定一个二叉搜索树的根节点 root 和一个值 key,删除二叉搜索树中的 key 对应的节点,并保证二叉搜索树的性质不变。返回二叉搜索树(有可能被更新)的根节点的引用。

一般来说,删除节点可分为两个步骤:

  1. 首先找到需要删除的节点;
  2. 如果找到了,删除它。

 

示例 1:

输入: root = [5,3,6,2,4,null,7], key = 3
输出: [5,4,6,2,null,null,7]
解释: 给定需要删除的节点值是 3,所以我们首先找到 3 这个节点,然后删除它。
一个正确的答案是 [5,4,6,2,null,null,7], 如下图所示。
另一个正确答案是 [5,2,6,null,4,null,7]。

示例 2:

输入: root = [5,3,6,2,4,null,7], key = 0
输出: [5,3,6,2,4,null,7]
解释: 二叉树不包含值为 0 的节点

示例 3:

输入: root = [], key = 0
输出: []

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 二叉搜索树
  • 删除

  删除二叉搜索树的某个结点,有 5 种情况:

  1. 没有找到目标结点,将根节点返回
  2. 找到了目标结点,是叶子结点,直接删除
  3. 找到了目标节点(不是叶子结点),且只有左子树。此时找到左子树最大的结点(一定是叶子结点),用它替换目标结点,之后删除左子树中这个最大的结点
  4. 找到了目标节点(不是叶子结点),且只有右子树。此时找到右子树最小的结点(一定是叶子结点),用它替换目标结点,只有删除右子树中这个最小的结点
  5. 找到了目标结点(不是叶子结点),它既有左子树又有右子树。此时套用方法3、4中的任意一个即可

image.png

递归法实现删除二叉搜索树中的结点

/**
 * 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
 * @param {number} key
 * @return {TreeNode}
 */
var deleteNode = function (root, key) {
  // 没找到目标结点,返回
  if (!root) {
    return root
  }
  // 定位到目标结点
  if (root.val === key) {
    // 如果是叶子结点,直接删除,不影响搜索二叉树结构
    if (!root.left && !root.right) {
      root = null
    }
    // 有左子树
    else if (root.left) {
      // 查找左子树里val最大的结点
      const leftMax = findLeftMax(root.left)
      // leftMax 替换当前要删除的结点,分为两步
      // 1. 替换val
      root.val = leftMax.val
      // 2. 删除leftMax结点(该结点一定是叶子结点,可以直接删)
      root.left = deleteNode(root.left, leftMax.val)
    }
    // 有右子树
    else {
      // 查找右子树里val最小的结点
      const rightMin = findRightMin(root.right)
      // rightMin 替换当前要删除的结点,分为两步
      // 1. 替换val
      root.val = rightMin.val
      // 2. 删除rightMin结点(该结点一定是叶子结点,可以直接删)
      root.right = deleteNode(root.right, rightMin.val)
    }
  }
  // 目标结点在右子树
  else if (root.val < key) {
    root.right = deleteNode(root.right, key)
  }
  // 目标结点在左子树
  else {
    root.left = deleteNode(root.left, key)
  }
  return root
}

// 查找左子树val最大结点
let findLeftMax = function (root) {
  while (root.right) {
    root = root.right
  }
  return root
}

// 查找右子树val最小结点
let findRightMin = function (root) {
  while (root.left) {
    root = root.left
  }
  return root
}

总结

  • 搜索二叉树删除 操作稍微复杂一点。使用递归的思路是将目标结点的 val 替换成一个适合的叶子结点的值,之后将那个叶子结点删除

(Medium) —— 验证二叉搜索树

给你一个二叉树的根节点 root ,判断其是否是一个有效的二叉搜索树。

有效 二叉搜索树定义如下:

  • 节点的左子树只包含 小于 当前节点的数。
  • 节点的右子树只包含 大于 当前节点的数。
  • 所有左子树和右子树自身必须也是二叉搜索树。

 

示例 1:

输入: root = [2,1,3]
输出: true

示例 2:

输入: root = [5,1,4,null,null,3,6]
输出: false
解释: 根节点的值是 5 ,但是右子节点的值是 4

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 二叉搜索树
  • 验证

  验证二叉树是否是一个有效的二叉搜索树,思路说起来也简单,只需要判断对每个结点都进行验证,验证它的左子树所有结点的 val 是否都小于它自身的 val,它的右子树所有结点的 val 是否都大于它自身的 val

  好了,有重复性的操作,最简单的思路当然是递归。

递归验证搜索二叉树的有效性

/**
 * 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 {boolean}
 */
var isValidBST = function (root) {
  // 借助一个递归函数
  const dfs = function (root, minValue, maxValue) {
    // 空树,合法
    if (!root) {
      return true
    }
    // 不合法的条件:左孩子大于根节点值 || 右孩子小于根节点值
    if (root.val <= minValue || root.val >= maxValue) {
      return false
    }
    // 分别将左右孩子传过去,分别验证
    return (
      dfs(root.left, minValue, root.val) && dfs(root.right, root.val, maxValue)
    )
  }
  // 初始化的时候,给个一定没有问题的最大最小值(Infinity表示JavaScript数字类型的最大值)
  return dfs(root, -Infinity, Infinity)
}

总结

  • 这道题使用递归的方式非常简介和巧妙,多看注释思考,背也要背下来

(Medium) —— 将二叉搜索树变平衡

给你一棵二叉搜索树,请你返回一棵 平衡后 的二叉搜索树,新生成的树应该与原来的树有着相同的节点值。如果有多种构造方法,请你返回任意一种。

如果一棵二叉搜索树中,每个节点的两棵子树高度差不超过 1 ,我们就称这棵二叉搜索树是 平衡的 。

 

示例 1:

输入: root = [1,null,2,null,3,null,4,null,null]
输出: [2,1,3,null,null,null,4]
解释: 这不是唯一的正确答案,[3,1,4,null,2,null,null] 也是一个可行的构造方案。

示例 2:

输入: root = [2,1,3]
输出: [2,1,3]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 普通二叉树
  • 转化
  • 平衡二叉树

  普通二叉树转化为平衡二叉树的思路很简单,获取普通二叉树的中序遍历顺序,使用二分法转化成的二叉树就是平衡二叉树

普通二叉树转化为平衡二叉树

/**
 * 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 {TreeNode}
 */
var balanceBST = function (root) {
  // 先构造出中序遍历数组
  // 再二分构造出平衡二叉树
  const nums = [] // 中序遍历数组
  // 中序遍历
  const inorder = function (root) {
    if (!root) {
      return
    }
    //
    if (root.left) inorder(root.left)
    nums.push(root.val)
    if (root.right) inorder(root.right)
  }
  // 二分构建平衡二叉树
  const buildAVL = function (low, high) {
    // 叶子结点,某个子结点是null
    if (low > high) {
      return null
    }
    const mid = Math.floor(low + (high - low) / 2) // 取中值
    const cur = new TreeNode(nums[mid]) // 构造节点
    cur.left = buildAVL(low, mid - 1) // 构造左子树
    cur.right = buildAVL(mid + 1, high) // 构造右子树
    return cur // 返回根节点
  }
  inorder(root)
  return buildAVL(0, nums.length - 1)
}

总结

  • 普通二叉树转化为平衡二叉树的思路很简单,获取普通二叉树的中序遍历顺序,使用二分法转化成的二叉树就是平衡二叉树

(Medium) —— 数组中的第K个最大元素

给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。

请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。

你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。

 

示例 1:

输入: [3,2,1,5,6,4], k = 2
输出: 5

示例 2:

输入: [3,2,3,1,2,4,5,5,6], k = 4
输出: 4

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 非排序数组
  • 第k个最大元素

  取 第k个最大元素 的方式很简单,只要数组是有序的,那么不是分分钟就取出来?先按照这个思路写一下

先排序,后取数

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function(nums, k) {
    nums.sort((a, b) => b - a) // 对数组进行原地排序
    return nums[k - 1] // 返回第k个最大元素
};

  上面的方式简单有效,但不够出彩,这道题有一个有逼格的解法,优先队列。下面简单介绍一下 优先队列

  优先队列本质上就是一个 堆结构(heap),主要是大顶堆和小顶堆。

  对于这道题,我们的思路是:

  1. 先维护一个元素数量为k的小顶堆
  2. 后续的元素如果大于堆顶元素,则删除栈顶元素,将新元素插入
  3. 后续的元素如果小于堆顶元素,不理会
  4. 元素遍历完后,堆顶元素即为第k个最大的元素

  堆的常见操作有元素的 插入取出堆顶 这两种,这道题都用到了。

  • 第一步用到了 插入
  • 第二步、第三步 用到了 取出顶堆

优先队列(小顶堆)获取第k个最大

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function (nums, k) {
  // 先维护一个元素数量为k的小顶堆
  // 后续的元素如果大于堆顶元素,则删除栈顶元素,将新元素插入
  // 后续的元素如果小于堆顶元素,不理会
  // 元素遍历完后,堆顶元素即为第k个最大的元素

  const heap = [] // 堆
  let cur = 0 // 指针(指向堆最后)
  const len = nums.length

  // 创建小顶堆
  function createHeap() {
    for (let i = 0; i < k; i++) {
      // 前k个元素插入小顶堆中
      insertHeap(nums[i])
    }
  }
  // 元素插入小顶堆
  function insertHeap(n) {
    heap[cur] = n // 先放到小顶堆最后面
    // 向上对比+交换
    upHeap(0, cur)
    cur++
  }
  // 插入
  function upHeap(low, high) {
    let child = high
    let parent = Math.floor((child - 1) / 2)
    while (parent >= low) {
      // 交换
      if (heap[parent] > heap[child]) {
        let temp = heap[parent]
        heap[parent] = heap[child]
        heap[child] = temp
        child = parent
        parent = Math.floor((child - 1) / 2)
      } else {
        break
      }
    }
  }
  // 删除
  function downHeap(low, high) {
    let parent = low
    let child = 2 * parent + 1 // 初始化左孩子
    while (child <= high) {
      if (child + 1 <= high && heap[child + 1] < heap[child]) {
        child = child + 1
      }
      // 交换
      if (heap[parent] > heap[child]) {
        let temp = heap[parent]
        heap[parent] = heap[child]
        heap[child] = temp
        parent = child
        child = 2 * parent + 1
      } else {
        break
      }
    }
  }
  // 更新小顶堆
  function updateHeap() {
    for (let i = k; i < len; i++) {
      if (nums[i] > heap[0]) {
        heap[0] = nums[i]
        downHeap(0, k)
      }
    }
  }
  createHeap() // 初始化元素数量为k的小顶堆
  updateHeap() // 不断更新小顶堆
  return heap[0] // 最终的堆顶元素就是第k个最大元素
}

总结

  • 获取第k个最大元素,有两种思路:
  1. (较简单)先排序,后索引取
  2. (较复杂)维护一个元素数为k的二叉堆,不断用后面的元素更新这个二叉堆,最终的堆顶元素就是结果