Skip to content
Merged
43 changes: 43 additions & 0 deletions docs/paper/reductions.typ
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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$.],
) <fig:kth-best-spanning-tree>
]
]
}
#{
let x = load-model-example("KColoring")
let nv = graph-num-vertices(x.instance)
Expand Down
22 changes: 22 additions & 0 deletions docs/paper/references.bib
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
1 change: 1 addition & 0 deletions docs/src/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions problemreductions-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions problemreductions-cli/src/commands/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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(),
Expand All @@ -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,
Expand Down Expand Up @@ -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]",
Expand All @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions problemreductions-cli/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -282,3 +286,19 @@ pub fn parse_edge_pairs(s: &str) -> Result<Vec<(usize, usize)>> {
})
.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}"
);
}
}
68 changes: 68 additions & 0 deletions problemreductions-cli/tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions src/example_db/fixtures/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}]},
Expand Down
5 changes: 3 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading