LeetCode 236. 二叉树的最近公共祖先:两种解法详解(递归+迭代)

0 阅读8分钟

LeetCode 上的经典二叉树问题——236. 二叉树的最近公共祖先,这道题是二叉树遍历的核心应用,也是面试中高频考察的题目,不仅要会做,还要理解两种主流解法(递归、迭代)的逻辑,搞懂“最近”二字的底层判断逻辑。

先明确题目核心,避免踩坑:题目给出一棵二叉树(注意:不是二叉搜索树!这和后续的 BST 版本有本质区别),以及两个指定节点 p 和 q,要求找到它们的最近公共祖先(LCA)。

一、题目核心定义(必看)

百度百科对最近公共祖先的定义:对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足两个条件:

  • x 是 p 和 q 的共同祖先(节点本身也可以是自己的祖先,比如 p 是 q 的祖先时,p 就是 LCA);

  • x 的深度尽可能大(“最近”的核心含义:在所有共同祖先中,离 p、q 最近,也就是最深的那个)。

举个简单例子:如果 p 在 q 的左子树里,那么 q 就是 LCA;如果 p 和 q 分别在某个节点的左右子树里,那么这个节点就是 LCA。

二、前置准备(TreeNode 类定义)

题目给出的二叉树节点类定义(TypeScript 版本),后续解法均基于这个类实现,这里统一贴出,方便大家对照:

class TreeNode {
  val: number
  left: TreeNode | null
  right: TreeNode | null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.left = (left === undefined ? null : left)
    this.right = (right === undefined ? null : right)
  }
}

三、解法一:递归(最优解,时间/空间 O(n))

3.1 核心思路

递归的核心是「后序遍历」—— 因为我们需要先遍历当前节点的左、右子树,判断左、右子树中是否包含 p 或 q,再根据判断结果,确定当前节点是否是 LCA。

递归函数的作用:判断当前节点所在的子树中,是否包含 p 或 q(返回布尔值)。

LCA 的判断条件(满足任一即可):

  1. 当前节点的左子树包含一个节点(p 或 q),右子树包含另一个节点(q 或 p)—— 此时当前节点就是 LCA;

  2. 当前节点本身就是 p 或 q,且其左子树或右子树中包含另一个节点 —— 此时当前节点就是 LCA(因为节点本身可以是自己的祖先)。

注意:因为递归是后序遍历,会从叶子节点往上回溯,所以第一次满足上述条件的节点,就是「最深」的公共祖先(即最近公共祖先),我们只需记录这个节点即可。

3.2 完整代码

function lowestCommonAncestor_1(root: TreeNode | null, p: TreeNode | null, q: TreeNode | null): TreeNode | null {
  let result: TreeNode | null = null; // 存储最终的最近公共祖先
  // 递归函数:判断当前节点所在子树是否包含p或q
  const dfs = (node: TreeNode | null, p: TreeNode | null, q: TreeNode | null): boolean => {
    if (!node) { // 空节点,不包含p和q,返回false
      return false;
    }
    // 递归遍历左子树,判断左子树是否包含p/q
    const lson = dfs(node.left, p, q);
    // 递归遍历右子树,判断右子树是否包含p/q
    const rson = dfs(node.right, p, q);
    
    // 判断当前节点是否是LCA
    if ((lson && rson) || ((node.val === p?.val || node.val === q?.val) && (lson || rson))) {
      result = node; // 满足条件,更新result为当前节点
    }
    
    // 返回当前子树是否包含p或q(供父节点判断)
    return lson || rson || (node.val === p?.val || node.val === q?.val);
  }
  dfs(root, p, q); // 从根节点开始递归
  return result;
};

3.3 代码解析(关键细节)

  • result 变量:用于存储 LCA,因为递归函数返回的是布尔值,无法直接返回节点,所以用全局变量(相对于递归函数)记录。

  • lson 和 rson:分别表示左、右子树是否包含 p 或 q,由递归调用的返回值获得。

  • 判断条件拆解:

    • (lson && rson):左子树有一个,右子树有另一个,当前节点是 LCA;

    • ((node.val === p?.val || node.val === q?.val) && (lson || rson)):当前节点是 p 或 q,且子树中包含另一个节点,当前节点是 LCA。

  • 递归终止条件:节点为 null 时,返回 false(空树不包含任何节点)。

3.4 复杂度分析

  • 时间复杂度:O(n) —— 最坏情况下,需要遍历二叉树的所有节点(比如 p 和 q 分别在叶子节点)。

  • 空间复杂度:O(n) —— 递归调用栈的深度,最坏情况下(二叉树为链状),栈深为 n。

四、解法二:迭代(基于栈的后序遍历,时间/空间 O(n))

递归的思路很简洁,但如果担心递归栈溢出(比如面对极深的二叉树),可以用迭代的方式实现,核心还是「后序遍历」,同时记录节点的路径,通过路径对比找到 LCA。

4.1 核心思路

迭代法的核心是「记录路径」:

  1. 用栈模拟后序遍历,遍历过程中,记录从根节点到当前节点的路径(path 数组);

  2. 用 visited 集合标记已经访问过的节点(避免重复遍历右子树);

  3. 第一次找到 p 或 q 时,记录此时的路径(tempPath);

  4. 第二次找到另一个节点时,对比 tempPath(第一个节点的路径)和当前 path(第二个节点的路径),找到两条路径的「最后一个公共节点」,这个节点就是 LCA;

  5. 如果只找到一个节点(p 或 q 不在树中),则返回 null。

4.2 完整代码

function lowestCommonAncestor_2(root: TreeNode | null, p: TreeNode | null, q: TreeNode | null): TreeNode | null {
  // 边界判断:根节点或p/q为空,直接返回null
  if (!root || !p || !q) {
    return null;
  }
  // 特殊情况:根节点就是p或q,直接返回根节点(另一个节点一定在根节点的子树中)
  if (root === p || root === q) {
    return root;
  }

  const path: TreeNode[] = []; // 存储当前遍历的路径(根节点到当前节点)
  const visited: Set<TreeNode> = new Set(); // 标记已访问的节点,避免重复处理
  let tempPath: TreeNode[] = []; // 存储第一个找到的节点(p或q)的路径
  let curr: TreeNode | null | undefined = root;
  // 先把根节点的所有左子节点入栈(初始化路径)
  while (curr) {
    path.push(curr);
    curr = curr.left;
  }
  
  let foundCount = 0; // 记录找到p/q的次数(0/1/2)
  let result: TreeNode | null = null;
  
  while (path.length) {
    curr = path[path.length - 1]; // 栈顶元素(当前节点)
    if (!curr) { // 空节点,弹出,继续循环
      path.pop();
      continue;
    }
    if (!visited.has(curr)) {
      // 标记当前节点为已访问(先标记,再处理右子树)
      visited.add(curr);

      // 判断当前节点是否是p或q
      if (curr?.val === p?.val || curr?.val === q?.val) {
        foundCount++;
        if (foundCount === 1) {
          // 第一次找到,记录当前路径
          tempPath = [...path];
        } else if (foundCount === 2) {
          // 第二次找到,对比两条路径,找最后一个公共节点
          let i = 0;
          while (i < tempPath.length && i < path.length && tempPath[i].val === path[i].val) {
            i++;
          }
          // i-1就是最后一个公共节点的索引(i是第一个不相等的位置)
          result = i > 0 ? tempPath[i - 1] : root;
          break; // 找到LCA,退出循环
        }
      }

      // 处理右子树:把右子树的所有左节点入栈(模拟后序遍历)
      let right: TreeNode | null = curr.right;
      while (right && !visited.has(right)) {
        path.push(right);
        right = right.left;
      }
    } else {
      // 已访问过,弹出栈(后序遍历:左->右->根,处理完右子树再弹出根)
      path.pop();
    }
  }
  // 只有找到两个节点,才返回result,否则返回null
  return foundCount === 2 ? result : null;
};

4.3 代码解析(关键细节)

  • 边界处理:先判断根节点、p、q是否为空,再判断根节点是否是p或q(此时根节点就是LCA)。

  • 路径初始化:先把根节点的所有左子节点入栈,这是迭代后序遍历的常用初始化方式,保证先遍历左子树。

  • visited 集合:避免重复处理节点(比如右子树的节点不会被多次入栈),同时控制栈的弹出时机(处理完右子树后,再弹出当前节点)。

  • 路径对比:当第二次找到节点时,两条路径的前缀是从根节点到LCA的路径,循环找到第一个不相等的位置,前一个位置就是LCA。

4.4 复杂度分析

  • 时间复杂度:O(n) —— 每个节点入栈、出栈各一次,遍历所有节点。

  • 空间复杂度:O(n) —— 栈(path)和 visited 集合的最大容量均为 n(链状二叉树时)。

五、两种解法对比 & 解题技巧

解法优点缺点适用场景
递归(解法一)代码简洁、逻辑清晰,容易理解和实现递归栈可能溢出(极深二叉树)日常刷题、面试优先写(代码短,不易出错)
迭代(解法二)无递归栈溢出问题,稳定性更高代码繁琐,逻辑细节多(路径记录、访问标记)二叉树极深,或禁止使用递归的场景

解题关键技巧

  • 区分「二叉树」和「二叉搜索树」的LCA:本题是普通二叉树,无法利用BST的有序性,只能用后序遍历;如果是BST,可通过节点值大小判断p、q在左/右子树,逻辑更简单。

  • 节点本身是自己的祖先:这是容易忽略的点,比如p是q的父节点,此时p就是LCA,两种解法都通过判断“当前节点是p/q且子树包含另一个节点”覆盖了这种情况。

  • 递归的“回溯性”:递归的后序遍历天然带有回溯特性,从叶子节点往上判断,第一次满足条件的节点就是最近公共祖先,无需额外判断深度。

六、总结

LeetCode 236 是二叉树后序遍历的典型应用,核心是「先判断子树,再判断当前节点」,找到满足条件的最深公共祖先。

面试中,优先写出递归解法(简洁高效),并能解释清楚递归的逻辑(后序遍历、LCA的判断条件);如果面试官追问迭代解法,再写出基于栈的后序遍历+路径对比的实现。

建议大家动手模拟一下递归和迭代的执行过程(比如用简单的二叉树,比如根节点1,左2右3,p=2、q=3),就能更直观地理解两种解法的逻辑,避免死记硬背代码。