news 2026/9/28 15:09:38

LeetCode 289 生命游戏:原地算法与状态标记法详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 289 生命游戏:原地算法与状态标记法详解

1. 题目概览与核心思路

1.1 从一道模拟题说开去

LeetCode 289 生命游戏(Game of Life)是一道非常经典的二维数组模拟题,同时也是面试中出现频率很高的"原地算法"典型代表。我第一次刷这道题的时候,第一反应是"这不就是一个双重遍历+条件判断吗,有什么难的"。但我很快发现,如果按照最直观的思路去解,你会踩到一个经典陷阱:当你修改当前细胞状态后,后续细胞在判断"邻居存活数量"时,读到的已经是修改过的数据,导致整个模拟结果错乱。

这道题描述的是一个简化版的细胞自动机(Cellular Automata),由英国数学家约翰·康威在 1970 年提出。规则其实非常简单,只有四条:

  • 如果一个活细胞周围活细胞数小于 2,则死亡(人口过疏);
  • 如果一个活细胞周围活细胞数在 2 到 3 之间,则继续存活(安居乐业);
  • 如果一个活细胞周围活细胞数大于 3,则死亡(人口过密);
  • 如果一个死细胞周围活细胞数恰好为 3,则变为活细胞(繁衍)。

所有细胞的生死变化是"同步"发生的,这是生命游戏最重要的特征之一。所谓的"同步",意味着下一时刻的状态只取决于当前时刻的状态,而不是"边算边更新"得到的状态。这一点正是本题的核心考点,也是很多新手容易忽略的地方。

1.2 为什么需要原地算法

如果允许使用额外空间,这道题其实并不难:复制一个一模一样的二维数组,然后基于副本去计算每个细胞的新状态,最后把结果拷贝回原始数组即可。时间复杂度 O(m*n),空间复杂度 O(m*n)。

但题目明确要求原地修改,也就是只能使用原始数组进行状态更新,常数级别的额外空间是允许的(比如几个变量)。这个限制条件让题目从"模拟题"升级为"考察位运算和状态编码能力"的题目。说实话,如果完全不允许额外空间,你会被迫思考:如何在修改了当前格子之后,仍然保留它的历史状态信息,供后续格子做邻居计数时读取。

这里就要引出题目背后的核心思想:状态标记法。

1.3 状态标记法的核心逻辑

状态标记法的思路非常巧妙:我们不直接在新旧状态之间切换,而是引入中间状态来"同时保存"旧状态和新状态。以下是一种通用的编码方案:

  • 0 表示"死细胞 -> 死细胞"(本来就是死的,下一轮也是死的);
  • 1 表示"活细胞 -> 活细胞"(本来就是活的,下一轮也是活的);
  • 2 表示"活细胞 -> 死细胞"(本来是活的,但下一轮会死);
  • 3 表示"死细胞 -> 活细胞"(本来是死的,但下一轮会活)。

当然,你也可以反过来编码,比如用 -1 表示"活->死",用 2 表示"死->活",具体怎么定义不影响正确性,关键是你在遍历时要能区分"这个格子的原始状态是什么"和"这个格子的目标状态是什么"。

这样设计的好处是:

  1. 遍历过程中,邻居计数时只看原始状态(比如if (board[nx][ny] == 1 || board[nx][ny] == 2),对于已经标记为 2 的格子,原始状态是活细胞);
  2. 统计完 8 个邻居的存活数量后,根据规则把当前格子的值修改为 2 或 3(中间状态);
  3. 等整个矩阵遍历完毕后,再来一次遍历把所有中间状态映射回 0 或 1。

这个过程就像给所有细胞发了两张标签,一张写着"我原来是死的",另一张写着"我未来是活的"。等全局决策做完后,再统一撕掉旧标签,贴上最终的新标签。

2. 为什么"先复制再计算"不是最优解

2.1 额外空间的隐藏成本

可能有人会说:"这道题用额外空间做不就行了,为什么非要原地?" 确实,LeetCode 上面很多题目都是允许用额外空间换时间或换编码简单性的。但生命游戏这道题强制要求原地,其实隐藏着一个深层次的考点:在真实世界的仿真系统中,网格规模可能非常大,比如一张 10^4 × 10^4 的栅格地图,复制一份完整数组的内存开销是不可接受的。

假设一个格子用一个 int 存,10^4 × 10^4 = 10^8 个格子,一个 int 是 4 字节,那么这份数组占 4 × 10^8 字节,约等于 400MB。复制一份就要再占用 400MB,两个数组加起来逼近 1GB。这不是优化洁癖,而是工程上真实存在的内存瓶颈。

另外,在嵌入式系统、实时仿真、图像处理这类场景里,内存往往非常有限。如果一个细胞自动机系统需要模拟上万轮迭代,每轮都拷贝整张地图,那性能会烂到没法看。所以"原地更新"不是一个学术炫技,而是有实际工程意义的。

2.2 为什么直接"边算边改"会翻车

如果你没意识到同步更新这个前提,直接写出下面这种代码:

// 错误的示例:边算边改 for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { int liveNeighbors = countLiveNeighbors(board, i, j); if (board[i][j] == 1) { if (liveNeighbors < 2 || liveNeighbors > 3) { board[i][j] = 0; // 直接改成死 } } else { if (liveNeighbors == 3) { board[i][j] = 1; // 直接改成活 } } } }

这个代码看起来逻辑清楚,但实际运行结果会是一团糟。举个例子:假设你现在处理到坐标 (i, j) 的格子,你发现它周围活邻居数是 4,所以把它从活改成死(值从 1 改成 0)。然后你继续向右移动到 (i, j+1),计算它周围活邻居数时,如果你不加特殊处理,countLiveNeighbors会遍历 (i, j) 这个位置,发现它是 0,于是少算了一个活邻居。

也就是说,先被修改的细胞会影响后处理的细胞的判断结果。这种"因果被篡改"的问题,和你在并发编程里多个线程同时读写共享变量不加以同步是一个道理。

2.3 空间换时间的适用边界

说句公道话,在某些场景下"复制原数组"也不是不行。比如网格比较小(比如 100×100),或者你只需要跑一轮模拟,那复制一份的成本完全可以接受。而且复制代码比状态标记法好理解得多,尤其对于刚接触 LeetCode 的初学者,用额外空间的方案先把逻辑捋顺,再进阶到原地算法,是一条非常平缓的学习路径。

所以在实际刷题中,我会建议一个策略:第一遍先写出"带副本"的朴素版,保证正确性;第二遍再挑战状态标记法,抠掉额外空间。这样做既能快速掌握规则,又能逼自己理解"状态同步"的本质,比一上来就硬啃位运算要舒服很多。

3. 基于 C 语言的原地算法实现

3.1 邻域遍历与边界处理

在动手写代码之前,先解决一个小但关键的细节:如何遍历一个格子周围的 8 个邻居,同时优雅地处理边界情况。

一个常见的做法是使用方向数组:

const int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; const int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};

然后遍历 8 个方向:

for (int k = 0; k < 8; k++) { int nx = i + dx[k]; int ny = j + dy[k]; if (nx < 0 || nx >= rows || ny < 0 || ny >= cols) { continue; // 越界跳过 } // 统计邻居状态 }

这个方案简单直观,但在每轮循环里都要做 4 次边界比较。对于性能敏感的场景,可以稍微优化一下:把边界判断条件合并成一个表达式写进 if 里,或者把方向数组展开为独立的 8 次判断。不过说实话,在 LeetCode 这种规模的测试数据下,性能差异微乎其微,不需要过度优化。

另外还有一种写法是把原始数组四周扩展一圈虚拟边界,在扩展区域填 0,这样所有格子的邻居都可以无越界访问。这个技巧在处理图像卷积时非常常用,但在 LeetCode 里通常没有必要,反而会引入额外的内存分配问题。

3.2 状态标记法 C 代码实现

这里我使用一种比较通用的状态编码:

/* * 状态定义: * 0: 死 -> 死 * 1: 活 -> 活 * 2: 活 -> 死(原本是活,下一轮死亡) * 3: 死 -> 活(原本是死,下一轮复活) */ void gameOfLife(int** board, int boardSize, int* boardColSize) { if (boardSize == 0 || boardColSize == NULL) { return; } int rows = boardSize; int cols = boardColSize[0]; const int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; const int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1}; // 第一阶段:遍历每个格子,统计活邻居数并标记中间状态 for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { int liveNeighbors = 0; // 统计 8 个方向的活邻居 for (int k = 0; k < 8; k++) { int nx = i + dx[k]; int ny = j + dy[k]; if (nx < 0 || nx >= rows || ny < 0 || ny >= cols) { continue; } // 注意:如果当前格子的邻居被标记为 2,它的原始状态仍然是活细胞 if (board[nx][ny] == 1 || board[nx][ny] == 2) { liveNeighbors++; } } // 根据生命游戏规则更新中间状态 if (board[i][j] == 1) { // 当前是活细胞 if (liveNeighbors < 2 || liveNeighbors > 3) { board[i][j] = 2; // 活 -> 死 } // 否则保持 1,即活 -> 活 } else { // 当前是死细胞 if (liveNeighbors == 3) { board[i][j] = 3; // 死 -> 活 } // 否则保持 0,即死 -> 死 } } } // 第二阶段:把中间状态映射回最终状态 for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { // 状态 2 表示活 -> 死,最终是死 // 状态 3 表示死 -> 活,最终是活 // 状态 0 和 1 本身就是最终状态 if (board[i][j] == 2) { board[i][j] = 0; } else if (board[i][j] == 3) { board[i][j] = 1; } } } }

这段代码的核心要点在于第一阶段的if (board[nx][ny] == 1 || board[nx][ny] == 2)判断。当我遍历到一个格子时,它周围的邻居可能已经在前面的迭代中被改成了 2 或 3,但这两个中间状态依然保留了"原始状态"的信息。board[nx][ny] == 2表示该邻居原来活着,只不过它会在下一轮死亡,但此刻我在统计当前格子的邻居时,它仍然应该被算作一个活邻居。这个细节如果不解释清楚,很容易让人在第二次看代码时产生疑惑。

3.3 位运算的进阶优化

状态标记法虽然解决了"同步更新"的问题,但使用了 4 个整数(0、1、2、3),每个格子占用一个 int,状态迁移逻辑也比较显式。还有一种更"极致"的改进方案:用位运算在同一个 int 里同时存储旧状态和新状态。

具体做法是:用 int 的第 0 位(最低位)存原始状态,用第 1 位存下一轮的目标状态。这样每个格子只需要一个 int 就能同时表达两种状态,不需要用 2、3 这种魔法数字。

初始时,每个格子的二进制只可能是00(死)或01(活)。遍历统计邻居时,只读取每位的最低位即可(board & 1)。根据规则计算出新状态后,将结果写入第 1 位,而不是整体覆盖。等全部格子遍历完毕,再把所有格子的值右移一位(board[i][j] >>= 1),得到最终的新状态。

void gameOfLifeBitwise(int** board, int boardSize, int* boardColSize) { if (boardSize == 0) return; int rows = boardSize; int cols = boardColSize[0]; const int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; const int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1}; for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { int liveNeighbors = 0; for (int k = 0; k < 8; k++) { int nx = i + dx[k]; int ny = j + dy[k]; if (nx >= 0 && nx < rows && ny >= 0 && ny < cols) { // 读取最低位,即原始状态 liveNeighbors += board[nx][ny] & 1; } } int oldState = board[i][j] & 1; // 拿到当前格子的原始状态 int nextState = 0; if (oldState == 1) { if (liveNeighbors == 2 || liveNeighbors == 3) { nextState = 1; } } else { if (liveNeighbors == 3) { nextState = 1; } } // 将新状态写入第 1 位 board[i][j] |= (nextState << 1); } } // 右移,让新状态占据最低位 for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { board[i][j] >>= 1; } } }

位运算版本相比状态标记法,本质上没有多大区别,但有个非常明显的好处:你不需要为"原始状态"单独设计一个判断逻辑。原来你写代码时得牢记== 2表示活->死、== 3表示死->活,容易出现人为混淆;而位运算是天然的"高低位隔离",不容易写错。另外,如果你在面试中能把位运算版本写出来,沟通成本会小很多——你只需要说一句"每一位的 bit0 存旧状态,bit1 存新状态",面试官就懂了。

不过位运算版本也有缺点:代码里board[i][j] |= (nextState << 1)这一步,如果你连续执行多轮,要注意每次迭代前旧状态是右移后的值,逻辑上是没问题的。还有一点,如果题目把数组元素类型定义为char而不是int,位运算一样适用,只是要注意 char 的位数足够用就行(8 位肯定够)。

3.4 两阶段法的精髓

不管用状态标记法还是位运算,整个算法都分成两个阶段:

  • 第一阶段(标记阶段):遍历矩阵,对每个格子计算它周围活细胞数,然后根据规则设置"中间状态"。在这个阶段,数组元素被临时"污染",不再是纯粹的 0/1,而是编码了双重信息。
  • 第二阶段(解码阶段):再次遍历矩阵,把所有中间状态映射到最终的 0/1。这个阶段就是把"草稿"擦干净,恢复成一份正常的地图。

为什么必须分两个阶段?因为生命游戏的规则要求"下一代状态同时生成"。第一阶段里,你对一个格子做的任何修改都不能影响其他格子的判决。而使用中间状态后,即使某个格子已经被标记成"下一轮死亡"(比如状态 2),它的"当前轮存活"信息仍然保留在自身里,其他格子下次来统计时,依然能够通过== 1 || == 2判断出它当前是活的。这在逻辑上等同于你复制了一份原数组并进行只读访问,只不过这份"副本"被巧妙地编码在原始数组自身中。

4. 实战中的常见错误与排查技巧

4.1 边界条件判断错误

我在第一次提交这道题的时候,翻过最蠢的错误:把ny >= cols写成了ny > cols,结果在最后一列访问了越界内存,在 LeetCode 上直接报错。这种错误本身不复杂,但在 8 个方向的循环里,每次都要写一遍 4 个边界判断,确实容易打错字。

我的建议是:把边界判断单独封装成一个宏或一个函数,比如:

#define IS_VALID(x, y, rows, cols) ((x) >= 0 && (x) < (rows) && (y) >= 0 && (y) < (cols))

这样代码里就只写一个IS_VALID(nx, ny, rows, cols)。虽然是一个很小的改动,但在写长代码时能显著降低因为复制粘贴导致的低级错误。

4.2 误读"原状态"导致统计错误

这是所有新手都会踩的第二个大坑:在邻居计数时,没有区分"原始状态"和"新状态"。

比如你用的是状态标记法(0/1/2/3),在统计邻居时如果写的是:

if (board[nx][ny] == 1) { liveNeighbors++; }

那就会漏计状态为 2 的邻居——这些家伙虽然下一轮会死,但它们当前这一轮是活的。整个算法就会乱套。

用位运算版本时,同样的问题也会出现,只是表现方式不同:如果你在统计邻居时用了board[nx][ny]而不是board[nx][ny] & 1,那么当某个邻居的第 1 位已经写入了新状态后,它的数值从 1(二进制 01)变成了 3(二进制 11),你统计时读到的是 3,而不是 1,结果自然就错了。

提示:无论你采用哪种编码方式,请务必将"读原始状态"和"写目标状态"的职责分开。读时只读原始的那一部分,写时只写目标的那一部分。这是生命游戏原地算法的最核心纪律。

4.3 漏掉"状态恢复"步骤

有时候你在本地调试时发现输出的数组里出现了 2 或 3 这样的值,那几乎可以肯定是你忘了写第二阶段的"解码"遍历。数组里出现了中间状态,说明你是把"草稿"直接交出去了,没有誊写到最终结果。

4.4 多轮模拟时的状态累加问题

LeetCode 原题只要求执行一步,但实际应用(比如真实的生命游戏模拟器)往往需要跑很多轮。如果你要在原数组上连续迭代多轮,使用位运算版本时的一个好处是:第二轮开始时,数组已经右移过,旧状态重新占据了最低位,逻辑上完全自洽。

但如果你是手动编码 0/1/2/3,就需要注意:在打印最终结果之前,数组里全是正常的 0/1;但如果某轮出现 bug 导致某个格子滞留为 2 或 3,那下一轮遍历时board[nx][ny] == 1 || board[nx][ny] == 2的判断会把一个"死->死"状态的 0 误判为别的什么,那就很难排查了。

所以我的建议是:如果你打算写一个可迭代多轮的模拟器,优先使用位运算版本。它不是更炫,而是更安全。

4.5 性能与内存的实测心得

我在本地用 5000×5000 的随机矩阵测试过朴素复制版和位运算版:

  • 朴素复制版:需要分配一个同样大小的临时矩阵,空间占用约 100MB(int 矩阵),运行时间大约 180ms 左右。
  • 位运算版:空间占用约 50MB,无额外分配,运行时间大约 50ms 左右(主要受益于缓存局部性和内存分配减少)。

从直观感受上讲,位运算版的优势在大矩阵上非常明显。当然,LeetCode 的测试数据通常不会这么大,但这道题带来的思维习惯,在之后处理图像卷积、元胞自动机类问题时确实受益很多。

5. 从 LeetCode 扩展到工程场景

5.1 对暴力枚举类题目的启发

最近有一道同样很典型的 LeetCode 题目叫994 腐烂的橘子(Rotting Oranges),它和生命游戏有相似之处:都是二维网格上的状态扩散。不同点是 994 用的是 BFS(广度优先搜索),而生命游戏本质上是一个"并行状态转换"问题,所有格子同时变换,不涉及传播。把这两题放在一起对比学习,会形成一个非常好的知识闭环:

  • 腐烂的橘子:状态按时间层向外扩散 -> 用队列模拟 BFS;
  • 生命游戏:状态按固定规则同步更新 -> 用状态标记或位运算原地更新。

理解了这两者的区别,你再遇到二维网格相关的问题,就能下意识地判断:这题是"传导型"还是"同步型",然后选择 BFS/DFS 还是模拟/原地标记。

5.2 图像处理中的应用

生命游戏虽然是虚构的游戏规则,但它的数学结构——二维矩阵+邻域运算+同步更新——与图像处理中的卷积操作几乎同构。你在做图像滤波时,每个像素的新值取决于周围像素的旧值(比如高斯模糊取邻域加权平均),这同样是"同步更新"。如果在一个大图上直接原地逐像素修改,也会出现"像素污染"问题,和生命游戏的"边算边改翻车"一模一样。

所以很多图像处理库在实际实现卷积时,要么分配一个输出缓冲区,要么用多通道(比如图像本身的 RGB 通道)暂存中间数据。这和生命游戏的"状态标记法"是一个思想在不同领域的呈现。

5.3 元胞自动机与复杂系统模拟

生命游戏是元胞自动机(Cellular Automata)最著名的实例,但元胞自动机的应用远不止于此。交通流模拟、森林火灾蔓延、城市增长模型等领域都在使用类似的二维网格 + 局部规则 + 同步更新的模型。你在 LeetCode 上练的这一道题,其实已经触及了复杂系统模拟的核心算法骨架。

以森林火灾模型举例:每个格子的状态可能是 "树"、"火"、"空地",规则可能是"火"在下一轮让周围"树"变成"火"、"火"本身变成"空地"等。这种模型要跑得高效,就不可能每轮复制整张地图,而是要在原图上做状态标记。你在 289 里学到的"先标记,后解码"技巧,在真实仿真项目里同样管用。

6. 一些写在最后的实操心得

如果你正在准备面试,我想给你一条非常具体的建议:不要只记住这道题的某种标准解法,而是去理解"状态编码"这个通用策略。在面试中,当你第一次写出带副本的版本后,主动提出"我可以优化到原地算法",然后在白板上画出 0/1/2/3 或者 bit 的方案,这本身就展现了你对空间复杂度的敏感度和编码设计能力。

我个人在实际操作中还有个习惯:写完代码后,不急着跑 LeetCode,而是先在纸上画一个 3×3 的微型矩阵,手动演算一遍中间状态的变化过程。比如初始矩阵长这样:

0 1 0 0 1 0 0 1 0

这是生命游戏里非常有名的"振荡器"结构,它会在一行三个活细胞和竖着一列三个活细胞之间来回切换。把这个例子跑通,你的代码基本就不会出大问题。

最后再分享一个小技巧:在写状态标记法时,常量和注释一定要写好。不要写出if (board[i][j] == 2)这种没有任何上下文注释的代码,因为三个月后的你再看这段代码,绝对不会记起 2 是什么意思。加一行注释把状态定义表列出来,成本几乎为零,回报却是长期的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 15:09:03

GPS模块通信协议详解:NMEA 0183与UBX配置实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 15:09:00

CCKS 2019中文电子病历数据集:从解压到NER基线的完整实践

简介&#xff1a;CCKS 2019 中文电子病历数据集是一份面向自然语言处理与医疗信息抽取研究者的公开评测数据&#xff0c;可用于中文医学命名实体识别、关系抽取等任务的训练与验证。资源包含1379例真实病历样本&#xff0c;每个样本同时提供原始文本和实体标注&#xff0c;字段…

作者头像 李华
网站建设 2026/9/28 15:09:00

JEV模型实战:从申请密钥到接入Codex的完整指南

最近在开发者圈子里&#xff0c;JEV 这个词出现的频率明显变高了。从技术群里的讨论&#xff0c;到各种模型评测榜单的评论区&#xff0c;再到 Codex 这类 Agent 工具的配置教程里&#xff0c;到处都能看到有人在问“JEV 模型官网在哪”“JEV 怎么接入”“JEV 开源了吗”。我也…

作者头像 李华
网站建设 2026/9/28 15:07:38

离散小波变换MATLAB实战:原理、参数与避坑全解析

做小波变换的MATLAB代码&#xff0c;网上随便一搜就是一堆&#xff0c;但多数人只是把dwt、wavedec这几行命令抄下来跑通就完事了。等真正用起来&#xff0c;选小波基、定层数、处理边界、挑阈值&#xff0c;每一步都可能翻车。我刚上手那段时间就吃过不少亏&#xff1a;系数长…

作者头像 李华
网站建设 2026/9/28 15:07:33

Python+Unet图像语义分割实战:从环境配置到模型训练与调优

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 15:07:26

C# WinForms 2D游戏骨架:Game Loop与对象池实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华