Как эффективно вычислить максимальное значение коллекции после применения некоторой функции
Предположим, что у вас есть такой метод, который вычисляет максимум Collection
для некоторого ToIntFunction
:
static <T> void foo1(Collection<? extends T> collection, ToIntFunction<? super T> function) {
if (collection.isEmpty())
throw new NoSuchElementException();
int max = Integer.MIN_VALUE;
T maxT = null;
for (T t : collection) {
int result = function.applyAsInt(t);
if (result >= max) {
max = result;
maxT = t;
}
}
// do something with maxT
}
С Java 8 это можно перевести в
static <T> void foo2(Collection<? extends T> collection, ToIntFunction<? super T> function) {
T maxT = collection.stream()
.max(Comparator.comparingInt(function))
.get();
// do something with maxT
}
Недостатком новой версии является то, что function.applyAsInt
вызывается повторно для одного и того же значения T
. (В частности, если коллекция имеет размер n
, foo1
вызывает applyAsInt
n
раз, тогда как foo2
вызывает его 2n - 2
раз).
Недостатки первого подхода заключаются в том, что код менее ясен, и вы не можете его модифицировать для использования parallelism.
Предположим, вы хотели сделать это, используя параллельные потоки и только для вызова applyAsInt
один раз для каждого элемента. Может ли это быть написано простым способом?
Ответы
Ответ 1
Вы можете использовать пользовательский коллекционер, который будет поддерживать пару максимального значения и максимального элемента:
static <T> void foo3(Collection<? extends T> collection, ToIntFunction<? super T> function) {
class Pair {
int max = Integer.MIN_VALUE;
T maxT = null;
}
T maxT = collection.stream().collect(Collector.of(
Pair::new,
(p, t) -> {
int result = function.applyAsInt(t);
if (result >= p.max) {
p.max = result;
p.maxT = t;
}
},
(p1, p2) -> p2.max > p1.max ? p2 : p1,
p -> p.maxT
));
// do something with maxT
}
Одно из преимуществ заключается в том, что это создает отдельный промежуточный объект Pair
, который используется в процессе сбора данных. Каждый раз, когда элемент принимается, этот держатель обновляется с новым максимумом. Операция финишера возвращает максимальный элемент и отбрасывает максимальное значение.
Ответ 2
Как я уже говорил в комментариях, я предлагаю ввести промежуточную структуру данных, например:
static <T> void foo2(Collection<? extends T> collection, ToIntFunction<? super T> function) {
if (collection.isEmpty()) {
throw new IllegalArgumentException();
}
class Pair {
final T value;
final int result;
public Pair(T value, int result) {
this.value = value;
this.result = result;
}
public T getValue() {
return value;
}
public int getResult() {
return result;
}
}
T maxT = collection.stream().map(t -> new Pair(t, function.applyAsInt(t)))
.max(Comparator.comparingInt(Pair::getResult)).get().getValue();
// do something with maxT
}
Ответ 3
Другим способом было бы использовать memoized версию function
:
static <T> void foo2(Collection<? extends T> collection,
ToIntFunction<? super T> function, T defaultValue) {
T maxT = collection.parallelStream()
.max(Comparator.comparingInt(ToIntMemoizer.memoize(function)))
.orElse(defaultValue);
// do something with maxT
}
Где ToIntMemoizer.memoize(function)
код будет выглядеть следующим образом:
public class ToIntMemoizer<T> {
private final Map<T, Integer> cache = new ConcurrentHashMap<>();
private ToIntMemoizer() {
}
private ToIntFunction<T> doMemoize(ToIntFunction<T> function) {
return input -> cache.computeIfAbsent(input, function::apply);
}
public static <T> ToIntFunction<T> memoize(ToIntFunction<T> function) {
return new ToIntMemoizer<T>().doMemoize(function);
}
}
Это использует ConcurrentHashMap
для кэширования уже вычисленных результатов. Если вам не нужно поддерживать parallelism, вы можете отлично использовать HashMap
.
Одним из недостатков является то, что результат функции должен быть помещен в коробку/распакован. С другой стороны, поскольку функция запоминается, результат будет вычисляться только один раз для каждого повторяющегося элемента коллекции. Затем, если функция вызывается с повторным входным значением, результат будет возвращен из кеша.
Ответ 4
Если вы не возражаете против использования сторонней библиотеки, StreamEx оптимизирует все эти случаи в специальных методах, таких как maxByInt
и так далее. Поэтому вы можете просто использовать:
static <T> void foo3(Collection<? extends T> collection, ToIntFunction<? super T> function) {
T maxT = StreamEx.of(collection).parallel()
.maxByInt(function)
.get();
// do something with maxT
}
В реализации используется reduce
с изменяемым контейнером. Это, вероятно, немного нарушает API, но отлично работает для последовательных и параллельных потоков и в отличие от решения collect
откладывает выделение контейнера на первый накопленный элемент (таким образом, никакой контейнер не выделяется, если параллельная подзадача не покрывает элементы, которые встречаются довольно часто, если у вас есть фильтрация вверх по течению).