康威生命游戏(Conway’s Game of Life)是数学家约翰·康威于1970年提出的零玩家游戏,也是细胞自动机(Cellular Automaton)领域最具代表性的模型。它看似简单——只有”存活”与”死亡”两种状态,却能在二维网格上涌现出令人惊叹的复杂行为:滑翔机跨越屏幕、脉冲星周期闪烁、甚至某些构型可模拟通用图灵机。本文将用Java完整实现生命游戏模拟器,深入讲解邻域扫描算法与B3/S23状态转移规则的工程实现。
一、规则建模:B3/S23 生存与繁殖
生命游戏的核心规则极度简洁,通常记为 B3/S23:
| 当前状态 | 邻居存活数 | 下一代状态 | 规则说明 |
|---|---|---|---|
| 存活 | < 2 | 死亡 | 孤独:邻居太少,无法维持生命 |
| 存活 | 2 或 3 | 存活 | 稳定:恰到好处的社群环境 |
| 存活 | > 3 | 死亡 | 拥挤:资源竞争导致灭绝 |
| 死亡 | 3 | 存活 | 繁殖:恰好三个邻居,诞生新生命 |
邻居采用摩尔邻域(Moore Neighborhood),即周围8个方向(上下左右 + 四个对角)。
二、核心数据结构
2.1 网格表示
使用二维布尔数组表示细胞状态,true 为存活,false 为死亡。为简化边界处理,采用固定边界 + 全死亡策略。
/**
* 生命游戏网格核心类
* 使用 boolean[][] 表示细胞存活状态
*/
public class LifeGrid {
private final int rows;
private final int cols;
// 当前代与下一代的双缓冲,避免原地更新导致的时序错误
private boolean[][] current;
private boolean[][] next;
public LifeGrid(int rows, int cols) {
this.rows = rows;
this.cols = cols;
this.current = new boolean[rows][cols];
this.next = new boolean[rows][cols];
}
/**
* 设置初始存活细胞
*/
public void setAlive(int row, int col) {
if (inBounds(row, col)) {
current[row][col] = true;
}
}
/**
* 设置初始死亡细胞
*/
public void setDead(int row, int col) {
if (inBounds(row, col)) {
current[row][col] = false;
}
}
/**
* 查询细胞当前状态
*/
public boolean isAlive(int row, int col) {
return inBounds(row, col) && current[row][col];
}
private boolean inBounds(int row, int col) {
return row >= 0 && row < rows && col >= 0 && col < cols;
}
public int getRows() { return rows; }
public int getCols() { return cols; }
/**
* 获取当前代引用(用于渲染)
*/
public boolean[][] getCurrentGrid() {
return current;
}
2.2 摩尔邻域扫描算法
这是生命游戏最频繁的计算操作。对每个细胞,统计其8个邻居中的存活数量。
/**
* 统计指定位置周围8个邻居的存活数量
* 采用摩尔邻域(Moore Neighborhood)
*
* @param row 目标行
* @param col 目标列
* @return 存活邻居数(0-8)
*/
public int countLiveNeighbors(int row, int col) {
int count = 0;
// 遍历 3x3 区域,排除中心点自身
for (int i = -1; i <= 1; i++) {
for (int j = -1; j <= 1; j++) {
if (i == 0 && j == 0) continue; // 跳过自己
int nr = row + i;
int nc = col + j;
// 边界外视为死亡,也可改为环形边界(torus)
if (inBounds(nr, nc) && current[nr][nc]) {
count++;
}
}
}
return count;
}
三、状态转移:一代到下一代的演化
状态转移必须基于当前代的全量快照计算下一代,不能边扫描边修改——否则尚未扫描的细胞会读取到已被更新的邻居状态,造成时序错误。我们使用双缓冲(Double Buffering)技术解决此问题。
/**
* 计算下一代网格状态
* 核心逻辑:遍历每个细胞,根据B3/S23规则决定其下一代生死
*/
public void nextGeneration() {
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
int neighbors = countLiveNeighbors(r, c);
boolean alive = current[r][c];
if (alive) {
// S23:存活细胞需要 2 或 3 个邻居才能继续存活
next[r][c] = (neighbors == 2 || neighbors == 3);
} else {
// B3:死亡细胞恰好有 3 个邻居时复活
next[r][c] = (neighbors == 3);
}
}
}
// 交换缓冲区:下一代变为当前代
swapBuffers();
}
/**
* 交换 current 与 next 数组引用,时间复杂度 O(1)
*/
private void swapBuffers() {
boolean[][] temp = current;
current = next;
next = temp;
}
四、经典图案加载器
生命游戏的魅力在于特定初始构型会产生稳定的周期性行为。我们提供几个经典图案的坐标加载器。
import java.util.List;
/**
* 经典图案工厂
* 所有坐标均为相对偏移量,方便在网格任意位置放置
*/
public class PatternFactory {
/**
* 滑翔机(Glider):最小的可移动图案,每4代沿对角线移动一格
*/
public static List<int[]> glider() {
return List.of(
new int[]{0, 1},
new int[]{1, 2},
new int[]{2, 0},
new int[]{2, 1},
new int[]{2, 2}
);
}
/**
* 脉冲星(Pulsar):周期为3的稳定振荡器,由三个十字交叉组成
*/
public static List<int[]> pulsar() {
return List.of(
// 上侧十字
new int[]{2, 4}, new int[]{2, 5}, new int[]{2, 6},
new int[]{2, 10}, new int[]{2, 11}, new int[]{2, 12},
// 上中横条
new int[]{4, 2}, new int[]{4, 7}, new int[]{4, 9}, new int[]{4, 14},
new int[]{5, 2}, new int[]{5, 7}, new int[]{5, 9}, new int[]{5, 14},
new int[]{6, 2}, new int[]{6, 7}, new int[]{6, 9}, new int[]{6, 14},
// 中间横条
new int[]{7, 4}, new int[]{7, 5}, new int[]{7, 6},
new int[]{7, 10}, new int[]{7, 11}, new int[]{7, 12},
// 下侧(镜像对称)
new int[]{9, 4}, new int[]{9, 5}, new int[]{9, 6},
new int[]{9, 10}, new int[]{9, 11}, new int[]{9, 12},
new int[]{10, 2}, new int[]{10, 7}, new int[]{10, 9}, new int[]{10, 14},
new int[]{11, 2}, new int[]{11, 7}, new int[]{11, 9}, new int[]{11, 14},
new int[]{12, 2}, new int[]{12, 7}, new int[]{12, 9}, new int[]{12, 14},
new int[]{14, 4}, new int[]{14, 5}, new int[]{14, 6},
new int[]{14, 10}, new int[]{14, 11}, new int[]{14, 12}
);
}
/**
* 信标(Beacon):周期为2的简单振荡器
*/
public static List<int[]> beacon() {
return List.of(
new int[]{0, 0}, new int[]{0, 1},
new int[]{1, 0}, new int[]{1, 1},
new int[]{2, 2}, new int[]{2, 3},
new int[]{3, 2}, new int[]{3, 3}
);
}
/**
* 将图案加载到网格指定位置
*/
public static void loadPattern(LifeGrid grid, List<int[]> pattern, int offsetRow, int offsetCol) {
for (int[] cell : pattern) {
grid.setAlive(offsetRow + cell[0], offsetCol + cell[1]);
}
}
}
五、主控引擎与终端渲染
为保持示例的纯粹性,我们使用终端字符渲染。你可以轻松替换为 JavaFX 或 Swing 图形界面。
/**
* 生命游戏主控引擎
* 负责演化调度与终端可视化
*/
public class GameOfLifeEngine {
private final LifeGrid grid;
private final int maxGenerations;
private final int delayMs;
public GameOfLifeEngine(int rows, int cols, int maxGenerations, int delayMs) {
this.grid = new LifeGrid(rows, cols);
this.maxGenerations = maxGenerations;
this.delayMs = delayMs;
}
public LifeGrid getGrid() {
return grid;
}
/**
* 启动模拟循环
*/
public void run() throws InterruptedException {
System.out.println("=== 康威生命游戏模拟器 ===");
System.out.println("初始状态:");
render();
for (int gen = 1; gen <= maxGenerations; gen++) {
Thread.sleep(delayMs);
grid.nextGeneration();
System.out.println("\n第 " + gen + " 代:");
render();
}
}
/**
* 终端渲染:用 '█' 表示存活,'·' 表示死亡
*/
private void render() {
boolean[][] state = grid.getCurrentGrid();
for (int r = 0; r < grid.getRows(); r++) {
StringBuilder line = new StringBuilder();
for (int c = 0; c < grid.getCols(); c++) {
line.append(state[r][c] ? "█" : "·");
}
System.out.println(line);
}
}
}
5.1 可运行的入口程序
/**
* 生命游戏入口
* 演示滑翔机与脉冲星的演化过程
*/
public class GameOfLifeDemo {
public static void main(String[] args) throws InterruptedException {
// 创建 20x40 的模拟网格
GameOfLifeEngine engine = new GameOfLifeEngine(20, 40, 50, 300);
LifeGrid grid = engine.getGrid();
// 加载滑翔机(左上角)
PatternFactory.loadPattern(grid, PatternFactory.glider(), 1, 1);
// 加载信标(中部)
PatternFactory.loadPattern(grid, PatternFactory.beacon(), 5, 15);
// 加载脉冲星(右下区域)
PatternFactory.loadPattern(grid, PatternFactory.pulsar(), 2, 22);
// 额外随机播种一些细胞增加趣味性
grid.setAlive(10, 10);
grid.setAlive(10, 11);
grid.setAlive(10, 12);
grid.setAlive(9, 12);
grid.setAlive(8, 11);
// 启动演化
engine.run();
}
}
六、算法优化:从 O(8RC) 到 O(RC) 的常数优化
虽然时间复杂度已经是网格规模的线性级,但每个细胞需要访问8次内存。利用字节掩码,我们可以将8个方向预先编码,减少分支预测失败的概率。对于超大规模网格(如 10^4 × 10^4),可进一步采用以下策略:
| 优化策略 | 核心思想 | 适用场景 |
|---|---|---|
| 环形边界(Torus) | 网格四边相连,消除边界判断 | 无限平面模拟 |
| 哈希集合稀疏存储 | 仅存储存活细胞坐标 | 低密度大网格 |
| SIMD 并行 | 利用 CPU 向量指令同时计算多个细胞 | 高性能实时渲染 |
| 多线程分块 | 将网格划分为区域并行计算下一代 | 多核 CPU 环境 |
七、复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(R × C) | R行C列,每代每个细胞只计算一次邻居数 |
| 空间复杂度 | O(R × C) | 双缓冲需要两份布尔网格 |
| 邻居计算 | O(1) | 固定8次访问,与网格规模无关 |
| 代际演进 | O(G × R × C) | G 为总代数 |
八、结语
康威生命游戏用最简单的局部规则,演绎了涌现(Emergence)的深刻思想:复杂的全局行为并不需要复杂的底层指令。本文实现的邻域扫描与双缓冲状态转移机制,不仅是细胞自动机的基础,更是图像处理、物理模拟、甚至某些加密算法中常用的技术范式。试着修改 B3/S23 规则为其他数字组合(如 B36/S23 的 HighLife),你会观察到完全不同的宇宙。