Ruby Kata
I recently got a chance to work with Ruby for the first time. I’m pretty pleased with the result, and I thought it would make a pretty good post here.
What follows is what’s called a code kata, and comes from this website. Code katas are a kind of practice problem, and the word comes from a Japanese word for a kind of ‘practicing the motions’ martial art exercise. The problems are much simpler than most business problems, but are complicated enough to be interesting. There’s a full description of the idea here.
What this kata asks is to make a program that transforms one word into another by changing one letter at a time, without making any invalid words. This kind of problem was invented by Lewis Carrol, who called them ‘word ladders‘. This kind of logic problem is a good fit for katas because it gives us a chance to be clever; I have written this so that it always finds the shortest path between two words.
The code uses a word list of ‘real’ or ‘valid’ words named words.txt which must be in the same folder as the Ruby script for it to run.
# Checks to see if you can move from the first string to the second in a single
# move
def connected?(first, second)
if first.length != second.length
return false
end
# Count how many letters in the first string are different from in the second
# string
count = 0
for index in 0..first.length
if first[index] != second[index]
count = count + 1
end
end
count == 1
end
# Gets the set of words in a list that are connected to a given word by a
# single move
def get_connected_words(word, list)
connected_words = []
list.each{|word_to_check|
if connected?(word, word_to_check)
connected_words << word_to_check
end
}
return connected_words
end
# Does the work of finding the chain by doing a breadth-first search of the
# dictionary
def find_chain(first, target)
if first.length != target.length
puts "There is no chain between '" + first + "' and '" + target + " because they are different lengths."
return
end
# Build an array of unvisited words of the right length from our dictionary
unvisited_words = []
File.open('words.txt').each_line{ |line|
if line.strip.length == first.length
unvisited_words << line.strip
end
}
# Now that we have a list of words of the right length in memory, we might as
# well check our arguments for validity
if !unvisited_words.include?(first)
puts "There is no chain between '" + first + "' and '" + target + "' because '" + first + "' is not in my dictionary."
return
elsif !unvisited_words.include?(target)
puts "There is no chain between '" + first + "' and '" + target + "' because '" + target + "' is not in my dictionary."
return
end
puts "Please wait..."
# Build a queue to hold the chains that we're going to try
queue_of_chains = []
# Add our degenerate chain (the start link) to the queue
start_chain = Array.new()
start_chain << first # Array with the single word in it
queue_of_chains << start_chain
# Remove our starting point from our list of nodes; we know it's a dead end
unvisited_words.delete(first)
success = false
# Visit each chain in the queue, checking if the last link in the chain is
# our target. Work finishes when we find a chain ending in our target
# (success!) or we run out of queue (failure!)
while !queue_of_chains.empty?
chain_to_visit = queue_of_chains.shift()
if chain_to_visit.last == target
puts "Chain found!"
success = true
puts chain_to_visit
break
else
# The chain we're looking at isn't a match, so try to add more things to
# our queue. We're adding copies of our chain, plus an unvisited word
# that will connect to the last link
words_to_visit = get_connected_words(chain_to_visit.last, unvisited_words)
words_to_visit.each{ |word|
links_of_new_chain = Array.new(chain_to_visit)
links_of_new_chain << word
queue_of_chains << links_of_new_chain
}
# Don't let the words we just enqueued be added a second time
unvisited_words = unvisited_words - words_to_visit
end
end
if !success
puts "Sorry, there is no chain connecting '" + first + "' and '" + target + "'."
end
end
puts "This program tries to find a word chain between two words. Please enter a first word."
# All of the words in our dictionary are lowercase, so let's transform input
# rather than comparing case-insensitive everywhere else.
first = gets.chomp.downcase
puts "Okay; and your second word?"
target = gets.chomp.downcase
find_chain(first, target)