LearnRuby.com

Ruby Training and Ruby on Rails Training exclusively

Preferable Pairs

This is a solution to Ruby Quiz #165. The goal of the quiz is to pair up individuals based on their preferences trying to achieve the best possible pairings.

Here is the solution:


# This is a solution to RubyQuiz #165 named Preferable Pairs.  the
# problem description can be found at:
#
#    http://splatbang.com/rubyquiz/quiz.rhtml?id=165_Preferable_Pairs
#
# This solution uses a genetic algorithm to try to find the best
# possible set of pairings (called a partnership scheme).  It's not
# guaranteed to find the best, however.  A population of partnership
# schemes is created, and a new generation is created by mutating a
# random selection of the current population.  The previous population
# plus the new generation are combined, and the best of those plus a
# random selection of the remaining create the next population.  The
# fitness of a given scheme is the score, where lower scores equate to
# greater fitness.
#
# The latest version of this solution can be found at:
#
#     http://LearnRuby.com/code.html


require 'enumerator'


class Integer
  # produces a string with a character every so many digits from the
  # right; by default it's a comma every three digits
  def to_separated_s(separater = ",", every = 3)
    digits = to_s.split(//)
    cursor = every + 1
    while cursor <= digits.size
      digits.insert(-cursor, ",")
      cursor += every + 1
    end
    digits.join('')
  end
end


class Person
  attr_reader :name, :preferences

  def initialize(name, preferences)
    @name, @preferences = name, preferences
  end

  def score(other)
    @scores[other]
  end

  # transforms preference list form names to Person instances and adds
  # a scores hash to quickly go from partner to score (to avoid linear
  # search); this can only be done once all Persons are created
  def transform
    preferences.map! { |name| Person.lookup(name) }

    @scores = {}
    preferences.each_with_index do |other, index|
      @scores[other] = index
    end
  end

  class << self
    def people
      @people.values
    end
    
    def count
      @people.size
    end
    
    def lookup(name)
      @people[name]
    end

    def load(input)
      @people = {}
      while line = gets
        person = new($1, $2.split) if line.match /(.*): (.*)/
        @people[person.name] = person
      end

      # now transform everyone
      @people.values.each do |person|
        person.transform
      end
    end

    protected :new
  end
end


# Represents a pair of Persons, or possibly one Person and nil if
# there were originally an odd number of Persons.
class Partnership
  attr_reader :people

  def initialize(person1, person2)
    @people = [person1, person2]
  end

  def first
    @people.first
  end

  def second
    @people.last
  end

  # the score for a parternship of one person is always 0, otherwise
  # it's the sum of the squares of the indices of each Person in the
  # Partnership
  def score
    @score ||=
      first && second ?
        first.score(second) ** 2 + second.score(first) ** 2 :
        0
  end

  # true if only one person is in the Partnership
  def solo?
    !(first && second)
  end

  def to_s
    if first && second : "#{first.name} #{second.name}"
    elsif first : first.name
    else second.name
    end
  end
end


# A ParternshipScheme is a set of Partnerships where each Person
# appears in only one Partnership.
class ParternshipScheme
  attr_reader :partnerships

  def initialize(partnerships)
    raise problem unless partnerships.all? { |p| p.is_a? Partnership }
    @partnerships = partnerships
  end

  def score
    @score ||= @partnerships.inject(0) { |sum, p| sum + p.score }
  end

  # Picks two of the Parternships at random, swaps one person from
  # each, and generates a new scheme.  It's important that when we mix
  # up the two Partnerships that one person moves from the first
  # position to the second and another vice versa.  If people are
  # frozen in the first or second, then it limits the potential
  # Partnerships and ParternshipSchemes available.
  def mutate
    new_partnerships = @partnerships.dup
    partnership1 =
      new_partnerships.delete_at(rand(new_partnerships.size))
    partnership2 =
      new_partnerships.delete_at(rand(new_partnerships.size))
    new_partnerships <<
      Partnership.new(partnership1.first, partnership2.first)
    new_partnerships <<
      Partnership.new(partnership1.second, partnership2.second)
    ParternshipScheme.new(new_partnerships)
  end

  def to_s
    @partnerships.sort_by { |p|
      p.solo?.hash }.map { |partnership|
      partnership.to_s }.join("\n")
  end

  class << self
    def randomized(people)
      partnerships = []
      randomized = people.sort_by { rand }
      randomized.each_slice(2) do |p1, p2|
        partnerships << Partnership.new(p1, p2)
      end
      new(partnerships)
    end

    # given a fixed number of people returns how many pair
    # parternships are possible; one formula for this value is the
    # product of all odd values from 1..people.
    def possible_count(people)
      # people.factorial / (2 ** (people / 2)) / (people / 2).factorial
      (1..people).select { |p| p & 1 == 1 }.inject { |product, v|
        product * v }
    end
  end
end


if $0 == __FILE__
  case ARGV.size
  when 0 : Person.load(STDIN)
  when 1 : open(ARGV[0]) { |f| Person.load(f) }
  else
    STDERR.puts "Usage: #{$0} [data-file]"
    STDERR.puts "  stdin used if no data-file provided"
    exit 1
  end

  possible_scheme_count =
    ParternshipScheme.possible_count(Person.count)

  puts "Calculating for #{Person.count} people."
  puts "The number of possible partnership combinations is:\n    " +
    "#{possible_scheme_count.to_separated_s}."

  ## Determine the basic parameters for the genetic algorithm.

  # there is no great insight behind this formula other than that when
  # there were 20 people to be paired up, 400 seemed to work well for
  # both population_size and generations, so I used a logorithmic
  # scale to make it come out that way
  population_size =
    ((Math.log(possible_scheme_count) / Math.log(2))**1.8).to_i

  generations = population_size
  offspring = population_size / 2
  keep_best = population_size / 2
  keep_random = population_size + offspring - keep_best

  puts "Population: #{population_size} ; Generations: #{generations}"

  ## Create the initial population.
  
  population = []
  population_size.times do
    population << ParternshipScheme.randomized(Person.people)
  end

  ## Run through the generations.
  
  generations.times do |generation|
    # add offspring to population
    offspring.times do
      population << population[rand(population_size)].mutate
    end

    # sort new population by fitness
    population = population.sort_by { |scheme| scheme.score }

    # the next generation's pop. consists of keep_best best scoring
    # plus a random selection of the remaining non-best in the
    # population
    best = population[0, keep_best]
    random =
      population[keep_best..-1].sort_by { rand }[0, keep_random]
    population = best + random
  end

  ## Display the best results.

  puts "Score of best partnership combination found: " +
    "#{population.first.score.to_separated_s}."
  puts population.first
end