學完本章你將能夠:
描繪集合框架的整體階層:List、Set、Queue 與自成體系的 Map。
熟練 ArrayList 與 LinkedList 的操作,並依效能特性選擇。
用四種方式走訪集合,並正確地在走訪中刪除元素。
解釋 HashSet 去重的原理,知道自訂類別必須覆寫 equals 與 hashCode。
熟練 HashMap 的操作與三種走訪,分辨 Hash、LinkedHash、Tree 三種實作的順序特性。
用 Comparator 與 lambda 自訂排序規則,並依需求選出正確的集合型別。
初級篇的陣列有兩個天生的限制:長度固定(建立後不能增減,塞滿了得手動搬家)與功能陽春(搜尋、去重、鍵值對應都要自己寫迴圈)。Java 的集合框架(Collections Framework)用一組精心設計的介面與實作類別解決這些問題:容量自動成長、常用操作一應俱全,而且以介面為中心,換一種實作不必改動使用端的程式。
集合框架也是前三章知識的第一個大型應用現場:它以介面定義契約(第 2 章)、以泛型保證型別安全(下一章深入)、以 |equals| 與 |hashCode| 契約運作(第 1 章)。本章聚焦最常用的成員,學會之後,你日常八成的資料處理需求都有現成的工具。
集合框架總覽
框架的骨幹是兩棵介面樹。|Collection| 是「一群元素」的容器;|Map| 存的是「鍵值對」,自成體系、不繼承 |Collection|:
|Collection|(介面):單一元素的容器
|List|:有序、可重複、可用索引存取——|ArrayList|、|LinkedList|
|Set|:不可重複——|HashSet|、|LinkedHashSet|、|TreeSet|
|Queue|:佇列(先進先出)——|ArrayDeque|、|PriorityQueue|;其子介面 |Deque| 是雙端佇列,也常拿來當堆疊
|Map|(介面):鍵值對,鍵不可重複——|HashMap|、|LinkedHashMap|、|TreeMap|
命名規律值得記住:前綴代表資料結構(Hash 雜湊表、Linked 鏈結、Tree 紅黑樹、Array 陣列),後綴代表介面。看到 |TreeMap| 就該反射出「用樹實作的 Map,所以依鍵排序」。
泛型語法與自動裝箱
集合宣告時用角括號指定元素型別,例如 |List<String>|。Java 7 起右邊可以用菱形運算子省略型別參數,由編譯器推斷:|List<String> list = new ArrayList<>();|。宣告時建議左邊用介面型別(|List| 而非 |ArrayList|),日後要換實作只需改 |new| 的那一端。
角括號裡只能放參考型別,不能放 |int| 這類基本型別,因此每個基本型別都有對應的包裝類別(|Integer|、|Double|、|Boolean| 等)。Java 會自動在兩者之間轉換——放進集合時自動裝箱(autoboxing),取出運算時自動拆箱(unboxing)。方便歸方便,有一個著名陷阱:
/** 包裝類別的快取陷阱:== 比較的是參考,不是數值 */ public class BoxingTrap
public static void main(String[] args)
// 自動裝箱:int 值自動包成 Integer 物件 Integer a = 100; Integer b = 100; Integer c = 1000; Integer d = 1000;
// -128〜127 落在快取區間:a 與 b 指向同一個快取物件 System.out.println("100 == 100:" + (a == b));
// 1000 超出快取區間:c 與 d 是兩個不同的物件 System.out.println("1000 == 1000:" + (c == d));
// 正解:包裝物件一律用 equals 比較數值 System.out.println("equals 比較:" + c.equals(d));
100 == 100:true 1000 == 1000:false equals 比較:true
|Integer| 會快取 \(-128\) 到 \(127\) 的物件,裝箱時這個範圍回傳同一顆快取物件,範圍外則每次建立新物件——於是 |==|(比較參考)對小數字碰巧為 |true|、對大數字為 |false|,測試時全過、上線後炸裂。鐵律:包裝物件比較數值一律用 equals(或先拆箱成基本型別再比)。這正是第 1 章「|==| 比參考、|equals| 比內容」的又一次體現。
List:有序、可重複
ArrayList 常用操作
|ArrayList| 是使用率最高的集合,底層是一塊會自動擴容的陣列:容量不足時配置一塊約 1.5 倍大的新陣列並搬移既有元素。搬家偶爾發生、平攤下來尾端新增仍是常數時間,但這也解釋了它的效能特性:依索引存取極快,頭部或中間插入刪除則需要搬動後續元素。
import java.util.ArrayList; import java.util.List;
/** ArrayList 常用操作總覽 */ public class ArrayListBasics
public static void main(String[] args)
// 用介面型別宣告 + 菱形運算子:右邊的型別參數可以省略 List<String> fruits = new ArrayList<>();
fruits.add("蘋果"); // 加到尾端 fruits.add("香蕉"); fruits.add("櫻桃"); fruits.add(1, "藍莓"); // 插到索引 1,後面的元素往後挪 System.out.println("目前內容:" + fruits);
System.out.println("取索引 2:" + fruits.get(2)); System.out.println("元素個數:" + fruits.size()); System.out.println("包含香蕉?" + fruits.contains("香蕉")); System.out.println("香蕉的索引:" + fruits.indexOf("香蕉"));
fruits.set(0, "鳳梨"); // 覆蓋索引 0 的元素 fruits.remove("藍莓"); // 依值刪除(第一個相符者) fruits.remove(1); // 依索引刪除 System.out.println("調整之後:" + fruits); System.out.println("是否為空:" + fruits.isEmpty());
目前內容:[蘋果, 藍莓, 香蕉, 櫻桃] 取索引 2:香蕉 元素個數:4 包含香蕉?true 香蕉的索引:2 調整之後:[鳳梨, 櫻桃] 是否為空:false
逐步追蹤最後兩步:|remove("藍莓")| 依值刪除(內部用 |equals| 比對),清單變成鳳梨、香蕉、櫻桃;|remove(1)| 依索引刪除香蕉,剩下鳳梨與櫻桃。整數清單要特別小心這組多載:!list.remove(1)! 刪的是索引 1,!list.remove(Integer.valueOf(1))! 才是刪除值為 1 的元素。
LinkedList 與效能對照
|LinkedList| 底層是雙向鏈結串列:每個節點持有元素以及指向前後節點的參考。它同時實作了 |List| 與 |Deque| 介面,因此還提供 |addFirst|、|removeFirst| 等頭尾操作:
LinkedList<String> queue = new LinkedList<>(); queue.addLast("甲"); // 尾端加入:一般排隊 queue.addLast("乙"); queue.addFirst("急件"); // 頭端插隊:O(1),不必搬動任何元素 String next = queue.removeFirst(); // 取出隊頭:「急件」
兩種 List 的效能對照(\(n\) 為元素個數):
| 操作 | ArrayList | LinkedList |
|---|---|---|
| 依索引存取 |get(i)| | \(O(1)\):陣列直接定址 | \(O(n)\):從頭或尾逐節點走 |
| 尾端新增 |add(e)| | 平攤 \(O(1)\) | \(O(1)\) |
| 頭端插入與刪除 | \(O(n)\):整批往後搬 | \(O(1)\):改幾個參考 |
| 中間插入與刪除 | \(O(n)\):搬移後續元素 | 定位 \(O(n)\),接鏈 \(O(1)\) |
| 記憶體 | 連續緊湊 | 每節點多兩個參考的負擔 |
實務建議:預設用 ArrayList。現代硬體上連續記憶體對快取極度友善,ArrayList 幾乎全面勝出;只有「頻繁在頭端進出」的佇列型場景值得考慮 |LinkedList| 或效能更好的 |ArrayDeque|。
走訪集合的四種方式
import java.util.ArrayList; import java.util.Iterator; import java.util.List;
/** 四種走訪方式的比較 */ public class TraversalDemo
public static void main(String[] args)
List<String> langs = new ArrayList<>(List.of("Java", "Kotlin", "Go"));
// 方式一:傳統索引 for——需要索引值的時候用 for (int i = 0; i < langs.size(); i++) System.out.println("索引 " + i + ":" + langs.get(i));
// 方式二:for-each——最常用,簡潔且不會寫錯邊界 for (String lang : langs) System.out.print(lang + " "); System.out.println();
// 方式三:Iterator——走訪中需要刪除元素時的正規做法 Iterator<String> it = langs.iterator(); while (it.hasNext()) System.out.print(it.next().length() + " "); System.out.println();
// 方式四:forEach 搭配 lambda(進階篇詳述,先感受寫法) langs.forEach(lang -> System.out.print(lang + " ")); System.out.println();
索引 0:Java 索引 1:Kotlin 索引 2:Go Java Kotlin Go 4 6 2 Java Kotlin Go
迭代器(|Iterator|)是集合框架的統一走訪協定:|hasNext()| 問「還有嗎」,|next()| 取下一個。for-each 只是它的語法糖——編譯器會把方式二改寫成方式三。順帶一提,|List.of(...)| 建立的是不可變清單,任何修改都會拋 |UnsupportedOperationException|,所以上面用 |new ArrayList<>(...)| 包一層取得可變複本。
走訪中刪除元素
「把清單裡的偶數都刪掉」是新手墳場。直覺寫法——在 for-each 裡呼叫 |remove|——會當場翻車:
Exception in thread "main" java.util.ConcurrentModificationException at java.base/java.util.ArrayList\(Itr.checkForComodification(ArrayList.java:1095) at java.base/java.util.ArrayList\)Itr.next(ArrayList.java:1049) at BrokenRemoval.main(BrokenRemoval.java:8)
集合內部有一個「結構修改次數」計數器,迭代器建立時會記下當時的值;走訪途中若集合被迭代器以外的管道修改(如直接呼叫 |list.remove|),下一次 |next()| 發現計數對不上,立刻拋出 |ConcurrentModificationException|。這是快速失敗(fail-fast)設計:與其讓走訪在錯亂的結構上繼續、產生無法解釋的結果,不如立刻報錯。堆疊追蹤指向 for 那一行(第 8 行),正是隱藏的 |next()| 呼叫點。
兩種正解——透過迭代器自己的 |remove|,或交給 |removeIf|:
正解一:[1, 3, 5] 正解二:[1, 3, 5]
Set:不重複的集合
|Set| 的唯一使命是不重複。主力實作 |HashSet| 的去重原理直接建立在第 1 章的契約上:加入元素時先用 |hashCode()| 定位到雜湊桶,再用 |equals()| 與桶內元素比對,兩關都判定「相同」才視為重複。因此——自訂類別要放進 HashSet,必須同時正確覆寫 equals 與 hashCode,缺一不可:
import java.util.LinkedHashSet; import java.util.Objects; import java.util.Set;
/** 會員:正確覆寫 equals 與 hashCode,HashSet 才能正確去重 */ class Member
private final String id; // 會員編號:身分依據 private final String name;
Member(String id, String name) this.id = id; this.name = name;
@Override public boolean equals(Object obj) if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; return id.equals(((Member) obj).id); // 以編號判斷同一人
@Override public int hashCode() return Objects.hash(id); // 與 equals 使用同一組欄位
@Override public String toString() return name + "(" + id + ")";
/** 驗證去重是否生效 */ public class HashSetDemo
public static void main(String[] args)
// 用 LinkedHashSet 讓輸出順序可預測(依插入順序) Set<Member> members = new LinkedHashSet<>();
members.add(new Member("M001", "小明")); members.add(new Member("M002", "小華")); // 編號重複:equals 與 hashCode 判定為同一人,不會加入 boolean added = members.add(new Member("M001", "小明"));
System.out.println("重複加入成功嗎?" + added); System.out.println("集合內容:" + members); // contains 也是靠 hashCode 與 equals 運作 System.out.println("包含 M002 嗎?" + members.contains(new Member("M002", "任意名字")));
重複加入成功嗎?false 集合內容:[小明(M001), 小華(M002)] 包含 M002 嗎?true
若忘了覆寫 |hashCode|,兩個「編號相同」的物件會落在不同的雜湊桶,|equals| 連被呼叫的機會都沒有——重複元素照收不誤、|contains| 找不到東西,正是第 1 章警告過的契約災難。三種 |Set| 的順序特性比較:
import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Set; import java.util.TreeSet;
/** 三種 Set 的順序行為比較 */ public class SetOrderDemo
public static void main(String[] args)
String[] data = "banana", "apple", "cherry", "apple" ;
Set<String> hash = new HashSet<>(); // 不保證任何順序 Set<String> linked = new LinkedHashSet<>(); // 記住插入順序 Set<String> tree = new TreeSet<>(); // 隨時保持排序
for (String s : data) // 同一批資料放進三種 Set hash.add(s); linked.add(s); tree.add(s);
// HashSet 的走訪順序不可依賴,因此只印元素個數 System.out.println("元素個數(重複已剔除):" + hash.size()); System.out.println("LinkedHashSet:" + linked); System.out.println("TreeSet:" + tree);
元素個數(重複已剔除):3 LinkedHashSet:[banana, apple, cherry] TreeSet:[apple, banana, cherry]
|HashSet| 最快但不保證走訪順序(本書凡需要穩定輸出之處一律改用 |LinkedHashSet|);|LinkedHashSet| 額外用一條鏈結記住插入順序;|TreeSet| 用紅黑樹隨時維持排序(操作為 \(O(\log n)\)),但元素必須實作 |Comparable|,或在建構時提供 |Comparator|。
Map:鍵值對
|Map| 是「查表」的抽象:以鍵找值,鍵不可重複。|HashMap| 的原理與 |HashSet| 同源(事實上 |HashSet| 內部就是一顆 |HashMap|),自訂類別當鍵時同樣必須覆寫 |equals| 與 |hashCode|:
import java.util.LinkedHashMap; import java.util.Map;
/** Map 常用操作:以「商品庫存」為例 */ public class MapBasics
public static void main(String[] args)
// 用 LinkedHashMap 讓走訪順序可預測(HashMap 不保證順序) Map<String, Integer> stock = new LinkedHashMap<>();
stock.put("鍵盤", 10); // 新增鍵值對 stock.put("滑鼠", 25); stock.put("螢幕", 3); stock.put("鍵盤", 12); // 鍵已存在:新值覆蓋舊值
System.out.println("鍵盤庫存:" + stock.get("鍵盤")); // get 查無此鍵回傳 null;getOrDefault 可以給預設值 System.out.println("耳機庫存:" + stock.getOrDefault("耳機", 0)); System.out.println("有賣螢幕嗎?" + stock.containsKey("螢幕"));
stock.remove("螢幕"); // 依鍵刪除
// merge:鍵存在就用函數合併新舊值;不存在就直接放入 stock.merge("滑鼠", 5, Integer::sum); // 25 + 5 = 30 stock.merge("耳機", 8, Integer::sum); // 不存在,直接放 8
System.out.println("目前庫存:" + stock); System.out.println("商品種類數:" + stock.size());
鍵盤庫存:12 耳機庫存:0 有賣螢幕嗎?true 目前庫存:鍵盤=12, 滑鼠=30, 耳機=8 商品種類數:3
|merge| 是「計數、累加」場景的利器:第三個參數 |Integer::sum| 是方法參考,意思是「把兩個數相加」(進階篇詳述,這裡先當成慣用片語)。三種走訪方式如下——只要鍵、只要值、或鍵值都要:
import java.util.LinkedHashMap; import java.util.Map;
/** Map 的三種走訪方式 */ public class MapTraversal
public static void main(String[] args)
Map<String, Integer> scores = new LinkedHashMap<>(); scores.put("國文", 88); scores.put("英文", 95); scores.put("數學", 72);
// 走訪一:keySet()——只需要鍵 for (String subject : scores.keySet()) System.out.print(subject + " "); System.out.println();
// 走訪二:values()——只需要值 int total = 0; for (int score : scores.values()) total += score; // 自動拆箱:Integer 轉回 int System.out.println("總分:" + total);
// 走訪三:entrySet()——鍵值都要(完整走訪的首選) for (Map.Entry<String, Integer> entry : scores.entrySet()) System.out.println(entry.getKey() + ":" + entry.getValue());
國文 英文 數學 總分:255 國文:88 英文:95 數學:72
鍵值都需要時務必用 |entrySet()|:走訪 |keySet()| 再對每個鍵呼叫 |get| 等於把查找做了兩次。順序特性與 Set 完全平行:|HashMap| 不保證順序、|LinkedHashMap| 依插入順序、|TreeMap| 依鍵排序。
排序:Comparator 與 Collections 工具
第 2 章的 |Comparable| 定義的是型別「與生俱來」的單一排序;|Comparator| 則把比較邏輯抽成獨立物件,同一種資料可以隨場合換規則。搭配 lambda 與工廠方法,排序程式碼精簡得驚人:
import java.util.ArrayList; import java.util.Comparator; import java.util.List;
/** 學生:示範排序用的簡單資料類別 */ class Student
final String name; // 聚焦排序示範,欄位用套件層級存取 final int score;
Student(String name, int score) this.name = name; this.score = score;
@Override public String toString() return name + "=" + score;
/** Comparator 搭配 lambda:排序規則想怎麼換就怎麼換 */ public class SortingDemo
public static void main(String[] args)
List<Student> list = new ArrayList<>(List.of( new Student("Bob", 92), new Student("Cindy", 85), new Student("Amy", 85), new Student("David", 78)));
// 依分數遞增:comparingInt 接收「怎麼取出比較鍵」的函數 list.sort(Comparator.comparingInt(s -> s.score)); System.out.println("分數遞增:" + list);
// 依分數遞減:reversed() 反轉既有規則 list.sort(Comparator.comparingInt((Student s) -> s.score) .reversed()); System.out.println("分數遞減:" + list);
// 多鍵排序:先比分數(遞減),同分再依姓名字母序 list.sort(Comparator.comparingInt((Student s) -> s.score) .reversed() .thenComparing(s -> s.name)); System.out.println("同分比名:" + list);
分數遞增:[David=78, Cindy=85, Amy=85, Bob=92] 分數遞減:[Bob=92, Cindy=85, Amy=85, David=78] 同分比名:[Bob=92, Amy=85, Cindy=85, David=78]
觀察前兩行輸出中 Cindy 與 Amy 的相對位置:兩人同為 85 分,順序維持原樣——Java 的排序是穩定排序,同值元素不會被打亂;第三行加上 |thenComparing| 之後,同分改依姓名排序,Amy 才排到 Cindy 前面。另外,|Collections| 工具類別提供一批操作集合的靜態方法:
import java.util.ArrayList; import java.util.Collections; import java.util.List;
/** Collections 工具類別的常用靜態方法 */ public class CollectionsDemo
public static void main(String[] args)
List<Integer> nums = new ArrayList<>(List.of(5, 1, 4, 2, 3));
Collections.sort(nums); // 遞增排序(就地修改) System.out.println("排序:" + nums);
Collections.reverse(nums); // 反轉 System.out.println("反轉:" + nums);
System.out.println("最大值:" + Collections.max(nums)); System.out.println("最小值:" + Collections.min(nums));
// Collections.shuffle(nums); // 隨機洗牌:每次結果不同
// 不可修改視圖:任何寫入操作都會拋出例外 List<Integer> readOnly = Collections.unmodifiableList(nums); try readOnly.add(99); catch (UnsupportedOperationException e) System.out.println("唯讀清單拒絕修改");
排序:[1, 2, 3, 4, 5] 反轉:[5, 4, 3, 2, 1] 最大值:5 最小值:1 唯讀清單拒絕修改
|unmodifiableList| 回傳的是視圖而非複本:把它交給外部程式,對方就只能讀不能寫,是保護內部集合的常用手法(注意原清單若變動,視圖看到的內容也會跟著變)。
集合選型指南
| 需求 | 建議集合 |
|---|---|
| 有序清單、依索引隨機存取 | |ArrayList| |
| 頻繁在頭尾兩端進出(佇列、堆疊) | |ArrayDeque| |
| 去除重複、只問「有沒有」 | |HashSet| |
| 去重且保留插入順序 | |LinkedHashSet| |
| 去重且隨時排好序 | |TreeSet| |
| 以鍵查值、計數、分組 | |HashMap| |
| 以鍵查值且保留插入順序 | |LinkedHashMap| |
| 以鍵查值且依鍵排序 | |TreeMap| |
選型的思考順序:先問資料的形狀(一串元素選 |Collection| 家族;鍵值對選 |Map| 家族),再問要不要去重(要就 Set),最後問順序需求(不在乎選 Hash 系最快;要插入順序選 Linked 系;要排序選 Tree 系)。三個問題答完,答案幾乎唯一。
綜合範例:單字頻率統計
收尾範例是文字處理的經典任務:統計一段文字中每個單字出現的次數,並列出前三名。解法動員了本章多位主角——|HashMap| 計數、|entrySet| 搬運、|Comparator| 排序:
import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map;
/** * 單字頻率統計:集合框架的綜合應用。 * 流程:切割文字、用 HashMap 計數、搬進 List 排序、輸出前三名。 */ public class WordFrequency
public static void main(String[] args)
String text = "the quick brown fox jumps over the lazy dog " + "the fox is quick and the dog is lazy";
// 步驟一:以空白切割成單字陣列 String[] words = text.split(" ");
// 步驟二:HashMap 累計次數(鍵:單字;值:出現次數) Map<String, Integer> freq = new HashMap<>(); for (String w : words) freq.merge(w, 1, Integer::sum); // 首次放 1,之後累加
// 步驟三:Map 本身沒有順序概念,搬進 List 才能排序 List<Map.Entry<String, Integer>> entries = new ArrayList<>(freq.entrySet());
// 次數遞減;次數相同再依字母序,讓結果完全可預測 entries.sort(Comparator .comparingInt((Map.Entry<String, Integer> e) -> e.getValue()) .reversed() .thenComparing(Map.Entry::getKey));
// 步驟四:輸出前三名 System.out.println("出現次數前三名:"); for (int i = 0; i < 3; i++) Map.Entry<String, Integer> e = entries.get(i); System.out.println((i + 1) + ". " + e.getKey() + ":" + e.getValue() + " 次");
出現次數前三名: 1. the:4 次 2. dog:2 次 3. fox:2 次
驗算一下:the 出現 4 次;dog、fox、is、lazy、quick 各出現 2 次,依字母序 dog 與 fox 排進前三名。這裡計數用 |HashMap|(不需要順序、速度優先),輸出前才排序——「儲存用最快的結構、呈現前再整理順序」是集合實務的常見節奏。
本章小結
集合框架兩大體系:|Collection|(List 有序可重複、Set 不重複、Queue 佇列)與 |Map|(鍵值對);命名規律是「資料結構前綴加介面後綴」。
宣告用介面型別加菱形運算子:|List<String> list = new ArrayList<>();|。
自動裝箱讓基本型別進出集合;比較包裝物件一律用 |equals|,|==| 會踩中 \(-128\) 到 \(127\) 的快取陷阱。
|ArrayList| 底層是自動擴容陣列:隨機存取 \(O(1)\)、頭端插入 \(O(n)\);預設選它,頭尾頻繁進出才考慮 |ArrayDeque|。
走訪有四式:索引 for、for-each、|Iterator|、|forEach| 加 lambda。
走訪中刪除元素必用 |Iterator.remove| 或 |removeIf|,直接呼叫集合的 remove 會拋 |ConcurrentModificationException|。
|HashSet| 去重靠 |hashCode| 定位加 |equals| 比對:自訂類別必須兩者一起覆寫。
順序特性:Hash 系不保證、Linked 系記插入順序、Tree 系自動排序(需 |Comparable| 或 |Comparator|,操作 \(O(\log n)\))。
|Map| 常用:|put|、|get|、|getOrDefault|、|containsKey|、|remove|、|merge|;鍵值都要時走 |entrySet()|。
|Comparator| 搭配 |comparing| 系列、|reversed|、|thenComparing| 組合排序規則;Java 排序是穩定排序。
本章練習
觀念題:|Integer a = 127, b = 127;| 時 |a == b| 為 |true|,改成 128 之後變 |false|,為什麼?正確的比較方式是什麼?(提示:整數快取區間與參考比較。)
實作題:建立一個 |ArrayList| 存放 1 到 20,先用 |removeIf| 刪除所有 3 的倍數,再用 |Iterator| 手動刪除所有大於 15 的數,印出結果。(提示:兩種正解各練一次。)
實作題:自訂 |Point| 類別(欄位 x、y),先「不覆寫」|equals| 與 |hashCode|,把兩個座標相同的點加入 |HashSet| 觀察大小;再正確覆寫後重測。(提示:親眼見證契約的效果。)
實作題:用 |HashMap| 統計一句英文中每個「字母」出現的次數,輸出時依字母順序排列。(提示:|merge| 計數;輸出前把 keySet 搬進 |TreeSet| 或改用 |TreeMap|。)
實作題:對 |Student| 清單完成三種排序:依分數遞減、依姓名字母序、先依分數遞減同分再依姓名。(提示:|comparingInt|、|reversed|、|thenComparing|。)
挑戰題:用 |Map<String, List<String>>| 把一批單字依「字首字母」分組,例如 a 對應 apple 與 ant。走訪輸出時依字首排序。(提示:|computeIfAbsent(key, k -> new ArrayList<>())| 可以優雅地建立分組清單。)