diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ca2a2276c..61b0b422c 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -129,6 +129,7 @@ "SumOfSquaresPartition": [Sum of Squares Partition], "SequencingWithinIntervals": [Sequencing Within Intervals], "DirectedTwoCommodityIntegralFlow": [Directed Two-Commodity Integral Flow], + "RectilinearPictureCompression": [Rectilinear Picture Compression], "StringToStringCorrection": [String-to-String Correction], ) @@ -2476,6 +2477,60 @@ NP-completeness was established by Garey, Johnson, and Stockmeyer @gareyJohnsonS *Example.* Let $n = 4$ items with weights $(2, 3, 4, 5)$, values $(3, 4, 5, 7)$, and capacity $C = 7$. Selecting $S = {1, 2}$ (items with weights 3 and 4) gives total weight $3 + 4 = 7 lt.eq C$ and total value $4 + 5 = 9$. Selecting $S = {0, 3}$ (weights 2 and 5) gives weight $2 + 5 = 7 lt.eq C$ and value $3 + 7 = 10$, which is optimal. ] +#{ + let x = load-model-example("RectilinearPictureCompression") + let mat = x.instance.matrix + let m = mat.len() + let n = mat.at(0).len() + let K = x.instance.bound_k + // Convert bool matrix to int for display + let M = mat.map(row => row.map(v => if v { 1 } else { 0 })) + [ + #problem-def("RectilinearPictureCompression")[ + Given an $m times n$ binary matrix $M$ and a nonnegative integer $K$, + determine whether there exists a collection of at most $K$ + axis-aligned rectangles that covers precisely the 1-entries of $M$. + Each rectangle is a quadruple $(a, b, c, d)$ with $a lt.eq b$ and $c lt.eq d$, + covering entries $M_(i j)$ for $a lt.eq i lt.eq b$ and $c lt.eq j lt.eq d$, + where every covered entry must satisfy $M_(i j) = 1$. + ][ + Rectilinear Picture Compression is a classical NP-complete problem from Garey & Johnson (A4 SR25, p.~232) @garey1979. It arises naturally in image compression, DNA microarray design, integrated circuit manufacturing, and access control list minimization. NP-completeness was established by Masek (1978) via transformation from 3SAT. A straightforward exact baseline, including the brute-force solver in this crate, enumerates subsets of the maximal all-1 rectangles. If an instance has $R$ such rectangles, this gives an $O^*(2^R)$ exact search, so the worst-case behavior remains exponential in the instance size. + + *Example.* Let $M = mat(#M.map(row => row.map(v => str(v)).join(", ")).join("; "))$ (a $#m times #n$ matrix) and $K = #K$. The two maximal all-1 rectangles cover rows $0..1$, columns $0..1$ and rows $2..3$, columns $2..3$. Selecting both gives $|{R_1, R_2}| = 2 lt.eq K = #K$ and their union covers all eight 1-entries, so the answer is YES. + + #figure( + { + let cell-size = 0.5 + let blue = graph-colors.at(0) + let teal = rgb("#76b7b2") + // Rectangle covers: R1 covers rows 0..1, cols 0..1; R2 covers rows 2..3, cols 2..3 + let rect-color(r, c) = { + if r <= 1 and c <= 1 { blue.transparentize(40%) } + else if r >= 2 and c >= 2 { teal.transparentize(40%) } + else { white } + } + align(center, grid( + columns: n, + column-gutter: 0pt, + row-gutter: 0pt, + ..range(m).map(r => + range(n).map(c => { + let val = M.at(r).at(c) + let fill = if val == 1 { rect-color(r, c) } else { white } + box(width: cell-size * 1cm, height: cell-size * 1cm, + fill: fill, stroke: 0.4pt + luma(180), + align(center + horizon, text(8pt, weight: if val == 1 { "bold" } else { "regular" }, + if val == 1 { "1" } else { "0" }))) + }) + ).flatten(), + )) + }, + caption: [Rectilinear Picture Compression: matrix $M$ covered by two rectangles $R_1$ (blue, top-left $2 times 2$) and $R_2$ (teal, bottom-right $2 times 2$), with $|{R_1, R_2}| = 2 lt.eq K = #K$.], + ) + ] + ] +} + #problem-def("RuralPostman")[ Given an undirected graph $G = (V, E)$ with edge lengths $l: E -> ZZ_(gt.eq 0)$, a subset $E' subset.eq E$ of required edges, and a bound $B in ZZ^+$, determine whether there exists a circuit (closed walk) in $G$ that traverses every edge in $E'$ and has total length at most $B$. ][ diff --git a/docs/src/cli.md b/docs/src/cli.md index ad4fc80c9..46339f699 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -346,6 +346,8 @@ pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.json pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3 -o kth.json pred create SpinGlass --graph 0-1,1-2 -o sg.json pred create MaxCut --graph 0-1,1-2,2-0 -o maxcut.json +pred create RectilinearPictureCompression --matrix "1,1,0,0;1,1,0,0;0,0,1,1;0,0,1,1" --k 2 -o rpc.json +pred solve rpc.json --solver brute-force pred create MinimumMultiwayCut --graph 0-1,1-2,2-3,3-0 --terminals 0,2 --edge-weights 3,1,2,4 -o mmc.json pred create SteinerTree --graph 0-1,0-3,1-2,1-3,2-3,2-4,3-4 --edge-weights 2,5,2,1,5,6,1 --terminals 0,2,4 -o steiner.json pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1 -o utcif.json diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index a69445d83..77d9d8f64 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -264,6 +264,7 @@ Flags by problem type: FlowShopScheduling --task-lengths, --deadline [--num-processors] StaffScheduling --schedules, --requirements, --num-workers, --k MinimumTardinessSequencing --n, --deadlines [--precedence-pairs] + RectilinearPictureCompression --matrix (0/1), --k SCS --strings, --bound [--alphabet-size] StringToStringCorrection --source-string, --target-string, --bound [--alphabet-size] D2CIF --arcs, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 @@ -342,10 +343,10 @@ pub struct CreateArgs { /// Number of variables (for SAT/KSAT) #[arg(long)] pub num_vars: Option, - /// Matrix for QUBO (semicolon-separated rows, e.g., "1,0.5;0.5,2") + /// Matrix input (semicolon-separated rows; use `pred create ` for problem-specific formats) #[arg(long)] pub matrix: Option, - /// Number of colors for KColoring + /// Shared integer parameter (use `pred create ` for the problem-specific meaning) #[arg(long)] pub k: Option, /// Generate a random instance (graph-based problems only) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 85d61d8df..6ac146c85 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -14,9 +14,9 @@ use problemreductions::models::graph::{ }; use problemreductions::models::misc::{ BinPacking, FlowShopScheduling, LongestCommonSubsequence, MinimumTardinessSequencing, - MultiprocessorScheduling, PaintShop, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, SubsetSum, - SumOfSquaresPartition, + MultiprocessorScheduling, PaintShop, RectilinearPictureCompression, + SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, + StringToStringCorrection, SubsetSum, SumOfSquaresPartition, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -335,6 +335,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10" } "SubgraphIsomorphism" => "--graph 0-1,1-2,2-0 --pattern 0-1", + "RectilinearPictureCompression" => { + "--matrix \"1,1,0,0;1,1,0,0;0,0,1,1;0,0,1,1\" --k 2" + } "SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11", "SumOfSquaresPartition" => "--sizes 5,3,8,2,7,1 --num-groups 3 --bound 240", "ComparativeContainment" => { @@ -370,6 +373,8 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { match (canonical, field_name) { ("BoundedComponentSpanningForest", "max_components") => return "k".to_string(), ("BoundedComponentSpanningForest", "max_weight") => return "bound".to_string(), + ("LengthBoundedDisjointPaths", "max_length") => return "bound".to_string(), + ("RectilinearPictureCompression", "bound_k") => return "k".to_string(), ("PrimeAttributeName", "num_attributes") => return "universe".to_string(), ("PrimeAttributeName", "dependencies") => return "deps".to_string(), ("PrimeAttributeName", "query_attribute") => return "query".to_string(), @@ -1468,6 +1473,21 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (ser(BMF::new(matrix, rank))?, resolved_variant.clone()) } + // RectilinearPictureCompression + "RectilinearPictureCompression" => { + let matrix = parse_bool_matrix(args)?; + let k = args.k.ok_or_else(|| { + anyhow::anyhow!( + "RectilinearPictureCompression requires --matrix and --k\n\n\ + Usage: pred create RectilinearPictureCompression --matrix \"1,1,0,0;1,1,0,0;0,0,1,1;0,0,1,1\" --k 2" + ) + })?; + ( + ser(RectilinearPictureCompression::new(matrix, k))?, + resolved_variant.clone(), + ) + } + // ConsecutiveOnesSubmatrix "ConsecutiveOnesSubmatrix" => { let matrix = parse_bool_matrix(args)?; @@ -2942,7 +2962,7 @@ fn parse_schedules(args: &CreateArgs, usage: &str) -> Result>> { } fn parse_bool_rows(rows_str: &str) -> Result>> { - rows_str + let matrix: Vec> = rows_str .split(';') .map(|row| { row.trim() @@ -2956,7 +2976,16 @@ fn parse_bool_rows(rows_str: &str) -> Result>> { }) .collect() }) - .collect() + .collect::>()?; + + if let Some(expected_width) = matrix.first().map(Vec::len) { + anyhow::ensure!( + matrix.iter().all(|row| row.len() == expected_width), + "All rows in --matrix must have the same length" + ); + } + + Ok(matrix) } fn parse_requirements(args: &CreateArgs, usage: &str) -> Result> { @@ -3692,7 +3721,11 @@ mod tests { let result = std::panic::catch_unwind(|| create(&args, &out)); assert!(result.is_ok(), "create should return an error, not panic"); let err = result.unwrap().unwrap_err().to_string(); - assert!(err.contains("schedule 1 has 6 periods, expected 7")); + // parse_bool_rows catches ragged rows before validate_staff_scheduling_args + assert!( + err.contains("All rows") || err.contains("schedule 1 has 6 periods, expected 7"), + "expected row-length validation error, got: {err}" + ); } fn empty_args() -> CreateArgs { diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index ef6e32cea..8a0a33785 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1084,6 +1084,47 @@ fn test_solve_d2cif_default_solver_suggests_bruteforce() { std::fs::remove_file(&output_file).ok(); } +#[test] +fn test_inspect_rectilinear_picture_compression_lists_bruteforce_only() { + let output_file = std::env::temp_dir().join("pred_test_inspect_rpc.json"); + let create_output = pred() + .args([ + "-o", + output_file.to_str().unwrap(), + "create", + "RectilinearPictureCompression", + "--matrix", + "1,1;1,1", + "--k", + "1", + ]) + .output() + .unwrap(); + assert!( + create_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&create_output.stderr) + ); + + let inspect_output = pred() + .args(["inspect", output_file.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + inspect_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&inspect_output.stderr) + ); + let stdout = String::from_utf8(inspect_output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + json["solvers"] == serde_json::json!(["brute-force"]), + "inspect should list only usable solvers, got: {json}" + ); + + std::fs::remove_file(&output_file).ok(); +} + #[test] fn test_create_x3c_rejects_duplicate_subset_elements() { let output = pred() @@ -2860,6 +2901,94 @@ fn test_create_set_basis_no_flags_uses_actual_cli_flag_names() { ); } +#[test] +fn test_create_rectilinear_picture_compression_help_uses_k_flag() { + let output = pred() + .args(["create", "RectilinearPictureCompression"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--matrix"), + "expected '--matrix' in help output, got: {stderr}" + ); + assert!( + stderr.contains("--k"), + "expected '--k' in help output, got: {stderr}" + ); + assert!( + !stderr.contains("--bound-k"), + "help should advertise the actual CLI flag name, got: {stderr}" + ); +} + +#[test] +fn test_create_rectilinear_picture_compression_rejects_ragged_matrix() { + let output = pred() + .args([ + "create", + "RectilinearPictureCompression", + "--matrix", + "1,0;1", + "--k", + "1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("All rows in --matrix must have the same length"), + "expected rectangular-matrix validation error, got: {stderr}" + ); + assert!( + !stderr.contains("panicked at"), + "ragged matrix should not crash the CLI, got: {stderr}" + ); +} + +#[test] +fn test_create_help_uses_generic_matrix_and_k_descriptions() { + let output = pred().args(["create", "--help"]).output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Matrix input"), + "expected generic matrix help, got: {stdout}" + ); + assert!( + stdout.contains("Shared integer parameter"), + "expected generic k help, got: {stdout}" + ); + assert!( + !stdout.contains("Matrix for QUBO"), + "create --help should not imply --matrix is QUBO-only, got: {stdout}" + ); + assert!( + !stdout.contains("Number of colors for KColoring"), + "create --help should not imply --k is KColoring-only, got: {stdout}" + ); +} + +#[test] +fn test_create_length_bounded_disjoint_paths_help_uses_bound_flag() { + let output = pred() + .args(["create", "LengthBoundedDisjointPaths"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--bound"), + "expected '--bound' in help output, got: {stderr}" + ); + assert!( + !stderr.contains("--max-length"), + "help should advertise the actual CLI flag name, got: {stderr}" + ); +} + #[test] fn test_create_consecutive_ones_submatrix_no_flags_uses_actual_cli_help() { let output = pred() diff --git a/src/lib.rs b/src/lib.rs index 8a1364e67..ac2c767c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,9 +61,9 @@ pub mod prelude { pub use crate::models::misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, - SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, - ShortestCommonSupersequence, StaffScheduling, StringToStringCorrection, SubsetSum, - SumOfSquaresPartition, + RectilinearPictureCompression, SequencingWithReleaseTimesAndDeadlines, + SequencingWithinIntervals, ShortestCommonSupersequence, StaffScheduling, + StringToStringCorrection, SubsetSum, SumOfSquaresPartition, }; pub use crate::models::set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, @@ -102,6 +102,9 @@ pub use inventory; #[path = "unit_tests/graph_models.rs"] mod test_graph_models; #[cfg(test)] +#[path = "unit_tests/prelude.rs"] +mod test_prelude; +#[cfg(test)] #[path = "unit_tests/property.rs"] mod test_property; #[cfg(test)] diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index b8b69b96d..6c0049285 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -10,6 +10,7 @@ //! - [`MinimumTardinessSequencing`]: Minimize tardy tasks in single-machine scheduling //! - [`PaintShop`]: Minimize color switches in paint shop scheduling //! - [`PrecedenceConstrainedScheduling`]: Schedule unit tasks on processors by deadline +//! - [`RectilinearPictureCompression`]: Cover 1-entries with bounded rectangles //! - [`SequencingWithReleaseTimesAndDeadlines`]: Single-machine scheduling feasibility //! - [`SequencingWithinIntervals`]: Schedule tasks within time windows //! - [`ShortestCommonSupersequence`]: Find a common supersequence of bounded length @@ -26,6 +27,7 @@ mod minimum_tardiness_sequencing; mod multiprocessor_scheduling; pub(crate) mod paintshop; mod precedence_constrained_scheduling; +mod rectilinear_picture_compression; mod sequencing_with_release_times_and_deadlines; mod sequencing_within_intervals; pub(crate) mod shortest_common_supersequence; @@ -43,6 +45,7 @@ pub use minimum_tardiness_sequencing::MinimumTardinessSequencing; pub use multiprocessor_scheduling::MultiprocessorScheduling; pub use paintshop::PaintShop; pub use precedence_constrained_scheduling::PrecedenceConstrainedScheduling; +pub use rectilinear_picture_compression::RectilinearPictureCompression; pub use sequencing_with_release_times_and_deadlines::SequencingWithReleaseTimesAndDeadlines; pub use sequencing_within_intervals::SequencingWithinIntervals; pub use shortest_common_supersequence::ShortestCommonSupersequence; @@ -58,6 +61,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "m x n binary matrix" }, + FieldInfo { name: "bound_k", type_name: "usize", description: "Maximum number of rectangles allowed" }, + ], + } +} + +/// The Rectilinear Picture Compression problem. +/// +/// Given an m x n binary matrix M and a nonnegative integer K, determine whether +/// there exists a collection of at most K axis-aligned all-1 rectangles that +/// covers precisely the 1-entries of M. +/// +/// # Representation +/// +/// The configuration space consists of the maximal all-1 rectangles in the +/// matrix. Each variable is binary: 1 if the rectangle is selected, 0 otherwise. +/// The problem is satisfiable iff the selected rectangles number at most K and +/// their union covers all 1-entries. +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::misc::RectilinearPictureCompression; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// let matrix = vec![ +/// vec![true, true, false, false], +/// vec![true, true, false, false], +/// vec![false, false, true, true], +/// vec![false, false, true, true], +/// ]; +/// let problem = RectilinearPictureCompression::new(matrix, 2); +/// let solver = BruteForce::new(); +/// let solution = solver.find_satisfying(&problem); +/// assert!(solution.is_some()); +/// ``` +#[derive(Debug, Clone, Serialize)] +pub struct RectilinearPictureCompression { + matrix: Vec>, + bound_k: usize, + #[serde(skip)] + maximal_rects: Vec, +} + +impl<'de> Deserialize<'de> for RectilinearPictureCompression { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Inner { + matrix: Vec>, + bound_k: usize, + } + let inner = Inner::deserialize(deserializer)?; + Ok(Self::new(inner.matrix, inner.bound_k)) + } +} + +impl RectilinearPictureCompression { + /// Create a new RectilinearPictureCompression instance. + /// + /// # Panics + /// + /// Panics if `matrix` is empty or has inconsistent row lengths. + pub fn new(matrix: Vec>, bound_k: usize) -> Self { + assert!(!matrix.is_empty(), "Matrix must not be empty"); + let cols = matrix[0].len(); + assert!(cols > 0, "Matrix must have at least one column"); + assert!( + matrix.iter().all(|row| row.len() == cols), + "All rows must have the same length" + ); + let mut instance = Self { + matrix, + bound_k, + maximal_rects: Vec::new(), + }; + instance.maximal_rects = instance.compute_maximal_rectangles(); + instance + } + + /// Returns the number of rows in the matrix. + pub fn num_rows(&self) -> usize { + self.matrix.len() + } + + /// Returns the number of columns in the matrix. + pub fn num_cols(&self) -> usize { + self.matrix[0].len() + } + + /// Returns the bound K. + pub fn bound_k(&self) -> usize { + self.bound_k + } + + /// Returns a reference to the binary matrix. + pub fn matrix(&self) -> &[Vec] { + &self.matrix + } + + /// Returns the precomputed maximal all-1 sub-rectangles. + /// + /// Each rectangle is `(r1, c1, r2, c2)` covering rows `r1..=r2` and + /// columns `c1..=c2`. + pub fn maximal_rectangles(&self) -> &[Rectangle] { + &self.maximal_rects + } + + fn build_prefix_sum(&self) -> Vec> { + let m = self.num_rows(); + let n = self.num_cols(); + let mut prefix_sum = vec![vec![0; n + 1]; m + 1]; + + for r in 0..m { + let mut row_sum = 0; + for c in 0..n { + row_sum += usize::from(self.matrix[r][c]); + prefix_sum[r + 1][c + 1] = prefix_sum[r][c + 1] + row_sum; + } + } + + prefix_sum + } + + fn range_is_all_ones( + prefix_sum: &[Vec], + r1: usize, + c1: usize, + r2: usize, + c2: usize, + ) -> bool { + let area = (r2 - r1 + 1) * (c2 - c1 + 1); + let sum = prefix_sum[r2 + 1][c2 + 1] + prefix_sum[r1][c1] + - prefix_sum[r1][c2 + 1] + - prefix_sum[r2 + 1][c1]; + sum == area + } + + /// Enumerate all maximal all-1 sub-rectangles in the matrix. + /// + /// A rectangle is maximal if it cannot be extended one step left, right, + /// up, or down while remaining all-1. The result is sorted lexicographically. + fn compute_maximal_rectangles(&self) -> Vec { + let m = self.num_rows(); + let n = self.num_cols(); + + // Step 1: Enumerate right-maximal candidate rectangles by fixing + // (r1, c1, r2) and taking the widest all-1 prefix common to rows r1..=r2. + let mut candidates = Vec::new(); + for r1 in 0..m { + for c1 in 0..n { + if !self.matrix[r1][c1] { + continue; + } + // Find the rightmost column from c1 that is all-1 in row r1. + let mut c_max = n; + for c in c1..n { + if !self.matrix[r1][c] { + c_max = c; + break; + } + } + // Extend downward row by row, narrowing column range. + let mut c_end = c_max; // exclusive upper bound on columns + for r2 in r1..m { + // Narrow c_end based on row r2. + let mut new_c_end = c1; + for c in c1..c_end { + if self.matrix[r2][c] { + new_c_end = c + 1; + } else { + break; + } + } + if new_c_end <= c1 { + break; + } + c_end = new_c_end; + candidates.push((r1, c1, r2, c_end - 1)); + } + } + } + + // Step 2: Remove duplicates. + candidates.sort(); + candidates.dedup(); + + // Step 3: Filter to keep only rectangles that cannot be extended in + // any cardinal direction. A 2D prefix sum makes each extension check O(1). + let prefix_sum = self.build_prefix_sum(); + candidates + .into_iter() + .filter(|&(r1, c1, r2, c2)| { + let can_extend_left = + c1 > 0 && Self::range_is_all_ones(&prefix_sum, r1, c1 - 1, r2, c1 - 1); + let can_extend_right = + c2 + 1 < n && Self::range_is_all_ones(&prefix_sum, r1, c2 + 1, r2, c2 + 1); + let can_extend_up = + r1 > 0 && Self::range_is_all_ones(&prefix_sum, r1 - 1, c1, r1 - 1, c2); + let can_extend_down = + r2 + 1 < m && Self::range_is_all_ones(&prefix_sum, r2 + 1, c1, r2 + 1, c2); + + !(can_extend_left || can_extend_right || can_extend_up || can_extend_down) + }) + .collect() + } +} + +impl Problem for RectilinearPictureCompression { + const NAME: &'static str = "RectilinearPictureCompression"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![2; self.maximal_rects.len()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + let rects = &self.maximal_rects; + if config.len() != rects.len() { + return false; + } + if config.iter().any(|&v| v >= 2) { + return false; + } + + // Count selected rectangles. + let selected_count: usize = config.iter().sum(); + if selected_count > self.bound_k { + return false; + } + + // Check that all 1-entries are covered. + let m = self.num_rows(); + let n = self.num_cols(); + let mut covered = vec![vec![false; n]; m]; + for (i, &x) in config.iter().enumerate() { + if x == 1 { + let (r1, c1, r2, c2) = rects[i]; + for row in &mut covered[r1..=r2] { + for cell in &mut row[c1..=c2] { + *cell = true; + } + } + } + } + + for (row_m, row_c) in self.matrix.iter().zip(covered.iter()) { + for (&entry, &cov) in row_m.iter().zip(row_c.iter()) { + if entry && !cov { + return false; + } + } + } + + true + } +} + +impl SatisfactionProblem for RectilinearPictureCompression {} + +crate::declare_variants! { + default sat RectilinearPictureCompression => "2^(num_rows * num_cols)", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "rectilinear_picture_compression", + // Config: select both maximal rectangles (the two 2x2 blocks). + // The maximal rectangles for this matrix are exactly: + // (0,0,1,1) and (2,2,3,3), so config [1,1] selects both. + instance: Box::new(RectilinearPictureCompression::new( + vec![ + vec![true, true, false, false], + vec![true, true, false, false], + vec![false, false, true, true], + vec![false, false, true, true], + ], + 2, + )), + optimal_config: vec![1, 1], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/rectilinear_picture_compression.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 3dcc1dfd5..d712ad00e 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -27,9 +27,9 @@ pub use graph::{ pub use misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, - PrecedenceConstrainedScheduling, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StaffScheduling, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, + PrecedenceConstrainedScheduling, RectilinearPictureCompression, + SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, + StaffScheduling, StringToStringCorrection, SubsetSum, SumOfSquaresPartition, }; pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/unit_tests/models/misc/rectilinear_picture_compression.rs b/src/unit_tests/models/misc/rectilinear_picture_compression.rs new file mode 100644 index 000000000..3b7d38bf6 --- /dev/null +++ b/src/unit_tests/models/misc/rectilinear_picture_compression.rs @@ -0,0 +1,230 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::traits::Problem; + +fn two_block_matrix() -> Vec> { + vec![ + vec![true, true, false, false], + vec![true, true, false, false], + vec![false, false, true, true], + vec![false, false, true, true], + ] +} + +fn issue_matrix() -> Vec> { + // 6x6 matrix from the issue description + vec![ + vec![true, true, true, false, false, false], + vec![true, true, true, false, false, false], + vec![false, false, true, true, true, false], + vec![false, false, true, true, true, false], + vec![false, false, false, false, true, true], + vec![false, false, false, false, true, true], + ] +} + +#[test] +fn test_rectilinear_picture_compression_basic() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + assert_eq!(problem.num_rows(), 4); + assert_eq!(problem.num_cols(), 4); + assert_eq!(problem.bound_k(), 2); + assert_eq!( + ::NAME, + "RectilinearPictureCompression" + ); + assert_eq!( + ::variant(), + vec![] + ); +} + +#[test] +fn test_rectilinear_picture_compression_maximal_rectangles_two_blocks() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + let rects = problem.maximal_rectangles(); + // Two disjoint 2x2 blocks: (0,0,1,1) and (2,2,3,3) + assert_eq!(rects, vec![(0, 0, 1, 1), (2, 2, 3, 3)]); +} + +#[test] +fn test_rectilinear_picture_compression_dims() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + // 2 maximal rectangles -> 2 binary variables + assert_eq!(problem.dims(), vec![2, 2]); +} + +#[test] +fn test_rectilinear_picture_compression_evaluate_satisfying() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + // Select both maximal rectangles + assert!(problem.evaluate(&[1, 1])); +} + +#[test] +fn test_rectilinear_picture_compression_evaluate_unsatisfying_not_all_covered() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + // Select only first rectangle - second block uncovered + assert!(!problem.evaluate(&[1, 0])); + // Select only second rectangle - first block uncovered + assert!(!problem.evaluate(&[0, 1])); + // Select none + assert!(!problem.evaluate(&[0, 0])); +} + +#[test] +fn test_rectilinear_picture_compression_evaluate_bound_exceeded() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 1); + // Both selected but bound is 1 + assert!(!problem.evaluate(&[1, 1])); +} + +#[test] +fn test_rectilinear_picture_compression_evaluate_wrong_config_length() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + assert!(!problem.evaluate(&[1])); + assert!(!problem.evaluate(&[1, 1, 0])); +} + +#[test] +fn test_rectilinear_picture_compression_evaluate_invalid_variable_value() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + assert!(!problem.evaluate(&[2, 0])); +} + +#[test] +fn test_rectilinear_picture_compression_issue_matrix_satisfiable() { + let problem = RectilinearPictureCompression::new(issue_matrix(), 3); + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_some()); + let sol = solution.unwrap(); + assert!(problem.evaluate(&sol)); +} + +#[test] +fn test_rectilinear_picture_compression_issue_matrix_unsatisfiable() { + let problem = RectilinearPictureCompression::new(issue_matrix(), 2); + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_none()); +} + +#[test] +fn test_rectilinear_picture_compression_brute_force() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + let solver = BruteForce::new(); + let solution = solver + .find_satisfying(&problem) + .expect("should find a solution"); + assert!(problem.evaluate(&solution)); +} + +#[test] +fn test_rectilinear_picture_compression_brute_force_all() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + let solver = BruteForce::new(); + let solutions = solver.find_all_satisfying(&problem); + // Two disjoint 2x2 blocks with K=2: exactly one satisfying config [1,1]. + assert_eq!(solutions.len(), 1); + for sol in &solutions { + assert!(problem.evaluate(sol)); + } +} + +#[test] +fn test_rectilinear_picture_compression_serialization() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + let json = serde_json::to_value(&problem).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "matrix": [ + [true, true, false, false], + [true, true, false, false], + [false, false, true, true], + [false, false, true, true], + ], + "bound_k": 2, + }) + ); + let restored: RectilinearPictureCompression = serde_json::from_value(json).unwrap(); + assert_eq!(restored.num_rows(), problem.num_rows()); + assert_eq!(restored.num_cols(), problem.num_cols()); + assert_eq!(restored.bound_k(), problem.bound_k()); + assert_eq!(restored.matrix(), problem.matrix()); +} + +#[test] +fn test_rectilinear_picture_compression_single_cell() { + // Single 1-entry matrix + let matrix = vec![vec![true]]; + let problem = RectilinearPictureCompression::new(matrix, 1); + let rects = problem.maximal_rectangles(); + assert_eq!(rects, vec![(0, 0, 0, 0)]); + assert_eq!(problem.dims(), vec![2]); + assert!(problem.evaluate(&[1])); + assert!(!problem.evaluate(&[0])); +} + +#[test] +fn test_rectilinear_picture_compression_all_zeros() { + // Matrix with no 1-entries: no maximal rectangles, always satisfiable + let matrix = vec![vec![false, false], vec![false, false]]; + let problem = RectilinearPictureCompression::new(matrix, 0); + let rects = problem.maximal_rectangles(); + assert!(rects.is_empty()); + assert_eq!(problem.dims(), Vec::::new()); + // Empty config satisfies (no 1-entries to cover) + assert!(problem.evaluate(&[])); +} + +#[test] +fn test_rectilinear_picture_compression_full_matrix() { + // 2x2 all-ones matrix: one maximal rectangle covers everything + let matrix = vec![vec![true, true], vec![true, true]]; + let problem = RectilinearPictureCompression::new(matrix, 1); + let rects = problem.maximal_rectangles(); + assert_eq!(rects, vec![(0, 0, 1, 1)]); + assert!(problem.evaluate(&[1])); + assert!(!problem.evaluate(&[0])); +} + +#[test] +fn test_rectilinear_picture_compression_overlapping_rectangles() { + // L-shaped region: requires multiple rectangles, some may overlap + let matrix = vec![vec![true, true], vec![true, false]]; + let problem = RectilinearPictureCompression::new(matrix, 2); + let rects = problem.maximal_rectangles(); + // Maximal rectangles: (0,0,1,0) vertical bar, (0,0,0,1) horizontal bar + assert!(rects.contains(&(0, 0, 1, 0))); + assert!(rects.contains(&(0, 0, 0, 1))); + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem).unwrap(); + assert!(problem.evaluate(&solution)); +} + +#[test] +fn test_rectilinear_picture_compression_matrix_getter() { + let matrix = two_block_matrix(); + let problem = RectilinearPictureCompression::new(matrix.clone(), 2); + assert_eq!(problem.matrix(), &matrix); +} + +#[test] +#[should_panic(expected = "empty")] +fn test_rectilinear_picture_compression_empty_matrix_panics() { + RectilinearPictureCompression::new(vec![], 1); +} + +#[test] +#[should_panic(expected = "column")] +fn test_rectilinear_picture_compression_empty_row_panics() { + RectilinearPictureCompression::new(vec![vec![]], 1); +} + +#[test] +#[should_panic(expected = "same length")] +fn test_rectilinear_picture_compression_inconsistent_rows_panics() { + RectilinearPictureCompression::new(vec![vec![true, false], vec![true]], 1); +} diff --git a/src/unit_tests/prelude.rs b/src/unit_tests/prelude.rs new file mode 100644 index 000000000..fd89ef5c6 --- /dev/null +++ b/src/unit_tests/prelude.rs @@ -0,0 +1,7 @@ +use crate::prelude::*; + +#[test] +fn test_prelude_exports_rectilinear_picture_compression() { + let problem = RectilinearPictureCompression::new(vec![vec![true]], 1); + assert_eq!(problem.bound_k(), 1); +}