Steiner木問題に対する定式化とソルバー

準備

import networkx as nx
from gurobipy import Model, quicksum, GRB
# from mypulp import Model, quicksum, GRB
import matplotlib.pyplot as plt
import random

Steiner木問題に対する定式化

Steiner木問題は,最小木問題の拡張であり,$NP$-困難である. 最小木問題が,すべての点を通る全域木を求めるのに対して,Stiner木問題では,与えられた点(ターミナルと呼ばれる)の部分集合を繋ぐ木を求める. ターミナル以外の点は使っても良いし,使わなくても良い.これが問題を難しくする.定義は以下のようになる.

無向グラフ $G(V,E)$ と枝上の費用関数 $c: E \rightarrow \mathbf{R}$ が与えられたとき, 点の部分集合 $VT \subseteq V$ と枝の部分集合 $ET \subseteq E$ から構成される連結グラフで

$$ \sum_{e \in ET} c_e $$

を最小にするものを求める.ただし,特定の点 $0$ は必ず $VT$ に含まれるものと仮定する.

最小木問題に対する多品種流定式化を用いると,Steiner木問題を混合整数最適化問題として定式化できる.

枝 $e \in E$ がSteiner木に含まれるとき $1$,それ以外のとき $0$ を表す $0$-$1$ 変数 $x_e$ を導入する. 点$0$ から点$k$ へ流れるフローが,枝$(i,j)$ 上を $i$ から $j$ の向きで通過する量を表す実数変数を $f_{ij}^k$ と したとき,多品種流定式化は,以下のようになる.

$$ \begin{array}{l l l } minimize & \sum_{e \in E} c_e x_e & \\ s.t. & \sum_{(j,i) \in \delta (\{i\})} f_{ji}^k -\sum_{(i,j) \in \delta (\{i\})} f_{ij}^k = \left\{ \begin{array}{l } -1 \\ 0 \\ 1 \\ \end{array} \right. & \begin{array}{l } i=0, \forall k \in VT \setminus \{0\} \\ \forall i \in V \setminus \{0,k\}, k \in V \setminus \{0\} \\ i=k, \forall k \in VT \setminus \{0\} \\ \end{array} \\ & f_{ij}^k +f_{ji}^{k'} \leq x_e & \forall k,k' \in V \setminus \{0\}, e=(i,j) \in E \\ & x_e \in \{0,1\} & \forall e \in E \\ & f_{ij}^k \geq 0, f_{ji}^k \geq 0 & \forall k \in V \setminus \{0\}, e=(i,j) \in E \end{array} $$

ランダムに枝上の費用(重み)を設定した格子グラフに対して,四隅の点をターミナルとしたSteiner木問題を解いてみる.

m, n = 5, 5
lb, ub = 1, 20
random.seed(1)
G = nx.grid_2d_graph(m, n)
pos = {(i, j): (i, j) for (i, j) in G.nodes()}
for (i, j) in G.edges():
    G[i][j]["weight"] = random.randint(lb, ub)

Terminal = [(0, 0), (m - 1, 0), (0, n - 1), (m - 1, n - 1)]
root = Terminal[0]
model = Model()
x, f = {}, {}
for (i, j) in G.edges():
    x[i, j] = model.addVar(vtype="B", name=f"x[{i},{j}]")
    for k in Terminal[1:]:
        f[i, j, k] = model.addVar(vtype="C", name=f"f[{i},{j},{k}]")
        f[j, i, k] = model.addVar(vtype="C", name=f"f[{j},{i},{k}]")
model.update()
for k in Terminal[1:]:
    for i in G.nodes():
        if i == root:
            model.addConstr(
                quicksum(f[i, j, k] for j in G[i]) - quicksum(f[j, i, k] for j in G[i])
                == 1
            )
        elif i == k:
            model.addConstr(
                quicksum(f[i, j, k] for j in G[i]) - quicksum(f[j, i, k] for j in G[i])
                == -1
            )
        else:
            model.addConstr(
                quicksum(f[i, j, k] for j in G[i]) - quicksum(f[j, i, k] for j in G[i])
                == 0
            )
for (i, j) in G.edges():
    for k in Terminal[1:]:
        model.addConstr(f[i, j, k] + f[j, i, k] <= x[i, j])
model.setObjective(
    quicksum(G[i][j]["weight"] * x[i, j] for (i, j) in G.edges()), GRB.MINIMIZE
)
model.optimize()
edges = []
print(model.ObjVal)
for (i, j) in G.edges():
    if x[i, j].X > 0.01:
        edges.append((i, j))
Gurobi Optimizer version 9.1.1 build v9.1.1rc0 (mac64)
Thread count: 8 physical cores, 16 logical processors, using up to 16 threads
Optimize a model with 195 rows, 280 columns and 840 nonzeros
Model fingerprint: 0xa9abb313
Variable types: 240 continuous, 40 integer (40 binary)
Coefficient statistics:
  Matrix range     [1e+00, 1e+00]
  Objective range  [1e+00, 2e+01]
  Bounds range     [1e+00, 1e+00]
  RHS range        [1e+00, 1e+00]
Found heuristic solution: objective 288.0000000
Presolve removed 18 rows and 30 columns
Presolve time: 0.00s
Presolved: 177 rows, 250 columns, 756 nonzeros
Variable types: 210 continuous, 40 integer (40 binary)

Root relaxation: objective 7.300000e+01, 106 iterations, 0.00 seconds

    Nodes    |    Current Node    |     Objective Bounds      |     Work
 Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time

     0     0   73.00000    0   10  288.00000   73.00000  74.7%     -    0s
H    0     0                     101.0000000   73.00000  27.7%     -    0s
H    0     0                      98.0000000   76.00000  22.4%     -    0s
     0     0   78.00000    0   16   98.00000   78.00000  20.4%     -    0s
H    0     0                      88.0000000   78.00000  11.4%     -    0s
H    0     0                      81.0000000   78.00000  3.70%     -    0s

Cutting planes:
  Gomory: 14

Explored 1 nodes (129 simplex iterations) in 0.02 seconds
Thread count was 16 (of 16 available processors)

Solution count 5: 81 88 98 ... 288

Optimal solution found (tolerance 1.00e-04)
Best objective 8.100000000000e+01, best bound 8.100000000000e+01, gap 0.0000%
81.0
plt.figure()
nx.draw(G, pos=pos, node_size=100)
edge_labels = {}
for (i, j) in G.edges():
    edge_labels[i, j] = f"{ G[i][j]['weight'] }"
nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels)
nx.draw(G, pos=pos, width=5, edgelist=edges, edge_color="orange")
plt.show()

Steiner木問題に対する近似解法

networkXを用いてSteiner木問題の近似解をえることができる.

from networkx.algorithms.approximation.steinertree import steiner_tree
steiner = steiner_tree(G, terminal_nodes=Terminal)
total = 0
for (i, j) in steiner.edges():
    total += G[i][j]["weight"]
print(total)
85
plt.figure()
nx.draw(G, pos=pos, with_labels=False, node_size=100)
nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels)
nx.draw(G, pos=pos, width=5, edgelist=steiner.edges(), edge_color="orange", node_size=0)
plt.show()

賞金収集有向Steiner木問題

ここでは,応用(枝の向きによって費用が異なる場合もあるので)を意識して有向グラフ上で考える. 有向グラフ $D(N,A)$, 点上の利得(賞金)関数 $p: N \rightarrow \mathbf{R}$, 枝上の費用関数 $c: S \rightarrow \mathbf{R}$ が与えられたとき, 点の部分集合 $NT \subseteq N$ と枝の部分集合 $AT \subseteq A$ から構成される連結グラフで

$$ \sum_{i \in NT} p_i - \sum_{(i,j) \in AT} c_{ij} $$

を最大にするものを求める.ただし,特定の点 $0$ は必ず $NT$ に含まれるものと仮定する.

最小木問題に対する多品種流定式化を用いると,賞金収集有向Steiner木問題を混合整数最適化問題として定式化できる.

有向グラフ $D=(N,A)$ 上で定式化する.有向枝 $(i,j) \in A$ がSteiner木に含まれるとき $1$,それ以外のとき $0$ を表す $0$-$1$ 変数 $x_{ij}$ を導入する. さらに,点 $i$ がSteiner木に含まれるとき $1$ の$0$-$1$ 変数 $y_{i}$ を準備する. 点$0$ から点$k$ へ流れるフローが,枝$(i,j)$ 上を通過する量を表す実数変数を $f_{ij}^k$ と したとき,賞金収集有向Steiner木問題に対する多品種流定式化は,以下のようになる.

$$ \begin{array}{l l l } maximize & \sum_{i \in N \setminus \{0\}} p_i y_i - \sum_{(i,j) \in A} c_{ij} x_{ij} & \\ s.t. & \sum_{j} x_{ij} = y_i & \forall i \in N \setminus \{0\} \\ & \sum_{j} f_{ji}^k -\sum_{j} f_{ij}^k = \left\{ \begin{array}{l } -y_k \\ 0 \\ y_k \\ \end{array} \right. & \begin{array}{l } i=0, \forall k \in N \setminus \{0\} \\ \forall i \in N \setminus \{0,k\}, k \in V \setminus \{0\} \\ i=k, \forall k \in N \setminus \{0\} \\ \end{array} \\ & f_{ij}^k \leq x_{ij} & \forall k \in N \setminus \{0\},(i,j) \in A \\ & x_{ij} \in \{0,1\} & \forall (i,j) \in A \\ & y_i \in \{0,1\} & \forall i \in N \\ & f_{ij}^k \geq 0 & \forall k \in V \setminus \{0\}, (i,j) \in A \\ \end{array} $$
m, n = 5, 5
lb, ub = 1, 20
plb, pub = 1, 20
root = (0, 0)
G = nx.grid_2d_graph(m, n)
pos = {(i, j): (i, j) for (i, j) in G.nodes()}
for (i, j) in G.edges():
    G[i][j]["weight"] = random.randint(lb, ub)
for i in G.nodes():
    if i != root:
        G.nodes[i]["prize"] = random.randint(plb, pub)
D = G.to_directed()
model = Model()
x, y, f = {}, {}, {}
for (i, j) in D.edges():
    x[i, j] = model.addVar(vtype="B", name=f"x[{i},{j}]")
    for k in D.nodes():
        if k != root:
            f[i, j, k] = model.addVar(vtype="C", name=f"f[{i},{j},{k}]")
for i in D.nodes():
    if i != root:
        y[i] = model.addVar(vtype="B", name=f"y[{i}]")
model.update()
for i in D.nodes():
    if i != root:
        model.addConstr(quicksum(x[j, i] for j in D[i]) == y[i])
for k in D.nodes():
    if k == root:
        continue
    for i in D.nodes():
        if i == root:
            model.addConstr(
                quicksum(f[i, j, k] for j in D[i]) - quicksum(f[j, i, k] for j in D[i])
                == y[k]
            )
        elif i == k:
            model.addConstr(
                quicksum(f[i, j, k] for j in D[i]) - quicksum(f[j, i, k] for j in D[i])
                == -y[k]
            )
        else:
            model.addConstr(
                quicksum(f[i, j, k] for j in D[i]) - quicksum(f[j, i, k] for j in D[i])
                == 0
            )
for (i, j) in D.edges():
    for k in D.nodes:
        if k != root:
            model.addConstr(f[i, j, k] <= x[i, j])
model.setObjective(
    quicksum(D.nodes[i]["prize"] * y[i] for i in D.nodes() if i != root)
    - quicksum(D[i][j]["weight"] * x[i, j] for (i, j) in D.edges()),
    GRB.MAXIMIZE,
)
model.optimize()
edges = []
print(model.ObjVal)
for (i, j) in D.edges():
    if x[i, j].X > 0.01:
        edges.append((i, j))
Gurobi Optimizer version 9.1.1 build v9.1.1rc0 (mac64)
Thread count: 8 physical cores, 16 logical processors, using up to 16 threads
Optimize a model with 2544 rows, 2024 columns and 7830 nonzeros
Model fingerprint: 0x70599446
Variable types: 1920 continuous, 104 integer (104 binary)
Coefficient statistics:
  Matrix range     [1e+00, 1e+00]
  Objective range  [1e+00, 2e+01]
  Bounds range     [1e+00, 1e+00]
  RHS range        [0e+00, 0e+00]
Found heuristic solution: objective -0.0000000
Presolve removed 257 rows and 297 columns
Presolve time: 0.02s
Presolved: 2287 rows, 1727 columns, 6964 nonzeros
Found heuristic solution: objective 12.0000000
Variable types: 1624 continuous, 103 integer (103 binary)

Root relaxation: objective 9.300000e+01, 342 iterations, 0.00 seconds

    Nodes    |    Current Node    |     Objective Bounds      |     Work
 Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time

*    0     0               0      93.0000000   93.00000  0.00%     -    0s

Explored 0 nodes (342 simplex iterations) in 0.03 seconds
Thread count was 16 (of 16 available processors)

Solution count 3: 93 12 -0 

Optimal solution found (tolerance 1.00e-04)
Best objective 9.300000000000e+01, best bound 9.300000000000e+01, gap 0.0000%
93.0
plt.figure()
nx.draw(G, pos=pos, with_labels=False, node_size=1000, node_color="yellow")
edge_labels = {}
for (i, j) in G.edges():
    edge_labels[i, j] = f"{ G[i][j]['weight'] }"
node_labels = {}
G.nodes[root]["prize"] = 0
for i in G.nodes():
    node_labels[i] = G.nodes[i]["prize"]
nx.draw_networkx_labels(G, pos, labels=node_labels)
nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels)
nx.draw(D, pos=pos, width=5, edgelist=edges, edge_color="red", node_size=0)
plt.show()