每日算法 — 使用java实现贪吃蛇:Dinic最小割逃生通道判定与Menger定理

贪吃蛇 AI 最难的部分不是”怎么吃到食物”,而是”吃到之后怎么不被自己困死”。绝大多数启发式(曼哈顿距离、贪心、甚至 A*)都会在蛇身变长后陷入死局:蛇头钻进一个封闭的小空间,把食物吃掉,然后无路可走。问题的本质不是”路有多长”,而是“通道有多宽”——一条只有 1 格宽的走廊,蛇身一旦挤进去,出口就被自己的身体堵死了。

这个”通道宽度”恰好有一个严格的数学刻画:最大流最小割定理 + Menger 定理。本文用 Dinic 最大流把”蛇头到目标之间有多少条互不相交的独立通道”算出来,并据此做出既能吃到食物、又不把自己困死的决策。

一、为什么是”独立通道数”,而不是”可达格数”

BFS 只能回答”可达/不可达”这种二值问题。对贪吃蛇来说,需要量化的关键指标是冗余度:

  • 如果蛇头到尾巴之间有 3 条互不相交的路径,那么走错一条不会致命;
  • 如果只有 1 条,那么这条路径上的任意一格就是咽喉(articulation),蛇身一旦占据它,尾巴就彻底断联。

Menger 定理给出了这个量与图割之间的等价关系:

最大流(顶点容量为 1 的网络)= 内部顶点不相交路径的最大条数 = 最小顶点割的大小。

也就是说,只要建一张”每格容量 1″的流网络,跑一次最大流,得到的数字就是独立逃生通道数;而最小割对应的那些格子,正是必须守住的咽喉格。这比”可达格数”信息量大得多。

二、建图:顶点容量 1 的标准技巧

棋盘 R × C,0 空 1 障碍。把每个空闲格拆成 in 与 out 两个节点:

边 容量 含义
in(u) → out(u) 1 每格只能被一条路径占用(顶点容量)
out(u) → in(v)(四邻接) ∞ 相邻格可通行,不做限制
S → out(head) ∞ 超级源接蛇头
in(target) → T ∞ 目标格接入超级汇

限制全部施加在”格子内部”的边上,因此每条流路径互相不许共享格子——这正是”顶点不相交”的定义。求一次 maxFlow(S,T) 即得通道数。

三、完整实现

import java.util.*;

/**
 * 贪吃蛇 AI:用 Dinic 最大流 / 最小割计算"独立逃生通道数"。
 * 棋盘 0 = 空地,1 = 障碍(蛇身 / 墙壁)。
 */
public class SnakeFlowAI {

    static final int[] DR = {-1, 1, 0, 0};
    static final int[] DC = {0, 0, -1, 1};
    static final int INF = 1_000_000;      // 代表"无穷"容量的边

    private final int rows, cols, cells, nodes;
    private final int source, sink;
    private final Dinic dinic;

    public SnakeFlowAI(int rows, int cols) {
        this.rows = rows;
        this.cols = cols;
        this.cells = rows * cols;
        this.nodes = 2 * cells + 2;
        this.source = 2 * cells;
        this.sink = 2 * cells + 1;
        this.dinic = new Dinic(nodes);
    }

    private int in(int r, int c)  { return r * cols + c; }
    private int out(int r, int c) { return cells + r * cols + c; }

    /**
     * 计算蛇头与目标格之间的最大互不相交通道数(= 最小顶点割大小)。
     *
     * @param grid  0 空地 / 1 障碍(不含蛇头所在格)
     * @param hr    蛇头行
     * @param hc    蛇头列
     * @param tr    目标行
     * @param tc    目标列
     * @return 独立通道条数;返回 0 表示完全不连通
     */
    public int escapeRoutes(int[][] grid, int hr, int hc, int tr, int tc) {
        if (hr == tr && hc == tc) return 4;            // 已经在目标格上
        dinic.reset();

        // 目标格虽然可能正被蛇身占据(例如尾巴),但它即将腾空,按可通行处理
        int saved = grid[tr][tc];
        grid[tr][tc] = 0;

        // 1. 顶点容量:每格最多被一条路径占用
        for (int r = 0; r < rows; r++)
            for (int c = 0; c < cols; c++)
                if (grid[r][c] == 0)
                    dinic.addEdge(in(r, c), out(r, c), 1);

        // 2. 邻接边容量无穷:格子之间不限制路径数量
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (grid[r][c] != 0) continue;
                for (int d = 0; d < 4; d++) {
                    int nr = r + DR[d], nc = c + DC[d];
                    if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
                    if (grid[nr][nc] != 0) continue;
                    dinic.addEdge(out(r, c), in(nr, nc), INF);
                }
            }
        }

        // 3. 源与汇
        dinic.addEdge(source, out(hr, hc), INF);
        dinic.addEdge(in(tr, tc), sink, INF);

        int flow = dinic.maxFlow(source, sink);
        grid[tr][tc] = saved;                          // 恢复棋盘
        return flow;
    }

    /**
     * 决策:在 4 个合法方向中挑一步,同时满足两个条件
     *   ① 走完之后蛇头到尾巴仍至少 2 条独立通道(不被自己堵死)
     *   ② 走完之后到食物的通道数 ≥ 1(食物仍可达)
     * 双条件都满足时,用"到食物的最短距离"做最终排序。
     */
    public int decide(int[][] grid, int hr, int hc, int tr, int tc,
                      int fr, int fc) {
        int bestDir = -1;
        int bestScore = Integer.MAX_VALUE;
        boolean foundSafe = false;

        for (int d = 0; d < 4; d++) {
            int nr = hr + DR[d], nc = hc + DC[d];
            if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
            if (grid[nr][nc] != 0) continue;           // 撞墙 / 撞蛇身

            grid[hr][hc] = 1;                          // 原头格变成身体
            int toTail = escapeRoutes(grid, nr, nc, tr, tc);
            int toFood = escapeRoutes(grid, nr, nc, fr, fc);
            int dist = bfsDist(grid, nr, nc, fr, fc);
            grid[hr][hc] = 0;                          // 回滚

            boolean safe = toTail >= 2 && toFood >= 1;
            int score = (toTail >= 2 ? 0 : 100) + (toFood >= 1 ? 0 : 50) + dist;

            if (safe && !foundSafe) {
                foundSafe = true; bestScore = score; bestDir = d;
            } else if (safe == foundSafe && score < bestScore) {
                bestScore = score; bestDir = d;
            }
        }
        return bestDir;                                // -1 表示四面皆险,需降级策略
    }

    /** 普通 BFS 最短距离,用于在同为安全候选时做优选 */
    private int bfsDist(int[][] grid, int sr, int sc, int tr, int tc) {
        if (grid[tr][tc] != 0) return INF;
        int[][] dist = new int[rows][cols];
        for (int[] row : dist) Arrays.fill(row, -1);
        Deque<int[]> q = new ArrayDeque<>();
        q.offer(new int[]{sr, sc}); dist[sr][sc] = 0;
        while (!q.isEmpty()) {
            int[] cur = q.poll();
            if (cur[0] == tr && cur[1] == tc) return dist[tr][tc];
            for (int d = 0; d < 4; d++) {
                int nr = cur[0] + DR[d], nc = cur[1] + DC[d];
                if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
                if (grid[nr][nc] != 0 || dist[nr][nc] != -1) continue;
                dist[nr][nc] = dist[cur[0]][cur[1]] + 1;
                q.offer(new int[]{nr, nc});
            }
        }
        return INF;
    }

    /** Dinic 最大流:BFS 分层 + DFS 阻塞流 + 当前弧优化 */
    static class Dinic {
        private final int n;
        private final List<int[]> edges = new ArrayList<>();  // {to, cap, revIndex}
        private final List<List<Integer>> g;

        Dinic(int n) {
            this.n = n;
            g = new ArrayList<>(n);
            for (int i = 0; i < n; i++) g.add(new ArrayList<>());
        }

        void reset() {
            edges.clear();
            for (List<Integer> l : g) l.clear();
        }

        void addEdge(int u, int v, int cap) {
            g.get(u).add(edges.size());
            edges.add(new int[]{v, cap, edges.size() + 1});
            g.get(v).add(edges.size());
            edges.add(new int[]{u, 0, edges.size() - 1});
        }

        private int[] level, iter;

        private boolean bfs(int s, int t) {
            level = new int[n];
            Arrays.fill(level, -1);
            Deque<Integer> q = new ArrayDeque<>();
            q.offer(s); level[s] = 0;
            while (!q.isEmpty()) {
                int u = q.poll();
                for (int id : g.get(u)) {
                    int[] e = edges.get(id);
                    if (e[1] > 0 && level[e[0]] < 0) {
                        level[e[0]] = level[u] + 1;
                        q.offer(e[0]);
                    }
                }
            }
            return level[t] >= 0;
        }

        private int dfs(int u, int t, int f) {
            if (u == t) return f;
            for (; iter[u] < g.get(u).size(); iter[u]++) {
                int id = g.get(u).get(iter[u]);
                int[] e = edges.get(id);
                if (e[1] > 0 && level[e[0]] == level[u] + 1) {
                    int d = dfs(e[0], t, Math.min(f, e[1]));
                    if (d > 0) {
                        e[1] -= d;
                        edges.get(e[2])[1] += d;     // 反向边增容
                        return d;
                    }
                }
            }
            return 0;
        }

        int maxFlow(int s, int t) {
            int flow = 0;
            while (bfs(s, t)) {
                iter = new int[n];
                int f;
                while ((f = dfs(s, t, INF)) > 0) flow += f;
            }
            return flow;
        }

        /** 残量网络上从源点是否可达(用于提取最小割的 from 侧) */
        boolean reachable(int v) {
            return level != null && v < level.length && level[v] >= 0;
        }
    }

    /** 从残量网络提取最小割:割边的 from 侧即"关键格"集合 */
    public List<int[]> criticalCells(int[][] grid, int hr, int hc, int tr, int tc) {
        escapeRoutes(grid, hr, hc, tr, tc);            // 先把最大流跑满
        // 此时 level 数组中"可达侧"标记了割的 from 侧
        List<int[]> res = new ArrayList<>();
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (grid[r][c] != 0) continue;
                // 可达侧截至 in 点、但 out 点未达 → 该格自身即割点
                if (dinic.reachable(in(r, c)) && !dinic.reachable(out(r, c)))
                    res.add(new int[]{r, c});
            }
        }
        return res;
    }

    public static void main(String[] args) {
        // 场景一:中间一道墙只留 1 格缺口——典型咽喉
        int[][] wall = new int[12][12];
        for (int c = 0; c < 12; c++) wall[6][c] = 1;
        wall[6][6] = 0;                                  // 唯一缺口
        SnakeFlowAI ai = new SnakeFlowAI(12, 12);
        System.out.println("跨墙独立通道数 = " + ai.escapeRoutes(wall, 5, 6, 7, 6)); // 期望 1

        // 场景二:开阔场上蛇身竖直排列,尾巴在下,食物在右侧
        int[][] grid = new int[12][12];
        for (int r = 6; r <= 9; r++) grid[r][6] = 1;     // 蛇身
        int dir = ai.decide(grid, 5, 6, 9, 6, 5, 9);     // 头(5,6) 尾(9,6) 食物(5,9)
        System.out.println("选择方向索引 = " + dir + " (0上 1下 2左 3右)");
    }
}

四、为什么”通道数 ≥ 2″是判死的分界线

考虑一条 T 形走廊:蛇头在交叉口,往左是一条 20 格的死胡同,往右是开阔地。BFS 可达格数两边相同(都能走 20+ 格),但左边一旦走进去,蛇身会把出入口堵死。

用最小割看,差别立刻显现:

  • 走左边后,”蛇头 → 尾巴”的最小割只剩 1(因为死胡同与外界只隔交叉口这一格),Menger 定理告诉我们此时只有 1 条独立通道——蛇身一填,就断联;
  • 走右边后,割值上升到 3 或 4(取决于蛇头度数),说明有多条互不相交的逃生路径。

所以 toTail >= 2 不是拍脑袋的经验值,它有严格的图论含义:至少存在 2 条内部不相交的路径,意味着即使一条被身体占据,还有另一条可用。这是”不被自己堵死”的充分条件。

五、”到食物的通道数”约束了什么

只保证”能逃”是不够的,AI 还要保证”能吃”。这里第二个流解决的是另一类陷阱:

食物被卡在一个入口只有 1 格宽的凹槽里。这时 toFood 的最小割是 1——虽然当前可达,但吃下食物的瞬间,蛇身会占据那唯一的入口,形成”吃到即死”的局面。这类局面下 BFS 会兴高采烈地说”可达”,而流算法会明确报出 1。

实践中的处理策略是:

toTail toFood 决策
≥ 2 ≥ 1 正常追食物
≥ 2 0 换个目标,等它变可达
1 任意 只走能立刻提高 toTail 的步(通常是朝开阔区反向撤退)
0 — 必死局,转为”最大化存活步数”的兜底策略

六、复杂度与工程优化

单次建图边数约为 5 RC(拆点边 + 四邻接 + 两条超级边),Dinic 在单位容量网络上近似 O(E√V)。对 20×20 棋盘,一次求解在毫秒级。但要注意:decide() 对每个方向(4 次)各求解两次流,因此每帧要做 8 次最大流 + 4 次 BFS。

实用的加速手段:

  • 快速否定路径:先用一次 BFS 判断连通性,若目标根本不可达(toFood = 0),直接跳过流计算;
  • 度数上界剪枝:蛇头度数 ≤ 4,所以 toTail ≤ 4。一旦流达到 2(决策阈值),可以在 dfs 中提前终止,无需跑满最大流——这能省掉一大半工作量;
  • 区域预分割:用并查集先找连通块,若整片区域是一个完整的连通块且无瓶颈候选,直接按”安全”处理;
  • 增量维护:蛇每步只改变 2 个格(头进、尾出),理论上可做动态流维护,但 Dinic 的残余图不适合直接增量,工程上通常改为缓存层级图 + 局部重跑。

七、最小割的另一重用法:标记危险格

criticalCells() 展示了最小割的第二个价值:直接给出咽喉位置。做法是最大流跑满后,在残量网络上从源点做一次可达性标记——in 侧可达而 out 侧不可达的格子,就是割点。

这些格子可以直接喂给评估函数,作为”高风险区”加权惩罚。长期来看,AI 会自发学会避开这些瓶颈通道,行为上表现为”绕远路但更安全”——这正是高水平贪吃蛇 AI 与贪心 AI 的典型差别。

小结

最大流最小割给贪吃蛇提供了一个全局连通冗余度的量化指标,补上了启发式搜索最缺的一环:判断这一步之后自己还能不能活。三个关键设计是:顶点容量 1 的拆点建图(保证路径互不相交)、用 Menger 定理把流值翻译成”独立逃生通道数”、以及双目标(尾巴 + 食物)双流判定。把它作为过滤器放在贪心或 A* 之前,即可得到一个可控、可解释、且不会自我困死的蛇 AI。