leetcode 之递归(二叉树)

286 阅读9分钟

该部分为重点内容。 建议多刷几遍 中序遍历的迭代法和最近公共祖先。

树结构

  • 二叉树:一个二叉树的所有非叶子节点都存在左右子节点,并且所有叶子节点都在同一层级上。(所以节点都是满的)

  • 完全二叉树:相比满二叉树,完全二叉树的最后一层的叶子节点可以不满,并且如果减少就只能按照从右往左的顺序减少

  • 二叉搜索/查找树:左子树节点值都小于根节点,右子树节点值都大于根节点,并且左右子树也都为二叉搜索树。(没有相同值)

  • 平衡二叉树:(1)要么是空树;(2)要么左右子树的高度之差不大于1;(3)子树也都是平衡二叉树。

  • 红黑树:也叫自平衡二叉搜索树,综合了平衡二叉树和二叉搜索树的性质。


递归终止条件:当参数为xx时,递归结束,返回结果。

找出函数的等价关系式:通过一些辅助的变量或操作不断缩小参数的范围,并且保证原函数的结果不变。

路径之和问题

112. 路径总和:分三种情况讨论

题目112.路径总和

分别判断 空节点、叶子节点和其他节点 这三种情况。

const hasPathSum = function(root, targetSum) {
  // 遍历到空节点
  if (!root)  return false;
  // 遍历到叶子节点,判断值相等
  if (!root.left && !root.right)  return root.val == targetSum;
  // 用 ||,只要有一个满足即可返回 true!!!
  return hasPathSum(root.left, targetSum - root.val) || hasPathSum(root.right, targetSum - root.val);
}

时间复杂度:O(n),其中 n 是树的节点数,对每个节点访问一次。

空间复杂度:O(H), 其中H是树的高度。空间复杂度主要取决于递归时栈空间的开销,最坏情况树呈现链状,此时空间复杂度O(n),平均情况下树的高度与节点数的对数正相关,此时空间复杂度O(logn)。

129. 求根节点到叶节点数字之和:分三种情况 + preSum

题目129.求根节点到叶节点数字之和

  • 分别判断 空节点、叶子节点和其他节点 三种情况
  • 注意函数参数要加一个 preSum
const dfs = function(root, preSum = 0) {  // 注意
  if (!root)  return 0;  // 空节点
  
  preSum = preSum * 10 + root.val;
  if (!root.left && !root.right)  return preSum;
  
  return dfs(root.left, preSum) + dfs(root.right, preSum);
}

时间复杂度:O(n),其中 n 是二叉树的节点个数。

空间复杂度:O(H)。

二叉树的前中后序遍历:递归、迭代

题目144. 二叉树的前序遍历

题目94. 二叉树的中序遍历

题目145. 二叉树的后序遍历

  • 前序遍历顺序:根节点 -> 左子树 -> 右子树
  • 中序遍历顺序:左子树 -> 根节点 -> 右子树
  • 后序遍历顺序:左子树 -> 右子树 -> 根节点

遍历的顺序可以记为根节点的遍历位置。

1. 递归:借助三元运算符

// 前序遍历
const preorder = function(root) {
  return root ? [root.val, ...preorder(root.left), ...preorder(root.right)] : [];
}
// 中序遍历
const inorder = function(root) {
  return root ? [...inorder(root.left), root.val, ...inorder(root.right)] : [];
}
// 后序遍历
const postorder = function(root) {
  return root ? [...postorder(root.left), ...postorder(root.right), root.val] : [];
}

时间复杂度:O(n), 其中n是二叉树的节点数。

空间复杂度:O(H),H是树的高度。

2. 迭代:root & stack.length

由于递归解法较简单,在面试时通常会让你使用非递归的算法,这就需要借助 来实现。「递归的代替就是 栈 + 循环」

对于前序遍历,我们先让父节点进栈再出栈,然后根据栈 「后进先出」 的性质,先让root.right入栈、再让root.left入栈。

const preorder = function(root) {
  if (!root)  return [];
  const res = [];  // 遍历结果
  const stack = [root];  // 栈,模拟递归调用
  
  while (stack.length > 0) { // 关注 stack.length
    let node = stack.pop();  
    res.push(node.val);
    node.right && stack.push(node.right);  // 注意 1.判断; 2. node,不是root
    node.left && stack.push(node.left); 
  }
  return res;
}

「那么后序遍历呢?」

前序遍历是中左右,后序遍历是左右中,根据前序遍历的迭代解法,我们将左右子节点的入栈顺序调换,使得输出顺序为 中右左,然后我们返回倒序的数组使得顺序为 左右中。

  • 改变前序遍历的左右子树入栈顺序;
  • reverse颠倒顺序。
const postorder = function(root) {
  if (!root)  return [];
  const res = [];  
  const stack = [root];  
  
  while (stack.length > 0) {
    let node = stack.pop();
    res.push(node.val);
    node.left && stack.push(node.left);  
    node.right && stack.push(node.right);  
  }
  return res.reverse(); // 唯一区别点
};

「那么中序遍历呢? - 中序迭代最容易考到」

中序遍历的顺序是左中右,和前后序的迭代遍历思路不一样。多记多熟悉

leetcode 关于二叉树的讲解

const inorder = function(root) {
  const res = [];  
  const stack = [];  
  
  // 思路随着 最左侧叶子节点 走
  while (root || stack.length > 0) {  // 注意
    if (root) {  // 将根节点和所有左子节点都入栈,从最左侧的叶子节点开始
      stack.push(root);
      root = root.left;
    } else { 
      root = stack.pop();  // root 为空就给 root 重新赋值
      res.push(root.val);
      root = root.right; // 右节点入栈
    } 
  }
  return res;
};

230. 二叉搜索树中第K小的元素:中序遍历迭代法

题目230. 二叉搜索树中第K小的元素

根据二叉搜索树的性质,中序遍历得到的是升序数组 ,因此可以借助中序遍历的迭代法,在找到答案后就提前返回,不需要遍历整棵树。

const kthSmallest = function(root, k) {
  const stack = [];
  
  while (root || stack.length > 0) {
    if (root) {
      stack.push(root);
      root = root.left;
    } else {
      root = stack.pop();
      k--;
      if (k == 0)  return root.val;
      root = root.right;
    }
  }
}

236. 二叉树的最近公共祖先:分三种情况讨论

该题较典型,大公司考察比较多,多刷几遍。

题目236. 二叉树的最近公共祖先

最近公共祖先指的是同时为两个节点的祖先,且深度尽可能大!

参考题解

「最近公共祖先的情况有3种」

  1. rootpq中的一个,这时公共祖先就是root
  2. pq分别root的左右子树上,这时公共祖先也只有root本身。
  3. pq同时root的左子树,则公共祖先就是递归得到的左子树结果;同理当都在右子树,那么公共祖先就是递归得到的右子树结果。
const common = function(root, p, q) {
  if (!root)  return null;  // 递归终止条件
  if (root == p || root == q)  return root;  // 第1种情况
  
  // 递归遍历,判断 p 和 q 在两侧子树还是都在一侧
  let left = common(root.left, p, q); 
  let right = common(root.right, p, q);
  
  if (left && right)  return root; 
  return left ? left : right;  // 有一侧为空的情况
}

226.翻转二叉树

题目226.翻转二叉树

递归反转即可。

const invertTree = function(root) {
  if (!root)  return null;
  
  [root.left, root.right] = [root.right, root.left];  
  invertTree(root.left);
  invertTree(root.right);
  
  return root; 
};

时间复杂度:O(n)。

根据两个顺序构造二叉树

首先,对二叉树的3种遍历顺序进行总结:

「前序遍历」:[根节点, [左子树的前序遍历结果], [右子树的前序遍历结果]]

「中序遍历」:[[左子树的中序遍历结果], 根节点, [右子树的中序遍历结果]]

「后序遍历」:[[左子树的后序遍历结果], [右子树的后序遍历结果], 根节点]

然后我们来看看构造的思路,

「前序 + 中序 => 二叉树」:前序遍历的第一个元素[根节点] 为切割点,由于题意给出二叉树没有重复元素,可以在中序数组中用indexOf()定位出根节点索引。然后我们就知道左子树和右子树的节点数目、知道左右子树的前序和中序遍历结果,递归构造根节点即可。

「中序 + 后序 => 二叉树」:后序遍历的最后一个元素为切割点,其他同上。

注意,前序和后序不能构造唯一的二叉树,因为无法计算出左右子树的节点数目。

105. 从前序和中序遍历序列构造二叉树

题目105.从前序和中序遍历序列构造二叉树

const buildTree = function(preorder, inorder) {
  if (preorder.length == 0) return null;  // 递归终止条件
  
  let root = new TreeNode(preorder[0]);  // 创建节点
  let index = inorder.indexOf(root.val);   
  root.left = buildTree(preorder.slice(1, index + 1), inorder.slice(0, index));
  root.right = buildTree(preorder.slice(index + 1),inorder.slice(index + 1));
  
  return root;  // 注意
};

106. 从中序和后序遍历序列构造二叉树

题目106.从中序和后序遍历序列构造二叉树

const buildTree = function(inorder, postorder) {
    if (inorder.length == 0)   return null;
    
    let root = new TreeNode(postorder[postorder.length - 1]);
    let index = inorder.indexOf(root.val);
    root.left = buildTree(inorder.slice(0, index), postorder.slice(0, index));
    root.right = buildTree(inorder.slice(index + 1), postorder.slice(index, -1));  // slice(,-1)
    
    return root;
};

最大深度问题

104. 二叉树的最大深度

题目 104.二叉树的最大深度

最大深度是根节点到最远叶子节点的最长路径上的节点数

递归法较为常用,迭代法也建议掌握。

1. 递归

const maxDepth = function(root) {
  if (!root) return 0;
  return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
};

时间复杂度:O(n)。

空间复杂度:O(H)。 H是二叉树的高度,递归需要栈空间,而栈空间取决于递归的深度。

2. 迭代

  1. 根节点入队
  2. 队头出队列,然后左右子节点入队
  3. 当前这一层的所有节点都出队后,再对下一层重复该操作

通过一个 while 循环控制从上向下一层层遍历, for 循环控制每一层从左向右遍历

// 广度优先搜索
const maxDepth = function(root) {
  if (!root)  return 0;
  
  let res = 0;  // 最大深度
  const stack = [root];
  
  while (stack.length > 0) {  
    // 必须要有 n,因为下一层循环要把上一层的节点全部 pop
    let n = stack.length;  
    for (let i = 0; i < n; i++) {
      let node = stack.shift();  // 注意shift
      node.left && stack.push(node.left);
      node.right && stack.push(node.right);
    }
    res++;
  }
  return res;
}

110.平衡二叉树

题目110.平衡二叉树

「自顶向下地递归」: 借助求二叉树最大深度的函数,递归比较每个节点的左右子树的最大高度差。

const maxDepth = function(root) {  // 求二叉树最大深度
  if (!root) return 0;
  return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
};

const isBalanced = function(root) {
  if (!root)  return true;  // 空树也是平衡二叉树
  // 当高度差小于等于1时,继续递归左右子节点
  return Math.abs(maxDepth(root.left) - maxDepth(root.right)) <= 1 
         && isBalanced(root.left) 
         && isBalanced(root.right);
}

时间复杂度:O(n^2)。

空间复杂度:O(H)。 主要取决于递归调用的层数。

543. 二叉树的直径

题目543.二叉树的直径

分析:

  • 假设一条路径上有N个节点,那么它的长度就是N - 1
  • 对二叉树的任一节点,以它为根节点,假设我们知道它的左子树向下遍历经过的最多节点数是L (也就是深度)、右子树向下遍历经过的最多节点数是R,那么 「经过该节点的路径上最多包含的节点数」L + R + 1「路径长度」L + R
  • 二叉树的直径就是 「以所有节点为根、求得的路径长度中最大的那个」

思路: 我们借助求二叉树最大深度的函数,对二叉树递归遍历、更新最大值。

注意: 「二叉树的直径」 等于max(root.left的直径, root.right的直径, root.left的最大深度 + root.right的最大深度),因为最长的路径不一定经过根节点root,所以左右子树也要写上。

const maxDepth = function(root) {  // 求最大深度,即经过的节点数
  if (!root) return 0;
  return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
};

const diam = function(root) {
  if (!root)  return 0;  
  let height = maxDepth(root.left) + maxDepth(root.right);  // 对左右子节点求最大深度,路径长度 L + R
  return Math.max(height, diam(root.left), diam(root.right));
}