diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index a4d185192..9c27e50a5 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -158,6 +158,7 @@ "QuantifiedBooleanFormulas": [Quantified Boolean Formulas (QBF)], "RectilinearPictureCompression": [Rectilinear Picture Compression], "ResourceConstrainedScheduling": [Resource Constrained Scheduling], + "RootedTreeStorageAssignment": [Rooted Tree Storage Assignment], "SchedulingWithIndividualDeadlines": [Scheduling With Individual Deadlines], "SequencingToMinimizeMaximumCumulativeCost": [Sequencing to Minimize Maximum Cumulative Cost], "SequencingToMinimizeWeightedCompletionTime": [Sequencing to Minimize Weighted Completion Time], @@ -2912,6 +2913,70 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("RootedTreeStorageAssignment") + let n = x.instance.universe_size + let subsets = x.instance.subsets + let m = subsets.len() + let K = x.instance.bound + let config = x.optimal_config + let edges = config.enumerate().filter(((v, p)) => v != p).map(((v, p)) => (p, v)) + let fmt-set(s) = "${" + s.map(e => str(e)).join(", ") + "}$" + let highlight-nodes = (0, 2, 4) + let highlight-edges = ((0, 2), (2, 4)) + [ + #problem-def("RootedTreeStorageAssignment")[ + Given a finite set $X = {0, 1, dots, #(n - 1)}$, a collection $cal(C) = {X_1, dots, X_m}$ of subsets of $X$, and a nonnegative integer $K$, find a directed rooted tree $T = (X, A)$ and supersets $X_i' supset.eq X_i$ such that every $X_i'$ forms a directed path in $T$ and $sum_(i = 1)^m |X_i' backslash X_i| <= K$. + ][ + Rooted Tree Storage Assignment is the storage-and-retrieval problem SR5 in Garey and Johnson @garey1979. Their catalog credits a reduction from Rooted Tree Arrangement, framing the problem as hierarchical file organization: pick a rooted tree on the records so every request set can be completed to a single root-to-leaf path using only a limited number of extra records. The implementation here uses one parent variable per element of $X$, so the direct exhaustive bound is $|X|^(|X|)$ candidate parent arrays, filtered down to valid rooted trees#footnote[No exact algorithm improving on the direct parent-array search bound is claimed here for the general formulation.]. + + *Example.* Let $X = {0, 1, dots, #(n - 1)}$, $K = #K$, and $cal(C) = {#range(m).map(i => $X_#(i + 1)$).join(", ")}$ with #subsets.enumerate().map(((i, s)) => $X_#(i + 1) = #fmt-set(s)$).join(", "). The satisfying parent array $p = (#config.map(str).join(", "))$ encodes the rooted tree with arcs #edges.map(((u, v)) => $(#u, #v)$).join(", "). In this tree, $X_1 = {0, 2}$, $X_2 = {1, 3}$, and $X_4 = {2, 4}$ are already directed paths. The only extension is $X_3 = {0, 4}$, which becomes $X_3' = {0, 2, 4}$ along the path $0 -> 2 -> 4$, so the total extension cost is exactly $1 = K$. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o rooted-tree-storage-assignment.json", + "pred solve rooted-tree-storage-assignment.json --solver brute-force", + "pred evaluate rooted-tree-storage-assignment.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 1cm, { + import draw: * + + let positions = ( + (1.5, 1.8), + (0.6, 0.9), + (2.4, 0.9), + (0.6, 0.0), + (2.4, 0.0), + ) + + for (u, v) in edges { + let highlighted = highlight-edges.contains((u, v)) + line( + positions.at(u), + positions.at(v), + stroke: if highlighted { 1.2pt + graph-colors.at(0) } else { 0.8pt + luma(140) }, + mark: (end: "straight", scale: 0.45), + ) + } + + for (vertex, pos) in positions.enumerate() { + let highlighted = highlight-nodes.contains(vertex) + circle( + pos, + radius: 0.2, + fill: if highlighted { graph-colors.at(0) } else { white }, + stroke: 0.6pt + black, + ) + content(pos, if highlighted { text(fill: white)[$#vertex$] } else { [$#vertex$] }) + } + }), + caption: [Rooted Tree Storage Assignment example. The rooted tree encoded by $p = (#config.map(str).join(", "))$ is shown; the blue path $0 -> 2 -> 4$ is the unique extension needed to realize $X_3 = {0, 4}$ within total cost $K = #K$.], + ) + ] + ] +} + #{ let x = load-model-example("TwoDimensionalConsecutiveSets") let n = x.instance.alphabet_size diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 4c968e141..eaef07dcd 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -260,6 +260,7 @@ Flags by problem type: SetBasis --universe, --sets, --k MinimumCardinalityKey --num-attributes, --dependencies, --k PrimeAttributeName --universe, --deps, --query + RootedTreeStorageAssignment --universe, --sets, --bound TwoDimensionalConsecutiveSets --alphabet-size, --sets BicliqueCover --left, --right, --biedges, --k BalancedCompleteBipartiteSubgraph --left, --right, --biedges, --k diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index cc0cd8c0f..98067c41a 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -598,6 +598,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "KColoring" => "--graph 0-1,1-2,2-0 --k 3", "HamiltonianCircuit" => "--graph 0-1,1-2,2-3,3-0", "EnsembleComputation" => "--universe 4 --sets \"0,1,2;0,1,3\" --budget 4", + "RootedTreeStorageAssignment" => "--universe 5 --sets \"0,2;1,3;0,4;2,4\" --bound 1", "MinMaxMulticenter" => { "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2 --bound 2" } @@ -2668,6 +2669,31 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // RootedTreeStorageAssignment + "RootedTreeStorageAssignment" => { + let usage = + "Usage: pred create RootedTreeStorageAssignment --universe 5 --sets \"0,2;1,3;0,4;2,4\" --bound 1"; + let universe_size = args.universe.ok_or_else(|| { + anyhow::anyhow!("RootedTreeStorageAssignment requires --universe\n\n{usage}") + })?; + let subsets = parse_sets(args)?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("RootedTreeStorageAssignment requires --bound\n\n{usage}") + })?; + let bound = parse_nonnegative_usize_bound(bound, "RootedTreeStorageAssignment", usage)?; + ( + ser( + problemreductions::models::set::RootedTreeStorageAssignment::try_new( + universe_size, + subsets, + bound, + ) + .map_err(anyhow::Error::msg)?, + )?, + resolved_variant.clone(), + ) + } + // BicliqueCover "BicliqueCover" => { let usage = "pred create BicliqueCover --left 2 --right 2 --biedges 0-0,0-1,1-1 --k 2"; @@ -7495,6 +7521,38 @@ mod tests { assert!(err.contains("ExpectedRetrievalCost requires --latency-bound")); } + #[test] + fn test_create_rooted_tree_storage_assignment_json() { + let mut args = empty_args(); + args.problem = Some("RootedTreeStorageAssignment".to_string()); + args.universe = Some(5); + args.sets = Some("0,2;1,3;0,4;2,4".to_string()); + args.bound = Some(1); + + let output_path = + std::env::temp_dir().join("pred_test_create_rooted_tree_storage_assignment.json"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let content = std::fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["type"], "RootedTreeStorageAssignment"); + assert_eq!(json["data"]["universe_size"], 5); + assert_eq!( + json["data"]["subsets"], + serde_json::json!([[0, 2], [1, 3], [0, 4], [2, 4]]) + ); + assert_eq!(json["data"]["bound"], 1); + + std::fs::remove_file(output_path).ok(); + } + #[test] fn test_create_stacker_crane_json() { let mut args = empty_args(); diff --git a/src/lib.rs b/src/lib.rs index a91083181..82c575b16 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,7 +80,8 @@ pub mod prelude { }; pub use crate::models::set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, - MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, PrimeAttributeName, SetBasis, + MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, PrimeAttributeName, + RootedTreeStorageAssignment, SetBasis, }; // Core traits diff --git a/src/models/mod.rs b/src/models/mod.rs index 496fa7cba..a653f9561 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -48,6 +48,6 @@ pub use misc::{ }; pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, - MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, PrimeAttributeName, SetBasis, - TwoDimensionalConsecutiveSets, + MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, PrimeAttributeName, + RootedTreeStorageAssignment, SetBasis, TwoDimensionalConsecutiveSets, }; diff --git a/src/models/set/mod.rs b/src/models/set/mod.rs index d96b2d3e2..136a74bb2 100644 --- a/src/models/set/mod.rs +++ b/src/models/set/mod.rs @@ -8,6 +8,7 @@ //! - [`MinimumHittingSet`]: Minimum-size universe subset hitting every set //! - [`MinimumSetCovering`]: Minimum weight set cover //! - [`PrimeAttributeName`]: Determine if an attribute belongs to any candidate key +//! - [`RootedTreeStorageAssignment`]: Extend subsets to directed tree paths within a total-cost bound pub(crate) mod comparative_containment; pub(crate) mod consecutive_sets; @@ -17,6 +18,7 @@ pub(crate) mod minimum_cardinality_key; pub(crate) mod minimum_hitting_set; pub(crate) mod minimum_set_covering; pub(crate) mod prime_attribute_name; +pub(crate) mod rooted_tree_storage_assignment; pub(crate) mod set_basis; pub(crate) mod two_dimensional_consecutive_sets; @@ -28,6 +30,7 @@ pub use minimum_cardinality_key::MinimumCardinalityKey; pub use minimum_hitting_set::MinimumHittingSet; pub use minimum_set_covering::MinimumSetCovering; pub use prime_attribute_name::PrimeAttributeName; +pub use rooted_tree_storage_assignment::RootedTreeStorageAssignment; pub use set_basis::SetBasis; pub use two_dimensional_consecutive_sets::TwoDimensionalConsecutiveSets; @@ -42,6 +45,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "Collection of subsets of X" }, + FieldInfo { name: "bound", type_name: "usize", description: "Upper bound K on the total extension cost" }, + ], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "RootedTreeStorageAssignmentDef")] +pub struct RootedTreeStorageAssignment { + universe_size: usize, + subsets: Vec>, + bound: usize, +} + +#[derive(Debug, Deserialize)] +struct RootedTreeStorageAssignmentDef { + universe_size: usize, + subsets: Vec>, + bound: usize, +} + +impl RootedTreeStorageAssignment { + pub fn new(universe_size: usize, subsets: Vec>, bound: usize) -> Self { + Self::try_new(universe_size, subsets, bound).unwrap_or_else(|err| panic!("{err}")) + } + + pub fn try_new( + universe_size: usize, + subsets: Vec>, + bound: usize, + ) -> Result { + let subsets = subsets + .into_iter() + .enumerate() + .map(|(subset_index, mut subset)| { + let mut seen = HashSet::with_capacity(subset.len()); + for &element in &subset { + if element >= universe_size { + return Err(format!( + "subset {subset_index} contains element {element} outside universe of size {universe_size}" + )); + } + if !seen.insert(element) { + return Err(format!( + "subset {subset_index} contains duplicate element {element}" + )); + } + } + subset.sort_unstable(); + Ok(subset) + }) + .collect::, _>>()?; + + Ok(Self { + universe_size, + subsets, + bound, + }) + } + + pub fn universe_size(&self) -> usize { + self.universe_size + } + + pub fn num_subsets(&self) -> usize { + self.subsets.len() + } + + pub fn subsets(&self) -> &[Vec] { + &self.subsets + } + + pub fn bound(&self) -> usize { + self.bound + } + + fn analyze_tree(config: &[usize]) -> Option> { + let roots = config + .iter() + .enumerate() + .filter(|(vertex, parent)| *vertex == **parent) + .count(); + if roots != 1 { + return None; + } + + let n = config.len(); + let mut state = vec![0u8; n]; + let mut depth = vec![0usize; n]; + + fn visit(vertex: usize, config: &[usize], state: &mut [u8], depth: &mut [usize]) -> bool { + match state[vertex] { + 2 => return true, + 1 => return false, + _ => {} + } + + state[vertex] = 1; + let parent = config[vertex]; + if parent == vertex { + depth[vertex] = 0; + } else { + if !visit(parent, config, state, depth) { + return false; + } + depth[vertex] = depth[parent] + 1; + } + state[vertex] = 2; + true + } + + for vertex in 0..n { + if !visit(vertex, config, &mut state, &mut depth) { + return None; + } + } + + Some(depth) + } + + fn is_ancestor(ancestor: usize, mut vertex: usize, config: &[usize], depth: &[usize]) -> bool { + if depth[ancestor] > depth[vertex] { + return false; + } + + while depth[vertex] > depth[ancestor] { + vertex = config[vertex]; + } + + ancestor == vertex + } + + fn subset_extension_cost( + &self, + subset: &[usize], + config: &[usize], + depth: &[usize], + ) -> Option { + if subset.len() <= 1 { + return Some(0); + } + + let mut ordered = subset.to_vec(); + ordered.sort_by_key(|&vertex| depth[vertex]); + + for pair in ordered.windows(2) { + if !Self::is_ancestor(pair[0], pair[1], config, depth) { + return None; + } + } + + let top = ordered[0]; + let bottom = *ordered.last().unwrap(); + Some(depth[bottom] - depth[top] + 1 - ordered.len()) + } +} + +impl Problem for RootedTreeStorageAssignment { + const NAME: &'static str = "RootedTreeStorageAssignment"; + type Metric = bool; + + fn dims(&self) -> Vec { + vec![self.universe_size; self.universe_size] + } + + fn evaluate(&self, config: &[usize]) -> bool { + if config.len() != self.universe_size { + return false; + } + if config.iter().any(|&parent| parent >= self.universe_size) { + return false; + } + if self.universe_size == 0 { + return self.subsets.is_empty(); + } + + let Some(depth) = Self::analyze_tree(config) else { + return false; + }; + + let mut total_cost = 0usize; + for subset in &self.subsets { + let Some(cost) = self.subset_extension_cost(subset, config, &depth) else { + return false; + }; + total_cost += cost; + if total_cost > self.bound { + return false; + } + } + + true + } + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } +} + +impl SatisfactionProblem for RootedTreeStorageAssignment {} + +crate::declare_variants! { + default sat RootedTreeStorageAssignment => "universe_size^universe_size", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "rooted_tree_storage_assignment", + instance: Box::new(RootedTreeStorageAssignment::new( + 5, + vec![vec![0, 2], vec![1, 3], vec![0, 4], vec![2, 4]], + 1, + )), + optimal_config: vec![0, 0, 0, 1, 2], + optimal_value: serde_json::json!(true), + }] +} + +impl TryFrom for RootedTreeStorageAssignment { + type Error = String; + + fn try_from(value: RootedTreeStorageAssignmentDef) -> Result { + Self::try_new(value.universe_size, value.subsets, value.bound) + } +} + +#[cfg(test)] +#[path = "../../unit_tests/models/set/rooted_tree_storage_assignment.rs"] +mod tests; diff --git a/src/unit_tests/models/set/rooted_tree_storage_assignment.rs b/src/unit_tests/models/set/rooted_tree_storage_assignment.rs new file mode 100644 index 000000000..478c71e6d --- /dev/null +++ b/src/unit_tests/models/set/rooted_tree_storage_assignment.rs @@ -0,0 +1,76 @@ +use super::*; +use crate::solvers::BruteForce; +use crate::traits::Problem; + +fn yes_instance(bound: usize) -> RootedTreeStorageAssignment { + RootedTreeStorageAssignment::new( + 5, + vec![vec![0, 2], vec![1, 3], vec![0, 4], vec![2, 4]], + bound, + ) +} + +#[test] +fn test_rooted_tree_storage_assignment_creation() { + let problem = yes_instance(1); + assert_eq!(problem.universe_size(), 5); + assert_eq!(problem.num_subsets(), 4); + assert_eq!(problem.bound(), 1); + assert_eq!( + problem.subsets(), + &[vec![0, 2], vec![1, 3], vec![0, 4], vec![2, 4]] + ); + assert_eq!(problem.dims(), vec![5; 5]); +} + +#[test] +fn test_rooted_tree_storage_assignment_evaluate_yes_instance() { + let problem = yes_instance(1); + assert!(problem.evaluate(&[0, 0, 0, 1, 2])); +} + +#[test] +fn test_rooted_tree_storage_assignment_rejects_invalid_tree_configs() { + let problem = yes_instance(1); + + assert!(!problem.evaluate(&[0, 0, 1, 2])); + assert!(!problem.evaluate(&[0, 0, 0, 1, 5])); + assert!(!problem.evaluate(&[0, 1, 2, 3, 4])); + assert!(!problem.evaluate(&[1, 0, 0, 1, 2])); +} + +#[test] +fn test_rooted_tree_storage_assignment_solver_finds_known_solution() { + let problem = yes_instance(1); + let solutions = BruteForce::new().find_all_satisfying(&problem); + assert!(!solutions.is_empty()); + assert!(solutions.contains(&vec![0, 0, 0, 1, 2])); +} + +#[test] +fn test_rooted_tree_storage_assignment_no_instance() { + let problem = yes_instance(0); + let solutions = BruteForce::new().find_all_satisfying(&problem); + assert!(solutions.is_empty()); +} + +#[test] +fn test_rooted_tree_storage_assignment_serialization() { + let problem = RootedTreeStorageAssignment::new(5, vec![vec![2, 0], vec![3, 1]], 7); + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: RootedTreeStorageAssignment = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.universe_size(), 5); + assert_eq!(deserialized.subsets(), &[vec![0, 2], vec![1, 3]]); + assert_eq!(deserialized.bound(), 7); +} + +#[test] +fn test_rooted_tree_storage_assignment_paper_example() { + let problem = yes_instance(1); + let config = vec![0, 0, 0, 1, 2]; + + assert!(problem.evaluate(&config)); + + let solutions = BruteForce::new().find_all_satisfying(&problem); + assert!(solutions.contains(&config)); +}