[QUIZ] Barrel of Monkeys (#30)

R

Ruby Quiz

The three rules of Ruby Quiz:

1. Please do not post any solutions or spoiler discussion for this quiz until
48 hours have passed from the time on this message.

2. Support Ruby Quiz by submitting ideas as often as you can:

http://www.rubyquiz.com/

3. Enjoy!

-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=

by Gavin Kistner

Last week one of the local radio stations was having a "Barrel of Monkey"
afternoon. While a song was playing, listeners would call in and suggest the
next song, which had to begin with the same letter as the current song ended in.

So, for example, a sample (eclectic) Barrel of Monkeys playlist might be:

Peace Train
No More "I Love You's"
Super Trooper
Rock Me, Amadeus
Song of the South
Hooked on a Feeling
Go Tell it on the Mountain
...
(See how each song name begins with the last letter of the song before it?)

Just creating ANY playlist would be too easy, however. We need a Worthy Problem
to solve.

1) Given any starting and ending song, create a playlist that connects the
two songs.

2) For extra credit, try to create a playlist of a specific duration (to
fill a particular time slot on the radio).

3) For more extra credit, try to find the shortest playlist that links the
songs. (Either in terms of number of songs, or total play time.)

You can find an XML file with over 5,000 song names and play time at
http://rubyquiz.com/SongLibrary.xml.gz (100k compressed). The song durations are
in milliseconds.

Finally, because this problem may be enough fun without having to discover
trouble yourself, I offer a few (spoiler?) things to think about below. (Far
below, in case you want to skip them.)

THE END OF THIS QUIZ MENTIONS A FEW PITFALLS THAT I THOUGHT OF WHILE WRITING IT
UP

IT'S UP TO YOU TO DECIDE IF YOU WANT TO READ THEM

I DON'T HAVE ANY DOMAIN KNOWLEDGE IN THIS PROBLEM AREA
AND I HAVEN'T TRIED TO SOLVE THE PROBLEM MYSELF
SO THESE AREN'T SUBTLE NUANCES THAT WILL GREATLY HELP YOU

DO NOT READ ANY FURTHER IF YOU DO NOT WANT TO READ THEM

THERE IS NOTHING MORE OF ANY INTEREST IN THIS QUIZ AFTER THIS TEXT OTHER THAN
THE PITFALLS

SO YOU DON'T NEED TO KEEP READING IF YOU WANT TO THINK ABOUT THE PROBLEM IN A
VIRGIN STATE

I THINK THIS MAY BE ENOUGH OF A WARNING

* What do you do with songs with names like "'74-'75" or "Seventy Times 7"
or "=:0 :("?

* How about a song named "Candy Everybody Wants (unplugged)" or "Voulez-Vous
[Extended Remix, 1979 US Promo]" or "Speed Racer - Hardcore Mix" or
"Breathe Remix Feat Sean Paul"?

* What do you do if there IS no way to connect two songs? (And how do you
know for sure?)
 
D

Dave Burt

1) Given any starting and ending song, create a playlist that connects the
two songs.

2) For extra credit, try to create a playlist of a specific duration (to
fill a particular time slot on the radio).

3) For more extra credit, try to find the shortest playlist that links the
songs. (Either in terms of number of songs, or total play time.)

How about extra credit for finding the longest possible no-repeat playlist?

Cheers,
Dave
 
B

Bill Guindon

You can find an XML file with over 5,000 song names and play time at
http://rubyquiz.com/SongLibrary.xml.gz (100k compressed). The song durations are
in milliseconds.

Any suggestions for an easy to use XML library? Or for that matter,
is it ok to convert it to another format? Personally, I'd prefer CSV
or YAML (hate the extra baggage that comes with XML).
 
G

Gavin Kistner

R

Ryan Leavengood

Gavin said:
Certainly you can move the library to any format you want, or come up
with your own populated playlist format.

I use REXML for XML.

I decided to use REXML as I started working on this quiz, and I must say
it is pretty sweet. It took only a couple lines of code to parse out the
songs from the given file (I develop iteratively, and that was my first
iteration :)

Ryan
 
N

NAKAMURA, Hiroshi

Hi all,

Sorry for OT & advertising.

Bill said:
Any suggestions for an easy to use XML library? Or for that matter,
is it ok to convert it to another format? Personally, I'd prefer CSV
or YAML (hate the extra baggage that comes with XML).

With the latest snapshot of soap4r, you can parse that like this;

0% irb
irb(main):001:0> require 'zlib'
=> true
irb(main):002:0> gz =
Zlib::GzipReader.new(File.open("SongLibrary.xml.gz"))
=> #<Zlib::GzipReader:0x101d5458>
irb(main):003:0> require 'xsd/mapping'
=> true
irb(main):004:0> library = XSD::Mapping.xml2obj(gz.read); nil
=> nil
irb(main):005:0> library.Artist.size
=> 1004
irb(main):006:0> library.Artist.find { |artist| artist.xmlattr_name =
"ABBA" }.Song.collect { |song| song.xmlattr_name }
=> ["Caught Up In You", "Fantasy Girl", "Hold On Loosely", "Second
Chance", "Teacher, Teacher", "Back Where You Belong", "The Sound of Your
Voice"]

xml2obj generates singleton object for each XML element (It means you
can't Marshal.dump it. ).

Regards,
// NaHi
 
B

Bill Guindon

Hi all,

Sorry for OT & advertising.

Bill said:
Any suggestions for an easy to use XML library? Or for that matter,
is it ok to convert it to another format? Personally, I'd prefer CSV
or YAML (hate the extra baggage that comes with XML).

With the latest snapshot of soap4r, you can parse that like this;

0% irb
irb(main):001:0> require 'zlib'
=> true
irb(main):002:0> gz =
Zlib::GzipReader.new(File.open("SongLibrary.xml.gz"))
=> #<Zlib::GzipReader:0x101d5458>
irb(main):003:0> require 'xsd/mapping'
=> true
irb(main):004:0> library = XSD::Mapping.xml2obj(gz.read); nil
=> nil
irb(main):005:0> library.Artist.size
=> 1004
irb(main):006:0> library.Artist.find { |artist| artist.xmlattr_name =
"ABBA" }.Song.collect { |song| song.xmlattr_name }
=> ["Caught Up In You", "Fantasy Girl", "Hold On Loosely", "Second
Chance", "Teacher, Teacher", "Back Where You Belong", "The Sound of Your
Voice"]

xml2obj generates singleton object for each XML element (It means you
can't Marshal.dump it. ).

Regards,
// NaHi

Some very cool stuff. Glad I asked. I'll poke into it, and REXML as
Gavin and Ryan suggested.

Thanks much.
 
E

Ezra Zygmuntowicz

--Apple-Mail-6--835254283
Content-Transfer-Encoding: 7bit
Content-Type: text/plain;
charset=US-ASCII;
delsp=yes;
format=flowed


Hi all,

Sorry for OT & advertising.

Bill said:
Any suggestions for an easy to use XML library? Or for that matter,
is it ok to convert it to another format? Personally, I'd prefer CSV
or YAML (hate the extra baggage that comes with XML).

With the latest snapshot of soap4r, you can parse that like this;

0% irb
irb(main):001:0> require 'zlib'
=> true
irb(main):002:0> gz =
Zlib::GzipReader.new(File.open("SongLibrary.xml.gz"))
=> #<Zlib::GzipReader:0x101d5458>
irb(main):003:0> require 'xsd/mapping'
=> true
irb(main):004:0> library = XSD::Mapping.xml2obj(gz.read); nil
=> nil
irb(main):005:0> library.Artist.size
=> 1004
irb(main):006:0> library.Artist.find { |artist|
artist.xmlattr_name =
"ABBA" }.Song.collect { |song| song.xmlattr_name }
=> ["Caught Up In You", "Fantasy Girl", "Hold On Loosely", "Second
Chance", "Teacher, Teacher", "Back Where You Belong", "The Sound of
Your
Voice"]

xml2obj generates singleton object for each XML element (It means you
can't Marshal.dump it. ).

Regards,
// NaHi

Hey I am t6rying to play with this xml2object method but when I do a
require 'xsd/mapping'
it fails. What library is xsd/mapping ? Where do I get it or is it
supposed top be in the core?

Thanks-
-Ezra


--Apple-Mail-6--835254283--
 
G

Gavin Kistner

Following is my solution to the Barrel of Monkeys quiz. I got the
'extra credit' for short playlists, but didn't have time to try out
the only solution I came up with for the 'specific duration' extra
credit.

(There is a bug exposed by the test case, where (for reasons I
haven't figured out) it doesn't always find the absolutely shortest
playlist.)

I'll discuss some of the lessons I learned when I write up the
Summary on Wednesday.


A sample of the output:

[Sliver:~/Desktop/Barrel of Monkeys] gkistner$ ruby barrel.rb
Stopwatch started at Sun May 01 19:20:21 MDT 2005
+0.1s to load marshalled library from file

Looking for a path between 'Moab' and 'I'm On My Way'
#0 - Jamie Janover :: Moab :: 9:05
#1 - Shamwari :: Babmudiki :: 5:26
#2 - The Proclaimers :: I'm On My Way :: 3:44
+0.1s to create playlist

Looking for a path between 'My Girl' and 'I'm A Rainbow Too'
#0 - The Mamas & The Papas :: My Girl :: 3:30
#1 - Creedence Clearwater Revival :: Lodi :: 3:11
#2 - Bob Marley & Fatboy Slim :: I'm A Rainbow Too :: 8:00
+0.1s to create playlist

Looking for a path between 'Sand in My Shoes' and 'Bug City'
#0 - Dido :: Sand in My Shoes :: 5:00
#1 - Isao Tomita :: Syncopated Clock :: 2:31
#2 - Fats Waller :: Keepin' Out of Mischief Now :: 3:16
#3 - The Offspring :: Why Don't You Get A Job? :: 2:52
#4 - The Presidents of the United States of America :: Bug City :: 3:05
+0.0s to create playlist


What I found surprising is how often (given the supplied library)
there is a single 3-song playlist that connects just about any two
end songs.


There are six files:
barrel.rb -- loads the SongLibrary.xml file and picks two songs at
random and tries to find a playlist.
barrel_of_classes.rb -- all the classes used in the solution
barrel_of_tests.rb -- some limited unit tests (which helped my find
some bugs, yay testing!)
stopwatch.rb -- an unrelated class just for my own trivial timing
tests
arabicnumerals.rb -- (not included here) The solution to quiz number
25 by Eliah Hecht





==================================================
barrel.rb
==================================================
require 'rexml/document'
require 'rexml/xpath'
require 'barrel_of_classes'
require 'stopwatch'

$library_file = 'library.marshal'

Stopwatch.start

if File.exist?( $library_file )
library = Marshal.load( File.new( $library_file, 'r+' ) )
Stopwatch.mark( 'load marshalled library from file')
else
include REXML

xml = Document.new( File.new( 'SongLibrary.xml' ) )
Stopwatch.mark( 'load XML')

song_nodes = XPath.match( xml, '//Song' )
Stopwatch.mark( 'find songs in xml' )

library = SongLibrary.new( song_nodes.inject( [] ) do |lib,
song_node|
lib << Song.new( song_node.attributes['name'],
song_node.attributes['duration'].to_i, song_node.parent.attributes
['name'] )
end )

Stopwatch.mark( 'fill library' )


# Get rid of songs with useless names
library.songs.delete_if{ |song| song.clean_name.length < 2 }
puts "Deleted #{song_nodes.length - library.songs.length} songs"
Stopwatch.mark( 'clean library' )

# Save the library just to save time for future runs.
Marshal.dump( library, File.new( $library_file, 'w+' ) )
Stopwatch.mark( 'save library to file' )
end

all_songs = library.songs

100.times{
start_index = rand( library.songs.length )
end_index = rand( library.songs.length )

start_song = all_songs[ start_index ]
end_song = all_songs[ end_index ]

puts "\nLooking for a path between '#{start_song.name}' and '#
{end_song.name}'"

pl = Playlist::BarrelOfMonkeys.new( library.songs, start_song,
end_song )
puts pl
Stopwatch.mark( 'create playlist' )
}

Stopwatch.stop





==================================================
barrel_of_classes.rb
==================================================
require 'arabicnumerals'

class Song
attr_reader :artist, :name, :duration

# The song name made turned into only [a-z ], with no leading or
trailing spaces
attr_reader :clean_name

# The first and last letters of the song name (after 'cleaning')
attr_reader :first, :last

def initialize( name, duration=0, artist='' )
@artist = artist
@duration = duration
@name = name
@clean_name = name.downcase

# "forever young (dune remix)" => "forever young"
@clean_name.gsub!( /\s*\([^)]*mix[^)]*\)/, '' )

# "voulez-vous [extended remix, 1979 us promo]" => "voulez-
vous"
@clean_name.gsub!( /\s*\[[^\]]*mix[^\]]*\]/, '' )

# "hava nagila (live)" => "hava nagila"
@clean_name.gsub!( /\s*\([^)]*\blive\b[^)]*\)/, '' )

# "everything in its own time [live]" => "everything in
its own time"
@clean_name.gsub!( /\s*\[[^\]]*\blive\b[^\]]*\]/, '' )

# "it's a fine day (radio edit)" => "it's a fine day"
@clean_name.gsub!( /\s*\([^)]*\bedit\b[^)]*\)/, '' )

# "pearl's girl [7" edit]" => "pearl's girl"
@clean_name.gsub!( /\s*\[[^\]]*\bedit\b[^\]]*\]/, '' )

# "can't stop raving - remix" => "can't stop raving -"
@clean_name.gsub!( /\s*remix\s*$/, '' )

# "50,000 watts" => "50000 watts"
@clean_name.gsub!( /,/, '' )

# "50000 watts" => "fifty thousand watts"
@clean_name.gsub!( /\b\d+\b/ ){ |match| match.to_i.to_en }

@clean_name.gsub!( /[^a-z ]/, '' )
@clean_name.strip!

@first = @clean_name[ 0..0 ]
@last = @clean_name [ -1..-1 ]
end

def to_s
self.artist + ' :: ' + self.name + ' :: ' +
self.duration.as_time_from_ms
end
end

class Numeric
# Treat the number as a number of milliseconds and return a
formatted version
# Produces "0:07" for 7124
# Produces "8:17" for 497000
# Produces "1:07:43" for 4063000
# Produces "59:27:44" for 214063999
# (Only handles units of time up to hours)
def as_time_from_ms
minutes = 0
seconds = (self / 1000.0).round
if seconds >= 60
minutes = seconds / 60
seconds %= 60
if minutes >= 60
hours = minutes / 60
minutes %= 60
end
end
seconds = seconds.to_s
seconds = '0' + seconds unless seconds.length > 1

minutes = minutes.to_s
minutes = '0' + minutes unless minutes.length > 1 or not hours
"#{hours}#{hours ? ':' : ''}#{minutes}:#{seconds}"
end
end

class Array
def random
self[ rand( self.length ) ]
end
end

class SongLibrary
attr_accessor :songs
def initialize( array_of_songs = [] )
@songs = array_of_songs
end
end

class Playlist
attr_reader :songs
def initialize( *songs )
@songs = songs
@current_song_number = 0
end

def to_s
out = ''
songs.each_with_index{ |song,i| out << "##{i} - #{song}\n" }
if songs
out
end

class BarrelOfMonkeys < Playlist
# Given an array of Song items and songs to start with and
end with
# produce a playlist where each song begins with the same
letter as
# the previous song ended with
def initialize( songs, start_song, end_song, options={} )

# Create a map to each song, by first letter and then
last letter
@song_links = {}
songs.each do |song|
first_map = @song_links[ song.first ] ||= {}
( first_map[ song.last ] ||= [] ) << song
end

# For speed, pick only one song for each unique
first_last pair
@song_links.each_pair do | first_letter,
songs_by_last_letters |
songs_by_last_letters.each_key do |last_letter|
songs_by_last_letters[ last_letter ] =
songs_by_last_letters[ last_letter ].random
end
end

# Get rid of any songs which start and end with the same
letter
@song_links.each_pair do | first_letter,
songs_by_last_letters |
songs_by_last_letters.delete( first_letter )
end

@songs = shortest_path( start_song, end_song )
unless @songs
warn "There is no path to make a Barrel of Monkeys
playlist between '#{start_song.name}' and '#{end_song.name}' with the
supplied library."
end
end

private
def shortest_path( start_song, end_song,
start_letters_seen='', d=0 )
# Bail out if a shorter solution was already found
return nil if @best_depth and ( @best_depth <= d )

#puts( ( "." * d ) + start_song.name )
path = [ start_song ]

if start_song.last == end_song.first
best_path = [ end_song ]
@best_depth = d
else
best_length = nil

songs_by_last_letters = @song_links
[ start_song.last ]
if songs_by_last_letters
songs_by_last_letters.each_pair do |
last_letter, song|
if start_letters_seen.include?
( song.first ) || ( start_letters_seen.include?( song.last ) &&
( song.last != end_song.first ) )
next
end
start_letters_seen += start_song.first
trial_path = shortest_path( song,
end_song, start_letters_seen, d+1 )
if trial_path && ( !best_length ||
( trial_path.length < best_length ) )
best_path = trial_path
end
end
end
end

if best_path
path << best_path
path.flatten!
else
nil
end

end
end
end





==================================================
barrel_of_tests.rb
==================================================
require 'test/unit'
require 'barrel_of_classes.rb'

class SongTest < Test::Unit::TestCase
def test_cleaning
song_name = 'Hello World'
clean_name = song_name.downcase
s1 = Song.new( song_name )
assert_equal( clean_name, s1.clean_name )

song_name = 'Hello World (remix)'
s1 = Song.new( song_name )
assert_equal( clean_name, s1.clean_name )

song_name = ' Hello World - remix'
s1 = Song.new( song_name )
assert_equal( clean_name, s1.clean_name )

song_name = ' Hello World Remix '
s1 = Song.new( song_name )
assert_equal( clean_name, s1.clean_name )

song_name = "'74 - '75"
s1 = Song.new( song_name )
assert_equal( 's', s1.first )
assert_equal( 'e', s1.last )

song_name = 'As Lovers Go [Ron Fair Remix]'
clean_name = 'as lovers go'
s1 = Song.new( song_name )
assert_equal( clean_name, s1.clean_name )
end
end

class BarrelTest < Test::Unit::TestCase
def setup
@lib = SongLibrary.new

('A'..'H').each{ |x| @lib.songs << Song.new( 'Alpha ' + x ) }
@lib.songs << Song.new( 'Beta F' )
('A'..'I').each{ |x| @lib.songs << Song.new( 'Foo ' + x ) }
@lib.songs << Song.new( 'Icarus X' )
('A'..'H').each{ |x| @lib.songs << Song.new( 'Jim ' + x ) }

@links = { }
@lib.songs.each{ |song|
link = song.first + song.last
@links[ link ] = song
}
end

def test1_valid
af = @links[ 'af' ]
fg = @links[ 'fg' ]
pl = Playlist::BarrelOfMonkeys.new( @lib.songs, af, fg )
desired_playlist = [ af, fg ]
assert_equal( desired_playlist, pl.songs )

ab = @links[ 'ab' ]
bf = @links[ 'bf' ]
fi = @links[ 'fi' ]
pl = Playlist::BarrelOfMonkeys.new( @lib.songs, ab, fi)
desired_playlist = [ ab, bf, fi ]
assert_equal( desired_playlist, pl.songs )

ix = @links[ 'ix' ]
pl = Playlist::BarrelOfMonkeys.new( @lib.songs, ab, ix )
desired_playlist << ix
assert_equal( desired_playlist, pl.songs )

aa = @links[ 'aa' ]
pl = Playlist::BarrelOfMonkeys.new( @lib.songs, aa, ix )
desired_playlist = [ aa, af, fi, ix ]
assert_equal( desired_playlist, pl.songs )
end

def test3_broken
aa = @links[ 'aa' ]
ab = @links[ 'ab' ]
jh = @links[ 'jh' ]
pl = Playlist::BarrelOfMonkeys.new( @lib.songs, aa, jh )
assert_nil( pl.songs )

pl = Playlist::BarrelOfMonkeys.new( @lib.songs, ab, jh )
assert_nil( pl.songs )
end

end






=============================================
stopwatch.rb
=============================================
module Stopwatch
class Lap
attr_reader :name, :time
def initialize( name )
@name = name
@time = Time.new
end
end

def self.start
@laps = [ ]
self.mark :start
end

def self.mark( lap_name )
lap = Lap.new( lap_name )
if @laps.empty?
puts "Stopwatch started at #{lap.time}"
else
last_lap = @laps.last
elapsed = lap.time - last_lap.time
puts "+#{(elapsed*10).round/10.0}s to #{lap_name}" # +
" (since #{last_lap.name})"
end
@laps << lap
end

def self.time( lap_name )
yield
self.mark lap_name
end

def self.stop
now = Time.new
elapsed = now - @laps.first.time
puts "Stopwatch stopped at #{now}; #{(elapsed*10).round/10.0}
s elapsed"
end
end
 
D

Dave Burt

My solution is here:
http://www.dave.burt.id.au/ruby/barrel_of_monkeys.rb
It requires YAMLized song library, such as that available here:
http://www.dave.burt.id.au/ruby/SongLibrary.zip
And it requires my solution to last week's quiz, HighLine::Dave, available
here:
http://www.dave.burt.id.au/ruby/highline/dave.rb

Get the lot here:
http://www.dave.burt.id.au/ruby/barrel_of_monkeys.zip

The core of my solution is a function (BarrelOfMonkeys#playlist) that
constructs playlists from the library matching criteria passed as parameters
to the function, and also the requirement that all songs begin with the last
letter of the previous song.
The criteria this function accepts include first letter, last letter, bounds
and targets for number of songs and duration, and excluded songs.

When run as an application, my barrel_of_monkeys.rb prompts the user for
these parameters, passes them to this function and returns the result.

A weakness in my solution, that I'd remove if I had time, is that the
playlist-constructing search is depth-first, which means that a query "find
the playlist with the least number of songs" takes the same time as "find
the playlist with the most songs".

Now, for the quiz criteria:

1) My solution doesn't take starting and ending songs, it takes first and
last letters. To find a playlist to join "Peace Train" and "Go Tell it on
the Mountain", you would enter first letter => "n", last letter => "g".

Note that my solution ignores dangly bits on the end of a song's name, like
"(Hardcore remix)" or "feat. Albert", when calculating a song's first and
last letters. And a song has to have at least one actual alphabetic letter
to really be considered.

2) You can enter a target duration. You can also set bounds on the duration,
which allows a query like "as close as possible to 5 minutes, but no longer"
(max duration => 300_000, target duration => 300_000)

3) Similarly to above, you can find the shortest playlist by setting the
target duration or target songs to 0 or 1.

An example session is given below, and that's all. Thanks for another quiz.

Cheers,
Dave

Loading...done.
What letter should the first song start with? z
What letter should the last song end with? a
Minimum songs in playlist: [1]
Maximum songs in playlist (more than 3 could take a while): [5115] 3
Target number of songs: [no target] 1
Minimum duration in milliseconds: [0]
Maximum duration in milliseconds: [Infinity]
Target duration in milliseconds: [no target]
Generating playlists...done in 41.063 seconds.
Found 61 playlists:
<Playlist tracks: 2, duration: 0:9:20>
1. Banco de Gaia - Zeus No Like Techno (6:01)
2. Buena Vista Social Club - Orgullecida (3:19)
....
<Playlist tracks: 2, duration: 0:9:08>
1. The Cranberries - Zombie (5:06)
2. The Carpenters - Every Sha La La La (4:02)
Another barrel of monkeys? [yN] y
What letter should the first song start with? z
What letter should the last song end with? a
Minimum songs in playlist: [1] 3
Maximum songs in playlist (more than 3 could take a while): [5115] 3
Target number of songs: [no target]
Minimum duration in milliseconds: [0]
Maximum duration in milliseconds: [Infinity]
Target duration in milliseconds: [no target] 999999999
Generating playlists...done in 47.656 seconds.
Found 1 playlist:
<Playlist tracks: 3, duration: 7:44:06>
1. Cherry Poppin Daddies - Zoot Suit Riot (3:53)
2. Robert A. Heinlein - The Menace from Earth (452:54)
3. Afro Celt Sound System - Hypnotica (7:18)
Another barrel of monkeys? [yN]
 
J

James Edward Gray II

barrel_of_classes.rb -- all the classes used in the solution
barrel_of_tests.rb -- some limited unit tests (which helped my find
some bugs, yay testing!)

These file names are great. :)

James Edward Gray II
 
J

James Edward Gray II

Following is my solution to the Barrel of Monkeys quiz.

Here is my solution. It's not terribly clever. I basically punt on
the whole weird names issue, for example. I ran out of time to even
handle the situation where there is no path. (Haven't found an example
of this yet with the default song list.)

Perhaps the only point of interest here is that I search from both ends
of the list simultaneously. I branch forward from the start song and
backwards from the end song, until the branches meet. I did this to
keep the search space as small as possible, for as long as possible.

It does find the shortest list.

James Edward Gray II

#!/usr/local/bin/ruby -w

require "rexml/document"

# Global song list.
$songs = [ ]

# A simple data class for representing songs.
class Song
# Create an instance of Song from _title_, _artist_, and _duration_.
def initialize( title, artist, duration )
@title, @artist, @duration = title, artist, duration
end

# Readers for song details.
attr_reader :title, :artist, :duration

# This method returns true if this Song's _title_ makes sense in a
"Barrel
# of Monkeys" playlist.
def for_barrel?( )
@title =~ /[A-Za-z0-9]/
end

# Returns the first letter or digit in a Song _title_.
def first( )
@title[/[A-Za-z0-9]/].downcase
end

# Returns the last letter or digit in a Song _title_.
def last( )
@title[/[A-Za-z0-9](?=[^A-Za-z0-9]*$)/].downcase
end

# Convert to user readable display.
def to_s( )
"#@title by #@artist <<#@duration>>"
end

# For camparison. All attributes must match.
def ==( other )
to_s == other.to_s
end
end

# This object represents a node in a "Barrel of Monkeys" playlist tree.
Each
# of these nodes has a Song associated with it, Songs that can be
played next,
# and a previously played Song.
class BarrelOfMonkeysTreeNode
# Create an instance of BarrelOfMonkeysTreeNode. You must pass a
_song_ for
# this node, but _previous_ is optional.
def initialize( song, previous = nil )
@song, @previous = song, previous
@next = [ ]
end

# The song held by this node.
attr_reader :song
# The Song played before this Song.
attr_reader :previous

# Adds _next_song_ to the list of Songs that can be played next for
this
# node. Returns, the new Song's node.
def add_next( next_song )
new_node = self.class.new(next_song, self)
@next << new_node
new_node
end

# For node comparison. Nodes are equal if they hold the same Song.
def ==( other )
@song == other.song
end
end

# This class holds an expandable tree of Songs for a "Barrel of Monkeys"
# playlist. The tree can grow forward or backward, from the starting
Song.
class BarrelOfMonkeysTree
# Create an instance of BarrelOfMonkeysTree. The _song_ parameter
will be
# used as the root of this tree. If _forward_ is set to true, the
tree will
# grow down the playlist from the Song. If not, growth will be towards
# preceeding Songs.
def initialize( song, forward = true )
@root = BarrelOfMonkeysTreeNode.new(song)
@leaves = [@root]
@forward = forward
end

# For internal use only. Allows other trees to compare _leaves_.
attr_reader :leaves
protected :leaves

# This method will fill in the next set of branches for this tree,
expanding
# the search.
def grow( )
new_leaves = [ ]
@leaves.each do |leaf|
if @forward
search = lambda { |song| leaf.song.last == song.first }
else
search = lambda { |song| leaf.song.first == song.last }
end
$songs.find_all(&search).each do |next_song|
new_leaves << leaf.add_next(next_song)
end
end
@leaves = new_leaves
end

# Returns the generated playlist of Songs, in order to be played. The
# playlist will begin with the Song initially used to build the tree
and end
# with the provided _end_node_. Note that this will order will be
backwards
# from how the Songs would play unless _forward_ is +true+ for this
tree.
def playlist( end_node )
current = @leaves.find { |leaf| leaf == end_node }
nodes = [ current ]
while current
previous = current.previous
nodes << previous
current = previous
end

songs = nodes.compact.map { |node| node.song }
if @forward
songs.reverse
else
songs
end
end

# This method returns a true value if the end points of this tree and
the
# provided _other_ tree intersect. That true value is the node of
their
# intersection.
def touch?( other )
common_leaves = [ ]
other.leaves.each do |leaf|
@leaves.include?(leaf) and common_leaves << leaf
end
if common_leaves.size > 0
common_leaves.first
else
false
end
end
end

#:enddoc:
if __FILE__ == $0
# Read song list.
puts
puts "Reading library file..."
xml = File.open("SongLibrary.xml", "r") { |file|
REXML::Document.new(file) }
xml.elements.each("Library/Artist") do |artist|
artist.elements.each("Song") do |song|
$songs << Song.new( song.attributes["name"],
artist.attributes["name"],
song.attributes["duration"] )
$songs.pop unless $songs[-1].for_barrel?
end
end

# Locate start and end songs.
start = $songs.find { |song|
song.title.downcase.index(ARGV[0].downcase) }
finish = $songs.find { |song|
song.title.downcase.index(ARGV[1].downcase) }
raise "Couldn't find #{ARGV[0]} in song list." if start.nil?
raise "Couldn't find #{ARGV[1]} in song list." if finish.nil?
puts
puts "Start song: #{start}"
puts " End song: #{finish}"

# Search for playlist.
puts
puts "Building playlist..."
if start == finish
songs = [start]
elsif start.last == finish.first
songs = [start, finish]
else
start = BarrelOfMonkeysTree.new(start)
finish = BarrelOfMonkeysTree.new(finish, false)

until (join_node = start.touch?(finish))
start.grow
finish.grow
end

songs = start.playlist(join_node)
songs.push(*finish.playlist(join_node)).uniq!
end

# Show playlist.
puts
songs.each_with_index { |song, index| puts "#{index + 1}: #{song}" }
puts
end
 
I

Ilmari Heikkinen

--Apple-Mail-2--609648688
Content-Transfer-Encoding: 7bit
Content-Type: text/plain;
charset=US-ASCII;
format=flowed


Here is my solution.

And here's mine. Quick hack, but seems to work.

Uses Dijkstra's graph search algorithm with a block-customizable edge
cost. No much interface to speak of, just finds the shortest (by
duration) playlist between two random songs. It caches the parsed XML
song list into songlist.cache and then uses that on subsequent runs,
since XML parsing was taking a whole lot of time.

Would need some extra fiddling to find a playlist closest to a given
time by passing the weigher block the current cost and then subtracting
that from the wanted time to get the weight. Or something like that.


--Apple-Mail-2--609648688
Content-Transfer-Encoding: 7bit
Content-Type: application/octet-stream;
x-unix-mode=0644;
name="monkey_barrel.rb"
Content-Disposition: attachment;
filename=monkey_barrel.rb

require 'rexml/document'
require 'zlib'
require 'rand'


class Configurable

def initialize(config = default_config, &optional_config_block)
config = default_config.merge(config)
config.each{|k,v| instance_variable_set("@#{k}", v)}
optional_config_block.call(self) if block_given?
end

def default_config
{}
end

end


class Song < Configurable

attr_reader :name, :artist, :id, :duration

def find_first_alnum(str)
(str.match(/[[:alnum:]]/i) || str[0,1]).to_s
end

def first_letter
@first_letter ||= find_first_alnum(name).downcase
end

def last_letter
@last_letter ||= find_first_alnum(
name.split(/[\(\[]/).first.reverse
).downcase
end

end


class MonkeyBarrel

attr_accessor :songs
attr_reader :song_starts, :song_ends

# Create new MonkeyBarrel that uses the given song list.
def initialize(songs)
self.songs = songs
end

# Sets @songs to the given value and
# generates a hashtable of the songs:
# keyed by the first letter of the song name.
#
def songs=(new_songs)
@songs = new_songs
@song_starts = {}
@songs.each{|song|
fl = song.first_letter
(@song_starts[fl] ||= []) << song
}
end

# Search for the shortest playlist that starts with start
# and ends with goal. If a playlist is found, it is returned in
# an array. If a playlist can't be found, returns nil.
#
# If a weigher block is given, it is used to determine playlist length.
# If there is no weigher block, the length is determined as amount of
# songs.
#
# Implemented as Dijkstra graph search with some pessimizations.
#
def find_playlist(start, goal, &weigher)
weigher = lambda{ 1 } unless block_given?
open = [[weigher[start], :start, start]]
closed = {}
found = loop do
len, prev, curr = *open.shift
break false unless curr # open heap empty
oid = curr.object_id
next if closed[oid]
closed[oid] = [len, prev]
break curr if curr == goal # this is after closed-list insert
# so that goal goes to closed too
unless closed[curr.last_letter] and
closed[curr.last_letter] <= len
children = (@song_starts[curr.last_letter] || []).
map{|c| [len + weigher[c], curr, c]}
if block_given?
heap_insert(open, children)
else
children.shuffle!
open.push(*children)
end
closed[curr.last_letter] = len
end
end
if found
compose_path(goal, closed)
else
nil
end
end

private
# Min "heap" insert :p
def heap_insert(open, inserts)
inserts.sort!{|a,b| a[0] <=> b[0]}
(0...open.size).each{|i|
open[i,0] = [inserts.shift] while not inserts.empty? and
open[0] > inserts.first[0]
break if inserts.empty?
}
open.push *inserts
end

def compose_path(goal, closed)
curr = goal
path = [curr]
loop do
arr = closed[curr.object_id]
curr = arr[1]
break if curr == :start
path << curr
end
path.reverse
end

end



# Load song list and find a random playlist.

song_list = nil
unless File.exists?("songlist.cache")
puts "Reading song list from SongLibrary.xml.gz"
song_list = []
doc = REXML::Document.new(
Zlib::GzipReader.open("SongLibrary.xml.gz"){|f| f.read})
doc.each_element('Library/Artist'){|a|
artist = a.attributes['name']
a.each_element('Song') do |e|
song_list << Song.new(
:artist => artist,
:name => e.attributes['name'].to_s,
:id => e.attributes['id'].to_s.to_i,
:duration => e.attributes['duration'].to_s.to_i
)
end
}
puts "Song list read."
puts "Caching song list to songlist.cache."
File.open("songlist.cache",'wb'){|f| f.write Marshal.dump(song_list)}
else
puts "Loading song list from songlist.cache"
song_list = Marshal.load(File.read("songlist.cache"))
end

mb = MonkeyBarrel.new(song_list)
s1 = song_list.pick
s2 = song_list.pick

puts
puts %Q(Trying to find playlist from "#{s1.name}" to "#{s2.name}")

playlist = mb.find_playlist(s1, s2){|s| s.duration}

if playlist
puts "", "Found playlist:"
song_names = playlist.map{|s| s.name}
longest_song = song_names.max{|a,b| a.size <=> b.size}.size
puts playlist.map{|s|
raw_seconds = s.duration / 1000
minutes = raw_seconds / 60
seconds = raw_seconds % 60
timestr = "(#{minutes}:#{seconds.to_s.rjust(2,"0")})"
"#{s.name.ljust(longest_song)}\t #{timestr.ljust(7)} by #{s.artist}"
}
else
puts "No playlist found."
end

--Apple-Mail-2--609648688--
 
G

Gavin Kistner

Before I begin my analysis of others solutions, let me share two
things I learned, in the hopes that it will help someone else:

1) I procrastinate. (I didn't learn this - I already knew it.) I
discovered this manifesting itself in my code as I repeatedly put off
the hard part of actually creating the playlist, and ended up
refactoring my classes about 6 times, and spending a full half hour
on just cleaning up the names of the songs. I learned the importance
of doing a 'red thread' spike - lay down the core functionality
first, get it working, and then make it pretty later.

If you run out of time on a project, it's arguably better to have
SOMETHING working, than a really nice solution that's only half
implemented, and doesn't do anything.

2) Premature Optimization is the Root of All Evil[1] - I had plans to
implement my solution in the most brain-dead simple way possible, and
only then (if necessary) optimize it. Somewhere along the way I
wandered off the path, and ended up using 'ugly' hashes of hashes for
speed sake and a single recursive function. In the end, I did need to
do a bit of optimization to get it to run reasonably (see below). But
even before that, the damage to my code was severe enough that when I
wanted to try out a variation on the algorithm, my own code was ugly
enough to deter me from messing with it. Even after my optimization,
however, the algorithm was pretty brain-dead stupid, and STILL
finished in a tenth of a second.

Moral - don't kill yourself on optimization until you're sure it's
necessary.



So, on to the code analysis. Every solution needed to do four things
to succeed:
1) Decide the parameters for the playlist to create (user input).
2) Load in the information for a song library.
3) Find a valid "Barrel of Monkeys" playlist based on the library and
the playlist parameters.
4) Display the resulting solution.

Let's look at the solutions for each of these, spending the most time
on the #3


==========================
Decide the Playlist Parameters
==========================
Ilmari, Pedro and I all picked songs at random from the library to
make a path between. None of our solutions had any customization in
terms of types of playlist to create. So much for the value of the
Highline quiz ... it seems that UI is still an afterthought when it
comes to solving a problem :)

Brian's solution does support different kinds of playlists (any path,
shortest path, and best time fill) but he didn't have time to create
a user UI, so his solution hard codes a few start and end points and
then solves them.

James' solution assumed that you would supply two command-line
arguments, the names of the first and last songs. I hadn't know about
the Enumerable#find method, so it was nice to see it used here
(finding the first song which had the supplied substring somewhere in
the song title). Beyond that, his solution had no further parameters
needed for a playlist.

Dave didn't let you pick the specific songs to start and end with
(you pick a letter for the first song should start with and a letter
for the last song to end with), but otherwise went all out on the
options - his solution supports finding playlists with minimum,
maximum, and target number of songs and/or playlist duration. The
console input provides defaults while entering, making things
particularly convenient. So, apparently the Highline quiz was
valuable after all :)


==========================
Load in the Song Library
==========================
The provided song library was an XML file. There were three
approaches to handling this, with very different speed results:

Gavin, James, Ilmari and Brian all read the XML file using REXML, and
then used it's access methods to create instances of a Song class.
(Ilmari actually used zlib to load the gzipped version directly, with
roughly no performance hit. Nice!)

Time to load the XML file into an REXML Document: ~4s
Time to scrape the information from that into Song instances:
Gavin: 28s (I did a lot of name cleaning during song
initialization)
James: 12s
Ilmari: 4.5s
Brian: 2.6s

Dave converted the library into YAML (how, I'm not sure) and loaded
that directly.
Time to load the YAML file of Songs: 1.5s

Pedro went hardcore and simply used regular expressions on each line
of the XML file.
Time to read the file and use regexp to find information: 0.2s

Gavin and Ilmari both dumped their libraries to a binary file using
Marshal after parsing it the first time. I know that for me, this
turned out to be a great decision, shaving 30+ seconds off every test
iteration I had to do.
Time to load a Marshalled file of songs: 0.1s


It appears that I was the only one who tried to do something about
all the terribly-named songs in the song library I supplied. I used
one of the solutions from the English Numerals quiz to turn integers
into english words. I tried to intelligently remove various "remix"
and "live" and "edit" variations on the name. In the end, I wish I
hadn't - the english numerals were a nice touch (and let me use songs
with names like "18" or "'74-'75"), but the rest was an exercise in
futility. Any reasonable playlist system should assume that it's
going to have a really nice, cleaned playlist fed to it. Regexp
heuristics to clean up messy names just aren't a substitute for a few
hours spent fixing one's ID3 tags :)


==========================
Create the Playlist
==========================
So, as the astute may have noticed, this quiz is really just a path-
finding in fancy clothes. Just as Mapquest creates directions from
point A to point B using roads connecting intersections, so this quiz
is about finding your way from song A to song B. There are just a
hell of a lot more roads connecting the intersections.

I'll cover my approach in depth, and then look at the code other
participants supplied.

As I mentioned above, I decided that I would first try a really brain-
dead algorithm and see how it performed. (I intended to try a
solution on my own and later do research into optimized algorithms in
this field, but never got around to the latter.)

After reading in all the songs, I partitioned them off into a Hash
that mapped each letter of the alphabet to the songs that started
with that letter. For each of those songs, I then figured out all the
unique end letters that you could get to. For simplicity sake,
whenever there were multiple songs with the same start/end letters
(e.g. "All Out of Love" and "A
Whiter Shade of Pale") I simply picked one of them at random and
threw out the rest.

I also threw out all the cases where songs started with and ended
with the same letter. These might be very useful in determining
specific-length playlists, but I didn't want to deal with them.

The end result looked something like this:

@song_links = {
'a' => { 'c' => <Song 'Aerodynamic'>, 'd' => <Song 'All I
Need'>, ... },
'b' => { 'a' => <Song 'Bawitdaba'>, 'e' => <Song 'Brother of
Mine'>, ... },
...
}

This hash of hashes was then my roadmap, telling me what
intersections I could get to for each letter, and what songs I
travelled along to take that path.

To walk 50 steps, you first have to walk 49 and then take the last
step. Similarly, I decided that I would write a recursive function
that checked to see if two songs could be linked together. If they
could, that was the path. If not, take one 'step' forward - see if a
path to the last song existed from each of the songs which connected
to the first.

My first brain-dead approach just let me wander along each
possibility until I found each match, and stored all the successes.
(My idea was to find all the solutions and then select the shortest
one from the list.) I quickly ran into neverending loops as I visited
the same song again and again. So, I started passing along a string
of all the letters I had already tried. Before taking a step to a new
song, I checked to make sure it wasn't in this list.

NOW I WAS COOKING! Along with some debug output, I watched as my code
began traverse the possibility path. And watch, and watch. I started
thinking about it.

With 5000 songs evenly distributed across all the letters, and paths
that can go 26 levels deep, my back-of-the-envelope calculations led
me to realize there there were (very roughly) something like
878406105516319579477535398778457892291056669879055897379772997878407474
708480000000000 possible paths. Ooops. That would take a while to
search the entire possibility tree. After waiting for 20 minutes, I
realized I might have to wait a really long time.

So, I made one more adjustment - whenever I found a valid path, I
stored the length of that path in an instance variable. Any time my
recursive function tried to go deeper than than, I bailed out. (If
know one route to get to the mall that takes 3 miles, and you start
down a strange road looking for a shortcut, after you've gone more
than 3 miles you know it's time to head home. You might be able to
get to the mall by driving across the country and back, but it's not
the solution you're looking for.)

And WHAM...my brain-dead solution went from taking longer than the
universe has existed, to finding solutions in less than a second.
Usually less than 1/10th of a second.

Take THAT, optimized algorithms! :)

[As a disclaimer, in the cases where no such path exists the code has
to end up searching the entire possibility space, so...it essentially
hangs when it can't find the right path.]

I had visions of using the same sort of solution to find a specific-
length playlist: I would accumulate time as I recursed, and bail out
once I had passed the limit. And then I went to bed instead. :)


So, on to looking at others' solutions, as best I can.

Pedro's solution appears to use recursion, like mine. I can't quite
tell how it's preventing infinite circular lists, or how it goes so
fast. It stores a list of possible solutions, and when finished it
yields the list of all found solutions to a passed-in proc to
determine which solution is correct.

So, for example, the line:
result=search(songs, first, last, &min_dur)
picks the playlist with the shortest duration from those it found,
while:
result=search(songs, first, last, &min_len)
picks the playlist with the fewest songs.

Because Pedro's solution didn't do any song name cleaning, it can
produce some interesting results:
Proudest Monkey - Dave Matthews Band - 551 ms ===> 'Round The World
With The Rubber Duck - C.W. McCall - 247 ms
Proudest Monkey - Dave Matthews Band - 551 ms
You May Be Right - Billy Joel - 255 ms
Teacher, Teacher - .38 Special - 193 ms
Rock Me - ABBA - 185 ms
Eden - 10,000 Maniacs - 250 ms
North - Afro Celt Sound System - 409 ms
Here Without You - 3 Doors Down - 238 ms
Under Attack - ABBA - 227 ms
Kelly Watch The Stars - Air [French Band] - 226 ms
Saguaro - A Small, Good Thing - 353 ms
Ostrichism - A Small, Good Thing - 152 ms
Mamma Mia - ABBA - 212 ms
All I Need - Air [French Band] - 268 ms
Does Your Mother Know - ABBA - 195 ms
When You're Falling - Afro Celt Sound System - 314 ms
God Must Have Spent A Little.. - Alabama - 203 ms
... to calm a turbulent soul - Falling You - 469 ms
Losing Grip - Avril Lavigne - 233 ms
Preacher in this Ring, Part I - Bruce Hornsby - 302 ms
Intergalactic - Beastie Boys - 231 ms
CDJ - Pizzicato Five - 344 ms
Jumpin' Jumpin' - Destiny's Child - 229 ms
'Round The World With The Rubber Duck - C.W. McCall - 247 ms

(Note the linkage of songs with apostrophes or periods. :)

However it traverses the results, it's speediness seems to be
mitigated by results that aren't quite as short as possible. For
example (after I hacked Pedro's code to ignore everything but spaces
and letters in a title) it thinks that the shortest playlist between
"Que Sera" and "Zaar" is:

Que Sera - Ace of Base - 227 ms ===> Zaar - Peter Gabriel - 178 ms
Que Sera - Ace of Base - 227 ms
Angeleyes - ABBA - 259 ms
Second Chance - .38 Special - 271 ms
Eden - 10,000 Maniacs - 250 ms
North - Afro Celt Sound System - 409 ms
Hold On Loosely - .38 Special - 279 ms
You May Be Right - Billy Joel - 255 ms
Teacher Teacher - .38 Special - 193 ms
Release It Instrumental - Afro Celt Sound System - 387 ms
Little Bird - Annie Lennox - 279 ms
Dont Talk - 10,000 Maniacs - 321 ms
Keepin Up - Alabama - 185 ms
Pluto - Björk - 199 ms
Ostrichism - A Small, Good Thing - 152 ms
Mountain Music - Alabama - 252 ms
Caught Up In You - .38 Special - 276 ms
Unknown Track - Boards of Canada - 311 ms
in the Morning - LL Cool J - 222 ms
Get Me Off - Basement Jaxx - 289 ms
Fine and Mellow - Billie Holiday - 220 ms
Waltz - Gabriel Yared - 118 ms
Zaar - Peter Gabriel - 178 ms

while my code discovered this path (after I stole the code from James
to pick start and end songs):

Looking for a path between 'Que Sera' and 'Zaar'
#0 - Ace of Base :: Que Sera :: 3:47
#1 - Eric Serra :: A Bomb In The Hotel :: 2:15
#2 - Duke Ellington :: Limbo Jazz :: 5:14
#3 - Peter Gabriel :: Zaar :: 2:59
+0.2s to create playlist


James did a nice two-way search for his solution - instead of walking
an ever-broadening possibility tree going from the start to the
finish, he has the finish also start branching out at the same time
until they meet. From a geometric perspective, a circle centered on
the start point and touching the end point will cover twice the area
of two circles centered on each point which just touch each other.
This would seem to me to be an obvious benefit from both a speed and
memory standpoint. (I had originally thought I would do something
like this, but couldn't come up with a clear idea of how to implement
it.)

I particularly like the incredible simplicity of James' code that
does the work:

until (join_node = start.touch?(finish))
start.grow
finish.grow
end

Ruby code can be so simple and expressive when done right! Another
nice line (from Brian's code):
connections = starts & endings


Unfortunately, when I asked James' code to find the same path as
above, it took a very long time (almost half an hour) to produce a
result. I wish I had more domain knowledge (and time) to research why
this was the case, with an algorithm that I though would be a better
performer. (It does come up with a nice terse path, however:
1: Que Sera by Ace of Base <<227422>>
2: All Through the Night by Cyndi Lauper <<269740>>
3: These Are Days by 10,000 Maniacs <<293067>>
4: Santa Cruz by David Qualey <<131053>>
5: Zaar by Peter Gabriel <<293355>>



I wanted to play more with Brian and Dave's solutions, but they also
took a very long time to finish. The first time I ran Dave's solution
it took over 2 hours and 400MB of RAM, and eventually dumped out
every playlist it could find that matched the criteria I supplied. (A
1-3 song playlist between two letters that I forget, with no time
constraints.)

As noted earlier, Dave's solution gives you the power to limit number
of songs (min, max, and target) and playlist duration (again, min,
max, and target). I wish I had thought of this - being able to take
the number of recursive depths from 26 to something like 10 helps
tremendously in terms of search space. Dave also notes that his code
is a depth-first recursion, and seeing his exclamation point in the
comment
# (recursively, depth-first(!))
makes me realize that when searching for a shortest-path algorithm, a
breadth-first search would almost certainly prove a better performer.
Damn. :)

One thing I found particularly interesting in Dave's code was his way
to specify Infinity as a starting point for a minimum-value algorithm:

result.inject(1.0/0.0) do |memo, pl|
[ memo, (pl.total_duration - target_duration).abs ].min
end

In Javascript I've done stuff like that starting out with values like
var theMin = Number.MAX_VALUE;
or
var theMin = Infinity;
and had wished for a way to do the same in Ruby. I still wish for a
nice portable Infinity constant, but Dave's solution here will do
nicely for me for the future. Thanks Dave! :)


Brian's solution version quickly finds a few shortest playlists, but
when it goes on to find an a->z playlist that's as close to 30
minutes as possible, it ... well, it's been over 3 hours and it still
hasn't finished, though it occasionally spits out progress which
looks like it found a new playlist that is a slightly better match
than the last.

Brian's code looks quite nice, however. Brian genericized his
solution to allow for any type of minimization, using his
BasicMatchEvaluator class. While the code is not documented, it would
appear that his PlaytimeEvaluator subclass uses the square of the
difference between the desired time and the actual playlist to
determine fitness. But if that's all it did, it would require finding
every solution - as it is, the #continue? method looks to be designed
to provide a customizable way to determine early on if a path is a
'dead end'.


Ilmari's solution is fast! I don't have the expertise to analyze his
implementation of "Dijkstra's graph search algorithm with a block-
customizable edge cost", but whatever it is, it works. Every solution
took less than 1 second, usually under 1/2 a second.


One optimization that I had thought of, but that nobody implemented
(as far as I could tell) was to find 'gaps'. For example, no song in
the supplied playlist ended with a 'q', so all songs that started
with 'q' could be removed. Similarly for songs beginning with 'x'.


==========================
Show the Result!
==========================
Finally, we get to the end - showing off the end result.
A few people (Gavin, Brian, Ilmari) took the messy milliseconds
duration and made them into something more human readable. Ilmari's
solution stands out in this department as particularly attractive:

Trying to find playlist from "Que Sera" to "Zaar"

Found playlist:
Que Sera (3:47) by Ace of Base
And Now Little Green Bag (0:15) by Steven Wright
Good Friends Are for Keeps (1:08) by The Carpenters
Santa Cruz (2:11) by David Qualey
Zaar (4:53) by Peter Gabriel

(Needs a fixed-width font to display as intended.)


==========================
Summary
==========================
As I noted, I spent far too much time working on petty details, and
didn't get to personally spend as much time playing with algorithms
and research as I would have liked. I'm quite pleased to see all the
solutions everyone submitted, because they showcase various nice Ruby-
isms and programming styles. (Blocks and procs, or custom classes,
used to turn a specific algorithm into a nice generic solution that
can use all sorts of criteria for what a good playlist is.)

I've focused on performance a lot during this summary. The point was
certainly not to write the fastest solution possible; however, in
playing with these sorts of NP-complete[2] problems you sometimes
HAVE to think about speed just to get a solution in a time that isn't
measured in years.

I hope you all had fun playing with this very common, very difficult
problem, even if it was disguised in sheep's clothing. :)


[1] http://c2.com/cgi/wiki?PrematureOptimization
[2] http://en.wikipedia.org/wiki/NP-complete : Some flavors of this
quiz (such as "shortest playlist") are not NP-complete, but I think
other flavors ("playlist closest to 30 minutes") are.
 
J

James Edward Gray II

==========================
Summary
==========================

I hope everyone else enjoyed Gavin's summary half as much as I did!
I learned so much I'm ready for a second crack at that problem.

I just wanted to say a HUGE thank you to Gavin for keeping me on
autopilot all week and running the show beautifully! It was such a
nice change, I took the opportunity to just work the problem like
everyone else and it was great fun. Thanks again Gavin!

Two quizzes AND two summaries folks, that's the record to beat...

James Edward Gray II
 

Ask a Question

Want to reply to this thread or ask your own question?

You'll need to choose a username for the site, which only take a couple of moments. After that, you can post your question and our members will help you out.

Ask a Question

Members online

No members online now.

Forum statistics

Threads
473,769
Messages
2,569,581
Members
45,057
Latest member
KetoBeezACVGummies

Latest Threads

Top