Skip to content

Commit d0035bf

Browse files
zazabapclaude
andcommitted
Implement #122: Add SteinerTree model
- Add SteinerTree<G, W> problem model (edge-based minimization) - Register in graph/mod.rs, prelude, CLI dispatch/aliases/create - Add --terminals CLI flag for pred create SteinerTree - 12 unit tests: creation, evaluation, brute-force, serialization - Add paper entry with Dreyfus-Wagner complexity and example figure - Regenerate problem_schemas.json and reduction_graph.json Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3aadb35 commit d0035bf

12 files changed

Lines changed: 543 additions & 34 deletions

File tree

docs/paper/reductions.typ

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"BicliqueCover": [Biclique Cover],
5252
"BinPacking": [Bin Packing],
5353
"ClosestVectorProblem": [Closest Vector Problem],
54+
"SteinerTree": [Steiner Tree],
5455
)
5556

5657
// Definition label: "def:<ProblemName>" — each definition block must have a matching label
@@ -455,6 +456,45 @@ One of the most intensely studied NP-hard problems, with applications in logisti
455456
caption: [Complete graph $K_4$ with weighted edges. The optimal tour $v_0 -> v_1 -> v_2 -> v_3 -> v_0$ (blue edges) has cost 6.],
456457
) <fig:k4-tsp>
457458
]
459+
#problem-def("SteinerTree")[
460+
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_.
461+
][
462+
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.
463+
464+
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.
465+
466+
*Example.* Consider $G$ with $n = 5$ vertices, $m = 7$ edges, and terminals $T = {v_0, v_2, v_4}$. The optimal Steiner tree uses edges ${(v_0, v_1), (v_1, v_2), (v_1, v_3), (v_3, v_4)}$ with Steiner vertices ${v_1, v_3}$ acting as relay points. The total cost is $w(v_0, v_1) + w(v_1, v_2) + w(v_1, v_3) + w(v_3, v_4) = 2 + 2 + 1 + 1 = 6$. Note the only direct terminal--terminal edge $(v_2, v_4)$ has weight 6, equaling the entire Steiner tree cost.
467+
468+
#figure({
469+
// Layout: v0 top-left, v1 top-center, v2 top-right, v3 bottom-center, v4 bottom-right
470+
let verts = ((0, 1.2), (1.2, 1.2), (2.4, 1.2), (1.2, 0), (2.4, 0))
471+
let all-edges = ((0,1),(0,3),(1,2),(1,3),(2,3),(2,4),(3,4))
472+
let tree-edges = ((0,1),(1,2),(1,3),(3,4))
473+
let weights = ("2", "5", "2", "1", "5", "6", "1")
474+
let terminals = (0, 2, 4)
475+
canvas(length: 1cm, {
476+
for (idx, (u, v)) in all-edges.enumerate() {
477+
let on-tree = tree-edges.any(t => (t.at(0) == u and t.at(1) == v) or (t.at(0) == v and t.at(1) == u))
478+
g-edge(verts.at(u), verts.at(v),
479+
stroke: if on-tree { 2pt + graph-colors.at(0) } else { 1pt + luma(200) })
480+
let mx = (verts.at(u).at(0) + verts.at(v).at(0)) / 2
481+
let my = (verts.at(u).at(1) + verts.at(v).at(1)) / 2
482+
let dx = if u == 0 and v == 3 { -0.3 } else if u == 2 and v == 3 { 0.3 } else { 0 }
483+
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 }
484+
draw.content((mx + dx, my + dy), text(7pt, fill: luma(80))[#weights.at(idx)])
485+
}
486+
for (k, pos) in verts.enumerate() {
487+
let is-terminal = terminals.any(t => t == k)
488+
g-node(pos, name: "v" + str(k),
489+
fill: if is-terminal { graph-colors.at(0) } else { white },
490+
stroke: if is-terminal { none } else { 1pt + graph-colors.at(0) },
491+
label: text(fill: if is-terminal { white } else { black })[$v_#k$])
492+
}
493+
})
494+
},
495+
caption: [Steiner tree on 5 vertices with terminals $T = {v_0, v_2, v_4}$ (filled blue). Steiner vertices $v_1, v_3$ (outlined) relay connections. Blue edges form the optimal tree with cost 6.],
496+
) <fig:steiner-tree>
497+
]
458498
#problem-def("MaximumClique")[
459499
Given $G = (V, E)$, find $K subset.eq V$ maximizing $|K|$ such that all pairs in $K$ are adjacent: $forall u, v in K: (u, v) in E$. Equivalent to MIS on the complement graph $overline(G)$.
460500
][

docs/paper/references.bib

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,24 @@ @article{alber2004
366366
doi = {10.1016/j.jalgor.2003.10.001}
367367
}
368368

369+
@article{dreyfuswagner1971,
370+
author = {S. E. Dreyfus and R. A. Wagner},
371+
title = {The Steiner Problem in Graphs},
372+
journal = {Networks},
373+
volume = {1},
374+
number = {3},
375+
pages = {195--207},
376+
year = {1971},
377+
doi = {10.1002/net.3230010302}
378+
}
379+
380+
@article{byrka2013,
381+
author = {Jarosław Byrka and Fabrizio Grandoni and Thomas Rothvoß and Laura Sanità},
382+
title = {Steiner Tree Approximation via Iterative Randomized Rounding},
383+
journal = {Journal of the ACM},
384+
volume = {60},
385+
number = {1},
386+
pages = {1--33},
387+
year = {2013},
388+
doi = {10.1145/2432622.2432628}
389+
}

docs/src/reductions/problem_schemas.json

Lines changed: 23 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,6 @@
88
"type_name": "Vec<Vec<bool>>",
99
"description": "Target boolean matrix A"
1010
},
11-
{
12-
"name": "m",
13-
"type_name": "usize",
14-
"description": "Number of rows"
15-
},
16-
{
17-
"name": "n",
18-
"type_name": "usize",
19-
"description": "Number of columns"
20-
},
2111
{
2212
"name": "k",
2313
"type_name": "usize",
@@ -75,11 +65,6 @@
7565
"name": "circuit",
7666
"type_name": "Circuit",
7767
"description": "The boolean circuit"
78-
},
79-
{
80-
"name": "variables",
81-
"type_name": "Vec<String>",
82-
"description": "Circuit variable names"
8368
}
8469
]
8570
},
@@ -337,24 +322,9 @@
337322
"description": "Minimize color changes in paint shop sequence",
338323
"fields": [
339324
{
340-
"name": "sequence_indices",
341-
"type_name": "Vec<usize>",
342-
"description": "Car sequence as indices"
343-
},
344-
{
345-
"name": "car_labels",
325+
"name": "sequence",
346326
"type_name": "Vec<String>",
347-
"description": "Unique car labels"
348-
},
349-
{
350-
"name": "is_first",
351-
"type_name": "Vec<bool>",
352-
"description": "First occurrence flags"
353-
},
354-
{
355-
"name": "num_cars",
356-
"type_name": "usize",
357-
"description": "Number of unique cars"
327+
"description": "Car labels (each must appear exactly twice)"
358328
}
359329
]
360330
},
@@ -411,6 +381,27 @@
411381
}
412382
]
413383
},
384+
{
385+
"name": "SteinerTree",
386+
"description": "Find minimum weight tree connecting terminal vertices",
387+
"fields": [
388+
{
389+
"name": "graph",
390+
"type_name": "G",
391+
"description": "The underlying graph G=(V,E)"
392+
},
393+
{
394+
"name": "edge_weights",
395+
"type_name": "Vec<W>",
396+
"description": "Edge weights w: E -> R"
397+
},
398+
{
399+
"name": "terminals",
400+
"type_name": "Vec<usize>",
401+
"description": "Terminal vertices T that must be connected"
402+
}
403+
]
404+
},
414405
{
415406
"name": "TravelingSalesman",
416407
"description": "Find minimum weight Hamiltonian cycle in a graph (Traveling Salesman Problem)",

docs/src/reductions/reduction_graph.json

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,26 @@
357357
"doc_path": "models/graph/struct.SpinGlass.html",
358358
"complexity": "2^num_spins"
359359
},
360+
{
361+
"name": "SteinerTree",
362+
"variant": {
363+
"graph": "SimpleGraph",
364+
"weight": "One"
365+
},
366+
"category": "graph",
367+
"doc_path": "models/graph/struct.SteinerTree.html",
368+
"complexity": "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2"
369+
},
370+
{
371+
"name": "SteinerTree",
372+
"variant": {
373+
"graph": "SimpleGraph",
374+
"weight": "i32"
375+
},
376+
"category": "graph",
377+
"doc_path": "models/graph/struct.SteinerTree.html",
378+
"complexity": "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2"
379+
},
360380
{
361381
"name": "TravelingSalesman",
362382
"variant": {
@@ -1201,7 +1221,7 @@
12011221
"doc_path": "rules/spinglass_casts/index.html"
12021222
},
12031223
{
1204-
"source": 39,
1224+
"source": 41,
12051225
"target": 8,
12061226
"overhead": [
12071227
{

problemreductions-cli/src/cli.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,9 @@ pub struct CreateArgs {
326326
/// Variable bounds for CVP as "lower,upper" (e.g., "-10,10") [default: -10,10]
327327
#[arg(long, allow_hyphen_values = true)]
328328
pub bounds: Option<String>,
329+
/// Terminal vertices for SteinerTree (comma-separated indices, e.g., "0,2,4")
330+
#[arg(long)]
331+
pub terminals: Option<String>,
329332
}
330333

331334
#[derive(clap::Args)]

problemreductions-cli/src/commands/create.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool {
4545
&& args.basis.is_none()
4646
&& args.target_vec.is_none()
4747
&& args.bounds.is_none()
48+
&& args.terminals.is_none()
4849
}
4950

5051
fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str {
@@ -83,6 +84,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
8384
"SpinGlass" => "--graph 0-1,1-2 --couplings 1,1",
8485
"KColoring" => "--graph 0-1,1-2,2-0 --k 3",
8586
"Factoring" => "--target 15 --m 4 --n 4",
87+
"SteinerTree" => "--graph 0-1,1-2,1-3,3-4 --edge-weights 2,2,1,1 --terminals 0,2,4",
8688
_ => "",
8789
}
8890
}
@@ -193,6 +195,19 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
193195
create_vertex_weight_problem(args, canonical, graph_type, &resolved_variant)?
194196
}
195197

198+
// SteinerTree (graph + edge weights + terminals)
199+
"SteinerTree" => {
200+
let (graph, _) = parse_graph(args).map_err(|e| {
201+
anyhow::anyhow!(
202+
"{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"
203+
)
204+
})?;
205+
let edge_weights = parse_edge_weights(args, graph.num_edges())?;
206+
let terminals = parse_terminals(args, graph.num_vertices())?;
207+
let data = ser(SteinerTree::new(graph, edge_weights, terminals))?;
208+
(data, resolved_variant.clone())
209+
}
210+
196211
// Graph problems with edge weights
197212
"MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
198213
let (graph, _) = parse_graph(args).map_err(|e| {
@@ -611,6 +626,27 @@ fn parse_vertex_weights(args: &CreateArgs, num_vertices: usize) -> Result<Vec<i3
611626
}
612627
}
613628

629+
/// Parse `--terminals` as comma-separated vertex indices.
630+
fn parse_terminals(args: &CreateArgs, num_vertices: usize) -> Result<Vec<usize>> {
631+
let s = args
632+
.terminals
633+
.as_deref()
634+
.ok_or_else(|| anyhow::anyhow!("SteinerTree requires --terminals (e.g., \"0,2,4\")"))?;
635+
let terminals: Vec<usize> = s
636+
.split(',')
637+
.map(|t| t.trim().parse::<usize>())
638+
.collect::<std::result::Result<Vec<_>, _>>()
639+
.context("invalid terminal index")?;
640+
for &t in &terminals {
641+
anyhow::ensure!(
642+
t < num_vertices,
643+
"terminal {t} >= num_vertices ({num_vertices})"
644+
);
645+
}
646+
anyhow::ensure!(terminals.len() >= 2, "at least 2 terminals required");
647+
Ok(terminals)
648+
}
649+
614650
/// Parse `--edge-weights` as edge weights (i32), defaulting to all 1s.
615651
fn parse_edge_weights(args: &CreateArgs, num_edges: usize) -> Result<Vec<i32>> {
616652
match &args.edge_weights {
@@ -893,6 +929,23 @@ fn create_random(
893929
(data, variant)
894930
}
895931

932+
// SteinerTree
933+
"SteinerTree" => {
934+
let edge_prob = args.edge_prob.unwrap_or(0.5);
935+
if !(0.0..=1.0).contains(&edge_prob) {
936+
bail!("--edge-prob must be between 0.0 and 1.0");
937+
}
938+
let graph = util::create_random_graph(num_vertices, edge_prob, args.seed);
939+
let edge_weights = vec![1i32; graph.num_edges()];
940+
let num_terminals = std::cmp::max(2, num_vertices * 2 / 5);
941+
let terminals: Vec<usize> = (0..num_terminals).collect();
942+
let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]);
943+
(
944+
ser(SteinerTree::new(graph, edge_weights, terminals))?,
945+
variant,
946+
)
947+
}
948+
896949
// SpinGlass
897950
"SpinGlass" => {
898951
let edge_prob = args.edge_prob.unwrap_or(0.5);

problemreductions-cli/src/dispatch.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ pub fn load_problem(
213213
"MaxCut" => deser_opt::<MaxCut<SimpleGraph, i32>>(data),
214214
"MaximalIS" => deser_opt::<MaximalIS<SimpleGraph, i32>>(data),
215215
"TravelingSalesman" => deser_opt::<TravelingSalesman<SimpleGraph, i32>>(data),
216+
"SteinerTree" => deser_opt::<SteinerTree<SimpleGraph, i32>>(data),
216217
"KColoring" => match variant.get("k").map(|s| s.as_str()) {
217218
Some("K3") => deser_sat::<KColoring<K3, SimpleGraph>>(data),
218219
_ => deser_sat::<KColoring<KN, SimpleGraph>>(data),
@@ -269,6 +270,7 @@ pub fn serialize_any_problem(
269270
"MaxCut" => try_ser::<MaxCut<SimpleGraph, i32>>(any),
270271
"MaximalIS" => try_ser::<MaximalIS<SimpleGraph, i32>>(any),
271272
"TravelingSalesman" => try_ser::<TravelingSalesman<SimpleGraph, i32>>(any),
273+
"SteinerTree" => try_ser::<SteinerTree<SimpleGraph, i32>>(any),
272274
"KColoring" => match variant.get("k").map(|s| s.as_str()) {
273275
Some("K3") => try_ser::<KColoring<K3, SimpleGraph>>(any),
274276
_ => try_ser::<KColoring<KN, SimpleGraph>>(any),

problemreductions-cli/src/problem_name.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub const ALIASES: &[(&str, &str)] = &[
2222
("BP", "BinPacking"),
2323
("CVP", "ClosestVectorProblem"),
2424
("MaxMatching", "MaximumMatching"),
25+
("ST", "SteinerTree"),
2526
];
2627

2728
/// Resolve a short alias to the canonical problem name.
@@ -52,6 +53,7 @@ pub fn resolve_alias(input: &str) -> String {
5253
"bicliquecover" => "BicliqueCover".to_string(),
5354
"bp" | "binpacking" => "BinPacking".to_string(),
5455
"cvp" | "closestvectorproblem" => "ClosestVectorProblem".to_string(),
56+
"st" | "steinertree" | "steiner" => "SteinerTree".to_string(),
5557
_ => input.to_string(), // pass-through for exact names
5658
}
5759
}

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ pub mod prelude {
4141
pub use crate::models::graph::{BicliqueCover, SpinGlass};
4242
pub use crate::models::graph::{
4343
KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching,
44-
MinimumDominatingSet, MinimumVertexCover, TravelingSalesman,
44+
MinimumDominatingSet, MinimumVertexCover, SteinerTree, TravelingSalesman,
4545
};
4646
pub use crate::models::misc::{BinPacking, Factoring, PaintShop};
4747
pub use crate::models::set::{MaximumSetPacking, MinimumSetCovering};

src/models/graph/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub(crate) mod maximum_matching;
2323
pub(crate) mod minimum_dominating_set;
2424
pub(crate) mod minimum_vertex_cover;
2525
pub(crate) mod spin_glass;
26+
pub(crate) mod steiner_tree;
2627
pub(crate) mod traveling_salesman;
2728

2829
pub use biclique_cover::BicliqueCover;
@@ -35,4 +36,5 @@ pub use maximum_matching::MaximumMatching;
3536
pub use minimum_dominating_set::MinimumDominatingSet;
3637
pub use minimum_vertex_cover::MinimumVertexCover;
3738
pub use spin_glass::SpinGlass;
39+
pub use steiner_tree::SteinerTree;
3840
pub use traveling_salesman::TravelingSalesman;

0 commit comments

Comments
 (0)