CodeGym /課程 /JAVA 25 SELF /groupingBy 與 partitioningBy 方法(Collectors)

groupingBy 與 partitioningBy 方法(Collectors)

JAVA 25 SELF
等級 31 , 課堂 2
開放

1. 前言

在實務中幾乎總是需要對資料進行分組。例如:把學生依年級分組,把商品依類別歸類,把已成年與未成年分開,等等。

沒有 Stream API 時,這類任務通常得手動完成:遍歷集合、檢查條件,並把元素加入 Map 中對應的清單。下面是典型的「老派」程式碼:

Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
    String dept = employee.getDepartment();
    byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}

能運作,但就像你在手動把義大利麵分裝到不同罐子裡。若需要依多個條件分組呢?或者還要對各組求和?程式碼會急速膨脹且難以閱讀。

Stream API 與專用收集器(groupingBypartitioningBy)能讓你用一兩行就搞定——看起來就像真正的 Java 魔法師。

2. 收集器 groupingBy:依鍵值分組

核心概念

groupingBy 是一個收集器,會把元素串流轉換為 Map:鍵是分組鍵函式的回傳值,值是符合該鍵的元素清單。

方法簽章:

Collectors.groupingBy(Function<T, K>)
  • T — 串流中元素的型別,
  • K — 分組鍵(群組)的型別,即函式的回傳型別。

簡單範例:依部門分組員工

假設有以下類別:

public class Employee {
    private final String name;
    private final String department;
    // ... 建構子與 getter
    public Employee(String name, String department) {
        this.name = name;
        this.department = department;
    }
    public String getName() { return name; }
    public String getDepartment() { return department; }
}

以及一個員工集合:

List<Employee> employees = List.of(
    new Employee("Alisa", "IT"),
    new Employee("Bob", "HR"),
    new Employee("Klara", "IT"),
    new Employee("Denis", "Finance"),
    new Employee("Eva", "HR")
);

依部門分組:

Map<String, List<Employee>> byDepartment = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment));

得到了什麼?

  • 鍵:部門名稱(String)。
  • 值:該部門的員工清單(List<Employee>)。

列印結果:

byDepartment.forEach((dept, emps) -> {
    System.out.println(dept + ": " +
        emps.stream().map(Employee::getName).toList());
});

輸出:

IT: [Alisa, Klara]
HR: [Bob, Eva]
Finance: [Denis]

底層如何運作?

首先對串流中的每個元素計算鍵(例如 getDepartment())。如果該鍵已存在於 Map 中,就將元素加入對應清單;若不存在,則建立新的清單。

類比

想像你在把信件歸類到資料夾:對於每封信,先看是否已有對應名稱的資料夾;若沒有,就建立一個新的,然後把信放進去。

3. 收集器 partitioningBy:分成兩組

有時我們不是要「按值分組」,而只是想依布林條件把集合分成兩部分(true/false)。例如:薪資高於或低於某門檻的員工;學生分為「及格/不及格」。

這時可以使用特殊的收集器——partitioningBy

方法簽章:

Collectors.partitioningBy(Predicate<T>)

Predicate —— 會回傳布林值的函式。

範例:依薪資高低分組員工

假設有:

public class Employee {
    private final String name;
    private final int salary;
    // ... 建構子與 getter
    public Employee(String name, int salary) {
        this.name = name;
        this.salary = salary;
    }
    public String getName() { return name; }
    public int getSalary() { return salary; }
}

以及清單:

List<Employee> employees = List.of(
    new Employee("Alisa", 120_000),
    new Employee("Bob", 80_000),
    new Employee("Klara", 150_000),
    new Employee("Denis", 95_000)
);

把員工分成「高薪」與「低薪」:

Map<Boolean, List<Employee>> partitioned = employees.stream()
    .collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
  • true —— 薪資大於 100_000 的員工。
  • false —— 其餘員工。

列印:

System.out.println("高薪: " +
    partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("低薪: " +
    partitioned.get(false).stream().map(Employee::getName).toList());

結果:

高薪: [Alisa, Klara]
低薪: [Bob, Denis]

何時使用 partitioningBy,何時使用 groupingBy

  • 如果群組多於兩個——使用 groupingBy
  • 如果只有依布林條件分成兩組——使用 partitioningBy:更快也更清楚。

4. 巢狀分組:依多個條件分組

有時不僅想依單一條件分組,還想「把分組巢狀起來」。例如:先依部門分組,再在部門內依職稱分組。

範例:

假設在我們的 Employee 類別中,還有一個欄位 position

public class Employee {
    private final String name;
    private final String department;
    private final String position;
    // ... 建構子與 getter
}

巢狀分組:

Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment,
        Collectors.groupingBy(Employee::getPosition)));
  • 外層鍵——部門。
  • 內層鍵——職稱。
  • 值——員工清單。

如何取得 IT 部門且職稱為 "Developer" 的員工?

List<Employee> itDevs = byDeptAndPosition
    .getOrDefault("IT", Map.of())
    .getOrDefault("Developer", List.of());

視覺化示意

Map<Department, Map<Position, List<Employee>>>
└─ "IT"
    ├─ "Developer" -> [Alisa, Klara]
    └─ "QA"        -> [Boris]
└─ "HR"
    └─ "Recruiter" -> [Denis]

5. 實用分組範例

範例 1:依字串長度分組

List<String> words = List.of("cat", "dog", "elephant", "bee", "ant", "dolphin");

Map<Integer, List<String>> byLength = words.stream()
    .collect(Collectors.groupingBy(String::length));

byLength.forEach((len, ws) -> System.out.println(len + ": " + ws));

輸出:

3: [cat, dog, bee, ant]
8: [elephant, dolphin]

範例 2:依奇偶性分組數字

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);

Map<String, List<Integer>> byParity = numbers.stream()
    .collect(Collectors.groupingBy(n -> n % 2 == 0 ? "even" : "odd"));

System.out.println(byParity);
// {odd=[1, 3, 5], even=[2, 4, 6]}

範例 3:對字串使用 partitioningBy

把字串分成以 "A" 開頭與其他:

List<String> names = List.of("Alice", "Bob", "Anna", "Charlie");

Map<Boolean, List<String>> byA = names.stream()
    .collect(Collectors.partitioningBy(s -> s.startsWith("A")));

System.out.println("A-names: " + byA.get(true));  // [Alice, Anna]
System.out.println("Other: " + byA.get(false));   // [Bob, Charlie]

5. 實用細節

如何使用分組結果

很多時候,我們不只想得到 Map,還想進一步處理它:

  • 遍歷所有群組並輸出資訊。
  • 找出元素數量最多的群組。
  • 為每個群組計算加總或平均——詳細內容在下一講。

範例:輸出每個部門的員工數量

byDepartment.forEach((dept, emps) ->
    System.out.println(dept + ": " + emps.size() + " 位員工"));

與手動實作的比較

複習一下:傳統作法下,依部門分組大概是這樣:

Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
    String dept = e.getDepartment();
    byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}

使用 Stream API 則是:

Map<String, List<Employee>> byDepartment = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment));

結論:程式碼更少、錯誤更少、可讀性更高。

小技巧

如果不想收集成 List,而是 Set——可把第二個參數設為 Collectors.toSet()

Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())

也可直接做彙總:例如,取得各部門的員工數:

Collectors.groupingBy(Employee::getDepartment, Collectors.counting())

但這部分將在下一講詳細說明!

使用 partitioningBy 時,結果一定會有兩個鍵:truefalse。即使其中一組為空。

做了巢狀分組後,結構會變成「樹」:Map 裡面包著 Map,依此類推。

6. 分組與 partitioning 的常見錯誤

錯誤 № 1:結果型別誤解。 新手常以為 groupingBy 的結果只是 List<T>,而不是 Map<K, List<T>>。於是嘗試呼叫清單的方法而導致編譯錯誤。記住:分組的結果永遠是 Map

錯誤 № 2:存取不存在的群組時發生 NullPointerException。 如果你嘗試用不存在的鍵取得清單——會得到 null。請使用 getOrDefault(key, List.of()),或先用 containsKey 檢查鍵是否存在。

錯誤 № 3:多於兩組卻使用 partitioningBy。 partitioningBy 適用於只有兩組(true/false)的情況。若群組超過兩個——請使用 groupingBy

錯誤 № 4:在串流內修改集合。 不要嘗試在串流內修改原始集合或 Map——這會導致難以預期的錯誤。請把處理邏輯交給 Stream API 與收集器完成。

錯誤 № 5:忽略巢狀分組的結構。 巢狀的 groupingBy 會得到 Map 內嵌 Map 的結果。取值時別忘了正確存取(例如使用 getOrDefault),否則會得到 ClassCastException

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION