上一课我们学了数组、链表、栈、队列,还动手写过一维数组。这一课,我们把数组铺成二维,玩一个震撼的小东西——康威生命游戏(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

注意三件事:

  1. dr == 0 and dc == 0 是格子自己,要跳过。
  2. 0 <= rr < ROWS and 0 <= cc < COLS 是边界判断——边缘格子邻居不足 8 个,我们把"墙外"当作死,这样最省事。
  3. 返回值 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)。

换句话说,生命游戏是一个每个格子都同步、且只依赖局部邻居的典型问题——正因为每个格子的计算互相独立,它特别适合"并行"。这也是元胞自动机被用来研究复杂系统、甚至做并行计算教学的原因。


十、小结

  1. 元胞自动机 = 格子 + 局部规则 + 同步更新,生命游戏只是它最出名的一个例子,四条规则就能涌现出"移动、繁殖、稳定结构"。
  2. 二维数组是网格的天然表达——grid[r][c] 按下标直接定位,数邻居就是双层循环扫周围 8 格。
  3. 整批更新一定要"读旧表、写新表",边算边改会让规则串味、结果错乱;这也是所有"同步演化"问题的通用铁律。

先别急着往后翻,把滑翔机、振荡器、随机世界三个实验都跑一遍,亲眼看看"四条规则"怎么让死板的格子自己活过来——这就是"简单规则 → 复杂涌现"给你上的第一课。

标签: none

添加新评论