Photo

Hi, I'm Aaron.

Minesweeper

The Problem: Minesweeper

This exercise, one of the ones designated “Medium”, is called “Minesweeper”. It’s an exercise to populate a minesweeper grid with the number values for the eponymous computer game.

Your task is to add the mine counts to empty squares in a completed Minesweeper board. The board itself is a rectangle composed of squares that are either empty (‘ ‘) or a mine (‘*’). For each empty square, count the number of mines adjacent to it (horizontally, vertically, diagonally). If the empty square has no adjacent mines, leave it empty. Otherwise replace it with the adjacent mines count.

https://exercism.org/tracks/ruby/exercises/minesweeper/solutions/armahillo

Pass 1: Scaffolding

I usually like to start with creating a separate class, to keep the “execution flow” separate from the data modeling. From some preliminary test runs, I knew that the inputs would get passed in as a string and get passed out as a string. But I also knew that in order to leverage a lot of the Ruby enumerable methods, I would need to convert to Arrays. Since we care about setting a number, we’re going to initialize any blank space to a 0, and then reverse that if it’s still 0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Minefield
  attr_accessor :grid

  def initialize(input)
    @grid = input.map! { |i| i.chars.map! { |c| c == ' ' ? 0 : c  } }
  end

  def to_s
    @grid.map { |i| i.map(&:to_s).join.gsub('0', ' ') }
  end
end

class Minesweeper # this is the default class the tests expect
  def self.annotate(input)
    field = Minefield.new(input)


    field.to_s
  end
end

Due to some junk inputs, there was a little bit of robustness that had to be added to the initialize and to_s methods:

# In
@grid = input.map! { |i| i == '' ? '' : i.chars.map! { |c| c == ' ' ? 0 : c  } }

# Out
@grid.map { |i| i.is_a?(Array) ? i.map(&:to_s).join.gsub('0', ' ') : i || '' }

Once I had this, it gave me an abstraction I could work with, and also get back out of.

Pass 2: Finding neighbors

One of the early starts I had involved trying to find each empty square and then calculating how many mines were around it, but for most boards that would be more iterations since there are generally more empty spaces than mines.

Instead, what I decided to do was find each mine and then consider all of its neighbors. A “neighbor” is any adjacent square (orthogonal, diagonal), like this (the 💣 is the mine):

🔘🔘🔘
🔘💣🔘
🔘🔘🔘

The cool thing here is that any neighbor is adjacent, meaning it’s only 1 unit away on either row or column, so we can define any given neighbor set as:

[row-1, col-1], [row-1, col], [row-1, col+1]
[row,   col-1],      💣       [row,   col+1]
[row+1, col-1], [row+1, col], [row+1, col+1]

If we iterate through the field as a 2D array (rows, columns), we then can consider each individual cell (a ‘*’ is a mine, here):

1
2
3
4
5
6
7
8
9
10
11
12
13
class Minesweeper # this is the default class the tests expect
  def self.annotate(input)
    field = Minefield.new(input)

    field.grid.each.with_index do |row, row_index|
      row.each.with_index do |col, col_index|
        field.update_neighbors(row_index, col_index) if col == '*'
      end
    end

    field.to_s
  end
end

But what about when we’re looking at cells on the edge? Well, we know that we don’t want any negative values for either. We can also ensure that we’re not using values that are equal or greater than the number of rows / cols. I called the method that determines this “viable_neighbors”.

1
2
3
4
5
6
7
8
9
10
11
def viable_neighbors(row, col)
  [
    [row-1, col-1], [row-1, col], [row-1, col+1],
    [row,   col-1],               [row,   col+1],
    [row+1, col-1], [row+1, col], [row+1, col+1]
  ].reject { |neighbor|
    neighbor.any?(&:negative?) ||
    neighbor[0] >= @grid.size ||      # @grid.size is the number of rows
    neighbor[1] >= @grid.first.size   # @grid.first.size is the number of columns
  }
end

This then returns an array of tuples that we can increment:

1
2
3
4
5
def update_neighbors(row = 0, col = 0)
  viable_neighbors(row, col).each do |neighbor|
    @grid[neighbor[0]][neighbor[1]] += 1
  end
end

Pass 3: Implementing Enumerable

This is perhaps some syntactic sugar, but I really wanted to have a simpler API where I can just use :each instead of a nested loop. So I included Enumerable, and then added this method to Minefield:

1
2
3
4
5
6
7
def each
  @grid.each.with_index do |cells, row|
    cells.each.with_index do |cell, col|
      yield(cell, row, col)
    end
  end
end

This then let me change the .annotate method to:

1
2
3
4
5
6
7
8
9
def self.annotate(input)
  @field = Minefield.new(input)

  @field.each do |cell, row, col|
    @field.update_neighbors(row, col) if cell == '*'
  end

  @field.to_s
end

Because of a bad-input gotcha, had to add this inside the outer loop of :each:

@grid.each.with_index do |cells, row|
  next unless cells.is_a?(Array)

At this point, the tests passed.

Pass 4: Polish

At this point I made two final changes:

class Minefield
  MINE_CHAR = '*'

The “mine” character is defined as a constant. Also:

neighbor.any?(&:negative?) || !@grid.dig(*neighbor).is_a?(Integer)

Array gives the :dig method, which lets you specify a series of args and it will attempt to safely find its way into that element. All we care about is whether or not the field is an integer already. If the attempted cell is out of bounds, we just ignore it.

A gotcha here is that we have to keep the “negative” check because technically you can access negative indices in an array, it just wraps around in reverse; but not in the other direction.

irb(main):001> array = [1,2,3]
=> [1, 2, 3]
irb(main):002> array[2]
=> 3
irb(main):003> array[-1]
=> 3

Tests all passed. I added a couple comments to clarify, and submitted!