Week 6 的 Lab 回頭鞏固兩個 Week 4–5 的核心技巧:遞迴與排序。 它們在本週的位置並不突兀——第 [sec:adt] 節的鏈結串列天生適合遞迴處理, 第 [sec:generics] 節的 與 都建立在排序之上, 而「把演算法當參數傳」正是介面的又一個應用。 隨附程式在 底下的 資料夾。
遞迴:兩個部分,一個信念
一個函式在定義中呼叫自己。每個正確的遞迴函式都有:
:問題小到可以直接回答,不再遞迴。
:把問題化成更小的同類問題,假設小問題已解,再把答案組合起來。
而且必須能說明「每次遞迴呼叫都讓某個量嚴格變小並終將抵達基底」,否則永不終止。
/** n! = n × (n-1)!,0! = 1。前置條件:n >= 0 */
static long factorial(int n) {
if (n == 0) return 1; // 基底情況
return n * factorial(n - 1); // 遞迴情況:n 嚴格變小
}
/** 陣列 arr[from..] 的總和:第一個元素 + 其餘元素的總和。 */
static int sum(int[] arr, int from) {
if (from >= arr.length) return 0; // 基底:沒有元素了
return arr[from] + sum(arr, from + 1);
}
/** 迴文判斷:頭尾相同,且中間也是迴文。 */
static boolean isPalindrome(String s) {
if (s.length() <= 1) return true;
if (s.charAt(0) != s.charAt(s.length() - 1)) return false;
return isPalindrome(s.substring(1, s.length() - 1));
}
寫遞迴情況時,不要在腦中展開所有層次。只做三件事: (1) 確認呼叫的是「更小」的問題;(2) 假設那個呼叫會正確回傳;(3) 用它的結果組出這一層的答案。 只需說:「如果頭尾相同,且假設 是對的,那整體就是對的。」 這其實就是數學歸納法。
輔助參數與對外包裝
很多遞迴需要「多出來的參數」記錄進度( 的 、二分搜尋的 /)。 呼叫者不該知道這些細節,所以慣例是寫一個對外的簡潔版本包住它——這和封裝的精神一致:
static int binarySearch(int[] haystack, int needle, int lo, int hi) {
if (lo > hi) return -1; // 基底:範圍變空,確定不存在
int mid = (lo + hi) / 2;
if (haystack[mid] == needle) return mid;
else if (haystack[mid] < needle) return binarySearch(haystack, needle, mid + 1, hi); // 右半
else return binarySearch(haystack, needle, lo, mid - 1); // 左半
}
/** 對外版本:呼叫者不需要知道 lo/hi。 */
static int binarySearch(int[] haystack, int needle) {
return binarySearch(haystack, needle, 0, haystack.length - 1);
}
二分搜尋每一層把範圍減半,所以只需 \(\bigO{\log n}\) 層——這是的典型: 分(切成更小的子問題)、治(遞迴解決)、合(組合答案)。 快速冪 也是:算一次 再平方,\(2^{1000}\) 只需約 10 層。
每層遞迴都占用一個堆疊框,而堆疊有限(預設約幾 MB)。 會在數萬層時拋出 ——即使邏輯完全正確。 兩個常見原因:(1) 忘了基底情況或永遠碰不到它( 會一路往負數跑); (2) 問題規模本身太大(用遞迴走訪一百萬個節點的鏈結串列)。後者改用迴圈即可。 遞迴適合「結構本身是遞迴的」問題:樹、分治、巢狀結構;一維線性走訪用迴圈就好。
排序:兩條後置條件
執行後必須同時滿足:
遞增排序:對所有 \(i\),;
是原本元素的一個排列:沒有多、沒有少、沒有變。
很多人只測第 1 條。一個把所有元素都設成 0 的「排序」也滿足第 1 條! 用 一次驗兩條( 由 產生)。
四種演算法的核心想法與迴圈不變量(每回合開始時都成立的事):
@lL0.40L0.36@ 演算法 & 每一回合做什麼 & 迴圈不變量
選擇排序 & 從未排序區找最小值,換到未排序區最前面 & 已排序,且都 \(\le\) 未排序區的每個元素
氣泡排序 & 相鄰兩兩比較,把最大值一路「冒」到尾端;整趟沒交換就提前結束 & 已排序,且都 \(\ge\) 前面的每個元素
插入排序 & 取下一個元素,向左挪出空位後「插入」正確位置 & 已排序(但不一定是最小的那些)
合併排序 & 切半 → 遞迴排序兩半 → 合併兩個已排序陣列 & (遞迴)子呼叫回傳的陣列已排序
static void insertionSort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
// INV: arr[0..i-1] 已排序
int x = arr[i];
int j = i - 1;
while (j >= 0 && less(x, arr[j])) {
arr[j + 1] = arr[j]; // 比 x 大的元素往右挪一格
j = j - 1;
}
arr[j + 1] = x; // 放進空出來的位置
}
}
static int[] mergeSort(int[] input, int start, int end) {
if (end < start) return new int[]{}; // 基底:空範圍
if (end == start) return new int[]{input[start]}; // 基底:單一元素本身就是排好的
int mid = (start + end) / 2; // 分
int[] left = mergeSort(input, start, mid); // 治
int[] right = mergeSort(input, mid + 1, end);
return merge(left, right); // 合:O(left + right)
}
實測:比較次數
把每次 比較都記錄下來,對 \(n = 2000\) 的三種資料實測():
| 演算法 | 隨機 | 已排序(最佳) | 反向(最差) | Big-O(最差) |
|---|---|---|---|---|
| 選擇排序 | 1,999,000 | 1,999,000 | 1,999,000 | \(\bigO{n^2}\),永遠 \(n(n-1)/2\) |
| 氣泡排序 | 1,994,905 | 1,999 | 1,999,000 | \(\bigO{n^2}\);已排序時 \(\bigO{n}\) |
| 插入排序 | 999,913 | 1,999 | 1,999,000 | \(\bigO{n^2}\);已排序時 \(\bigO{n}\) |
| 合併排序 | 19,363 | 11,088 | 10,878 | \(\bigO{n \log n}\),與輸入順序幾乎無關 |
選擇排序完全不看資料:不管輸入如何都是 \(\binom{n}{2} = 1{,}999{,}000\) 次。
氣泡與插入排序對「幾乎排好」的資料很快(\(n-1 = 1999\) 次),這是它們在小型或近乎有序資料上仍被使用的原因。
合併排序約 \(n \log_2 n \approx 2000 \times 11 = 22{,}000\) 的量級,比 \(n^2\) 少了一百倍;\(n\) 越大差距越誇張。 代價是需要 \(\bigO{n}\) 的額外空間。Java 的 對物件用的是合併排序的改良版(TimSort)。
:相等的元素排序後保持原本的相對順序。 中「相等取左邊」就是為了穩定。 當你先依名字排序、再依年級排序,穩定排序能保證同年級的人仍按名字排列。
把演算法當參數: 介面
測試程式如何對四種排序跑同一組測試?定義一個只有一個方法的介面,再把方法參照(method reference)當成它的實作傳進去:
interface Sorter { void sort(int[] arr); } // 函式介面:只有一個抽象方法
static void checkSorter(String name, Sorter sorter) {
for (int[] original : cases) {
int[] toSort = original.clone(), expected = original.clone();
Arrays.sort(expected);
sorter.sort(toSort); // 呼叫「被傳進來的演算法」
assert Arrays.equals(toSort, expected) : name + " 失敗:" + Arrays.toString(toSort);
}
}
checkSorter("insertionSort", Sorting::insertionSort); // Sorting::insertionSort 自動變成一個 Sorter
checkSorter("mergeSort", Sorting::mergeSort);
這是第 [sec:adt] 節「介面 = 契約」的另一種用法:契約只有一條「給你一個陣列,把它排好」,任何滿足契約的函式都能插進來。 第 [sec:generics] 節的 與第 [sec:ws7] 節的 都是同一個模式。
Lab 練習
用遞迴(不可用迴圈)實作並用 測試: (a) —— 在 出現的次數; (b) —— 是否遞增; (c) ——歐幾里得算法:\(\gcd(a, 0) = a\),\(\gcd(a, b) = \gcd(b, a \bmod b)\)。 對每一題寫出:基底情況是什麼?哪個量嚴格變小?
(a) 基底 回 0;遞迴 ;變小的量是 。 (b) 基底 回 ;遞迴 。 (c) 基底 回 ;遞迴 ;變小的量是 (因為 \(a \bmod b < b\))。
寫一個故意錯誤的 :把陣列每個元素都設成 0。把它交給 ,觀察哪一個 抓到它。 接著寫 (不使用 ;提示:對每個元素計數),使測試不必依賴標準函式庫的排序。
回到第 [sec:adt] 節的 ,把 改寫成遞迴的私有輔助方法 :基底 回傳 ;否則 。 比較它與迴圈版本的可讀性,並說明為什麼串列很長時仍應保留迴圈版本。