动态规划求解最小斯坦纳树(证了一天两夜)

最小斯坦纳树

给定点的“最小生成树”问题。

背景

给定无向连通图 G = ( V , E ) G=(V,E) G=(V,E),给出包含 k k k 个结点的点集 S S S,包含点集 S S S 的连通图被称作 斯坦纳树。但我们关注的是如何求出包含点集 S S S最小连通图 G ′ = ( V ′ , E ′ ) G'=(V',E') G=(V,E)最小斯坦纳树。其中 S ⊆ V ′ , E ′ ⊆ E S\subseteq V',E'\subseteq E SV,EE

此处的最小可以指最小的点权和边权和等。

求解最小斯坦纳树是一个 N P − H a r d \color{red}NP-Hard NPHard 问题,所以只会有近似解,并且数据范围不会太大即 k ≤ 10 k\leq10 k10

目前的最好算法的算法时间复杂度为: O ( 3 K N + 2 K M l o g M ) O(3^KN+2^KMlogM) O(3KN+2KMlogM)。其中 N = ∣ V ∣ , M = ∣ E ∣ N=|V|,M=|E| N=V,M=E

动态规划+状态压缩

笔者对着题解思考了这个方法近 1 1 1 天半,并网上查阅了很多资料,再加上自己的一些证明,终于能够彻底搞懂这个算法的精髓。下文将着重论述笔者当时卡住的地方。

我们先假设求的是包含点集 S S S边权和最小的连通块。设权重函数 W : E → N W:E\rarr N W:EN

【起源】

【性质 1 1 1包含点集 S S S边权和最小的子图 G ′ = ( V ′ , E ′ ) G'=(V',E') G=(V,E) 一定是一棵

证明】若 G ′ G' G 中包含环,那么将环中边权最大的边删去后, G ′ G' G 仍连通且边权和变得更小,与前面假设的边权和最小矛盾,故 G ′ G' G 不含环,证毕。

根据性质 1 1 1,我们不难得到一个暴力算法:枚举 G G G 的所有子图 ,对子图进行最小生成树算法。虽然这样一定能够求出包含 S S S 的斯坦纳树,但时间复杂度是 O ( 2 V E l o g V ) O(2^{V}ElogV) O(2VElogV),这是因为有许多子图是没必要枚举的,且每次枚举后还要进行生成树算法,不如我们干脆就从包含 S S S 的树开始转移

d p [ i ] [ S ] dp[i][S] dp[i][S] 表示以 i i i 为根结点且包含 S S S 的树的最小边权和,因为 S S S 是一个集合,且集合内的元素不超过 10 10 10,所以我们考虑用二进制来表示这个集合。

到这里,几乎所有的题解/博客都是直接给出转移方程,然后再贴个代码。但里面的内容却不细讲,可能是觉得大家都学到斯坦纳树了,没必要再写太过详尽。但是却苦了我这个菜鸟。

为了理解透彻我们先从初始化开始讲。

【初始化】

S = { t e r m i n a l [ 1 ] , t e r m i n a l [ 2 ] , ⋯   , t e r m i n a l [ k ] } S=\{terminal[1],terminal[2],\cdots,terminal[k]\} S={terminal[1],terminal[2],,terminal[k]} 。(最初定义斯坦纳树的论文中,管 S S S 中的点叫做终端( t e r m i n a l terminal terminal)点,斯坦纳树中的不属于终端点的其他点叫做斯坦纳点。)那么则有初始化:

for i in (1,2,...,k) :
     dp[terminal[i]][1<<(i-1)] = 0;

d p [ t e r m i n a l [ i ] ] [ 1 < < ( i − 1 ) ] dp[terminal[i]][1<<(i-1)] dp[terminal[i]][1<<(i1)] 中,根是终端点本身,包含的集合也是终端点本身。因为算的是子树的最小边权和,而只有一个点的树显然没有边,所以赋值为 0 0 0

数组第二维的 ( i − 1 ) (i-1) (i1) 是为了和集合 S S S 的二进制表示所对齐,因为 S S S 的二进制表示是 1 ⋯ 1 ⏟ k 个 1 \underbrace{1\cdots 1}_{k个1} k1 11,即 ( 1 < < k ) − 1 (1<<k)-1 (1<<k)1,而 1 < < ( i − 1 ) 1<<(i-1) 1<<(i1) 正好是 S S S 的二进制中第 i i i 1 1 1

初始化了解完了之后,我们需要了解答案是怎么产生的。

【答案产生】

{ d p [ i ] [ S ] ; ∀ i ∈ S } \{dp[i][S];\forall i\in S\} {dp[i][S];iS} 显然都是正确答案,因为 d p [ i ] [ S ] dp[i][S] dp[i][S] 表示以 i i i 为根结点且包含 S S S 的树的最小边权和,而最小斯坦纳树显然包括 S S S,所以 S S S 中的任意一点为根都可以作为答案。

因为最终答案的 S S S 的二进制表示中有 k k k 1 1 1,但初始化时只有一个 1 1 1,所以 d p dp dp 的转移过程中必然需要子集合的合并。

比如说 d p [ i ] [ 00 ⋯ 10 ] dp[i][00\cdots10] dp[i][0010] d p [ i ] [ 00 ⋯ 01 ] dp[i][00\cdots01] dp[i][0001] 可以合并为 d p [ i ] [ 00 ⋯ 11 ] dp[i][00\cdots11] dp[i][0011] d p dp dp 的第二维是集合的二进制表示)。

看到这里可能会感到好奇,因为初始化时 d p dp dp 数组中根和集合都是终端点本身,即一个点对应一个集合,但上文的 d p [ i ] [ 00 ⋯ 10 ] dp[i][00\cdots10] dp[i][0010] d p [ i ] [ 00 ⋯ 01 ] dp[i][00\cdots01] dp[i][0001] 却是一个点对应两个集合。这是怎么从初始态转移而来的?

我们定义 d ( i , j ) d(i,j) d(i,j) 为原图中 i ⇝ j i\leadsto j ij最短路径长度。我们强行让 j j j 结点和 一棵以 t e r m i n a l [ i ] terminal[i] terminal[i] 为根,包含集合 1 < < ( i − 1 ) 1<<(i-1) 1<<(i1) 的子树连通,那么连通后就有了一棵新树。以 j j j 为根,包含集合 1 < < ( i − 1 ) 1<<(i-1) 1<<(i1) 的子树的最小边权和是:
d p [ j ] [ 1 < < ( i − 1 ) ] = d p [ t e r m i n a l [ i ] ] [ 1 < < ( i − 1 ) ] + d ( t e r m i n a l [ i ] , j ) (1) dp[j][1<<(i-1)] = dp[terminal[i]][1<<(i-1)] + d(terminal[i],j)\tag{1} dp[j][1<<(i1)]=dp[terminal[i]][1<<(i1)]+d(terminal[i],j)(1)
也就是在原来的基础上加上了 t e r m i n a l [ i ] terminal[i] terminal[i] j j j 的最短路。这个式子的正确性是显然的。

接下来我们将演示在一个简单图上的 d p dp dp 过程。

请看下图演示

S = { 1 , 3 } S=\{1,3\} S={1,3}

在这里插入图片描述

初始化

d p [ 1 ] [ 01 ] = 0 , d p [ 3 ] [ 10 ] = 0 dp[1][01]=0,dp[3][10]=0 dp[1][01]=0,dp[3][10]=0 d p dp dp 的第二维是集合的二进制表示, 01 01 01 表示 结点 1 1 1 是第一个终端点, 10 10 10 表示结点 3 3 3 是第二个终端点。)。

强行连通

2 , 3 , 4 2,3,4 2,3,4 d p [ 1 ] [ 01 ] dp[1][01] dp[1][01] 代表的子树连通:

d p [ 2 ] [ 01 ] = 3 , d p [ 3 ] [ 01 ] = 1 , d p [ 4 ] [ 01 ] = 3 dp[2][01]=3,dp[3][01]=1,dp[4][01]=3 dp[2][01]=3,dp[3][01]=1,dp[4][01]=3

1 , 2 , 4 1,2,4 1,2,4 d p [ 3 ] [ 10 ] dp[3][10] dp[3][10] 代表的子树连通:

d p [ 1 ] [ 10 ] = 1 , d p [ 2 ] [ 10 ] = 2 , d p [ 4 ] [ 10 ] = 4 dp[1][10]=1,dp[2][10]=2,dp[4][10]=4 dp[1][10]=1,dp[2][10]=2,dp[4][10]=4

现在所有结点都连通了包含 t e r m i n a l [ i ] terminal[i] terminal[i] 的子树,且包含的集合大小为 1 1 1

合并子树

接下来对于每个结点 v v v ,我们将两棵以 v v v 为根,包含大小为 1 1 1 的集合的子树,合并为一棵 以 v v v 为根,包含大小为 2 2 2 的集合的树。
d p [ v ] [ 11 ] = d p [ v ] [ 10 ] + d p [ v ] [ 01 ] (2) dp[v][11]=dp[v][10]+dp[v][01]\tag{2} dp[v][11]=dp[v][10]+dp[v][01](2)
在这个例子中, S S S 集合大小为 2 2 2,所以此时已经做完了。不妨更普遍一些, ∣ S ∣ > 2 |S|>2 S>2 显然我们要遍历所有的子集合,设 S S S 的子集合为 S ′ S' S,则有:
d p [ v ] [ S ] = m i n ∀ S ′ ⊆ S ( d p [ v ] [ S ] , d p [ v ] [ S ′ ] + d p [ v ] [ S − S ′ ] ) (2’) dp[v][S]=\underset{\forall S'\subseteq S}{min}(dp[v][S],dp[v][S']+dp[v][S-S'])\tag{2'} dp[v][S]=SSmin(dp[v][S],dp[v][S]+dp[v][SS])(2’)
那么我们可以将 ( 1 ) (1) (1) 式的强行连通也写的更具有普遍性(这样可以求出真正的 d p [ v ] [ S ] dp[v][S] dp[v][S]):
d p [ v ] [ S ] = m i n ∀ u ∈ V { d p [ u ] [ S ] + d ( u , v ) } (1’) dp[v][S]=\underset{\forall u\in V}{min}\{dp[u][S]+d(u,v)\}\tag{1'} dp[v][S]=uVmin{dp[u][S]+d(u,v)}(1’)
所以, d p dp dp 过程就是不断的进行三个操作:“强行连通”“合并子树““强行连通”

稍等…为什么合并子树之后还需要进行强行连通?

举个简单的例子: S = 2 S=2 S=2,对于任意一个结点 v v v 而言,合并子树只是令 d p [ v ] [ 11 ] = d p [ v ] [ 01 ] + d p [ v ] [ 10 ] dp[v][11]=dp[v][01]+dp[v][10] dp[v][11]=dp[v][01]+dp[v][10]。但不代表合并之后 d p [ v ] [ 01 ] + d p [ v ] [ 10 ] dp[v][01]+dp[v][10] dp[v][01]+dp[v][10] 就是真正的一棵以 v v v 为根且包含 S S S 的最小边权和子树。

在这里插入图片描述

假设 v = 1 v=1 v=1 S = { 3 , 4 } S=\{3,4\} S={3,4},此时经过一次强行连通之后 d p [ 1 ] [ 01 ] = a + c , d p [ 1 ] [ 10 ] = a + b dp[1][01]=a+c,dp[1][10]=a+b dp[1][01]=a+c,dp[1][10]=a+b,合并后 d p [ 1 ] [ 11 ] = 2 a + b + c dp[1][11]=2a+b+c dp[1][11]=2a+b+c。但实际上 d p [ 1 ] [ 11 ] = d p [ 2 ] [ 11 ] + d ( 1 , 2 ) = a + b + c dp[1][11]=dp[2][11]+d(1,2)=a+b+c dp[1][11]=dp[2][11]+d(1,2)=a+b+c。这也就是我们需要再进行一次强行连通的目的。因为进行完了一次强行连通,我们会对所有的包含集合大小相同的以 v v v 为根的树求出其真正的最小边权和。

这就是为什么我们要在合并子树之后再进行一次强行连通。

但其实,如果我们循环进行两次操作:“合并子树”“强行连通” 也是没问题的。因为合并子树的目的是 “扩大集合”,前提是子树所代表的最小边权和准确。因为初始时只有 d p [ t e r m i n a l [ i ] ] [ 1 < < ( i − 1 ) ] dp[terminal[i]][1<<(i-1)] dp[terminal[i]][1<<(i1)],如果此时我们进行合并子树的话,是不会有任何反应的,因为没有进行连通,所以一个点只对应一个集合。然后再进行强行连通操作。所以每次只要执行两次操作就行了,更省事。

d p dp dp 过程就是不断的进行两个操作:“合并子树”“强行连通”

到这里,我们可以用一个基础版的代码求出给定点集 S S S 的最小斯坦纳树。基础版的时间复杂度是 O ( 3 k N + N 2 l o g M + 2 k N 2 ) O(3^kN+N^2logM+2^kN^2) O(3kN+N2logM+2kN2)。因为我们需要用到最短路,所以考虑用 n n n d i j k s t r a dijkstra dijkstra 求解出任意两点之间的最短路(当且仅当不含负权边,且 N N N 数量级较小时用)。

合并子树:遍历 S S S 的子集 m a s k mask mask + 遍历每个点 + 枚举 m a s k mask mask 的子集 = O ( 3 k N ) O(3^kN) O(3kN)

强行连通:遍历 S S S 的子集 m a s k mask mask + 遍历最终根结点 u u u 和中转根结点 v v v = O ( 2 k N 2 ) O(2^kN^2) O(2kN2)

预处理最短路 n n n D i j k s t r a Dijkstra Dijkstra = O ( N 2 l o g M ) O(N^2logM) O(N2logM)

for (int mask = 1; mask < (1 << k); mask ++) {
    
    for (int submask = mask; submask; submask = (submask - 1) & mask)
        for (int u = 0; u < N; u ++)
            dp[mask][u] = min(dp[mask][u], dp[submask][u] + dp[mask ^ submask][u]);

    for (int u = 0; u < N; u ++) {
        for (int v = 0; v < N; v ++)
            dp[mask][v] = min(dp[mask][v], dp[mask][u] + d[u][v]);
         
}
【至臻版本】

代码来自 J i a n g l y Jiangly Jiangly(进行了一些个人习惯上的修改)。

for (int s = 0; s < (1 << (K - 1)); s++) {
        for (int t = s; t != 0; t = (t - 1) & s) 
            for (int i = 0; i < N; i++) 
                dp[s][i] = min(dp[s][i], dp[t][i] + dp[s ^ t][i]);
     
        priority_queue<PII, vector<PII>, greater<PII>> pq;
        vector<bool> vis(N, false);
     
        for (int i = 0; i < N; i++) 
            if (dp[s][i] != inf) 
                pq.push({dp[s][i], i});

        while (!pq.empty()) {
            auto [d, x] = pq.top();
            pq.pop();
            
            if (vis[x]) continue;
            vis[x] = true;
             
            for (auto [y, w] : adj[x]) {
                if (dp[s][y] > dp[s][x] + w) {
                    dp[s][y] = dp[s][x] + w;
                    pq.push({dp[s][y], y});
                }
            }
        }
    }

至臻版本和普通版本的区别就是,至臻版本没有提前预处理出任意两结点之间的最短路。而是在需要用到最短路的时候调用 D i j k s t r a Dijkstra Dijkstra

我们将有效值统统插入最小优先队列中,然后进行 D i j k s t r a Dijkstra Dijkstra d p [ S ] [ i ] dp[S][i] dp[S][i] 会在 D i j k s t r a Dijkstra Dijkstra 中松弛为最小值。

【问题】
for (int i = 0; i < N; i++) 
    if (dp[s][i] != inf) 
        pq.push({dp[s][i], i});

这段代码为啥和普通的 D i j k s t r a Dijkstra Dijkstra 不一样?正常的 D i j k s t r a Dijkstra Dijkstra 是令起点为 0 0 0,并且只有一个起点。

这段代码的意思是什么?

往最小堆中插入多个值,其中最小的会在第一次被拿出来。此时最小的值对应的根节点可以看作起点。假设这个点为 u u u,然后用这个起点的值 d p [ s ] [ u ] dp[s][u] dp[s][u],去更新起点周围的点,设为 v v v。此时如果 d p [ s ] [ v ] dp[s][v] dp[s][v] 大于 d p [ s ] [ u ] + w ( u , v ) dp[s][u]+w(u,v) dp[s][u]+w(u,v),那么说明 v v v u u u 转移过来更优(强行连通操作之前, v v v 的合并只会从 v v v 的子树中转移,详细见合并操作)。那么 v v v 就会被松弛成功。更新完 u u u 周围的点,我们重新从最小堆中拿出最小的点作为起点去松弛周围的点。

为什么这样做是对的?

性质 2 2 2】每次从最小堆中取出来的值,设其对应根结点是 u u u,显然 u u u 一定从最优的点转移过来的。

证明】因为进行 D i j k s t r a Dijkstra Dijkstra 的元素一定是同集合大小的,假设 d p [ s ] [ u ] dp[s][u] dp[s][u] 是第一个被取出来的最小值,若它不是最优解,假设 u u u 真正的最优解是 ρ ( s , u ) \rho(s,u) ρ(s,u),且设它是由 v v v 转移而来的,显然有 ρ ( s , u ) = d p [ s ] [ v ] + d ( u , v ) ≤ d p [ s ] [ u ] \rho(s,u)=dp[s][v]+d(u,v) \leq dp[s][u] ρ(s,u)=dp[s][v]+d(u,v)dp[s][u]。也就是说存在 d p [ s ] [ v ] < d p [ s ] [ u ] dp[s][v]<dp[s][u] dp[s][v]<dp[s][u],所以 d p [ s ] [ u ] dp[s][u] dp[s][u] 不是第一个被取出来的最小值,这与假设矛盾。所以第一个被取出的最小值一定是最优解。

假设 d p [ s ] [ v ] dp[s][v] dp[s][v] 是第 t t t 次被取出来的最小值,如果此时 d p [ s ] [ v ] = ρ ( s , v ) dp[s][v]=\rho(s,v) dp[s][v]=ρ(s,v),说明接下来它不会被后续的点再更新。

设后续的点为 x x x,如果 x x x 能更新 v v v,说明 d p [ s ] [ v ] > d p [ s ] [ x ] + d ( v , x ) dp[s][v]>dp[s][x]+d(v,x) dp[s][v]>dp[s][x]+d(v,x),又因为后续出现的点 d p [ s ] [ x ] > d p [ s ] [ v ] dp[s][x]>dp[s][v] dp[s][x]>dp[s][v],这与假设矛盾,所以 x x x 必然是 v v v 或者 v v v 之前出现过的点即前 t − 1 t-1 t1 个点。所以每次从最小堆中取出来的值,一定是最优解。

性质 2 2 2 证毕。

根据性质 2 2 2,我们可以知道当 D i j k s t r a Dijkstra Dijkstra 结束之后, ∀ i ∈ V , d p [ s ] [ i ] = ρ ( s , i ) \forall i\in V,dp[s][i]=\rho(s,i) iV,dp[s][i]=ρ(s,i)。所以算法正确。

模版

#include <bits/stdc++.h>
using namespace std;
//#pragma GCC optimize(2)
#define int long long
#define endl '\n'
#define PII pair<int,int>
#define INF 1e18

const int N = 2000;
vector<PII> g[N]; //邻接表存图
int terminal[11]; //终端点
void add (int u, int v, int w) { g[u].push_back({v,w}); }

void slove () {
    int n, m, k;
    cin >> n >> m >> k;

    for (int i = 1; i <= m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        add (u, v, w), add(v, u, w);
    }

    vector<vector<int>> dp(1 << k, vector<int>(n + 1, INF));
    for (int i = 1; i <= k; i++) {
        cin >> terminal[i];
        dp[1 << (i - 1)][terminal[i]] = 0;
    }

    for (int S = 1; S < (1 << k); S++) {
        for (int t = S; t; t = (t - 1) & S ) {
            for (int u = 1; u <= n; u++) {
                dp[S][u] = min(dp[S][u], dp[t][u] + dp[S^t][u]);
            }
        }

        priority_queue<PII, vector<PII>, greater<PII>> q;
        vector<bool> vis(n + 1, false);

        for (int i = 1; i <= n; i++) {
            if (dp[S][i] != INF) q.push({dp[S][i], i});
        }

        while (q.size()) {
            int u = q.top().second;
            q.pop();
            if (vis[u]) continue;
            vis[u] = 1;
            for (auto [v,w] : g[u]) {
                if (dp[S][v] > dp[S][u] + w) {
                    dp[S][v] = dp[S][u] + w;
                    q.push({dp[S][v], v});
                }
            }
        }
    }

    cout << dp[(1 << k) - 1][terminal[1]] << endl;
}

signed main () {
    ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
    slove();
}

p s ps ps:现在是凌晨四点半,11点的时候喝了一杯咖啡,然后奋战到现在,妈呀真的战斗了个痛快。为什么写这一篇文章,主要是因为我实在看不懂网上的题解,有好多地方不清楚,然后又没有人细讲,所以干脆自己写了一篇,供大家参考。当然斯坦纳树还有许多的应用,我后续再补充,应该不会有第一次学这么难。

以下是使用Kruskal算法求解最小斯坦纳树的Python代码: ```python from collections import defaultdict class UnionFind: def __init__(self, n): self.parent = list(range(n)) self.rank = [0] * n def find(self, x): if self.parent[x] != x: self.parent[x] = self.find(self.parent[x]) return self.parent[x] def union(self, x, y): px, py = self.find(x), self.find(y) if px == py: return False if self.rank[px] < self.rank[py]: px, py = py, px self.parent[py] = px if self.rank[px] == self.rank[py]: self.rank[px] += 1 return True def min_spanning_tree(graph): n = len(graph) uf = UnionFind(n) edges = [] for i in range(n): for j in range(i + 1, n): if graph[i][j] > 0: edges.append((i, j, graph[i][j])) edges.sort(key=lambda x: x[2]) tree = defaultdict(list) for u, v, w in edges: if uf.union(u, v): tree[u].append((v, w)) tree[v].append((u, w)) return tree def min_steiner_tree(n, terminals, edges): # 取出所有的终端点 t = len(terminals) if t == 1: return {terminals[0]: []} # 构造子图 subgraph = [[0] * t for _ in range(t)] for i in range(t): for j in range(i + 1, t): if (terminals[i], terminals[j]) in edges: subgraph[i][j] = subgraph[j][i] = edges[(terminals[i], terminals[j])] # 求解子图的最小生成树 subtree = min_spanning_tree(subgraph) # 构造最小斯坦纳树 steiner = defaultdict(list) for u, v, w in edges: if u in subtree and v in subtree[u]: steiner[u].append((v, w)) steiner[v].append((u, w)) elif v in subtree and u in subtree[v]: steiner[v].append((u, w)) steiner[u].append((v, w)) else: for i in range(t): if (u, terminals[i]) in subtree and (v, terminals[i]) in subtree: steiner[terminals[i]].append((u, w + subtree[(u, terminals[i])]) if (u, terminals[i]) in subtree else (v, w + subtree[(v, terminals[i])])) steiner[u].append((terminals[i], w + subtree[(u, terminals[i])]) if (u, terminals[i]) in subtree else (terminals[i], w + subtree[(v, terminals[i])])) steiner[v].append((terminals[i], w + subtree[(v, terminals[i])]) if (v, terminals[i]) in subtree else (terminals[i], w + subtree[(u, terminals[i])])) return steiner # 示例代码 if __name__ == '__main__': n = 5 terminals = [0, 2, 4] edges = { (0, 1): 3, (0, 2): 1, (1, 2): 1, (1, 3): 2, (2, 3): 8, (2, 4): 1, (3, 4): 2 } steiner = min_steiner_tree(n, terminals, edges) print(steiner) ``` 其中,`min_spanning_tree`函数使用Kruskal算法求解最小生成树,`min_steiner_tree`函数则使用贪心算法构造最小斯坦纳树
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

louisdlee.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值