diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 4f3cb6ad2..8ea63dd0f 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -72,6 +72,7 @@ "UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow], "LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths], "IsomorphicSpanningTree": [Isomorphic Spanning Tree], + "KthBestSpanningTree": [Kth Best Spanning Tree], "KColoring": [$k$-Coloring], "MinimumDominatingSet": [Minimum Dominating Set], "MaximumMatching": [Maximum Matching], @@ -919,6 +920,48 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] ] } +#{ + let x = load-model-example("KthBestSpanningTree") + let edges = x.instance.graph.inner.edges.map(e => (e.at(0), e.at(1))) + let weights = x.instance.weights + let m = edges.len() + let sol = x.optimal.at(0).config + let tree1 = sol.enumerate().filter(((i, v)) => i < m and v == 1).map(((i, _)) => edges.at(i)) + let blue = graph-colors.at(0) + let gray = luma(190) + [ + #problem-def("KthBestSpanningTree")[ + Given an undirected graph $G = (V, E)$ with edge weights $w: E -> ZZ_(gt.eq 0)$, a positive integer $k$, and a bound $B in ZZ_(gt.eq 0)$, determine whether there exist $k$ distinct spanning trees $T_1, dots, T_k subset.eq E$ such that $sum_(e in T_i) w(e) lt.eq B$ for every $i$. + ][ + Kth Best Spanning Tree is catalogued as ND9 in Garey and Johnson @garey1979 and is marked there with an asterisk because the general problem is NP-hard but not known to lie in NP. For any fixed value of $k$, Lawler's $k$-best enumeration framework gives a polynomial-time algorithm when combined with minimum-spanning-tree subroutines @lawler1972. For output-sensitive enumeration, Eppstein gave an algorithm that lists the $k$ smallest spanning trees of a weighted graph in $O(m log beta(m, n) + k^2)$ time @eppstein1992. + + Variables: $k |E|$ binary values grouped into $k$ consecutive edge-selection blocks. Entry $x_(i, e) = 1$ means edge $e$ belongs to the $i$-th candidate tree. A configuration is satisfying exactly when each block selects a spanning tree, every selected tree has total weight at most $B$, and the $k$ blocks encode pairwise distinct edge sets. + + *Example.* Consider $K_4$ with edge weights $w = {(0,1): 1, (0,2): 1, (0,3): 2, (1,2): 2, (1,3): 2, (2,3): 3}$. With $k = 2$ and $B = 4$, exactly two of the $16$ spanning trees have total weight $lt.eq 4$: the star $T_1 = {(0,1), (0,2), (0,3)}$ with weight $4$ and $T_2 = {(0,1), (0,2), (1,3)}$ with weight $4$. Since two distinct bounded spanning trees exist, this is a YES-instance. + + #figure({ + canvas(length: 1cm, { + import draw: * + let pos = ((0.0, 1.8), (2.4, 1.8), (2.4, 0.0), (0.0, 0.0)) + for (idx, (u, v)) in edges.enumerate() { + let in-tree1 = tree1.any(e => (e.at(0) == u and e.at(1) == v) or (e.at(0) == v and e.at(1) == u)) + g-edge(pos.at(u), pos.at(v), stroke: if in-tree1 { 2pt + blue } else { 1pt + gray }) + let mid-x = (pos.at(u).at(0) + pos.at(v).at(0)) / 2 + let mid-y = (pos.at(u).at(1) + pos.at(v).at(1)) / 2 + // Offset diagonal edge labels to avoid overlap at center + let (ox, oy) = if u == 0 and v == 2 { (0.3, 0) } else if u == 1 and v == 3 { (-0.3, 0) } else { (0, 0) } + content((mid-x + ox, mid-y + oy), text(7pt)[#weights.at(idx)], fill: white, frame: "rect", padding: .06, stroke: none) + } + for (idx, p) in pos.enumerate() { + g-node(p, name: "v" + str(idx), fill: white, label: $v_#idx$) + } + }) + }, + caption: [Kth Best Spanning Tree on $K_4$. Blue edges show $T_1 = {(0,1), (0,2), (0,3)}$, one of two spanning trees with weight $lt.eq 4$.], + ) + ] + ] +} #{ let x = load-model-example("KColoring") let nv = graph-num-vertices(x.instance) diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 5a949127a..3aa8d155c 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -798,6 +798,28 @@ @article{papadimitriou1982 doi = {10.1145/322307.322309} } +@article{lawler1972, + author = {Eugene L. Lawler}, + title = {A Procedure for Computing the $K$ Best Solutions to Discrete Optimization Problems and Its Application to the Shortest Path Problem}, + journal = {Management Science}, + volume = {18}, + number = {7}, + pages = {401--405}, + year = {1972}, + doi = {10.1287/mnsc.18.7.401} +} + +@article{eppstein1992, + author = {David Eppstein}, + title = {Finding the $k$ Smallest Spanning Trees}, + journal = {BIT}, + volume = {32}, + number = {2}, + pages = {237--248}, + year = {1992}, + doi = {10.1007/BF01994880} +} + @article{chopra1996, author = {Sunil Chopra and Jonathan H. Owen}, title = {Extended formulations for the A-cut problem}, diff --git a/docs/src/cli.md b/docs/src/cli.md index 11086f4ac..ad4fc80c9 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -343,6 +343,7 @@ pred create MIS --graph 0-1,1-2,2-3 --weights 2,1,3,1 -o problem.json pred create SAT --num-vars 3 --clauses "1,2;-1,3" -o sat.json pred create QUBO --matrix "1,0.5;0.5,2" -o qubo.json pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.json +pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3 -o kth.json pred create SpinGlass --graph 0-1,1-2 -o sg.json pred create MaxCut --graph 0-1,1-2,2-0 -o maxcut.json pred create MinimumMultiwayCut --graph 0-1,1-2,2-3,3-0 --terminals 0,2 --edge-weights 3,1,2,4 -o mmc.json diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 09ac353c4..8a80850fb 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -229,6 +229,7 @@ Flags by problem type: BoundedComponentSpanningForest --graph, --weights, --k, --bound UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 IsomorphicSpanningTree --graph, --tree + KthBestSpanningTree --graph, --edge-weights, --k, --bound LengthBoundedDisjointPaths --graph, --source, --sink, --num-paths-required, --bound Factoring --target, --m, --n BinPacking --sizes, --capacity diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index dcec61cc2..2a14d8a5c 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -288,6 +288,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --num-paths-required 2 --bound 3" } "IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2", + "KthBestSpanningTree" => "--graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3", "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" } @@ -350,6 +351,13 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { } } +fn uses_edge_weights_flag(canonical: &str) -> bool { + matches!( + canonical, + "KthBestSpanningTree" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" | "RuralPostman" + ) +} + fn help_flag_name(canonical: &str, field_name: &str) -> String { // Problem-specific overrides first match (canonical, field_name) { @@ -359,6 +367,10 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { ("StaffScheduling", "shifts_per_schedule") => return "k".to_string(), _ => {} } + // Edge-weight problems use --edge-weights instead of --weights + if field_name == "weights" && uses_edge_weights_flag(canonical) { + return "edge-weights".to_string(); + } // General field-name overrides (previously in cli_flag_name) match field_name { "universe_size" => "universe".to_string(), @@ -376,6 +388,25 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { } } +fn reject_vertex_weights_for_edge_weight_problem( + args: &CreateArgs, + canonical: &str, + graph_type: Option<&str>, +) -> Result<()> { + if args.weights.is_some() && uses_edge_weights_flag(canonical) { + bail!( + "{canonical} uses --edge-weights, not --weights.\n\n\ + Usage: pred create {} {}", + match graph_type { + Some(g) => format!("{canonical}/{g}"), + None => canonical.to_string(), + }, + example_for(canonical, graph_type) + ); + } + Ok(()) +} + fn help_flag_hint( canonical: &str, field_name: &str, @@ -844,8 +875,37 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // KthBestSpanningTree (weighted graph + k + bound) + "KthBestSpanningTree" => { + reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; + let (graph, _) = parse_graph(args).map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3" + ) + })?; + let edge_weights = parse_edge_weights(args, graph.num_edges())?; + let (k, _variant) = + util::validate_k_param(&resolved_variant, args.k, None, "KthBestSpanningTree")?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "KthBestSpanningTree requires --bound\n\n\ + Usage: pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3" + ) + })? as i32; + ( + ser(problemreductions::models::graph::KthBestSpanningTree::new( + graph, + edge_weights, + k, + bound, + ))?, + resolved_variant.clone(), + ) + } + // Graph problems with edge weights "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { + reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; let (graph, _) = parse_graph(args).map_err(|e| { anyhow::anyhow!( "{e}\n\nUsage: pred create {} --graph 0-1,1-2,2-3 [--edge-weights 1,1,1]", @@ -864,6 +924,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // RuralPostman "RuralPostman" => { + reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; let (graph, _) = parse_graph(args).map_err(|e| { anyhow::anyhow!( "{e}\n\nUsage: pred create RuralPostman --graph 0-1,1-2,2-3 --edge-weights 1,1,1 --required-edges 0,2 --bound 6" diff --git a/problemreductions-cli/src/util.rs b/problemreductions-cli/src/util.rs index 4991d63d8..9f2ffb115 100644 --- a/problemreductions-cli/src/util.rs +++ b/problemreductions-cli/src/util.rs @@ -65,6 +65,10 @@ pub fn validate_k_param( }, }; + if effective_k == 0 { + bail!("{problem_name}: --k must be positive"); + } + // Build the variant map with the effective k let mut variant = resolved_variant.clone(); variant.insert("k".to_string(), k_variant_str(effective_k).to_string()); @@ -282,3 +286,19 @@ pub fn parse_edge_pairs(s: &str) -> Result> { }) .collect() } + +#[cfg(test)] +mod tests { + use super::validate_k_param; + use std::collections::BTreeMap; + + #[test] + fn test_validate_k_param_rejects_zero() { + let err = validate_k_param(&BTreeMap::new(), Some(0), None, "KthBestSpanningTree") + .expect_err("k=0 should be rejected before problem construction"); + assert!( + err.to_string().contains("positive"), + "unexpected error message: {err}" + ); + } +} diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index d2a6ffa95..e6ff49245 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -2911,6 +2911,74 @@ fn test_create_kcoloring_missing_k() { assert!(stderr.contains("--k")); } +#[test] +fn test_create_kth_best_spanning_tree_rejects_zero_k() { + let output = pred() + .args([ + "create", + "KthBestSpanningTree", + "--graph", + "0-1,1-2,0-2", + "--edge-weights", + "2,3,1", + "--k", + "0", + "--bound", + "3", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("must be positive"), + "expected positive-k validation error, got: {stderr}" + ); +} + +#[test] +fn test_create_kth_best_spanning_tree_help_uses_edge_weights() { + let output = pred() + .args(["create", "KthBestSpanningTree"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--edge-weights"), + "expected edge-weight help, got: {stderr}" + ); + assert!( + !stderr.contains("\n --weights"), + "vertex-weight flag should not be suggested, got: {stderr}" + ); +} + +#[test] +fn test_create_kth_best_spanning_tree_rejects_vertex_weights_flag() { + let output = pred() + .args([ + "create", + "KthBestSpanningTree", + "--graph", + "0-1,0-2,1-2", + "--weights", + "9,9,9", + "--k", + "1", + "--bound", + "3", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--edge-weights"), + "expected guidance toward edge weights, got: {stderr}" + ); +} + #[test] fn test_create_length_bounded_disjoint_paths_rejects_equal_terminals() { let output = pred() diff --git a/src/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index d5007656f..c143c6e05 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -18,6 +18,7 @@ {"problem":"IsomorphicSpanningTree","variant":{},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[0,3,null],[1,2,null],[1,3,null],[2,3,null]],"node_holes":[],"nodes":[null,null,null,null]}},"tree":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[0,3,null]],"node_holes":[],"nodes":[null,null,null,null]}}},"samples":[{"config":[0,1,2,3],"metric":true}],"optimal":[{"config":[0,1,2,3],"metric":true},{"config":[0,1,3,2],"metric":true},{"config":[0,2,1,3],"metric":true},{"config":[0,2,3,1],"metric":true},{"config":[0,3,1,2],"metric":true},{"config":[0,3,2,1],"metric":true},{"config":[1,0,2,3],"metric":true},{"config":[1,0,3,2],"metric":true},{"config":[1,2,0,3],"metric":true},{"config":[1,2,3,0],"metric":true},{"config":[1,3,0,2],"metric":true},{"config":[1,3,2,0],"metric":true},{"config":[2,0,1,3],"metric":true},{"config":[2,0,3,1],"metric":true},{"config":[2,1,0,3],"metric":true},{"config":[2,1,3,0],"metric":true},{"config":[2,3,0,1],"metric":true},{"config":[2,3,1,0],"metric":true},{"config":[3,0,1,2],"metric":true},{"config":[3,0,2,1],"metric":true},{"config":[3,1,0,2],"metric":true},{"config":[3,1,2,0],"metric":true},{"config":[3,2,0,1],"metric":true},{"config":[3,2,1,0],"metric":true}]}, {"problem":"KColoring","variant":{"graph":"SimpleGraph","k":"K3"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"num_colors":3},"samples":[{"config":[0,1,1,0,2],"metric":true}],"optimal":[{"config":[0,1,1,0,2],"metric":true},{"config":[0,1,1,2,0],"metric":true},{"config":[0,1,2,0,1],"metric":true},{"config":[0,2,1,0,2],"metric":true},{"config":[0,2,2,0,1],"metric":true},{"config":[0,2,2,1,0],"metric":true},{"config":[1,0,0,1,2],"metric":true},{"config":[1,0,0,2,1],"metric":true},{"config":[1,0,2,1,0],"metric":true},{"config":[1,2,0,1,2],"metric":true},{"config":[1,2,2,0,1],"metric":true},{"config":[1,2,2,1,0],"metric":true},{"config":[2,0,0,1,2],"metric":true},{"config":[2,0,0,2,1],"metric":true},{"config":[2,0,1,2,0],"metric":true},{"config":[2,1,0,2,1],"metric":true},{"config":[2,1,1,0,2],"metric":true},{"config":[2,1,1,2,0],"metric":true}]}, {"problem":"KSatisfiability","variant":{"k":"K3"},"instance":{"clauses":[{"literals":[1,2,3]},{"literals":[-1,-2,3]},{"literals":[1,-2,-3]}],"num_vars":3},"samples":[{"config":[1,0,1],"metric":true}],"optimal":[{"config":[0,0,1],"metric":true},{"config":[0,1,0],"metric":true},{"config":[1,0,0],"metric":true},{"config":[1,0,1],"metric":true},{"config":[1,1,1],"metric":true}]}, + {"problem":"KthBestSpanningTree","variant":{"weight":"i32"},"instance":{"bound":4,"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[0,3,null],[1,2,null],[1,3,null],[2,3,null]],"node_holes":[],"nodes":[null,null,null,null]}},"k":2,"weights":[1,1,2,2,2,3]},"samples":[{"config":[1,1,1,0,0,0,1,1,0,0,1,0],"metric":true},{"config":[0,0,0,0,0,0,0,0,0,0,0,0],"metric":false}],"optimal":[{"config":[1,1,0,0,1,0,1,1,1,0,0,0],"metric":true},{"config":[1,1,1,0,0,0,1,1,0,0,1,0],"metric":true}]}, {"problem":"LengthBoundedDisjointPaths","variant":{"graph":"SimpleGraph"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[1,6,null],[0,2,null],[2,3,null],[3,6,null],[0,4,null],[4,5,null],[5,6,null]],"node_holes":[],"nodes":[null,null,null,null,null,null,null]}},"max_length":3,"num_paths_required":2,"sink":6,"source":0},"samples":[{"config":[1,1,0,0,0,0,1,1,0,1,1,0,0,1],"metric":true}],"optimal":[{"config":[1,0,0,0,1,1,1,1,0,1,1,0,0,1],"metric":true},{"config":[1,0,0,0,1,1,1,1,1,0,0,0,0,1],"metric":true},{"config":[1,0,1,1,0,0,1,1,0,0,0,1,1,1],"metric":true},{"config":[1,0,1,1,0,0,1,1,1,0,0,0,0,1],"metric":true},{"config":[1,1,0,0,0,0,1,1,0,0,0,1,1,1],"metric":true},{"config":[1,1,0,0,0,0,1,1,0,1,1,0,0,1],"metric":true}]}, {"problem":"LongestCommonSubsequence","variant":{},"instance":{"alphabet_size":2,"bound":3,"strings":[[0,1,0,1,1,0],[1,0,0,1,0,1],[0,0,1,0,1,1],[1,1,0,0,1,0],[0,1,0,1,0,1],[1,0,1,0,1,0]]},"samples":[{"config":[0,1,0],"metric":true}],"optimal":[{"config":[0,0,0],"metric":true},{"config":[0,0,1],"metric":true},{"config":[0,1,0],"metric":true},{"config":[1,0,1],"metric":true},{"config":[1,1,1],"metric":true}]}, {"problem":"MaxCut","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_weights":[1,1,1,1,1,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}}},"samples":[{"config":[1,0,0,1,0],"metric":{"Valid":5}}],"optimal":[{"config":[0,1,1,0,0],"metric":{"Valid":5}},{"config":[0,1,1,0,1],"metric":{"Valid":5}},{"config":[1,0,0,1,0],"metric":{"Valid":5}},{"config":[1,0,0,1,1],"metric":{"Valid":5}}]}, diff --git a/src/lib.rs b/src/lib.rs index 1bcad8a36..bba9b1ea6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,8 +47,9 @@ pub mod prelude { pub use crate::models::graph::{ BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GraphPartitioning, - HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, LengthBoundedDisjointPaths, - SpinGlass, SteinerTree, StrongConnectivityAugmentation, SubgraphIsomorphism, + HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KthBestSpanningTree, + LengthBoundedDisjointPaths, SpinGlass, SteinerTree, StrongConnectivityAugmentation, + SubgraphIsomorphism, }; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, diff --git a/src/models/graph/kth_best_spanning_tree.rs b/src/models/graph/kth_best_spanning_tree.rs new file mode 100644 index 000000000..aa155cc6d --- /dev/null +++ b/src/models/graph/kth_best_spanning_tree.rs @@ -0,0 +1,256 @@ +//! Kth Best Spanning Tree problem implementation. +//! +//! Given a weighted graph, determine whether it contains `k` distinct spanning +//! trees whose total weights are all at most a prescribed bound. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::types::WeightElement; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +inventory::submit! { + ProblemSchemaEntry { + name: "KthBestSpanningTree", + display_name: "Kth Best Spanning Tree", + aliases: &[], + dimensions: &[VariantDimension::new("weight", "i32", &["i32"])], + module_path: module_path!(), + description: "Do there exist k distinct spanning trees with total weight at most B?", + fields: &[ + FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Edge weights w(e) for each edge in E" }, + FieldInfo { name: "k", type_name: "usize", description: "Number of distinct spanning trees required" }, + FieldInfo { name: "bound", type_name: "W::Sum", description: "Upper bound B on each spanning tree weight" }, + ], + } +} + +/// Kth Best Spanning Tree. +/// +/// Given an undirected graph `G = (V, E)`, non-negative edge weights `w(e)`, +/// a positive integer `k`, and a bound `B`, determine whether there are `k` +/// distinct spanning trees of `G` whose total weights are all at most `B`. +/// +/// # Representation +/// +/// A configuration is `k` consecutive binary blocks of length `|E|`. +/// Each block selects the edges of one candidate spanning tree. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KthBestSpanningTree { + graph: SimpleGraph, + weights: Vec, + k: usize, + bound: W::Sum, +} + +impl KthBestSpanningTree { + /// Create a new KthBestSpanningTree instance. + /// + /// # Panics + /// + /// Panics if the number of weights does not match the number of edges, or + /// if `k` is zero. + pub fn new(graph: SimpleGraph, weights: Vec, k: usize, bound: W::Sum) -> Self { + assert_eq!( + weights.len(), + graph.num_edges(), + "weights length must match graph num_edges" + ); + assert!(k > 0, "k must be positive"); + + Self { + graph, + weights, + k, + bound, + } + } + + /// Get the underlying graph. + pub fn graph(&self) -> &SimpleGraph { + &self.graph + } + + /// Get the edge weights. + pub fn weights(&self) -> &[W] { + &self.weights + } + + /// Get the requested number of trees. + pub fn k(&self) -> usize { + self.k + } + + /// Get the weight bound. + pub fn bound(&self) -> &W::Sum { + &self.bound + } + + /// Get the number of vertices. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Check whether the problem uses a non-unit weight type. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Check whether a configuration satisfies the problem. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + self.evaluate_config(config) + } + + fn block_is_valid_tree(&self, block: &[usize], edges: &[(usize, usize)]) -> bool { + if block.len() != edges.len() || block.iter().any(|&value| value > 1) { + return false; + } + + let num_vertices = self.graph.num_vertices(); + let selected_count = block.iter().filter(|&&value| value == 1).count(); + if selected_count != num_vertices.saturating_sub(1) { + return false; + } + + let mut total_weight = W::Sum::zero(); + let mut adjacency = vec![Vec::new(); num_vertices]; + let mut start = None; + + for (idx, &selected) in block.iter().enumerate() { + if selected == 0 { + continue; + } + total_weight += self.weights[idx].to_sum(); + let (u, v) = edges[idx]; + adjacency[u].push(v); + adjacency[v].push(u); + if start.is_none() { + start = Some(u); + } + } + + if total_weight > self.bound { + return false; + } + + if num_vertices <= 1 { + return true; + } + + // SAFETY: num_vertices > 1 and selected_count == num_vertices - 1 > 0, + // so at least one edge was selected and `start` is Some. + let start = start.expect("at least one selected edge"); + + let mut visited = vec![false; num_vertices]; + let mut queue = VecDeque::new(); + visited[start] = true; + queue.push_back(start); + + while let Some(vertex) = queue.pop_front() { + for &neighbor in &adjacency[vertex] { + if !visited[neighbor] { + visited[neighbor] = true; + queue.push_back(neighbor); + } + } + } + + visited.into_iter().all(|seen| seen) + } + + fn blocks_are_pairwise_distinct(&self, config: &[usize], block_size: usize) -> bool { + debug_assert!(block_size > 0, "block_size must be positive"); + let blocks: Vec<&[usize]> = config.chunks_exact(block_size).collect(); + for left in 0..blocks.len() { + for right in (left + 1)..blocks.len() { + if blocks[left] == blocks[right] { + return false; + } + } + } + true + } + + fn evaluate_config(&self, config: &[usize]) -> bool { + let block_size = self.graph.num_edges(); + let expected_len = self.k * block_size; + if config.len() != expected_len { + return false; + } + + if block_size == 0 { + return self.k == 1 && self.block_is_valid_tree(config, &[]); + } + + let edges = self.graph.edges(); + + if !self.blocks_are_pairwise_distinct(config, block_size) { + return false; + } + + config + .chunks_exact(block_size) + .all(|block| self.block_is_valid_tree(block, &edges)) + } +} + +impl Problem for KthBestSpanningTree +where + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "KthBestSpanningTree"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![W] + } + + fn dims(&self) -> Vec { + vec![2; self.k * self.graph.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.evaluate_config(config) + } +} + +impl SatisfactionProblem for KthBestSpanningTree where + W: WeightElement + crate::variant::VariantParam +{ +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "kth_best_spanning_tree_i32", + build: || { + // K4 with weights [1,1,2,2,2,3], k=2, B=4. + // 16 spanning trees; exactly 2 have weight ≤ 4 (both weight 4): + // {01,02,03} (star at 0) and {01,02,13}. + // Satisfying configs = 2 (the two orderings of this pair). + // 12 variables → 2^12 = 4096 configs, fast to enumerate. + let graph = SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]); + let problem = KthBestSpanningTree::new(graph, vec![1, 1, 2, 2, 2, 3], 2, 4); + crate::example_db::specs::satisfaction_example( + problem, + vec![vec![1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 1, 0], vec![0; 12]], + ) + }, + }] +} + +crate::declare_variants! { + default sat KthBestSpanningTree => "2^(num_edges * k)", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/kth_best_spanning_tree.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 7bdb4fea3..202c53275 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -11,6 +11,7 @@ //! - [`GraphPartitioning`]: Minimum bisection (balanced graph partitioning) //! - [`HamiltonianCircuit`]: Hamiltonian circuit (decision problem) //! - [`IsomorphicSpanningTree`]: Isomorphic spanning tree (satisfaction) +//! - [`KthBestSpanningTree`]: K distinct bounded spanning trees (satisfaction) //! - [`KColoring`]: K-vertex coloring //! - [`PartitionIntoTriangles`]: Partition vertices into triangles //! - [`MaximumMatching`]: Maximum weight matching @@ -44,6 +45,7 @@ pub(crate) mod hamiltonian_circuit; pub(crate) mod hamiltonian_path; pub(crate) mod isomorphic_spanning_tree; pub(crate) mod kcoloring; +pub(crate) mod kth_best_spanning_tree; pub(crate) mod length_bounded_disjoint_paths; pub(crate) mod max_cut; pub(crate) mod maximal_is; @@ -77,6 +79,7 @@ pub use hamiltonian_circuit::HamiltonianCircuit; pub use hamiltonian_path::HamiltonianPath; pub use isomorphic_spanning_tree::IsomorphicSpanningTree; pub use kcoloring::KColoring; +pub use kth_best_spanning_tree::KthBestSpanningTree; pub use length_bounded_disjoint_paths::LengthBoundedDisjointPaths; pub use max_cut::MaxCut; pub use maximal_is::MaximalIS; @@ -110,6 +113,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec KthBestSpanningTree { + let graph = SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]); + KthBestSpanningTree::new(graph, vec![1, 1, 2, 2, 2, 3], 2, 4) +} + +fn no_instance() -> KthBestSpanningTree { + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); + let weights = vec![1, 1, 1]; + KthBestSpanningTree::new(graph, weights, 2, 3) +} + +fn small_yes_instance() -> KthBestSpanningTree { + let graph = SimpleGraph::new(3, vec![(0, 1), (0, 2), (1, 2)]); + let weights = vec![1, 1, 1]; + KthBestSpanningTree::new(graph, weights, 2, 2) +} + +/// Star at 0: edges {01,02,03}, then {01,02,13}. +fn yes_witness_config() -> Vec { + vec![ + 1, 1, 1, 0, 0, 0, // block 1: edges 0,1,2 = {01,02,03} + 1, 1, 0, 0, 1, 0, // block 2: edges 0,1,4 = {01,02,13} + ] +} + +#[test] +fn test_kthbestspanningtree_creation() { + let problem = yes_instance(); + + assert_eq!(problem.dims(), vec![2; 12]); + assert_eq!(problem.graph().num_vertices(), 4); + assert_eq!(problem.graph().num_edges(), 6); + assert_eq!(problem.num_vertices(), 4); + assert_eq!(problem.num_edges(), 6); + assert_eq!(problem.k(), 2); + assert_eq!(problem.weights(), &[1, 1, 2, 2, 2, 3]); + assert_eq!(*problem.bound(), 4); + assert!(problem.is_weighted()); + assert_eq!(KthBestSpanningTree::::NAME, "KthBestSpanningTree"); +} + +#[test] +fn test_kthbestspanningtree_evaluation_yes_instance() { + let problem = yes_instance(); + assert!(problem.evaluate(&yes_witness_config())); + assert!(problem.is_valid_solution(&yes_witness_config())); +} + +#[test] +fn test_kthbestspanningtree_evaluation_rejects_duplicate_trees() { + let problem = yes_instance(); + // Same tree in both blocks: {01,02,03} twice + let dup = vec![1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0]; + assert!(!problem.evaluate(&dup)); +} + +#[test] +fn test_kthbestspanningtree_evaluation_rejects_overweight_tree() { + let problem = yes_instance(); + // {01,03,12} w=5 and {01,02,03} w=4: first tree exceeds B=4 + let config = vec![1, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0]; + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_kthbestspanningtree_evaluation_rejects_wrong_length_config() { + let problem = yes_instance(); + assert!(!problem.evaluate(&yes_witness_config()[..11])); +} + +#[test] +fn test_kthbestspanningtree_evaluation_rejects_nonbinary_value() { + let problem = yes_instance(); + let mut config = yes_witness_config(); + config[0] = 2; + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_kthbestspanningtree_solver_exhaustive() { + let problem = yes_instance(); + let solver = BruteForce::new(); + + // Exactly 2 spanning trees have weight ≤ 4, so exactly 2! = 2 satisfying configs. + let all = solver.find_all_satisfying(&problem); + assert_eq!(all.len(), 2); + assert!(all.iter().all(|config| problem.evaluate(config))); +} + +#[test] +fn test_kthbestspanningtree_solver_no_instance() { + let problem = no_instance(); + let solver = BruteForce::new(); + + assert!(solver.find_satisfying(&problem).is_none()); + assert!(solver.find_all_satisfying(&problem).is_empty()); +} + +#[test] +fn test_kthbestspanningtree_small_exhaustive_search() { + let problem = small_yes_instance(); + let solver = BruteForce::new(); + + let all = solver.find_all_satisfying(&problem); + assert_eq!(all.len(), 6); + assert!(all.iter().all(|config| problem.evaluate(config))); +} + +#[test] +fn test_kthbestspanningtree_serialization() { + let problem = yes_instance(); + let json = serde_json::to_string(&problem).unwrap(); + let restored: KthBestSpanningTree = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.num_vertices(), problem.num_vertices()); + assert_eq!(restored.num_edges(), problem.num_edges()); + assert_eq!(restored.k(), problem.k()); + assert_eq!(restored.weights(), problem.weights()); + assert_eq!(restored.bound(), problem.bound()); + assert!(restored.evaluate(&yes_witness_config())); +} + +#[test] +fn test_kthbestspanningtree_single_vertex_accepts_single_empty_tree() { + let problem = KthBestSpanningTree::::new(SimpleGraph::new(1, vec![]), vec![], 1, 0); + assert!(problem.evaluate(&[])); + assert!(problem.is_valid_solution(&[])); +} + +#[test] +fn test_kthbestspanningtree_single_vertex_rejects_multiple_empty_trees() { + let problem = KthBestSpanningTree::::new(SimpleGraph::new(1, vec![]), vec![], 2, 0); + assert!(!problem.evaluate(&[])); +} + +#[test] +#[should_panic(expected = "weights length must match graph num_edges")] +fn test_kthbestspanningtree_creation_rejects_weight_length_mismatch() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let _ = KthBestSpanningTree::new(graph, vec![1], 1, 2); +} + +#[test] +#[should_panic(expected = "k must be positive")] +fn test_kthbestspanningtree_creation_rejects_zero_k() { + let _ = KthBestSpanningTree::::new(SimpleGraph::new(1, vec![]), vec![], 0, 0); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 2afba773a..04f4a9195 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -102,6 +102,15 @@ fn test_all_problems_implement_trait_correctly() { ), "StrongConnectivityAugmentation", ); + check_problem_trait( + &KthBestSpanningTree::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + vec![1, 1, 1], + 1, + 2, + ), + "KthBestSpanningTree", + ); check_problem_trait( &HamiltonianCircuit::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (2, 0)])), "HamiltonianCircuit",