From: Ruby Quiz Date: 2007-01-04T23:14:53+09:00 Subject: [SUMMARY] Word Search (#107) I had a few moments this weekend and sat down to work this problem. I was quite surprised to find it tougher than I had expected. I fiddled around for about an hour trying to find a solution I felt was elegant, but never really got there. Luckily, we have many submitters smarter than me. Almost all of the solutions this week took different approaches and they are all quite interesting. This doesn't seem to be one of those problems we have a standard approach for. Some solutions did a boring search using X and Y coordinates; others started by building a map of points in the puzzle to letters or even letters to points; one even works by performing board transformations. I'm going to show the basic X and Y search in this summary, but the other method were equally interesting and well worth a look. Let's talk about the solution from Bob Showalter, which begins like this: class WordSearch class Board < Array def to_s collect {|s| s.split(//).join(' ')}.join("\n") end end # ... Here we see an Array subclass with a trivial modification. Board objects are just two-dimensional Arrays that know how to draw themselves in the quiz output format. Let's move on to the setup code: # ... attr_reader :board, :solution # creates a new, empty solver def initialize @board = Board.new @solution = Board.new end # resets the solution def reset @solution.clear @board.each {|row| @solution << row.gsub(/./, '+')} end # ... Here we have the initialization for two Board objects. One will hold the actual board, or puzzle object, and the other the solution-in-progress. reset() gives us hints at the solution object structure, which is cleared to a board full of plus characters. The board is loaded with the following code: # ... # checks that the board contains only letters and that it has a uniform # rectangular shape def validate @board.size > 0 or raise "Board has no rows" @board.grep(/[^A-Z]/).empty? or raise "Board contains non-letters" w = @board.collect {|row| row.size}.uniq w.size == 1 or raise "Board rows are not all the same length" w.first > 0 or raise "Board has no columns" end # parses the board by reading lines from io until a blank line (or EOF) # is read. def parse(io = ARGV) @board.clear while line = io.gets line = line.strip.upcase break if line == '' @board << line end validate reset end # ... validate() is just a reality check for the board. It verifies that we have some rows, those rows contain letters, there are columns in each row, and in fact the same number of columns. That check is run towards the end of parse(), which just reads line by line filling the board object. Note that reset() is also called to prepare the solution object. The real work comes in the methods that search for words: # ... # search for word. returns number of times found. solution is updated # with all occurences. def search(word) found = 0 0.upto(board.size-1) do |y| 0.upto(board[y].size-1) do |x| [-1, 0, 1].each do |dy| [-1, 0, 1].each do |dx| next if dx == 0 and dy == 0 found += 1 if search_for(word.strip.upcase, x, y, dx, dy) end end end end found end # search for word in board starting at position (x,y) and moving in # direction (dx,dy). returns true if found, false if not found. def search_for(word, x, y, dx, dy) return false if x < 0 or x >= board.first.size or y < 0 or y >= board.size return false if board[y][x] != word[0] prev = solution[y][x] solution[y][x] = board[y][x] return true if word.length <= 1 found = search_for(word[1,word.length-1], x + dx, y + dy, dx, dy) solution[y][x] = prev unless found found end # ... The search() method manages the hunt for a single term. It's a trivial brute-force find from every starting square in all eight directions. The method maintains and returns a count, for all occurrences found during the search. The actual letter match is handed off to search_for() though. In search_for() a recursive search is used to find letter by letter. The tricky part here is that the solution object is modified as we search, assuming we will find the word. This means that an extra walk isn't needed to update the solution later, but it also means the code must revert the changes to the solution object if the word isn't found. Those are the gymnastics you see in the second half of the method. The next two methods wrap an interface around these calls: # ... # creates a new puzzle by parsing the board from io. see # WordSearch#parse def self.parse(io = ARGF) obj = new obj.parse(io) obj end def to_s solution.to_s end end # ... The class method parse() constructs an object and parses the board from the passed IO object. The instance method to_s() can then be used to show final results, after one or more calls to search(). The solution ends with code to kick-off the process: # ... # parse the board first p = WordSearch.parse # parse the words until a blank line is read words = [] while line = ARGF.gets line = line.strip.upcase break if line == '' words += line.gsub(',', ' ').split end # submit each word and show how many times it was found for word in words.sort.uniq n = p.search(word) puts word + ' was ' + (n == 0 ? 'not found' : n == 1 ? 'found once' : "found #{n} times") end # show the solution puts p Here we see the WordSearch object constructed and the board parsed out of the input. The remainder of the input is divided into words and search() is called for each one. A found count is printed for each word as the search is made, then the final solution is displayed. + + + M + + + + + + + + + + Y + + + + + T H A N K S + + + + O + + + + + + + + + + L L A + T H E + + + + + + C + + + + + + + S O L V E R S + + + + + e + + + + + + + + + V + + + + + + + + + E + + + + + + + + + R + + + + + Tomorrow we have a new word game, so stay tuned...