用 Java 計算加權平均值
一、簡介
在本文中,我們將探討解決相同問題的幾種不同方法 - 計算一組值的加權平均值。
2. 什麼是加權平均值?
我們透過將所有數字相加然後除以數字的數量來計算一組數字的標準平均值。例如,數字 1、3、5、7、9 的平均值將為 (1 + 3 + 5 + 7 + 9) / 5,等於 5。
當我們計算加權平均值時,我們有一組數字,每個數字都有重量:
數位 | 重量 |
1 | 10 |
3 | 20 |
5 | 30 |
7 | 50 |
9 | 40 |
在這種情況下,我們需要考慮權重。新的計算方法是將每個數字與其權重的乘積相加,然後除以所有權重的總和。例如,這裡的平均值將為 ((1 * 10) + (3 * 20) + (5 * 30) + (7 * 50) + (9 * 40)) / (10 + 20 + 30 + 50 + 40 ),等於6.2。
3. 設定
為了這些範例,我們將進行一些初始設定。最重要的是我們需要一種類型來表示我們的權重值:
private static class Values {
int value;
int weight;
public Values(int value, int weight) {
this.value = value;
this.weight = weight;
}
}
在我們的範例程式碼中,我們還將獲得一組初始值和平均值的預期結果:
private List<Values> values = Arrays.asList(
new Values(1, 10),
new Values(3, 20),
new Values(5, 30),
new Values(7, 50),
new Values(9, 40)
);
private Double expected = 6.2;
4. 二次計算
最明顯的計算方法正如我們上面所看到的。我們可以迭代數字列表並分別對除法所需的值求和:
double top = values.stream()
.mapToDouble(v -> v.value * v.weight)
.sum();
double bottom = values.stream()
.mapToDouble(v -> v.weight)
.sum();
完成此操作後,我們的計算現在只是一個除以另一個的情況:
double result = top / bottom;
我們可以透過使用傳統的for
迴圈來進一步簡化這一點,並在進行過程中進行兩個求和。這裡的缺點是結果不能是不可變的值:
double top = 0;
double bottom = 0;
for (Values v : values) {
top += (v.value * v.weight);
bottom += v.weight;
}
5. 擴大清單
我們可以用不同的方式來考慮加權平均計算。我們可以擴展每個加權值,而不是計算乘積總和。例如,我們可以擴展清單以包含 10 個「1」副本、20 個「2」副本,依此類推。此時,我們可以對擴展列表進行直接平均:
double result = values.stream()
.flatMap(v -> Collections.nCopies(v.weight, v.value).stream())
.mapToInt(v -> v)
.average()
.getAsDouble();
這顯然會降低效率,但也可能更清晰、更容易理解。我們還可以更輕鬆地對最終一組數字進行其他操作 - 例如,透過這種方式找到中位數更容易理解。
6. 減少清單
我們已經看到,對乘積和權重求和比嘗試展開值更有效。但是,如果我們想在一次傳遞中完成此操作而不使用可變值怎麼辦?我們可以使用 Streams 中的reduce()
功能來實現這一點。特別是,我們將使用它來執行加法,將運行總計收集到一個物件中。
我們想要的第一件事是一個將運行總計收集到的類別:
class WeightedAverage {
final double top;
final double bottom;
public WeightedAverage(double top, double bottom) {
this.top = top;
this.bottom = bottom;
}
double average() {
return top / bottom;
}
}
我們也包含了一個average()
函數來完成我們的最終計算。現在,我們可以執行縮減操作:
double result = values.stream()
.reduce(new WeightedAverage(0, 0),
(acc, next) -> new WeightedAverage(
acc.top + (next.value * next.weight),
acc.bottom + next.weight),
(left, right) -> new WeightedAverage(
left.top + right.top,
left.bottom + right.bottom))
.average();
這看起來很複雜,所以讓我們把它分成幾個部分。
reduce()
的第一個參數是我們的身分。這是值為 0 的加權平均值。
下一個參數是一個 lambda,它採用WeightedAverage
實例並將下一個值加入其中。我們會注意到,這裡的總和的計算方式與我們之前執行的方式相同。
最後一個參數是用來組合兩個WeightedAverage
實例的 lambda。這對於使用reduce()
的某些情況是必要的,例如我們在並行流上執行此操作。
然後, reduce()
呼叫的結果是一個WeightedAverage
實例,我們可以用它來計算結果。
7. 定制收集器
我們的reduce()
版本當然是乾淨的,但它比我們的其他嘗試更難理解。我們最終將兩個 lambda 傳遞到函數中,並且仍然需要執行後處理步驟來計算平均值。
我們可以探索的最後一個解決方案是編寫一個自訂收集器來封裝這項工作。這將直接產生我們的結果,並且使用起來會更簡單。
在編寫收集器之前,我們先來看看需要實現的介面:
public interface Collector<T, A, R> {
Supplier<A> supplier();
BiConsumer<A, T> accumulator();
BinaryOperator<A> combiner();
Function<A, R> finisher();
Set<Characteristics> characteristics();
}
這裡發生了很多事情,但我們將在構建收集器時解決它。我們還將看到這種額外的複雜性如何允許我們在並行流上而不是僅在順序流上使用完全相同的收集器。
首先要注意的是泛型類型:
-
T
– 這是輸入類型。我們的收集器始終需要與其可以收集的值的類型相關聯。 -
R
– 這是結果類型。我們的收集器總是需要指定它將產生的類型。 -
A
– 這是聚合類型。這通常是收集器內部的,但對於某些函數簽名是必需的。
這意味著我們需要定義一個聚合類型。這只是一種收集運行結果的類型。我們不能直接在收集器中執行此操作,因為我們需要能夠支援並行流,其中可能同時發生未知數量的平行流。因此,我們定義一個單獨的類型來儲存每個並行流的結果:
class RunningTotals {
double top;
double bottom;
public RunningTotals() {
this.top = 0;
this.bottom = 0;
}
}
這是一種可變類型,但因為它的使用僅限於一個並行流,所以沒關係。
現在,我們可以實作我們的收集器方法。我們會注意到大多數都返回 lambda。同樣,這是為了支援並行流,其中底層流框架將根據需要調用它們的某種組合。
第一個方法是supplier()
.
這將會建構一個新的、零的RunningTotals
實例:
@Override
public Supplier<RunningTotals> supplier() {
return RunningTotals::new;
}
接下來,我們有accumulator()
。這需要一個RunningTotals
實例和下一個Values
實例來處理並組合它們,從而就地更新我們的RunningTotals
實例:
@Override
public BiConsumer<RunningTotals, Values> accumulator() {
return (current, next) -> {
current.top += (next.value * next.weight);
current.bottom += next.weight;
};
}
我們的下一個方法是combiner()
。這需要兩個來自不同平行流的RunningTotals
實例,並將它們合併為一個:
@Override
public BinaryOperator<RunningTotals> combiner() {
return (left, right) -> {
left.top += right.top;
left.bottom += right.bottom;
return left;
};
}
在這種情況下,我們正在改變我們的輸入之一並直接返回它。這是完全安全的,但如果更容易的話我們也可以回傳一個新實例。
只有當 JVM 決定將流處理拆分為多個平行流時才會使用此方法,這取決於多個因素。但是,我們應該實施它,以防這種情況發生。
我們需要實作的最後一個 lambda 方法是finisher()
。這將會取得所有值累積完畢且所有並行流合併後剩下的最終RunningTotals
實例,並傳回最終結果:
@Override
public Function<RunningTotals, Double> finisher() {
return rt -> rt.top / rt.bottom;
}
我們的Collector
還需要一個characteristics()
方法,傳回一組描述如何使用收集器的特徵。 Collectors.Characteristics
枚舉包含三個值:
-
CONCURRENT
–accumulator()
函數可以安全地從平行執行緒呼叫相同聚合實例。如果指定了這一點,則永遠不會使用combiner()
函數,但aggregation()
函數必須格外小心。 -
UNORDERED
– 收集器可以以任何順序安全地處理底層流中的元素。如果未指定,則在可能的情況下,將以正確的順序提供值。 -
IDENTITY_FINISH
–finisher()
函數直接傳回其輸入。如果指定了這一點,那麼收集過程可能會短路此呼叫並直接傳回值。
在我們的例子中,我們有一個UNORDERED
收集器,但需要省略其他兩個:
@Override
public Set<Characteristics> characteristics() {
return Collections.singleton(Characteristics.UNORDERED);
}
我們現在準備好使用我們的收集器:
double result = values.stream().collect(new WeightedAverage());
雖然編寫收集器比以前複雜得多,但使用它卻要容易得多。我們還可以利用並行流之類的東西,而無需額外的工作,這意味著這為我們提供了一個更易於使用且更強大的解決方案(假設我們需要重複使用它)。
八、結論
在這裡,我們看到了計算一組值的加權平均值的幾種不同方法,從簡單地自行循環這些值到編寫一個完整的Collector
實例,只要我們需要執行此計算,就可以重複使用該實例。下次您需要這樣做時,為什麼不嘗試其中一個呢?
與往常一樣,本文的完整程式碼可以在 GitHub 上找到。