集合操作是日常編程的基礎,但很多人寫得很醜。本篇涵蓋三個常見的集合操作場景:List 差集、JsonArray 分割成 List,以及 Stream 查詢計數。這些小技巧能讓代碼更簡潔。

1. List 差集

需求:從 list1 移除所有在 list2 裡出現的元素。

簡單但爛的做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
List<String> list1 = Arrays.asList("Apple", "Banana", "Cherry", "Orange");
List<String> list2 = Arrays.asList("Banana", "Orange");

// 爛做法 1:逐個刪(在迴圈中修改 list,會爆 ConcurrentModificationException)
for (String item : list1) {
if (list2.contains(item)) {
list1.remove(item); // 危險!
}
}

// 爛做法 2:手動建立新 list
List<String> result = new ArrayList<>();
for (String item : list1) {
if (!list2.contains(item)) {
result.add(item);
}
}

正確的 Stream 做法

1
2
3
4
5
List<String> result = list1.stream()
.filter(s -> !list2.contains(s))
.collect(Collectors.toList());

// 結果:["Apple", "Cherry"]

很簡潔,但有個效能問題。

效能陷阱:contains 很慢

list2.contains(s) 對每個元素都要遍歷整個 list2,時間複雜度是 O(n*m)。如果 list1 有 10000 個,list2 有 1000 個,就是 1000 萬次比較。

優化做法:先轉 Set

1
2
3
4
5
Set<String> set2 = new HashSet<>(list2);

List<String> result = list1.stream()
.filter(s -> !set2.contains(s))
.collect(Collectors.toList());

現在是 O(n + m),快多了。

完整例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public List<String> getDifference(List<String> list1, List<String> list2) {
Set<String> set2 = new HashSet<>(list2);

return list1.stream()
.filter(item -> !set2.contains(item))
.collect(Collectors.toList());
}

// 使用
List<String> products = Arrays.asList("Apple", "Banana", "Cherry", "Orange");
List<String> sold = Arrays.asList("Banana", "Orange");

List<String> remaining = getDifference(products, sold);
System.out.println(remaining); // [Apple, Cherry]

2. Stream 分割 JsonArray 轉 List

從 Jackson 的 JsonNode 陣列轉成 List

原始做法(笨重)

1
2
3
4
5
6
7
8
9
10
11
12
JsonNode jsonArray = mapper.readTree("""
[
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
{"id": 3, "name": "Cathy"}
]
""");

List<JsonNode> list = new ArrayList<>();
for (int i = 0; i < jsonArray.size(); i++) {
list.add(jsonArray.get(i));
}

用 Stream 優雅做法

1
2
3
4
List<JsonNode> list = StreamSupport.stream(
jsonArray.spliterator(),
false
).collect(Collectors.toList());

邏輯:

  • jsonArray 本身可以 iterable
  • Spliterator 是 Java 8 用來支援平行 stream 的介面
  • StreamSupport.stream() 把 spliterator 轉成 stream
  • 第二個參數 false 代表不要平行

對象型的 JsonArray

如果陣列裡是物件,可以邊轉邊過濾:

1
2
3
4
5
6
7
8
List<JsonNode> users = StreamSupport.stream(
jsonArray.spliterator(),
false
)
.filter(node -> node.path("age").asInt() > 25)
.collect(Collectors.toList());

// 只會包含年齡 > 25 的使用者

轉成其他型態

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 轉成 List<String>(取 name 欄位)
List<String> names = StreamSupport.stream(
jsonArray.spliterator(),
false
)
.map(node -> node.path("name").asText())
.collect(Collectors.toList());

// 轉成 Map(key=id, value=name)
Map<Integer, String> map = StreamSupport.stream(
jsonArray.spliterator(),
false
)
.collect(Collectors.toMap(
node -> node.path("id").asInt(),
node -> node.path("name").asText()
));

3. Stream 查詢計數

需求:檢查 List 中有多少元素符合某個條件。

簡單場景

1
2
3
4
5
6
7
8
9
List<String> types = Arrays.asList("Apple", "Banana", "Apple", "Orange", "Apple");
List<String> PRO_SURE_TYPE = Arrays.asList("Apple", "Orange");

// 計算有多少是 PRO_SURE_TYPE
long count = types.stream()
.filter(PRO_SURE_TYPE::contains)
.count();

System.out.println(count); // 4 (3 個 Apple + 1 個 Orange)

這邊用 method reference PRO_SURE_TYPE::contains,等於 type -> PRO_SURE_TYPE.contains(type),更簡潔。

複雜條件

如果要查複雜條件,比如「id > 10 且 status = ACTIVE」:

1
2
3
4
5
List<User> users = /* ... */;

long activeCount = users.stream()
.filter(user -> user.getId() > 10 && user.getStatus() == Status.ACTIVE)
.count();

結合 Set 優化(重要)

如果 PRO_SURE_TYPE 是個 List,每次 contains() 都要遍歷,效能差。轉 Set:

1
2
3
4
5
Set<String> proPriceTypeSet = new HashSet<>(PRO_SURE_TYPE);

long count = types.stream()
.filter(proPriceTypeSet::contains)
.count();

O(n) vs O(n*m),快很多。

不只是計數

如果要拿到符合條件的元素,不只是計數:

1
2
3
4
5
6
7
8
9
// 拿到所有 PRO_SURE_TYPE
List<String> proPriceItems = types.stream()
.filter(PRO_SURE_TYPE::contains)
.collect(Collectors.toList());

// 或轉成 Set 去重
Set<String> uniqueProPriceItems = types.stream()
.filter(PRO_SURE_TYPE::contains)
.collect(Collectors.toSet());

實戰組合

假設有個 API 回傳商品清單,要計算「高價商品但不在銷售清單裡」的數量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
List<Product> allProducts = getProducts();  // API 回傳
List<String> soldProductIds = getSoldList(); // 銷售清單

Set<String> soldSet = new HashSet<>(soldProductIds);

long expensiveUnsoldCount = allProducts.stream()
.filter(p -> p.getPrice() > 1000)
.filter(p -> !soldSet.contains(p.getId()))
.count();

// 取出具體的商品(不只是計數)
List<Product> expensiveUnsold = allProducts.stream()
.filter(p -> p.getPrice() > 1000)
.filter(p -> !soldSet.contains(p.getId()))
.collect(Collectors.toList());

常見坑

1. List.contains() 效能差

1
2
3
4
5
6
// 爛做法:O(n*m)
list1.stream().filter(item -> list2.contains(item))

// 好做法:O(n + m)
Set<String> set2 = new HashSet<>(list2);
list1.stream().filter(item -> set2.contains(item))

2. 在 Stream 中修改原始 list

1
2
3
4
5
6
7
// 爛做法:邊遍歷邊改會爆炸
list.stream().forEach(item -> list.remove(item));

// 正確做法:建立新 list
List<Item> removed = list.stream()
.filter(condition)
.collect(Collectors.toList());

3. 忘記 false 參數會變成平行 stream

1
2
3
4
5
// 預設是順序
StreamSupport.stream(spliterator, false);

// 如果想平行(大資料集)
StreamSupport.stream(spliterator, true);

平行不一定快,小資料集反而更慢(因為建立執行緒的開銷)。

性能比較

假設 list1 有 10000 個元素,list2 有 1000 個:

1
2
list2.contains() 方式:10000 * 1000 = 1000 萬次比較(慢)
先轉 Set.contains() 方式:10000 + 1000 = 11000 次操作(快)

百倍以上的差別。

總結

三大法則:

  1. List 差集 - 用 Stream filter,大 list 先轉 Set
  2. JsonArray 分割 - 用 StreamSupport.stream(spliterator, false)
  3. 計數和查詢 - 用 Stream filter 和 count,method reference 更簡潔

這些小操作看似簡單,但寫得好和寫得爛,效能能差百倍。特別是處理大資料時,千萬別亂搞。