diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 83c5c5f7f..3e8213c9d 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -81,6 +81,7 @@ "KClique": [$k$-Clique], "MinimumDominatingSet": [Minimum Dominating Set], "MaximumMatching": [Maximum Matching], + "BottleneckTravelingSalesman": [Bottleneck Traveling Salesman], "TravelingSalesman": [Traveling Salesman], "MaximumClique": [Maximum Clique], "MaximumSetPacking": [Maximum Set Packing], @@ -1286,6 +1287,96 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] } +#{ + let x = load-model-example("BottleneckTravelingSalesman") + let nv = graph-num-vertices(x.instance) + let edges = x.instance.graph.edges + let ew = x.instance.edge_weights + let sol = (config: x.optimal_config, metric: x.optimal_value) + let tour-edges = sol.config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => edges.at(i)) + let bottleneck = sol.metric.Valid + let tour-weights = tour-edges.map(((u, v)) => { + let idx = edges.position(e => e == (u, v) or e == (v, u)) + int(ew.at(idx)) + }) + let tour-total = tour-weights.sum() + let tour-order = (0,) + let remaining = tour-edges + for _ in range(nv - 1) { + let curr = tour-order.last() + let next-edge = remaining.find(e => e.at(0) == curr or e.at(1) == curr) + let next-v = if next-edge.at(0) == curr { next-edge.at(1) } else { next-edge.at(0) } + tour-order.push(next-v) + remaining = remaining.filter(e => e != next-edge) + } + let tsp-order = (0, 2, 3, 1, 4) + let tsp-total = 13 + let tsp-bottleneck = 5 + let weight-labels = edges.map(((u, v)) => { + let idx = edges.position(e => e == (u, v)) + (u: u, v: v, w: ew.at(idx)) + }) + [ + #problem-def("BottleneckTravelingSalesman")[ + Given an undirected graph $G=(V,E)$ with edge weights $w: E -> RR$, find an edge set $C subset.eq E$ that forms a cycle visiting every vertex exactly once and minimizes $max_(e in C) w(e)$. + ][ + This min-max variant models routing where the worst leg matters more than the total distance. Garey and Johnson list the threshold decision version as ND24 @garey1979: given a bound $B$, ask whether some Hamiltonian tour has every edge weight at most $B$. The optimization version implemented here subsumes that decision problem. The classical Held--Karp dynamic programming algorithm still yields an exact $O(n^2 dot 2^n)$-time algorithm @heldkarp1962, while Garey and Johnson note the polynomial-time special case of Gilmore and Gomory @gilmore1964. + + *Example.* Consider the complete graph $K_#nv$ with vertices ${#range(nv).map(i => $v_#i$).join(", ")}$ and edge weights #weight-labels.map(l => $w(v_#(l.u), v_#(l.v)) = #(int(l.w))$).join(", "). The unique optimal bottleneck tour is $#tour-order.map(v => $v_#v$).join($arrow$) arrow v_#(tour-order.at(0))$ with edge weights #tour-weights.map(w => str(w)).join(", ") and bottleneck #bottleneck. Its total weight is #tour-total. By contrast, the minimum-total-weight TSP tour $#tsp-order.map(v => $v_#v$).join($arrow$) arrow v_#(tsp-order.at(0))$ has total weight #tsp-total but bottleneck #tsp-bottleneck, because it uses the weight-5 edge $(v_0, v_4)$. Here every other Hamiltonian tour in $K_#nv$ contains a weight-5 edge, so the blue tour is the only one that keeps the maximum edge weight at 4. + + #figure({ + let verts = ((0, 1.8), (1.7, 0.55), (1.05, -1.45), (-1.05, -1.45), (-1.7, 0.55)) + canvas(length: 1cm, { + for (idx, (u, v)) in edges.enumerate() { + let on-tour = tour-edges.any(t => (t.at(0) == u and t.at(1) == v) or (t.at(0) == v and t.at(1) == u)) + let on-tsp-only = (u == 0 and v == 4) or (u == 4 and v == 0) + g-edge( + verts.at(u), + verts.at(v), + stroke: if on-tour { + 2pt + graph-colors.at(0) + } else if on-tsp-only { + 1.5pt + rgb("#c44e38") + } else { + 0.8pt + 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 + let (dx, dy) = if u == 0 and v == 1 { + (0.16, 0.2) + } else if u == 0 and v == 2 { + (0.25, 0.03) + } else if u == 0 and v == 3 { + (-0.25, 0.03) + } else if u == 0 and v == 4 { + (-0.16, 0.2) + } else if u == 1 and v == 2 { + (0.22, -0.05) + } else if u == 1 and v == 3 { + (0.12, -0.18) + } else if u == 1 and v == 4 { + (0, 0.12) + } else if u == 2 and v == 3 { + (0, -0.2) + } else if u == 2 and v == 4 { + (-0.12, -0.18) + } else { + (-0.22, -0.05) + } + draw.content((mx + dx, my + dy), text(7pt, fill: luma(80))[#str(int(ew.at(idx)))]) + } + for (k, pos) in verts.enumerate() { + g-node(pos, name: "v" + str(k), fill: graph-colors.at(0), label: text(fill: white)[$v_#k$]) + } + }) + }, + caption: [The $K_5$ bottleneck-TSP instance. Blue edges form the unique optimal bottleneck tour; the red edge $(v_0, v_4)$ is the weight-5 edge used by the minimum-total-weight TSP tour.], + ) + ] + ] +} + #{ let x = load-model-example("TravelingSalesman") let nv = graph-num-vertices(x.instance) diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 40833d6ac..f24f1a092 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -449,6 +449,17 @@ @article{heldkarp1962 doi = {10.1137/0110015} } +@article{gilmore1964, + author = {P. C. Gilmore and R. E. Gomory}, + title = {Sequencing a One State-Variable Machine: A Solvable Case of the Traveling Salesman Problem}, + journal = {Operations Research}, + volume = {12}, + number = {5}, + pages = {655--679}, + year = {1964}, + doi = {10.1287/opre.12.5.655} +} + @article{beigel2005, author = {Richard Beigel and David Eppstein}, title = {3-Coloring in Time {$O(1.3289^n)$}}, diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index d5856d619..f10cb96b4 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -216,7 +216,7 @@ TIP: Run `pred create ` (no other flags) to see problem-specific help. Flags by problem type: MIS, MVC, MaxClique, MinDomSet --graph, --weights - MaxCut, MaxMatching, TSP --graph, --edge-weights + MaxCut, MaxMatching, TSP, BottleneckTravelingSalesman --graph, --edge-weights ShortestWeightConstrainedPath --graph, --edge-lengths, --edge-weights, --source-vertex, --target-vertex, --length-bound, --weight-bound MaximalIS --graph, --weights SAT, NAESAT --num-vars, --clauses diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 7d526a368..955aa441f 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -527,7 +527,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { } "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" => { + "BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" } "ShortestWeightConstrainedPath" => { @@ -649,12 +649,13 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { fn uses_edge_weights_flag(canonical: &str) -> bool { matches!( canonical, - "KthBestSpanningTree" + "BottleneckTravelingSalesman" + | "KthBestSpanningTree" | "MaxCut" | "MaximumMatching" - | "TravelingSalesman" - | "RuralPostman" | "MixedChinesePostman" + | "RuralPostman" + | "TravelingSalesman" ) } @@ -1458,7 +1459,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { } // Graph problems with edge weights - "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { + "BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; let (graph, _) = parse_graph(args).map_err(|e| { anyhow::anyhow!( @@ -1468,6 +1469,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { })?; let edge_weights = parse_edge_weights(args, graph.num_edges())?; let data = match canonical { + "BottleneckTravelingSalesman" => ser(BottleneckTravelingSalesman::new(graph, edge_weights))?, "MaxCut" => ser(MaxCut::new(graph, edge_weights))?, "MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?, "TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?, @@ -5186,7 +5188,7 @@ fn create_random( } // Graph problems with edge weights - "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { + "BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { 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"); @@ -5194,8 +5196,14 @@ fn create_random( 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]; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + let variant = match canonical { + "BottleneckTravelingSalesman" => variant_map(&[]), + _ => variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]), + }; let data = match canonical { + "BottleneckTravelingSalesman" => { + ser(BottleneckTravelingSalesman::new(graph, edge_weights))? + } "MaxCut" => ser(MaxCut::new(graph, edge_weights))?, "MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?, "TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?, @@ -5303,7 +5311,8 @@ fn create_random( "Random generation is not supported for {canonical}. \ Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \ MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, TravelingSalesman, \ - SteinerTreeInGraphs, HamiltonianCircuit, SteinerTree, OptimalLinearArrangement, HamiltonianPath, GeneralizedHex)" + BottleneckTravelingSalesman, SteinerTreeInGraphs, HamiltonianCircuit, SteinerTree, \ + OptimalLinearArrangement, HamiltonianPath, GeneralizedHex)" ), }; diff --git a/src/lib.rs b/src/lib.rs index 38d1ccfb7..5d7f10b32 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,7 +49,7 @@ pub mod prelude { }; pub use crate::models::graph::{ AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, - BiconnectivityAugmentation, BoundedComponentSpanningForest, + BiconnectivityAugmentation, BottleneckTravelingSalesman, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KClique, KthBestSpanningTree, LengthBoundedDisjointPaths, MixedChinesePostman, SpinGlass, SteinerTree, diff --git a/src/models/graph/bottleneck_traveling_salesman.rs b/src/models/graph/bottleneck_traveling_salesman.rs new file mode 100644 index 000000000..e86b7c95e --- /dev/null +++ b/src/models/graph/bottleneck_traveling_salesman.rs @@ -0,0 +1,173 @@ +//! Bottleneck Traveling Salesman problem implementation. +//! +//! The Bottleneck Traveling Salesman problem asks for a Hamiltonian cycle +//! minimizing the maximum selected edge weight. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::{Direction, SolutionSize}; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "BottleneckTravelingSalesman", + display_name: "Bottleneck Traveling Salesman", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Find a Hamiltonian cycle minimizing the maximum selected edge weight", + fields: &[ + FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> Z" }, + ], + } +} + +/// The Bottleneck Traveling Salesman problem on a simple weighted graph. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BottleneckTravelingSalesman { + graph: SimpleGraph, + edge_weights: Vec, +} + +impl BottleneckTravelingSalesman { + /// Create a BottleneckTravelingSalesman problem from a graph with edge weights. + pub fn new(graph: SimpleGraph, edge_weights: Vec) -> Self { + assert_eq!( + edge_weights.len(), + graph.num_edges(), + "edge_weights length must match num_edges" + ); + Self { + graph, + edge_weights, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &SimpleGraph { + &self.graph + } + + /// Get the weights for the problem. + pub fn weights(&self) -> Vec { + self.edge_weights.clone() + } + + /// 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 all edges with their weights. + pub fn edges(&self) -> Vec<(usize, usize, i32)> { + self.graph + .edges() + .into_iter() + .zip(self.edge_weights.iter().copied()) + .map(|((u, v), w)| (u, v, w)) + .collect() + } + + /// 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() + } + + /// This model is always weighted. + pub fn is_weighted(&self) -> bool { + true + } + + /// Check if a configuration is a valid Hamiltonian cycle. + 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(); + super::traveling_salesman::is_hamiltonian_cycle(&self.graph, &selected) + } +} + +impl Problem for BottleneckTravelingSalesman { + const NAME: &'static str = "BottleneckTravelingSalesman"; + type Metric = SolutionSize; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + 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 !super::traveling_salesman::is_hamiltonian_cycle(&self.graph, &selected) { + return SolutionSize::Invalid; + } + + let bottleneck = config + .iter() + .zip(self.edge_weights.iter()) + .filter_map(|(&selected, &weight)| (selected == 1).then_some(weight)) + .max() + .expect("valid Hamiltonian cycle selects at least one edge"); + + SolutionSize::Valid(bottleneck) + } +} + +impl OptimizationProblem for BottleneckTravelingSalesman { + type Value = i32; + + fn direction(&self) -> Direction { + Direction::Minimize + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "bottleneck_traveling_salesman", + instance: Box::new(BottleneckTravelingSalesman::new( + SimpleGraph::new( + 5, + vec![ + (0, 1), + (0, 2), + (0, 3), + (0, 4), + (1, 2), + (1, 3), + (1, 4), + (2, 3), + (2, 4), + (3, 4), + ], + ), + vec![5, 4, 4, 5, 4, 1, 2, 1, 5, 4], + )), + optimal_config: vec![0, 1, 1, 0, 1, 0, 1, 0, 0, 1], + optimal_value: serde_json::json!({"Valid": 4}), + }] +} + +crate::declare_variants! { + default opt BottleneckTravelingSalesman => "num_vertices^2 * 2^num_vertices", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/bottleneck_traveling_salesman.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 0c95f91cf..dbdb632f8 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -29,6 +29,7 @@ //! - [`BalancedCompleteBipartiteSubgraph`]: Balanced biclique decision problem //! - [`BiconnectivityAugmentation`]: Biconnectivity augmentation with weighted potential edges //! - [`BoundedComponentSpanningForest`]: Partition vertices into bounded-weight connected components +//! - [`BottleneckTravelingSalesman`]: Hamiltonian cycle minimizing the maximum selected edge weight //! - [`MultipleCopyFileAllocation`]: File-copy placement under storage and access costs //! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K) //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs @@ -48,6 +49,7 @@ pub(crate) mod acyclic_partition; pub(crate) mod balanced_complete_bipartite_subgraph; pub(crate) mod biclique_cover; pub(crate) mod biconnectivity_augmentation; +pub(crate) mod bottleneck_traveling_salesman; pub(crate) mod bounded_component_spanning_forest; pub(crate) mod directed_two_commodity_integral_flow; pub(crate) mod generalized_hex; @@ -92,6 +94,7 @@ pub use acyclic_partition::AcyclicPartition; pub use balanced_complete_bipartite_subgraph::BalancedCompleteBipartiteSubgraph; pub use biclique_cover::BicliqueCover; pub use biconnectivity_augmentation::BiconnectivityAugmentation; +pub use bottleneck_traveling_salesman::BottleneckTravelingSalesman; pub use bounded_component_spanning_forest::BoundedComponentSpanningForest; pub use directed_two_commodity_integral_flow::DirectedTwoCommodityIntegralFlow; pub use generalized_hex::GeneralizedHex; @@ -164,6 +167,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec BottleneckTravelingSalesman { + BottleneckTravelingSalesman::new( + SimpleGraph::new( + 5, + vec![ + (0, 1), + (0, 2), + (0, 3), + (0, 4), + (1, 2), + (1, 3), + (1, 4), + (2, 3), + (2, 4), + (3, 4), + ], + ), + vec![5, 4, 4, 5, 4, 1, 2, 1, 5, 4], + ) +} + +#[test] +fn test_bottleneck_traveling_salesman_creation_and_size_getters() { + let mut problem = k5_btsp(); + + assert_eq!(problem.graph().num_vertices(), 5); + assert_eq!(problem.num_vertices(), 5); + assert_eq!(problem.graph().num_edges(), 10); + assert_eq!(problem.num_edges(), 10); + assert_eq!(problem.dims(), vec![2; 10]); + assert_eq!(problem.num_variables(), 10); + assert_eq!(problem.weights(), vec![5, 4, 4, 5, 4, 1, 2, 1, 5, 4]); + assert_eq!( + problem.edges(), + vec![ + (0, 1, 5), + (0, 2, 4), + (0, 3, 4), + (0, 4, 5), + (1, 2, 4), + (1, 3, 1), + (1, 4, 2), + (2, 3, 1), + (2, 4, 5), + (3, 4, 4), + ] + ); + assert!(problem.is_weighted()); + + problem.set_weights(vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + assert_eq!(problem.weights(), vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); +} + +#[test] +fn test_bottleneck_traveling_salesman_evaluate_valid_and_invalid() { + let problem = k5_btsp(); + + let valid_cycle = vec![0, 1, 1, 0, 1, 0, 1, 0, 0, 1]; + assert!(problem.is_valid_solution(&valid_cycle)); + assert_eq!(problem.evaluate(&valid_cycle), SolutionSize::Valid(4)); + + let degree_violation = vec![1, 1, 1, 0, 1, 0, 1, 0, 0, 1]; + assert!(!problem.is_valid_solution(°ree_violation)); + assert_eq!(problem.evaluate(°ree_violation), SolutionSize::Invalid); +} + +#[test] +fn test_bottleneck_traveling_salesman_evaluate_disconnected_subtour_invalid() { + let problem = BottleneckTravelingSalesman::new( + SimpleGraph::new( + 6, + vec![(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)], + ), + vec![1, 1, 1, 2, 2, 2], + ); + + let disconnected_subtour = vec![1, 1, 1, 1, 1, 1]; + assert!(!problem.is_valid_solution(&disconnected_subtour)); + assert_eq!(problem.evaluate(&disconnected_subtour), SolutionSize::Invalid); +} + +#[test] +fn test_bottleneck_traveling_salesman_direction() { + let problem = BottleneckTravelingSalesman::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + vec![7, 4, 6], + ); + + assert_eq!(problem.direction(), Direction::Minimize); +} + +#[test] +fn test_bottleneck_traveling_salesman_bruteforce_unique_optimum() { + let problem = k5_btsp(); + let solver = BruteForce::new(); + let best = solver.find_all_best(&problem); + + assert_eq!(best, vec![vec![0, 1, 1, 0, 1, 0, 1, 0, 0, 1]]); + assert_eq!(problem.evaluate(&best[0]), SolutionSize::Valid(4)); +} + +#[test] +fn test_bottleneck_traveling_salesman_serialization() { + let problem = k5_btsp(); + + let json = serde_json::to_string(&problem).unwrap(); + let restored: BottleneckTravelingSalesman = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.graph(), problem.graph()); + assert_eq!(restored.weights(), problem.weights()); + assert_eq!( + restored.evaluate(&[0, 1, 1, 0, 1, 0, 1, 0, 0, 1]), + SolutionSize::Valid(4) + ); +} + +#[test] +fn test_bottleneck_traveling_salesman_paper_example() { + let problem = k5_btsp(); + let config = vec![0, 1, 1, 0, 1, 0, 1, 0, 0, 1]; + + assert!(problem.is_valid_solution(&config)); + assert_eq!(problem.evaluate(&config), SolutionSize::Valid(4)); + + let solver = BruteForce::new(); + let best = solver.find_all_best(&problem); + assert_eq!(best.len(), 1); + assert_eq!(best[0], config); +}