Merge hashes that have the same description field, summing the other fields

I have an array of hashes of form:

{ description: 'string', b: float, c: float }


I need to get an array of merged hashes so that if hashes have same value at key description sum values of b and c, (of course keeping value of a unaltered), otherwise (if value of description is unique) just add hash to array.

Current solution:

array_of_hashes.inject([]) do |lines, line|
if lines.none? { |l| l[:description] == line[:description] }
lines << line
else
lines
.find { |l| l[:description] == line[:description] }
.merge!(line) { |key, old_v, new_v| key == :description ? old_v : old_v + new_v }
lines
end
end


I am 100% sure there's a nicer solution.

Besides the code being a little more complex than it needs to be, I see two issues:

• a performance problem: the .find { |l| l[:description] == line[:description] } is called for each element in the array of hashes, leading to an O(n^2) time algorithm (because the array lookup takes linear time)
• a more subtle thing: the initial hashes are mutated by your code. When you do old_v + new_v that modifies the very hashes that have been provided as an input to your function/code. This might not be a problem for you, but I think it's good practice to avoid mutability where possible.

To solve these things, let's use a hash of hashes. We'll have a hash that has the :description values as key (thus allowing to look things up in constant time) and a hash containing the values to be cummulated.

Also, to save the verification of whether a certain key already exists or not, I will use the new {|hash, key| block } syntax when creating the outside hash.

Here's the code:

results = Hash.new { |h, k| h[k] = {b: 0, c: 0} }
array_of_hashes.each do |h|
cummulative_hash = results[h[:description]]
cummulative_hash[:b] += h[:b]
cummulative_hash[:c] += h[:c]
end


At this point, results looks like this:

{"string"=>{:b=>3, :c=>6}, "other_string"=>{:b=>1, :c=>2}}


This can be easily converted to your array format, like this:

results.map{|k, v| {description: k}.merge v}

=> [{:description=>"string", :b=>3, :c=>6},
{:description=>"other_string", :b=>1, :c=>2}]


In the example above I hardcoded the :b and :c keys. For a more generic treatment of the keys to be cummulated, here's the (slightly adjusted) code:

results = Hash.new { |h, k| h[k] = {} }
array_of_hashes.each do |h|
cummulative = results[h[:description]]
h.each do |k, v|
cummulative[k] = (cummulative[k] || 0) + h[k] if k != :description
end
end
results.map{|k, v| {description: k}.merge v}

• Thanks a ton for your answer! cummulative_hash = results[h[:description]] is an awesome thing as well as default proc. My concern here is that I would like to get it done within single cycle of iteration, and both of solution you've provided suggest 2 iterations. – Andrey Deineko Oct 26 '17 at 8:08
• @AndreyDeineko Is it absolutely necessary that the output is an array? If so, I don't know how you can do it in less than two iterations with reasonable performance. If, however, it's acceptable to output a hash, then just use the code without the last line. – Cristian Lupascu Oct 30 '17 at 8:29

Use Enumerable#group_by to divide the array_of_hashes into groups by description. Then replace each group with a single hash that contains the sum.

array_of_hashes = [
{description: 'small', a: 1, b: 0.2, c: 0.3},
{description: 'large', a: 100, b: 200, c: 300},
{description: 'small', a: 4, b: 0.5, c: 0.6},
{description: 'large', a: 400, b: 500, c: 600},
{description: 'unique', a: 'hi', b: true, c: false},
]

if rest.empty?
first
else
first.dup.tap {|sum|
rest.each {|h| sum[:b] += h[:b]; sum[:c] += h[:c]}}
end
}

require 'pp'

[{:description=>"small", :a=>1, :b=>0.7, :c=>0.8999999999999999},

In this code, if a group has only one hash (with a unique description), we just add hash to answer without duplicating the hash or doing sums (0 + true would fail). If a group has more than one hash, we put the sums in a duplicate of the first hash. Unfortunately, the answer doesn't preserve the values of a: 4 and a: 400.