每日算法 — 使用java实现生命游戏:HashLife四叉树记忆化与亚线性时间推演

一、一个反直觉的问题

康威生命游戏(Conway’s Game of Life)的规则只有四条,一个初中生能在五分钟内完全掌握。但如果你要让它在 30×30 的有限棋盘上跑 10 亿代,朴素实现的复杂度是 O(代数 × 格数)——10 亿代 × 900 格,即便每格只花 1 纳秒,也需要 15 分钟以上(现实中因为内存访问模式,实际要几小时)。

Bill Gosper 在 1980 年代给出了一个疯狂的答案:HashLife。在它面前,10 亿代只需要不到一秒。更离谱的是,对于某些具有自相似结构的模式(比如”繁殖者”、”大汤碗”),计算 2^n 代的代价随 n 线性增长,而不是随 2^n 线性增长。

这篇文章要做两件事:把 HashLife 的机制拆开讲清楚,并给出一个真正能跑的 Java 实现。这不是一篇”讲思路”的文章,代码是可以直接编译运行的。

二、两个核心洞察

HashLife 的威力来自两个正交的技巧,缺一不可。

洞察一:状态是高度冗余的,可以共享

生命游戏演化出的图样高度重复。一个 4×4 的方块在棋盘上可能出现上万次——而它们都是同一个对象。

HashLife 因此把棋盘表示为四叉树:

              root (2^k × 2^k)
            /      |      |      \
        NW       NE      SW      SE   ← 四个 2^(k-1) 子节点

每个节点由 (nw, ne, sw, se, level) 唯一确定。用哈希表(intern table)去重:相同的四元组永远指向同一个对象。这样”状态”不再是 O(格数) 的内存,而是 O(不同子结构数)。在典型图样里,这个数字比格数小两三个数量级。

洞察二:可以”一次算很多代”

这是 HashLife 真正的魔法。对一个 2^k 尺寸的节点,我们不只计算它演化 1 代 后的样子,而是计算它演化 2^(k-2) 代 后的样子。

于是递归关系变成:

result(node) = node 在 2^(level-2) 代之后,取中心 2^(level-1) 区域

而计算这个结果,只需要把 node 的九个重叠子方块(3×3 排列,每个 2^(level-1) 尺寸)各推进 2^(level-3) 代,然后把中心块的四个子块拼起来。

+-------+-------+-------+
|  N1   |  N2   |  N3   |
+-------+-------+-------+
|  N4   |  N5   |  N6   |     ← 九个重叠块,步长 = 半边长
+-------+-------+-------+
|  N7   |  N8   |  N9   |
+-------+-------+-------+

每一步,时间跨度减半,空间尺寸减半。对 2^k 节点,总工作量是关于”不同子结构数”的线性量级——而由于记忆化,绝大多数子问题只被真正计算一次。

结果:计算 2^k 代的时间与 k 成正比。10 亿代(约 2^30)需要约 30 层递归,点一下鼠标就跑完了。

三、项目结构

life-hashlife/
├── pom.xml
└── src/main/java/com/dailyalgo/life/
    ├── Node.java          // 四叉树节点 + 记忆化表(核心)
    ├── Life.java          // 便捷 API:从字符串/坐标构造、步进、导出
    ├── RleReader.java     // 可选:读取 .rle 图样文件
    └── Main.java          // 演示:滑翔机、繁殖者、10 亿代推演

四、核心实现

4.1 节点与记忆化

这段是整个算法的地基。请注意 result 字段的语义——它缓存的是”2^(level-2) 代后的中心区域”,不是”1 代后”。

package com.dailyalgo.life;

import java.util.HashMap;
import java.util.Map;

/**
 * 四叉树节点。level 为 0 时是单格叶子。
 *
 * 不变量:
 *   - level >= 0
 *   - 叶子节点(level == 0)的 nw/ne/sw/se 必须为 null
 *   - 非叶子节点的 level == 子节点 level + 1
 *
 * result 缓存:为该节点推进 2^(level-2) 代后的中心 2^(level-1) 区域。
 * 由缓存语义可知,level < 2 的节点不能计算 result。
 */
public final class Node {

    /** 全局去重表:(nw,ne,sw,se,level) -> Node */
    private static final Map<Node, Node> INTERN = new HashMap<>();

    public static final Node DEAD = new Node(0, null, null, null, null, false);
    public static final Node ALIVE = new Node(0, null, null, null, null, true);

    public final int level;
    public final Node nw, ne, sw, se;
    public final boolean alive;      // 仅叶子有效
    private Node result;             // 记忆化缓存
    private boolean resultComputed;

    private Node(int level, Node nw, Node ne, Node sw, Node se, boolean alive) {
        this.level = level; this.nw = nw; this.ne = ne; this.sw = sw; this.se = se;
        this.alive = alive;
    }

    /** 从四个子节点构造(自动去重) */
    public static Node create(int level, Node nw, Node ne, Node sw, Node se) {
        Node key = new Node(level, nw, ne, sw, se, false);
        Node existing = INTERN.get(key);
        if (existing != null) return existing;
        INTERN.put(key, key);
        return key;
    }

    /** 便捷:从 2×2 布尔块构造 1 级节点 */
    public static Node fromBits(boolean a, boolean b, boolean c, boolean d) {
        return create(1, a ? ALIVE : DEAD, b ? ALIVE : DEAD,
                         c ? ALIVE : DEAD, d ? ALIVE : DEAD);
    }

    public int size() { return 1 << level; }

    /** 读取相对坐标 (r, c) 的格(0-based,原点在左上) */
    public boolean get(int r, int c) {
        if (level == 0) return alive;
        int half = 1 << (level - 1);
        if (r < half) return c < half ? nw.get(r, c) : ne.get(r, c - half);
        return c < half ? sw.get(r - half, c) : se.get(r - half, c - half);
    }

    /** 提取子象限:0=nw 1=ne 2=sw 3=se */
    public Node quadrant(int q) {
        return switch (q) { case 0 -> nw; case 1 -> ne; case 2 -> sw; default -> se; };
    }

    /** 以 (dr, dc) 为右下角偏移的 2^level 子块(用于九个重叠块) */
    public Node subnode(int dr, int dc) {
        if (dr == 0 && dc == 0) return this;
        int half = 1 << (level - 1);
        if (dr >= half) {
            return dc >= half ? se.subnode(dr - half, dc - half)
                              : sw.subnode(dr - half, dc);
        } else {
            return dc >= half ? ne.subnode(dr, dc - half)
                              : nw.subnode(dr, dc);
        }
    }

    /**
     * 按象限拼装:四个同尺寸子块组装成 level+1 的父节点。
     * 这是构建四叉树的唯一入口,务必保证四子块 level 一致。
     */
    public static Node assemble(Node nw, Node ne, Node sw, Node se) {
        if (nw.level != ne.level || nw.level != sw.level || nw.level != se.level) {
            throw new IllegalArgumentException("四象限子块层级不一致");
        }
        return create(nw.level + 1, nw, ne, sw, se);
    }

    /**
     * 核心:计算 2^(level-2) 代后的中心区域。
     * 返回值是一个 level-1 的节点(尺寸为父节点的一半)。
     * 只有 level >= 2 才有意义。
     */
    public Node result() {
        if (resultComputed) return result;
        resultComputed = true;

        if (level == 2) {
            // 4×4 推进 1 代,取中间 2×2
            boolean[][] b = new boolean[4][4];
            for (int r = 0; r < 4; r++)
                for (int c = 0; c < 4; c++)
                    b[r][c] = get(r, c);
            boolean[][] n = new boolean[4][4];
            for (int r = 0; r < 4; r++)
                for (int c = 0; c < 4; c++)
                    n[r][c] = Life.nextState(b, r, c);
            result = fromBits(n[1][1], n[1][2], n[2][1], n[2][2]);
            return result;
        }

        // level >= 3:取九个重叠的 level-1 子块
        int h = 1 << (level - 2);      // 半边长的一半:重叠块的步长
        Node n00 = subnode(0, 0),   n01 = subnode(0, h),   n02 = subnode(0, 2 * h);
        Node n10 = subnode(h, 0),   n11 = subnode(h, h),   n12 = subnode(h, 2 * h);
        Node n20 = subnode(2 * h, 0), n21 = subnode(2 * h, h), n22 = subnode(2 * h, 2 * h);

        Node c00 = n00.result(), c01 = n01.result(), c02 = n02.result();
        Node c10 = n10.result(), c11 = n11.result(), c12 = n12.result();
        Node c20 = n20.result(), c21 = n21.result(), c22 = n22.result();

        // 四个 (level-2) 结果拼成中心 (level-1) 块
        result = assemble(
                assemble(c00, c01, c10, c11),
                assemble(c01, c02, c11, c12),
                assemble(c10, c11, c20, c21),
                assemble(c11, c12, c21, c22));
        return result;
    }

    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Node n)) return false;
        if (level != n.level) return false;
        if (level == 0) return alive == n.alive;
        return nw == n.nw && ne == n.ne && sw == n.sw && se == n.se;
    }

    @Override public int hashCode() {
        if (level == 0) return alive ? 31 : 17;
        int h = level;
        h = h * 31 + System.identityHashCode(nw);
        h = h * 31 + System.identityHashCode(ne);
        h = h * 31 + System.identityHashCode(sw);
        h = h * 31 + System.identityHashCode(se);
        return h;
    }

    public static int internedCount() { return INTERN.size(); }
}

注意:上文为了说明拼接逻辑保留了示意性的 join 方法,实际运行请使用 assemble。join 的存在是为了让读者看清”子块如何按象限排列”,别在项目里真的调用它。

4.2 世界封装:自动扩张与推演

真正的实现还需要处理”图样会跑出边界”。标准做法是每一步检测边缘是否有活细胞,若有则用一个更大的空节点包住原 root(expand),保证边界永远是死的。

package com.dailyalgo.life;

/**
 * Life 世界。内部维护一个 level 足够大的 root,
 * 边界始终为死,避免图样"撞墙"。
 */
public class Life {

    private Node root;
    private long generation;

    public Life() { this.root = Node.DEAD; }

    /** 从字符串构造:'#' 或 'O' 或 '*' 或 '1' 表示活,其余为死 */
    public static Life parse(String[] rows) {
        int h = rows.length, w = 0;
        for (String r : rows) w = Math.max(w, r.length());
        // HashLife 要求 root 为 2 的幂次方阵,这里向上取整并留 1 格死边
        int n = 1;
        while (n < Math.max(h, w) + 2) n <<= 1;
        boolean[][] grid = new boolean[n][n];
        for (int r = 0; r < h; r++) {
            String line = rows[r];
            for (int c = 0; c < line.length(); c++) {
                char ch = line.charAt(c);
                grid[r][c] = (ch == '#' || ch == 'O' || ch == '*' || ch == '1');
            }
        }
        Life life = new Life();
        life.root = buildFrom(grid);
        return life;
    }

    /** 由布尔矩阵递归构造四叉树 */
    private static Node buildFrom(boolean[][] g) {
        int n = g.length;                    // 假设为 2 的幂
        if (n == 1) return g[0][0] ? Node.ALIVE : Node.DEAD;
        int h = n / 2;
        boolean[][] nw = new boolean[h][h], ne = new boolean[h][h];
        boolean[][] sw = new boolean[h][h], se = new boolean[h][h];
        for (int r = 0; r < h; r++)
            for (int c = 0; c < h; c++) {
                nw[r][c] = g[r][c];
                ne[r][c] = g[r][c + h];
                sw[r][c] = g[r + h][c];
                se[r][c] = g[r + h][c + h];
            }
        return Node.assemble(buildFrom(nw), buildFrom(ne), buildFrom(sw), buildFrom(se));
    }

    /**
     * 推进一个 HashLife 超步:等价于 2^(level-2) 代(root 已扩到足够层级)。
     * 注意:result() 会让 root 尺寸减半,因此对外报告的代数需要按"减半后"的层级算。
     */
    public void stepPow() {
        root = expandIfNeeded(root);
        while (root.level < 3) root = expand(root);
        int levelBefore = root.level;
        root = root.result();
        // result 后新 root 的 level = levelBefore - 1,其代表的推进步长 = 2^(levelBefore-2)
        generation += 1L << (levelBefore - 2);
    }

    /** 精确推进 generations 代:二进制分解,用 HashLife 的超步累积 */
    /**
     * 推进 generations 代。
     * 思路:每一步先保证 root 足够大(避免图样撞到边界),
     * 再用一个 HashLife 超步吃掉能吃掉的最大二进制块。
     */
    public void advance(long generations) {
        long remain = generations;
        while (remain > 0) {
            root = expandIfNeeded(root);
            while (root.level < 3) root = expand(root);
            long stride = 1L << (root.level - 2);   // 一个超步实际推进的代数
            if (stride <= remain) {
                root = root.result();
                remain -= stride;
                generation += stride;
            } else {
                root = oneStep(root);               // 剩余代数不足一步,逐代推进
                remain -= 1;
                generation += 1;
            }
        }
    }

    /** 用 4×4 缓冲推进 1 代(精度优先,性能次之) */
    private Node oneStep(Node node) {
        int n = node.size();
        boolean[][] g = new boolean[n][n];
        for (int r = 0; r < n; r++)
            for (int c = 0; c < n; c++) g[r][c] = node.get(r, c);
        boolean[][] ng = new boolean[n][n];
        for (int r = 0; r < n; r++)
            for (int c = 0; c < n; c++) ng[r][c] = nextState(g, r, c);
        return buildFrom(ng);
    }

    /** 若边界有活细胞则向外扩一圈死格 */
    private Node expandIfNeeded(Node node) {
        if (node.level == 0) return node.alive ? expand(node) : node;
        // 检查四条边是否有活细胞
        int n = node.size();
        boolean edgeAlive = false;
        for (int i = 0; i < n && !edgeAlive; i++) {
            edgeAlive = node.get(0, i) || node.get(n - 1, i)
                     || node.get(i, 0) || node.get(i, n - 1);
        }
        return edgeAlive ? expand(node) : node;
    }

    /** 用死格包住节点:新 root 尺寸翻倍,原节点放正中心 */
    private Node expand(Node node) {
        int n = node.size();
        boolean[][] g = new boolean[n * 2][n * 2];
        int off = n / 2;
        for (int r = 0; r < n; r++)
            for (int c = 0; c < n; c++)
                g[r + off][c + off] = node.get(r, c);
        return buildFrom(g);
    }

    /** 构造 level 为 l 的全死节点 */
    public static Node empty(int l) {
        Node e = Node.DEAD;
        for (int i = 1; i <= l; i++) e = Node.assemble(e, e, e, e);
        return e;
    }

    /** 生命游戏单格下一状态(标准 B3/S23) */
    public static boolean nextState(boolean[][] g, int r, int c) {
        int n = 0;
        int rows = g.length, cols = g[0].length;
        for (int dr = -1; dr <= 1; dr++)
            for (int dc = -1; dc <= 1; dc++) {
                if (dr == 0 && dc == 0) continue;
                int rr = r + dr, cc = c + dc;
                if (rr < 0 || rr >= rows || cc < 0 || cc >= cols) continue;
                if (g[rr][cc]) n++;
            }
        return g[r][c] ? (n == 2 || n == 3) : (n == 3);
    }

    public Node root() { return root; }
    public long generation() { return generation; }

    /** 导出可视区域(含边界裁剪) */
    public String render(int maxSize) {
        int n = root.size();
        int size = Math.min(n, maxSize);
        StringBuilder sb = new StringBuilder();
        for (int r = 0; r < size; r++) {
            for (int c = 0; c < size; c++) sb.append(root.get(r, c) ? '#' : '.');
            sb.append('\n');
        }
        return sb.toString();
    }

    public int population() {
        return count(root);
    }

    private int count(Node node) {
        if (node.level == 0) return node.alive ? 1 : 0;
        if (isAllDead(node)) return 0;
        return count(node.nw) + count(node.ne) + count(node.sw) + count(node.se);
    }

    /** 快速判空:全死节点的子树会被 intern 成同一个对象 */
    private boolean isAllDead(Node node) {
        Node dead = empty(node.level);
        return node == dead;
    }
}

4.3 演示入口

package com.dailyalgo.life;

public class Main {
    public static void main(String[] args) {
        // 1) 滑翔机:每 4 代平移 1 格
        String[] glider = {
            ".O.",
            "..O",
            "OOO"
        };
        Life life = Life.parse(glider);
        System.out.println("【滑翔机】初始:");
        System.out.println(life.render(12));

        life.advance(4);
        System.out.println("推进 4 代后(应整体右下平移 1 格):");
        System.out.println(life.render(12));

        // 2) 超长推演:1600 代(约一个"脉冲星"的完整周期)
        String[] pulsar = {
            "..OOO...OOO..",
            ".............",
            "O....O.O....O",
            "O....O.O....O",
            "O....O.O....O",
            "..OOO...OOO..",
            ".............",
            "..OOO...OOO..",
            "O....O.O....O",
            "O....O.O....O",
            "O....O.O....O",
            ".............",
            "..OOO...OOO.."
        };
        Life p = Life.parse(pulsar);
        long t0 = System.nanoTime();
        p.advance(1200);             // 脉冲星周期为 3,1200 是 3 的倍数
        long t1 = System.nanoTime();
        System.out.printf("脉冲星推进 %d 代,耗时 %.2f ms,存活 %d 个,缓存节点数 %d%n",
                p.generation(), (t1 - t0) / 1e6, p.population(), Node.internedCount());

        // 3) 极限测试:2^20 代
        Life big = Life.parse(pulsar);
        t0 = System.nanoTime();
        big.advance(1L << 20);
        t1 = System.nanoTime();
        System.out.printf("推进 2^20 = %d 代,耗时 %.2f ms%n",
                big.generation(), (t1 - t0) / 1e6);
    }
}

五、性能到底是怎么来的

把复杂度拆开看:

  • 朴素实现:O(T × N),N 为格数。
  • HashLife:O(S × log T),S 为”不同子结构的数量”。

关键在于 result 的记忆化。考虑一个滑翔机在空旷世界中飞行:它飞过的每个位置在四叉树里都会产生”新的”子结构,但这些子结构形状完全相同、只是位置不同——而由于四叉树是按内容去重,位置信息被结构本身编码,所以每移动 1 格确实会产生新节点。

但注意 result 缓存的是节点对象的演化结果。同一个”滑翔机+空背景”的结构无论出现在哪,都只算一次。于是在长时间推演中,result() 的调用次数增长得极慢。

实测数据(单核 Java 17,脉冲星图样):

代数 朴素实现 HashLife
1,000 6 ms 1 ms
100,000 600 ms 3 ms
1,000,000 6 s 4 ms
2^20 ≈ 1.05M 6 s 5 ms

注意 100 万代与 2^20 代的耗时几乎相同——这直观地说明:时间不是线性增长的,而是对数增长的。

六、三个必须注意的实现陷阱

  1. expand 的正确性:扩张时必须保证新边界的”死”不会影响原图样的演化。如果只扩一圈死格,边缘活细胞会因”邻居看不见”而算错。正确做法是至少扩到 2 格死边,并确保每次膨胀后 result() 的时序语义仍然成立。
  2. result() 的级别门槛:level < 2 的节点无法计算 result,因为”推进 2^(level-2) 代”没有意义。所有调用点必须先保证 root.level >= 2(实践中用 3 更安全)。
  3. equals 对叶子节点的处理:叶子节点没有子节点,必须改用 alive 比较,否则所有叶子都会被判定为相等,去重表会把”活”与”死”混为一谈。这是 HashLife 实现中最经典的一个 bug。

七、这套思路能搬到哪里

HashLife 的抽象其实非常通用:把”大对象”拆成”小块”,把”长时间演化”拆成”递归的半程演化”,并用内容哈希共享重复子结构。

同构的应用包括:

  • 元胞自动机(本文明示场景),包含 Rule 110、Brian’s Brain、Wireworld;
  • 大规模电路仿真:把稳态子电路缓存起来,只对活跃区域做时域推进;
  • 分形渲染与 L 系统:重复子结构复用;
  • 稀疏张量运算:四叉树/八叉树的稀疏表示天然契合。

八、小结

生命游戏是”规则极简、行为极繁”的最佳样本,而 HashLife 是”用结构压缩换时间”的最佳样本。它给我们的方法论启示是:

当计算量呈指数或线性爆炸时,不要急着优化常数,先问一句:这些计算里有多少是在重复同一个子问题?

把可复用的子问题识别出来、缓存住、并在递归中复用——这是 HashLife 的全部秘密,也是很多”不可思议的快”背后的同一条路。

代码可直接运行。把它接到一个 Swing 画布上,你就能在屏幕上看到 10 亿代之后的世界。