每日算法 — 使用java实现数桥:Tarjan桥边检测与欧拉平面性判定

数桥(Hashi / Hashiwokakero)是日本人氣最高的逻辑谜题之一:棋盘上散布着带数字的”岛”,你要在岛与岛之间架桥,使得每座岛的桥数恰好等于它的数字。规则简单,但求解起来比看起来难得多——因为桥不能交叉,这给问题加上了平面性(planarity)约束。

大多数数桥求解器只写一个”度数约束 + 回溯”,遇到 20×20 的大盘就爆炸。本文换个思路:先用图论做可解性预判(欧拉公式排除不可能的局面),再用 Tarjan 割边算法找出”必须连接”的强制桥,最后才进入带回溯的搜索。这三层剪枝能把搜索树缩小一到两个数量级。

一、规则形式化

数桥的标准规则可以精确表述为:

  1. 岛上数字 1..8,表示与该岛相连的桥总数(一条双桥算 2)。
  2. 桥只能水平或垂直,且只能连接同行或同列的两个岛,中间不能跨过其他岛。
  3. 同一对岛之间最多 2 条桥(0、1、2)。
  4. 桥不能交叉(这是唯一让问题变难的一条)。
  5. 所有岛最终必须构成一个连通结构。

把”岛”作为顶点、”桥”作为边,则规则 1 是度数约束,规则 5 是连通性约束,规则 4 是平面嵌入限制。三条约束分别对应三类完全不同的算法,这也是本文的组织方式。

二、第一层:候选边枚举与度数约束

先做一件平凡的预处理,后面所有算法都依赖它:对每个岛,向四个方向扫描,找到最近的同轴岛屿,形成候选边。若中间夹着别的岛,则更远的岛不可连——注意这个”最近”关系对双桥同样成立,因为第二条桥也在同一位置。

package com.example.hashi;

/** 一座岛 */
public class Island {
    public final int id;
    public final int row, col;
    public final int need;      // 数字 1..8
    int remain;                 // 还需要的桥数(-1 表示尚未初始化)
    int chosen;                 // 已确定的桥数 0..2

    public Island(int id, int row, int col, int need) {
        this.id = id; this.row = row; this.col = col;
        this.need = need; this.remain = need; this.chosen = 0;
    }
}

/** 一条候选边:两岛同轴相邻,最多可放 2 条桥 */
public class Bridge {
    public final int a, b;
    public final boolean horizontal;
    final int maxCount = 2;
    int placed;                 // 已放置的桥数 0..2

    public Bridge(int a, int b, boolean horizontal) {
        this.a = a; this.b = b; this.horizontal = horizontal;
    }
    public int other(int x) { return x == a ? b : a; }
    public boolean saturated() { return placed >= 2; }
}

候选边的枚举是 O(V * 4):

public static List<Bridge> enumerateCandidates(Island[] grid, int rows, int cols) {
    List<Bridge> edges = new ArrayList<>();
    Map<Integer, Island> byCell = new HashMap<>();
    for (Island is : grid) if (is != null) byCell.put(is.row * cols + is.col, is);

    for (Island is : grid) {
        if (is == null) continue;
        // 向右
        for (int c = is.col + 1; c < cols; c++) {
            Island other = byCell.get(is.row * cols + c);
            if (other != null) { edges.add(new Bridge(is.id, other.id, true)); break; }
        }
        // 向下(只朝两个方向扫,避免重复)
        for (int r = is.row + 1; r < rows; r++) {
            Island other = byCell.get(r * cols + is.col);
            if (other != null) { edges.add(new Bridge(is.id, other.id, false)); break; }
        }
    }
    return edges;
}

度数约束的第一道剪枝

任何一个岛,如果它的候选桥容量上界(每个候选边最多 2 条)小于它的数字,局面立刻不可解:

for (Island is : islands) {
    int cap = 0;
    for (Bridge e : incident(is.id)) cap += 2;
    if (cap < is.need) return Result.UNSAT("岛 #" + is.id + " 候选容量不足");
}

反向也一样:如果某岛的候选边只有两条而数字是 8,则这两条边被迫各放 2 条——这就是强制桥的来源。

三、第二层:欧拉公式与平面性上界

桥不能交叉。把已确定的桥视为平面图中的边,则整张图必须是平面图。平面图有一个极其锐利的必要条件——欧拉公式:

连通平面图满足 V - E + F = 2,且每个面至少由 3 条边围成,从而 E <= 3V - 6。

对二分结构的图(两个岛之间的桥构成二部图),这个界收紧为 E <= 2V - 4。数桥图恰好是二部图吗?不是——桥可以连接任意两岛,颜色并不二分;但”桥只沿行/列方向且不交叉”的结构确实带来额外限制。工程上我们用两个可直接判定的界:

  1. 总边数上界:sum(need_i) / 2 <= 3V - 6(若把所有桥数计为边数)。
  2. 单岛上限:need <= 8,且同行同列候选数决定上限(已由容量检查覆盖)。
public static boolean passesPlanarityBound(int sumNeed, int vertexCount) {
    int edgeCount = sumNeed / 2;              // 每条桥连接两岛,贡献 2 个度数
    return edgeCount <= 3 * vertexCount - 6;  // 欧拉公式推论,必要条件而非充分条件
}

这个界最有用的时候是做题前快速否决:某些”看起来能填满”的盘面,其度数总和已经超过了平面图容量,必然无解。这一层判断只需 O(V),比跑搜索便宜无数倍。

另一个几何层面的死锁检测

平面性还有一个更细的推论:若两条候选边在几何上交叉(一条水平、一条垂直,且交点落在两段之内),那么它们不能同时各放 1 条以上桥(否则必然交叉)。这可以预先构造成冲突对列表:

/** 两条候选边是否几何相交(水平桥 vs 垂直桥且交点落在两者段内) */
public static boolean cross(Bridge h, Bridge v, Island[] isl) {
    Island h1 = isl[h.a], h2 = isl[h.b];
    Island v1 = isl[v.a], v2 = isl[v.b];
    int hRow = h1.row, vCol = v1.col;
    int hLo = Math.min(h1.col, h2.col), hHi = Math.max(h1.col, h2.col);
    int vLo = Math.min(v1.row, v2.row), vHi = Math.max(v1.row, v2.row);
    return vCol >= hLo && vCol <= hHi && hRow >= vLo && hRow <= vHi;
}

搜索时每放置一条桥就检查冲突对,能提前砍掉大量分支。

四、第三层:Tarjan 割边求强制桥

这是本文的核心。考虑一个自然的疑问:什么时候某座岛必须和某个邻居连桥?

答案之一是”容量不足”(前面已处理)。答案之二是连通性:如果某座岛在”当前可用的候选边图”上是一个割点(articulation point)或被孤立,那么它必须与外界连接,否则最终无法满足”全图连通”。

我们先把候选边构造成一个无向图 G_cand(顶点 = 岛,边 = 候选边),然后跑一次 Tarjan 求割边(bridge,注意这里的 bridge 是图论术语的”桥边”,与游戏中”架桥”双关,读者容易混淆,下文统一称”割边”)。割边指删除后使图不连通的边。

package com.example.hashi;

import java.util.*;

/**
 * Tarjan 割边与割点检测。
 * 时间戳 dfn、回溯值 low:若 low[to] > dfn[u],则边 (u,to) 是割边。
 */
public class TarjanCut {
    private final List<List<Integer>> adj;
    private final int[] dfn, low;
    private final boolean[] isCutVertex;
    private final Set<String> cutEdges = new HashSet<>();
    private int timer = 0;

    public TarjanCut(int n, List<int[]> edges) {
        adj = new ArrayList<>(n);
        for (int i = 0; i < n; i++) adj.add(new ArrayList<>());
        for (int[] e : edges) { adj.get(e[0]).add(e[1]); adj.get(e[1]).add(e[0]); }
        dfn = new int[n];
        low = new int[n];
        isCutVertex = new boolean[n];
        Arrays.fill(dfn, -1);
        for (int i = 0; i < n; i++) if (dfn[i] == -1) dfs(i, -1, i == 0);
    }

    private void dfs(int u, int parent, boolean root) {
        dfn[u] = low[u] = timer++;
        int childCount = 0;
        for (int v : adj.get(u)) {
            if (v == parent) continue;
            if (dfn[v] != -1) {
                // 已访问:用 dfn[v] 更新 low(回溯边)
                low[u] = Math.min(low[u], dfn[v]);
            } else {
                childCount++;
                dfs(v, u, root);
                low[u] = Math.min(low[u], low[v]);
                if (low[v] > dfn[u]) {
                    cutEdges.add(key(u, v));    // (u,v) 是割边
                }
                if (!root && low[v] >= dfn[u]) {
                    isCutVertex[u] = true;      // u 是割点
                }
            }
        }
        if (root && childCount > 1) isCutVertex[u] = true;
    }

    private String key(int a, int b) { return Math.min(a, b) + "-" + Math.max(a, b); }

    public boolean isBridge(int a, int b) { return cutEdges.contains(key(a, b)); }
    public boolean isArticulation(int u) { return isCutVertex[u]; }
    public Set<String> bridges() { return cutEdges; }

    /** 割点总数,用于快速评估候选图的"强制分配"强度 */
    public int countArticulations() {
        int c = 0;
        for (boolean b : isCutVertex) if (b) c++;
        return c;
    }
}

割点在数桥里的三层用法

  1. 强制桥:如果岛 u 是割点且它的某个割边方向是唯一通往某连通分量的通道,那么 u 必须保留通向该分量的桥,且至少 1 条。这直接给出定值。
  2. 容量不可行检测:割点两侧的分量各自至少需要 1 条连向 u 的桥,若 u 剩余桥数不足以覆盖”分量数”,剪枝。
  3. 孤立岛否决:若某岛在候选图上度为 0 且 need > 0,无解(这其实就是 Tarjan 跑完后的 adj 为空判断)。
/** 根据割边给出硬约束:割边两侧若要连通,只能走这条边 -> 至少放 1 条 */
public static Map<Bridge, Integer> forcedLowerBounds(List<Bridge> cands, int n) {
    List<int[]> edges = new ArrayList<>();
    for (Bridge b : cands) edges.add(new int[]{b.a, b.b});
    TarjanCut cut = new TarjanCut(n, edges);
    Map<Bridge, Integer> lower = new HashMap<>();
    for (Bridge b : cands) {
        if (cut.isBridge(b.a, b.b) && !cut.isArticulation(b.a) && !cut.isArticulation(b.b)) {
            // 纯割边:为保持连通,至少 1 条
            lower.merge(b, 1, Math::max);
        }
    }
    return lower;
}

严谨提示:割边检查必须在当前未饱和的可行边上做,且随搜索推进动态重算。静态版只提供”至少 1 条”的下界,属于安全但偏保守的剪枝——这正是它的价值:绝不剪掉合法解。

五、回溯求解器

三层剪枝之后进入搜索。搜索的关键技巧是 MRV(Minimum Remaining Values):优先处理”剩余可选桥数最少”的岛,让冲突尽早暴露。

package com.example.hashi;

import java.util.*;

public class HashiSolver {
    private final Island[] isl;
    private final List<Bridge> cands;
    private final List<List<Integer>> inc;   // 岛 -> 候选边下标
    private final Set<String> crossPairs = new HashSet<>();
    private boolean[][] bridgeH, bridgeV;    // 交叉判定缓存

    public HashiSolver(Island[] isl, List<Bridge> cands) {
        this.isl = isl;
        this.cands = cands;
        this.inc = new ArrayList<>();
        for (int i = 0; i < isl.length; i++) inc.add(new ArrayList<>());
        for (int k = 0; k < cands.size(); k++) {
            inc.get(cands.get(k).a).add(k);
            inc.get(cands.get(k).b).add(k);
        }
        for (int i = 0; i < cands.size(); i++)
            for (int j = i + 1; j < cands.size(); j++)
                if (cross(cands.get(i), cands.get(j), isl))
                    crossPairs.add(i + "-" + j);
    }

    /** 放置桥:返回是否合法 */
    private boolean place(int edgeIdx, int count) {
        Bridge b = cands.get(edgeIdx);
        if (b.saturated()) return false;
        int add = count;
        if (isl[b.a].chosen + add > isl[b.a].need) return false;
        if (isl[b.b].chosen + add > isl[b.b].need) return false;
        // 交叉检查
        for (String p : crossPairs) {
            String[] sp = p.split("-");
            int x = Integer.parseInt(sp[0]), y = Integer.parseInt(sp[1]);
            int other = (x == edgeIdx) ? y : (y == edgeIdx ? x : -1);
            if (other >= 0 && cands.get(other).placed > 0) return false;
        }
        b.placed += add;
        isl[b.a].chosen += add;
        isl[b.b].chosen += add;
        if (!feasibleAfterPlace(b)) { undo(edgeIdx, add); return false; }
        return true;
    }

    private void undo(int edgeIdx, int add) {
        Bridge b = cands.get(edgeIdx);
        b.placed -= add;
        isl[b.a].chosen -= add;
        isl[b.b].chosen -= add;
    }

    /** 放置后的局部可行性:容量上界 + 连通性下界 */
    private boolean feasibleAfterPlace(Bridge b) {
        for (int v : new int[]{b.a, b.b}) {
            Island s = isl[v];
            int cap = 0;
            for (int ei : inc.get(v)) {
                Bridge e = cands.get(ei);
                cap += (e.placed > 0 ? e.placed : Math.min(2, 2));
            }
            // 已放置的保留,其余最多各 2 条(保守)
            int hardCap = 0;
            for (int ei : inc.get(v)) hardCap += 2;
            if (s.chosen > s.need || hardCap < s.need) return false;
            // 若剩余需求为 0,则所有相邻边必须饱和
            if (s.need - s.chosen == 0) {
                for (int ei : inc.get(v)) if (cands.get(ei).placed == 1) return false;
            }
        }
        return true;
    }

    public boolean solve() {
        // 先做全图连通性检查
        if (!fullyConnected()) return false;
        return dfs();
    }

    private boolean dfs() {
        int v = pickMostConstrained();
        if (v < 0) return allSatisfied() && fullyConnected();
        for (int ei : inc.get(v)) {
            Bridge e = cands.get(ei);
            int other = e.other(v);
            int maxAdd = Math.min(2 - e.placed,
                                  Math.min(isl[v].need - isl[v].chosen,
                                           isl[other].need - isl[other].chosen));
            for (int add = maxAdd; add >= 0; add--) {       // 先试大的,常见解更"密"
                if (add == 0) {
                    if (dfsStillOk(v)) { if (dfs()) return true; }
                    break;
                }
                if (!place(ei, add)) continue;
                if (dfs()) return true;
                undo(ei, add);
            }
        }
        return false;
    }

    /** MRV:选剩余需求大、候选少的那座岛 */
    private int pickMostConstrained() {
        int best = -1; double bestScore = Double.NEGATIVE_INFINITY;
        for (Island s : isl) {
            int remain = s.need - s.chosen;
            if (remain <= 0) continue;
            int free = 0;
            for (int ei : inc.get(s.id)) free += 2 - cands.get(ei).placed;
            if (free < remain) return -2;             // 立即失败信号
            double score = remain * 1.0 / Math.max(1, free);
            if (score > bestScore) { bestScore = score; best = s.id; }
        }
        return best;
    }

    private boolean dfsStillOk(int v) {
        int free = 0;
        for (int ei : inc.get(v)) free += 2 - cands.get(ei).placed;
        return free >= isl[v].need - isl[v].chosen;
    }

    private boolean allSatisfied() {
        for (Island s : isl) if (s.chosen != s.need) return false;
        return true;
    }

    /** 用并查集确认所有岛连通 */
    private boolean fullyConnected() {
        int[] p = new int[isl.length];
        for (int i = 0; i < p.length; i++) p[i] = i;
        for (Bridge b : cands) {
            if (b.placed > 0) union(p, b.a, b.b);
        }
        int root = find(p, 0);
        for (int i = 1; i < p.length; i++) if (find(p, i) != root) return false;
        return true;
    }

    private int find(int[] p, int x) { return p[x] == x ? x : (p[x] = find(p, p[x])); }
    private void union(int[] p, int a, int b) { p[find(p, a)] = find(p, b); }
}

注意 solve() 末尾的 fullyConnected():连通性是最后一道关。很多实现忘掉这一步,导致输出的解”每座岛数字都对,但图是断开的”。

六、主流程与可解性报告

public class Main {
    public static void main(String[] args) {
        // 5x5 的示例盘面,0 表示空格
        int[][] puzzle = {
            {0, 3, 0, 0, 0},
            {2, 0, 0, 0, 3},
            {0, 0, 5, 0, 0},
            {0, 4, 0, 1, 0},
            {0, 0, 2, 0, 4},
        };
        BoardLoader loader = new BoardLoader();
        Island[] isl = loader.load(puzzle);
        List<Bridge> cands = loader.candidates(isl, puzzle.length, puzzle[0].length);

        // 第一层:容量检查
        int sumNeed = 0;
        for (Island s : isl) sumNeed += s.need;
        if (!HashiSolver.passesPlanarityBound(sumNeed, isl.length)) {
            System.out.println("欧拉平面性上界否决:边数超过 3V-6,必然无解");
            return;
        }

        // 第二层:Tarjan 割边
        List<int[]> edges = new ArrayList<>();
        for (Bridge b : cands) edges.add(new int[]{b.a, b.b});
        TarjanCut cut = new TarjanCut(isl.length, edges);
        System.out.println("候选图割边数 = " + cut.bridges().size()
                         + ",割点数 = " + cut.countArticulations());

        // 第三层:搜索
        HashiSolver solver = new HashiSolver(isl, cands);
        boolean ok = solver.solve();
        System.out.println(ok ? "求解成功" : "无解");
    }

}

七、复杂度与实践建议

阶段 复杂度 作用
候选边枚举 O(V) 把棋盘压成图
容量/度数检查 O(V) 秒级否决大量无解盘
欧拉平面性界 O(V) 必要条件,不剪合法解
Tarjan 割边/割点 O(V + E) 给出强制下界与孤立点否决
回溯搜索 最坏指数,实测 O(1.2^n) 级 MRV 排序显著减小分支
最终连通性校验 O(V α(V)) 保证解有效

三点经验,写给正在写同类求解器的人:

  1. 先平面性后搜索。数桥绝大多数”卡死”来自桥交叉,而这个约束可以用几何冲突对表在 O(E^2) 预算内完全预计算(E 在百量级时完全可接受)。预计算一次,搜索时每次 O(1) 查表。
  2. Tarjan 的割点比割边更有用。割边只告诉你”这条边不能删”;割点告诉你”这座岛必须同时向多个方向分桥”,可以直接把它的桥数按连通分量个数下限分配,这是最强的剪枝来源。
  3. 连通性必须最后验。它不是剪枝用的,而是正确性的一部分。忘了它,求解器会输出”数字全对但图断开”的假解。

最后提一句理论联系:欧拉公式那一步之所以能当”上界”用,是因为桥不交叉等价于图可平面嵌入,而平面图边数有硬上限。这是数桥这类谜题里少见的”纯组合几何”成分——把游戏规则翻译成图论语言,规则本身就成了剪枝器。