今天拆解 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. 原代码核心思路(正确,新手友好)
核心思路是「先计算所有细胞的新状态,再批量更新到原数组」,避免了「修改一个细胞影响其他细胞判断」的问题,步骤清晰:
-
创建一个和原数组同尺寸的 newBoard(全0初始化),用于存储所有细胞的新状态;
-
编写 countNeighbors 函数,遍历当前细胞的 8 个邻居,统计活细胞(1)的数量;
-
遍历原数组的每个细胞,根据邻居数和生存规则,给 newBoard 赋值(新状态);
-
将 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)
}
}
};
最优解优势说明
-
空间最优:无需创建任何额外数组,额外空间复杂度 O(1),完全符合原地修改要求;
-
时间高效:仍然是 O(m×n) 时间复杂度,且位运算的使用让执行速度更快;
-
逻辑严谨:先标记所有新状态,再统一更新,完全满足「同时更新」的规则,没有任何逻辑漏洞。
四、关键知识点总结(必记,应对同类题目)
这道题的核心考点不是「遍历」,而是「原地修改」和「位运算优化」,记住这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」的优化思路,确保能写出正确、高效的代码,理解方向数组和位运算的基础用法;
-
进阶:吃透「版本2」的原地标记法,尝试自己手写一遍,重点理解「用二进制位存储双状态」的思路,以后遇到同类原地更新题就能举一反三;