从算法到人工智能 · 第 3 课:生命游戏——四条规则"活"出来的世界
上一课我们学了数组、链表、栈、队列,还动手写过一维数组。这一课,我们把数组铺成二维,玩一个震撼的小东西——康威生命游戏(Conway's Game of Life)。
先看一个神奇的事实:下面这个"世界"里,没有任何一只生物是被我们手动画出来的。我们只写下了四条简单的规则,然后让时间一秒一秒往前走,整个世界的生死、移动、繁殖,全都自己"长"了出来。
这正是这一课的主角——元胞自动机(cellular automaton),也是"简单规则涌现复杂行为"最经典的案例。
一、它是什么:一张会自我演化的格子纸
想象一张无限大的方格纸,每个格子里要么有生命(涂黑),要么没有(留白)。
每一"代"(generation),所有格子同时按照同一条规则更新一次:
一个格子的下一代会怎样,只看它当前这代的状态,以及它周围 8 个邻居里有几个是活的。
这就是"元胞自动机":格子是"元胞",规则是"自动",大家一起"机械"地演化。
二、四条规则:生、死、繁殖
设一个格子周围 8 个邻居里,活着的数量为 n:
| 当前状态 | 邻居活数 n | 下一代 | 解释 |
|---|---|---|---|
| 活 | n < 2 | 死 | 太孤独,饿死 |
| 活 | n = 2 或 3 | 活 | 不多不少,活下去 |
| 活 | n > 3 | 死 | 太拥挤,挤死 |
| 死 | n = 3 | 活 | 正好三个邻居,繁殖 |
就这么四条,没有第五条。可就是这四条,能演化出在屏幕上游走的"滑翔机"、原地脉动的"振荡器",甚至有人用它造出了能计算的通用计算机。
三、用二维数组表示世界
上一课我们说过,数组是"按下标读"最快的数据结构。一张格子纸,天然就是一个二维数组:
# grid[row][col],1 表示活,0 表示死
grid = [
[0, 0, 0, 0, 0],
[0, 0, 1, 0, 0],
[0, 0, 1, 0, 0],
[0, 0, 1, 0, 0],
[0, 0, 0, 0, 0],
]grid[1][2] 就是第 1 行第 2 列的格子,值为 1,代表它是活的。
先定两个基本参数:
ROWS = 20 # 行数
COLS = 40 # 列数四、数邻居:生命游戏的核心一步
规则说"看周围 8 个格子"。怎么写?给一个格子 (r, c),它的 8 个邻居是:
(r-1, c-1) (r-1, c) (r-1, c+1)
(r, c-1) (r,c) (r, c+1)
(r+1, c-1) (r+1, c) (r+1, c+1)用两层循环把这 8 个位置扫一遍:
def count_neighbors(grid, r, c):
n = 0
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
if dr == 0 and dc == 0:
continue # 跳过自己
rr, cc = r + dr, c + dc
if 0 <= rr < ROWS and 0 <= cc < COLS: # 别越界
n += grid[rr][cc]
return n注意三件事:
dr == 0 and dc == 0是格子自己,要跳过。0 <= rr < ROWS and 0 <= cc < COLS是边界判断——边缘格子邻居不足 8 个,我们把"墙外"当作死,这样最省事。- 返回值
n就是"活邻居数"。
五、边界:把墙外当作死
上面的写法,格子到了边缘,墙外那部分邻居直接不算。这等价于"墙外全是死的"。
这是最简单也最常用的边界处理。另一种做法是"首尾相连"(右上角格子的右边是左下角),像地球仪一样绕回来,叫环形世界(torus)。我们这一课用第一种,够用且直观。
六、关键细节:为什么不能边算边改
现在要生成下一代了。新手最容易犯的错,是一边遍历一边修改同一个 grid。
比如你刚把某个格子从死改成活,紧接着它又被当成"上一代"的活格子去影响别的格子——结果就是串味,规则被污染,画面错乱。
正确做法:读旧表、写新表,算完一整代再一次性替换。
def next_generation(grid):
new_grid = [[0] * COLS for _ in range(ROWS)] # 全新空表
for r in range(ROWS):
for c in range(COLS):
n = count_neighbors(grid, r, c) # 只看旧表 grid
if grid[r][c] == 1: # 当前是活的
if n == 2 or n == 3:
new_grid[r][c] = 1 # 活下去
else:
new_grid[r][c] = 0 # 饿死/挤死
else: # 当前是死的
if n == 3:
new_grid[r][c] = 1 # 繁殖
return new_grid这里 new_grid 是全新的一张表,所有判断都基于旧的 grid,绝不串味。这是生命游戏(以及大量"整批更新"问题)的标准姿势。
七、让世界跑起来
有了 next_generation,剩下的就是循环:打印 → 计算下一代 → 打印。
import time
def print_grid(grid):
for row in grid:
# 活格子打印 ■,死格子打印空格,看起来舒服
print("".join("■" if cell else " " for cell in row))
print("-" * COLS)
def run(grid, generations=50, delay=0.2):
for _ in range(generations):
print_grid(grid)
grid = next_generation(grid)
time.sleep(delay)
# 造一个"滑翔机"作为初始状态
grid = [[0] * COLS for _ in range(ROWS)]
glider = [
(0, 1),
(1, 2),
(2, 0), (2, 1), (2, 2),
]
for dr, dc in glider:
grid[5 + dr][5 + dc] = 1
run(grid, generations=30, delay=0.3)跑起来,你会看到一个"小船"一样的东西,斜着朝右下角一路滑过去——这就是滑翔机(glider),生命游戏里最著名的图案。它每 4 代平移一格,是无数复杂构造的基础元件。
八、三个经典实验,动手做
实验 1:滑翔机
上面那段代码,把 generations 调大,看它一路滑到边界。体会:一个能"动"的东西,没有任何代码在"移动"它——是四条规则让生命自己"走"起来的。
实验 2:振荡器(blinker)
把初始图案换成一根竖线三格,它会每两代"横—竖—横—竖"来回闪:
grid = [[0] * COLS for _ in range(ROWS)]
for dr in (9, 10, 11):
grid[dr][20] = 1 # 一竖排三个活格子
run(grid, generations=10, delay=0.3)实验 3:随机世界
把初始状态随机铺满,看它如何从一片噪声,慢慢"凝固"成稳定的街区、振荡器和还在滑翔的滑翔机:
import random
random.seed(42)
grid = [[random.randint(0, 1) for _ in range(COLS)] for _ in range(ROWS)]
run(grid, generations=60, delay=0.1)你会看到:混乱 → 稳定结构 的过程。那些稳定下来的方块、振荡器,和仍在移动的滑翔机,全都不是我们设计的,是规则自己筛选出来的。
九、复杂度分析:它快吗
设网格有 R × C 个格子。
- 空间:我们同时存新旧两张表,是 O(R × C)。要省空间的话可以只存一张表 + 一份"变更清单",但初学不必纠结。
- 时间:每一代,每个格子都要数一遍 8 个邻居,一共 O(8 × R × C),常数 8 抹掉,就是 O(R × C)。每一代都是这个量级,跑 100 代就是 100 × O(R × C)。
换句话说,生命游戏是一个每个格子都同步、且只依赖局部邻居的典型问题——正因为每个格子的计算互相独立,它特别适合"并行"。这也是元胞自动机被用来研究复杂系统、甚至做并行计算教学的原因。
十、小结
- 元胞自动机 = 格子 + 局部规则 + 同步更新,生命游戏只是它最出名的一个例子,四条规则就能涌现出"移动、繁殖、稳定结构"。
- 二维数组是网格的天然表达——
grid[r][c]按下标直接定位,数邻居就是双层循环扫周围 8 格。 - 整批更新一定要"读旧表、写新表",边算边改会让规则串味、结果错乱;这也是所有"同步演化"问题的通用铁律。
先别急着往后翻,把滑翔机、振荡器、随机世界三个实验都跑一遍,亲眼看看"四条规则"怎么让死板的格子自己活过来——这就是"简单规则 → 复杂涌现"给你上的第一课。