diff --git a/docs/paper/examples/maximumindependentset_to_maximumclique.json b/docs/paper/examples/maximumindependentset_to_maximumclique.json index 352ddeb6..e8be9c62 100644 --- a/docs/paper/examples/maximumindependentset_to_maximumclique.json +++ b/docs/paper/examples/maximumindependentset_to_maximumclique.json @@ -2,8 +2,8 @@ "source": { "problem": "MaximumIndependentSet", "variant": { - "graph": "SimpleGraph", - "weight": "i32" + "weight": "i32", + "graph": "SimpleGraph" }, "instance": { "edges": [ @@ -31,8 +31,8 @@ "target": { "problem": "MaximumClique", "variant": { - "graph": "SimpleGraph", - "weight": "i32" + "weight": "i32", + "graph": "SimpleGraph" }, "instance": { "edges": [ diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 2b632fad..35a014d7 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -78,6 +78,7 @@ "SubsetSum": [Subset Sum], "MinimumFeedbackArcSet": [Minimum Feedback Arc Set], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], + "PartitionIntoPathsOfLength2": [Partition into Paths of Length 2], "ShortestCommonSupersequence": [Shortest Common Supersequence], "MinimumSumMulticenter": [Minimum Sum Multicenter], "SubgraphIsomorphism": [Subgraph Isomorphism], @@ -688,6 +689,14 @@ caption: [A directed graph with FVS $S = {v_0}$ (blue, $w(S) = 1$). Removing $v_ ) ] +#problem-def("PartitionIntoPathsOfLength2")[ + Given $G = (V, E)$ with $|V| = 3q$, determine if $V$ can be partitioned into $q$ disjoint sets $V_1, ..., V_q$ of three vertices each, such that each $V_t$ induces at least two edges in $G$. +][ +A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], proved hard by reduction from 3-Dimensional Matching. Each triple in the partition must form a path of length 2 (exactly two edges, i.e., a $P_3$ subgraph) or a triangle (all three edges). The problem models constrained grouping scenarios where cluster connectivity is required. The best known exact approach uses subset DP in $O^*(3^n)$ time. + +*Example.* Consider the graph $G$ with $n = 9$ vertices and edges ${0,1}, {1,2}, {3,4}, {4,5}, {6,7}, {7,8}$ (plus cross-edges ${0,3}, {2,5}, {3,6}, {5,8}$). Setting $q = 3$, the partition $V_1 = {0,1,2}$, $V_2 = {3,4,5}$, $V_3 = {6,7,8}$ is valid: $V_1$ contains edges ${0,1}, {1,2}$ (path $0 dash.em 1 dash.em 2$), $V_2$ contains ${3,4}, {4,5}$, and $V_3$ contains ${6,7}, {7,8}$. +] + #problem-def("MinimumSumMulticenter")[ Given a graph $G = (V, E)$ with vertex weights $w: V -> ZZ_(>= 0)$, edge lengths $l: E -> ZZ_(>= 0)$, and a positive integer $K <= |V|$, find a set $P subset.eq V$ of $K$ vertices (centers) that minimizes the total weighted distance $sum_(v in V) w(v) dot d(v, P)$, where $d(v, P) = min_(p in P) d(v, p)$ is the shortest-path distance from $v$ to the nearest center in $P$. ][ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 0e67c051..fe5cc7b0 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -239,6 +239,7 @@ Flags by problem type: LCS --strings FAS --arcs [--weights] [--num-vertices] FVS --arcs [--weights] [--num-vertices] + PartitionIntoPathsOfLength2 --graph FlowShopScheduling --task-lengths, --deadline [--num-processors] SCS --strings, --bound [--alphabet-size] ILP, CircuitSAT (via reduction only) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index b89d65c9..e8c31544 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -982,6 +982,25 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // PartitionIntoPathsOfLength2 + "PartitionIntoPathsOfLength2" => { + let (graph, _) = parse_graph(args).map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create PartitionIntoPathsOfLength2 --graph 0-1,1-2,3-4,4-5" + ) + })?; + if graph.num_vertices() % 3 != 0 { + bail!( + "PartitionIntoPathsOfLength2 requires vertex count divisible by 3, got {}", + graph.num_vertices() + ); + } + ( + ser(problemreductions::models::graph::PartitionIntoPathsOfLength2::new(graph))?, + resolved_variant.clone(), + ) + } + _ => bail!("{}", crate::problem_name::unknown_problem_error(canonical)), }; diff --git a/src/lib.rs b/src/lib.rs index 1f1c99c3..576596ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,7 +52,7 @@ pub mod prelude { KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumSumMulticenter, MinimumVertexCover, OptimalLinearArrangement, - PartitionIntoTriangles, RuralPostman, TravelingSalesman, + PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, TravelingSalesman, }; pub use crate::models::misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, PaintShop, diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 7d76de65..997cc8b1 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -16,6 +16,7 @@ //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`HamiltonianPath`]: Hamiltonian path (simple path visiting every vertex) +//! - [`PartitionIntoPathsOfLength2`]: Partition vertices into triples with at least two edges each //! - [`BicliqueCover`]: Biclique cover on bipartite graphs //! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K) //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs @@ -39,6 +40,7 @@ pub(crate) mod minimum_feedback_vertex_set; pub(crate) mod minimum_sum_multicenter; pub(crate) mod minimum_vertex_cover; pub(crate) mod optimal_linear_arrangement; +pub(crate) mod partition_into_paths_of_length_2; pub(crate) mod partition_into_triangles; pub(crate) mod rural_postman; pub(crate) mod spin_glass; @@ -61,6 +63,7 @@ pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; pub use minimum_sum_multicenter::MinimumSumMulticenter; pub use minimum_vertex_cover::MinimumVertexCover; pub use optimal_linear_arrangement::OptimalLinearArrangement; +pub use partition_into_paths_of_length_2::PartitionIntoPathsOfLength2; pub use partition_into_triangles::PartitionIntoTriangles; pub use rural_postman::RuralPostman; pub use spin_glass::SpinGlass; @@ -86,5 +89,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec { + /// The underlying graph. + graph: G, +} + +impl PartitionIntoPathsOfLength2 { + /// Create a new PartitionIntoPathsOfLength2 problem from a graph. + /// + /// # Panics + /// Panics if `graph.num_vertices()` is not divisible by 3. + pub fn new(graph: G) -> Self { + assert_eq!( + graph.num_vertices() % 3, + 0, + "Number of vertices ({}) must be divisible by 3", + graph.num_vertices() + ); + Self { graph } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges in the graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Get q = |V| / 3, the number of groups in the partition. + pub fn num_groups(&self) -> usize { + self.graph.num_vertices() / 3 + } + + /// Check if a configuration represents a valid partition. + /// + /// A valid configuration assigns each vertex to a group (0..q-1) such that: + /// 1. Each group contains exactly 3 vertices. + /// 2. Each group induces at least 2 edges. + pub fn is_valid_partition(&self, config: &[usize]) -> bool { + let n = self.graph.num_vertices(); + let q = self.num_groups(); + + if config.len() != n { + return false; + } + + // Check all assignments are in range + if config.iter().any(|&g| g >= q) { + return false; + } + + // Count vertices per group + let mut group_sizes = vec![0usize; q]; + for &g in config { + group_sizes[g] += 1; + } + + // Each group must have exactly 3 vertices + if group_sizes.iter().any(|&s| s != 3) { + return false; + } + + // Check each group induces at least 2 edges (single pass over edges) + let mut group_edge_counts = vec![0usize; q]; + for (u, v) in self.graph.edges() { + if config[u] == config[v] { + group_edge_counts[config[u]] += 1; + } + } + if group_edge_counts.iter().any(|&c| c < 2) { + return false; + } + + true + } +} + +impl Problem for PartitionIntoPathsOfLength2 +where + G: Graph + VariantParam, +{ + const NAME: &'static str = "PartitionIntoPathsOfLength2"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G] + } + + fn dims(&self) -> Vec { + let q = self.num_groups(); + vec![q; self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.is_valid_partition(config) + } +} + +impl SatisfactionProblem for PartitionIntoPathsOfLength2 {} + +crate::declare_variants! { + default sat PartitionIntoPathsOfLength2 => "3^num_vertices", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "partition_into_paths_of_length_2_simplegraph", + build: || { + let problem = PartitionIntoPathsOfLength2::new(SimpleGraph::new( + 6, + vec![(0, 1), (1, 2), (3, 4), (4, 5)], + )); + crate::example_db::specs::satisfaction_example(problem, vec![vec![0, 0, 0, 1, 1, 1]]) + }, + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/partition_into_paths_of_length_2.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index e4448805..fea1e0bf 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -15,8 +15,8 @@ pub use graph::{ BicliqueCover, GraphPartitioning, HamiltonianPath, IsomorphicSpanningTree, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumSumMulticenter, MinimumVertexCover, - OptimalLinearArrangement, PartitionIntoTriangles, RuralPostman, SpinGlass, SubgraphIsomorphism, - TravelingSalesman, + OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, + SpinGlass, SubgraphIsomorphism, TravelingSalesman, }; pub use misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, PaintShop, diff --git a/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs b/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs new file mode 100644 index 00000000..c576cdef --- /dev/null +++ b/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs @@ -0,0 +1,159 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; + +#[test] +fn test_partition_into_paths_basic() { + // 9-vertex graph: three P3 paths: 0-1-2, 3-4-5, 6-7-8 + let graph = SimpleGraph::new( + 9, + vec![ + (0, 1), + (1, 2), + (3, 4), + (4, 5), + (6, 7), + (7, 8), + (0, 3), + (2, 5), + (3, 6), + (5, 8), + ], + ); + let problem = PartitionIntoPathsOfLength2::new(graph); + + assert_eq!(problem.num_vertices(), 9); + assert_eq!(problem.num_edges(), 10); + assert_eq!(problem.num_groups(), 3); + assert_eq!(problem.dims(), vec![3; 9]); + + // Valid partition: {0,1,2}, {3,4,5}, {6,7,8} + // Config: vertex i -> group i/3 + let valid_config = vec![0, 0, 0, 1, 1, 1, 2, 2, 2]; + assert!(problem.evaluate(&valid_config)); + + // Alternative valid partition: {0,1,3}, {2,4,5}, {6,7,8} + // Group {0,1,3}: edges (0,1) and (0,3) — 2 edges, valid + // Group {2,4,5}: edges (4,5) and (2,5) — 2 edges, valid + let another_config = vec![0, 0, 1, 0, 1, 1, 2, 2, 2]; + assert!(problem.evaluate(&another_config)); +} + +#[test] +fn test_partition_into_paths_no_solution() { + // 6-vertex graph where no valid partition exists + // Edges: {0,1}, {2,3}, {0,4}, {1,5} + let graph = SimpleGraph::new(6, vec![(0, 1), (2, 3), (0, 4), (1, 5)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_groups(), 2); + + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_none(), "Expected no solution for this graph"); +} + +#[test] +fn test_partition_into_paths_solver() { + // Simple 6-vertex graph with obvious partition: 0-1-2 and 3-4-5 + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + let solver = BruteForce::new(); + let solutions = solver.find_all_satisfying(&problem); + assert!(!solutions.is_empty(), "Expected at least one solution"); + + for sol in &solutions { + assert!(problem.evaluate(sol)); + } +} + +#[test] +fn test_partition_into_paths_invalid_group_size() { + // 6-vertex path: 0-1-2-3-4-5 + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + // Config where group 0 has 4 vertices and group 1 has 2 vertices + let bad_config = vec![0, 0, 0, 0, 1, 1]; + assert!(!problem.evaluate(&bad_config)); +} + +#[test] +fn test_partition_into_paths_insufficient_edges() { + // 6 vertices, only 2 edges — not enough for any group to have 2 edges + let graph = SimpleGraph::new(6, vec![(0, 1), (3, 4)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + // Even a well-sized partition fails because groups lack edges + let config = vec![0, 0, 0, 1, 1, 1]; + // Group {0,1,2}: only edge (0,1) — 1 edge < 2, invalid + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_partition_into_paths_triangle() { + // Triangle group: 3 vertices, 3 edges — also valid (>= 2 edges) + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + // Single group with all 3 vertices forming a triangle + let config = vec![0, 0, 0]; + assert!(problem.evaluate(&config)); +} + +#[test] +fn test_partition_into_paths_serialization() { + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: PartitionIntoPathsOfLength2 = + serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.num_vertices(), 6); + assert_eq!(deserialized.num_edges(), 4); + assert_eq!(deserialized.num_groups(), 2); + + // Verify evaluation is consistent + let config = vec![0, 0, 0, 1, 1, 1]; + assert_eq!(problem.evaluate(&config), deserialized.evaluate(&config)); +} + +#[test] +#[should_panic(expected = "must be divisible by 3")] +fn test_partition_into_paths_invalid_vertex_count() { + let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]); + let _problem = PartitionIntoPathsOfLength2::new(graph); +} + +#[test] +fn test_partition_into_paths_size_getters() { + let graph = SimpleGraph::new(9, vec![(0, 1), (1, 2), (3, 4), (4, 5), (6, 7), (7, 8)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + assert_eq!(problem.num_vertices(), 9); + assert_eq!(problem.num_edges(), 6); + assert_eq!(problem.num_groups(), 3); +} + +#[test] +fn test_partition_into_paths_out_of_range_group() { + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + // Group index out of range (q=2, so valid groups are 0 and 1) + let config = vec![0, 0, 0, 2, 2, 2]; + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_partition_into_paths_is_valid_partition() { + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + assert!(problem.is_valid_partition(&[0, 0, 0, 1, 1, 1])); + assert!(!problem.is_valid_partition(&[0, 0, 1, 1, 1, 1])); // Wrong group sizes + assert!(!problem.is_valid_partition(&[0, 0, 0])); // Wrong length +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index ebbc68a0..547d050d 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -122,6 +122,10 @@ fn test_all_problems_implement_trait_correctly() { &FlowShopScheduling::new(2, vec![vec![1, 2], vec![3, 4]], 10), "FlowShopScheduling", ); + check_problem_trait( + &PartitionIntoPathsOfLength2::new(SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)])), + "PartitionIntoPathsOfLength2", + ); } #[test]