Here is my solution.

It uses logic to deduce numbers for open cells which is enough for most  
easy and intermediate sudokus (this is quite fast, usually <100ms), but it  
can also guess and backtrack to solve the hard ones and those with  
multiple solutions.

The SudokuSolver class can solve sudokus for all valid square board sizes  
(1x1, 4x4, 9x9, 16x16, ...), but the "if $0 == __FILE__"-part only handles  
9x9 puzzles.

Dominik


The code:

class SudokuSolver

     # sudoku is an array of arrays, containing the rows, which contain the
     # cells (all non valid entries are interpreted as open)
     def initialize(sudoku)
         # determine @n / @sqrt_n
         @n = sudoku.size
         @sqrt_n = Math.sqrt(@n).to_i
         raise "wrong sudoku size" unless @sqrt_n * @sqrt_n == @n

         # populate internal representation
         @arr = sudoku.collect { |row|
             # ensure correct width for all rows
             (0...@n).collect { |i|
                 # fixed cell or all values possible for open cell
                 ((1..@n) === row[i]) ? [row[i]] : (1..@n).to_a
             }
         }

         # initialize fix arrays
         # they will contain all fixed cells for all rows, cols and boxes
         @rfix=Array.new(@n) { [] }
         @cfix=Array.new(@n) { [] }
         @bfix=Array.new(@n) { [] }
         @n.times { |r| @n.times { |c| update_fix(r, c) } }

         # check for non-unique numbers
         [@rfix, @cfix, @bfix].each { |fix| fix.each { |x|
             unless x.size == x.uniq.size
                 raise "non-unique numbers in row, col or box"
             end
         } }
     end

     # returns the internal representation as array of arrays
     def to_a
         @arr.collect { |row| row.collect { |x|
             (x.size == 1) ? x[0] : nil
         } }
     end

     # returns a simple string representation
     def to_s
         fw = @n.to_s.size
         to_a.collect { |row| row.collect { |x|
             (x ? x.to_s : "_").rjust(fw)
         }.join " " }.join "\n"
     end

     # returns whether the puzzle is solved
     def finished?
         @arr.each { |row| row.each { |x| return false if x.size > 1 } }
         true
     end

     # for each cell remove the possibilities, that are already used in the
     # cell's row, col or box
     # return if successful
     def reduce
         success = false
         @n.times { |r| @n.times { |c|
             if (sz = @arr[r][c].size) > 1
                 @arr[r][c] = @arr[r][c] -
                     (@rfix[r] | @cfix[c] | @bfix[rc2box(r, c)])
                 raise "impossible to solve" if @arr[r][c].empty?
                 # have we been successful
                 if @arr[r][c].size < sz
                     success = true
                     update_fix(r, c)
                 end
             end
         } }
         success
     end

     # find open cells with unique elements in their row, col or box
     # return if successful
     # reduce must return false when this method is called (if the  
possibilities
     # aren't reduced, bad things may happen...)
     def deduce
         success = false
         [:col_each, :row_each, :box_each].each { |meth|
             @n.times { |i|
                 u = uniqs_in(meth, i)
                 unless u.empty?
                     send(meth, i) { |x|
                         if x.size > 1 && ((u2 = u & x).size == 1)
                             success = true
                             u2
                         else
                             nil
                         end
                     }
                     # change only one row/col/box at a time
                     return success if success
                 end
             }
         }
         success
     end

     # tries to solve the sudoku with reduce and deduce
     # returns one of :impossible, :solved, :unknown
     def solve
         begin
             until finished?
                 progress = false
                 while reduce
                     progress = true
                 end
                 progress = true if deduce
                 return :unknown unless progress
             end
             :solved
         rescue
             :impossible
         end
     end

     # solves the sudoku using solve and if that fails, it tries to guess
     # returns one of :impossible, :solved, :multiple_solutions
     def backtrack_solve
         if (res = solve) == :unknown
             # find first open cell
             r, c = 0, 0
             @rfix.each_with_index { |rf, r|
                 break if rf.size < @n
             }
             @arr[r].each_with_index { |x, c|
                 break if x.size > 1
             }
             partial = to_a
             solutions = []
             # try all possibilities for the open cell
             @arr[r][c].each { |guess|
                 partial[r][c] = guess
                 rsolver = SudokuSolver.new(partial)
                 case rsolver.backtrack_solve
                 when :multiple_solutions
                     initialize(rsolver.to_a)
                     return :multiple_solutions
                 when :solved
                     solutions << rsolver
                 end
             }
             if solutions.empty?
                 return :impossible
             else
                 initialize(solutions[0].to_a)
                 return solutions.size > 1 ? :multiple_solutions : :solved
             end
         end
         res
     end

     private

     # returns the box index of row r and col c
     def rc2box(r, c)
          (r - (r % @sqrt_n)) + (c / @sqrt_n)
     end

     # if row r, col c contains a fixed cell, it is added to the fixed  
arrays
     def update_fix(r, c)
         if @arr[r][c].size == 1
             @rfix[r] << @arr[r][c][0]
             @cfix[c] << @arr[r][c][0]
             @bfix[rc2box(r, c)] << @arr[r][c][0]
         end
     end

     # yields each cell of row r and assigns the result of the yield unless  
it
     # is nil
     def row_each(r)
         @n.times { |c|
             if (res = yield(@arr[r][c]))
                 @arr[r][c] = res
                 update_fix(r, c)
             end
         }
     end
     # yields each cell of col c and assigns the result of the yield unless  
it
     # is nil
     def col_each(c)
         @n.times { |r|
             if (res = yield(@arr[r][c]))
                 @arr[r][c] = res
                 update_fix(r, c)
             end
         }
     end
     # yields each cell of box b and assigns the result of the yield unless  
it
     # is nil
     def box_each(b)
         off_r, off_c = (b - (b % @sqrt_n)), (b % @sqrt_n) * @sqrt_n
         @n.times { |i|
             r, c = off_r + (i / @sqrt_n), off_c + (i % @sqrt_n)
             if (res = yield(@arr[r][c]))
                 @arr[r][c] = res
                 update_fix(r, c)
             end
         }
     end

     # find unique numbers in possibility lists of a row, col or box
     # each_meth must be :row_each, :col_each or :box_each
     def uniqs_in(each_meth, index)
         h = Hash.new(0)
         send(each_meth, index) { |x|
             x.each { |n| h[n] += 1 } if x.size > 1
             nil # we didn't change anything
         }
         h.select { |k, v| v == 1 }.collect { |k, v| k }
     end

end

if $0 == __FILE__
     # read a sudoku from stdin
     sudoku = []
     while sudoku.size < 9
         row = gets.scan(/\d|_/).map { |s| s.to_i }
         sudoku << row if row.size == 9
     end
     # solve
     begin
         solver = SudokuSolver.new(sudoku)
         puts "Input:", solver
         case solver.backtrack_solve
         when :solved
             puts "Solution:"
         when :multiple_solutions
             puts "There are multiple solutions!", "One solution:"
         else
             puts "Impossible:"
         end
         puts solver
     rescue => e
         puts "Error: #{e.message}"
     end
end