Algorithm
We are given an array input
of equal-size arrays called "rows". The example given in the question is as follows.
input = [
["X", "X", "O", "X"],
["X", "X", "O", "X"],
["O", "O", "X", "O"],
["O", "O", "X", "O"],
["O", "O", "O", "X"],
["O", "O", "O", "X"]
]
For convenience, I will refer to the element input[3][2] #=> "X"
as the element in row 3
and column 2
(even though Ruby has no concept of two-dimensional arrays or of arrays having rows and columns).
The first step is to construct an array groups
, each element (a "group") comprised of one element, the indices of an "X" in one of the rows:
groups
#=> [[[0, 0]], [[0, 1]], [[0, 3]], [[1, 0]], [[1, 1]], [[1, 3]],
# [[2, 2]], [[3, 2]], [[4, 3]], [[5, 3]]]
We now consider the last element of groups
([[5, 3]]
) and ask if any of its elements (there is just one, [5, 3]
) are in the same island as the elements in any of the other groups. We find that it is on the same island as is [4, 3]
in group [[4, 3]]
(because the elements are in the same column, one row apart). We therefore delete the last group and add all of its elements (here just the one) to the group [[4, 3]]
. We now have:
groups
#=> [[[0, 0]], [[0, 1]], [[0, 3]], [[1, 0]], [[1, 1]], [[1, 3]],
# [[2, 2]], [[3, 2]], [[4, 3], [5, 3]]]
We now repeat the process with what is now the last group, [[4, 3], [5, 3]]
. We must determine if either of the elements of that group are on the same island as any element in each of the other groups. They are not1. We therefore have identified the first island, comprised of the locations [4, 3]
and [5, 3]
.
After initializing islands = []
, we perform the following operation:
islands << groups.pop
so now
groups
#=>#=> [[[0, 0]], [[0, 1]], [[0, 3]], [[1, 0]], [[1, 1]], [[1, 3]],
# [[2, 2]], [[3, 2]]]
islands
#=> [[[4, 3], [5, 3]]]
We continue this way until groups
is empty, at which time each element of islands
is an island.
Code
def find_islands(input)
groups = input.each_with_index.with_object([]) { |(row,i),groups|
row.each_with_index { |c,j| groups << [[i,j]] if c == 'X' } }
islands = []
while groups.any?
last_group = groups.pop
idx = groups.each_index.find { |idx| same_island?(groups[idx], last_group) }
if idx.nil?
islands << last_group
else
groups[idx] += last_group
end
end
islands.map(&:sort)
end
def same_island?(group1, group2)
group1.product(group2).any? { |(i1,j1),(i2,j2)|
((i1==i2) && (j1-j2).abs == 1) || ((j1==j2) && (i1-i2).abs == 1) }
end
Example
For the array input
given above we obtain the following array of islands.
find_islands(input)
#=> [[[4, 3], [5, 3]],
# [[2, 2], [3, 2]],
# [[0, 3], [1, 3]],
# [[0, 0], [0, 1], [1, 0], [1, 1]]]
Explanation
There is admittedly a great deal for an inexperienced Rubiest to learn to understand the workings of the two methods I've presented. Familiarity is needed with the following methods:
Initial calculation of groups
If this made a separate method (not a bad idea) we would write the following.
def construct_initial_groups(input)
input.each_with_index.with_object([]) { |(row,i),groups|
row.each_with_index { |c,`j| groups << [[i,j]] if c == 'X' } }
end
A newcomer to Ruby would probably find this a bit overwhelming. In fact, it is just the Ruby way to tighten up the following method.
def construct_initial_groups(input)
groups = []
i = 0
input.each do |row|
j = 0
row.each do |c|
groups << [[i,j]] if c == 'X'
j += 1
end
i += 1
end
groups
end
The first step to getting from here to there is to make use of the method Enumerable#each_with_index
.
def construct_initial_groups(input)
groups = []
input.each_with_index do |row,i|
row.each_with_index do |c,j|
groups << [[i,j]] if c == 'X'
end
end
groups
end
Next we make use of the method Enumerator#with_object
to obtain the first form above of construct_initial_groups
.
Writing the block variables (|(row,i),groups|
) is probably still confusing. You will learn that
enum = input.each_with_index.with_object([])
#=> #<Enumerator: #<Enumerator: [["X", "X", "O", "X"], ["X", "X", "O", "X"],
# ["O", "O", "X", "O"], ["O", "O", "X", "O"], ["O", "O", "O", "X"],
# ["O", "O", "O", "X"]]:each_with_index>:with_object([])>
is an enumerator, whose elements are generated by applying the method Enumerator#next
.
(row,i), groups = enum.next
#=> [[["X", "X", "O", "X"], 0], []]
Ruby uses disambiguation or decomposition to assign values to each of the three block variables:
row
#=> ["X", "X", "O", "X"]
i #=> 0
groups
#=> []
We then perform the block calculation.
row.each_with_index { |c,j| groups << [[i,j]] if c == 'X' }
#=> ["X", "X", "O", "X"].each_with_index { |c,j| groups << [[i,j]] if c == 'X' }
#=> ["X", "X", "O", "X"]
so now
groups
#=> [[[1, 0]], [[1, 1]], [[1, 3]]]
A second element of enum
is now generated and passed to the block and the block calculations are performed.
(row,i), groups = enum.next
#=> [[["O", "O", "X", "O"], 2], [[[1, 0]], [[1, 1]], [[1, 3]]]]
row
#=> ["O", "O", "X", "O"]
i #=> 2
groups
#=> [[[1, 0]], [[1, 1]], [[1, 3]]]
row.each_with_index { |c,j| groups << [[i,j]] if c == 'X' }
#=> ["O", "O", "X", "O"]
Now groups
has two elements.
groups
#=> [[[1, 0]], [[1, 1]],
# [[1, 3]], [[2, 2]]]
The remaining calculations are similar.
same_island? method
Suppose
group1 #=> [[0, 0], [1, 0]]
group2 #=> [[0, 1], [1, 1]]
This tells us that [0, 0]
, [1, 0]
are on the same island and that [0, 1]
and [1, 1]
areon the same island, but we do not know as yet whether all four coordinates are on the same island. To determine if that is the case we will look at all pairs of coordinates, where one is from group1
and the other is from group2
. If we compare [0, 0]
and [1, 1]
we cannot conclude they are on the same island (or different islands). However, when we compare [0, 0]
and [0, 1]
, we see they are on the same island (because they are in the same row in adjacent columns), so we infer that all elements of both groups are on the same island. We could then, for example, move the coordinates from group2
to group1
, and eliminate group2
from further consideration.
Now consider the steps performed by the method same_island?
for these two groups.
group1 = [[0, 0], [1, 0]]
group2 = [[0, 1], [1, 1]]
a = group1.product(group2)
#=> [[[0, 0], [0, 1]], [[0, 0], [1, 1]], [[1, 0], [0, 1]],
# [[1, 0], [1, 1]]]
(i1,j1),(i2,j2) = a.first
#=> [[0, 0], [0, 1]]
i1 #=> 0
j1 #=> 0
i2 #=> 0
j2 #=> 1
b = i1==i2 && (j1-j2).abs == 1
#=> true
c = j1==j2 && (i1-i2).abs == 1
#=> false
b || c
#=> true
The first pair of coordinates considered have been found to be on the same island. (In fact, the calculation of c
would not be performed because b
was found to be true
.)
find_islands
commented
To show how everything fits together, I will add comments to the method find_islands
.
def find_islands(input)
groups = input.each_with_index.with_object([]) { |(row,i),groups|
row.each_with_index { |c,j| groups << [[i,j]] if c == 'X' } }
islands = []
# the following is the same as: until groups.empty?
while groups.any?
# pop removes and returns last element of `groups`. `groups` is modified
last_group = groups.pop
# iterate over indices of `groups` looking for one whose members
# are on the same island as last_group
idx = groups.each_index.find { |idx| same_island?(groups[idx], last_group) }
if idx.nil?
# last_group is an island, so append it to islands
islands << last_group
else
# groups[idx] members are found to be on the same island as last_group,
# so add last_group to group groups[idx]
groups[idx] += last_group
end
end
# sort coordinates in each island, to improve presentation
islands.map(&:sort)
end
1 That is, there is no element groups[i,j]
for which either [4, 3]
or [5, 3]
are in the same column and one row apart or in in the same row one column apart.
2 Instance methods in the module Kernel
are documented in the class Object
. See the first two paragraphs at Kernel and second paragraph at Object.