LeetCode 289. 生命游戏:题解+优化,从基础到原地最优

今天拆解 LeetCode 中等题「289. 生命游戏」,这道题是经典的细胞自动机问题,核心考察对「原地修改」和「位运算优化」的理解,新手容易踩浅拷贝的坑,进阶则需要掌握空间复杂度的优化技巧。下面从题目解读、原代码分析、问题修正到最优解,一步步讲明白,保证新手能看懂,进阶者有收获。

一、题目核心解读(必看,避免理解偏差)

先明确题目本质:给定 m×n 的网格(每个格子是细胞,0=死、1=活),根据 4 条生存规则,同时更新所有细胞的状态(重点:同时更新,不能修改一个细胞后,用它的新状态去判断其他细胞),且要求「原地修改」(不返回任何值,直接修改输入的 board 数组)。

4条生存规则(简化记忆)

  • 活细胞(1):

    • 邻居活细胞数 < 2 → 死亡(变0)

    • 邻居活细胞数 = 2/3 → 存活(保持1)

    • 邻居活细胞数 > 3 → 死亡(变0)

  • 死细胞(0):

    • 邻居活细胞数 = 3 → 复活(变1)

补充:邻居是指细胞的 8 个相邻位置(水平、垂直、对角线),超出网格边界的位置不计入邻居。

二、原代码解读(你的代码,先看优点再找问题)

先贴出你提供的代码,再逐段分析其思路、优点和可优化/修正的点,这样更容易理解优化的意义。

typescript 复制代码
/**
 Do not return anything, modify board in-place instead.
 */
function gameOfLife(board: number[][]): void {
  const rows = board.length;
  const cols = board[0].length;
  const newBoard = new Array(rows).fill(null).map(() => new Array(cols).fill(0));

  const countNeighbors = (board: number[][], row: number, col: number): number => {
    let count = 0;
    for (let i = -1; i <= 1; i++) {
      for (let j = -1; j <= 1; j++) {
        if (i === 0 && j === 0) {
          continue;
        }
        const newRow = row + i;
        const newCol = col + j;
        if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols) {
          count += board[newRow][newCol];
        }
      }
    }
    return count;
  }

  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      const count = countNeighbors(board, i, j);
      if (board[i][j]) {
        if (count >= 2 && count <= 3) {
          newBoard[i][j] = 1;
        }
      } else {
        if (count === 3) {
          newBoard[i][j] = 1;
        }
      }
    }
  }

  board.forEach((row, i) => {
    row.forEach((cell, j) => {
      board[i][j] = newBoard[i][j];
    });
  });
};

1. 原代码核心思路(正确,新手友好)

核心思路是「先计算所有细胞的新状态,再批量更新到原数组」,避免了「修改一个细胞影响其他细胞判断」的问题,步骤清晰:

  1. 创建一个和原数组同尺寸的 newBoard(全0初始化),用于存储所有细胞的新状态;

  2. 编写 countNeighbors 函数,遍历当前细胞的 8 个邻居,统计活细胞(1)的数量;

  3. 遍历原数组的每个细胞,根据邻居数和生存规则,给 newBoard 赋值(新状态);

  4. 将 newBoard 的值逐个拷贝回原数组 board,完成原地修改。

2. 优点(值得保留)

  • 逻辑清晰,新手容易理解,完全规避了「同时更新」的坑;

  • countNeighbors 函数的边界判断正确,没有遗漏超出网格的情况;

  • 规则判断严谨,活细胞和死细胞的分支逻辑正确,没有违背题目规则。

3. 可优化/可改进的点(重点,提升效率)

这段代码功能正确,但在「时间效率」和「空间效率」上有优化空间,同时存在一些冗余操作,具体如下:

(1)空间冗余:newBoard 占用额外 O(m×n) 空间

题目要求「原地修改」,虽然最终实现了,但创建 newBoard 会占用和原数组一样的空间,当 m、n 很大时(比如 1000×1000),内存开销会比较大。最优解可以做到 O(1) 额外空间(不创建新数组)。

(2)时间冗余:邻居统计和数组拷贝可优化
  • countNeighbors 函数用双层 for 循环(i从-1到1,j从-1到1)遍历8个邻居,需要判断「i=0 && j=0」跳过自身,可简化为「方向数组」遍历,减少条件判断;

  • 最终的 board.forEach 双层拷贝,有函数调用开销,可替换为更高效的直接循环赋值;

  • count += board[newRow][newCol] 可结合位运算优化,提升执行效率(后续会讲)。

(3)边界处理缺失:空数组判断

当 board 是空数组(rows=0)时,board[0] 会报错,需要添加边界处理(一行代码即可)。

三、优化版本(分两步:先优化时间,再优化空间)

下面分两个版本优化,先保留 newBoard 思路、优化时间效率(新手易上手),再给出最优的「原地标记法」(进阶,O(1) 额外空间),大家可根据自身基础选择。

版本1:时间优化(保留 newBoard,提升运行速度)

针对原代码的时间冗余,做5处优化,不改变核心思路,却能显著提升实际运行效率(常数级优化):

typescript 复制代码
/**
 Do not return anything, modify board in-place instead.
 */
function gameOfLife(board: number[][]): void {
  const rows = board.length;
  // 优化1:添加空数组边界处理,避免 board[0] 报错
  if (rows === 0) return;
  const cols = board[0].length;
  const newBoard = new Array(rows).fill(null).map(() => new Array(cols).fill(0));

  // 优化2:用方向数组存储8个邻居的偏移量,替代双层for循环,减少条件判断
  const directions = [
    [-1, -1], [-1, 0], [-1, 1],
    [0, -1],          [0, 1],
    [1, -1],  [1, 0], [1, 1]
  ];

  // 优化3:简化邻居统计函数,用方向数组遍历,去掉i===0&&j===0的判断
  const countNeighbors = (row: number, col: number): number => {
    let count = 0;
    for (const [dx, dy] of directions) {
      const newRow = row + dx;
      const newCol = col + dy;
      // 合并边界判断,提前跳过无效坐标
      if (newRow < 0 || newRow >= rows || newCol < 0 || newCol >= cols) {
        continue;
      }
      // 优化4:用 &1 提取最低位,确保只统计活细胞(健壮性+效率提升)
      count += board[newRow][newCol] & 1;
    }
    return count;
  };

  // 优化5:简化规则判断,合并分支,减少嵌套if
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      const neighborCount = countNeighbors(i, j);
      const isAlive = board[i][j] === 1;
      // 一行代码实现规则判断,逻辑更简洁,执行效率更高
      newBoard[i][j] = (isAlive && (neighborCount === 2 || neighborCount === 3)) 
                      || (!isAlive && neighborCount === 3) 
                      ? 1 : 0;
    }
  }

  // 优化6:替换双层forEach,用直接循环赋值,减少函数调用开销
  for (let i = 0; i < rows; i++) {
    board[i] = newBoard[i]; // 直接覆盖行数组,比逐个赋值j更快
  }
};
版本1优化说明(关键亮点)
  • 方向数组:将8个邻居的偏移量存在数组中,遍历数组即可,去掉了「跳过自身」的条件判断,代码更简洁,执行更快;

  • 位运算 &1:board[newRow][newCol] &1 能快速提取数字的最低位(0或1),确保统计的是活细胞,同时位运算比普通数值相加效率更高(CPU直接支持底层运算);

  • 简化分支:将活细胞、死细胞的规则合并为一行条件判断,减少嵌套if,降低CPU分支预测失败的概率,提升运行效率;

  • 高效拷贝:用 board[i] = newBoard[i] 直接覆盖行数组,替代双层forEach,避免了forEach的函数调用开销,拷贝速度更快。

版本2:最优解 | 原地标记法(O(1) 额外空间,工业级方案)

这是这道题的最优解,核心是「用位运算在原数组中存储两个状态」,彻底去掉 newBoard,实现 O(1) 额外空间,同时保持时间复杂度 O(m×n)(下界,无法再优化)。

核心思路(关键理解点)

每个细胞的值是整数(0或1),而整数在计算机中以二进制存储,我们可以利用「二进制的不同位」存储不同状态:

  • 二进制 最低位(第0位):存储细胞的「原始状态」(0=死,1=活);

  • 二进制 第1位:存储细胞的「新状态」(0=死,1=活);

举例:

  • 原始状态1(活),新状态1(活)→ 二进制 11 → 十进制 3;

  • 原始状态1(活),新状态0(死)→ 二进制 01 → 十进制 1;

  • 原始状态0(死),新状态1(活)→ 二进制 10 → 十进制 2;

  • 原始状态0(死),新状态0(死)→ 二进制 00 → 十进制 0。

步骤:先遍历所有细胞,根据规则给「第1位(新状态)」赋值;遍历结束后,将每个细胞的值右移1位,丢弃原始状态(第0位),保留新状态(第1位),完成原地更新。

最优解代码
typescript 复制代码
/**
 Do not return anything, modify board in-place instead.
 */
function gameOfLife(board: number[][]): void {
  const rows = board.length;
  if (rows === 0) return;
  const cols = board[0].length;

  // 方向数组,简化8个邻居遍历
  const directions = [
    [-1, -1], [-1, 0], [-1, 1],
    [0, -1],          [0, 1],
    [1, -1],  [1, 0], [1, 1]
  ];

  // 统计邻居活细胞数量(只取原始状态,即最低位)
  const countNeighbors = (row: number, col: number): number => {
    let count = 0;
    for (const [dx, dy] of directions) {
      const newRow = row + dx;
      const newCol = col + dy;
      if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols) {
        // 只取最低位(原始状态),不受新状态(第1位)干扰
        count += board[newRow][newCol] & 1;
      }
    }
    return count;
  };

  // 第一步:遍历所有细胞,标记新状态(存储在第1位)
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      const neighborCount = countNeighbors(i, j);
      const originalState = board[i][j] & 1; // 提取原始状态

      // 根据规则更新第1位(新状态)
      if (originalState === 1) {
        // 活细胞:2/3个邻居 → 新状态1(将第1位置为1,用 | 2 实现)
        if (neighborCount === 2 || neighborCount === 3) {
          board[i][j] |= 2; // 2的二进制是10,按位或赋值,不影响第0位
        }
        // 否则新状态0,无需操作(第1位默认0)
      } else {
        // 死细胞:3个邻居 → 新状态1(将第1位置为1)
        if (neighborCount === 3) {
          board[i][j] |= 2;
        }
      }
    }
  }

  // 第二步:右移1位,丢弃原始状态,保留新状态(完成原地更新)
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      board[i][j] >>= 1; // 右移1位,等价于 Math.floor(board[i][j]/2)
    }
  }
};
最优解优势说明
  1. 空间最优:无需创建任何额外数组,额外空间复杂度 O(1),完全符合原地修改要求;

  2. 时间高效:仍然是 O(m×n) 时间复杂度,且位运算的使用让执行速度更快;

  3. 逻辑严谨:先标记所有新状态,再统一更新,完全满足「同时更新」的规则,没有任何逻辑漏洞。

四、关键知识点总结(必记,应对同类题目)

这道题的核心考点不是「遍历」,而是「原地修改」和「位运算优化」,记住这2个关键点,能应对同类细胞自动机或原地更新问题:

1. 原地修改的核心技巧

当需要「同时更新所有元素」,且不能用额外空间时,可利用「原有元素的冗余位/值域」存储临时状态(本题用二进制位,其他题可能用正负值标记),避免创建新数组。

2. 位运算的实用场景

  • 提取最低位:x & 1 → 判断x是奇数(1)还是偶数(0),也可用于提取二进制最低位的状态;

  • 设置某一位为1:x |= (1 << k) → 将x的第k位置为1(本题用 x |= 2,即 1<<1);

  • 右移实现整除:x >>= 1 → 等价于 Math.floor(x / 2),效率比除法高。

3. 邻居遍历的简化方法

对于「8方向邻居」或「4方向邻居」,用「方向数组」存储偏移量,能简化代码、减少条件判断,提升可读性和执行效率,这是刷题中常用的小技巧。

五、刷题建议

  1. 新手:先掌握「版本1」的优化思路,确保能写出正确、高效的代码,理解方向数组和位运算的基础用法;

  2. 进阶:吃透「版本2」的原地标记法,尝试自己手写一遍,重点理解「用二进制位存储双状态」的思路,以后遇到同类原地更新题就能举一反三;

相关推荐
自己的九又四分之三站台2 小时前
9:MemNet记忆层使用,实现大模型对话上下文记忆
人工智能·算法·机器学习
LXS_3572 小时前
STL - 函数对象
开发语言·c++·算法
aini_lovee2 小时前
基于粒子群算法(PSO)优化BP神经网络权值与阈值的实现
神经网络·算法
老鼠只爱大米2 小时前
LeetCode经典算法面试题 #230:二叉搜索树中第K小的元素(递归法、迭代法、Morris等多种实现方案详细解析)
算法·leetcode·二叉搜索树·二叉树遍历·第k小的元素·morris遍历
星期五不见面2 小时前
嵌入式学习!(一)C++学习-leetcode(21)-26/1/29
学习·算法·leetcode
有诺千金2 小时前
VUE3入门很简单(4)---组件通信(props)
前端·javascript·vue.js
2501_944711432 小时前
Vue-路由懒加载与组件懒加载
前端·javascript·vue.js
2501_941322032 小时前
通信设备零部件识别与检测基于改进YOLOv8-HAFB-2算法实现
算法·yolo
modelmd2 小时前
【递归算法】汉诺塔
python·算法