diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 565abdb1..3b808ada 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -55,6 +55,7 @@ "ClosestVectorProblem": [Closest Vector Problem], "SubsetSum": [Subset Sum], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], + "SteinerTreeInGraphs": [Steiner Tree in Graphs], ) // Definition label: "def:" — each definition block must have a matching label @@ -570,6 +571,46 @@ caption: [A directed graph with FVS $S = {v_0}$ (blue, $w(S) = 1$). Removing $v_ ) ] +#problem-def("SteinerTreeInGraphs")[ + Given an undirected graph $G = (V, E)$ with edge weights $w: E -> RR_(>= 0)$ and a set of terminal vertices $R subset.eq V$, find a subtree $T$ of $G$ that spans all terminals in $R$ and minimizes the total edge weight $sum_(e in T) w(e)$. +][ +A classical NP-complete problem from Karp's list (as "Steiner Tree in Graphs," Garey & Johnson ND12) @karp1972. Central to network design, VLSI layout, and phylogenetic reconstruction. The problem generalizes minimum spanning tree (where $R = V$) and shortest path (where $|R| = 2$). The Dreyfus--Wagner dynamic programming algorithm @dreyfuswagner1971 solves it in $O(3^k dot n + 2^k dot n^2 + n^3)$ time, where $k = |R|$ and $n = |V|$. Bjorklund et al. @bjorklund2007 achieved $O^*(2^k)$ using subset convolution over the Mobius algebra, and Nederlof @nederlof2009 gave an $O^*(2^k)$ polynomial-space algorithm. + +*Example.* Consider a graph $G$ with $n = 6$ vertices and $|E| = 7$ edges. The terminals are $R = {v_0, v_3, v_5}$ (blue). The optimal Steiner tree uses Steiner vertex $v_2$ (gray, dashed border) and edges ${v_0, v_2}$, ${v_2, v_3}$, ${v_2, v_5}$ with total weight $2 + 1 + 2 = 5$. The direct path $v_0 -> v_1 -> v_3$ plus $v_3 -> v_4 -> v_5$ would cost $3 + 2 + 3 + 1 = 9$. + +#figure({ + // Graph: 6 vertices arranged in two rows + let verts = ((0, 1), (1.5, 1), (3, 1), (1.5, -0.5), (3, -0.5), (4.5, 0.25)) + let edges = ((0, 1), (0, 2), (1, 3), (2, 3), (2, 5), (3, 4), (4, 5)) + let weights = ("3", "2", "4", "1", "2", "3", "1") + let terminals = (0, 3, 5) + let steiner-verts = (2,) + let tree-edges = ((0, 2), (2, 3), (2, 5)) // optimal Steiner tree + canvas(length: 1cm, { + // Draw edges + for (idx, (u, v)) in edges.enumerate() { + 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)) + g-edge(verts.at(u), verts.at(v), + stroke: if on-tree { 2pt + graph-colors.at(0) } else { 1pt + luma(200) }) + let mx = (verts.at(u).at(0) + verts.at(v).at(0)) / 2 + let my = (verts.at(u).at(1) + verts.at(v).at(1)) / 2 + draw.content((mx, my), text(7pt, fill: luma(80))[#weights.at(idx)]) + } + // Draw vertices + for (k, pos) in verts.enumerate() { + let is-terminal = terminals.contains(k) + let is-steiner = steiner-verts.contains(k) + g-node(pos, name: "v" + str(k), + fill: if is-terminal { graph-colors.at(0) } else if is-steiner { luma(220) } else { white }, + stroke: if is-steiner { (dash: "dashed", paint: graph-colors.at(0)) } else { 1pt + black }, + label: if is-terminal { text(fill: white)[$v_#k$] } else { [$v_#k$] }) + } + }) +}, +caption: [Steiner Tree: terminals $R = {v_0, v_3, v_5}$ (blue), Steiner vertex $v_2$ (dashed). Optimal tree (blue edges) has weight 5.], +) +] + == Set Problems #problem-def("MaximumSetPacking")[ diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 0ec874f6..675dfe5f 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -456,3 +456,34 @@ @article{cygan2014 note = {Conference version: STOC 2014}, doi = {10.1137/140990255} } + +@article{dreyfuswagner1971, + author = {Stuart E. Dreyfus and Robert A. Wagner}, + title = {The Steiner Problem in Graphs}, + journal = {Networks}, + volume = {1}, + number = {3}, + pages = {195--207}, + year = {1971}, + doi = {10.1002/net.3230010302} +} + +@inproceedings{bjorklund2007, + author = {Andreas Bj\"{o}rklund and Thore Husfeldt and Petteri Kaski and Mikko Koivisto}, + title = {Fourier Meets M\"{o}bius: Fast Subset Convolution}, + booktitle = {Proceedings of the 39th ACM Symposium on Theory of Computing (STOC)}, + pages = {67--74}, + year = {2007}, + doi = {10.1145/1250790.1250801} +} + +@inproceedings{nederlof2009, + author = {Jesper Nederlof}, + title = {Fast Polynomial-Space Algorithms Using {M\"{o}bius} Inversion: Improving on {Steiner} Tree and Related Problems}, + booktitle = {Proceedings of the 36th International Colloquium on Automata, Languages and Programming (ICALP)}, + series = {LNCS}, + volume = {5555}, + pages = {713--725}, + year = {2009}, + doi = {10.1007/978-3-642-02927-1_59} +} diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 91792e20..3f8b9d13 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -218,6 +218,7 @@ Flags by problem type: BMF --matrix (0/1), --rank CVP --basis, --target-vec [--bounds] FVS --arcs [--weights] [--num-vertices] + SteinerTreeInGraphs --graph, --edge-weights, --terminals ILP, CircuitSAT (via reduction only) Geometry graph variants (use slash notation, e.g., MIS/KingsSubgraph): @@ -332,6 +333,9 @@ pub struct CreateArgs { /// Directed arcs for directed graph problems (e.g., 0>1,1>2,2>0) #[arg(long)] pub arcs: Option, + /// Terminal vertices for SteinerTreeInGraphs (comma-separated, e.g., 0,3,5) + #[arg(long)] + pub terminals: Option, } #[derive(clap::Args)] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 2df4f099..ac899b09 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -48,6 +48,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.target_vec.is_none() && args.bounds.is_none() && args.arcs.is_none() + && args.terminals.is_none() } fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { @@ -81,6 +82,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" } + "SteinerTreeInGraphs" => "--graph 0-1,1-2,2-3 --edge-weights 1,1,1 --terminals 0,3", "Satisfiability" => "--num-vars 3 --clauses \"1,2;-1,3\"", "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", "QUBO" => "--matrix \"1,0.5;0.5,2\"", @@ -227,6 +229,21 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (data, resolved_variant.clone()) } + // SteinerTreeInGraphs (graph + edge weights + terminals) + "SteinerTreeInGraphs" => { + let (graph, _) = parse_graph(args).map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create SteinerTreeInGraphs --graph 0-1,1-2,2-3 --terminals 0,3 [--edge-weights 1,1,1]" + ) + })?; + let edge_weights = parse_edge_weights(args, graph.num_edges())?; + let terminals = parse_terminals(args)?; + ( + ser(SteinerTreeInGraphs::new(graph, terminals, edge_weights))?, + resolved_variant.clone(), + ) + } + // KColoring "KColoring" => { let (graph, _) = parse_graph(args).map_err(|e| { @@ -785,6 +802,23 @@ fn parse_fields_f64(args: &CreateArgs, num_vertices: usize) -> Result> } } +/// Parse `--terminals` as comma-separated terminal vertex indices. +fn parse_terminals(args: &CreateArgs) -> Result> { + let terminals_str = args.terminals.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SteinerTreeInGraphs requires --terminals (comma-separated vertex indices, e.g., 0,3,5)" + ) + })?; + terminals_str + .split(',') + .map(|s| { + s.trim() + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid terminal vertex: {}", e)) + }) + .collect() +} + /// Parse `--clauses` as semicolon-separated clauses of comma-separated literals. /// E.g., "1,2;-1,3;2,-3" fn parse_clauses(args: &CreateArgs) -> Result> { @@ -994,6 +1028,25 @@ fn create_random( (data, variant) } + // SteinerTreeInGraphs + "SteinerTreeInGraphs" => { + let edge_prob = args.edge_prob.unwrap_or(0.5); + if !(0.0..=1.0).contains(&edge_prob) { + bail!("--edge-prob must be between 0.0 and 1.0"); + } + let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); + let num_edges = graph.num_edges(); + let edge_weights = vec![1i32; num_edges]; + // Use first half of vertices as terminals (at least 2) + let num_terminals = std::cmp::max(2, num_vertices / 2); + let terminals: Vec = (0..num_terminals).collect(); + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + ( + ser(SteinerTreeInGraphs::new(graph, terminals, edge_weights))?, + variant, + ) + } + // SpinGlass "SpinGlass" => { let edge_prob = args.edge_prob.unwrap_or(0.5); @@ -1026,7 +1079,7 @@ fn create_random( _ => bail!( "Random generation is not supported for {canonical}. \ Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \ - MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, TravelingSalesman)" + MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, TravelingSalesman, SteinerTreeInGraphs)" ), }; diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 49dd523c..062af9b8 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -214,6 +214,7 @@ pub fn load_problem( "MaxCut" => deser_opt::>(data), "MaximalIS" => deser_opt::>(data), "TravelingSalesman" => deser_opt::>(data), + "SteinerTreeInGraphs" => deser_opt::>(data), "KColoring" => match variant.get("k").map(|s| s.as_str()) { Some("K3") => deser_sat::>(data), _ => deser_sat::>(data), @@ -274,6 +275,7 @@ pub fn serialize_any_problem( "MaxCut" => try_ser::>(any), "MaximalIS" => try_ser::>(any), "TravelingSalesman" => try_ser::>(any), + "SteinerTreeInGraphs" => try_ser::>(any), "KColoring" => match variant.get("k").map(|s| s.as_str()) { Some("K3") => try_ser::>(any), _ => try_ser::>(any), diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 2b6c8c73..b846020c 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -56,6 +56,7 @@ pub fn resolve_alias(input: &str) -> String { "knapsack" => "Knapsack".to_string(), "fvs" | "minimumfeedbackvertexset" => "MinimumFeedbackVertexSet".to_string(), "subsetsum" => "SubsetSum".to_string(), + "steinertreeingraphs" => "SteinerTreeInGraphs".to_string(), _ => input.to_string(), // pass-through for exact names } } diff --git a/src/lib.rs b/src/lib.rs index a74c906f..3d5cfcb8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,8 @@ pub mod prelude { pub use crate::models::graph::{BicliqueCover, GraphPartitioning, SpinGlass}; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover, TravelingSalesman, + MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover, SteinerTreeInGraphs, + TravelingSalesman, }; pub use crate::models::misc::{BinPacking, Factoring, Knapsack, PaintShop, SubsetSum}; pub use crate::models::set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 42f46a15..4c0048f1 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -14,6 +14,7 @@ //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`BicliqueCover`]: Biclique cover on bipartite graphs +//! - [`SteinerTreeInGraphs`]: Minimum weight Steiner tree connecting terminal vertices pub(crate) mod biclique_cover; pub(crate) mod graph_partitioning; @@ -27,6 +28,7 @@ pub(crate) mod minimum_dominating_set; pub(crate) mod minimum_feedback_vertex_set; pub(crate) mod minimum_vertex_cover; pub(crate) mod spin_glass; +pub(crate) mod steiner_tree_in_graphs; pub(crate) mod traveling_salesman; pub use biclique_cover::BicliqueCover; @@ -41,4 +43,5 @@ pub use minimum_dominating_set::MinimumDominatingSet; pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; pub use minimum_vertex_cover::MinimumVertexCover; pub use spin_glass::SpinGlass; +pub use steiner_tree_in_graphs::SteinerTreeInGraphs; pub use traveling_salesman::TravelingSalesman; diff --git a/src/models/graph/steiner_tree_in_graphs.rs b/src/models/graph/steiner_tree_in_graphs.rs new file mode 100644 index 00000000..04737aea --- /dev/null +++ b/src/models/graph/steiner_tree_in_graphs.rs @@ -0,0 +1,293 @@ +//! Steiner Tree in Graphs problem implementation. +//! +//! The Steiner Tree problem asks for a minimum-weight subtree of a graph +//! that connects all terminal vertices. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::{Direction, SolutionSize, WeightElement}; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "SteinerTreeInGraphs", + module_path: module_path!(), + description: "Find minimum weight subtree connecting all terminal vertices", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "terminals", type_name: "Vec", description: "Required terminal vertices R ⊆ V" }, + FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, + ], + } +} + +/// The Steiner Tree in Graphs problem. +/// +/// Given a weighted graph G = (V, E) with edge weights w_e and a +/// subset R ⊆ V of required terminal vertices, find a subtree T of G +/// that includes all vertices of R and minimizes the total edge weight +/// Σ_{e ∈ T} w(e). +/// +/// # Representation +/// +/// Each edge is assigned a binary variable: +/// - 0: edge is not in the tree +/// - 1: edge is in the tree +/// +/// A valid Steiner tree requires: +/// - All terminal vertices are connected through selected edges +/// - Selected edges form a connected subgraph (optimally a tree) +/// +/// # Type Parameters +/// +/// * `G` - The graph type (e.g., `SimpleGraph`) +/// * `W` - The weight type for edges (e.g., `i32`, `f64`) +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::graph::SteinerTreeInGraphs; +/// use problemreductions::topology::SimpleGraph; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// // Path graph 0-1-2-3, terminals {0, 3} +/// let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); +/// let problem = SteinerTreeInGraphs::new(graph, vec![0, 3], vec![1, 1, 1]); +/// +/// let solver = BruteForce::new(); +/// let solution = solver.find_best(&problem).unwrap(); +/// // Optimal: select all 3 edges (the only path from 0 to 3) +/// assert_eq!(solution, vec![1, 1, 1]); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SteinerTreeInGraphs { + /// The underlying graph. + graph: G, + /// Required terminal vertices. + terminals: Vec, + /// Weights for each edge (in edge index order). + edge_weights: Vec, +} + +impl SteinerTreeInGraphs { + /// Create a SteinerTreeInGraphs problem from a graph, terminals, and edge weights. + /// + /// # Panics + /// Panics if `edge_weights.len() != graph.num_edges()` or any terminal index is out of bounds. + pub fn new(graph: G, terminals: Vec, edge_weights: Vec) -> Self { + assert_eq!( + edge_weights.len(), + graph.num_edges(), + "edge_weights length must match num_edges" + ); + for &t in &terminals { + assert!( + t < graph.num_vertices(), + "terminal vertex {} out of bounds (num_vertices = {})", + t, + graph.num_vertices() + ); + } + Self { + graph, + terminals, + edge_weights, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the terminal vertices. + pub fn terminals(&self) -> &[usize] { + &self.terminals + } + + /// Get all edges with their weights. + pub fn edges(&self) -> Vec<(usize, usize, W)> { + self.graph + .edges() + .into_iter() + .zip(self.edge_weights.iter().cloned()) + .map(|((u, v), w)| (u, v, w)) + .collect() + } + + /// Set new weights for the problem. + pub fn set_weights(&mut self, weights: Vec) { + assert_eq!(weights.len(), self.graph.num_edges()); + self.edge_weights = weights; + } + + /// Get the weights for the problem. + pub fn weights(&self) -> Vec { + self.edge_weights.clone() + } + + /// Check if the problem uses a non-unit weight type. + pub fn is_weighted(&self) -> bool + where + W: WeightElement, + { + !W::IS_UNIT + } + + /// Check if a configuration is a valid Steiner tree. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + if config.len() != self.graph.num_edges() { + return false; + } + let selected: Vec = config.iter().map(|&s| s == 1).collect(); + is_steiner_tree(&self.graph, &self.terminals, &selected) + } +} + +impl SteinerTreeInGraphs { + /// Get the number of vertices in the underlying graph. + pub fn num_vertices(&self) -> usize { + self.graph().num_vertices() + } + + /// Get the number of edges in the underlying graph. + pub fn num_edges(&self) -> usize { + self.graph().num_edges() + } + + /// Get the number of terminal vertices. + pub fn num_terminals(&self) -> usize { + self.terminals.len() + } +} + +impl Problem for SteinerTreeInGraphs +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "SteinerTreeInGraphs"; + type Metric = SolutionSize; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G, W] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> SolutionSize { + if config.len() != self.graph.num_edges() { + return SolutionSize::Invalid; + } + let selected: Vec = config.iter().map(|&s| s == 1).collect(); + if !is_steiner_tree(&self.graph, &self.terminals, &selected) { + return SolutionSize::Invalid; + } + let mut total = W::Sum::zero(); + for (idx, &sel) in config.iter().enumerate() { + if sel == 1 { + if let Some(w) = self.edge_weights.get(idx) { + total += w.to_sum(); + } + } + } + SolutionSize::Valid(total) + } +} + +impl OptimizationProblem for SteinerTreeInGraphs +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + type Value = W::Sum; + + fn direction(&self) -> Direction { + Direction::Minimize + } +} + +/// Check if a selection of edges forms a valid Steiner tree (connected subgraph spanning all terminals). +/// +/// A valid Steiner tree requires: +/// 1. All terminal vertices are reachable from each other through selected edges. +/// 2. The selected edges form a connected subgraph that includes all terminals. +/// +/// Note: The optimal solution is always a tree, but we accept any connected subgraph +/// spanning all terminals (the brute-force solver will find the minimum-weight one). +/// +/// # Panics +/// Panics if `selected.len() != graph.num_edges()`. +pub(crate) fn is_steiner_tree( + graph: &G, + terminals: &[usize], + selected: &[bool], +) -> bool { + assert_eq!( + selected.len(), + graph.num_edges(), + "selected length must match num_edges" + ); + + // If no terminals, any selection is trivially valid (including empty) + if terminals.is_empty() { + return true; + } + + // If only one terminal, it's valid as long as that terminal exists + // (no edges needed to connect a single vertex) + if terminals.len() == 1 { + return true; + } + + // Build adjacency list from selected edges + let n = graph.num_vertices(); + let edges = graph.edges(); + let mut adj: Vec> = vec![vec![]; n]; + + let mut has_any_edge = false; + for (idx, &sel) in selected.iter().enumerate() { + if sel { + let (u, v) = edges[idx]; + adj[u].push(v); + adj[v].push(u); + has_any_edge = true; + } + } + + if !has_any_edge { + return false; + } + + // BFS from the first terminal to check connectivity of all terminals + let start = terminals[0]; + let mut visited = vec![false; n]; + let mut queue = std::collections::VecDeque::new(); + visited[start] = true; + queue.push_back(start); + + while let Some(node) = queue.pop_front() { + for &neighbor in &adj[node] { + if !visited[neighbor] { + visited[neighbor] = true; + queue.push_back(neighbor); + } + } + } + + // All terminals must be reachable + terminals.iter().all(|&t| visited[t]) +} + +crate::declare_variants! { + SteinerTreeInGraphs => "3^num_terminals * num_vertices", + SteinerTreeInGraphs => "3^num_terminals * num_vertices", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/steiner_tree_in_graphs.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index ceb584ce..d0bf8d5a 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -14,7 +14,7 @@ pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackVertexSet, - MinimumVertexCover, SpinGlass, TravelingSalesman, + MinimumVertexCover, SpinGlass, SteinerTreeInGraphs, TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, PaintShop, SubsetSum}; pub use set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/unit_tests/models/graph/steiner_tree_in_graphs.rs b/src/unit_tests/models/graph/steiner_tree_in_graphs.rs new file mode 100644 index 00000000..3ed61cdf --- /dev/null +++ b/src/unit_tests/models/graph/steiner_tree_in_graphs.rs @@ -0,0 +1,221 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::Direction; + +#[test] +fn test_steiner_tree_creation() { + // Path graph: 0-1-2-3 + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); + let problem = SteinerTreeInGraphs::new(graph, vec![0, 3], vec![1i32, 2, 3]); + assert_eq!(problem.graph().num_vertices(), 4); + assert_eq!(problem.graph().num_edges(), 3); + assert_eq!(problem.terminals(), &[0, 3]); + assert_eq!(problem.dims().len(), 3); + assert_eq!(problem.num_vertices(), 4); + assert_eq!(problem.num_edges(), 3); + assert_eq!(problem.num_terminals(), 2); +} + +#[test] +fn test_steiner_tree_evaluation() { + // Triangle graph: 0-1, 1-2, 0-2, with terminal {0, 2} + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let problem = SteinerTreeInGraphs::new(graph, vec![0, 2], vec![3i32, 4, 1]); + + // Select edge 0-2 (weight 1): valid, connects terminals directly + let config_direct = vec![0, 0, 1]; + let result = problem.evaluate(&config_direct); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 1); + + // Select edges 0-1 and 1-2 (weights 3+4=7): valid, connects via vertex 1 + let config_via = vec![1, 1, 0]; + let result = problem.evaluate(&config_via); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 7); + + // Select only edge 0-1: invalid (terminal 2 not reached) + let config_invalid = vec![1, 0, 0]; + let result = problem.evaluate(&config_invalid); + assert!(!result.is_valid()); + + // Select no edges: invalid + let config_empty = vec![0, 0, 0]; + let result = problem.evaluate(&config_empty); + assert!(!result.is_valid()); +} + +#[test] +fn test_steiner_tree_direction() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = SteinerTreeInGraphs::new(graph, vec![0, 2], vec![1i32; 2]); + assert_eq!(problem.direction(), Direction::Minimize); +} + +#[test] +fn test_steiner_tree_solver() { + // Diamond graph: + // 1 + // / \ + // 0 3 + // \ / + // 2 + // Edges: 0-1(w=2), 0-2(w=1), 1-3(w=2), 2-3(w=1) + // Terminals: {0, 3} + // Optimal path: 0-2-3 with weight 1+1=2 + let graph = SimpleGraph::new(4, vec![(0, 1), (0, 2), (1, 3), (2, 3)]); + let problem = SteinerTreeInGraphs::new(graph, vec![0, 3], vec![2, 1, 2, 1]); + + let solver = BruteForce::new(); + let solution = solver.find_best(&problem).unwrap(); + let value = problem.evaluate(&solution); + assert!(value.is_valid()); + assert_eq!(value.unwrap(), 2); + // Should select edges 0-2 and 2-3 + assert_eq!(solution, vec![0, 1, 0, 1]); +} + +#[test] +fn test_steiner_tree_with_steiner_vertices() { + // Star graph: center vertex 1 connected to 0, 2, 3 + // Edges: 0-1(w=1), 1-2(w=1), 1-3(w=1) + // Terminals: {0, 2, 3} + // Optimal: use vertex 1 as Steiner vertex, select all 3 edges, weight = 3 + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (1, 3)]); + let problem = SteinerTreeInGraphs::new(graph, vec![0, 2, 3], vec![1i32; 3]); + + let solver = BruteForce::new(); + let solution = solver.find_best(&problem).unwrap(); + let value = problem.evaluate(&solution); + assert!(value.is_valid()); + assert_eq!(value.unwrap(), 3); + assert_eq!(solution, vec![1, 1, 1]); +} + +#[test] +fn test_steiner_tree_is_valid_solution() { + // Path graph: 0-1-2 + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = SteinerTreeInGraphs::new(graph, vec![0, 2], vec![1i32; 2]); + + // Valid: both edges selected + assert!(problem.is_valid_solution(&[1, 1])); + // Invalid: only first edge + assert!(!problem.is_valid_solution(&[1, 0])); + // Invalid: only second edge + assert!(!problem.is_valid_solution(&[0, 1])); + // Invalid: no edges + assert!(!problem.is_valid_solution(&[0, 0])); +} + +#[test] +fn test_steiner_tree_size_getters() { + let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]); + let problem = SteinerTreeInGraphs::new(graph, vec![0, 2, 4], vec![1i32; 4]); + assert_eq!(problem.num_vertices(), 5); + assert_eq!(problem.num_edges(), 4); + assert_eq!(problem.num_terminals(), 3); +} + +#[test] +fn test_steiner_tree_problem_name() { + assert_eq!( + as Problem>::NAME, + "SteinerTreeInGraphs" + ); +} + +#[test] +fn test_steiner_tree_serialization() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = SteinerTreeInGraphs::new(graph, vec![0, 2], vec![1i32; 2]); + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: SteinerTreeInGraphs = + serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.graph().num_vertices(), 3); + assert_eq!(deserialized.terminals(), &[0, 2]); + assert_eq!(deserialized.num_edges(), 2); +} + +#[test] +fn test_steiner_tree_single_terminal() { + // Single terminal: any config (including empty) is valid + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = SteinerTreeInGraphs::new(graph, vec![1], vec![1i32; 2]); + + // No edges needed for a single terminal + let result = problem.evaluate(&[0, 0]); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 0); +} + +#[test] +fn test_steiner_tree_all_vertices_terminal() { + // When all vertices are terminals, it degenerates to spanning tree + // Path: 0-1-2 + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = SteinerTreeInGraphs::new(graph, vec![0, 1, 2], vec![1i32; 2]); + + let solver = BruteForce::new(); + let solution = solver.find_best(&problem).unwrap(); + let value = problem.evaluate(&solution); + assert!(value.is_valid()); + assert_eq!(value.unwrap(), 2); +} + +#[test] +fn test_steiner_tree_edges_accessor() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = SteinerTreeInGraphs::new(graph, vec![0, 2], vec![5i32, 10]); + let edges = problem.edges(); + assert_eq!(edges.len(), 2); + assert_eq!(edges[0].2, 5); + assert_eq!(edges[1].2, 10); +} + +#[test] +fn test_steiner_tree_weights_management() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let mut problem = SteinerTreeInGraphs::new(graph, vec![0, 2], vec![1i32; 2]); + assert!(problem.is_weighted()); + assert_eq!(problem.weights(), vec![1, 1]); + + problem.set_weights(vec![5, 10]); + assert_eq!(problem.weights(), vec![5, 10]); +} + +#[test] +fn test_steiner_tree_example_from_issue() { + // Example from issue #255: + // Graph with 8 vertices {0,1,2,3,4,5,6,7} and 12 edges + // Terminals R = {0, 3, 5, 7} + let graph = SimpleGraph::new( + 8, + vec![ + (0, 1), // w=2, idx=0 + (0, 2), // w=3, idx=1 + (1, 2), // w=1, idx=2 + (1, 3), // w=4, idx=3 + (2, 4), // w=2, idx=4 + (3, 4), // w=3, idx=5 + (3, 5), // w=5, idx=6 + (4, 5), // w=1, idx=7 + (4, 6), // w=2, idx=8 + (5, 6), // w=3, idx=9 + (5, 7), // w=4, idx=10 + (6, 7), // w=1, idx=11 + ], + ); + let weights = vec![2, 3, 1, 4, 2, 3, 5, 1, 2, 3, 4, 1]; + let problem = + SteinerTreeInGraphs::new(graph, vec![0, 3, 5, 7], weights); + + // Verify the claimed optimal solution from the issue: + // Edges: {0,1}(2) + {1,2}(1) + {2,4}(2) + {3,4}(3) + {4,5}(1) + {4,6}(2) + {6,7}(1) = 12 + let config = vec![1, 0, 1, 0, 1, 1, 0, 1, 1, 0, 0, 1]; + let result = problem.evaluate(&config); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 12); +}