ExamplesBy LevelBy TopicLearning Paths
1167 Advanced

0/1 Knapsack Solved with Memoized Recursion

Functional Programming

Tutorial

The Problem

The 0/1 knapsack problem asks: given a set of items each with a weight and value, and a knapsack of fixed capacity, which items maximize total value without exceeding the weight limit? Each item is either taken (1) or left (0) — no fractional amounts. The naive recursive solution is exponential (2ⁿ subsets), but memoization reduces it to O(n × W) by caching overlapping subproblems — a canonical example of dynamic programming via top-down recursion.

🎯 Learning Outcomes

  • • Understand why the knapsack problem has optimal substructure and overlapping subproblems
  • • Learn how memoization transforms exponential recursion into polynomial time
  • • See how Rust's HashMap serves as a memo table for recursive DP
  • • Understand the difference between top-down (memoized recursion) and bottom-up (tabulation) DP
  • Code Example

    pub fn knapsack(items: &[Item], capacity: usize) -> u64 {
        let mut memo: HashMap<(usize, usize), u64> = HashMap::new();
        solve(items, 0, capacity, &mut memo)
    }
    
    fn solve(items: &[Item], i: usize, cap: usize,
             memo: &mut HashMap<(usize, usize), u64>) -> u64 {
        if i >= items.len() || cap == 0 { return 0; }
        if let Some(&cached) = memo.get(&(i, cap)) { return cached; }
        let item = items[i];
        let without = solve(items, i + 1, cap, memo);
        let with_item = if item.weight <= cap {
            item.value + solve(items, i + 1, cap - item.weight, memo)
        } else { 0 };
        let best = without.max(with_item);
        memo.insert((i, cap), best);
        best
    }

    Key Differences

  • Mutable state threading: Rust requires &mut HashMap as an explicit parameter; OCaml closures capture mutable Hashtbl by reference implicitly.
  • Recursion style: Both use let rec / recursive functions, but Rust lacks TCO, so deep recursion on very large inputs risks stack overflow.
  • Cache key: Both use (item_index, capacity) tuples; Rust's HashMap requires Hash + Eq on keys — tuples derive both automatically.
  • Overflow safety: Rust uses u64 with explicit bounds; OCaml uses arbitrary-precision integers via Zarith for large instances.
  • OCaml Approach

    OCaml uses a Hashtbl for memoization, typically wrapped in a let memo = Hashtbl.create 16 at the top of the function. The recursive function is defined with let rec solve i w = match Hashtbl.find_opt memo (i, w) with Some v -> v | None -> .... OCaml's closures naturally capture the memo table without threading it as a parameter, making the code more concise.

    Full Source

    #![allow(dead_code)]
    //! 0/1 Knapsack Solved with Memoized Recursion
    //! See example.ml for OCaml reference
    //!
    //! Top-down dynamic programming: the exponential recursive solution is made polynomial
    //! by caching results in a HashMap keyed on (item_index, remaining_capacity).
    
    use std::collections::HashMap;
    
    /// An item with a weight and value.
    #[derive(Debug, Clone, Copy)]
    pub struct Item {
        pub weight: usize,
        pub value: u64,
    }
    
    /// Idiomatic Rust: memoized recursive knapsack.
    /// `items` is the item list, `capacity` is the knapsack limit.
    /// Returns the maximum value achievable without exceeding `capacity`.
    pub fn knapsack(items: &[Item], capacity: usize) -> u64 {
        let mut memo: HashMap<(usize, usize), u64> = HashMap::new();
        solve(items, 0, capacity, &mut memo)
    }
    
    /// Internal recursive helper.
    /// `i` = current item index, `cap` = remaining capacity.
    fn solve(items: &[Item], i: usize, cap: usize, memo: &mut HashMap<(usize, usize), u64>) -> u64 {
        // Base case: no items left or no capacity remaining.
        if i >= items.len() || cap == 0 {
            return 0;
        }
        // Check memo table before recursing.
        if let Some(&cached) = memo.get(&(i, cap)) {
            return cached;
        }
        let item = items[i];
        // Option 1: skip this item.
        let without = solve(items, i + 1, cap, memo);
        // Option 2: take this item (only if it fits).
        let with_item = if item.weight <= cap {
            item.value + solve(items, i + 1, cap - item.weight, memo)
        } else {
            0
        };
        let best = without.max(with_item);
        memo.insert((i, cap), best);
        best
    }
    
    /// Functional/recursive: mirrors the OCaml solution exactly.
    /// Uses a closure-captured `Hashtbl` analogue — here `&mut HashMap` threaded explicitly.
    pub fn knapsack_from_pairs(items: &[(usize, u64)], capacity: usize) -> u64 {
        let items: Vec<Item> = items
            .iter()
            .map(|&(w, v)| Item {
                weight: w,
                value: v,
            })
            .collect();
        knapsack(&items, capacity)
    }
    
    /// Bottom-up tabulation: builds the DP table iteratively.
    /// Same asymptotic complexity as memoized recursion but avoids recursion overhead.
    pub fn knapsack_tabulation(items: &[Item], capacity: usize) -> u64 {
        let n = items.len();
        // dp[i][w] = best value using items[0..i] with capacity w.
        let mut dp = vec![vec![0u64; capacity + 1]; n + 1];
        for i in 1..=n {
            let item = items[i - 1];
            for w in 0..=capacity {
                // Skip this item.
                dp[i][w] = dp[i - 1][w];
                // Take this item if it fits.
                if item.weight <= w {
                    let take = item.value + dp[i - 1][w - item.weight];
                    if take > dp[i][w] {
                        dp[i][w] = take;
                    }
                }
            }
        }
        dp[n][capacity]
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        fn sample_items() -> Vec<Item> {
            vec![
                Item {
                    weight: 2,
                    value: 3,
                },
                Item {
                    weight: 3,
                    value: 4,
                },
                Item {
                    weight: 4,
                    value: 5,
                },
                Item {
                    weight: 5,
                    value: 6,
                },
            ]
        }
    
        #[test]
        fn test_knapsack_empty_items() {
            assert_eq!(knapsack(&[], 10), 0);
        }
    
        #[test]
        fn test_knapsack_zero_capacity() {
            let items = sample_items();
            assert_eq!(knapsack(&items, 0), 0);
        }
    
        #[test]
        fn test_knapsack_single_item_fits() {
            let items = vec![Item {
                weight: 3,
                value: 10,
            }];
            assert_eq!(knapsack(&items, 5), 10);
        }
    
        #[test]
        fn test_knapsack_single_item_too_heavy() {
            let items = vec![Item {
                weight: 10,
                value: 100,
            }];
            assert_eq!(knapsack(&items, 5), 0);
        }
    
        #[test]
        fn test_knapsack_multiple_items() {
            // Items: (2,3), (3,4), (4,5), (5,6); capacity 8
            // Optimal: take (2,3) + (3,4) + ... let's check: (2+3+4=9>8), (2+3=5, val=7), (3+4=7, val=9), (2+4=6, val=8), (3+5=8, val=10)
            assert_eq!(knapsack(&sample_items(), 8), 10);
        }
    
        #[test]
        fn test_knapsack_memoized_matches_tabulation() {
            let items = sample_items();
            for cap in 0..=15 {
                assert_eq!(
                    knapsack(&items, cap),
                    knapsack_tabulation(&items, cap),
                    "mismatch at capacity {}",
                    cap
                );
            }
        }
    
        #[test]
        fn test_knapsack_from_pairs() {
            assert_eq!(
                knapsack_from_pairs(&[(2, 3), (3, 4), (4, 5), (5, 6)], 8),
                10
            );
        }
    
        #[test]
        fn test_knapsack_all_items_fit() {
            let items = vec![
                Item {
                    weight: 1,
                    value: 1,
                },
                Item {
                    weight: 1,
                    value: 2,
                },
                Item {
                    weight: 1,
                    value: 3,
                },
            ];
            // All fit; take all for value 6.
            assert_eq!(knapsack(&items, 10), 6);
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        fn sample_items() -> Vec<Item> {
            vec![
                Item {
                    weight: 2,
                    value: 3,
                },
                Item {
                    weight: 3,
                    value: 4,
                },
                Item {
                    weight: 4,
                    value: 5,
                },
                Item {
                    weight: 5,
                    value: 6,
                },
            ]
        }
    
        #[test]
        fn test_knapsack_empty_items() {
            assert_eq!(knapsack(&[], 10), 0);
        }
    
        #[test]
        fn test_knapsack_zero_capacity() {
            let items = sample_items();
            assert_eq!(knapsack(&items, 0), 0);
        }
    
        #[test]
        fn test_knapsack_single_item_fits() {
            let items = vec![Item {
                weight: 3,
                value: 10,
            }];
            assert_eq!(knapsack(&items, 5), 10);
        }
    
        #[test]
        fn test_knapsack_single_item_too_heavy() {
            let items = vec![Item {
                weight: 10,
                value: 100,
            }];
            assert_eq!(knapsack(&items, 5), 0);
        }
    
        #[test]
        fn test_knapsack_multiple_items() {
            // Items: (2,3), (3,4), (4,5), (5,6); capacity 8
            // Optimal: take (2,3) + (3,4) + ... let's check: (2+3+4=9>8), (2+3=5, val=7), (3+4=7, val=9), (2+4=6, val=8), (3+5=8, val=10)
            assert_eq!(knapsack(&sample_items(), 8), 10);
        }
    
        #[test]
        fn test_knapsack_memoized_matches_tabulation() {
            let items = sample_items();
            for cap in 0..=15 {
                assert_eq!(
                    knapsack(&items, cap),
                    knapsack_tabulation(&items, cap),
                    "mismatch at capacity {}",
                    cap
                );
            }
        }
    
        #[test]
        fn test_knapsack_from_pairs() {
            assert_eq!(
                knapsack_from_pairs(&[(2, 3), (3, 4), (4, 5), (5, 6)], 8),
                10
            );
        }
    
        #[test]
        fn test_knapsack_all_items_fit() {
            let items = vec![
                Item {
                    weight: 1,
                    value: 1,
                },
                Item {
                    weight: 1,
                    value: 2,
                },
                Item {
                    weight: 1,
                    value: 3,
                },
            ];
            // All fit; take all for value 6.
            assert_eq!(knapsack(&items, 10), 6);
        }
    }

    Deep Comparison

    OCaml vs Rust: 0/1 Knapsack — Memoized Recursion

    Side-by-Side Code

    OCaml

    let knapsack items capacity =
      let n = List.length items in
      let items = Array.of_list items in
      let cache = Hashtbl.create 256 in
      let rec solve i cap =
        if i >= n || cap <= 0 then 0
        else match Hashtbl.find_opt cache (i, cap) with
        | Some v -> v
        | None ->
          let (w, v) = items.(i) in
          let without = solve (i+1) cap in
          let with_item = if w <= cap then v + solve (i+1) (cap - w) else 0 in
          let best = max without with_item in
          Hashtbl.add cache (i, cap) best;
          best
      in solve 0 capacity
    

    Rust (idiomatic — memoized recursion)

    pub fn knapsack(items: &[Item], capacity: usize) -> u64 {
        let mut memo: HashMap<(usize, usize), u64> = HashMap::new();
        solve(items, 0, capacity, &mut memo)
    }
    
    fn solve(items: &[Item], i: usize, cap: usize,
             memo: &mut HashMap<(usize, usize), u64>) -> u64 {
        if i >= items.len() || cap == 0 { return 0; }
        if let Some(&cached) = memo.get(&(i, cap)) { return cached; }
        let item = items[i];
        let without = solve(items, i + 1, cap, memo);
        let with_item = if item.weight <= cap {
            item.value + solve(items, i + 1, cap - item.weight, memo)
        } else { 0 };
        let best = without.max(with_item);
        memo.insert((i, cap), best);
        best
    }
    

    Rust (bottom-up tabulation)

    pub fn knapsack_tabulation(items: &[Item], capacity: usize) -> u64 {
        let n = items.len();
        let mut dp = vec![vec![0u64; capacity + 1]; n + 1];
        for i in 1..=n {
            let item = items[i - 1];
            for w in 0..=capacity {
                dp[i][w] = dp[i - 1][w];
                if item.weight <= w {
                    let take = item.value + dp[i - 1][w - item.weight];
                    if take > dp[i][w] { dp[i][w] = take; }
                }
            }
        }
        dp[n][capacity]
    }
    

    Type Signatures

    ConceptOCamlRust
    Memo tableHashtbl.t (captured by closure)HashMap<(usize, usize), u64> (passed as &mut)
    Item type(int * int) tuplestruct Item { weight: usize, value: u64 }
    Cache lookupHashtbl.find_opt cache (i, cap)memo.get(&(i, cap))
    Cache insertHashtbl.add cache (i, cap) bestmemo.insert((i, cap), best)
    Result typeintu64

    Key Insights

  • Mutable state threading: Rust requires the memo table to be passed as &mut HashMap through every recursive call, making data flow explicit. OCaml closures capture Hashtbl by reference implicitly — less boilerplate but implicit mutation.
  • Cache key: Both use (item_index, capacity) as the cache key. Rust's HashMap requires Hash + Eq on keys; (usize, usize) derives both automatically.
  • Closure vs. function: OCaml's let rec solve i cap = ... is a closure capturing cache, items, and n from the outer scope. Rust uses a standalone function with explicit parameters — the borrow checker ensures memo is not aliased unsafely.
  • Top-down vs. bottom-up: Memoized recursion (top-down) only computes subproblems actually reached; bottom-up tabulation fills the entire DP table. The memoized version can be faster when many subproblems are unreachable.
  • Stack depth: Both approaches risk stack overflow for very deep recursion (large n and capacity). The tabulation version avoids recursion entirely.
  • When to Use Each Style

    Use memoized recursion when: the recursion structure maps naturally to the problem, many subproblems are unreachable, or you want a direct correspondence with the mathematical definition. Use bottom-up tabulation when: all subproblems will be needed, you want predictable O(n × W) time and space with no recursion overhead, or stack depth is a concern.

    Exercises

  • Implement the base memoized solution and verify it produces the same answer as the brute-force exponential version on small inputs.
  • Add item reconstruction: trace back through the memo table to identify which items were selected.
  • Compare performance against bottom-up tabulation (fill a 2D array row by row) on a 100-item, capacity-1000 instance.
  • Open Source Repos