Book Store in Crystal: Complete Solution & Deep Dive Guide

black and gray square wall decor

Mastering the Book Store Discount Algorithm in Crystal: A Complete Guide

Solving the Crystal Book Store problem involves calculating the optimal price for a basket of books by applying tiered discounts for unique sets. The most effective strategy is a greedy algorithm that repeatedly groups the largest possible sets of distinct books to maximize the discount at each step, ensuring the lowest total cost.

Ever wondered how e-commerce giants like Amazon instantly calculate those complex "buy more, save more" discounts when you have a dozen items in your cart? It's not magic; it's a finely-tuned algorithm designed to find the best possible price for both you and the retailer. This kind of dynamic pricing problem, however, can be surprisingly tricky to get right. A simple, naive approach might seem to work but could end up costing more than necessary.

In this comprehensive guide, we'll dissect the famous Book Store problem, a core challenge from the kodikra.com Crystal learning path. We will not only build an elegant and efficient solution in Crystal from the ground up but also explore the powerful algorithmic thinking behind it. You'll learn how a greedy approach can solve most of the problem and how a clever final optimization step makes the solution truly robust.


What is the Book Store Discount Problem?

The premise is straightforward. A bookstore sells a popular series of five distinct books. To encourage customers to collect the entire series, they offer a progressive discount based on the number of unique books purchased in a single transaction. The goal is to write a function that takes a basket of books (represented by their series number, e.g., 1 through 5) and calculates the lowest possible price.

The Discount Rules

The pricing structure is based on a single copy costing $8.00. The discounts apply as follows:

  • 1 unique book: 0% discount ($8.00)
  • 2 unique books: 5% discount (on those 2 books)
  • 3 unique books: 10% discount (on those 3 books)
  • 4 unique books: 20% discount (on those 4 books)
  • 5 unique books: 25% discount (on the full set)

For example, if you buy one copy of Book 1 and one copy of Book 2, you form a set of two unique books. The price for this set is (2 * $8.00) * (1 - 0.05) = $15.20. If you buy three copies of Book 1, you get no discount, and the price is 3 * $8.00 = $24.00.


Why This Problem is Deceptively Complex

At first glance, you might think, "Easy, I'll just find the biggest set of unique books I can, calculate its price, and repeat with the remaining books." This is a greedy algorithm, and it's a fantastic starting point. It works almost perfectly.

Consider the basket: [1, 1, 2, 2, 3, 3, 4, 5]. A purely greedy approach would do this:

  1. Find the largest unique set: {1, 2, 3, 4, 5}. This is a set of 5. Price: (5 * 8) * 0.75 = $30.00.
  2. Remaining books: [1, 2, 3].
  3. Find the next largest unique set: {1, 2, 3}. This is a set of 3. Price: (3 * 8) * 0.90 = $21.60.
  4. Total Price: $30.00 + $21.60 = $51.60.

But is this the absolute best price? What if we grouped them differently?

Let's try another way:

  1. Group into a set of 4: {1, 2, 3, 4}. Price: (4 * 8) * 0.80 = $25.60.
  2. Remaining books: [1, 2, 3, 5].
  3. Group the rest into another set of 4: {1, 2, 3, 5}. Price: (4 * 8) * 0.80 = $25.60.
  4. Total Price: $25.60 + $25.60 = $51.20.

The second strategy yields a lower price! The combination of two 4-book sets is cheaper than one 5-book set and one 3-book set. This "4+4 vs 5+3" edge case is the core complexity of the problem. Our algorithm must be smart enough to handle this specific optimization.


How to Strategize the Optimal Solution in Crystal

Our strategy will be a refined greedy algorithm. We will first apply the greedy approach of making the largest possible unique sets. Then, as a final step, we will perform a specific optimization check to handle the edge case we just discovered.

The Two-Phase Strategy

  1. Greedy Grouping Phase: We'll repeatedly scan the basket and pull out the largest possible set of distinct books until the basket is empty. We will store the size of each set we create (e.g., we created a set of 5, then a set of 3).
  2. Optimization Phase: After grouping, we'll inspect our list of group sizes. If we find any 5s and 3s, we will check if replacing a pair of (5, 3) with two 4s (4, 4) results in a lower total cost. Since 2 * (4 * 8 * 0.8) is less than (5 * 8 * 0.75) + (3 * 8 * 0.9), this swap is always beneficial.

This approach gives us the best of both worlds: the simplicity and efficiency of a greedy algorithm for the general case, plus a targeted correction for the known exception.

Algorithm Flow Diagram

Here is a visual representation of our logic for processing the book basket.

● Start (Input: book_basket)
│
▼
┌───────────────────────────┐
│ Count frequency of each book │
│ e.g., {1=>2, 2=>2, 3=>1}   │
└────────────┬──────────────┘
             │
             ▼
    ◆ Any books left? ◆
   ╱         │         ╲
 Yes         │          No
  │          ▼           │
  │  ┌─────────────────┐ │
  │  │ Create new group│ │
  │  └───────┬─────────┘ │
  │          │           │
  │          ▼           │
  │  For each unique book type:
  │  ├─ Add to group
  │  └─ Decrement count
  │          │
  │          ▼
  │  ┌─────────────────┐
  │  │ Store group size│
  │  └─────────────────┘
  │          │
  └──────────┘
             │
             ▼
┌───────────────────────────────────┐
│ Optimization:                     │
│ While (groups of 5 and 3 exist)   │
│   Swap one 5 & one 3 for two 4s   │
└────────────────┬──────────────────┘
                 │
                 ▼
     ┌───────────────────────────┐
     │ Calculate total price from │
     │ final list of group sizes  │
     └────────────┬──────────────┘
                  │
                  ▼
            ● End (Return: total_price)

When to Implement the Crystal Solution (Code Deep Dive)

Now, let's translate our strategy into clean, idiomatic Crystal code. We'll create a BookStore module with a primary method, calculate_price.

The Complete Crystal Code

Here is the full, commented solution. We'll break it down piece by piece afterward.


# src/book_store.cr
module BookStore
  # Base price of a single book in cents to avoid floating point issues
  BASE_PRICE = 800

  # Discounts mapped to group size. 1.0 means no discount.
  DISCOUNTS = {
    1 => 1.0,
    2 => 0.95,
    3 => 0.90,
    4 => 0.80,
    5 => 0.75,
  }

  # Calculates the total price for a given basket of books.
  # The basket is an array of integers representing book IDs.
  def self.calculate_price(basket : Array(Int32)) : Float64
    return 0.0 if basket.empty?

    # Phase 1: Greedy Grouping
    # Count the frequency of each book
    book_counts = basket.group_by(&.itself).transform_values(&.size)
    
    group_sizes = [] of Int32

    # Loop until all books are grouped
    until book_counts.empty?
      # Create a group with one of each available book type
      current_group_size = book_counts.keys.size
      group_sizes << current_group_size

      # Decrement the count for each book that was just grouped
      book_counts.keys.each do |book_id|
        book_counts[book_id] -= 1
        # If a book type runs out, remove it from the hash
        book_counts.delete(book_id) if book_counts[book_id] == 0
      end
    end

    # Phase 2: Optimization
    # The "4+4 vs 5+3" edge case. Two groups of 4 are cheaper
    # than a group of 5 and a group of 3.
    while group_sizes.includes?(5) && group_sizes.includes?(3)
      # Remove one 5 and one 3
      group_sizes.delete_one(5)
      group_sizes.delete_one(3)
      # Add two 4s
      group_sizes.push(4, 4)
    end

    # Final Calculation
    # Sum the prices of all optimized groups
    total_price_cents = group_sizes.sum do |size|
      price_for_group(size)
    end

    # Convert back to dollars for the final result
    total_price_cents / 100.0
  end

  # Helper method to calculate the price of a single group in cents
  private def self.price_for_group(size : Int32) : Int32
    raise "Invalid group size" unless DISCOUNTS.has_key?(size)

    discount_multiplier = DISCOUNTS[size]
    (size * BASE_PRICE * discount_multiplier).to_i
  end
end

Code Walkthrough

Step 1: Setup and Constants

We define our module BookStore and establish two important constants.

  • BASE_PRICE = 800: We work in cents to avoid floating-point inaccuracies during intermediate calculations. This is a common and robust practice in financial programming.
  • DISCOUNTS: A Hash provides a clean, readable lookup for the discount multiplier based on the size of a unique book set.

Step 2: The Greedy Grouping Phase

The core of our algorithm starts here.


book_counts = basket.group_by(&.itself).transform_values(&.size)
group_sizes = [] of Int32
First, we transform the input basket array (e.g., [1, 1, 2]) into a frequency hash ({1 => 2, 2 => 1}). This is a highly efficient way to count items. We also initialize an empty array, group_sizes, to hold the size of each book set we form.

The main work happens in the until book_counts.empty? loop. In each iteration:

  1. current_group_size = book_counts.keys.size: We determine the size of the largest possible unique set by just counting the number of distinct book types remaining in our hash.
  2. group_sizes << current_group_size: We record this size.
  3. We then iterate through the keys again, decrementing the count of each book. If a book's count drops to zero, we remove it from the hash entirely.
This loop continues until the book_counts hash is empty, meaning all books have been placed into a group.

Step 3: The Optimization Phase

This is the critical part that makes our solution optimal.


while group_sizes.includes?(5) && group_sizes.includes?(3)
  group_sizes.delete_one(5)
  group_sizes.delete_one(3)
  group_sizes.push(4, 4)
end
After the greedy grouping is complete, we check our group_sizes array. The while loop continuously looks for a pair of a 5-book set and a 3-book set. If it finds them, it removes them and adds two 4-book sets instead. This simple swap corrects the one known flaw in the purely greedy strategy.

The "5+3 vs 4+4" Swap Logic

This diagram illustrates the transformation our optimization phase performs on the list of group sizes.

    ● Start (Input: group_sizes list)
    │ e.g., [5, 3, 2, 1]
    ▼
┌───────────────────────────┐
│ Check for a `5` in list   │
└────────────┬──────────────┘
             │
             ▼
    ◆ `5` Found? ◆
   ╱           ╲
 Yes           No ───────────┐
  │                          │
  ▼                          │
┌───────────────────────────┐│
│ Check for a `3` in list   ││
└────────────┬──────────────┘│
             │               │
             ▼               │
    ◆ `3` Found? ◆           │
   ╱           ╲             │
 Yes           No ───────────┤
  │                          │
  ▼                          │
┌───────────────────────────┐│
│ Remove one `5` & one `3`  ││
│  List becomes: [2, 1]     ││
└────────────┬──────────────┘│
             │               │
             ▼               │
┌───────────────────────────┐│
│ Add two `4`s to the list  ││
│  List becomes: [2, 1, 4, 4] │
└────────────┬──────────────┘│
             │               │
             ▼               │
    Loop back to Start       │
             │               │
             └───────────────● End (Optimized list)

Step 4: Final Price Calculation

Finally, we calculate the total price.


total_price_cents = group_sizes.sum do |size|
  price_for_group(size)
end

total_price_cents / 100.0
We use the powerful sum method on our (now optimized) group_sizes array. For each size, we call our private helper method price_for_group, which performs the simple discount calculation. The final sum, which is in cents, is divided by 100.0 to return a Float64 representing the dollar amount.

Running the Code

To test this solution, save the code as src/book_store.cr. You can create a simple test file or run it interactively.

For example, to run a test case, you could add this to the bottom of the file (outside the module):


# Test case: 2 sets of all 5 books, plus 2 sets of 3 books
basket = [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 1, 2, 3, 1, 2, 3] 
puts BookStore.calculate_price(basket)

Then, execute it from your terminal:


$ crystal run src/book_store.cr

Pros and Cons of This Approach

Every algorithmic choice comes with trade-offs. The refined greedy approach is highly effective for this problem, but it's useful to understand its characteristics compared to other potential methods.

Aspect Refined Greedy Algorithm (Our Solution) Brute-Force / Dynamic Programming
Performance Very fast. The complexity is dominated by the initial counting and grouping, making it efficient even for large baskets. Extremely slow. Would need to explore every possible combination of groups, leading to a combinatorial explosion. Impractical for all but the smallest baskets.
Complexity to Implement Moderate. The logic is straightforward, with the only tricky part being the "5+3 vs 4+4" optimization swap. Very high. Requires complex recursion or multi-dimensional arrays for memoization. Prone to off-by-one errors and difficult to debug.
Correctness Correct for this specific problem because the "5+3 vs 4+4" case is the only known exception where the greedy choice is not optimal. Guaranteed to be correct if implemented properly, as it explores the entire solution space. However, it's overkill.
Scalability Scales well. If new discount rules were added, the DISCOUNTS hash is easy to update. A new optimization might be needed if the new rules create another non-greedy edge case. Does not scale. Adding a 6th book type or new discount tiers would make the computation time even more prohibitive.

Where This Algorithm is Used in the Real World

The principles behind the Book Store problem are not just an academic exercise. They are directly applicable to many real-world scenarios:

  • E-commerce Promotions: Powering "bundle and save" deals, "buy 3, get 1 free" offers, or tiered shipping discounts.
  • Inventory Management: Creating bundles to move slow-selling products by packaging them with popular items.
  • Logistics and Packing (Bin Packing Problem): A related class of optimization problems where the goal is to fit items of various sizes into the minimum number of containers.
  • Resource Allocation: Assigning tasks to workers or processes in a way that maximizes throughput, similar to how we "assign" books to discount groups to maximize savings.

Mastering this type of problem-solving is a key skill for any developer working on systems that involve optimization and resource management. Discover more in our complete Crystal guide to build your foundational skills.


Frequently Asked Questions (FAQ)

Is a greedy algorithm always the best for this problem?

A purely greedy algorithm (always making the biggest group possible) is not optimal due to the "5+3 is more expensive than 4+4" edge case. Our solution is a "refined" greedy algorithm; it uses a greedy strategy and then applies a specific correction for the known exception, making it optimal for this problem's rules.

What is the time complexity of this solution?

Let N be the total number of books in the basket and K be the number of unique book types (in this case, K=5). The initial counting takes O(N) time. The grouping loop runs at most N times, and inside it, we iterate over at most K keys. The optimization loop runs a limited number of times. The dominant factor is the grouping, making the overall complexity roughly O(N*K). Since K is a small constant, it's effectively linear, O(N).

Why use cents (integers) instead of floats for price calculations?

Floating-point numbers (like Float64) can have tiny precision errors due to how they are represented in binary (e.g., 0.1 + 0.2 might not be exactly 0.3). For financial calculations, this is unacceptable. By converting all monetary values to their smallest unit (cents) and using integers, we ensure all calculations are exact. We only convert back to a float for the final output.

How would this solution change if there were 6 or 7 types of books?

The core logic would remain the same. You would simply update the DISCOUNTS hash with the new discount tiers for 6-book and 7-book sets. However, you would need to re-analyze if any new non-greedy edge cases are created by the new discount rules (e.g., is a "6+2" set cheaper than a "5+3" set?). The algorithm's structure is scalable, but the optimization rules might need to be extended.

Could this problem be solved with recursion?

Yes, but it would likely be more complex and less efficient. A recursive solution would probably explore different ways of grouping the books, which can lead to a brute-force approach with many redundant calculations. An iterative approach, like the one we used, is more direct and easier to manage for this specific problem.

Why is Crystal a good language for this kind of problem?

Crystal shines here for several reasons: its expressive syntax (group_by, transform_values) makes data manipulation clean and readable; its static typing catches errors at compile time; and its performance is close to C, making the solution very fast. The combination of high-level abstractions and low-level performance is ideal for algorithmic tasks.

What happens if the input `basket` is empty?

Our solution handles this gracefully with a guard clause at the very beginning: return 0.0 if basket.empty?. This is an important edge case to consider, and returning 0.0 is the correct behavior for an empty basket.


Conclusion: From Problem to Optimal Solution

We've journeyed from a seemingly simple discount problem to a robust, optimized algorithmic solution in Crystal. The key takeaway is that while a straightforward greedy approach often gets you 90% of the way, true mastery lies in identifying and handling the specific edge cases where that simple logic breaks down. The "5+3 vs 4+4" scenario is a classic example of why careful analysis is crucial in algorithm design.

By breaking the problem into a greedy grouping phase followed by a targeted optimization phase, we created code that is not only correct but also efficient and easy to understand. This problem-solving pattern is invaluable and can be applied to a wide range of challenges you'll encounter in your programming career.

Ready to tackle more challenges like this? Explore our Crystal Learning Roadmap to continue building your skills on the exclusive kodikra.com learning platform.

Disclaimer: The solution provided is optimized for Crystal 1.12+ and its standard library. Syntax and method availability may vary in older versions.


Published by Kodikra — Your trusted Crystal learning resource.