Collectors minBy and maxBy

Introduction

The Collectors class has many static utility methods that return an implementation of a Collector. Some examples are toMapgroupingBypartitioningby, and filtering, flatMapping and teeing. In this post, we will learn about the Collectors minBy and maxBy methods.

Collectors minBy and maxBy

Collectors minBy

Collectors.minBy method returns a Collector that produces the minimal element according to a given Comparator. The minimal element is returned as an Optional.

Method signature:
public static <T> Collector<T, ?, Optional<T>>
        minBy(Comparator<? super T> comparator)
 

Collectors maxBy

Collectors.maxBy method returns a Collector that produces the maximal element according to a given Comparator. The maximal element is returned as an Optional.

Method signature:
public static <T> Collector<T, ?, Optional<T>>
        maxBy(Comparator<? super T> comparator)

There are many helper methods to construct a Comparator. The Comparator comparing methods and Comparator nullsFirst and nullsLast explores them in detail.

Collectors minBy and maxBy on a stream

Let us create a stream out of a list and use Collectors.minBy on it to get the minimum element.

List<String> fruits = List.of("apple", "pear", "orange", "grapes");
Optional<String> minElement = fruits.stream()
        .collect(Collectors.minBy(Comparator.naturalOrder()));
System.out.println(minElement); //Optional[apple]

We use the naturalOrder comparator and hence the minimal element will be the fruit which appears first in the lexicographical sorting/arrangement of the fruit names.

To find the maximal element, use Collectors.maxBy.

Optional<String> maxElement = fruits.stream()
        .collect(Collectors.maxBy(Comparator.naturalOrder()));
System.out.println(maxElement); //Optional[pear]

If used on an empty stream, it returns an empty Optional.

maxElement = List.<String>of().stream()
        .collect(Collectors.maxBy(Comparator.naturalOrder()));
System.out.println(maxElement); //Optional.empty

Since it returns an optional we can chain orElse or orElseThrow on it.

System.out.println(fruits.stream()
        .collect(Collectors.maxBy(Comparator.naturalOrder()))
        .orElse("no-fruit-found"));
        
System.out.println(fruits.stream()
        .collect(Collectors.maxBy(Comparator.naturalOrder()))
        .orElseThrow(() -> new RuntimeException("No fruit found")));

Using other comparators

Let us get the smallest and largest fruits in terms of the fruit name (length).

Optional<String> shortestFruit = fruits.stream()
        .collect(Collectors.minBy(Comparator.comparingInt(String::length)));
System.out.println(shortestFruit); //Optional[pear]

Comparator.comparingInt(String::length) arranges the elements from shortest to the longest. Hence, minBy returns ‘pear’.

Optional<String> longestFruit = fruits.stream()
        .collect(Collectors.maxBy(Comparator.comparingInt(String::length)
                .thenComparing(Comparator.reverseOrder())));
System.out.println(longestFruit); //Optional[grapes]

The above comparator has a thenComparing clause to reverse sort the fruits if their lengths are same. Hence, orange will come before grapes. Hence, grapes is the result.

Difference between Collectors.minBy, maxBy and Stream min() and max()

Some IDEs (Intellij is one of them) give a warning or suggestion to replace the above style (using Collectors.minBy or maxBy on a collect) with min() or max() on the stream itself.
It will look like:

minElement = fruits.stream()
        .min(Comparator.naturalOrder());
System.out.println(minElement); //Optional[apple]

maxElement = fruits.stream()
        .max(Comparator.naturalOrder());
System.out.println(maxElement); //Optional[pear]

longestFruit = fruits.stream()
        .max(Comparator.comparingInt(String::length)
                .thenComparing(Comparator.reverseOrder()));
System.out.println(longestFruit); //Optional[grapes]

Here we pass the Comparator to the min and the max method on the stream itself to get the same result.

However, there is a caveat as there is a change in the semantics between using Collectors.minBy or maxBy and Stream min and max when the input has nulls in them.

Stream with nulls

When the input has nulls in them, the Stream min() and max() method could throw a NullPointerException.

fruits = Arrays.asList("apple", "pear", "orange", "grapes", null);

//throws java.lang.NullPointerException
minElement = fruits.stream()
         .min(Comparator.naturalOrder());
System.out.println(minElement);

There is a Comparator utility method called nullsFirst and nullsLast which helps in comparing null and non-null values. Let us use nullsFirst comparator to make null less than any non-null value.

minElement = fruits.stream()
        .min(Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println(minElement);

Unfortunately, even the above code throws a NullPointerException. The Stream’s min or the max does not allow the result to be null. 

There are three options we can use as a workaround.

Option #1 – Filter the nulls

Filter out the nulls from the stream before calling min/max.

minElement = fruits.stream()
        .filter(Objects::nonNull)
        .min(Comparator.naturalOrder());
System.out.println(minElement); //Optional[apple]

Option #2 – Move the null to the other end

This is sort of a trick. The Stream’s min method throws a NullPointerException only when the result is null. So, if we want the minimum element, we can use Comparator.nullsLast to sort the nulls at the end. Thus, the minimum result is a non-null value. 

minElement = fruits.stream()
        .min(Comparator.nullsLast(Comparator.naturalOrder()));
System.out.println(minElement); //Optional[apple]

But this does not work if all the elements in the list are null. This works only if there is at least one non-null value.

To summarize, when the stream has nulls and using nullsFirst when finding the min and nullsLast when finding max will result in a NullPointerException
//both throw NullPointerException
.max(Comparator.nullsLast(Comparator.naturalOrder()));
.min(Comparator.nullsFirst(Comparator.naturalOrder()));

To avoid it, we could swap them around i.e., use nullsLast when finding min and nullsFirst when finding the max.

Option #3 – Use Collectors.minBy/maxBy

If the input can have null, we have to use Collectors minBy/maxBy (back to where we have started).

minElement = fruits.stream()
        .collect(Collectors.minBy(Comparator.nullsLast(Comparator.naturalOrder())));
System.out.println(minElement); //Optional[apple]

Important: If we use Comparator.nullsFirst and find the min, it returns an empty optional (and not a null).

minElement = fruits.stream()
        .collect(Collectors.minBy(Comparator.nullsFirst(Comparator.naturalOrder())));
System.out.println(minElement); //Optional.empty

Collectors minBy and maxBy with Collectors groupingBy

Let us say we have an Employee object shown below. Each employee has a name, city, age and salary.

public class Employee{
    private String name;
    private String city;
    private Integer age;
    private Double salary;

    public Employee(String name, String city, Integer age, Double salary) {
        this.name = name;
        this.city = city;
        this.age = age;
        this.salary = salary;
    }

    public String getName() {
        return name;
    }

    public String getCity() {
        return city;
    }

    public Integer getAge() {
        return age;
    }

    public Double getSalary() {
        return salary;
    }

    @Override
    public String toString() {
        return name;
    }
}

There is a list of employees.

List<Employee> employees = List.of(
        new Employee("Mike", "London", 34, 12_000D),
        new Employee("Harry", "Paris", 28, 9000D),
        new Employee("Joe", "Berlin", 28, 13_000D),
        new Employee("Perry", "London", 23, 10_500D),
        new Employee("Josh", "London", 32, 11_500D),
        new Employee("Ron", "Berlin", 35, 12_000D));

Let us say we have to find the youngest employee in each city. We can use Collectors groupingBy to group by the city. We use Collectors.minBy as a downstream collector to pick the employee with the smallest value for age (youngest employee).

Map<String, Optional<Employee>> youngestEmployeesByCity = employees.stream()
        .collect(Collectors.groupingBy(Employee::getCity,
                Collectors.minBy(Comparator.comparingInt(Employee::getAge))));
System.out.println(youngestEmployeesByCity);

This produces the result:

{Berlin=Optional[Joe], London=Optional[Perry], Paris=Optional[Harry]}

Finding minimum/maximum when handling nulls

Let us say that the employees can have a null age.

List<Employee> employeesWithNullAge = new ArrayList<>(employees);
employeesWithNullAge.add(new Employee("Smith", "Cape Town", null, 12_500D));
employeesWithNullAge.add(new Employee("Paul", "Cape Town", 21, 10_000D));

If we use the above code, it results in a NullPointerException at the comparingInt method as a null cannot be unboxed to an int. Using comparing would also not work as those Comparators cannot handle null.

Comparator nullsLast isn’t helpful as well as they work only if the input is null (not when the mapped result of the underlying comparator is null) and hence the below code doesn’t work.
//Doesn't work
employeesWithNullAge.stream()
        .collect(Collectors.groupingBy(Employee::getCity,
                Collectors.minBy(Comparator.nullsLast(Comparator
                        .comparingInt(Employee::getAge)))));

The solution is to use Comparator.comparing to map the employee to the age (Integer which may be null) and use Comparator.nullsLast comparator on the mapped result.

Map<String, Optional<Employee>> youngestEmployeesByCity =
       employeesWithNullAge.stream()
               .collect(Collectors.groupingBy(Employee::getCity,
                       Collectors.minBy(
                               Comparator.comparing(Employee::getAge,
                                       Comparator.nullsLast(Comparator.naturalOrder()))
                               )));

This outputs,

{Cape Town=Optional[Paul], Berlin=Optional[Joe], London=Optional[Perry], Paris=Optional[Harry]}

Note that we get Paul for Cape Town because we made null greater than any non-null value (nullsLast). If we had used nullsFirst, we would get Smith as the first (smallest) element.

youngestEmployeesByCity = employeesWithNullAge.stream()
        .collect(Collectors.groupingBy(Employee::getCity,
                Collectors.minBy(
                        Comparator.comparing(Employee::getAge,
                                Comparator.nullsFirst(Comparator.naturalOrder()))
                )));
System.out.println(youngestEmployeesByCity);
{Cape Town=Optional[Smith], Berlin=Optional[Joe], London=Optional[Perry], Paris=Optional[Harry]}

Collectors minBy and maxBy with Collectors partitioningBy

Now we will partition the employees into two sets – those with salary less than 12,000 and those with salary at least 12000 and find the youngest employees in both.

Map<Boolean, Optional<Employee>> result = employees.stream()
        .collect(Collectors.partitioningBy(e -> e.getSalary() >= 12000,
                Collectors.minBy(Comparator.comparing(Employee::getAge,
                        Comparator.nullsLast(Comparator.naturalOrder())))));
System.out.println(result);

This outputs, 

{false=Optional[Perry],true=Optional[Joe]}

Perry is the youngest employee among those with salary less than 12000, and Joe is the youngest with salary greater than or equal to 12000.

Conclusion

In this post we saw the Collector minBy and maxBy methods. First we learnt the method signature of minBy and maxBy. Second we used it on a stream to find the minimum and maximum element. Third, we learnt the differences between Collector minBy and maxBy when compared to Stream’s min and max methods. Finally, we used minBy/maxBy on the groupingby and partitioningBy Collectors.

This post builds on top of the Comparator knowledge. So, if you haven’t read the posts on Comparator do check them out.

Useful references

Leave a Reply