Stream - Collect by property and max

2020-04-07 02:31发布

Problem Statement

Given the following class (simplified for the question):

public static class Match {

  private final String type;
  private final int score;

  public Match(String type, int score) {
    this.type = type;
    this.score = score;
  } 

  public String getType() {
    return type;
  }

  public int getScore() {
    return score;
  }
}

I have a Stream<Match> that contains multiple instances of the class, the same type appears multiple times, but with different scores:

Stream.of(new Match("A", 1), new Match("A", 2), new Match("A", 4), new Match("A", 10),
          new Match("B", 3), new Match("B", 6), new Match("B", 12),
          new Match("C", 1));

I now want to collect the stream so that the result is a List<Match> containing only the instances with the highest score of each type.

What I tried

The following code is working, but I am not sure if it is the "optimal" solution (aside from the horrible reading and formatting):

.collect(Collectors.collectingAndThen(
          Collectors.groupingBy(Match::getType, Collectors.collectingAndThen(
              Collectors.toList(),
              l -> l.stream().max(Comparator.comparing(Match::getScore)).get())), Map::values))
      .forEach(m -> System.out.println(m.getType() + ": " + m.getScore()));

and:

.collect(Collectors.collectingAndThen(
          Collectors.groupingBy(Match::getType, Collectors.maxBy(Comparator.comparing(Match::getScore))), Map::values))
      .forEach(m -> m.ifPresent(ma -> System.out.println(ma.getType() + ": " + ma.getScore())));

Output (correct):

A: 10
B: 12
C: 1


Additionally I was not able to extract a generic, static method returning a collector so that I can simply use it where I need in a way like:
.collect(distinctMaxByProperty(Match::getType, Match::getScore)

Any help would be greatly appreciated!

3条回答
不美不萌又怎样
2楼-- · 2020-04-07 02:58

I have two methods for you.

First Method: Collectors.toMap()

A possible implementation is to use Collectors.toMap() to answers your problem.

stream.collect(Collectors.toMap(Match::getType, Match::getScore, Math::max));

And if you prefer to get a List<Match>(), you can remap the result

stream
    .collect(Collectors.toMap(Match::getType, Match::getScore, Math::max))
    .entrySet()
    .stream()
    .map(e -> new Match(e.getKey(), e.getValue()))
    .collect(Collectors.toList());

Second Method: Custom Collector

Like you say in your question, it is possible to extract this logic in a custom collector. You can do it like this:

public class MaxMatch implements Collector<Match, Map<String, Integer>, List<Match>> {
    @Override
    public Supplier<Map<String, Integer>> supplier() {
        return HashMap::new;
    }

    @Override
    public BiConsumer<Map<String, Integer>, Match> accumulator() {
        return (map, match) -> {
            Integer score = match.getScore();
            if(map.containsKey(match.getType())) {
                score = Math.max(score, map.get(match.getType()));
            }
            map.put(match.getType(), score);
        };
    }

    @Override
    public BinaryOperator<Map<String, Integer>> combiner() {
        return (mapA, mapB) -> {
            mapA.forEach((k, v) -> {
                if(mapB.containsKey(k)) { mapB.put(k, Math.max(v, mapB.get(k))); }
                else { mapB.put(k, v); }
            });
            return mapB;
        };
    }

    @Override
    public Function<Map<String, Integer>, List<Match>> finisher() {
        return (map) -> map.entrySet().stream().map(e -> new Match(e.getKey(), e.getValue())).collect(Collectors.toList());
    }

    @Override
    public Set<Characteristics> characteristics() {
        return Collections.emptySet();
    }
}

and use it like this:

stream.collect(new MaxMatch());

Hope this help you :)

查看更多
冷血范
3楼-- · 2020-04-07 03:10

Don’t collect into a List, just to extract one value, when you can collect the maximum element in the first place, e.g.

Map<String,Match> result =
    Stream.of(new Match("A", 1), new Match("A", 2), new Match("A", 4), new Match("A", 10),
              new Match("B", 3), new Match("B", 6), new Match("B", 12), new Match("C", 1))
        .collect(Collectors.groupingBy(Match::getType, Collectors.collectingAndThen(
            Collectors.reducing(BinaryOperator.maxBy(
                                    Comparator.comparingInt(Match::getScore))),
            Optional::get)));

But whenever you encounter the necessity to extract an Optional in the context of groupingBy, it’s worth checking whether toMap` with merge function can give a simpler result:

Map<String,Match> result =
    Stream.of(new Match("A", 1), new Match("A", 2), new Match("A", 4), new Match("A", 10),
              new Match("B", 3), new Match("B", 6), new Match("B", 12), new Match("C", 1))
        .collect(Collectors.toMap(Match::getType, Function.identity(),
                 BinaryOperator.maxBy(Comparator.comparingInt(Match::getScore))));

Once you have the Map you can produce your desired output via

result.values().forEach(m -> System.out.println(m.getType() + ": " + m.getScore()));

But if you don’t need the actual Match instances, you can do it even simpler:

Stream.of(new Match("A", 1), new Match("A", 2), new Match("A", 4), new Match("A", 10),
          new Match("B", 3), new Match("B", 6), new Match("B", 12), new Match("C", 1))
    .collect(Collectors.toMap(Match::getType, Match::getScore, Math::max))
    .forEach((type,score) -> System.out.println(type + ": " + score));
查看更多
不美不萌又怎样
4楼-- · 2020-04-07 03:10

Try to use a TreeMap for this :

UPDATE

List<Match> matchStream1 = matchStream.
            collect(Collectors.groupingBy(Match::getType,
                    Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(Match::getScore)))))
            .values()
            .stream()
            .map(TreeSet::last)
            .collect(Collectors.toList());

In case your Match class implements Comparable. You can simplify this:

() -> new TreeSet<>(Comparator.comparing(Match::getScore))

to this:

TreeSet::new
查看更多
登录 后发表回答