Representing Rubik's Cube

I owned a Rubik’s Cube but never learned to solve it. I can get two “layers” in place, and I have been told that I am on the path to a solution, but I could never complete the third layer.

Over the summer, there was a bit of press about Rubik’s Cube. Using servers donated by Google, researchers proved that any cube could be solved in at most 20 moves.

I recently interviewed to teach an algorithms course at Lewis & Clark College. The interview included teaching an advanced algorithms class for an hour. While preparing, I became intrigued with the idea of representing Rubik’s Cube and having the computer solve it.

After about eight hours (spread over four days and nights) of trying different representations, I settled on a good one in Ruby.

The first thing to note is that each face has a center. The centers never move, so the relationships between the centers never change. I represented this with a class variable and an instance method to access it:

  @@neighbors = {
    "w" => %w(r b o g),
    "r" => %w(y b w g),
    "y" => %w(o b r g),
    "o" => %w(w b y g),
    "g" => %w(y r w o),
    "b" => %w(w r y o)
  }

  def neighbors
    @@neighbors
  end

A move is a rotation around one of the six centers. A counterclockwise rotation is a sequence of three clockwise rotations. (When you play with a cube, you might think about “rotating the middle layer,” but this is nothing but rotating one face clockwise and the other face counterclockwise.)

When a piece rotates around a center, its neighbors get substituted with new ones. I could have hard-coded the new neighbors, too, but I thought it more expressive to compute and then cache them instead:

  @@rotated = {}

  def rotated
    if @@rotated.empty?
      neighbors.each_pair do |color, color_neighbors|
        @@rotated[color] = color_neighbors.dup
        @@rotated[color] = @@rotated[color].unshift(@@rotated[color].pop)
      end
    end
    @@rotated
  end

When you make a move, pieces move from one position to another. Because the relationships between centers are fixed, each position can be uniquely identified by a set (unordered) of colors. A pair of colors identifies “side” positions, and a triple of colors identifies “corner” positions. There are 20 such positions:

  def canonical_key(colors)
    colors.sort.join
  end

  require 'set'
  @@positions = Set.new

  def positions
    if @@positions.empty?
      neighbors.each_pair do |color, color_neighbors|
        color_neighbors.each_with_index do |neighbor, index|
          @@positions < < canonical_key([color, neighbor])
          @@positions << canonical_key([color, neighbor, color_neighbors[(index + 1) % color_neighbors.length]])
        end
      end
    end
    @@positions
  end

A piece can similarly be identified by a set of colors.

You might think a cube can be represented by mapping twenty position sets to twenty piece sets. (I went down this path.) However, this is not sufficient information to fully represent the cube, because a piece may be in a position in various orientations, and you need to represent that. A side piece in a given position could be in two orientations, and a corner piece could be in three orientations.

To capture the orientation of the piece, I represent the piece as a mapping from position colors to piece colors. A piece is in its solved position if each of its position colors maps to the same piece color (an identity map). A piece is in its solved position but in the wrong orientation if the domain (position) set and range (piece) set are identical but the mapping is not an identity mapping.

The following code creates a mapping from the 20 positions to the pieces occupying each position.

  def position_pieces
    if @position_pieces.empty?
      positions.each do |name|
        @position_pieces[name] = Piece.new(name.split(''))
      end
    end
    @position_pieces
  end

A Piece can tell if it’s home. The rotate() method simply substitutes position elements from olds with corresponding elements from news.

class Piece
  def initialize(position)
    @position_colors = {}
    position.each do |color|
      @position_colors[color] = color
    end
  end

  def home?
    @position_colors.keys.inject(true) { |current, position| current && (@position_colors[position] == position) }
  end

  def rotate(olds, news)
    new_position_colors = {}
    @position_colors.each_pair do |oldpos, color|
      if (index = olds.index(oldpos))
        new_position_colors[news[index]] = color
      else
        new_position_colors[oldpos] = color
      end
    end
    @position_colors = new_position_colors
    self
  end

  def position
    @position_colors.keys
  end

  def serialize
    @position_colors.keys.sort.map { |position| @position_colors[position] }.join
  end

  def deserialize(colors)
    @position_colors.keys.sort.each_with_index do |position, index|
      @position_colors[position] = colors.split('')[index]
    end
  end
end

The cube is solved if all of its pieces are home.

  def solved?
    position_pieces.values.inject(true) { |solved, piece| solved && piece.home? }
  end

Cube.new returns a new cube in its solved (home) position.

After a bit of experimentation, I found it useful for the cube to maintain its history of rotations:

  attr_reader :history

  def initialize
    @position_pieces = {}
    @history = []
    yield self if block_given?
  end

A move is made by calling rotate_ccw() or rotate_cw() on the cube and passing them the color of the axis to rotate around. To rotate a face of the cube around a color, we find positions neighboring that color. For each of these, we retrieve the piece occupying the position and assign it to its new position.

  def positions_neighboring(color)
    position_pieces.keys.select { |pos| !pos.index(color).nil? }
  end

  def rotate_ccw(color)
    @history < < [:rotate_ccw, color]
    rotate(color, rotated[color], neighbors[color])
  end

  def rotate_cw(color)
    @history << [:rotate_cw, color]
    rotate(color, neighbors[color], rotated[color])
  end

  def rotate(color, from, to)
    # for each position neighboring the color
    neighboring_positions = positions_neighboring(color)
    positions_neighboring(color).map do |pos|
      # find the piece occupying the position
      # assign the piece to the rotated position
      position_pieces[pos].rotate(from, to)
    end.each do |piece|
      position_pieces[canonical_key(piece.position)] = piece
    end
    self
  end

You can call unsolve() on the cube to scramble it by a random number of moves between 10 and 30. That method also has an optional parameter for how many moves to scramble it by. For each scrambling move, it simply chooses a random color and calls rotate_cw() with that color.

  def random_color
    neighbors.keys[(rand * neighbors.keys.length).to_i]
  end

  def unsolve(moves = ((rand * 20).to_i + 10))
    # a random number of times
    moves.times do
      # choose a random color
      # rotate
      rotate_cw(random_color)
    end
    self
  end

To write a breadth-first search to solve the cube, I had to be able to persist the state of a cube. To avoid repeating positions that have already been tried, I needed a way to quickly compare one state to another. I decided to serialize and deserialize a cube to/from a string.

Other than not repeating positions that have already been tried, breadth-first search is a brute-force method and probably needs some optimizations. I find that cubes scrambled by more than six moves take a long time to solve.

  def serialize
    position_pieces.keys.sort.map do |key|
      position_pieces[key].serialize
    end.join(".")
  end

  def self.deserialize(string)
    names = string.split('.')
    Cube.new do |c|
      c.position_pieces.keys.sort.each_with_index do |key, index|
        c.position_pieces[key].deserialize(names[index])
      end
    end
  end

  def self.all_colors
    @@neighbors.keys
  end

  def solve
    processed = []
    solved = false
    # put the current state on the list
    to_visit = [serialize()]
    # While the list is nonempty
    while to_visit.length > 0 && !solved
      processed_state = to_visit.pop
      processed < < processed_state
      # For each rotated state
      Cube.all_colors.each do |color|
        rotated_cube = Cube.deserialize(processed_state).rotate_ccw(color)
        rotated_state = rotated_cube.serialize()
        puts "#{color} : #{rotated_state}"
        # If it is not on the list, put it on the end of the list
        to_visit.insert(0, rotated_state) unless (to_visit.include?(rotated_state) || processed.include?(rotated_state))
        # If it is a solved state, find the path to the current state
        solved ||= rotated_cube.solved?
      end
    end
  end

Because this is using a breadth-first search, it can find the shortest path to a solution. The next step is to store the path from the initial cube to the solution.

Tags: Fun, Technical

Created at: 16 October 2010 7:10 AM

NO COMMENTS ALLOWED