diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index a66536baf..79474972c 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -4262,6 +4262,52 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead _Solution extraction._ For VC solution $C$, return $S = V backslash C$, i.e.\ flip each variable: $s_v = 1 - c_v$. ] +#let gp_mc = load-example("GraphPartitioning", "MaxCut") +#let gp_mc_sol = gp_mc.solutions.at(0) +#let gp_mc_source_edges = gp_mc.source.instance.graph.edges.map(e => (e.at(0), e.at(1))) +#let gp_mc_target_edges = gp_mc.target.instance.graph.edges.map(e => (e.at(0), e.at(1))) +#let gp_mc_weights = gp_mc.target.instance.edge_weights +#let gp_mc_nv = gp_mc.source.instance.graph.num_vertices +#let gp_mc_ne = gp_mc_source_edges.len() +#let gp_mc_penalty = gp_mc_ne + 1 +#let gp_mc_side_a = range(gp_mc_nv).filter(i => gp_mc_sol.source_config.at(i) == 0) +#let gp_mc_side_b = range(gp_mc_nv).filter(i => gp_mc_sol.source_config.at(i) == 1) +#let gp_mc_weight_lo = gp_mc_target_edges.enumerate().filter(((i, e)) => gp_mc_weights.at(i) == gp_mc_penalty - 1).map(((i, e)) => e) +#let gp_mc_weight_hi = gp_mc_target_edges.enumerate().filter(((i, e)) => gp_mc_weights.at(i) == gp_mc_penalty).map(((i, e)) => e) +#let gp_mc_source_cross = gp_mc_source_edges.filter(e => gp_mc_sol.source_config.at(e.at(0)) != gp_mc_sol.source_config.at(e.at(1))) +#let gp_mc_cut_lo = gp_mc_target_edges.enumerate().filter(((i, e)) => + gp_mc_weights.at(i) == gp_mc_penalty - 1 and + gp_mc_sol.target_config.at(e.at(0)) != gp_mc_sol.target_config.at(e.at(1)) +).map(((i, e)) => e) +#let gp_mc_cut_hi = gp_mc_target_edges.enumerate().filter(((i, e)) => + gp_mc_weights.at(i) == gp_mc_penalty and + gp_mc_sol.target_config.at(e.at(0)) != gp_mc_sol.target_config.at(e.at(1)) +).map(((i, e)) => e) +#let gp_mc_cut_value = gp_mc_target_edges.enumerate().filter(((i, e)) => + gp_mc_sol.target_config.at(e.at(0)) != gp_mc_sol.target_config.at(e.at(1)) +).map(((i, e)) => gp_mc_weights.at(i)).sum(default: 0) +#reduction-rule("GraphPartitioning", "MaxCut", + example: true, + example-caption: [6-vertex minimum bisection to weighted Max-Cut], + extra: [ + Here $m = #gp_mc_ne$, so $P = m + 1 = #gp_mc_penalty$ \ + Weight $#(gp_mc_penalty - 1)$ edges (original edges): {#gp_mc_weight_lo.map(e => $(v_#(e.at(0)), v_#(e.at(1)))$).join(", ")} \ + Weight $#gp_mc_penalty$ edges (non-edges): {#gp_mc_weight_hi.map(e => $(v_#(e.at(0)), v_#(e.at(1)))$).join(", ")} \ + Canonical witness $A = {#gp_mc_side_a.map(i => $v_#i$).join(", ")}$, $B = {#gp_mc_side_b.map(i => $v_#i$).join(", ")}$ cuts source edges {#gp_mc_source_cross.map(e => $(v_#(e.at(0)), v_#(e.at(1)))$).join(", ")} and attains weighted cut $#gp_mc_cut_lo.len() * #(gp_mc_penalty - 1) + #gp_mc_cut_hi.len() * #gp_mc_penalty = #gp_mc_cut_value$ #sym.checkmark + ], +)[ + @garey1976 Graph Partitioning minimizes cut edges subject to a perfect-balance constraint, while Max-Cut maximizes a weighted cut without any balance constraint. A standard folklore construction in combinatorial optimization removes that constraint by rewarding every cross-pair equally and then subtracting one unit on original edges. The resulting weighted complete graph forces every optimum to be balanced first, and among balanced cuts it exactly minimizes the original bisection width. +][ + _Construction._ Given a Graph Partitioning instance $G = (V, E)$ with $n = |V|$ and $m = |E|$, set $P = m + 1$. Build the complete graph $G' = (V, E')$ on the same vertex set, where $E'$ contains every unordered pair $\{u, v\}$ with $u != v$. Assign weight $w'_(u, v) = P - 1$ when $(u, v) in E$, and $w'_(u, v) = P$ otherwise. For any partition $(A, B)$ of $V$, the weighted cut in $G'$ is + $ "cut"_(G')(A, B) = P |A| |B| - "cut"_G(A, B). $ + + _Correctness._ ($arrow.r.double$) Let $(A, B)$ be a maximum cut of $G'$. If it were unbalanced, then $|A| |B|$ would be at least one smaller than for a balanced partition $(A', B')$. Hence + $ "cut"_(G')(A', B') - "cut"_(G')(A, B) >= P - ("cut"_G(A', B') - "cut"_G(A, B)) >= P - m > 0, $ + because $0 <= "cut"_G(·, ·) <= m$ and $P = m + 1$. Therefore every maximum cut of $G'$ is balanced. Among balanced partitions, $P |A| |B| = P (n slash 2)^2$ is constant, so maximizing $"cut"_(G')(A, B)$ is equivalent to minimizing $"cut"_G(A, B)$. ($arrow.l.double$) Conversely, every minimum bisection of $G$ is balanced and therefore maximizes $P |A| |B| - "cut"_G(A, B)$ in $G'$. + + _Solution extraction._ Read off the same partition vector on the original vertex set: the Max-Cut bit for vertex $v$ is already the Graph Partitioning bit for $v$. +] + #let mis_clique = load-example("MaximumIndependentSet", "MaximumClique") #let mis_clique_sol = mis_clique.solutions.at(0) #reduction-rule("MaximumIndependentSet", "MaximumClique", diff --git a/src/models/formula/qbf.rs b/src/models/formula/qbf.rs index 5b48522c3..830ddc167 100644 --- a/src/models/formula/qbf.rs +++ b/src/models/formula/qbf.rs @@ -153,7 +153,6 @@ impl QuantifiedBooleanFormulas { } } } - } impl Problem for QuantifiedBooleanFormulas { diff --git a/src/rules/closestvectorproblem_qubo.rs b/src/rules/closestvectorproblem_qubo.rs index 69a2941b5..bfc4b6c73 100644 --- a/src/rules/closestvectorproblem_qubo.rs +++ b/src/rules/closestvectorproblem_qubo.rs @@ -163,8 +163,7 @@ impl ReduceTo> for ClosestVectorProblem { matrix[u][u] = gram[var_u][var_u] * weight_u * weight_u + 2.0 * weight_u * g_lo_minus_h[var_u]; - for v in (u + 1)..total_bits { - let (var_v, weight_v) = bit_terms[v]; + for (v, &(var_v, weight_v)) in bit_terms.iter().enumerate().skip(u + 1) { matrix[u][v] = 2.0 * gram[var_u][var_v] * weight_u * weight_v; } } diff --git a/src/rules/graphpartitioning_maxcut.rs b/src/rules/graphpartitioning_maxcut.rs new file mode 100644 index 000000000..0bf31b369 --- /dev/null +++ b/src/rules/graphpartitioning_maxcut.rs @@ -0,0 +1,108 @@ +//! Reduction from GraphPartitioning to MaxCut on a weighted complete graph. + +use crate::models::graph::{GraphPartitioning, MaxCut}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +/// Result of reducing GraphPartitioning to MaxCut. +#[derive(Debug, Clone)] +pub struct ReductionGPToMaxCut { + target: MaxCut, +} + +#[cfg(any(test, feature = "example-db"))] +const ISSUE_EXAMPLE_WITNESS: [usize; 6] = [0, 0, 0, 1, 1, 1]; + +impl ReductionResult for ReductionGPToMaxCut { + type Source = GraphPartitioning; + type Target = MaxCut; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +#[cfg(any(test, feature = "example-db"))] +fn issue_example() -> GraphPartitioning { + GraphPartitioning::new(SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 4), + (3, 5), + (4, 5), + ], + )) +} + +fn complete_graph_edges_and_weights(graph: &SimpleGraph) -> (Vec<(usize, usize)>, Vec) { + let num_vertices = graph.num_vertices(); + let p = penalty_weight(graph.num_edges()); + let mut edges = Vec::new(); + let mut weights = Vec::new(); + + for u in 0..num_vertices { + for v in (u + 1)..num_vertices { + edges.push((u, v)); + weights.push(if graph.has_edge(u, v) { p - 1 } else { p }); + } + } + + (edges, weights) +} + +fn penalty_weight(num_edges: usize) -> i32 { + i32::try_from(num_edges) + .ok() + .and_then(|num_edges| num_edges.checked_add(1)) + .expect("GraphPartitioning -> MaxCut penalty exceeds i32 range") +} + +#[reduction( + overhead = { + num_vertices = "num_vertices", + num_edges = "num_vertices * (num_vertices - 1) / 2", + } +)] +impl ReduceTo> for GraphPartitioning { + type Result = ReductionGPToMaxCut; + + fn reduce_to(&self) -> Self::Result { + let (edges, weights) = complete_graph_edges_and_weights(self.graph()); + let target = MaxCut::new(SimpleGraph::new(self.num_vertices(), edges), weights); + + ReductionGPToMaxCut { target } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "graphpartitioning_to_maxcut", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, MaxCut>( + issue_example(), + SolutionPair { + source_config: ISSUE_EXAMPLE_WITNESS.to_vec(), + target_config: ISSUE_EXAMPLE_WITNESS.to_vec(), + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/graphpartitioning_maxcut.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index a6b7b5fa1..04062a144 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -11,6 +11,7 @@ mod closestvectorproblem_qubo; pub(crate) mod coloring_qubo; pub(crate) mod factoring_circuit; mod graph; +pub(crate) mod graphpartitioning_maxcut; mod kcoloring_casts; mod knapsack_qubo; mod ksatisfiability_casts; @@ -93,6 +94,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec GraphPartitioning { + super::issue_example() +} + +#[test] +fn test_graphpartitioning_to_maxcut_closed_loop() { + let source = issue_example(); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "GraphPartitioning->MaxCut closed loop", + ); +} + +#[test] +fn test_graphpartitioning_to_maxcut_target_structure() { + let source = issue_example(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + let num_vertices = source.num_vertices(); + let penalty = i32::try_from(source.num_edges()).unwrap() + 1; + + assert_eq!(target.num_vertices(), num_vertices); + assert_eq!(target.num_edges(), num_vertices * (num_vertices - 1) / 2); + + for u in 0..num_vertices { + for v in (u + 1)..num_vertices { + let expected_weight = if source.graph().has_edge(u, v) { + penalty - 1 + } else { + penalty + }; + assert_eq!( + target.edge_weight(u, v), + Some(&expected_weight), + "unexpected weight on edge ({u}, {v})" + ); + } + } +} + +#[test] +fn test_graphpartitioning_to_maxcut_extract_solution_identity() { + let source = issue_example(); + let reduction = ReduceTo::>::reduce_to(&source); + let target_solution = super::ISSUE_EXAMPLE_WITNESS.to_vec(); + + assert_eq!( + reduction.extract_solution(&target_solution), + target_solution + ); +} + +#[test] +fn test_graphpartitioning_to_maxcut_penalty_overflow_panics() { + let result = std::panic::catch_unwind(|| super::penalty_weight(i32::MAX as usize)); + assert!(result.is_err()); +}