diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 915878827..931de158c 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -29,6 +29,7 @@ "MaximumIndependentSet": [Maximum Independent Set], "MinimumVertexCover": [Minimum Vertex Cover], "MaxCut": [Max-Cut], + "GraphPartitioning": [Graph Partitioning], "KColoring": [$k$-Coloring], "MinimumDominatingSet": [Minimum Dominating Set], "MaximumMatching": [Maximum Matching], @@ -379,6 +380,57 @@ Max-Cut is NP-hard on general graphs @barahona1982 but polynomial-time solvable caption: [The house graph with max cut $S = {v_0, v_3}$ (blue) vs $overline(S) = {v_1, v_2, v_4}$ (white). Cut edges shown in bold blue; 5 of 6 edges are cut.], ) ] +#problem-def("GraphPartitioning")[ + Given an undirected graph $G = (V, E)$ with $|V| = n$ (even), find a partition of $V$ into two disjoint sets $A$ and $B$ with $|A| = |B| = n slash 2$ that minimizes the number of edges crossing the partition: + $ "cut"(A, B) = |{(u, v) in E : u in A, v in B}|. $ +][ +Graph Partitioning is a core NP-hard problem arising in VLSI design, parallel computing, and scientific simulation, where balanced workload distribution with minimal communication is essential. Closely related to Max-Cut (which _maximizes_ rather than _minimizes_ the cut) and to the Ising Spin Glass model. NP-completeness was proved by Garey, Johnson and Stockmeyer (1976). Arora, Rao and Vazirani (2009) gave an $O(sqrt(log n))$-approximation algorithm. Standard partitioning tools include METIS, KaHIP, and Scotch. + +*Example.* Consider the graph $G$ with $n = 6$ vertices and 9 edges: $(v_0, v_1)$, $(v_0, v_2)$, $(v_1, v_2)$, $(v_1, v_3)$, $(v_2, v_3)$, $(v_2, v_4)$, $(v_3, v_4)$, $(v_3, v_5)$, $(v_4, v_5)$. The optimal balanced partition is $A = {v_0, v_1, v_2}$, $B = {v_3, v_4, v_5}$, with cut value 3: the crossing edges are $(v_1, v_3)$, $(v_2, v_3)$, $(v_2, v_4)$. All other balanced partitions yield a cut of at least 3. + +#figure( + canvas(length: 1cm, { + // 6-vertex layout: two columns of 3 + let verts = ( + (0, 2), // v0: top-left + (0, 1), // v1: mid-left + (0, 0), // v2: bottom-left + (2.5, 2), // v3: top-right + (2.5, 1), // v4: mid-right + (2.5, 0), // v5: bottom-right + ) + let edges = ((0,1),(0,2),(1,2),(1,3),(2,3),(2,4),(3,4),(3,5),(4,5)) + let side-a = (0, 1, 2) + let cut-edges = edges.filter(e => side-a.contains(e.at(0)) != side-a.contains(e.at(1))) + // Draw edges + for (u, v) in edges { + let crossing = cut-edges.any(e => (e.at(0) == u and e.at(1) == v) or (e.at(0) == v and e.at(1) == u)) + g-edge(verts.at(u), verts.at(v), + stroke: if crossing { 2pt + graph-colors.at(1) } else { 1pt + luma(180) }) + } + // Draw partition regions + import draw: * + on-layer(-1, { + rect((-0.5, -0.5), (0.5, 2.5), + fill: graph-colors.at(0).transparentize(90%), + stroke: (dash: "dashed", paint: graph-colors.at(0), thickness: 0.8pt)) + content((0, 2.8), text(8pt, fill: graph-colors.at(0))[$A$]) + rect((2.0, -0.5), (3.0, 2.5), + fill: graph-colors.at(1).transparentize(90%), + stroke: (dash: "dashed", paint: graph-colors.at(1), thickness: 0.8pt)) + content((2.5, 2.8), text(8pt, fill: graph-colors.at(1))[$B$]) + }) + // Draw nodes + for (k, pos) in verts.enumerate() { + let in-a = side-a.contains(k) + g-node(pos, name: "v" + str(k), + fill: if in-a { graph-colors.at(0) } else { graph-colors.at(1) }, + label: text(fill: white)[$v_#k$]) + } + }), + caption: [Graph with $n = 6$ vertices partitioned into $A = {v_0, v_1, v_2}$ (blue) and $B = {v_3, v_4, v_5}$ (red). The 3 crossing edges $(v_1, v_3)$, $(v_2, v_3)$, $(v_2, v_4)$ are shown in bold red; internal edges are gray.], +) +] #problem-def("KColoring")[ Given $G = (V, E)$ and $k$ colors, find $c: V -> {1, ..., k}$ minimizing $|{(u, v) in E : c(u) = c(v)}|$. ][ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 91e9bd252..44dfa163a 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -208,6 +208,7 @@ Flags by problem type: QUBO --matrix SpinGlass --graph, --couplings, --fields KColoring --graph, --k + GraphPartitioning --graph Factoring --target, --m, --n BinPacking --sizes, --capacity PaintShop --sequence diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 418bc520f..fed2e172e 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -5,6 +5,7 @@ use crate::problem_name::{parse_problem_spec, resolve_variant}; use crate::util; use anyhow::{bail, Context, Result}; use problemreductions::models::algebraic::{ClosestVectorProblem, BMF}; +use problemreductions::models::graph::GraphPartitioning; use problemreductions::models::misc::{BinPacking, PaintShop}; use problemreductions::prelude::*; use problemreductions::registry::collect_schemas; @@ -74,6 +75,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { Some("UnitDiskGraph") => "--positions \"0,0;1,0;0.5,0.8\" --radius 1.5", _ => "--graph 0-1,1-2,2-3 --weights 1,1,1,1", }, + "GraphPartitioning" => "--graph 0-1,1-2,2-3,0-2,1-3,0-3", "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" } @@ -193,6 +195,19 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { create_vertex_weight_problem(args, canonical, graph_type, &resolved_variant)? } + // Graph partitioning (graph only, no weights) + "GraphPartitioning" => { + let (graph, _) = parse_graph(args).map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create GraphPartitioning --graph 0-1,1-2,2-3,0-2,1-3,0-3" + ) + })?; + ( + ser(GraphPartitioning::new(graph))?, + resolved_variant.clone(), + ) + } + // Graph problems with edge weights "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { let (graph, _) = parse_graph(args).map_err(|e| { @@ -874,6 +889,27 @@ fn create_random( } } + // GraphPartitioning (graph only, no weights; requires even vertex count) + "GraphPartitioning" => { + let num_vertices = if num_vertices % 2 != 0 { + eprintln!( + "Warning: GraphPartitioning requires even vertex count; rounding {} up to {}", + num_vertices, + num_vertices + 1 + ); + num_vertices + 1 + } else { + num_vertices + }; + 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"); + } + let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); + let variant = variant_map(&[("graph", "SimpleGraph")]); + (ser(GraphPartitioning::new(graph))?, variant) + } + // Graph problems with edge weights "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { let edge_prob = args.edge_prob.unwrap_or(0.5); diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 7a8498421..3766a829d 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -210,6 +210,7 @@ pub fn load_problem( "MaximumClique" => deser_opt::>(data), "MaximumMatching" => deser_opt::>(data), "MinimumDominatingSet" => deser_opt::>(data), + "GraphPartitioning" => deser_opt::>(data), "MaxCut" => deser_opt::>(data), "MaximalIS" => deser_opt::>(data), "TravelingSalesman" => deser_opt::>(data), @@ -267,6 +268,7 @@ pub fn serialize_any_problem( "MaximumClique" => try_ser::>(any), "MaximumMatching" => try_ser::>(any), "MinimumDominatingSet" => try_ser::>(any), + "GraphPartitioning" => try_ser::>(any), "MaxCut" => try_ser::>(any), "MaximalIS" => try_ser::>(any), "TravelingSalesman" => try_ser::>(any), diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index acd9b4b59..a1ef83861 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -32,6 +32,7 @@ pub fn resolve_alias(input: &str) -> String { "3sat" => "KSatisfiability".to_string(), "ksat" | "ksatisfiability" => "KSatisfiability".to_string(), "qubo" => "QUBO".to_string(), + "graphpartitioning" => "GraphPartitioning".to_string(), "maxcut" => "MaxCut".to_string(), "spinglass" => "SpinGlass".to_string(), "ilp" => "ILP".to_string(), diff --git a/src/lib.rs b/src/lib.rs index 87b0126a0..580d9f37c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,7 +38,7 @@ pub mod prelude { // Problem types pub use crate::models::algebraic::{BMF, QUBO}; pub use crate::models::formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; - pub use crate::models::graph::{BicliqueCover, SpinGlass}; + pub use crate::models::graph::{BicliqueCover, GraphPartitioning, SpinGlass}; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumVertexCover, TravelingSalesman, diff --git a/src/models/graph/graph_partitioning.rs b/src/models/graph/graph_partitioning.rs new file mode 100644 index 000000000..b109a3479 --- /dev/null +++ b/src/models/graph/graph_partitioning.rs @@ -0,0 +1,142 @@ +//! GraphPartitioning problem implementation. +//! +//! The Graph Partitioning (Minimum Bisection) problem asks for a balanced partition +//! of vertices into two equal halves minimizing the number of crossing edges. + +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: "GraphPartitioning", + module_path: module_path!(), + description: "Find minimum cut balanced bisection of a graph", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The undirected graph G=(V,E)" }, + ], + } +} + +/// The Graph Partitioning (Minimum Bisection) problem. +/// +/// Given an undirected graph G = (V, E) with |V| = n (even), +/// partition V into two disjoint sets A and B with |A| = |B| = n/2, +/// minimizing the number of edges crossing the partition. +/// +/// # Type Parameters +/// +/// * `G` - The graph type (e.g., `SimpleGraph`) +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::graph::GraphPartitioning; +/// use problemreductions::topology::SimpleGraph; +/// use problemreductions::types::SolutionSize; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// // Square graph: 0-1, 1-2, 2-3, 3-0 +/// let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (3, 0)]); +/// let problem = GraphPartitioning::new(graph); +/// +/// let solver = BruteForce::new(); +/// let solutions = solver.find_all_best(&problem); +/// +/// // Minimum bisection of a 4-cycle: cut = 2 +/// for sol in solutions { +/// let size = problem.evaluate(&sol); +/// assert_eq!(size, SolutionSize::Valid(2)); +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphPartitioning { + /// The underlying graph structure. + graph: G, +} + +impl GraphPartitioning { + /// Create a GraphPartitioning problem from a graph. + /// + /// # Arguments + /// * `graph` - The undirected graph to partition + pub fn new(graph: G) -> Self { + Self { graph } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// 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() + } +} + +impl Problem for GraphPartitioning +where + G: Graph + crate::variant::VariantParam, +{ + const NAME: &'static str = "GraphPartitioning"; + type Metric = SolutionSize; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> SolutionSize { + let n = self.graph.num_vertices(); + if config.len() != n { + return SolutionSize::Invalid; + } + // Balanced bisection requires even n + if !n.is_multiple_of(2) { + return SolutionSize::Invalid; + } + // Check balanced: exactly n/2 vertices in partition 1 + let count_ones = config.iter().filter(|&&x| x == 1).count(); + if count_ones != n / 2 { + return SolutionSize::Invalid; + } + // Count crossing edges + let mut cut = 0i32; + for (u, v) in self.graph.edges() { + if config[u] != config[v] { + cut += 1; + } + } + SolutionSize::Valid(cut) + } +} + +impl OptimizationProblem for GraphPartitioning +where + G: Graph + crate::variant::VariantParam, +{ + type Value = i32; + + fn direction(&self) -> Direction { + Direction::Minimize + } +} + +crate::declare_variants! { + GraphPartitioning => "2^num_vertices", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/graph_partitioning.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 1198f7fbc..2e7cb23e5 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -7,6 +7,7 @@ //! - [`MinimumDominatingSet`]: Minimum dominating set //! - [`MaximumClique`]: Maximum weight clique //! - [`MaxCut`]: Maximum cut on weighted graphs +//! - [`GraphPartitioning`]: Minimum bisection (balanced graph partitioning) //! - [`KColoring`]: K-vertex coloring //! - [`MaximumMatching`]: Maximum weight matching //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) @@ -14,6 +15,7 @@ //! - [`BicliqueCover`]: Biclique cover on bipartite graphs pub(crate) mod biclique_cover; +pub(crate) mod graph_partitioning; pub(crate) mod kcoloring; pub(crate) mod max_cut; pub(crate) mod maximal_is; @@ -26,6 +28,7 @@ pub(crate) mod spin_glass; pub(crate) mod traveling_salesman; pub use biclique_cover::BicliqueCover; +pub use graph_partitioning::GraphPartitioning; pub use kcoloring::KColoring; pub use max_cut::MaxCut; pub use maximal_is::MaximalIS; diff --git a/src/models/mod.rs b/src/models/mod.rs index 96b4b79d1..2a5866bb8 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -12,8 +12,9 @@ pub mod set; pub use algebraic::{ClosestVectorProblem, BMF, ILP, QUBO}; pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ - BicliqueCover, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, - MaximumMatching, MinimumDominatingSet, MinimumVertexCover, SpinGlass, TravelingSalesman, + BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique, + MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumVertexCover, SpinGlass, + TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, PaintShop}; pub use set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/unit_tests/models/graph/graph_partitioning.rs b/src/unit_tests/models/graph/graph_partitioning.rs new file mode 100644 index 000000000..e674432b6 --- /dev/null +++ b/src/unit_tests/models/graph/graph_partitioning.rs @@ -0,0 +1,169 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::Direction; + +/// Issue example: 6 vertices, edges forming two triangles connected by 3 edges. +/// Optimal partition A={0,1,2}, B={3,4,5}, cut=3. +fn issue_example() -> GraphPartitioning { + let graph = SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 4), + (3, 5), + (4, 5), + ], + ); + GraphPartitioning::new(graph) +} + +#[test] +fn test_graphpartitioning_basic() { + let problem = issue_example(); + + // Check dims: 6 binary variables + assert_eq!(problem.dims(), vec![2, 2, 2, 2, 2, 2]); + + // Evaluate a valid balanced partition: A={0,1,2}, B={3,4,5} + // config: [0, 0, 0, 1, 1, 1] + // Crossing edges: (1,3), (2,3), (2,4) => cut = 3 + let config = vec![0, 0, 0, 1, 1, 1]; + let result = problem.evaluate(&config); + assert_eq!(result, SolutionSize::Valid(3)); +} + +#[test] +fn test_graphpartitioning_direction() { + let problem = issue_example(); + assert_eq!(problem.direction(), Direction::Minimize); +} + +#[test] +fn test_graphpartitioning_serialization() { + let problem = issue_example(); + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: GraphPartitioning = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.graph().num_vertices(), 6); + assert_eq!(deserialized.graph().num_edges(), 9); + + // Verify evaluation is consistent after round-trip + let config = vec![0, 0, 0, 1, 1, 1]; + assert_eq!(problem.evaluate(&config), deserialized.evaluate(&config)); +} + +#[test] +fn test_graphpartitioning_solver() { + let problem = issue_example(); + let solver = BruteForce::new(); + let best = solver.find_best(&problem).unwrap(); + let size = problem.evaluate(&best); + assert_eq!(size, SolutionSize::Valid(3)); + + // All optimal solutions should have cut = 3 + let all_best = solver.find_all_best(&problem); + assert!(!all_best.is_empty()); + for sol in &all_best { + assert_eq!(problem.evaluate(sol), SolutionSize::Valid(3)); + } +} + +#[test] +fn test_graphpartitioning_odd_vertices() { + // 3 vertices: all configs must be Invalid since n is odd + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = GraphPartitioning::new(graph); + + // Every possible config should be Invalid + for a in 0..2 { + for b in 0..2 { + for c in 0..2 { + assert_eq!( + problem.evaluate(&[a, b, c]), + SolutionSize::Invalid, + "Expected Invalid for odd n, config [{}, {}, {}]", + a, + b, + c + ); + } + } + } +} + +#[test] +fn test_graphpartitioning_unbalanced_invalid() { + // 4 vertices: only configs with exactly 2 ones are valid + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (0, 3)]); + let problem = GraphPartitioning::new(graph); + + // All zeros: 0 ones, not balanced + assert_eq!(problem.evaluate(&[0, 0, 0, 0]), SolutionSize::Invalid); + + // All ones: 4 ones, not balanced + assert_eq!(problem.evaluate(&[1, 1, 1, 1]), SolutionSize::Invalid); + + // One vertex in partition 1: not balanced + assert_eq!(problem.evaluate(&[1, 0, 0, 0]), SolutionSize::Invalid); + + // Three vertices in partition 1: not balanced + assert_eq!(problem.evaluate(&[1, 1, 1, 0]), SolutionSize::Invalid); + + // Two vertices in partition 1: balanced, should be Valid + // 4-cycle edges: (0,1),(1,2),(2,3),(0,3). Config [1,1,0,0] cuts (1,2) and (0,3) => cut=2 + assert_eq!(problem.evaluate(&[1, 1, 0, 0]), SolutionSize::Valid(2)); +} + +#[test] +fn test_graphpartitioning_size_getters() { + let problem = issue_example(); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 9); +} + +#[test] +fn test_graphpartitioning_square_graph() { + // Square graph: 0-1, 1-2, 2-3, 3-0 (the doctest example) + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (3, 0)]); + let problem = GraphPartitioning::new(graph); + + let solver = BruteForce::new(); + let all_best = solver.find_all_best(&problem); + + // Minimum bisection of a 4-cycle: cut = 2 + for sol in &all_best { + assert_eq!(problem.evaluate(sol), SolutionSize::Valid(2)); + } +} + +#[test] +fn test_graphpartitioning_problem_name() { + assert_eq!( + as Problem>::NAME, + "GraphPartitioning" + ); +} + +#[test] +fn test_graphpartitioning_graph_accessor() { + let problem = issue_example(); + let graph = problem.graph(); + assert_eq!(graph.num_vertices(), 6); + assert_eq!(graph.num_edges(), 9); +} + +#[test] +fn test_graphpartitioning_empty_graph() { + // 4 vertices, no edges: any balanced partition has cut = 0 + let graph = SimpleGraph::new(4, vec![]); + let problem = GraphPartitioning::new(graph); + + let config = vec![0, 0, 1, 1]; + assert_eq!(problem.evaluate(&config), SolutionSize::Valid(0)); +}