Skip to content

Commit 68c744b

Browse files
GiggleLiuisPANNclaude
authored
Fix #120: [Rule] GraphPartitioning to MaxCut (#707)
* Add plan for #120: [Rule] GraphPartitioning to MaxCut * test: add GraphPartitioning to MaxCut reduction tests * feat: add GraphPartitioning to MaxCut reduction * test: add GraphPartitioning to MaxCut fixtures * refactor: harden GraphPartitioning to MaxCut reduction * docs: add GraphPartitioning to MaxCut reduction * chore: remove plan file after implementation * Fix formatting and clippy issues after merge with main - Fix mod.rs ordering for graphpartitioning_maxcut - Fix rustfmt issues in test file and qbf.rs (from merge) - Fix clippy needless_range_loop in closestvectorproblem_qubo.rs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Xiwei Pan <xiwei.pan@connect.hkust-gz.edu.cn> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bb726f8 commit 68c744b

7 files changed

Lines changed: 224 additions & 8 deletions

File tree

docs/paper/reductions.typ

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4262,6 +4262,52 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead
42624262
_Solution extraction._ For VC solution $C$, return $S = V backslash C$, i.e.\ flip each variable: $s_v = 1 - c_v$.
42634263
]
42644264

4265+
#let gp_mc = load-example("GraphPartitioning", "MaxCut")
4266+
#let gp_mc_sol = gp_mc.solutions.at(0)
4267+
#let gp_mc_source_edges = gp_mc.source.instance.graph.edges.map(e => (e.at(0), e.at(1)))
4268+
#let gp_mc_target_edges = gp_mc.target.instance.graph.edges.map(e => (e.at(0), e.at(1)))
4269+
#let gp_mc_weights = gp_mc.target.instance.edge_weights
4270+
#let gp_mc_nv = gp_mc.source.instance.graph.num_vertices
4271+
#let gp_mc_ne = gp_mc_source_edges.len()
4272+
#let gp_mc_penalty = gp_mc_ne + 1
4273+
#let gp_mc_side_a = range(gp_mc_nv).filter(i => gp_mc_sol.source_config.at(i) == 0)
4274+
#let gp_mc_side_b = range(gp_mc_nv).filter(i => gp_mc_sol.source_config.at(i) == 1)
4275+
#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)
4276+
#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)
4277+
#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)))
4278+
#let gp_mc_cut_lo = gp_mc_target_edges.enumerate().filter(((i, e)) =>
4279+
gp_mc_weights.at(i) == gp_mc_penalty - 1 and
4280+
gp_mc_sol.target_config.at(e.at(0)) != gp_mc_sol.target_config.at(e.at(1))
4281+
).map(((i, e)) => e)
4282+
#let gp_mc_cut_hi = gp_mc_target_edges.enumerate().filter(((i, e)) =>
4283+
gp_mc_weights.at(i) == gp_mc_penalty and
4284+
gp_mc_sol.target_config.at(e.at(0)) != gp_mc_sol.target_config.at(e.at(1))
4285+
).map(((i, e)) => e)
4286+
#let gp_mc_cut_value = gp_mc_target_edges.enumerate().filter(((i, e)) =>
4287+
gp_mc_sol.target_config.at(e.at(0)) != gp_mc_sol.target_config.at(e.at(1))
4288+
).map(((i, e)) => gp_mc_weights.at(i)).sum(default: 0)
4289+
#reduction-rule("GraphPartitioning", "MaxCut",
4290+
example: true,
4291+
example-caption: [6-vertex minimum bisection to weighted Max-Cut],
4292+
extra: [
4293+
Here $m = #gp_mc_ne$, so $P = m + 1 = #gp_mc_penalty$ \
4294+
Weight $#(gp_mc_penalty - 1)$ edges (original edges): {#gp_mc_weight_lo.map(e => $(v_#(e.at(0)), v_#(e.at(1)))$).join(", ")} \
4295+
Weight $#gp_mc_penalty$ edges (non-edges): {#gp_mc_weight_hi.map(e => $(v_#(e.at(0)), v_#(e.at(1)))$).join(", ")} \
4296+
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
4297+
],
4298+
)[
4299+
@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.
4300+
][
4301+
_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
4302+
$ "cut"_(G')(A, B) = P |A| |B| - "cut"_G(A, B). $
4303+
4304+
_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
4305+
$ "cut"_(G')(A', B') - "cut"_(G')(A, B) >= P - ("cut"_G(A', B') - "cut"_G(A, B)) >= P - m > 0, $
4306+
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'$.
4307+
4308+
_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$.
4309+
]
4310+
42654311
#let mis_clique = load-example("MaximumIndependentSet", "MaximumClique")
42664312
#let mis_clique_sol = mis_clique.solutions.at(0)
42674313
#reduction-rule("MaximumIndependentSet", "MaximumClique",

src/models/formula/qbf.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,6 @@ impl QuantifiedBooleanFormulas {
153153
}
154154
}
155155
}
156-
157156
}
158157

159158
impl Problem for QuantifiedBooleanFormulas {

src/rules/closestvectorproblem_qubo.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,7 @@ impl ReduceTo<QUBO<f64>> for ClosestVectorProblem<i32> {
163163
matrix[u][u] =
164164
gram[var_u][var_u] * weight_u * weight_u + 2.0 * weight_u * g_lo_minus_h[var_u];
165165

166-
for v in (u + 1)..total_bits {
167-
let (var_v, weight_v) = bit_terms[v];
166+
for (v, &(var_v, weight_v)) in bit_terms.iter().enumerate().skip(u + 1) {
168167
matrix[u][v] = 2.0 * gram[var_u][var_v] * weight_u * weight_v;
169168
}
170169
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
//! Reduction from GraphPartitioning to MaxCut on a weighted complete graph.
2+
3+
use crate::models::graph::{GraphPartitioning, MaxCut};
4+
use crate::reduction;
5+
use crate::rules::traits::{ReduceTo, ReductionResult};
6+
use crate::topology::{Graph, SimpleGraph};
7+
8+
/// Result of reducing GraphPartitioning to MaxCut.
9+
#[derive(Debug, Clone)]
10+
pub struct ReductionGPToMaxCut {
11+
target: MaxCut<SimpleGraph, i32>,
12+
}
13+
14+
#[cfg(any(test, feature = "example-db"))]
15+
const ISSUE_EXAMPLE_WITNESS: [usize; 6] = [0, 0, 0, 1, 1, 1];
16+
17+
impl ReductionResult for ReductionGPToMaxCut {
18+
type Source = GraphPartitioning<SimpleGraph>;
19+
type Target = MaxCut<SimpleGraph, i32>;
20+
21+
fn target_problem(&self) -> &Self::Target {
22+
&self.target
23+
}
24+
25+
fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
26+
target_solution.to_vec()
27+
}
28+
}
29+
30+
#[cfg(any(test, feature = "example-db"))]
31+
fn issue_example() -> GraphPartitioning<SimpleGraph> {
32+
GraphPartitioning::new(SimpleGraph::new(
33+
6,
34+
vec![
35+
(0, 1),
36+
(0, 2),
37+
(1, 2),
38+
(1, 3),
39+
(2, 3),
40+
(2, 4),
41+
(3, 4),
42+
(3, 5),
43+
(4, 5),
44+
],
45+
))
46+
}
47+
48+
fn complete_graph_edges_and_weights(graph: &SimpleGraph) -> (Vec<(usize, usize)>, Vec<i32>) {
49+
let num_vertices = graph.num_vertices();
50+
let p = penalty_weight(graph.num_edges());
51+
let mut edges = Vec::new();
52+
let mut weights = Vec::new();
53+
54+
for u in 0..num_vertices {
55+
for v in (u + 1)..num_vertices {
56+
edges.push((u, v));
57+
weights.push(if graph.has_edge(u, v) { p - 1 } else { p });
58+
}
59+
}
60+
61+
(edges, weights)
62+
}
63+
64+
fn penalty_weight(num_edges: usize) -> i32 {
65+
i32::try_from(num_edges)
66+
.ok()
67+
.and_then(|num_edges| num_edges.checked_add(1))
68+
.expect("GraphPartitioning -> MaxCut penalty exceeds i32 range")
69+
}
70+
71+
#[reduction(
72+
overhead = {
73+
num_vertices = "num_vertices",
74+
num_edges = "num_vertices * (num_vertices - 1) / 2",
75+
}
76+
)]
77+
impl ReduceTo<MaxCut<SimpleGraph, i32>> for GraphPartitioning<SimpleGraph> {
78+
type Result = ReductionGPToMaxCut;
79+
80+
fn reduce_to(&self) -> Self::Result {
81+
let (edges, weights) = complete_graph_edges_and_weights(self.graph());
82+
let target = MaxCut::new(SimpleGraph::new(self.num_vertices(), edges), weights);
83+
84+
ReductionGPToMaxCut { target }
85+
}
86+
}
87+
88+
#[cfg(feature = "example-db")]
89+
pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::RuleExampleSpec> {
90+
use crate::export::SolutionPair;
91+
92+
vec![crate::example_db::specs::RuleExampleSpec {
93+
id: "graphpartitioning_to_maxcut",
94+
build: || {
95+
crate::example_db::specs::rule_example_with_witness::<_, MaxCut<SimpleGraph, i32>>(
96+
issue_example(),
97+
SolutionPair {
98+
source_config: ISSUE_EXAMPLE_WITNESS.to_vec(),
99+
target_config: ISSUE_EXAMPLE_WITNESS.to_vec(),
100+
},
101+
)
102+
},
103+
}]
104+
}
105+
106+
#[cfg(test)]
107+
#[path = "../unit_tests/rules/graphpartitioning_maxcut.rs"]
108+
mod tests;

src/rules/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod closestvectorproblem_qubo;
1111
pub(crate) mod coloring_qubo;
1212
pub(crate) mod factoring_circuit;
1313
mod graph;
14+
pub(crate) mod graphpartitioning_maxcut;
1415
mod kcoloring_casts;
1516
mod knapsack_qubo;
1617
mod ksatisfiability_casts;
@@ -93,6 +94,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::Ru
9394
specs.extend(closestvectorproblem_qubo::canonical_rule_example_specs());
9495
specs.extend(coloring_qubo::canonical_rule_example_specs());
9596
specs.extend(factoring_circuit::canonical_rule_example_specs());
97+
specs.extend(graphpartitioning_maxcut::canonical_rule_example_specs());
9698
specs.extend(knapsack_qubo::canonical_rule_example_specs());
9799
specs.extend(ksatisfiability_qubo::canonical_rule_example_specs());
98100
specs.extend(ksatisfiability_subsetsum::canonical_rule_example_specs());

src/unit_tests/models/formula/qbf.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,10 +255,7 @@ fn test_qbf_quantifier_clone() {
255255
#[test]
256256
fn test_qbf_empty_clause() {
257257
// An empty clause (disjunction of zero literals) is always false
258-
let problem = QuantifiedBooleanFormulas::new(
259-
1,
260-
vec![Quantifier::Exists],
261-
vec![CNFClause::new(vec![])],
262-
);
258+
let problem =
259+
QuantifiedBooleanFormulas::new(1, vec![Quantifier::Exists], vec![CNFClause::new(vec![])]);
263260
assert!(!problem.is_true());
264261
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
use crate::models::graph::{GraphPartitioning, MaxCut};
2+
use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target;
3+
use crate::rules::{ReduceTo, ReductionResult};
4+
use crate::topology::{Graph, SimpleGraph};
5+
6+
fn issue_example() -> GraphPartitioning<SimpleGraph> {
7+
super::issue_example()
8+
}
9+
10+
#[test]
11+
fn test_graphpartitioning_to_maxcut_closed_loop() {
12+
let source = issue_example();
13+
let reduction = ReduceTo::<MaxCut<SimpleGraph, i32>>::reduce_to(&source);
14+
15+
assert_optimization_round_trip_from_optimization_target(
16+
&source,
17+
&reduction,
18+
"GraphPartitioning->MaxCut closed loop",
19+
);
20+
}
21+
22+
#[test]
23+
fn test_graphpartitioning_to_maxcut_target_structure() {
24+
let source = issue_example();
25+
let reduction = ReduceTo::<MaxCut<SimpleGraph, i32>>::reduce_to(&source);
26+
let target = reduction.target_problem();
27+
let num_vertices = source.num_vertices();
28+
let penalty = i32::try_from(source.num_edges()).unwrap() + 1;
29+
30+
assert_eq!(target.num_vertices(), num_vertices);
31+
assert_eq!(target.num_edges(), num_vertices * (num_vertices - 1) / 2);
32+
33+
for u in 0..num_vertices {
34+
for v in (u + 1)..num_vertices {
35+
let expected_weight = if source.graph().has_edge(u, v) {
36+
penalty - 1
37+
} else {
38+
penalty
39+
};
40+
assert_eq!(
41+
target.edge_weight(u, v),
42+
Some(&expected_weight),
43+
"unexpected weight on edge ({u}, {v})"
44+
);
45+
}
46+
}
47+
}
48+
49+
#[test]
50+
fn test_graphpartitioning_to_maxcut_extract_solution_identity() {
51+
let source = issue_example();
52+
let reduction = ReduceTo::<MaxCut<SimpleGraph, i32>>::reduce_to(&source);
53+
let target_solution = super::ISSUE_EXAMPLE_WITNESS.to_vec();
54+
55+
assert_eq!(
56+
reduction.extract_solution(&target_solution),
57+
target_solution
58+
);
59+
}
60+
61+
#[test]
62+
fn test_graphpartitioning_to_maxcut_penalty_overflow_panics() {
63+
let result = std::panic::catch_unwind(|| super::penalty_weight(i32::MAX as usize));
64+
assert!(result.is_err());
65+
}

0 commit comments

Comments
 (0)