From 8c29e46ac7f6363830ff4eec722fb3cb0fc8e135 Mon Sep 17 00:00:00 2001 From: Xiwei Pan <90967972+isPANN@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:41:54 -0800 Subject: [PATCH] Fix #1096: [Rule] MinimumDominatingSet/One to MinimumHittingSet Add the reduction from MinimumDominatingSet to MinimumHittingSet: one set per vertex v holding its closed neighborhood N[v] over the vertex universe, with identity solution extraction. Includes unit tests, a canonical example, and a paper entry. Co-Authored-By: Claude Sonnet 5.5 --- docs/paper/reductions.typ | 37 +++ docs/paper/references.bib | 11 + src/models/set/minimum_hitting_set.rs | 2 +- .../minimumdominatingset_minimumhittingset.rs | 97 +++++++ src/rules/mod.rs | 2 + .../minimumdominatingset_minimumhittingset.rs | 239 ++++++++++++++++++ 6 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 src/rules/minimumdominatingset_minimumhittingset.rs create mode 100644 src/unit_tests/rules/minimumdominatingset_minimumhittingset.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 8e0462b7a..d87fe07d9 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -14739,6 +14739,43 @@ 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$. ] +// MinimumDominatingSet → MinimumHittingSet (#1096) +#let mds_hs = load-example("MinimumDominatingSet", "MinimumHittingSet") +#let mds_hs_sol = mds_hs.solutions.at(0) +#let mds_hs_n = graph-num-vertices(mds_hs.source.instance) +#let mds_hs_edges = graph-instance(mds_hs.source.instance).graph.edges.map(e => (e.at(0), e.at(1))) +#let mds_hs_rows = mds_hs.target.instance.sets +#let mds_hs_closed(v) = range(mds_hs_n).filter(u => u == v or mds_hs_edges.any(e => (e.at(0) == u and e.at(1) == v) or (e.at(0) == v and e.at(1) == u))) +#let mds_hs_pick = mds_hs_sol.source_config.enumerate().filter(((i, x)) => x).map(((i, x)) => i) +#let mds_hs_hits(pick, row) = row.any(u => pick.contains(u)) +#assert(mds_hs_rows == range(mds_hs_n).map(mds_hs_closed), message: "target rows must be the closed neighborhoods") +#assert(mds_hs_rows.map(r => r.len()).sum() == mds_hs_n + 2 * mds_hs_edges.len(), message: "incidence count n + 2m") +#assert(mds_hs_rows.all(r => mds_hs_hits(mds_hs_pick, r)), message: "stored witness must hit every row") +#assert(range(mds_hs_n).all(v => not mds_hs_rows.all(r => mds_hs_hits((v,), r))), message: "no singleton may hit every row") +#assert(mds_hs_pick.len() == 2, message: "optimum is 2") +#reduction-rule("MinimumDominatingSet", "MinimumHittingSet", + example: true, + example-caption: [Unit-weight Dominating Set to Hitting Set (path, $n = #mds_hs_n$, $|E| = #mds_hs_edges.len()$)], + extra: [ + #pred-commands( + "pred create --example " + rule-spec(mds_hs) + " -o mds.json", + "pred reduce mds.json --via route.json -o bundle.json", + "pred solve bundle.json", + "pred evaluate mds.json --config " + cli-config(mds_hs_sol.source_config), + ) + *YES example.* The path has edges #mds_hs_edges.map(e => $(#e.at(0), #e.at(1))$).join(", "), so the rows are #mds_hs_rows.map(r => $\{#fmt-values(r)\}$).join(", ") with #mds_hs_rows.map(r => r.len()).sum() $= #mds_hs_n + 2 dot #mds_hs_edges.len()$ incidences in total. The selection $H = {#fmt-values(mds_hs_pick)}$ meets every row, hence dominates the path, and has size #mds_hs_pick.len(). \ + *NO example (bound $k = 1$).* The empty selection hits no row. Each of the #mds_hs_n singletons misses a row: #range(mds_hs_n).map(v => { let miss = mds_hs_rows.position(r => not r.contains(v)); [$#v$ misses $F_#miss = \{#fmt-values(mds_hs_rows.at(miss))\}$] }).join(", "). So neither the Dominating Set nor the Hitting Set question with bound 1 is satisfiable, and the optimum of both is #mds_hs_pick.len(). + ], +)[ + A set of vertices dominates a graph exactly when it hits the closed neighborhood of every vertex; this observation is stated in the literature on Hitting Set kernelization @bannach2018. It is restricted to unit weights because Hitting Set has no element weights (definitions in @garey1979). +][ + _Construction._ Given a simple graph $G = (V, E)$ with $V = {0, dots, n - 1}$ and unit weights, let $N[v] = {v} union {u : {u, v} in E}$. Output the Hitting Set instance with universe $U = V$ and one subset $F_v = N[v]$ for each $v in V$, in vertex order and keeping equal subsets. Then $|U| = n$, $|cal(F)| = n$, and the subsets contain $n + 2 |E|$ elements in total. + + _Correctness._ ($arrow.r.double$) If $S$ dominates $G$ and $v in V$, then either $v in S$ or some $u in S$ is adjacent to $v$; in both cases $S inter N[v] != emptyset$, so $S$ hits every $F_v$. ($arrow.l.double$) If $H$ hits every $F_v$, pick $h in H inter N[v]$ for a vertex $v$. Then $h = v$ or $h$ is adjacent to $v$, so $v$ is dominated by $H$. As the same vertex set is used in both directions, the cardinality is preserved and the optima coincide. + + _Solution extraction._ After checking that the target configuration is a hitting set, return it unchanged: universe element $v$ is vertex $v$, so every feasible hitting set, optimal or not, is a dominating set of the same size. The source model admits only simple graphs, so loops and parallel edges do not occur. +] + #reduction-rule("MinimumHittingSet", "ILP")[ Each set must contain at least one selected element -- a standard set-covering constraint on the element indicators. ][ diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 0dda81fd2..ed08d2994 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -2352,3 +2352,14 @@ @article{een2006 pages = {1--26}, doi = {10.3233/SAT190014}, } + +@inproceedings{bannach2018, + author = {Max Bannach and Till Tantau}, + title = {Computing Hitting Set Kernels By {AC}$^{0}$-Circuits}, + booktitle = {35th Symposium on Theoretical Aspects of Computer Science (STACS 2018)}, + series = {Leibniz International Proceedings in Informatics (LIPIcs)}, + volume = {96}, + pages = {9:1--9:14}, + year = {2018}, + doi = {10.4230/LIPIcs.STACS.2018.9} +} diff --git a/src/models/set/minimum_hitting_set.rs b/src/models/set/minimum_hitting_set.rs index 7ad1038b6..aa4f42a7f 100644 --- a/src/models/set/minimum_hitting_set.rs +++ b/src/models/set/minimum_hitting_set.rs @@ -72,7 +72,7 @@ impl MinimumHittingSet { Self::try_new(universe_size, sets).unwrap_or_else(|error| panic!("{error}")) } - fn try_new( + pub(crate) fn try_new( universe_size: usize, mut sets: Vec>, ) -> Result { diff --git a/src/rules/minimumdominatingset_minimumhittingset.rs b/src/rules/minimumdominatingset_minimumhittingset.rs new file mode 100644 index 000000000..4ebf4907a --- /dev/null +++ b/src/rules/minimumdominatingset_minimumhittingset.rs @@ -0,0 +1,97 @@ +//! Reduction from MinimumDominatingSet (unit-weight) to MinimumHittingSet. +//! +//! The universe is the vertex set and every vertex `v` contributes its closed +//! neighborhood `N[v]` as one set. A vertex subset hits every `N[v]` exactly +//! when it dominates the graph, so the two optimization problems share their +//! feasible configurations and their cardinalities (Bannach and Tantau, 2018). + +use crate::models::graph::MinimumDominatingSet; +use crate::models::set::MinimumHittingSet; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; +use crate::types::One; + +/// Result of reducing MinimumDominatingSet to MinimumHittingSet. +#[derive(Debug, Clone)] +pub struct ReductionDSToHS { + target: MinimumHittingSet, +} + +impl ReductionResult for ReductionDSToHS { + type Source = MinimumDominatingSet; + type Target = MinimumHittingSet; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Solution extraction: universe element `v` is vertex `v`. + fn extract_solution( + &self, + target_solution: &::Solution, + ) -> crate::rules::ExtractionResult<::Solution> { + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.is_valid(), + "target configuration does not hit every closed neighborhood", + )?; + + Ok(target_solution.to_vec()) + } +} + +#[reduction( + transform = exact { + universe_size = "num_vertices", + num_sets = "num_vertices", + } +)] +impl ReduceTo for MinimumDominatingSet { + type Result = ReductionDSToHS; + + fn reduce_to(&self) -> Result { + let num_vertices = self.graph().num_vertices(); + + // One set per vertex: the vertex together with its neighbors. + let sets: Vec> = (0..num_vertices) + .map(|v| { + let mut set = self.neighbors(v); + set.push(v); + set + }) + .collect(); + + let target = MinimumHittingSet::try_new(num_vertices, sets) + .map_err(>::target_construction)?; + + Ok(ReductionDSToHS { target }) + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "minimumdominatingset_to_minimumhittingset", + build: || { + let source = MinimumDominatingSet::new( + SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]), + vec![One; 5], + ); + crate::example_db::specs::rule_example_with_witness::<_, MinimumHittingSet>( + source, + SolutionPair { + source_config: serde_json::json!(vec![false, true, false, true, false]), + target_config: serde_json::json!(vec![false, true, false, true, false]), + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/minimumdominatingset_minimumhittingset.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 7606f4923..19678cdcc 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -114,6 +114,7 @@ pub(crate) mod minimumcostmaximumflow_minimumcostcirculation; pub(crate) mod minimumcoveringbycliques_minimumintersectiongraphbasis; pub(crate) mod minimumdiscreteplanarinversekinematics_qubo; mod minimumdominatingset_casts; +pub(crate) mod minimumdominatingset_minimumhittingset; mod minimumfeedbackvertexset_casts; pub(crate) mod minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters; pub(crate) mod minimummaximalmatching_maximumachromaticnumber; @@ -484,6 +485,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, +) -> MinimumDominatingSet { + MinimumDominatingSet::new( + SimpleGraph::new(num_vertices, edges), + vec![One; num_vertices], + ) +} + +fn reduce(problem: &MinimumDominatingSet) -> ReductionDSToHS { + ReduceTo::::reduce_to(problem).expect("reduction should succeed") +} + +fn all_configs(n: usize) -> impl Iterator> { + (0usize..1 << n).map(move |mask| (0..n).map(|i| mask >> i & 1 == 1).collect()) +} + +#[test] +fn test_minimumdominatingset_to_minimumhittingset_closed_loop() { + let problem = source(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]); + let reduction = reduce(&problem); + let target = reduction.target_problem(); + assert_eq!(target.universe_size(), 5); + assert_eq!( + target.sets(), + &[ + vec![0, 1], + vec![0, 1, 2], + vec![1, 2, 3], + vec![2, 3, 4], + vec![3, 4] + ] + ); + assert_optimization_round_trip_from_optimization_target( + &problem, + &reduction, + "DS(One)->HittingSet path", + ); + // {1,3} is optimal; {0,2,4} and {0,1,3} are feasible but larger; no singleton works. + for (chosen, value) in [ + (vec![1, 3], Some(2)), + (vec![0, 2, 4], Some(3)), + (vec![0, 1, 3], Some(3)), + ] { + let config: Vec = (0..5).map(|v| chosen.contains(&v)).collect(); + assert_eq!(target.evaluate(&config).unwrap(), Min(value)); + assert_eq!( + problem + .evaluate(&reduction.extract_solution(&config).unwrap()) + .unwrap(), + Min(value) + ); + } + for v in 0..5 { + let config: Vec = (0..5).map(|u| u == v).collect(); + assert_eq!(target.evaluate(&config).unwrap(), Min(None)); + } + let optimum = BruteForce::new().find_all_witnesses(target).unwrap(); + assert!( + optimum + .iter() + .all(|c| c.iter().filter(|&&b| b).count() == 2) + ); +} + +#[test] +fn test_minimumdominatingset_to_minimumhittingset_exhaustive_up_to_four_vertices() { + let solver = BruteForce::new(); + let mut graphs = 0; + for n in 0..=4usize { + let possible: Vec<(usize, usize)> = (0..n) + .flat_map(|u| (u + 1..n).map(move |v| (u, v))) + .collect(); + for mask in 0usize..1 << possible.len() { + let edges: Vec<(usize, usize)> = possible + .iter() + .enumerate() + .filter(|(i, _)| mask >> i & 1 == 1) + .map(|(_, &e)| e) + .collect(); + let problem = source(n, edges.clone()); + let reduction = reduce(&problem); + let target = reduction.target_problem(); + assert_eq!(target.universe_size(), n); + assert_eq!(target.num_sets(), n); + assert_eq!( + target.sets().iter().map(Vec::len).sum::(), + n + 2 * edges.len() + ); + // Feasibility and value agree on every configuration. + for config in all_configs(n) { + assert_eq!( + problem.evaluate(&config).unwrap(), + target.evaluate(&config).unwrap(), + "n={n} edges={edges:?} config={config:?}" + ); + } + // Identity extraction of every optimal target configuration. + let source_best = problem + .evaluate(&solver.solve(&problem).unwrap().unwrap()) + .unwrap(); + for witness in solver.find_all_witnesses(target).unwrap() { + let extracted = reduction.extract_solution(&witness).unwrap(); + assert_eq!(extracted, witness); + assert_eq!(problem.evaluate(&extracted).unwrap(), source_best); + } + graphs += 1; + } + } + assert_eq!(graphs, 1 + 1 + 2 + 8 + 64); +} + +#[test] +fn test_minimumdominatingset_to_minimumhittingset_special_cases() { + let optimum = |problem: &MinimumDominatingSet| { + let reduction = reduce(problem); + BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .map(|c| c.iter().filter(|&&b| b).count()) + }; + // Empty graph: empty universe, empty family, empty configuration. + let empty = source(0, vec![]); + let reduction = reduce(&empty); + assert_eq!(reduction.target_problem().universe_size(), 0); + assert_eq!(reduction.target_problem().num_sets(), 0); + assert!(reduction.extract_solution(&vec![false]).is_err()); + assert_eq!( + reduction.extract_solution(&vec![]).unwrap(), + Vec::::new() + ); + assert_eq!(optimum(&empty), Some(0)); + // Isolated vertices: singleton rows force every vertex. + let isolated = source(3, vec![]); + assert_eq!( + reduce(&isolated).target_problem().sets(), + &[vec![0], vec![1], vec![2]] + ); + assert_eq!(optimum(&isolated), Some(3)); + // Paths, cycles, stars, complete graphs. + for n in 3..=8usize { + let path: Vec<_> = (0..n - 1).map(|v| (v, v + 1)).collect(); + let mut cycle = path.clone(); + cycle.push((0, n - 1)); + let star: Vec<_> = (1..n).map(|v| (0, v)).collect(); + let complete: Vec<_> = (0..n) + .flat_map(|u| (u + 1..n).map(move |v| (u, v))) + .collect(); + assert_eq!(optimum(&source(n, path)), Some(n.div_ceil(3))); + assert_eq!(optimum(&source(n, cycle)), Some(n.div_ceil(3))); + assert_eq!(optimum(&source(n, star)), Some(1)); + assert_eq!(optimum(&source(n, complete)), Some(1)); + } + // Disconnected graph with an isolated vertex. + assert_eq!( + optimum(&source(7, vec![(0, 1), (1, 2), (3, 4), (4, 5)])), + Some(3) + ); + // Equal closed neighborhoods keep one row per vertex. + let edge = source(2, vec![(0, 1)]); + assert_eq!( + reduce(&edge).target_problem().sets(), + &[vec![0, 1], vec![0, 1]] + ); +} + +#[test] +fn test_minimumdominatingset_to_minimumhittingset_bounded_no_instance() { + // Path P5 has optimum 2, so bound 1 is a NO instance on both sides. + let problem = source(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]); + let reduction = reduce(&problem); + for config in all_configs(5).filter(|c| c.iter().filter(|&&b| b).count() <= 1) { + assert_eq!(problem.evaluate(&config).unwrap(), Min(None)); + assert_eq!( + reduction.target_problem().evaluate(&config).unwrap(), + Min(None) + ); + } +} + +#[test] +fn test_minimumdominatingset_to_minimumhittingset_extraction_rejects_invalid() { + let problem = source(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]); + let reduction = reduce(&problem); + // Does not hit every set. + assert!(reduction.extract_solution(&vec![false; 5]).is_err()); + assert!( + reduction + .extract_solution(&vec![true, false, false, false, false]) + .is_err() + ); + // Wrong lengths. + assert!(reduction.extract_solution(&vec![true; 4]).is_err()); + assert!(reduction.extract_solution(&vec![true; 6]).is_err()); + assert!(reduction.extract_solution(&vec![]).is_err()); + // A valid witness is returned unchanged. + let valid = vec![false, true, false, true, false]; + assert_eq!(reduction.extract_solution(&valid).unwrap(), valid); +} + +#[test] +fn test_minimumdominatingset_to_minimumhittingset_rejects_non_simple_graphs() { + // The source model only admits simple graphs, so the construction never sees these. + assert!(SimpleGraph::try_new(3, vec![(1, 1)]).is_err()); + assert!(SimpleGraph::try_new(3, vec![(0, 1), (0, 1)]).is_err()); + assert!(SimpleGraph::try_new(3, vec![(0, 1), (1, 0)]).is_err()); + assert!(SimpleGraph::try_new(3, vec![(0, 3)]).is_err()); +} + +#[cfg(feature = "example-db")] +#[test] +fn test_minimumdominatingset_to_minimumhittingset_canonical_example() { + let example = (canonical_rule_example_specs() + .into_iter() + .find(|spec| spec.id == "minimumdominatingset_to_minimumhittingset") + .expect("example spec should be registered") + .build)(); + assert_eq!(example.source.problem, "MinimumDominatingSet"); + assert_eq!(example.target.problem, "MinimumHittingSet"); + assert_eq!(example.solutions.len(), 1); + let solution = &example.solutions[0]; + assert_eq!(solution.source_config, solution.target_config); + + let source: MinimumDominatingSet = + serde_json::from_value(example.source.instance.clone()).unwrap(); + let target: MinimumHittingSet = + serde_json::from_value(example.target.instance.clone()).unwrap(); + let source_config: Vec = serde_json::from_value(solution.source_config.clone()).unwrap(); + let target_config: Vec = serde_json::from_value(solution.target_config.clone()).unwrap(); + assert_eq!(source.evaluate(&source_config).unwrap(), Min(Some(2))); + assert_eq!(target.evaluate(&target_config).unwrap(), Min(Some(2))); +}