每日算法 — 使用java实现斯坦纳树:状态压缩动态规划求解最小连通子图

问题背景

斯坦纳树问题(Steiner Tree Problem)是图论中最经典的NP难问题之一。给定一个无向带权图 $G=(V, E)$ 和一个终端点集合 $T \subseteq V$,要求找到一棵连接 $T$ 中所有点的子树,使得总边权最小。

与最小生成树(MST)不同,斯坦纳树允许使用非终端点(称为斯坦纳点)作为中间节点。这使得在某些图中,斯坦纳树的总权值可以严格小于MST的权值。

对于 $|T|$ 较小(通常 $|T| \leq 20$)的情况,可以使用状态压缩动态规划在 $O(3^k \cdot n + 2^k \cdot m \log n)$ 的时间内精确求解,其中 $k = |T|$,$n = |V|$,$m = |E|$。

核心原理

状态定义

设终端点数量为 $k$,用二进制掩码 $mask$($0 \leq mask < 2^k$)表示当前需要连接的终端点子集。

$dp[mask][v]$:表示连接掩码 $mask$ 中所有终端点,并且以节点 $v$ 为根的子树的最小权值。

状态转移

状态转移分为两类:

1. 子集合并(SPFA/最短路松弛)

对于每个节点 $v$,可以将两个不相交的子集 $sub$ 和 $mask \oplus sub$ 合并:

$$dp[mask][v] = \min_{sub \subset mask} { dp[sub][v] + dp[mask \oplus sub][v] }$$

为了避免重复枚举,通常枚举 $sub = (mask – 1) \& mask$ 的非空真子集即可。

2. 最短路扩展(Dijkstra/SPFA)

固定 $mask$ 后,$dp[mask][v]$ 可以通过图中的边进行松弛:

$$dp[mask][to] = \min(dp[mask][to], dp[mask][v] + w(v, to))$$

这相当于在固定终端点集合的情况下,求从各起点出发的最短路。

算法流程

  1. 初始化:对于每个终端点 $t_i$,$dp[1<<i][t_i] = 0$
  2. 按照掩码从小到大枚举 $mask$
  3. 对每个 $mask$,先枚举子集合并更新 $dp[mask][v]$
  4. 然后以所有 $v$ 为源点运行SPFA或Dijkstra,通过边松弛更新 $dp[mask][to]$
  5. 最终答案为 $\min_v dp[(1<<k)-1][v]$

Java实现

import java.util.*;

/**
 * 斯坦纳树:状态压缩动态规划求解最小连通子图
 * 时间复杂度:O(3^k * n + 2^k * m * log n)
 * 空间复杂度:O(2^k * n)
 * 适用条件:终端点数量 k <= 20,顶点数 n <= 5000
 */
public class SteinerTree {

    // 图的邻接表:to, weight
    private List<int[]>[] graph;
    private int n;          // 顶点数
    private int m;          // 边数
    private int k;          // 终端点数
    private int[] terminals; // 终端点列表

    // dp[mask][v]:连接mask中终端点、以v为根的子树最小权值
    private int[][] dp;
    private static final int INF = Integer.MAX_VALUE / 4;

    public SteinerTree(int n, int m) {
        this.n = n;
        this.m = m;
        this.graph = new ArrayList[n + 1];
        for (int i = 1; i <= n; i++) {
            graph[i] = new ArrayList<>();
        }
    }

    /**
     * 添加无向边
     */
    public void addEdge(int u, int v, int w) {
        graph[u].add(new int[]{v, w});
        graph[v].add(new int[]{u, w});
    }

    /**
     * 设置终端点
     */
    public void setTerminals(int[] terminals) {
        this.terminals = terminals;
        this.k = terminals.length;
    }

    /**
     * 求解斯坦纳树最小权值
     * @return 连接所有终端点的最小子树权值
     */
    public int solve() {
        int fullMask = (1 << k) - 1;
        dp = new int[1 << k][n + 1];

        // 初始化dp数组为无穷大
        for (int mask = 0; mask <= fullMask; mask++) {
            Arrays.fill(dp[mask], INF);
        }

        // 初始化:单个终端点以自身为根,权值为0
        for (int i = 0; i < k; i++) {
            dp[1 << i][terminals[i]] = 0;
        }

        // 按mask从小到大枚举
        for (int mask = 1; mask <= fullMask; mask++) {

            // Step 1: 子集合并
            // 枚举mask的非空真子集sub
            // 技巧:sub = (mask - 1) & mask 开始,然后 sub = (sub - 1) & mask
            // 为了避免重复(sub和mask^sub都算一遍),只枚举sub为mask的"前半部分"
            for (int sub = (mask - 1) & mask; sub > 0; sub = (sub - 1) & mask) {
                int other = mask ^ sub;
                // 保证只枚举一次:sub < other 或固定一种顺序
                if (sub > other) continue;  // 避免重复计算

                for (int v = 1; v <= n; v++) {
                    if (dp[sub][v] == INF || dp[other][v] == INF) continue;
                    int newVal = dp[sub][v] + dp[other][v];
                    if (newVal < dp[mask][v]) {
                        dp[mask][v] = newVal;
                    }
                }
            }

            // Step 2: 最短路松弛(SPFA)
            // 以当前dp[mask][v]作为初始距离,运行最短路
            spfa(mask);
        }

        // 答案:所有节点作为根的最小值
        int ans = INF;
        for (int v = 1; v <= n; v++) {
            ans = Math.min(ans, dp[fullMask][v]);
        }
        return ans;
    }

    /**
     * SPFA最短路松弛:固定mask,通过边松弛更新dp[mask][*]
     */
    private void spfa(int mask) {
        Queue<Integer> queue = new LinkedList<>();
        boolean[] inQueue = new boolean[n + 1];

        // 将所有距离有限的节点加入队列
        for (int v = 1; v <= n; v++) {
            if (dp[mask][v] < INF) {
                queue.offer(v);
                inQueue[v] = true;
            }
        }

        while (!queue.isEmpty()) {
            int u = queue.poll();
            inQueue[u] = false;

            for (int[] edge : graph[u]) {
                int to = edge[0];
                int w = edge[1];

                if (dp[mask][u] + w < dp[mask][to]) {
                    dp[mask][to] = dp[mask][u] + w;
                    if (!inQueue[to]) {
                        queue.offer(to);
                        inQueue[to] = true;
                    }
                }
            }
        }
    }

    /**
     * 使用Dijkstra替代SPFA(更稳定,但代码稍长)
     * 时间复杂度:O(m log n) 单次
     */
    private void dijkstra(int mask) {
        PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[1]));
        boolean[] visited = new boolean[n + 1];

        // 初始化优先队列
        for (int v = 1; v <= n; v++) {
            if (dp[mask][v] < INF) {
                pq.offer(new int[]{v, dp[mask][v]});
            }
        }

        while (!pq.isEmpty()) {
            int[] cur = pq.poll();
            int u = cur[0];
            int dist = cur[1];

            if (visited[u]) continue;
            visited[u] = true;

            for (int[] edge : graph[u]) {
                int to = edge[0];
                int w = edge[1];

                if (dp[mask][u] + w < dp[mask][to]) {
                    dp[mask][to] = dp[mask][u] + w;
                    pq.offer(new int[]{to, dp[mask][to]});
                }
            }
        }
    }

    /**
     * 重构斯坦纳树的边集合(可选扩展)
     * 需要额外记录每个状态的前驱节点和转移方式
     */
    public List<int[]> reconstructTree() {
        // 需要扩展dp结构以记录前驱信息
        // 此处为简化版,仅返回空列表
        // 实际实现中需要记录:
        // 1. 子集合并时选择的最优分割点和根节点
        // 2. 最短路松弛时的前驱边
        return new ArrayList<>();
    }

    /**
     * 输出dp表格(调试用)
     */
    public void printDpTable() {
        int fullMask = (1 << k) - 1;
        System.out.println("=== DP Table ===");
        for (int mask = 1; mask <= fullMask; mask++) {
            System.out.printf("Mask %d: ", mask);
            for (int v = 1; v <= Math.min(n, 10); v++) {
                System.out.printf("%d ", dp[mask][v] == INF ? -1 : dp[mask][v]);
            }
            System.out.println();
        }
    }

    /**
     * 主函数:测试斯坦纳树算法
     */
    public static void main(String[] args) {
        // 示例1:经典对比案例
        // 斯坦纳树可以比MST更优
        System.out.println("=== 示例1:斯坦纳树 vs MST ===");
        SteinerTree st1 = new SteinerTree(6, 7);
        st1.addEdge(1, 2, 1);
        st1.addEdge(2, 3, 1);
        st1.addEdge(3, 4, 1);
        st1.addEdge(4, 5, 1);
        st1.addEdge(5, 6, 1);
        st1.addEdge(2, 5, 3);  // 跨接边
        st1.addEdge(3, 6, 3);  // 跨接边
        // 终端点:1, 4, 6
        st1.setTerminals(new int[]{1, 4, 6});
        int ans1 = st1.solve();
        System.out.println("斯坦纳树最小权值: " + ans1);
        // MST(1,4,6) = 1-2-3-4 + 3-6 = 1+1+1+3 = 6
        // Steiner: 1-2-3-4 (3) + 3-6 (3) = 6,或 1-2-5-6 + 2-3-4 = ...
        // 实际取决于图结构

        // 示例2:网格图
        System.out.println("\n=== 示例2:4x4网格图 ===");
        SteinerTree st2 = new SteinerTree(16, 24);
        // 构建4x4网格
        for (int i = 0; i < 4; i++) {
            for (int j = 0; j < 4; j++) {
                int id = i * 4 + j + 1;
                if (j + 1 < 4) st2.addEdge(id, id + 1, 1);
                if (i + 1 < 4) st2.addEdge(id, id + 4, 1);
            }
        }
        // 四个角落为终端点
        st2.setTerminals(new int[]{1, 4, 13, 16});
        int ans2 = st2.solve();
        System.out.println("4x4网格四个角落斯坦纳树: " + ans2);

        // 示例3:随机图性能测试
        System.out.println("\n=== 示例3:随机图性能测试 ===");
        int n = 100, k = 5;
        SteinerTree st3 = new SteinerTree(n, 0);
        Random rand = new Random(42);
        // 生成完全随机图
        for (int i = 1; i <= n; i++) {
            for (int j = i + 1; j <= n; j++) {
                if (rand.nextDouble() < 0.15) {
                    int w = rand.nextInt(20) + 1;
                    st3.addEdge(i, j, w);
                }
            }
        }
        int[] terms = new int[k];
        for (int i = 0; i < k; i++) terms[i] = rand.nextInt(n) + 1;
        st3.setTerminals(terms);

        long start = System.currentTimeMillis();
        int ans3 = st3.solve();
        long end = System.currentTimeMillis();
        System.out.println("随机图(n=100, k=5)斯坦纳树权值: " + ans3);
        System.out.println("运行时间: " + (end - start) + " ms");
    }
}

复杂度分析

时间复杂度

  • 子集合并:对每个 $mask$,枚举真子集 $sub$ 的复杂度为 $O(2^{popcount(mask)})$。总枚举量为 $\sum_{mask} 2^{popcount(mask)} = 3^k$。每次合并遍历所有节点 $O(n)$,总复杂度 $O(3^k \cdot n)$。
  • 最短路松弛:对每个 $mask$ 运行一次SPFA/Dijkstra。共有 $2^k$ 个mask,单次 $O(m)$(SPFA)或 $O(m \log n)$(Dijkstra)。总复杂度 $O(2^k \cdot m)$ 或 $O(2^k \cdot m \log n)$。

综合:$O(3^k \cdot n + 2^k \cdot m)$(使用SPFA)或 $O(3^k \cdot n + 2^k \cdot m \log n)$(使用Dijkstra)。

空间复杂度

$dp$ 数组大小为 $2^k \times (n+1)$,空间复杂度 $O(2^k \cdot n)$。

应用场景

  1. VLSI设计:芯片布线中连接多个引脚的最短导线网络
  2. 网络设计:通信网络中连接多个关键节点的最小成本拓扑
  3. 生物信息学:进化树构建中的最小连接网络
  4. 地理信息系统:连接多个目标位置的最短道路网络规划

扩展阅读

对于 $k$ 较大的情况($k > 20$),状态压缩DP不可行,需要采用:
– 近似算法:MST-based 2-近似、相对贪心算法
– 整数线性规划:用求解器精确求解小规模实例
– 启发式算法:模拟退火、遗传算法、蚁群优化等元启发式方法