Java8 calculate average of list of objects in the

2019-05-07 02:18发布

问题:

Initial data:

public class Stats {
    int passesNumber;
    int tacklesNumber;

    public Stats(int passesNumber, int tacklesNumber) {
        this.passesNumber = passesNumber;
        this.tacklesNumber = tacklesNumber;
    }

    public int getPassesNumber() {
        return passesNumber;
    }

    public void setPassesNumber(int passesNumber) {
        this.passesNumber = passesNumber;
    }

    public int getTacklesNumber() {
        return tacklesNumber;
    }

    public void setTacklesNumber(int tacklesNumber) {
        this.tacklesNumber = tacklesNumber;
    }
} 

Map<String, List<Stats>> statsByPosition = new HashMap<>();
statsByPosition.put("Defender", Arrays.asList(new Stats(10, 50), new Stats(15, 60), new Stats(12, 100)));
statsByPosition.put("Attacker", Arrays.asList(new Stats(80, 5), new Stats(90, 10)));

I need to calculate an average of Stats by position. So result should be a map with the same keys, however values should be aggregated to single Stats object (List should be reduced to single Stats object)

{
  "Defender" => Stats((10 + 15 + 12) / 3, (50 + 60 + 100) / 3),
  "Attacker" => Stats((80 + 90) / 2, (5 + 10) / 2)
} 

回答1:

I don't think there's anything new in Java8 that could really help in solving this problem, at least not efficiently.

If you look carefully at all new APIs, then you will see that majority of them are aimed at providing more powerful primitives for working on single values and their sequences - that is, on sequences of double, int, ? extends Object, etc.

For example, to compute an average on sequence on double, JDK introduces a new class - DoubleSummaryStatistics which does an obvious thing - collects a summary over arbitrary sequence of double values. I would actually suggest that you yourself go for similar approach: make your own StatsSummary class that would look along the lines of this:

// assuming this is what your Stats class look like:
class Stats {
  public final double a ,b; //the two stats
  public Stats(double a, double b) {
    this.a = a; this.b = b;
  }
}

// summary will go along the lines of:
class StatsSummary implements Consumer<Stats> {
  DoubleSummaryStatistics a, b; // summary of stats collected so far
  StatsSummary() {
    a = new DoubleSummaryStatistics();
    b = new DoubleSummaryStatistics();
  }

  // this is how we collect it:
  @Override public void accept(Stats stat) {
    a.accept(stat.a); b.accept(stat.b);
  }
  public void combine(StatsSummary other) {
    a.combine(other.a); b.combine(other.b);
  }

  // now for actual methods that return stuff. I will implement only average and min
  // but rest of them are not hard
  public Stats average() {
    return new Stats(a.getAverage(), b.getAverage());
  }
  public Stats min() {
    return new Stats(a.getMin(), b.getMin());
  }
}

Now, above implementation will actually allow you to express your proper intents when using Streams and such: by building a rigid API and using classes available in JDK as building blocks, you get less errors overall.

However, if you only want to compute average once somewhere and don't need anything else, coding this class is a little overkill, and here's a quick-and-dirty solution:

Map<String, Stats> computeAverage(Map<String, List<Stats>> statsByPosition) {
  Map<String, Stats> averaged = new HashMap<>();
  statsByPosition.forEach((position, statsList) -> {
    averaged.put(position, averageStats(statsList));
  });
  return averaged;
}

Stats averageStats(Collection<Stats> stats) {
  double a, b;
  int len = stats.size();
  for(Stats stat : stats) {
    a += stat.a;
    b += stat.b;
  }
  return len == 0d? new Stats(0,0) : new Stats(a/len, b/len);
}


回答2:

There is probably a cleaner solution with Java 8, but this works well and isn't too complex:

Map<String, Stats> newMap = new HashMap<>();

statsByPosition.forEach((key, statsList) -> {
    newMap.put(key, new Stats(
         (int) statsList.stream().mapToInt(Stats::getPassesNumber).average().orElse(0),
         (int) statsList.stream().mapToInt(Stats::getTacklesNumber).average().orElse(0))
    );
});

The functional forEach method lets you iterate over every key value pair of your given map.

You just put a new entry in your map for the averaged values. There you take the key you have already in your given map. The new value is a new Stats, where the arguments for the constructor are calculated directly.

Just take the value of your old map, which is the statsList in the forEach function, map the values from the given stats to Integer value with mapToInt and use the average function.

This function returns an OptionalDouble which is nearly the same as Optional<Double>. Preventing that anything didn't work, you use its orElse() method and pass a default value (like 0). Since the average values are double you have to cast the value to int.

As mentioned, there doubld probably be a even shorter version, using reduce.



回答3:

You might as well use custom collector. Let's add the following methods to Stats class:

 public Stats() {

 }

 public void accumulate(Stats stats) {
     passesNumber += stats.passesNumber;
     tacklesNumber += stats.tacklesNumber;
 }

 public Stats combine(Stats acc) {
     passesNumber += acc.passesNumber;
     tacklesNumber += acc.tacklesNumber;
     return this;
 }


 @Override
 public String toString() {
     return "Stats{" +
             "passesNumber=" + passesNumber +
             ", tacklesNumber=" + tacklesNumber +
             '}';
 }

Now we can use Stats in collect method:

System.out.println(statsByPosition.entrySet().stream().collect(
        Collectors.toMap(
            entity -> entity.getKey(),
            entity -> {
                Stats entryStats = entity.getValue().stream().collect(
                        Collector.of(Stats::new, Stats::accumulate, Stats::combine)
                ); // get stats for each map key. 

                // get average
                entryStats.setPassesNumber(entryStats.getPassesNumber() / entity.getValue().size());
                // get average
                entryStats.setTacklesNumber(entryStats.getTacklesNumber() / entity.getValue().size());

            return entryStats;
        }
))); // {Attacker=Stats{passesNumber=85, tacklesNumber=7}, Defender=Stats{passesNumber=12, tacklesNumber=70}}


回答4:

I did not want to post this, but it's too big for a comment. If java-9 is available and StreamEx, you could do :

    public static Map<String, Stats> third(Map<String, List<Stats>> statsByPosition) {

    return statsByPosition.entrySet().stream()
            .collect(Collectors.groupingBy(e -> e.getKey(),
                    Collectors.flatMapping(e -> e.getValue().stream(),
                            MoreCollectors.pairing(
                                    Collectors.averagingDouble(Stats::getPassesNumber),
                                    Collectors.averagingDouble(Stats::getTacklesNumber),
                                    (a, b) -> new Stats(a, b)))));
}