Ryan King
Ryan King

Reputation: 3696

Ruby / Rails Group Array Based on Comparing Adjacent Elements

I have a sorted array full of objects which I'd like to group by comparing them to their surrounding elements.

a is sorted by the start attribute.

 a = [{ name: "joe",   start: "9am",  end: "10am" },
      { name: "joe",   start: "10am", end: "11am" },
      { name: "harry", start: "11am", end: "12pm" },
      { name: "harry", start: "12pm", end: "1pm"  },
      { name: "harry", start: "2pm",  end: "3pm"  },
      { name: "joe",   start: "3pm",  end: "4pm"  },
      { name: "joe",   start: "4pm",  end: "5pm"  }]

I would like to group adjacent objects by the name attribute but only if the start and end times are the same, producing:

    a = [[{ name: "joe",   start: "9am",  end: "10am" }, { name: "joe",   start: "10am", end: "11am" }],
         [{ name: "harry", start: "11am", end: "12pm" }, { name: "harry", start: "12pm", end: "1pm"  }],
         [{ name: "harry", start: "2pm",  end: "3pm"  }],
         [{ name: "joe",   start: "3pm",  end: "4pm"  }, { name: "joe",   start: "4pm",  end: "5pm"  }]]

There is no maximum to the amount of consecutive time periods.


I can group them by name if adjacent as seen here: Ruby / Rails Groups only Adjacent Array Elements

a.chunk { |hash| hash[:name] }.map(&:last)

But it doesn't appear as though I can get the element index with chunk to do the start end time comparisons.


It looks like the answer is here: Grouping an array by comparing 2 adjacent elements

But I'm failing miserably at writing my own function. (I'm struggling to understand what slice_before does.)

def self.group_by(data)
  tmp = data.first
  data.slice_before do |item|
    tmp, prev = item, tmp
    item.application == prev.application &&
      item.start == prev.end
  end.to_a
  return data
end

Any help would be appreciated!

Upvotes: 0

Views: 1049

Answers (3)

EJAg
EJAg

Reputation: 3298

Using slice_before:

y=a[0]
a.slice_before { |hash| x=y 
                        y = hash
                        x[:name] != y[:name] || x[:end] != y[:start]
                        }.to_a

Upvotes: 0

O-I
O-I

Reputation: 1575

Here's one way using Enumerable#sort_by and Enumerable#slice_when. It requires Ruby 2.2+, though.

require 'time' # for sorting times

a = [{ name: "joe",   start: "9am",  end: "10am" },
     { name: "joe",   start: "10am", end: "11am" },
     { name: "harry", start: "11am", end: "12pm" },
     { name: "harry", start: "12pm", end: "1pm"  },
     { name: "harry", start: "2pm",  end: "3pm"  },
     { name: "joe",   start: "3pm",  end: "4pm"  },
     { name: "joe",   start: "4pm",  end: "5pm"  }]

a.sort_by { |h| [ h[:name], Time.parse(h[:start]) ] }                     # 1
 .slice_when { |x, y| x[:end] != y[:start] || x[:name] != y[:name] }.to_a # 2

which yields

=> [[{:name=>"harry", :start=>"11am", :end=>"12pm"}, {:name=>"harry", :start=>"12pm", :end=>"1pm"}],
    [{:name=>"harry", :start=>"2pm", :end=>"3pm"}],
    [{:name=>"joe", :start=>"9am", :end=>"10am"}, {:name=>"joe", :start=>"10am", :end=>"11am"}],
    [{:name=>"joe", :start=>"3pm", :end=>"4pm"}, {:name=>"joe", :start=>"4pm", :end=>"5pm"}]]

Here's a step-by-step explanation with intermediate results:

1) Sort the hashes by name and then by time within name. Note the use of Time.parse to temporarily convert your time string into a Time object for proper sorting:

 => [{:name=>"harry", :start=>"11am", :end=>"12pm"},
     {:name=>"harry", :start=>"12pm", :end=>"1pm"},
     {:name=>"harry", :start=>"2pm", :end=>"3pm"},
     {:name=>"joe", :start=>"9am", :end=>"10am"},
     {:name=>"joe", :start=>"10am", :end=>"11am"},
     {:name=>"joe", :start=>"3pm", :end=>"4pm"},
     {:name=>"joe", :start=>"4pm", :end=>"5pm"}]

2) Now slice this intermediate array when the end time of the former is not equal to the start time of the latter or when the names don't match using Daniel Polfer's solution. This returns an Enumerator object, hence the final to_a method call:

=> [[{:name=>"harry", :start=>"11am", :end=>"12pm"}, {:name=>"harry", :start=>"12pm", :end=>"1pm"}],
    [{:name=>"harry", :start=>"2pm", :end=>"3pm"}],
    [{:name=>"joe", :start=>"9am", :end=>"10am"}, {:name=>"joe", :start=>"10am", :end=>"11am"}],
    [{:name=>"joe", :start=>"3pm", :end=>"4pm"}, {:name=>"joe", :start=>"4pm", :end=>"5pm"}]]

If your hashes are already presorted, then Daniel Polfer's solution should work fine. But if you have any data where names and/or start times are out of order like this:

b = [{:name=>"joe", :start=>"3pm", :end=>"4pm"},
     {:name=>"bill", :start=>"2pm", :end=>"3pm"},
     {:name=>"joe", :start=>"5pm", :end=>"6pm"},
     {:name=>"joe", :start=>"4pm", :end=>"5pm"}] 

Just using slice_when returns

=> [[{:name=>"joe", :start=>"3pm", :end=>"4pm"}],
    [{:name=>"bill", :start=>"2pm", :end=>"3pm"}],
    [{:name=>"joe", :start=>"5pm", :end=>"6pm"}],
    [{:name=>"joe", :start=>"4pm", :end=>"5pm"}]]

instead of

=> [[{:name=>"bill", :start=>"2pm", :end=>"3pm"}],
    [{:name=>"joe", :start=>"3pm", :end=>"4pm"},
     {:name=>"joe", :start=>"4pm", :end=>"5pm"},
     {:name=>"joe", :start=>"5pm", :end=>"6pm"}]]

Upvotes: 2

Daniel Polfer
Daniel Polfer

Reputation: 61

Verbose, but gives result in order you presented, and works with older versions of Ruby...

a.inject([]) do |result,hash| 
  if (!result.empty? && 
      (result.last.last[:name] == hash[:name]) && 
      (result.last.last[:end] == hash[:start]))
    result.last << hash
  else
    result << [hash]
  end
  result
end

Builds the result one hash at a time, choosing to add a hash to the end of the last array or "step" to a new final array entry.


Ruby 2.2+

a.slice_when{|h1,h2| (h1[:name]!=h2[:name]) || (h1[:end]!=h2[:start])}.to_a

Upvotes: 2

Related Questions