164 Price Ranges

Description

Quiz description by James Edward Gray II

You have a list of price ranges from different vendors like:

Company A:  $1,000 - $3,000
Company B:  $6,000 - $10,000
Company C:  $500 - $2,500

Given such a list and the desired price range a shopper wishes to pay, return the companies the shopper should examine. For example, if the shopper’s price range was:

Low:   $2,500
High:  $5,000

the companies returned would be:

Company A
Company C

The shopper should also be allowed to provide just a low or just a high value instead of both, should they prefer to do so.

Summary

In this quiz, James presented us with a practical problem that is quite common in everyday life. Sometimes we make such decisions implicitly and “fuzzily”, when we shop at a particular store since another shop “is too expensive.” Other times it is a more explicit search, such as when filtering online listings for products, rentals, or other services.

The quiz boils down to determining which providers’ price ranges overlap that of the consumer’s desired range. To test whether range A and B overlap requires that at least one of the following hold:

A pretty simple task with a good amount of repetitive behavior, which suggests that these behaviors should be consolidated.

We’ll take a look at Chris Shea’s solution: it’s simple, well-documented and easy code to read in use. Chris defines a new class, PriceRange, rather than use the built-in Range class. This seems appropriate, as the class is not intended to be reused in the same manner as Range. Let’s first look at the initializer:

def initialize(opts={})
  @low  = opts[:low] || 0
  @high = opts[:high] || 1.0 / 0.0
end

The use of a hash as the initialization parameter allows Chris to make use of PriceRange like this:

customer = PriceRange.new(:low => 2_500, :high => 5_000)

And in fact, one or both of :low and :high could be left out to create open-ended or empty ranges. Even reordering them is fine, as they are no longer parameters but rather a single hash.

The initializer remembers the low and high values, providing defaults if necessary: zero for the low end of the range, and 1.0 / 0.0 – that is, Infinity, with a capital I. Infinity is a constant of class Float that is greater than all other numbers. So it serves as a valid, and quite convenient, high value for an open-ended range.

To check for basic overlap, Chris defines these methods:

def includes_price?(price)
  price >= @low and price <= @high
end

def includes_edge_of?(other)
  includes_price?(other.low) or includes_price?(other.high)
end

Very straightforward. As mentioned above, determining if two ranges overlap involves two parts, where includes_edge_of? satisfies one of those parts (here, a and b are PriceRange instances):

a.includes_edge_of?(b)

To perform the whole overlap check, then, is simply a matter of also doing the reverse check, and using the logical-or operator to combine:

a.includes_edge_of?(b) or b.includes_edge_of?(a)

And that is exactly what we see in the last method of the PriceRange class, select, but checking over all provided PriceRange arguments:

def select(*ranges)
  ranges.select { |range|
     self.includes_edge_of?(range) or range.includes_edge_of?(self)
  }
end

I like the simplicity of the code as well as the readability (as shown by example in the code’s comments). My only possible complaint is a preference to use a named constant for infinity in the code, though it would seem that inserting (1.0 / 0.0) is the easiest way to get such constant.


Wednesday, February 04, 2009