LeetCode 427. 建立四叉树:递归思想的经典应用

0 阅读7分钟

在LeetCode的树类题目中,四叉树的构建属于“分而治之”思想的典型实践——将一个大的二维区域不断拆分为更小的子区域,直到每个子区域满足“叶子节点”的条件。今天我们就来详细拆解427. 建立四叉树这道题,从题目理解到代码实现,再到细节优化,帮你彻底掌握这类递归题的解题逻辑。

一、题目核心理解

题目给出一个n×n的二进制矩阵(仅包含0和1),要求我们用四叉树表示该矩阵。首先得明确四叉树的核心规则,这是解题的前提:

  • 每个节点有两个关键属性:isLeaf(是否为叶子节点)和val(叶子节点的区域值,非叶子节点可任意赋值)。

  • 叶子节点:当前区域内所有元素值相同(全0或全1),此时isLeaf=true,四个子节点均为null。

  • 非叶子节点:当前区域内元素值不同,此时isLeaf=false,需将当前区域平均拆分为4个相等的子区域,递归构建每个子区域的四叉树作为子节点。

补充说明:题目中n一定是2的幂次(隐含条件,无需额外处理),因此每次拆分都能得到4个等大的子区域,避免了边界处理的复杂问题。

二、解题核心思路:递归 + 分治

四叉树的构建本质是“不断拆分区域、判断是否为叶子”的过程,完美契合递归的思想——大问题(构建整个矩阵的四叉树)可以拆解为4个小问题(构建4个子区域的四叉树),每个小问题的解决逻辑与大问题完全一致。

具体步骤可总结为3步:

  1. 判断当前区域是否为“纯区域”(全0或全1):遍历当前区域的所有元素,若所有元素与区域左上角元素相同,则为纯区域,直接返回叶子节点。

  2. 若不是纯区域:将当前区域沿水平和垂直方向各平分一次,拆分为上左、上右、下左、下右4个子区域。

  3. 递归构建4个子区域的四叉树,将它们作为当前非叶子节点的4个子节点,返回当前节点。

这里的关键是“如何定位区域”——我们不需要每次都截取子矩阵(会浪费空间),而是通过「区域的左上角坐标和右下角坐标」来定位当前区域,这样既能节省空间,又能提高效率。

三、代码详解(TypeScript)

先看题目给出的四叉树节点定义(无需修改),再逐步解析核心代码:

class _Node {
  val: boolean
  isLeaf: boolean
  topLeft: _Node | null
  topRight: _Node | null
  bottomLeft: _Node | null
  bottomRight: _Node | null
  constructor(val?: boolean, isLeaf?: boolean, topLeft?: _Node, topRight?: _Node, bottomLeft?: _Node, bottomRight?: _Node) {
    this.val = (val === undefined ? false : val)
    this.isLeaf = (isLeaf === undefined ? false : isLeaf)
    this.topLeft = (topLeft === undefined ? null : topLeft)
    this.topRight = (topRight === undefined ? null : topRight)
    this.bottomLeft = (bottomLeft === undefined ? null : bottomLeft)
    this.bottomRight = (bottomRight === undefined ? null : bottomRight)
  }
}

核心函数:construct

该函数是入口,主要作用是初始化递归,传入整个矩阵的区域范围(左上角(0,0),右下角(n,n),其中n是矩阵边长)。

function construct(grid: number[][]): _Node | null {
  // 递归函数:dfs(grid, 左上角行, 左上角列, 右下角行, 右下角列)
  const dfs = (grid: number[][], r0: number, c0: number, r1: number, c1: number) => {
    // 步骤1:判断当前区域是否为纯区域(全0或全1)
    let same = true;
    for (let i = r0; i < r1; ++i) {
      for (let j = c0; j < c1; ++j) {
        if (grid[i][j] !== grid[r0][c0]) {
          same = false;
          break; // 只要有一个不同,就不是纯区域,提前退出内层循环
        }
      }
      if (!same) break; // 提前退出外层循环,减少无效遍历
    }

    // 步骤2:若是纯区域,返回叶子节点
    if (same) {
      // val为true对应1,false对应0;isLeaf设为true,子节点默认为null
      return new _Node(grid[r0][c0] === 1, true);
    }

    // 步骤3:若不是纯区域,拆分区域,递归构建子节点
    const midR = Math.floor((r0 + r1) / 2); // 水平方向中点(行)
    const midC = Math.floor((c0 + c1) / 2); // 垂直方向中点(列)
    // 构建当前非叶子节点,val可任意设(这里设为true),isLeaf设为false
    const ret: _Node = new _Node(
      true,
      false,
      dfs(grid, r0, c0, midR, midC),    // 上左子区域
      dfs(grid, r0, midC, midR, c1),    // 上右子区域
      dfs(grid, midR, c0, r1, midC),    // 下左子区域
      dfs(grid, midR, midC, r1, c1)     // 下右子区域
    );
    return ret;
  }

  // 初始调用:整个矩阵的范围是(0,0)到(n,n),n=grid.length
  return dfs(grid, 0, 0, grid.length, grid.length);
};

代码关键细节解析

  1. 递归函数的参数设计:r0, c0 是当前区域的左上角坐标(行、列),r1, c1 是当前区域的右下角坐标(行、列),注意是“左闭右开”区间(即包含r0、c0,不包含r1、c1),这样拆分时不会出现重复或遗漏。

  2. 纯区域判断:通过两层循环遍历当前区域,一旦发现不同元素,立即设same=false并退出循环,避免无效遍历,提升效率。

  3. 区域拆分:中点midRmidC通过取整得到,确保拆分后的4个子区域大小相等(因为n是2的幂次,中点一定是整数)。

  4. 非叶子节点的val:题目明确说明,非叶子节点的val可任意赋值(判题机制会接受),因此这里设为true即可,不影响结果。

四、效率分析与优化方向

1. 时间复杂度

设矩阵边长为n(n=2^k),每次递归会将区域拆分为4个边长为n/2的子区域,直到区域边长为1(叶子节点)。

  • 第0层(整个矩阵):1个区域,遍历n²个元素;

  • 第1层:4个区域,每个遍历(n/2)²个元素,总遍历4×(n²/4) = n²;

  • 第2层:16个区域,每个遍历(n/4)²个元素,总遍历16×(n²/16) = n²;

  • ... 直到第k层,总遍历次数为(k+1)×n²。

因为k=log₂n,所以时间复杂度为O(n² log n),在n最大为1024(2^10)的情况下,效率完全可控。

2. 优化方向

上述代码的核心优化点的是“避免重复遍历”,当前代码已经做了“发现不同立即退出”的优化,还可以进一步优化:

  • 前缀和优化:提前计算矩阵的前缀和,通过前缀和快速判断当前区域是否为纯区域(无需遍历所有元素),将纯区域判断的时间从O(n²)降至O(1),整体时间复杂度可优化为O(n²)。

  • 空间优化:递归会占用栈空间,最坏情况下栈深度为log₂n(n=1024时栈深度为10),无需额外优化;若担心递归栈溢出,可将递归改为迭代(用栈模拟递归过程)。

五、常见易错点总结

  1. 区域坐标的“左闭右开”问题:若误将r1c1设为“包含”,会导致拆分时出现边界重叠或遗漏,比如n=2时,r0=0, r1=2,拆分后midR=1,子区域为(0,0)-(1,1)和(1,0)-(2,1),刚好覆盖整个区域。

  2. 叶子节点的子节点必须设为null:虽然节点构造函数中默认子节点为null,但手动确认更稳妥,避免出现不必要的错误。

  3. 非叶子节点的isLeaf必须设为false:若遗漏此设置,会导致判题错误,因为非叶子节点必须有4个子节点,isLeaf为false是区分叶子与非叶子的关键。

六、总结

LeetCode 427题的核心是“分治+递归”,解题的关键在于:

  • 用坐标定位区域,避免截取子矩阵,节省空间;

  • 先判断是否为纯区域,再决定是否拆分,减少无效递归;

  • 明确四叉树的节点属性规则,避免细节错误。

这道题虽然是树类题目,但本质是对“分而治之”思想的应用,掌握这种思路后,面对类似的“区域拆分”问题(如二维区域的递归处理),都能快速找到解题方向。