0

I am struggling with my algorithm for the following question:

Given a rectangular matrix which has only two possible values ‘X’ and ‘O’. The values ‘X’ always appear in form of rectangular islands and these islands are always row-wise and column-wise separated by at least one line of ‘O’s. Count the number of islands in the given matrix.

I have generated a solution however my loop is not going through the entire matrix. I have tried initializing j to 0 in the i loop but that throws an error. I am new to the subject and I really want to try to understand why my code is not working.

def matrix(input)
row = input.length
col = input[0].length
counter = 0
i = 0
j = 0
while i < row do 
  while j < col do 
  if input[i][j] == "X"
    if i == 0 || input[i-1][j] == "O" && j = 0 || input[i][j-1] == "O"
      counter += 1
    end 
  end 
  j += 1
 end
i += 1
end
  p counter
end 

matrix(
[
  ["X", "X", "O", "X"],
  ["X", "X", "O", "X"],
  ["O", "O", "X", "O"],
  ["O", "O", "X", "O"],
  ["O", "O", "O", "X"],
  ["O", "O", "O", "X"]
]
)
# expected output is 4

This is NOT homework. I am practicing data structures and algorithms. The original question can be found here

Natasha Kelly
  • 73
  • 1
  • 12
  • It doesn't go through the complete matrix because `j` value gets to `4` during first `i` iteration, and once it reaches that value, the `while j < col` is not executed anymore (so basically the second `while` is only executed once). Can you spot why is this happening? Hint: check _where_ you are defining `j`. – Gerry Aug 13 '17 at 02:26
  • @Gerry so if j-while loop is never being executed then i need to initialize j=0 before that line. Thats what I am assuming... – Natasha Kelly Aug 13 '17 at 02:42
  • @Gerry I tried the following and got an 8 for the output def matrix(input) row = input.length col = input[0].length counter = 0 i = 0 while i < row do j = 0 while j < col do if input[i][j] == "X" if i == 0 || input[i-1][j] == "O" && j == 0 || input[i][j-1] == "O" counter += 1 end end j += 1 end i += 1 end p counter end – Natasha Kelly Aug 13 '17 at 02:47
  • You assumed correctly: `j` iterates the column, so you must reset it (i.e. `j = 0`) for each row (i.e. each `i` iteration). Now you are getting `8` because there is some logic _off_ in `if i == 0 || input[i-1][j] == "O" && j == 0 || input[i][j-1] == "O"`, try printing the output of `i` and `j` every time you add `1` to `counter` to spot _when_ you are adding to `counter`. Pay attention to `input[i-1][j]` and `input[i][j-1]`, they may not behave the way you expect (i.e. `input[-1]` will return _the last row_). – Gerry Aug 13 '17 at 03:49
  • Also `j = 0` should be `j == 0`. – Gerry Aug 13 '17 at 03:50
  • @gerry I tried every possible combination. Ultimately, the intended behavior is to check if the cell above and to the left are O. I believe my starting pointers are in the wrong position. When I added the following line, puts "#{input[i-1][j]} #{input[i][j-1]}" after the counter incrementor, i got an output of 6 O X O X O O X O O O X O – Natasha Kelly Aug 13 '17 at 17:13

1 Answers1

2

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.

Cary Swoveland
  • 106,649
  • 6
  • 63
  • 100
  • thank you for taking the time to explain. I am stuck on the part where you iterate the groups array to find the "islands". Conceptually I understand how you got the 4 remaining groups, I just don't quite understand how you implemented the logic – Natasha Kelly Aug 13 '17 at 21:39
  • Natasha, I've done some editing (and made one correction, replacing `zip` with `product`). Did this help you understand how the methods work? – Cary Swoveland Aug 14 '17 at 06:45