I beefed an interview with AirBNB last week when my interviewee asked me to solve the NP-Complete problem described in this XKCD comic.
I got in my head almost immediately and got lost trying to design a recursive solution - instead attempting to iterate over the possibilities and then sub-iterate over a smaller set while keeping track of price.
Clear of the interview by a few days (and with a rejection letter in the inbox 😎) I wanted to try the problem again - starting from the very basic question: what are all the possible combinations of a N lengthed array of i length?
In the simplest form - how many 3-letter combinations can be produced from ['a', 'b', 'c']?
def possibilities(options, steps, prefix=nil)
  combos = []
  options.each do |option|
    option = prefix + option if prefix
    if steps > 1
      combos.concat(possibilities(options, steps - 1, option))
    else
      combos << option
    end
  end
  combos
end
p possibilities(['a', 'b', 'c'], 3)which outputs:
["aaa", "aab", "aac", "abb", "abc", "acc", "bbb", "bbc", "bcc", "ccc"]
Then I rewrote the possibilities method to concatenate arrays - not strings:
def possibilities(options, steps, prefix=nil)
  combos = []
  options.each do |option|
    option = [prefix, option].flatten if prefix
    if steps > 1
      combos.concat(possibilities(options, steps - 1, option))
    else
      combos << option
    end
  end
  combos
end
p possibilities(['a', 'b', 'c'], 3)leading to:
[["a", "a", "a"], ["a", "a", "b"], ["a", "a", "c"], ["a", "b", "a"], ["a", "b", "b"], ["a", "b", "c"], ["a", "c", "a"], ["a", "c", "b"], ["a", "c", "c"], ["b", "a", "a"], ["b", "a", "b"], ["b", "a", "c"], ["b", "b", "a"], ["b", "b", "b"], ["b", "b", "c"], ["b", "c", "a"], ["b", "c", "b"], ["b", "c", "c"], ["c", "a", "a"], ["c", "a", "b"], ["c", "a", "c"], ["c", "b", "a"], ["c", "b", "b"], ["c", "b", "c"], ["c", "c", "a"], ["c", "c", "b"], ["c", "c", "c"]]
Before moving back to the actual XKCD problem - there are definitely some inefficiencies in this approach, see how the above ouput has both ["a", "c", "c"] and ["c", "c", "a"]. One way to improve the iteration would be to pass in the growing finished combos array into the recursive steps and check whether that particular combination already exists in the array - and to skip it.
Ruby enumerable's inject method is great for this - as it accepts a an initial argument. Rewriting my method to use inject it looked like this:
def possibilities(options, steps, progress=[], prefix=nil)
  options.inject(progress) do |progress, option|
    option = [prefix, option].flatten if prefix
    if steps > 1
      progress = possibilities(options, steps - 1, progress, option)
    else
      progress << option
    end
    progress
  end
endThe two big changes: the parent's array is replaced (not concatenated) with the recursive functions results - and both functions now accept an argument for the progress (defaulting to an empty array if not set).
I could then proceed to improving the function by removing duplicates by standardizing the format of the sub-elements (by sorting) and then only adding new elements:
def possibilities(options, steps, progress=[], prefix=nil)
  options.inject(progress) do |progress, option|
    option = [prefix, option].flatten.sort if prefix
    if steps > 1
      progress = possibilities(options, steps - 1, progress, option)
    else
      progress << option unless progress.index option
    end
    progress
  end
endWhich produces the much more concise:
[["a", "a", "a"], ["a", "a", "b"], ["a", "a", "c"], ["a", "b", "b"], ["a", "b", "c"], ["a", "c", "c"], ["b", "b", "b"], ["b", "b", "c"], ["b", "c", "c"], ["c", "c", "c"]]
This can then be partially applied to the XKCD's problem set:
options = {
  mixed_fruit: 215,
  french_fries: 275,
  side_salad: 335,
  hot_wings: 355,
  mozzarella_sticks: 420,
  sampler_plate: 580
}
def possibilities(options, steps, progress=[], prefix=nil)
  options.inject(progress) do |progress, option|
    option = [prefix, option].flatten.sort if prefix
    if steps > 1
      progress = possibilities(options, steps - 1, progress, option)
    else
      progress << option unless progress.index option
    end
    progress
  end
end
p possibilities(options.keys, 7).countWill generate any N possible combinations for the given menu. And I can guess there'll never be more than seven items in an order - because the smallest order (mixed_fruit is $2.15 - so at most there are seven mixed_fruit in an order).
In fact we can simplify this to:
budget = 1505
least_priced = options.values.sort.first
combos = possibilities(options.keys, budget / least_priced)
p combos.select do |combo|
  combo.inject(0) { |sum, item| options[item] + sum } == budget
endBut you'll notice this still takes a lot of time and intuitively I know that I'm still generating possibilities like [:sampler_plate, :sampler_plate, :sampler_plate, :sampler_plate, :sampler_plate, :sampler_plate, :sampler_plate] which costs $40.60 - so I'm wasting time on combinations that violate the conditions of the problem. To improve on the efficiency you can move the price calculation into the recursive possibilities function (instead of using step).
you might enjoy this, my solution to the same problem, also presented as a code challenge in an interview
https://github.com/stephancom/xkcd-287