diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 8e0462b7a..a1a0822a2 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -14739,6 +14739,58 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ The hitting set $H$ is directly the vertex cover: $c_v = h_v$ for each $v in V$. ] +#let hs_sc = load-example("MinimumHittingSet", "MinimumSetCovering") +#let hs_sc_sol = hs_sc.solutions.at(0) +#let hs_sc_n = hs_sc.source.instance.universe_size +#let hs_sc_sets = hs_sc.source.instance.sets +#let hs_sc_dual = hs_sc.target.instance.sets +#let hs_sc_pick = hs_sc_sol.source_config.enumerate().filter(((i, x)) => x).map(((i, x)) => i) +#let hs_sc_hits = hs_sc_sets.map(s => s.filter(e => e in hs_sc_pick)) +#let hs_sc_covered = hs_sc_pick.map(u => hs_sc_dual.at(u)).flatten().dedup().sorted() +#let hs_sc_feasible = range(calc.pow(2, hs_sc_n)).filter(mask => { + let pick = range(hs_sc_n).filter(u => calc.rem(calc.quo(mask, calc.pow(2, u)), 2) == 1) + hs_sc_sets.all(s => s.any(e => e in pick)) +}) +#let hs_sc_unit = range(hs_sc_n).filter(u => calc.pow(2, u) in hs_sc_feasible) +#let hs_sc_min = calc.min(..hs_sc_feasible.map(mask => range(hs_sc_n).filter(u => calc.rem(calc.quo(mask, calc.pow(2, u)), 2) == 1).len())) +#assert(hs_sc_dual.len() == hs_sc_n and hs_sc.target.instance.universe_size == hs_sc_sets.len()) +#assert(hs_sc_sets.all(s => s.len() > 0) and hs_sc_hits.all(h => h.len() > 0)) +#assert(hs_sc_covered == range(hs_sc_sets.len()) and hs_sc_pick.len() == hs_sc_min) +#assert(hs_sc_unit.len() == 0 and hs_sc_pick.len() == 2) +#let hs_sc_no_n = 3 +#let hs_sc_no_sets = ((0, 1), (), (2,)) +#let hs_sc_no_dual = range(hs_sc_no_n).map(u => range(hs_sc_no_sets.len()).filter(j => u in hs_sc_no_sets.at(j))) +#let hs_sc_no_picks = range(calc.pow(2, hs_sc_no_n)).map(mask => range(hs_sc_no_n).filter(u => calc.rem(calc.quo(mask, calc.pow(2, u)), 2) == 1)) +#let hs_sc_no_empty = range(hs_sc_no_sets.len()).filter(j => hs_sc_no_sets.at(j).len() == 0) +#assert(hs_sc_no_empty == (1,)) +#assert(hs_sc_no_picks.all(h => not hs_sc_no_sets.all(s => s.any(e => e in h)))) +#assert(hs_sc_no_picks.all(h => h.map(u => hs_sc_no_dual.at(u)).flatten().dedup().sorted() != range(hs_sc_no_sets.len()))) +#assert(hs_sc_no_dual.flatten().all(j => j != 1)) +#assert(hs_sc_feasible.filter(mask => range(hs_sc_n).filter(u => calc.rem(calc.quo(mask, calc.pow(2, u)), 2) == 1).len() == hs_sc_min).len() == 1) +#reduction-rule("MinimumHittingSet", "MinimumSetCovering", + example: true, + example-caption: [Hitting Set with $|U| = #hs_sc_n$ elements and $m = #hs_sc_sets.len()$ sets], + extra: [ + #pred-commands( + "pred create --example " + rule-spec(hs_sc) + " -o hs.json", + "pred reduce hs.json --via route.json -o bundle.json", + "pred solve bundle.json", + "pred evaluate hs.json --config " + cli-config(hs_sc_sol.source_config), + ) + *YES instance.* $U = {#range(hs_sc_n).map(str).join(", ")}$ with sets #hs_sc_sets.enumerate().map(((j, s)) => $C_#j = {#fmt-values(s)}$).join(", "). The target has $|U'| = #hs_sc.target.instance.universe_size$ elements and the unit-weight sets #hs_sc_dual.enumerate().map(((u, d)) => $D_#u = {#fmt-values(d)}$).join(", "). The hitting set $H = {#fmt-values(hs_sc_pick)}$ meets #hs_sc_hits.enumerate().map(((j, h)) => $C_#j inter H = {#fmt-values(h)}$).join(", "), and the selected sets cover $D_#hs_sc_pick.at(0) union D_#hs_sc_pick.at(1) = {#fmt-values(hs_sc_covered)} = U'$, both of value #hs_sc_pick.len(). Of the $2^#hs_sc_n$ selections, #hs_sc_feasible.len() are feasible on both sides and the optimum #hs_sc_min is attained only by $H$; no singleton is feasible. + + *NO instance.* $U = {#range(hs_sc_no_n).map(str).join(", ")}$ with sets #hs_sc_no_sets.enumerate().map(((j, s)) => $C_#j = {#fmt-values(s)}$).join(", ") has no hitting set, since no element meets $C_#hs_sc_no_empty.at(0) = emptyset$ (all #hs_sc_no_picks.len() selections fail). The construction gives $U' = {#range(hs_sc_no_sets.len()).map(str).join(", ")}$ and #hs_sc_no_dual.enumerate().map(((u, d)) => $D_#u = {#fmt-values(d)}$).join(", "). Target element $#hs_sc_no_empty.at(0)$ lies in no $D_u$, so none of the #hs_sc_no_picks.len() selections covers $U'$ and the target is infeasible as well. + ], +)[ + Hitting a family of sets and covering a universe are dual: transposing the incidence relation turns each element into the set of members containing it. This is the dual set system construction, as in the CUHK CSCI3160 exercise solution @tao2024csci3160. @garey1979 gives only the two problem definitions. +][ + _Construction._ Given $(U, cal(C))$ with $U = {0, ..., n-1}$ and $cal(C) = (C_0, ..., C_(m-1))$, let the target universe be $U' = {0, ..., m-1}$, one element per source set. For each $u in U$ let $D_u = {j in U' : u in C_j}$ with weight $1$. The target has $n$ sets and $m$ elements, so `universe_size` $= m$ and `num_sets` $= n$ exactly. Empty families, empty members, repeated members and unused elements need no case split: an unused element gives an empty $D_u$, an empty member gives an element in no $D_u$. + + _Correctness._ For $H subset.eq U$, $j in union.big_(u in H) D_u$ iff some $u in H$ has $u in C_j$ iff $H inter C_j != emptyset$. ($arrow.r.double$) If $H$ hits every $C_j$, every $j in U'$ is covered by the sets $D_u$, $u in H$. ($arrow.l.double$) If ${D_u : u in H}$ covers $U'$, each $C_j$ contains some $u in H$. Both sides count $|H|$ unit weights, so feasibility and value coincide for every selection; an empty member makes both sides infeasible. + + _Solution extraction._ The selection vector is returned unchanged (length $n$) after the target witness is checked to be a cover. The objective sum is at most $n$ and the reduction rejects $n > 2^63 - 1$ with a typed error, so the `i64` value never overflows. +] + #reduction-rule("MinimumHittingSet", "ILP")[ Each set must contain at least one selected element -- a standard set-covering constraint on the element indicators. ][ @@ -17442,6 +17494,7 @@ The following table shows concrete target-variable counts for example instances, ), (source: "MaximumMatching", target: "MaximumSetPacking"), (source: "MinimumVertexCover", target: "MinimumSetCovering"), + (source: "MinimumHittingSet", target: "MinimumSetCovering"), (source: "MaxCut", target: "SpinGlass"), (source: "SpinGlass", target: "MaxCut"), ( diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 0dda81fd2..522e5568f 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -2352,3 +2352,10 @@ @article{een2006 pages = {1--26}, doi = {10.3233/SAT190014}, } + +@misc{tao2024csci3160, + author = {Yufei Tao}, + title = {{CSCI3160} Design and Analysis of Algorithms, Exercise 13 Solutions}, + howpublished = {Chinese University of Hong Kong, Fall 2024}, + url = {https://web.archive.org/web/2024/https://www.cse.cuhk.edu.hk/~taoyf/course/3160/24-fall/ex/ex13-sol.pdf} +} diff --git a/src/models/set/minimum_set_covering.rs b/src/models/set/minimum_set_covering.rs index 293434781..a7bd7717d 100644 --- a/src/models/set/minimum_set_covering.rs +++ b/src/models/set/minimum_set_covering.rs @@ -119,7 +119,7 @@ impl MinimumSetCovering { .unwrap_or_else(|error| panic!("{error}")) } - fn try_with_weights( + pub(crate) fn try_with_weights( universe_size: usize, sets: Vec>, weights: Vec, diff --git a/src/rules/minimumhittingset_ilp.rs b/src/rules/minimumhittingset_ilp.rs index 551785f41..a9dbfc20c 100644 --- a/src/rules/minimumhittingset_ilp.rs +++ b/src/rules/minimumhittingset_ilp.rs @@ -56,7 +56,7 @@ impl ReduceTo> for MinimumHittingSet { .collect(); let objective: Vec<(usize, i64)> = (0..num_vars).map(|i| (i, 1)).collect(); let target = ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize) - .map_err(Self::target_construction)?; + .map_err(>>::target_construction)?; Ok(ReductionHSToILP { target }) } } diff --git a/src/rules/minimumhittingset_minimumsetcovering.rs b/src/rules/minimumhittingset_minimumsetcovering.rs new file mode 100644 index 000000000..066beaca6 --- /dev/null +++ b/src/rules/minimumhittingset_minimumsetcovering.rs @@ -0,0 +1,102 @@ +//! Reduction from MinimumHittingSet to MinimumSetCovering. +//! +//! The target is the dual set system: one target element per source set, and +//! one unit-weight target set per source element containing the indices of the +//! source sets in which that element occurs. A selection of source elements +//! hits every source set exactly when the corresponding target sets cover every +//! target element, so the selection vectors coincide. + +use crate::models::set::{MinimumHittingSet, MinimumSetCovering}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; + +/// Result of reducing MinimumHittingSet to MinimumSetCovering. +#[derive(Debug, Clone)] +pub struct ReductionHSToSC { + target: MinimumSetCovering, +} + +impl ReductionResult for ReductionHSToSC { + type Source = MinimumHittingSet; + type Target = MinimumSetCovering; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Solution extraction: element `u` is selected in the hitting set exactly + /// when target set `D_u` is selected in the cover. + fn extract_solution( + &self, + target_solution: &::Solution, + ) -> crate::rules::ExtractionResult<::Solution> { + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0.is_some(), + "selected sets do not cover the universe", + )?; + + Ok(target_solution.to_vec()) + } +} + +#[reduction( + transform = exact { + universe_size = "num_sets", + num_sets = "universe_size", + } +)] +impl ReduceTo> for MinimumHittingSet { + type Result = ReductionHSToSC; + + fn reduce_to(&self) -> Result { + let num_elements = self.universe_size(); + // A selection has at most `num_elements` unit weights; its objective must fit `i64`. + >>::exact_i64( + num_elements, + "counting unit-weight target sets", + )?; + + // D_u = { j : u in C_j }; scanning the sets in order keeps each D_u sorted. + let mut dual: Vec> = vec![Vec::new(); num_elements]; + for (j, set) in self.sets().iter().enumerate() { + for &element in set { + dual[element].push(j); + } + } + + let target = MinimumSetCovering::::try_with_weights( + self.num_sets(), + dual, + vec![1; num_elements], + ) + .map_err(>>::target_construction)?; + + Ok(ReductionHSToSC { target }) + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "minimumhittingset_to_minimumsetcovering", + build: || { + let source = + MinimumHittingSet::new(5, vec![vec![0, 1], vec![1, 2, 3], vec![0, 3], vec![2, 4]]); + crate::example_db::specs::rule_example_with_witness::<_, MinimumSetCovering>( + source, + SolutionPair { + source_config: serde_json::json!(vec![true, false, true, false, false]), + target_config: serde_json::json!(vec![true, false, true, false, false]), + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/minimumhittingset_minimumsetcovering.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 7606f4923..abb4409b6 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -257,6 +257,7 @@ pub(crate) mod minimumfeedbackarcset_ilp; pub(crate) mod minimumfeedbackvertexset_ilp; pub(crate) mod minimumgraphbandwidth_ilp; pub(crate) mod minimumhittingset_ilp; +pub(crate) mod minimumhittingset_minimumsetcovering; pub(crate) mod minimuminternalmacrodatacompression_ilp; pub(crate) mod minimummatrixcover_ilp; pub(crate) mod minimummaximalmatching_ilp; @@ -483,6 +484,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec ReductionHSToSC { + ReduceTo::>::reduce_to(source).expect("reduction should succeed") +} + +fn all_configs(len: usize) -> Vec> { + (0..1usize << len) + .map(|bits| (0..len).map(|i| bits >> i & 1 == 1).collect()) + .collect() +} + +/// Every configuration has the same value on both sides (feasibility and cardinality). +fn assert_values_agree(source: &MinimumHittingSet) { + let reduction = reduce(source); + let target = reduction.target_problem(); + assert_eq!(target.universe_size(), source.num_sets()); + assert_eq!(target.num_sets(), source.universe_size()); + assert!(target.weights_ref().iter().all(|&w| w == 1)); + for config in all_configs(source.universe_size()) { + assert_eq!( + source.evaluate(&config).unwrap(), + target.evaluate(&config).unwrap(), + "{config:?}" + ); + } + assert_parameter_predictions(source, &reduction); +} + +#[test] +fn test_minimumhittingset_to_minimumsetcovering_closed_loop() { + let source = MinimumHittingSet::new(5, vec![vec![0, 1], vec![1, 2, 3], vec![0, 3], vec![2, 4]]); + let reduction = reduce(&source); + let target = reduction.target_problem(); + assert_eq!(target.universe_size(), 4); + assert_eq!( + target.sets(), + &[vec![0, 2], vec![0, 1], vec![1, 3], vec![1, 2], vec![3]] + ); + assert_values_agree(&source); + // {0, 2} is the unique optimum; no singleton is feasible. + let witness = vec![true, false, true, false, false]; + assert_eq!(target.evaluate(&witness).unwrap(), Min(Some(2))); + assert_eq!(reduction.extract_solution(&witness).unwrap(), witness); + for i in 0..5 { + let mut single = vec![false; 5]; + single[i] = true; + assert_eq!(target.evaluate(&single).unwrap(), Min(None)); + } + let optima = BruteForce::new().find_all_witnesses(target).unwrap(); + assert_eq!(optima, vec![witness]); + assert_optimization_round_trip_from_optimization_target(&source, &reduction, "HS->SC"); +} + +#[test] +fn test_minimumhittingset_to_minimumsetcovering_exhaustive_small() { + // Every ordered family of up to three subsets of a universe of size 0..=3. + for n in 0..=3usize { + let subsets: Vec> = (0..1usize << n) + .map(|mask| (0..n).filter(|e| mask >> e & 1 == 1).collect()) + .collect(); + for m in 0..=3usize { + let mut index = vec![0usize; m]; + loop { + let family: Vec> = index.iter().map(|&i| subsets[i].clone()).collect(); + let source = MinimumHittingSet::new(n, family); + assert_values_agree(&source); + // Feasible exactly when no member is empty; the round-trip helper needs a witness. + if source.sets().iter().all(|set| !set.is_empty()) { + let reduction = reduce(&source); + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "HS->SC exhaustive", + ); + } + // advance the odometer + let mut pos = 0; + while pos < m { + index[pos] += 1; + if index[pos] < subsets.len() { + break; + } + index[pos] = 0; + pos += 1; + } + if pos == m { + break; + } + } + } + } +} + +#[test] +fn test_minimumhittingset_to_minimumsetcovering_empty_family() { + // No sets to hit: the target universe is empty, every element gives an empty set. + let source = MinimumHittingSet::new(3, vec![]); + let reduction = reduce(&source); + assert_eq!(reduction.target_problem().universe_size(), 0); + assert_eq!( + reduction.target_problem().sets(), + &vec![Vec::::new(); 3] + ); + assert_values_agree(&source); + let (value, witnesses) = BruteForce::new() + .solve_with_witnesses(reduction.target_problem()) + .unwrap(); + assert_eq!(value, Min(Some(0))); + assert_eq!(witnesses, vec![vec![false; 3]]); + // Empty universe and empty family. + let source = MinimumHittingSet::new(0, vec![]); + let reduction = reduce(&source); + assert_eq!(reduction.target_problem().num_sets(), 0); + assert_eq!( + reduction.target_problem().evaluate(&vec![]).unwrap(), + Min(Some(0)) + ); + assert_eq!( + reduction.extract_solution(&vec![]).unwrap(), + Vec::::new() + ); +} + +#[test] +fn test_minimumhittingset_to_minimumsetcovering_empty_member_is_infeasible() { + let source = MinimumHittingSet::new(2, vec![vec![], vec![0]]); + let reduction = reduce(&source); + assert_eq!(reduction.target_problem().sets(), &[vec![1], vec![]]); + assert_values_agree(&source); + for config in all_configs(2) { + assert_eq!(source.evaluate(&config).unwrap(), Min(None)); + assert_eq!( + reduction.target_problem().evaluate(&config).unwrap(), + Min(None) + ); + // An infeasible target selection is rejected, not extracted. + assert!(reduction.extract_solution(&config).is_err()); + } + // Zero-element universe with one empty member. + let source = MinimumHittingSet::new(0, vec![vec![]]); + let reduction = reduce(&source); + assert_eq!(reduction.target_problem().universe_size(), 1); + assert_eq!(reduction.target_problem().num_sets(), 0); + assert_values_agree(&source); + assert!(reduction.extract_solution(&vec![]).is_err()); + let (value, witnesses) = BruteForce::new() + .solve_with_witnesses(reduction.target_problem()) + .unwrap(); + assert_eq!(value, Min(None)); + assert!(witnesses.is_empty()); +} + +#[test] +fn test_minimumhittingset_to_minimumsetcovering_repeated_sets_and_unused_elements() { + // Members 0 and 1 repeat; element 3 occurs nowhere. + let source = MinimumHittingSet::new(4, vec![vec![0, 1], vec![1, 0], vec![2]]); + let reduction = reduce(&source); + assert_eq!( + reduction.target_problem().sets(), + &[vec![0, 1], vec![0, 1], vec![2], vec![]] + ); + assert_values_agree(&source); + assert_optimization_round_trip_from_optimization_target(&source, &reduction, "repeated"); + // Duplicated elements inside a member are normalised by the source model. + let source = MinimumHittingSet::new(3, vec![vec![0, 0, 1, 1], vec![2, 2]]); + assert_eq!( + reduce(&source).target_problem().sets(), + &[vec![0], vec![0], vec![1]] + ); +} + +#[test] +fn test_minimumhittingset_to_minimumsetcovering_extraction_rejects_bad_witnesses() { + let source = MinimumHittingSet::new(3, vec![vec![0, 1], vec![2]]); + let reduction = reduce(&source); + // Wrong lengths. + assert!(reduction.extract_solution(&vec![true, true]).is_err()); + assert!( + reduction + .extract_solution(&vec![true, true, true, true]) + .is_err() + ); + assert!(reduction.extract_solution(&vec![]).is_err()); + // Non-cover. + assert!( + reduction + .extract_solution(&vec![true, true, false]) + .is_err() + ); + assert!(reduction.extract_solution(&vec![false; 3]).is_err()); + // Non-minimal covers are accepted and mapped identically. + assert_eq!( + reduction.extract_solution(&vec![true, true, true]).unwrap(), + vec![true, true, true] + ); +} + +#[test] +fn test_minimumhittingset_to_minimumsetcovering_numeric_boundary() { + // The objective of a selection is a unit-weight sum bounded by the universe size, + // which must fit i64. On a 64-bit target a larger universe is rejected with a typed + // error before anything is allocated. + if usize::BITS == 64 { + let too_large = MinimumHittingSet::new(i64::MAX as usize + 1, vec![]); + let error = ReduceTo::>::reduce_to(&too_large) + .expect_err("universe beyond i64 must be rejected"); + assert!(matches!( + error, + crate::rules::ReductionError::IntegerOverflow { .. } + )); + let largest = MinimumHittingSet::new(usize::MAX, vec![]); + assert!(ReduceTo::>::reduce_to(&largest).is_err()); + } + // Values just below the limit are accepted by the check itself. + assert_eq!( + >>::exact_i64( + i64::MAX as usize, + "boundary" + ) + .unwrap(), + i64::MAX + ); + // Target evaluation of unit weights is the cardinality, with a typed error on overflow. + let target = + MinimumSetCovering::::with_weights(0, vec![vec![], vec![]], vec![i64::MAX, 1]); + assert!(target.evaluate(&vec![true, true]).is_err()); + assert_eq!( + target.evaluate(&vec![true, false]).unwrap(), + Min(Some(i64::MAX)) + ); +}