Knapsack in Common-lisp: Complete Solution & Deep Dive Guide

a close up of a computer screen with code on it

The Complete Guide to Solving the Knapsack Problem in Common Lisp

The Knapsack problem in Common Lisp involves finding the most valuable combination of items that fit into a container with a weight limit. This classic optimization challenge is often solved using dynamic programming or combinatorial generation to efficiently explore all valid combinations and maximize total value without exceeding capacity.

Imagine you're standing at the base of a colossal mountain, a collection of valuable items laid out before you. You have a sturdy knapsack, but its capacity is finite. Every item has a weight and a value. Your challenge? To fill your knapsack with the perfect combination of items that yields the absolute maximum value without breaking the weight limit. This isn't just a mountain climber's dilemma; it's a core problem in computer science, and solving it elegantly requires a powerful tool. Enter Common Lisp.

Many developers feel the pressure of solving complex algorithmic puzzles, especially when dealing with constraints and optimization. The fear of writing inefficient, brute-force code that grinds to a halt is real. This guide promises to demystify the famous Knapsack problem, showing you how to craft a clever, functional, and efficient solution using the unique strengths of Common Lisp. By the end, you'll not only solve the problem but also gain a deeper appreciation for Lisp's expressive power.


What is the 0/1 Knapsack Problem?

The scenario described above is a classic formulation of what is known as the 0/1 Knapsack Problem. The "0/1" part is crucial: for any given item, you have only two choices—either you take the whole item (1) or you leave it behind (0). You cannot take a fraction of an item.

Let's formalize the components of this puzzle, which comes from the exclusive curriculum at kodikra.com:

  • A Knapsack with a Maximum Capacity (W): This is a single integer representing the maximum total weight the knapsack can hold.
  • A Set of Items (n): A collection of distinct items that can be chosen.
  • Weight and Value for Each Item: Every item i has a specific weight (wi) and a specific value (vi). Both are positive numbers.

The goal is to select a subset of these items such that the sum of their values is maximized, and the sum of their weights does not exceed the knapsack's capacity W. It's a constrained optimization problem that serves as a foundation for many real-world applications.

In our kodikra module, the items are represented as a list of property lists (plists) or association lists (alists), a common data structure in Lisp. For example:


;; A list of items, where each item is an alist
(
  '((:weight . 5) (:value . 10))
  '((:weight . 4) (:value . 40))
  '((:weight . 6) (:value . 30))
)

Here, the challenge is to write a function that takes this list and a maximum weight, then returns the highest possible total value you can achieve.


Why is the Knapsack Problem So Important?

While the story of a mountain porter is a great way to visualize the problem, the Knapsack algorithm's utility extends far beyond physical backpacks. It's a fundamental pattern for any problem involving resource allocation with constraints. Understanding it unlocks the ability to solve a wide range of practical challenges.

Consider these real-world scenarios:

  • Financial Portfolio Management: Imagine you have a fixed amount of capital (the knapsack's capacity) to invest. You have a list of potential assets (stocks, bonds), each with a cost (its weight) and an expected return (its value). The Knapsack algorithm can help you select the portfolio of assets that maximizes your expected return without exceeding your investment capital.
  • Cloud Resource Allocation: A cloud provider needs to load virtual machines (items) onto a physical server (the knapsack). Each VM requires a certain amount of CPU/RAM (weight), and running it generates a certain amount of revenue (value). The goal is to maximize revenue by selecting the best mix of VMs for a server without exceeding its resource limits.
  • Cargo Loading: A shipping company needs to load a truck or a container (the knapsack) with various packages (items). Each package has a weight and a commercial value. The objective is to maximize the total value of the cargo without overloading the vehicle.
  • Project Selection: A company has a limited budget and a limited number of employee hours (capacity) for the next quarter. There are numerous potential projects they could undertake (items), each with a cost and a projected profit (value). The Knapsack pattern helps decide which projects to fund to maximize overall profit.

Mastering this algorithm provides a powerful mental model for breaking down and solving complex optimization tasks. For those diving deep into Lisp, it's an excellent opportunity to explore functional programming paradigms, recursion, and data manipulation, which are all covered in our complete Common Lisp guide.


When to Use This Approach: Pros and Cons

The solution presented in this guide uses a combinatorial generation approach. It iteratively builds up all possible valid combinations of items that fit within the weight limit. This is a valid and often intuitive way to solve the problem, but it's essential to understand its trade-offs compared to other methods, like a pure dynamic programming approach with a memoization table.

The Combinatorial Generation Strategy

The core idea is to start with an empty set (a knapsack containing nothing, with a weight of 0 and value of 0). Then, for each item in our list, we create a new set of combinations by adding this item to all previously found valid combinations. After iterating through all items, we simply scan our final list of all possible valid knapsacks and pick the one with the highest value.

Here’s a breakdown of the pros and cons of this specific method:

Aspect Pros (Advantages) Cons (Disadvantages)
Conceptual Simplicity The logic is quite intuitive: start with nothing, and for each item, decide whether to add it to your existing valid combinations to form new ones. It can be harder to grasp its performance characteristics without a deep analysis. The memory usage isn't immediately obvious.
Memory Usage For problems with a small number of items, it can be very efficient as it doesn't pre-allocate a large table. The number of valid combinations can grow exponentially (up to 2n). Storing all of them can consume a significant amount of memory, potentially leading to performance issues or crashes for large n.
Implementation in Lisp This approach maps beautifully to functional constructs like mapcar and reduce, leading to concise and elegant Lisp code. An imperative-style programmer might find the functional approach less familiar than a traditional loop-and-array-based DP solution.
Performance Performs well when the number of items (n) is small, regardless of the knapsack's capacity (W). The time complexity is roughly O(n * 2n) in the worst case, making it unsuitable for problems with a large number of items. A standard DP solution is O(n * W), which is better when W is small.

Verdict: The combinatorial generation method is an excellent, Lisp-idiomatic solution for scenarios where the number of items is the primary constraint (e.g., fewer than 20-25 items). If you are dealing with a huge knapsack capacity but a small, fixed set of items, this approach shines. However, for problems with hundreds of items and a relatively small capacity, a traditional dynamic programming table-based solution would be more memory and time-efficient.


How to Solve the Knapsack Problem in Common Lisp: A Deep Dive

Now, let's dissect the elegant Common Lisp solution provided in the kodikra learning path. We will explore the logic, the functions, and the Lisp-specific features that make it work so well.

High-Level Algorithm Flow

Before we jump into the code, let's visualize the overall strategy. The algorithm processes one item at a time, continuously expanding a list of all possible valid knapsack combinations found so far.

    ● Start
    │
    ▼
  ┌───────────────────────────┐
  │ Initialize `combos` with  │
  │ one empty combo: (0 . 0)  │
  │ (weight . value)          │
  └────────────┬──────────────┘
               │
               ▼
  ┌───────────────────────────┐
  │ Loop through each `item`  │
  │ in the input list         │
  └────────────┬──────────────┘
               │
               ▼
  ┌───────────────────────────┐
  │ For the current `item`,   │
  │ generate `new_combos` by  │
  │ adding it to all existing │
  │ `combos`.                 │
  └────────────┬──────────────┘
               │
               ▼
  ┌───────────────────────────┐
  │ Merge `combos` and        │
  │ `new_combos` into a single│
  │ list.                     │
  └────────────┬──────────────┘
               │
               ▼
  ┌───────────────────────────┐
  │ Filter out any combo whose│
  │ weight > `max_weight`.    │
  └────────────┬──────────────┘
               │
   ◀───────────┘ (next item)
               │
               ▼
  ┌───────────────────────────┐
  │ After loop, find the max  │
  │ value among all remaining │
  │ valid `combos`.           │
  └────────────┬──────────────┘
               │
               ▼
    ● End (Return Max Value)

The Complete Code Solution

Here is the full source code from the module. We will break it down function by function.


(defpackage :knapsack
  (:use :cl)
  (:export :maximum-value))

(in-package :knapsack)

(defun add-cells (&rest cells)
  "Adds two or more cons cells representing (weight . value) pairs."
  (cons (reduce #'+ cells :key #'car)
        (reduce #'+ cells :key #'cdr)))

(defun combine-item (item combo-row)
  "Generates new combinations by adding a new item to an existing row of combos."
  (let ((new-combos (mapcar (lambda (cell) (add-cells item cell)) combo-row)))
    (append combo-row new-combos)))

(defun maximum-value (maximum-weight items)
  "Calculates the maximum value of items that fit in the knapsack."
  (let ((compact-items (loop for item in items
                             collect (cons (cdr (assoc :weight item))
                                           (cdr (assoc :value item))))))
    (let ((all-combos
            (reduce (lambda (combos item)
                      (remove-if (lambda (combo) (> (car combo) maximum-weight))
                                 (combine-item item combos)))
                    compact-items
                    :initial-value '((0 . 0)))))
      (if all-combos
          (apply #'max (mapcar #'cdr all-combos))
          0))))

Code Walkthrough: Step-by-Step Analysis

1. The `maximum-value` Function (The Main Entry Point)

This is the primary function that orchestrates the entire process. It takes the maximum-weight and the list of items as input.


(defun maximum-value (maximum-weight items)
  ...)

Inside, it first uses a let block to preprocess the items list. The original format '((:weight . 5) (:value . 10)) is a bit verbose for computation. The code transforms it into a more direct list of cons cells: '((5 . 10) (4 . 40) ...). This makes subsequent calculations much cleaner.


(let ((compact-items (loop for item in items
                             collect (cons (cdr (assoc :weight item))
                                           (cdr (assoc :value item))))))
  ...)
  • loop for item in items: This is a standard Common Lisp loop that iterates through each item in the input list.
  • assoc :weight item: Finds the (:weight . w) pair in the current item's alist.
  • cdr (assoc ...): Extracts the actual weight value w.
  • (cons ... ...): Creates a new cons cell (weight . value).
  • collect ...: Gathers all these new cons cells into the compact-items list.

The core logic resides in the second, nested let block, which calculates all possible combinations using reduce.


(let ((all-combos
        (reduce (lambda (combos item)
                  (remove-if (lambda (combo) (> (car combo) maximum-weight))
                             (combine-item item combos)))
                compact-items
                :initial-value '((0 . 0)))))
  ...)
  • reduce: This is a powerful higher-order function. It applies a function cumulatively to the items of a sequence, reducing the sequence to a single value. Here, it's used to build up our list of combinations.
  • :initial-value '((0 . 0)): This is crucial. We tell reduce to start with an accumulator (named combos in our lambda) that contains a single element: a knapsack with zero weight and zero value. This represents the "empty knapsack" choice.
  • (lambda (combos item) ...): This anonymous function is the heart of our algorithm. It's called for each item in compact-items. The combos argument holds the result from the previous iteration.
  • (combine-item item combos): This function (which we'll analyze next) takes the current list of valid combinations and the new item, and returns a new, larger list containing both the old combinations and the new ones formed by adding the item.
  • remove-if ...: After generating new combinations, we immediately filter out any that are invalid (i.e., their weight exceeds maximum-weight). This pruning step is vital for preventing the list of combinations from growing with useless, overweight entries.

Finally, after reduce has processed all items, all-combos holds every possible valid combination. The last part of the function finds the maximum value among them.


(if all-combos
    (apply #'max (mapcar #'cdr all-combos))
    0)
  • (if all-combos ...): A safety check. If no items could be added (or the initial list was empty), we might have an empty list of combos.
  • (mapcar #'cdr all-combos): Extracts just the value (the cdr) from each (weight . value) pair, creating a list of values like (10 40 50 ...).
  • (apply #'max ...): Applies the max function to this list of values to find the single largest one.
  • If all-combos is empty, it returns 0.

2. The `combine-item` Function (The Combination Generator)

This function is responsible for the core combinatorial step. It takes one item and the current list of valid combinations (combo-row) and generates the next generation of combinations.

    Input
    ├─ item: (5 . 10)
    └─ combo-row: ((0 . 0) (4 . 40))
         │
         ▼
  ┌───────────────────────────┐
  │ mapcar over combo-row...  │
  │ λ(cell) → add-cells(item, cell) │
  └────────────┬──────────────┘
               │
               ├─ add-cells((5 . 10), (0 . 0))  ⟶ (5 . 10)
               └─ add-cells((5 . 10), (4 . 40)) ⟶ (9 . 50)
               │
               ▼
    new-combos: ((5 . 10) (9 . 50))
         │
         ▼
  ┌───────────────────────────┐
  │ append(combo-row, new-combos) │
  └────────────┬──────────────┘
               │
               ▼
    Output: ((0 . 0) (4 . 40) (5 . 10) (9 . 50))

(defun combine-item (item combo-row)
  "Generates new combinations by adding a new item to an existing row of combos."
  (let ((new-combos (mapcar (lambda (cell) (add-cells item cell)) combo-row)))
    (append combo-row new-combos)))
  • mapcar: This applies a function to each element of a list. Here, it iterates through every existing combination in combo-row.
  • (lambda (cell) (add-cells item cell)): For each existing combo (cell), it calls add-cells to create a new combo by adding the current item's weight and value.
  • The result, new-combos, is a list containing only the newly created combinations.
  • (append combo-row new-combos): The function returns a single list containing both the original combinations (representing the choice of *not* taking the item) and the new combinations (representing the choice of *taking* the item).

3. The `add-cells` Function (A Simple Utility)

This is a helper function that simply adds the weights and values of two or more (weight . value) pairs together.


(defun add-cells (&rest cells)
  "Adds two or more cons cells representing (weight . value) pairs."
  (cons (reduce #'+ cells :key #'car)
        (reduce #'+ cells :key #'cdr)))
  • &rest cells: This allows the function to take any number of arguments, which are collected into a list called cells.
  • (reduce #'+ cells :key #'car): This sums up all the weights. :key #'car tells reduce to only look at the first part (the weight) of each cons cell in the cells list.
  • (reduce #'+ cells :key #'cdr): Similarly, this sums up all the values by looking at the second part of each cell.
  • (cons ... ...): It constructs a new cons cell with the resulting total weight and total value.

Running the Code

You can run this code in any Common Lisp environment, such as Steel Bank Common Lisp (SBCL). Save the code as knapsack.lisp and load it into your REPL (Read-Eval-Print Loop).

In your terminal:


$ sbcl
* (load "knapsack.lisp")
T
* (in-package :knapsack)
#<PACKAGE "KNAPSACK">
* (maximum-value 10 '((:weight . 5) (:value . 10) (:weight . 4) (:value . 40) (:weight . 6) (:value . 30) (:weight . 4) (:value . 50)))
90

The result is 90, which corresponds to taking the two items with weight 4 (total weight 8) and values 40 and 50 (total value 90). This combination fits within the maximum weight of 10 and provides the highest possible value.


Frequently Asked Questions (FAQ)

1. What is the time complexity of this Common Lisp Knapsack solution?
The time complexity is dominated by the growth of the `combos` list. In the worst case, the number of combinations can double with each new item. Therefore, the complexity is approximately O(2n), where `n` is the number of items. The filtering step inside `reduce` adds work, but the exponential growth is the main factor. This makes it efficient for small `n` but impractical for large `n`.
2. Is this a 0/1 Knapsack or a different variant?
This is a classic 0/1 Knapsack solution. The logic of `combine-item`, which either keeps the old combinations (leaving the item) or adds new ones with the item (taking the item), perfectly models the 0/1 choice. The algorithm does not allow for taking an item multiple times (Unbounded Knapsack) or taking fractions of an item (Fractional Knapsack).
3. Can this Common Lisp code handle fractional items?
No, this implementation cannot. The Fractional Knapsack problem is actually a much simpler problem that can be solved with a greedy algorithm. You would calculate the value-to-weight ratio for each item, sort them by this ratio, and fill the knapsack with the best-ratio items first, taking a fraction of the last item if needed. This code is specifically for the harder 0/1 version.
4. Why does the code use `(weight . value)` cons cells instead of a list or struct?
Using a cons cell `(car . cdr)` is a highly idiomatic and efficient way to represent a pair of related values in Lisp. Accessing the weight via `car` and the value via `cdr` is extremely fast. While a `struct` or `class` could provide more readable accessors (e.g., `item-weight`), for this tight computational loop, the raw performance and simplicity of a cons cell is often preferred.
5. How does this approach compare to a recursive solution with memoization?
A recursive solution with memoization (a top-down dynamic programming approach) typically solves the problem by defining a function `knapsack(index, capacity)`. It would explore taking or not taking the item at `index` and cache the results in a hash table or array to avoid re-computation. The time complexity of that approach is O(n * W), which is better than O(2n) when the capacity `W` is smaller than 2n. However, this iterative, bottom-up generation approach can be more memory-efficient if the number of *valid* combinations is small.
6. What are the common pitfalls when implementing the Knapsack algorithm?
One of the most common pitfalls is an "off-by-one" error in array-based DP solutions. Another is forgetting the base cases in a recursive solution (e.g., what happens when capacity is zero or there are no items left). For the combinatorial approach shown here, the biggest pitfall is not realizing its exponential nature; it works wonderfully for small inputs but can quickly run out of memory or time as the number of items grows.

Conclusion: The Power of Lisp for Algorithmic Problems

The 0/1 Knapsack problem is a cornerstone of algorithm design, teaching valuable lessons about optimization, constraints, and trade-offs. As we've seen, Common Lisp offers a uniquely expressive and powerful toolkit for tackling such challenges. The solution from the kodikra learning path leverages higher-order functions like reduce and mapcar to build a solution that is not only effective but also remarkably concise and functional.

By transforming the problem into a process of iteratively generating and filtering combinations, the code avoids complex loops and manual state management, embodying the functional programming paradigm. While this specific implementation has performance characteristics that make it ideal for item-constrained scenarios, it serves as a brilliant example of thinking "the Lisp way."

Whether you are managing financial assets, loading cargo, or simply honing your programming skills, understanding the Knapsack algorithm is an invaluable asset. By implementing it in a language as powerful as Common Lisp, you not only solve the problem at hand but also expand your ability to think algorithmically and functionally. To continue your journey, explore more challenges in our complete Common Lisp curriculum.

Disclaimer: The code and concepts discussed are based on modern Common Lisp implementations like SBCL 2.4+. While the core logic is standard, specific function performance may vary across different Lisp environments.


Published by Kodikra — Your trusted Common-lisp learning resource.