diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 9167a0c15..7252348d4 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -6680,6 +6680,44 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Sort tasks by their completion times $C_j$ and encode that order back into the source schedule representation. ] +#let hc_tsp = load-example("HamiltonianCircuit", "TravelingSalesman") +#let hc_tsp_sol = hc_tsp.solutions.at(0) +#let hc_tsp_n = graph-num-vertices(hc_tsp.source.instance) +#let hc_tsp_source_edges = hc_tsp.source.instance.graph.edges +#let hc_tsp_target_edges = hc_tsp.target.instance.graph.edges +#let hc_tsp_target_weights = hc_tsp.target.instance.edge_weights +#let hc_tsp_weight_one = hc_tsp_target_edges.enumerate().filter(((i, _)) => hc_tsp_target_weights.at(i) == 1).map(((i, e)) => (e.at(0), e.at(1))) +#let hc_tsp_weight_two = hc_tsp_target_edges.enumerate().filter(((i, _)) => hc_tsp_target_weights.at(i) == 2).map(((i, e)) => (e.at(0), e.at(1))) +#let hc_tsp_selected_edges = hc_tsp_target_edges.enumerate().filter(((i, _)) => hc_tsp_sol.target_config.at(i) == 1).map(((i, e)) => (e.at(0), e.at(1))) +#reduction-rule("HamiltonianCircuit", "TravelingSalesman", + example: true, + example-caption: [Cycle graph on $#hc_tsp_n$ vertices to weighted $K_#hc_tsp_n$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(hc_tsp.source) + " -o hc.json", + "pred reduce hc.json --to " + target-spec(hc_tsp) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate hc.json --config " + hc_tsp_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Start from the source graph.* The canonical source fixture is the cycle on vertices ${0, 1, 2, 3}$ with edges #hc_tsp_source_edges.map(e => $(#e.at(0), #e.at(1))$).join(", "). The stored Hamiltonian-circuit witness is the permutation $[#hc_tsp_sol.source_config.map(str).join(", ")]$.\ + + *Step 2 -- Complete the graph and encode adjacency by weights.* The target keeps the same $#hc_tsp_n$ vertices but adds the missing diagonals, so it becomes $K_#hc_tsp_n$ with $#graph-num-edges(hc_tsp.target.instance)$ undirected edges. The original cycle edges #hc_tsp_weight_one.map(e => $(#e.at(0), #e.at(1))$).join(", ") receive weight 1, while the diagonals #hc_tsp_weight_two.map(e => $(#e.at(0), #e.at(1))$).join(", ") receive weight 2.\ + + *Step 3 -- Verify the canonical witness.* The stored target configuration $[#hc_tsp_sol.target_config.map(str).join(", ")]$ selects the tour edges #hc_tsp_selected_edges.map(e => $(#e.at(0), #e.at(1))$).join(", "). Its total cost is $1 + 1 + 1 + 1 = #hc_tsp_n$, so every chosen edge is a weight-1 source edge, and traversing the selected cycle recovers the Hamiltonian circuit $[#hc_tsp_sol.source_config.map(str).join(", ")]$.\ + + *Multiplicity:* The fixture stores one canonical witness. For the 4-cycle there are $4 times 2 = 8$ Hamiltonian-circuit permutations (choice of start vertex and direction), but they all induce the same undirected target edge set. + ], +)[ + @garey1979 This $O(n^2)$ reduction constructs the complete graph on the same vertex set and uses edge weights to distinguish source edges from non-edges: weight 1 means "present in the source" and weight 2 means "missing in the source" ($n (n - 1) / 2$ target edges). +][ + _Construction._ Given a Hamiltonian Circuit instance $G = (V, E)$ with $n = |V|$, construct the complete graph $K_n$ on the same vertex set. For each pair $u < v$, set $w(u, v) = 1$ if $(u, v) in E$ and $w(u, v) = 2$ otherwise. The target TSP instance asks for a minimum-weight Hamiltonian cycle in this weighted complete graph. + + _Correctness._ ($arrow.r.double$) If $G$ has a Hamiltonian circuit $v_0, v_1, dots, v_(n-1), v_0$, then the same cycle exists in $K_n$. Every chosen edge belongs to $E$, so each edge has weight 1 and the resulting TSP tour has total cost $n$. ($arrow.l.double$) Every TSP tour on $n$ vertices uses exactly $n$ edges, and every target edge has weight at least 1, so any tour has cost at least $n$. If the optimum cost is exactly $n$, every selected edge must therefore have weight 1. Those edges are precisely edges of $G$, so the optimal TSP tour is already a Hamiltonian circuit in the source graph. + + _Solution extraction._ Read the selected TSP edges, traverse the unique degree-2 cycle they form, and return the resulting vertex permutation as the source Hamiltonian-circuit witness. +] + #let tsp_ilp = load-example("TravelingSalesman", "ILP") #let tsp_ilp_sol = tsp_ilp.solutions.at(0) #reduction-rule("TravelingSalesman", "ILP", diff --git a/src/rules/hamiltoniancircuit_travelingsalesman.rs b/src/rules/hamiltoniancircuit_travelingsalesman.rs new file mode 100644 index 000000000..f34703f7d --- /dev/null +++ b/src/rules/hamiltoniancircuit_travelingsalesman.rs @@ -0,0 +1,130 @@ +//! Reduction from HamiltonianCircuit to TravelingSalesman. +//! +//! The standard construction embeds the source graph into the complete graph on the +//! same vertex set, assigning weight 1 to source edges and weight 2 to non-edges. +//! The target optimum is exactly n iff the source graph contains a Hamiltonian circuit. + +use crate::models::graph::{HamiltonianCircuit, TravelingSalesman}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +/// Result of reducing HamiltonianCircuit to TravelingSalesman. +#[derive(Debug, Clone)] +pub struct ReductionHamiltonianCircuitToTravelingSalesman { + target: TravelingSalesman, +} + +impl ReductionResult for ReductionHamiltonianCircuitToTravelingSalesman { + type Source = HamiltonianCircuit; + type Target = TravelingSalesman; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let graph = self.target.graph(); + let n = graph.num_vertices(); + if n == 0 { + return vec![]; + } + + let edges = graph.edges(); + if target_solution.len() != edges.len() { + return vec![0; n]; + } + + let mut adjacency = vec![Vec::new(); n]; + let mut selected_count = 0usize; + for (idx, &selected) in target_solution.iter().enumerate() { + if selected != 1 { + continue; + } + let (u, v) = edges[idx]; + adjacency[u].push(v); + adjacency[v].push(u); + selected_count += 1; + } + + if selected_count != n || adjacency.iter().any(|neighbors| neighbors.len() != 2) { + return vec![0; n]; + } + + for neighbors in &mut adjacency { + neighbors.sort_unstable(); + } + + let mut order = Vec::with_capacity(n); + let mut prev = None; + let mut current = 0usize; + + for _ in 0..n { + order.push(current); + let neighbors = &adjacency[current]; + let next = match prev { + Some(previous) => { + if neighbors[0] == previous { + neighbors[1] + } else { + neighbors[0] + } + } + None => neighbors[0], + }; + prev = Some(current); + current = next; + } + + order + } +} + +#[reduction( + overhead = { + num_vertices = "num_vertices", + num_edges = "num_vertices * (num_vertices - 1) / 2", + } +)] +impl ReduceTo> for HamiltonianCircuit { + type Result = ReductionHamiltonianCircuitToTravelingSalesman; + + fn reduce_to(&self) -> Self::Result { + let num_vertices = self.num_vertices(); + let target_graph = SimpleGraph::complete(num_vertices); + let weights = target_graph + .edges() + .into_iter() + .map(|(u, v)| if self.graph().has_edge(u, v) { 1 } else { 2 }) + .collect(); + let target = TravelingSalesman::new(target_graph, weights); + + ReductionHamiltonianCircuitToTravelingSalesman { target } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "hamiltoniancircuit_to_travelingsalesman", + build: || { + let source = HamiltonianCircuit::new(SimpleGraph::cycle(4)); + crate::example_db::specs::rule_example_with_witness::< + _, + TravelingSalesman, + >( + source, + SolutionPair { + source_config: vec![0, 1, 2, 3], + target_config: vec![1, 0, 1, 1, 0, 1], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index d7929687b..421832b71 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod factoring_circuit; mod graph; pub(crate) mod graphpartitioning_maxcut; pub(crate) mod graphpartitioning_qubo; +pub(crate) mod hamiltoniancircuit_travelingsalesman; mod kcoloring_casts; mod knapsack_qubo; mod ksatisfiability_casts; @@ -105,6 +106,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec HamiltonianCircuit { + HamiltonianCircuit::new(SimpleGraph::cycle(4)) +} + +#[test] +fn test_hamiltoniancircuit_to_travelingsalesman_closed_loop() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "HamiltonianCircuit -> TravelingSalesman", + ); +} + +#[test] +fn test_hamiltoniancircuit_to_travelingsalesman_structure() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.graph().num_vertices(), 4); + assert_eq!(target.graph().num_edges(), 6); + + for ((u, v), weight) in target.graph().edges().into_iter().zip(target.weights()) { + let expected = if source.graph().has_edge(u, v) { 1 } else { 2 }; + assert_eq!(weight, expected, "unexpected weight on edge ({u}, {v})"); + } +} + +#[test] +fn test_hamiltoniancircuit_to_travelingsalesman_nonhamiltonian_cost_gap() { + let source = HamiltonianCircuit::new(SimpleGraph::star(4)); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + let best = BruteForce::new() + .find_best(target) + .expect("complete weighted graph should always admit a tour"); + + match target.evaluate(&best) { + SolutionSize::Valid(cost) => assert!(cost > 4, "expected cost > 4, got {cost}"), + SolutionSize::Invalid => panic!("best TSP solution evaluated as invalid"), + } +} + +#[test] +fn test_hamiltoniancircuit_to_travelingsalesman_extract_solution_cycle() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + let cycle_edges = [(0usize, 1usize), (1, 2), (2, 3), (0, 3)]; + let target_solution: Vec = target + .graph() + .edges() + .into_iter() + .map(|(u, v)| usize::from(cycle_edges.contains(&(u, v)) || cycle_edges.contains(&(v, u)))) + .collect(); + + let extracted = reduction.extract_solution(&target_solution); + + assert_eq!(target.evaluate(&target_solution), SolutionSize::Valid(4)); + assert_eq!(extracted.len(), 4); + assert!(source.evaluate(&extracted)); +}