diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 376712b9e..1f41cdcaa 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -111,6 +111,7 @@ "SpinGlass": [Spin Glass], "QUBO": [QUBO], "ILP": [Integer Linear Programming], + "IntegerKnapsack": [Integer Knapsack], "Knapsack": [Knapsack], "PartiallyOrderedKnapsack": [Partially Ordered Knapsack], "Satisfiability": [SAT], @@ -4023,6 +4024,33 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("IntegerKnapsack") + let sizes = x.instance.sizes + let values = x.instance.values + let B = x.instance.capacity + let n = sizes.len() + let config = x.optimal_config + let opt-val = metric-value(x.optimal_value) + let total-s = range(n).map(i => config.at(i) * sizes.at(i)).sum() + let total-v = range(n).map(i => config.at(i) * values.at(i)).sum() + [ + #problem-def("IntegerKnapsack")[ + Given $n$ items with sizes $s_0, dots, s_(n-1) in ZZ^+$ and values $v_0, dots, v_(n-1) in ZZ^+$, and a capacity $B in NN$, find non-negative integer multiplicities $c_0, dots, c_(n-1) in NN$ maximizing $sum_(i=0)^(n-1) c_i dot v_i$ subject to $sum_(i=0)^(n-1) c_i dot s_i lt.eq B$. + ][ + The Integer Knapsack (also called the _unbounded knapsack problem_) generalizes the 0-1 Knapsack by allowing each item to be selected more than once. Like 0-1 Knapsack, it admits a pseudo-polynomial $O(n B)$ dynamic-programming algorithm @garey1979. The problem is weakly NP-hard: when item sizes are bounded by a polynomial in $n$, DP runs in polynomial time. The brute-force approach enumerates all multiplicity vectors, giving $O(product_(i=0)^(n-1)(floor.l B slash s_i floor.r + 1))$ configurations.#footnote[No algorithm improving on brute-force enumeration of multiplicity vectors is known for the general Integer Knapsack problem.] + + *Example.* Let $n = #n$ items with sizes $(#sizes.map(s => str(s)).join(", "))$, values $(#values.map(v => str(v)).join(", "))$, and capacity $B = #B$. Setting multiplicities $(#config.map(c => str(c)).join(", "))$ gives total size $#total-s lt.eq B$ and total value $#total-v$, which is optimal. + + #pred-commands( + "pred create --example IntegerKnapsack -o ik.json", + "pred solve ik.json", + "pred evaluate ik.json --config " + x.optimal_config.map(str).join(","), + ) + ] + ] +} + #problem-def("PartiallyOrderedKnapsack")[ Given $n$ items with weights $w_0, dots, w_(n-1) in NN$ and values $v_0, dots, v_(n-1) in NN$, a partial order $prec$ on the items (given by its cover relations), and a capacity $C in NN$, find a downward-closed subset $S subset.eq {0, dots, n - 1}$ (i.e., if $i in S$ and $j prec i$ then $j in S$) maximizing $sum_(i in S) v_i$ subject to $sum_(i in S) w_i lt.eq C$. ][ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index b71d1b244..1a99531cd 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -297,6 +297,7 @@ Flags by problem type: SteinerTreeInGraphs --graph, --edge-weights, --terminals PartitionIntoPathsOfLength2 --graph ResourceConstrainedScheduling --num-processors, --resource-bounds, --resource-requirements, --deadline + IntegerKnapsack --sizes, --values, --capacity PartiallyOrderedKnapsack --sizes, --values, --capacity, --precedences QAP --matrix (cost), --distance-matrix StrongConnectivityAugmentation --arcs, --candidate-arcs, --bound [--num-vertices] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index a3e266d3a..b171f5508 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -725,6 +725,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "SequencingToMinimizeWeightedTardiness" => { "--sizes 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" } + "IntegerKnapsack" => "--sizes 3,4,5,2,7 --values 4,5,7,3,9 --capacity 15", "SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11", "ThreePartition" => "--sizes 4,5,6,4,6,5 --bound 15", "BoyceCoddNormalFormViolation" => { @@ -2333,6 +2334,41 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // IntegerKnapsack + "IntegerKnapsack" => { + let sizes_str = args.sizes.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "IntegerKnapsack requires --sizes, --values, and --capacity\n\n\ + Usage: pred create IntegerKnapsack --sizes 3,4,5,2,7 --values 4,5,7,3,9 --capacity 15" + ) + })?; + let values_str = args.values.as_deref().ok_or_else(|| { + anyhow::anyhow!("IntegerKnapsack requires --values (e.g., 4,5,7,3,9)") + })?; + let cap_str = args + .capacity + .as_deref() + .ok_or_else(|| anyhow::anyhow!("IntegerKnapsack requires --capacity (e.g., 15)"))?; + let sizes: Vec = util::parse_comma_list(sizes_str)?; + let values: Vec = util::parse_comma_list(values_str)?; + let capacity: i64 = cap_str.parse()?; + anyhow::ensure!( + sizes.len() == values.len(), + "sizes and values must have the same length, got {} and {}", + sizes.len(), + values.len() + ); + anyhow::ensure!(sizes.iter().all(|&s| s > 0), "all sizes must be positive"); + anyhow::ensure!(values.iter().all(|&v| v > 0), "all values must be positive"); + anyhow::ensure!(capacity >= 0, "capacity must be nonnegative"); + ( + ser(problemreductions::models::set::IntegerKnapsack::new( + sizes, values, capacity, + ))?, + resolved_variant.clone(), + ) + } + // SubsetSum "SubsetSum" => { let sizes_str = args.sizes.as_deref().ok_or_else(|| { diff --git a/src/lib.rs b/src/lib.rs index 1d95630dd..8a99b5938 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,9 +83,9 @@ pub mod prelude { TimetableDesign, }; pub use crate::models::set::{ - ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, - MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, PrimeAttributeName, - RootedTreeStorageAssignment, SetBasis, + ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, IntegerKnapsack, + MaximumSetPacking, MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, + PrimeAttributeName, RootedTreeStorageAssignment, SetBasis, }; // Core traits diff --git a/src/models/mod.rs b/src/models/mod.rs index 4cfdf1c86..031f17c8f 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -50,7 +50,7 @@ pub use misc::{ Term, ThreePartition, TimetableDesign, }; pub use set::{ - ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, + ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, IntegerKnapsack, MaximumSetPacking, MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, PrimeAttributeName, RootedTreeStorageAssignment, SetBasis, TwoDimensionalConsecutiveSets, }; diff --git a/src/models/set/integer_knapsack.rs b/src/models/set/integer_knapsack.rs new file mode 100644 index 000000000..aba6cb1f6 --- /dev/null +++ b/src/models/set/integer_knapsack.rs @@ -0,0 +1,231 @@ +//! Integer Knapsack problem implementation. +//! +//! The Integer Knapsack problem generalizes the 0-1 Knapsack by allowing +//! each item to be selected with a non-negative integer multiplicity. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::traits::Problem; +use crate::types::Max; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "IntegerKnapsack", + display_name: "Integer Knapsack", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Select items with integer multiplicities to maximize total value subject to capacity constraint", + fields: &[ + FieldInfo { name: "sizes", type_name: "Vec", description: "Positive item sizes s(u)" }, + FieldInfo { name: "values", type_name: "Vec", description: "Positive item values v(u)" }, + FieldInfo { name: "capacity", type_name: "i64", description: "Nonnegative knapsack capacity B" }, + ], + } +} + +/// The Integer Knapsack problem. +/// +/// Given `n` items, each with positive size `s_i` and positive value `v_i`, +/// and a nonnegative capacity `B`, +/// find non-negative integer multiplicities `c_0, ..., c_{n-1}` such that +/// `sum c_i * s_i <= B`, maximizing `sum c_i * v_i`. +/// +/// # Representation +/// +/// Variable `i` has domain `{0, ..., floor(B / s_i)}` representing the +/// multiplicity of item `i`. +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::set::IntegerKnapsack; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// let problem = IntegerKnapsack::new(vec![3, 4, 5, 2, 7], vec![4, 5, 7, 3, 9], 15); +/// let solver = BruteForce::new(); +/// let solution = solver.find_witness(&problem); +/// assert!(solution.is_some()); +/// ``` +#[derive(Debug, Clone, Serialize)] +#[serde(into = "RawIntegerKnapsack")] +pub struct IntegerKnapsack { + sizes: Vec, + values: Vec, + capacity: i64, +} + +impl IntegerKnapsack { + /// Create a new IntegerKnapsack instance. + /// + /// # Panics + /// Panics if `sizes` and `values` have different lengths, or if any + /// size or value is not positive, or if capacity is negative. + pub fn new(sizes: Vec, values: Vec, capacity: i64) -> Self { + assert_eq!( + sizes.len(), + values.len(), + "sizes and values must have the same length" + ); + assert!( + sizes.iter().all(|&s| s > 0), + "IntegerKnapsack sizes must be positive" + ); + assert!( + values.iter().all(|&v| v > 0), + "IntegerKnapsack values must be positive" + ); + assert!( + capacity >= 0, + "IntegerKnapsack capacity must be nonnegative" + ); + Self { + sizes, + values, + capacity, + } + } + + /// Returns the item sizes. + pub fn sizes(&self) -> &[i64] { + &self.sizes + } + + /// Returns the item values. + pub fn values(&self) -> &[i64] { + &self.values + } + + /// Returns the knapsack capacity. + pub fn capacity(&self) -> i64 { + self.capacity + } + + /// Returns the number of items. + pub fn num_items(&self) -> usize { + self.sizes.len() + } +} + +impl Problem for IntegerKnapsack { + const NAME: &'static str = "IntegerKnapsack"; + type Value = Max; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + self.sizes + .iter() + .map(|&s| (self.capacity / s + 1) as usize) + .collect() + } + + fn evaluate(&self, config: &[usize]) -> Max { + if config.len() != self.num_items() { + return Max(None); + } + let dims = self.dims(); + if config.iter().zip(dims.iter()).any(|(&c, &d)| c >= d) { + return Max(None); + } + let total_size: i64 = config + .iter() + .enumerate() + .map(|(i, &c)| c as i64 * self.sizes[i]) + .sum(); + if total_size > self.capacity { + return Max(None); + } + let total_value: i64 = config + .iter() + .enumerate() + .map(|(i, &c)| c as i64 * self.values[i]) + .sum(); + Max(Some(total_value)) + } +} + +crate::declare_variants! { + default IntegerKnapsack => "(capacity + 1)^num_items", +} + +/// Raw representation for serde deserialization with full validation. +#[derive(Deserialize, Serialize)] +struct RawIntegerKnapsack { + sizes: Vec, + values: Vec, + capacity: i64, +} + +impl From for RawIntegerKnapsack { + fn from(ik: IntegerKnapsack) -> Self { + RawIntegerKnapsack { + sizes: ik.sizes, + values: ik.values, + capacity: ik.capacity, + } + } +} + +impl TryFrom for IntegerKnapsack { + type Error = String; + + fn try_from(raw: RawIntegerKnapsack) -> Result { + if raw.sizes.len() != raw.values.len() { + return Err(format!( + "sizes and values must have the same length, got {} and {}", + raw.sizes.len(), + raw.values.len() + )); + } + if let Some(&s) = raw.sizes.iter().find(|&&s| s <= 0) { + return Err(format!("expected positive sizes, got {s}")); + } + if let Some(&v) = raw.values.iter().find(|&&v| v <= 0) { + return Err(format!("expected positive values, got {v}")); + } + if raw.capacity < 0 { + return Err(format!( + "expected nonnegative capacity, got {}", + raw.capacity + )); + } + Ok(IntegerKnapsack { + sizes: raw.sizes, + values: raw.values, + capacity: raw.capacity, + }) + } +} + +impl<'de> Deserialize<'de> for IntegerKnapsack { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = RawIntegerKnapsack::deserialize(deserializer)?; + IntegerKnapsack::try_from(raw).map_err(serde::de::Error::custom) + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + // 5 items: sizes [3,4,5,2,7], values [4,5,7,3,9], capacity 15 + // Optimal: c=(0,0,1,5,0) → total_size=5+10=15, total_value=7+15=22 + vec![crate::example_db::specs::ModelExampleSpec { + id: "integer-knapsack", + instance: Box::new(IntegerKnapsack::new( + vec![3, 4, 5, 2, 7], + vec![4, 5, 7, 3, 9], + 15, + )), + optimal_config: vec![0, 0, 1, 5, 0], + optimal_value: serde_json::json!(22), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/set/integer_knapsack.rs"] +mod tests; diff --git a/src/models/set/mod.rs b/src/models/set/mod.rs index 136a74bb2..cd71cd521 100644 --- a/src/models/set/mod.rs +++ b/src/models/set/mod.rs @@ -4,6 +4,7 @@ //! - [`ConsecutiveSets`]: Consecutive arrangement of subset elements in a string //! - [`ExactCoverBy3Sets`]: Exact cover by 3-element subsets (X3C) //! - [`ComparativeContainment`]: Compare containment-weight sums for two set families +//! - [`IntegerKnapsack`]: Maximize value with integer multiplicities subject to capacity //! - [`MaximumSetPacking`]: Maximum weight set packing //! - [`MinimumHittingSet`]: Minimum-size universe subset hitting every set //! - [`MinimumSetCovering`]: Minimum weight set cover @@ -13,6 +14,7 @@ pub(crate) mod comparative_containment; pub(crate) mod consecutive_sets; pub(crate) mod exact_cover_by_3_sets; +pub(crate) mod integer_knapsack; pub(crate) mod maximum_set_packing; pub(crate) mod minimum_cardinality_key; pub(crate) mod minimum_hitting_set; @@ -25,6 +27,7 @@ pub(crate) mod two_dimensional_consecutive_sets; pub use comparative_containment::ComparativeContainment; pub use consecutive_sets::ConsecutiveSets; pub use exact_cover_by_3_sets::ExactCoverBy3Sets; +pub use integer_knapsack::IntegerKnapsack; pub use maximum_set_packing::MaximumSetPacking; pub use minimum_cardinality_key::MinimumCardinalityKey; pub use minimum_hitting_set::MinimumHittingSet; @@ -40,6 +43,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec::NAME, "IntegerKnapsack"); + assert_eq!(::variant(), vec![]); +} + +#[test] +fn test_integer_knapsack_evaluate_optimal() { + let problem = IntegerKnapsack::new(vec![3, 4, 5, 2, 7], vec![4, 5, 7, 3, 9], 15); + // c=(0,0,1,5,0): size=0+0+5+10+0=15, value=0+0+7+15+0=22 + assert_eq!(problem.evaluate(&[0, 0, 1, 5, 0]), Max(Some(22))); +} + +#[test] +fn test_integer_knapsack_evaluate_feasible() { + let problem = IntegerKnapsack::new(vec![3, 4, 5, 2, 7], vec![4, 5, 7, 3, 9], 15); + // c=(1,0,0,6,0): size=3+0+0+12+0=15, value=4+0+0+18+0=22 + assert_eq!(problem.evaluate(&[1, 0, 0, 6, 0]), Max(Some(22))); +} + +#[test] +fn test_integer_knapsack_evaluate_overweight() { + let problem = IntegerKnapsack::new(vec![3, 4, 5, 2, 7], vec![4, 5, 7, 3, 9], 15); + // c=(5,0,0,1,0): size=15+0+0+2+0=17 > 15 + assert_eq!(problem.evaluate(&[5, 0, 0, 1, 0]), Max(None)); +} + +#[test] +fn test_integer_knapsack_evaluate_empty() { + let problem = IntegerKnapsack::new(vec![3, 4, 5, 2, 7], vec![4, 5, 7, 3, 9], 15); + assert_eq!(problem.evaluate(&[0, 0, 0, 0, 0]), Max(Some(0))); +} + +#[test] +fn test_integer_knapsack_evaluate_wrong_config_length() { + let problem = IntegerKnapsack::new(vec![3, 4], vec![4, 5], 10); + assert_eq!(problem.evaluate(&[1]), Max(None)); + assert_eq!(problem.evaluate(&[1, 0, 0]), Max(None)); +} + +#[test] +fn test_integer_knapsack_evaluate_out_of_domain() { + let problem = IntegerKnapsack::new(vec![3, 4], vec![4, 5], 10); + // dims = [4, 3], so config [4, 0] is out of domain for item 0 + assert_eq!(problem.evaluate(&[4, 0]), Max(None)); +} + +#[test] +fn test_integer_knapsack_empty_instance() { + let problem = IntegerKnapsack::new(vec![], vec![], 10); + assert_eq!(problem.num_items(), 0); + assert_eq!(problem.dims(), Vec::::new()); + assert_eq!(problem.evaluate(&[]), Max(Some(0))); +} + +#[test] +fn test_integer_knapsack_brute_force() { + let problem = IntegerKnapsack::new(vec![3, 4, 5, 2, 7], vec![4, 5, 7, 3, 9], 15); + let solver = BruteForce::new(); + let solution = solver + .find_witness(&problem) + .expect("should find a solution"); + let metric = problem.evaluate(&solution); + assert_eq!(metric, Max(Some(22))); +} + +#[test] +fn test_integer_knapsack_serialization() { + let problem = IntegerKnapsack::new(vec![3, 4, 5, 2, 7], vec![4, 5, 7, 3, 9], 15); + let json = serde_json::to_value(&problem).unwrap(); + let restored: IntegerKnapsack = serde_json::from_value(json).unwrap(); + assert_eq!(restored.sizes(), problem.sizes()); + assert_eq!(restored.values(), problem.values()); + assert_eq!(restored.capacity(), problem.capacity()); +} + +#[test] +fn test_integer_knapsack_zero_capacity() { + let problem = IntegerKnapsack::new(vec![1, 2], vec![10, 20], 0); + assert_eq!(problem.dims(), vec![1, 1]); // floor(0/1)+1=1, floor(0/2)+1=1 + assert_eq!(problem.evaluate(&[0, 0]), Max(Some(0))); + let solver = BruteForce::new(); + let solution = solver.find_witness(&problem).unwrap(); + assert_eq!(problem.evaluate(&solution), Max(Some(0))); +} + +#[test] +fn test_integer_knapsack_single_item() { + // Single item size=3, value=5, capacity=7 + // Max multiplicity: floor(7/3)=2, dims=[3] + let problem = IntegerKnapsack::new(vec![3], vec![5], 7); + assert_eq!(problem.dims(), vec![3]); + assert_eq!(problem.evaluate(&[0]), Max(Some(0))); + assert_eq!(problem.evaluate(&[1]), Max(Some(5))); + assert_eq!(problem.evaluate(&[2]), Max(Some(10))); + let solver = BruteForce::new(); + let solution = solver.find_witness(&problem).unwrap(); + assert_eq!(problem.evaluate(&solution), Max(Some(10))); +} + +#[test] +fn test_integer_knapsack_multiple_copies_better() { + // Item 0: size=3, value=4 + // Item 1: size=5, value=6 + // Capacity=9 + // 0-1 knapsack best: {0,1} size=8, value=10 + // Integer knapsack best: 3 copies of item 0 → size=9, value=12 + let problem = IntegerKnapsack::new(vec![3, 5], vec![4, 6], 9); + let solver = BruteForce::new(); + let solution = solver.find_witness(&problem).unwrap(); + assert_eq!(problem.evaluate(&solution), Max(Some(12))); +} + +#[test] +#[should_panic(expected = "sizes and values must have the same length")] +fn test_integer_knapsack_mismatched_lengths() { + IntegerKnapsack::new(vec![1, 2], vec![3], 5); +} + +#[test] +#[should_panic(expected = "positive")] +fn test_integer_knapsack_zero_size_panics() { + IntegerKnapsack::new(vec![0, 2], vec![3, 4], 5); +} + +#[test] +#[should_panic(expected = "positive")] +fn test_integer_knapsack_negative_size_panics() { + IntegerKnapsack::new(vec![-1, 2], vec![3, 4], 5); +} + +#[test] +#[should_panic(expected = "positive")] +fn test_integer_knapsack_zero_value_panics() { + IntegerKnapsack::new(vec![1, 2], vec![0, 4], 5); +} + +#[test] +#[should_panic(expected = "nonnegative")] +fn test_integer_knapsack_negative_capacity_panics() { + IntegerKnapsack::new(vec![1, 2], vec![3, 4], -1); +} + +#[test] +fn test_integer_knapsack_deserialization_rejects_invalid_fields() { + let invalid_cases = [ + ( + serde_json::json!({ + "sizes": [0, 2], + "values": [3, 4], + "capacity": 5, + }), + "positive", + ), + ( + serde_json::json!({ + "sizes": [-1, 2], + "values": [3, 4], + "capacity": 5, + }), + "positive", + ), + ( + serde_json::json!({ + "sizes": [1, 2], + "values": [-3, 4], + "capacity": 5, + }), + "positive", + ), + ( + serde_json::json!({ + "sizes": [1, 2], + "values": [3, 4], + "capacity": -1, + }), + "nonnegative", + ), + ( + serde_json::json!({ + "sizes": [1, 2, 3], + "values": [4, 5], + "capacity": 10, + }), + "same length", + ), + ]; + + for (invalid, expected_msg) in invalid_cases { + let error = serde_json::from_value::(invalid).unwrap_err(); + assert!( + error.to_string().contains(expected_msg), + "Expected error containing '{}', got: {}", + expected_msg, + error + ); + } +} + +#[test] +fn test_integer_knapsack_paper_example() { + // From issue #532: 5 items, sizes=[3,4,5,2,7], values=[4,5,7,3,9], B=15 + // Optimal=22 with c=(0,0,1,5,0) or c=(1,0,0,6,0) + let problem = IntegerKnapsack::new(vec![3, 4, 5, 2, 7], vec![4, 5, 7, 3, 9], 15); + + // Verify both optimal solutions + assert_eq!(problem.evaluate(&[0, 0, 1, 5, 0]), Max(Some(22))); + assert_eq!(problem.evaluate(&[1, 0, 0, 6, 0]), Max(Some(22))); + + // Brute force confirms the optimum + let solver = BruteForce::new(); + let solution = solver.find_witness(&problem).unwrap(); + assert_eq!(problem.evaluate(&solution), Max(Some(22))); +}