Skip to content

Commit 9a2b2b7

Browse files
zazabapGiggleLiuclaude
authored
Fix #122: Add SteinerTree model (#192)
* Fix #122: Add SteinerTree model (rebased on main) Clean rebase onto current main. Adds SteinerTree<G, W> with edge-based binary variables, BFS+tree validity checker, Dreyfus-Wagner complexity, full CLI integration (create, example, random), paper entry with CeTZ figure, and 18 unit tests including coverage for all public methods. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Final review fixes: display-name order, random terminals, paper example from fixtures - Move SteinerTree display-name entry to correct alphabetical position - Use seeded Fisher-Yates shuffle for random terminal selection instead of always picking first N vertices - Rewrite paper example to use load-model-example() from canonical examples.json fixtures instead of hand-written values - Regenerate example fixtures to include SteinerTree model Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Derive terminal-terminal edge from fixture data in paper example Replace hardcoded (v2,v4) reference with data-driven lookup from the canonical example, eliminating the last hardcoded instance value. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: random terminal selection and regenerate example fixtures - Add lcg_choose() utility for Fisher-Yates partial shuffle - Use random terminal selection and random edge weights in SteinerTree random generation - Regenerate examples.json to include SteinerTree canonical example - Fix pre-existing formatting issues from main merge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: GiggleLiu <cacate0129@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e2ef439 commit 9a2b2b7

18 files changed

Lines changed: 838 additions & 30 deletions

File tree

docs/paper/reductions.typ

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set],
101101
"ShortestCommonSupersequence": [Shortest Common Supersequence],
102102
"MinimumSumMulticenter": [Minimum Sum Multicenter],
103+
"SteinerTree": [Steiner Tree],
103104
"SubgraphIsomorphism": [Subgraph Isomorphism],
104105
"PartitionIntoTriangles": [Partition Into Triangles],
105106
"FlowShopScheduling": [Flow Shop Scheduling],
@@ -786,6 +787,72 @@ Graph Partitioning is a core NP-hard problem arising in VLSI design, parallel co
786787
]
787788
]
788789
}
790+
#{
791+
let x = load-model-example("SteinerTree")
792+
let nv = graph-num-vertices(x.instance)
793+
let ne = graph-num-edges(x.instance)
794+
let edges = x.instance.graph.inner.edges.map(e => (e.at(0), e.at(1)))
795+
let weights = x.instance.edge_weights
796+
let terminals = x.instance.terminals
797+
let sol = x.optimal.at(0)
798+
let tree-edge-indices = sol.config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i)
799+
let tree-edges = tree-edge-indices.map(i => edges.at(i))
800+
let cost = sol.metric.Valid
801+
// Steiner vertices: in tree but not terminals
802+
let tree-verts = tree-edges.map(e => (e.at(0), e.at(1))).fold((), (acc, pair) => {
803+
let (u, v) = pair
804+
let acc2 = if acc.contains(u) { acc } else { acc + (u,) }
805+
if acc2.contains(v) { acc2 } else { acc2 + (v,) }
806+
})
807+
let steiner-verts = tree-verts.filter(v => not terminals.contains(v))
808+
[
809+
#problem-def("SteinerTree")[
810+
Given an undirected graph $G = (V, E)$ with edge weights $w: E -> RR_(>= 0)$ and a set of terminal vertices $T subset.eq V$ with $|T| >= 2$, find a tree $S = (V_S, E_S)$ in $G$ such that $T subset.eq V_S$, minimizing $sum_(e in E_S) w(e)$. Vertices in $V_S backslash T$ are called _Steiner vertices_.
811+
][
812+
One of Karp's 21 NP-complete problems @karp1972, foundational in network design with applications in telecommunications backbone routing, VLSI chip interconnect, pipeline planning, and phylogenetic tree construction. When $T = V$, the problem reduces to the minimum spanning tree (polynomial). The NP-hardness arises from choosing which Steiner vertices to include.
813+
814+
The best known exact algorithm runs in $O^*(3^(|T|) dot n + 2^(|T|) dot n^2)$ time via Dreyfus--Wagner dynamic programming over terminal subsets @dreyfuswagner1971. Byrka _et al._ achieved a $ln(4) + epsilon approx 1.39$-approximation @byrka2013; the classic 2-approximation uses the minimum spanning tree of the terminal distance graph.
815+
816+
// Find the unique direct terminal-terminal edge (both endpoints in T, not in the optimal tree)
817+
#let terminal-set = terminals
818+
#let direct-tt-edges = edges.enumerate().filter(((i, e)) => {
819+
terminal-set.contains(e.at(0)) and terminal-set.contains(e.at(1)) and not tree-edge-indices.contains(i)
820+
})
821+
#let tt-edge = direct-tt-edges.at(0)
822+
#let tt-idx = tt-edge.at(0)
823+
#let tt-u = tt-edge.at(1).at(0)
824+
#let tt-v = tt-edge.at(1).at(1)
825+
826+
*Example.* Consider $G$ with $n = #nv$ vertices, $m = #ne$ edges, and terminals $T = {#terminals.map(t => $v_#t$).join(", ")}$. The optimal Steiner tree uses edges ${#tree-edges.map(e => $(v_#(e.at(0)), v_#(e.at(1)))$).join(", ")}$ with Steiner vertices ${#steiner-verts.map(v => $v_#v$).join(", ")}$ acting as relay points. The total cost is #tree-edge-indices.map(i => $#(weights.at(i))$).join($+$) $= #cost$. Note the only direct terminal--terminal edge $(v_#tt-u, v_#tt-v)$ has weight #weights.at(tt-idx), equaling the entire Steiner tree cost.
827+
828+
#figure({
829+
// Layout: v0 top-left, v1 top-center, v2 top-right, v3 bottom-center, v4 bottom-right
830+
let verts = ((0, 1.2), (1.2, 1.2), (2.4, 1.2), (1.2, 0), (2.4, 0))
831+
canvas(length: 1cm, {
832+
for (idx, (u, v)) in edges.enumerate() {
833+
let on-tree = tree-edge-indices.contains(idx)
834+
g-edge(verts.at(u), verts.at(v),
835+
stroke: if on-tree { 2pt + graph-colors.at(0) } else { 1pt + luma(200) })
836+
let mx = (verts.at(u).at(0) + verts.at(v).at(0)) / 2
837+
let my = (verts.at(u).at(1) + verts.at(v).at(1)) / 2
838+
let dx = if u == 0 and v == 3 { -0.3 } else if u == 2 and v == 3 { 0.3 } else { 0 }
839+
let dy = if u == 0 and v == 1 { 0.2 } else if u == 1 and v == 2 { 0.2 } else if u == 2 and v == 4 { 0.3 } else { 0 }
840+
draw.content((mx + dx, my + dy), text(7pt, fill: luma(80))[#weights.at(idx)])
841+
}
842+
for (k, pos) in verts.enumerate() {
843+
let is-terminal = terminals.contains(k)
844+
g-node(pos, name: "v" + str(k),
845+
fill: if is-terminal { graph-colors.at(0) } else { white },
846+
stroke: if is-terminal { none } else { 1pt + graph-colors.at(0) },
847+
label: text(fill: if is-terminal { white } else { black })[$v_#k$])
848+
}
849+
})
850+
},
851+
caption: [Steiner tree on #nv vertices with terminals $T = {#terminals.map(t => $v_#t$).join(", ")}$ (filled blue). Steiner vertices #steiner-verts.map(v => $v_#v$).join(", ") (outlined) relay connections. Blue edges form the optimal tree with cost #cost.],
852+
) <fig:steiner-tree>
853+
]
854+
]
855+
}
789856
#problem-def("OptimalLinearArrangement")[
790857
Given an undirected graph $G=(V,E)$ and a non-negative integer $K$, is there a bijection $f: V -> {0, 1, dots, |V|-1}$ such that $sum_({u,v} in E) |f(u) - f(v)| <= K$?
791858
][

docs/paper/references.bib

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,28 @@ @article{alber2004
459459
doi = {10.1016/j.jalgor.2003.10.001}
460460
}
461461

462+
@article{dreyfuswagner1971,
463+
author = {S. E. Dreyfus and R. A. Wagner},
464+
title = {The Steiner Problem in Graphs},
465+
journal = {Networks},
466+
volume = {1},
467+
number = {3},
468+
pages = {195--207},
469+
year = {1971},
470+
doi = {10.1002/net.3230010302}
471+
}
472+
473+
@article{byrka2013,
474+
author = {Jarosław Byrka and Fabrizio Grandoni and Thomas Rothvoß and Laura Sanità},
475+
title = {Steiner Tree Approximation via Iterative Randomized Rounding},
476+
journal = {Journal of the ACM},
477+
volume = {60},
478+
number = {1},
479+
pages = {1--33},
480+
year = {2013},
481+
doi = {10.1145/2432622.2432628}
482+
}
483+
462484
@article{horowitz1974,
463485
author = {Ellis Horowitz and Sartaj Sahni},
464486
title = {Computing Partitions with Applications to the Knapsack Problem},

docs/src/cli.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ pred create MIS --graph 0-1,1-2,2-3 -o problem.json
4545
# Create a weighted instance (variant auto-upgrades to i32)
4646
pred create MIS --graph 0-1,1-2,2-3 --weights 3,1,2,1 -o weighted.json
4747

48+
# Create a Steiner Tree instance
49+
pred create SteinerTree --graph 0-1,0-3,1-2,1-3,2-3,2-4,3-4 --edge-weights 2,5,2,1,5,6,1 --terminals 0,2,4 -o steiner.json
50+
4851
# Or start from a canonical model example
4952
pred create --example MIS/SimpleGraph/i32 -o example.json
5053

@@ -272,6 +275,7 @@ pred create QUBO --matrix "1,0.5;0.5,2" -o qubo.json
272275
pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.json
273276
pred create SpinGlass --graph 0-1,1-2 -o sg.json
274277
pred create MaxCut --graph 0-1,1-2,2-0 -o maxcut.json
278+
pred create SteinerTree --graph 0-1,0-3,1-2,1-3,2-3,2-4,3-4 --edge-weights 2,5,2,1,5,6,1 --terminals 0,2,4 -o steiner.json
275279
pred create Factoring --target 15 --bits-m 4 --bits-n 4 -o factoring.json
276280
pred create Factoring --target 21 --bits-m 3 --bits-n 3 -o factoring2.json
277281
pred create X3C --universe 9 --sets "0,1,2;0,2,4;3,4,5;3,5,7;6,7,8;1,4,6;2,5,8" -o x3c.json

docs/src/reductions/problem_schemas.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,27 @@
636636
}
637637
]
638638
},
639+
{
640+
"name": "SteinerTree",
641+
"description": "Find minimum weight tree connecting terminal vertices",
642+
"fields": [
643+
{
644+
"name": "graph",
645+
"type_name": "G",
646+
"description": "The underlying graph G=(V,E)"
647+
},
648+
{
649+
"name": "edge_weights",
650+
"type_name": "Vec<W>",
651+
"description": "Edge weights w: E -> R"
652+
},
653+
{
654+
"name": "terminals",
655+
"type_name": "Vec<usize>",
656+
"description": "Terminal vertices T that must be connected"
657+
}
658+
]
659+
},
639660
{
640661
"name": "SubgraphIsomorphism",
641662
"description": "Determine if host graph G contains a subgraph isomorphic to pattern graph H",

docs/src/reductions/reduction_graph.json

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,26 @@
491491
"doc_path": "models/graph/struct.SpinGlass.html",
492492
"complexity": "2^num_spins"
493493
},
494+
{
495+
"name": "SteinerTree",
496+
"variant": {
497+
"graph": "SimpleGraph",
498+
"weight": "One"
499+
},
500+
"category": "graph",
501+
"doc_path": "models/graph/struct.SteinerTree.html",
502+
"complexity": "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2"
503+
},
504+
{
505+
"name": "SteinerTree",
506+
"variant": {
507+
"graph": "SimpleGraph",
508+
"weight": "i32"
509+
},
510+
"category": "graph",
511+
"doc_path": "models/graph/struct.SteinerTree.html",
512+
"complexity": "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2"
513+
},
494514
{
495515
"name": "SubgraphIsomorphism",
496516
"variant": {},
@@ -713,7 +733,7 @@
713733
},
714734
{
715735
"source": 21,
716-
"target": 56,
736+
"target": 58,
717737
"overhead": [
718738
{
719739
"field": "num_elements",
@@ -1341,7 +1361,7 @@
13411361
"doc_path": "rules/spinglass_casts/index.html"
13421362
},
13431363
{
1344-
"source": 57,
1364+
"source": 59,
13451365
"target": 12,
13461366
"overhead": [
13471367
{
@@ -1356,7 +1376,7 @@
13561376
"doc_path": "rules/travelingsalesman_ilp/index.html"
13571377
},
13581378
{
1359-
"source": 57,
1379+
"source": 59,
13601380
"target": 49,
13611381
"overhead": [
13621382
{

problemreductions-cli/src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ Flags by problem type:
233233
X3C (ExactCoverBy3Sets) --universe, --sets (3 elements each)
234234
BicliqueCover --left, --right, --biedges, --k
235235
BMF --matrix (0/1), --rank
236+
SteinerTree --graph, --edge-weights, --terminals
236237
CVP --basis, --target-vec [--bounds]
237238
OptimalLinearArrangement --graph, --bound
238239
RuralPostman (RPP) --graph, --edge-weights, --required-edges, --bound
@@ -367,6 +368,9 @@ pub struct CreateArgs {
367368
/// Variable bounds for CVP as "lower,upper" (e.g., "-10,10") [default: -10,10]
368369
#[arg(long, allow_hyphen_values = true)]
369370
pub bounds: Option<String>,
371+
/// Terminal vertices for SteinerTree (comma-separated indices, e.g., "0,2,4")
372+
#[arg(long)]
373+
pub terminals: Option<String>,
370374
/// Tree edge list for IsomorphicSpanningTree (e.g., 0-1,1-2,2-3)
371375
#[arg(long)]
372376
pub tree: Option<String>,

problemreductions-cli/src/commands/create.rs

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use problemreductions::topology::{
1818
UnitDiskGraph,
1919
};
2020
use serde::Serialize;
21-
use std::collections::BTreeMap;
21+
use std::collections::{BTreeMap, BTreeSet};
2222

2323
/// Check if all data flags are None (no problem-specific input provided).
2424
fn all_data_flags_empty(args: &CreateArgs) -> bool {
@@ -51,6 +51,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool {
5151
&& args.basis.is_none()
5252
&& args.target_vec.is_none()
5353
&& args.bounds.is_none()
54+
&& args.terminals.is_none()
5455
&& args.tree.is_none()
5556
&& args.required_edges.is_none()
5657
&& args.bound.is_none()
@@ -239,6 +240,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
239240
}
240241
"PartitionIntoTriangles" => "--graph 0-1,1-2,0-2",
241242
"Factoring" => "--target 15 --m 4 --n 4",
243+
"SteinerTree" => "--graph 0-1,1-2,1-3,3-4 --edge-weights 2,2,1,1 --terminals 0,2,4",
242244
"OptimalLinearArrangement" => "--graph 0-1,1-2,2-3 --bound 5",
243245
"MinimumFeedbackArcSet" => "--arcs \"0>1,1>2,2>0\"",
244246
"RuralPostman" => {
@@ -360,6 +362,19 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
360362
create_vertex_weight_problem(args, canonical, graph_type, &resolved_variant)?
361363
}
362364

365+
// SteinerTree (graph + edge weights + terminals)
366+
"SteinerTree" => {
367+
let (graph, _) = parse_graph(args).map_err(|e| {
368+
anyhow::anyhow!(
369+
"{e}\n\nUsage: pred create SteinerTree --graph 0-1,1-2,1-3,3-4 --edge-weights 2,2,1,1 --terminals 0,2,4"
370+
)
371+
})?;
372+
let edge_weights = parse_edge_weights(args, graph.num_edges())?;
373+
let terminals = parse_terminals(args, graph.num_vertices())?;
374+
let data = ser(SteinerTree::new(graph, edge_weights, terminals))?;
375+
(data, resolved_variant.clone())
376+
}
377+
363378
// Graph partitioning (graph only, no weights)
364379
"GraphPartitioning" => {
365380
let (graph, _) = parse_graph(args).map_err(|e| {
@@ -1298,6 +1313,32 @@ fn parse_vertex_weights(args: &CreateArgs, num_vertices: usize) -> Result<Vec<i3
12981313
}
12991314
}
13001315

1316+
/// Parse `--terminals` as comma-separated vertex indices.
1317+
fn parse_terminals(args: &CreateArgs, num_vertices: usize) -> Result<Vec<usize>> {
1318+
let s = args
1319+
.terminals
1320+
.as_deref()
1321+
.ok_or_else(|| anyhow::anyhow!("SteinerTree requires --terminals (e.g., \"0,2,4\")"))?;
1322+
let terminals: Vec<usize> = s
1323+
.split(',')
1324+
.map(|t| t.trim().parse::<usize>())
1325+
.collect::<std::result::Result<Vec<_>, _>>()
1326+
.context("invalid terminal index")?;
1327+
for &t in &terminals {
1328+
anyhow::ensure!(
1329+
t < num_vertices,
1330+
"terminal {t} >= num_vertices ({num_vertices})"
1331+
);
1332+
}
1333+
let distinct_terminals: BTreeSet<_> = terminals.iter().copied().collect();
1334+
anyhow::ensure!(
1335+
distinct_terminals.len() == terminals.len(),
1336+
"terminals must be distinct"
1337+
);
1338+
anyhow::ensure!(terminals.len() >= 2, "at least 2 terminals required");
1339+
Ok(terminals)
1340+
}
1341+
13011342
/// Parse `--edge-weights` as edge weights (i32), defaulting to all 1s.
13021343
fn parse_edge_weights(args: &CreateArgs, num_edges: usize) -> Result<Vec<i32>> {
13031344
match &args.edge_weights {
@@ -1680,6 +1721,34 @@ fn create_random(
16801721
(data, variant)
16811722
}
16821723

1724+
// SteinerTree
1725+
"SteinerTree" => {
1726+
anyhow::ensure!(
1727+
num_vertices >= 2,
1728+
"SteinerTree random generation requires --num-vertices >= 2"
1729+
);
1730+
let edge_prob = args.edge_prob.unwrap_or(0.5);
1731+
if !(0.0..=1.0).contains(&edge_prob) {
1732+
bail!("--edge-prob must be between 0.0 and 1.0");
1733+
}
1734+
let mut state = util::lcg_init(args.seed);
1735+
let graph = util::create_random_graph(num_vertices, edge_prob, Some(state));
1736+
// Advance state past the graph generation
1737+
for _ in 0..num_vertices * num_vertices {
1738+
util::lcg_step(&mut state);
1739+
}
1740+
let edge_weights: Vec<i32> = (0..graph.num_edges())
1741+
.map(|_| (util::lcg_step(&mut state) * 9.0) as i32 + 1)
1742+
.collect();
1743+
let num_terminals = std::cmp::max(2, num_vertices * 2 / 5);
1744+
let terminals = util::lcg_choose(&mut state, num_vertices, num_terminals);
1745+
let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]);
1746+
(
1747+
ser(SteinerTree::new(graph, edge_weights, terminals))?,
1748+
variant,
1749+
)
1750+
}
1751+
16831752
// SpinGlass
16841753
"SpinGlass" => {
16851754
let edge_prob = args.edge_prob.unwrap_or(0.5);
@@ -1730,7 +1799,7 @@ fn create_random(
17301799
"Random generation is not supported for {canonical}. \
17311800
Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \
17321801
MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, TravelingSalesman, \
1733-
OptimalLinearArrangement, HamiltonianPath)"
1802+
SteinerTree, OptimalLinearArrangement, HamiltonianPath)"
17341803
),
17351804
};
17361805

problemreductions-cli/src/util.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,20 @@ pub fn create_random_float_positions(num_vertices: usize, seed: Option<u64>) ->
214214
.collect()
215215
}
216216

217+
/// Choose `k` distinct elements from `0..n` using Fisher-Yates partial shuffle.
218+
/// Returns a sorted vector of chosen indices.
219+
pub fn lcg_choose(state: &mut u64, n: usize, k: usize) -> Vec<usize> {
220+
assert!(k <= n, "k={k} exceeds n={n}");
221+
let mut indices: Vec<usize> = (0..n).collect();
222+
for i in 0..k {
223+
let j = i + (lcg_step(state) * (n - i) as f64) as usize % (n - i);
224+
indices.swap(i, j);
225+
}
226+
let mut chosen: Vec<usize> = indices[..k].to_vec();
227+
chosen.sort_unstable();
228+
chosen
229+
}
230+
217231
// ---------------------------------------------------------------------------
218232
// Small shared helpers
219233
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)