skip to content
BlogZzz

[Learn] Java Collections:List、Set、Map 與常用實作

/ 17 min read

Updated:
Table of Contents

學會 class 與 OOP 之後,日常 Java 程式碼最常遇到的下一組工具就是 Collections

同樣是保存多筆資料,選擇的 collection 會直接影響資料規則、操作方式與 iteration order

需要保存一串有順序的資料 → List
需要從頭尾加入或取出資料 → Deque
需要確保資料不重複 → Set
需要透過 key 快速找到資料 → Map

實際比較 ArrayList、LinkedList、ArrayDeque,以及 Hash、LinkedHash、Tree 三組 Set 與 Map 實作

List 與 Deque 的常用實作

List<E> 是有順序的 collection,可以透過 index 存取元素,也允許重複值

import java.util.ArrayList;
import java.util.List;
List<String> names = new ArrayList<>();
names.add("Amy");
names.add("Ben");
names.add("Amy");
System.out.println(names); // [Amy, Ben, Amy]
System.out.println(names.get(0)); // Amy
System.out.println(names.size()); // 3

index 從 0 開始

String first = names.get(0);
String second = names.get(1);

超出有效範圍時會拋出 IndexOutOfBoundsException

names.get(10);

List.of(...) 可以快速建立不能新增、刪除或替換元素的 list

List<String> roles = List.of("ADMIN", "EDITOR");
// roles.add("VIEWER"); // UnsupportedOperationException

如果需要一份可修改的 copy,可以再交給 ArrayList

List<String> mutableRoles = new ArrayList<>(roles);
mutableRoles.add("VIEWER");

List.of(...) 不接受 null

ArrayList:一般 List 的預設選擇

日常最常用的實作是 ArrayList

  • 依 index 讀取通常很快
  • 在尾端新增元素很方便
  • 在中間插入或刪除時,後方元素可能需要搬移

常見操作如下

names.add("Cara");
names.set(0, "Alice");
names.remove("Ben");
boolean containsAmy = names.contains("Amy");
int count = names.size();

LinkedList 同時實作 List 與 Deque

LinkedList 是 doubly-linked list,每個元素都透過前後連結串在一起

它同時實作 List 與 Deque,所以可以使用 index,也可以操作頭尾兩端

先把它當成一般 List 使用

import java.util.LinkedList;
import java.util.List;
List<String> route = new LinkedList<>();
route.add("Taipei");
route.add("Taichung");
route.add(1, "Taoyuan");
System.out.println(route); // [Taipei, Taoyuan, Taichung]
System.out.println(route.get(1)); // Taoyuan
route.remove(1);
System.out.println(route); // [Taipei, Taichung]

因為左側型別是 List<String>,呼叫端只看得到 List 契約

需要操作頭尾時,可以把 reference type 寫成 Deque<String>

import java.util.Deque;
import java.util.LinkedList;
Deque<String> route = new LinkedList<>();
route.addFirst("Taipei");
route.addLast("Taichung");
route.addLast("Kaohsiung");
System.out.println(route.getFirst()); // Taipei
System.out.println(route.getLast()); // Kaohsiung
route.removeFirst();
System.out.println(route); // [Taichung, Kaohsiung]

LinkedList 沒有像 ArrayList 一樣支援快速 random access

get(index) 需要從較接近的頭或尾沿著 link 尋找,所以大量使用 index 的程式通常更適合 ArrayList

LinkedList 在已經透過 iterator 到達指定位置時,可以直接在附近插入或刪除元素,但日常 List 情境仍然先選 ArrayList 會比較直覺

ArrayDeque 用來實作 queue 與 stack

Deque 是 double-ended queue,可以從頭尾兩端加入、查看或取出元素

ArrayDeque 使用可擴充 array 實作 Deque,不提供 index 存取,也不接受 null

當成 FIFO queue

FIFO 是 First In, First Out,先加入的元素會先被取出

import java.util.ArrayDeque;
import java.util.Deque;
Deque<String> waiting = new ArrayDeque<>();
waiting.offerLast("Amy");
waiting.offerLast("Ben");
waiting.offerLast("Cara");
String next = waiting.pollFirst();
String following = waiting.peekFirst();
System.out.println(next); // Amy
System.out.println(following); // Ben
System.out.println(waiting); // [Ben, Cara]
  • offerLast 從尾端加入
  • pollFirst 從頭端取出並移除
  • peekFirst 查看頭端但不移除

可以把它想成排隊

取出方向 ← [Amy, Ben, Cara] ← 加入方向

Amy 最早加入,所以第一次 pollFirst() 會得到 Amy

當成 LIFO stack

LIFO 是 Last In, First Out,最後放入的元素會先被取出

import java.util.ArrayDeque;
import java.util.Deque;
Deque<String> pages = new ArrayDeque<>();
pages.push("首頁");
pages.push("商品頁");
pages.push("結帳頁");
String current = pages.pop();
String previous = pages.peek();
System.out.println(current); // 結帳頁
System.out.println(previous); // 商品頁
System.out.println(pages); // [商品頁, 首頁]
  • push 把元素放到 stack 頂端
  • pop 取出並移除頂端元素
  • peek 查看頂端但不移除

可以把它想成疊盤子

頂端 → [結帳頁, 商品頁, 首頁]

最後放入的結帳頁位於頂端,所以第一次 pop() 會先得到結帳頁

Java 雖然有舊的 Stack class,現代程式通常使用 Deque 與 ArrayDeque

空 Deque 的兩組 API

Deque 提供兩種風格的操作

操作失敗時拋出 exception失敗時回傳特殊值
加入頭端addFirstofferFirst
加入尾端addLastofferLast
取出頭端removeFirstpollFirst
取出尾端removeLastpollLast
查看頭端getFirstpeekFirst
查看尾端getLastpeekLast

在空 Deque 呼叫 removeFirst、getFirst 或 pop 會拋出 NoSuchElementException

pollFirst、peekFirst 或 poll 則會回傳 null

因為 ArrayDeque 不接受 null element,所以 null 可以清楚代表目前沒有元素

ArrayList、LinkedList 與 ArrayDeque 如何選

實作實作的主要 interface適合情境注意事項
ArrayListListindex 存取、依序走訪、尾端新增中間插入刪除可能搬移元素
LinkedListList、Deque確實需要 linked list,或透過 iterator 頻繁修改目前位置index 存取需要 traversal,每個元素需要 node
ArrayDequeDequeFIFO queue、LIFO stack、操作頭尾兩端沒有 index、不接受 null、不是 thread-safe

大多數一般 List 用途先選 ArrayList

需要 queue、stack 或雙端操作時先選 ArrayDeque

只有在 LinkedList 的特性真的符合操作模式時才選 LinkedList,不要只因為名稱有 list 就預設使用它

Set 保存不重複的資料

Set<E> 不允許重複元素,適合保存 tag、權限、已處理 ID 等資料

import java.util.HashSet;
import java.util.Set;
Set<String> tags = new HashSet<>();
boolean firstAdd = tags.add("java");
boolean secondAdd = tags.add("java");
System.out.println(firstAdd); // true
System.out.println(secondAdd); // false
System.out.println(tags.size()); // 1

add 回傳 false 代表相同元素已經存在

常見實作的差異如下

實作iteration order一般 add/containsnull常見用途
HashSet不保證平均 O(1)接受一個 null element一般去重與 membership check
LinkedHashSetinsertion order平均 O(1)接受一個 null element去重後仍要維持加入順序
TreeSetsorted orderO(log n)natural order 不接受 null需要排序與範圍查詢

Set.of(...) 可以快速建立不能修改的 set

Set<String> permissions = Set.of("READ", "WRITE");

Set.of(...) 不接受 null,建立時傳入重複元素也會拋出 IllegalArgumentException

HashSet:只在意唯一性

HashSet 適合只在意元素是否存在,不在意 iteration order 的情境

import java.util.HashSet;
import java.util.Set;
Set<String> tags = new HashSet<>();
tags.add("spring");
tags.add("java");
tags.add("backend");
tags.add("java");
System.out.println(tags.contains("java")); // true
System.out.println(tags.size()); // 3
System.out.println(tags); // iteration order 不保證

最後一次加入 java 不會產生第二個元素

不要根據某一次執行看到的輸出順序撰寫程式,換資料、換 JDK 或重新配置內部 table 後,iteration order 都可能改變

HashSet 判斷自訂物件是否相同時,會使用 equals 與 hashCode

如果兩個物件在業務上應該視為相同,卻沒有正確實作這兩個 method,HashSet 可能無法如預期去重

LinkedHashSet:去重並保留加入順序

LinkedHashSet 在 hash table 之外維護元素的前後關係,因此 iteration 時會按照 insertion order

import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
List<String> input = List.of(
"banana",
"apple",
"orange",
"apple");
Set<String> unique = new LinkedHashSet<>(input);
System.out.println(unique); // [banana, apple, orange]

第二個 apple 被移除,第一次出現的位置則保留下來

這種特性很適合處理「依原始順序去重」

List<String> uniqueList = List.copyOf(new LinkedHashSet<>(input));

如果只用 HashSet 去重再轉回 List,就不能依賴結果仍然維持原始順序

TreeSet:唯一值加上排序

TreeSet 會按照 natural order 或傳入的 Comparator 排序

它也實作 NavigableSet,可以查找最接近指定條件的元素

import java.util.List;
import java.util.NavigableSet;
import java.util.TreeSet;
NavigableSet<Integer> scores = new TreeSet<>(
List.of(90, 60, 75, 60));
System.out.println(scores); // [60, 75, 90]
System.out.println(scores.ceiling(70)); // 75
System.out.println(scores.higher(75)); // 90
  • ceiling(70) 找大於或等於 70 的最小元素
  • higher(75) 找嚴格大於 75 的最小元素
  • 重複的 60 只會保留一份

String 的 natural order 是字典順序,也可以自己指定排序規則

import java.util.Comparator;
import java.util.Set;
import java.util.TreeSet;
Set<String> namesByLength = new TreeSet<>(
Comparator.comparingInt((String name) -> name.length())
.thenComparing(Comparator.naturalOrder()));
namesByLength.add("Cara");
namesByLength.add("Amy");
namesByLength.add("Alice");
namesByLength.add("Ben");
System.out.println(namesByLength); // [Amy, Ben, Cara, Alice]

先比較字串長度,相同長度時再依 natural order 排序

對 TreeSet 來說,Comparator.compare(a, b) == 0 就代表兩個元素相同

如果只比較字串長度,Amy 與 Ben 都是三個字元,其中一個會被視為重複值

因此需要同時保留相同長度的不同名稱時,要像範例一樣加入 thenComparing

Map 使用 key 找到 value

Map<K, V> 保存 key 與 value 的對應關係

import java.util.HashMap;
import java.util.Map;
Map<String, String> namesById = new HashMap<>();
namesById.put("S001", "Amy");
namesById.put("S002", "Ben");
String name = namesById.get("S001");
System.out.println(name); // Amy

Map 的 key 不能重複,再次放入相同 key 會取代舊 value

namesById.put("S001", "Alice");
System.out.println(namesById.get("S001")); // Alice

常見操作如下

boolean hasStudent = namesById.containsKey("S001");
String fallback = namesById.getOrDefault("S999", "查無學生");
namesById.remove("S002");

Map.get(key) 找不到時會回傳 null

如果 map 本身允許 null value,只看 get 無法分辨是沒有這個 key,還是 key 對應到 null

需要區分時可以使用 containsKey

需要依 key 建立並累積 list 時,computeIfAbsent 很方便

Map<String, List<String>> studentsByClass = new HashMap<>();
studentsByClass
.computeIfAbsent("A", key -> new ArrayList<>())
.add("Amy");

第一次遇到 A 時會建立新的 ArrayList,之後再遇到 A 就重用既有 list

Map.of(...) 可以建立不能修改的 map,同樣不接受 null key 或 null value

常見實作如下

實作key iteration order一般 get/putnull常見用途
HashMap不保證平均 O(1)接受一個 null key 與 null value一般 key-value 查找
LinkedHashMapinsertion 或 access order平均 O(1)接受一個 null key 與 null value需要穩定順序或簡單 cache
TreeMapkey 的 sorted orderO(log n)natural order 不接受 null key,接受 null value排序與範圍查詢

HashMap:一般 key-value 查找

HashMap 是最常見的 Map 實作,適合不需要 iteration order 的一般查找

import java.util.HashMap;
import java.util.Map;
Map<String, Integer> scores = new HashMap<>();
scores.put("S003", 90);
scores.put("S001", 70);
scores.put("S002", 80);
System.out.println(scores.get("S002")); // 80
System.out.println(scores.containsKey("S004")); // false
for (Map.Entry<String, Integer> entry : scores.entrySet()) {
System.out.println(entry.getKey() + "=" + entry.getValue());
}

entrySet() 的 iteration order 不保證,不要假設輸出一定是 S001、S002、S003

再次放入相同 key 會更新 value,不會增加新的 entry

Integer previous = scores.put("S002", 85);
System.out.println(previous); // 80
System.out.println(scores.get("S002")); // 85
System.out.println(scores.size()); // 3

HashMap 判斷 key 是否相同也依賴 equals 與 hashCode

如果使用自訂物件當 key,參與 equality 的內容放入 map 後不應再改變

LinkedHashMap:保留 insertion order

LinkedHashMap 預設按照 key 第一次插入的順序 iteration

import java.util.LinkedHashMap;
import java.util.Map;
Map<String, Integer> scores = new LinkedHashMap<>();
scores.put("S003", 90);
scores.put("S001", 70);
scores.put("S002", 80);
System.out.println(scores.keySet()); // [S003, S001, S002]
scores.put("S001", 95);
System.out.println(scores.keySet()); // [S003, S001, S002]
System.out.println(scores); // {S003=90, S001=95, S002=80}

更新既有 key 的 value 不會改變它第一次插入的位置

這很適合需要穩定輸出順序的情境,例如依讀取順序產生報表

LinkedHashMap 也能選擇 access-order mode

import java.util.LinkedHashMap;
LinkedHashMap<String, String> cache = new LinkedHashMap<>(
16,
0.75f,
true);
cache.put("A", "Apple");
cache.put("B", "Banana");
cache.put("C", "Cherry");
cache.get("A");
System.out.println(cache.keySet()); // [B, C, A]

第三個 constructor argument 設為 true 後,最近被存取的 A 會移到 iteration order 最後方

這個模式可以作為 LRU cache 的基礎,但 LinkedHashMap 本身不是 thread-safe,完整 cache 還需要處理容量、concurrency 與資料載入策略

TreeMap:按照 key 排序

TreeMap 會按照 key 的 natural order 或指定的 Comparator 排序

它也實作 NavigableMap,可以依 key 做範圍與相鄰查詢

import java.util.NavigableMap;
import java.util.TreeMap;
NavigableMap<String, Integer> scores = new TreeMap<>();
scores.put("S003", 90);
scores.put("S001", 70);
scores.put("S002", 80);
System.out.println(scores); // {S001=70, S002=80, S003=90}
System.out.println(scores.firstEntry()); // S001=70
System.out.println(scores.higherEntry("S002")); // S003=90

需要反向排序時,可以傳入 Comparator

import java.util.Comparator;
import java.util.Map;
import java.util.TreeMap;
Map<String, Integer> descending = new TreeMap<>(
Comparator.reverseOrder());
descending.put("S001", 70);
descending.put("S003", 90);
descending.put("S002", 80);
System.out.println(descending.keySet()); // [S003, S002, S001]

TreeMap 使用比較結果判斷 key 是否相同

Comparator.compare(a, b) == 0 時,後放入的 value 會取代同一個排序位置的舊 value

自訂 Comparator 時,最好讓排序結果與 key 的 equals 一致,避免 Map 行為難以理解

List、Set 與 Map 如何選

可以先問資料最重要的存取方式是什麼

問題選擇
需要保留加入順序或透過 index 存取List
只在意元素是否存在,而且不能重複Set
需要用唯一 key 找到 valueMap
同時需要順序與快速查找視需求組合 List 與 Map

不要只因為 List 最熟悉,就把所有資料都放進 List 再逐筆搜尋

選對 collection 能直接表達資料規則

Set 表達唯一性,Map 表達 key 與 value 的對應,通常也比額外的 if 更清楚

常見錯誤

把 List.of 當成可修改的 ArrayList

List<String> names = List.of("Amy", "Ben");
names.add("Cara"); // UnsupportedOperationException

需要修改時建立 mutable copy

List<String> names = new ArrayList<>(List.of("Amy", "Ben"));

依賴 HashSet 或 HashMap 的 iteration order

HashSet 與 HashMap 不保證 iteration order

順序是需求的一部分時,明確使用 LinkedHashSet、LinkedHashMap、TreeSet 或 TreeMap

使用 mutable object 當作 HashMap key

如果 key 參與 equals 或 hashCode 的內容在放入 map 後改變,之後可能再也找不到原本的 entry

Map key 適合使用 immutable 且 equality 穩定的型別

只因為中間插入就選 LinkedList

LinkedList 的 add(index, value) 仍然要先 traversal 到指定 index

只有透過 iterator 已經位於修改位置時,linked structure 才能避免搬移後方元素

一般 List 使用情境先選 ArrayList,queue 或 stack 則先選 ArrayDeque

結論

  • ArrayList 適合大多數一般 List 使用情境
  • ArrayDeque 適合 FIFO queue、LIFO stack 與雙端操作
  • LinkedList 同時實作 List 與 Deque,但要確認 linked structure 真的符合操作模式
  • HashSet 與 HashMap 適合不需要 iteration order 的一般查找
  • LinkedHashSet 與 LinkedHashMap 適合需要 insertion order 的資料
  • TreeSet 與 TreeMap 適合需要排序、範圍或相鄰查詢的資料
  • 選擇 collection 時先表達資料規則,再考慮實作細節與效能

參考資料