COMP1110/1140/6710 — Structured Programming


Java Lab 3:登山演算法

完全學習教材

2D 陣列 • 列舉(enum) • 貪婪爬坡 • 矩形二分搜尋


教材內容 觀念講解、圖解、逐步實作、完整測試、延伸挑戰
隨附程式 Direction.java、Mountain.java(含完整中文註解)
適用對象 已學過變數、迴圈、函式與一維陣列的 Java 初學者

依據課程講義「Lab 3: Mountain Climbing」編寫

想像你是一位在「數位山脈」中工作的測量員。你手上有一張高度圖 (heightmap):一個 2D 的整數格子,每個數值代表該座標的海拔高度。 地圖裡藏著一個黃金山頂(golden peak)——全地圖的最高點。 你的任務是寫出一系列演算法,讓程式自己「爬」上這座山: 從一個格子移動到另一個格子、分析周圍的坡度來判斷哪個方向是上坡, 最後還要最佳化路線,用最少的步數登頂。

本教材對應課程 Lab 3 的三個活動,完成後你將學會:

  • 撰寫非平凡的陣列程式:宣告、初始化、巡訪 2D 陣列, 並安全地處理邊界。

  • 使用列舉(enumeration,enum): 用型別安全的方式表達「方向」這種有限的選項。

  • 培養搜尋演算法的直覺:從一步一步的貪婪爬坡, 進化到對數時間的二分搜尋。

  • 用前置條件、後置條件與 assert 寫出 「自我檢查」的程式,並做到完整的分支覆蓋測試。

第 2–4 節是背景知識; 已經熟悉 2D 陣列與 enum 的讀者可以直接跳到 第 5 節的問題設定。 三個活動(第 6–8 節)請依序閱讀, 因為 climb 和 fastTravel 都建立在 lookAround 之上。 隨附的 Mountain.java 可以直接編譯執行 (見第 10 節),完整原始碼收錄於附錄。

背景知識 A:二維陣列

從一維到二維:陣列的陣列

一維陣列 int[] 是「一排整數」。那要怎麼表示一張表格 (例如高度圖)呢?Java 的答案很直接: 把「每一列」做成一個一維陣列,再用另一個陣列把所有列裝起來。 所以 int[][] 讀作「int 陣列 的 陣列」。

// 建立一張 24 列(rows)高、12 行(columns)寬的格子
int[][] heightmap = new int[24][12];

有一點容易混淆:new int[24][12] 其實是 「24 個 int[12]」——24 列,每列 12 個整數。 Java 把維度寫成這個順序,是為了跟索引的寫法一致(見下文)。

2D 陣列的真面目:一個裝著「列」的陣列,每一列又是一個一維陣列。

直接初始化:巢狀大括號

從 Mountain Lab 產生器複製地圖資料時,數值會包在巢狀大括號裡; 每一組內層大括號就是一個「水平的列」:

int[][] smallMap = {
    {10, 20, 10},   // Row 0(第 0 列)
    {20, 50, 20},   // Row 1(第 1 列)
    {10, 20, 10}    // Row 2(第 2 列)
};

存取資料:map[row][column]

要讀取某一格的高度,用兩組方括號:map[row][column]。

  • 第一個數字是垂直位置:你在哪一列(row)。

  • 第二個數字是水平位置:你在哪一行(column)。

Java 的陣列是 0-indexed:地圖的左上角永遠是 map[0][0],右下角是 map[map.length - 1][map[0].length - 1]。 合法索引範圍是 \(0 \le \texttt{row} < \texttt{map.length}\), 超出範圍就會拋出 ArrayIndexOutOfBoundsException。

用 y 與 x 思考:方向與鄰居

在高度圖上導航時,我們習慣把兩個索引取名為 y 與 x:

  • 列(y):改變第一個索引,就是往北或南移動。

  • 行(x):改變第二個索引,就是往東或西移動。

站在 [y][x] 這一格想「看看四周」時,只要調整索引:

方向 意義 索引寫法 變化
北 North(上) 上一列 map[y - 1][x] \(y\) 減 1
南 South(下) 下一列 map[y + 1][x] \(y\) 加 1
東 East(右) 右一行 map[y][x + 1] \(x\) 加 1
西 West(左) 左一行 map[y][x - 1] \(x\) 減 1
站在 [y][x] 環顧四周。特別注意:\(y\) 往「下」增加, 所以「北」是 \(y-1\),跟數學課的 \(y\) 軸方向相反!

map[y][x] 的第一個索引是列(垂直、\(y\)), 第二個才是行(水平、\(x\))。 跟平面座標 \((x, y)\) 的習慣順序剛好相反。 寫錯順序時,方形地圖可能「碰巧沒事」, 換成長方形地圖就會當掉或算錯——這正是本 Lab 用 \(24 \times 12\) 這種非正方形地圖的原因之一。

巡訪整張地圖:巢狀迴圈

處理 2D 陣列的標準寫法是「迴圈中的迴圈」——外層走過每一列, 內層走過該列的每一行:

for (int y = 0; y < map.length; y++) {          // 每一列
    for (int x = 0; x < map[0].length; x++) {   // 該列的每一行
        System.out.print(map[y][x] + " ");
    }
    System.out.println();   // 換列時順便換行
}

因為 2D 陣列其實是「陣列的陣列」,Java 允許每一列的長度 不同(例如三角形的資料)。 本 Lab 的高度圖保證是矩形,但寫程式時仍應把 「地圖是非空矩形」寫成前置條件明確檢查 ——我們稍後會用 isRectangular 這個輔助函式搭配 assert 來做這件事。

背景知識 B:列舉(enum)

為什麼需要 enum?

「方向」只有五種合法值:北、東、南、西、山頂。 如果用整數表示(0 代表北、1 代表東……),會有兩個大問題:

  1. 沒有型別保護:int dir = 7; 完全合法, 但 7 是什麼方向?程式進入無意義的狀態,編譯器卻不會阻止你。

  2. 可讀性差:看到 if (dir == 2), 你得去翻文件才知道 2 是南還是西。

enum(enumeration,列舉)一次解決兩個問題: 它宣告一個新型別,而且這個型別只有你列出的那幾個值。

本 Lab 的 Direction 列舉

課程儲存庫已提供 Direction.java(不可修改), 內容等價於:

public enum Direction {
    NORTH,   // 北:往上一列(y - 1)
    EAST,    // 東:往右一行(x + 1)
    SOUTH,   // 南:往下一列(y + 1)
    WEST,    // 西:往左一行(x - 1)
    PEAK     // 山頂:目前格子比四個鄰居都高
}

每個列舉值(如 Direction.NORTH)都是 Direction 型別的唯一物件,所以可以放心用 == 比較:

Direction dir = lookAround(y, x, heightmap);
if (dir == Direction.PEAK) {
    System.out.println("登頂成功!");
}

只要你的 Java 檔跟 Direction.java 放在同一個資料夾, 就能直接使用這個型別,不需要(也不應該)自己重新定義。

enum 與 switch 是天生一對

根據方向決定「怎麼移動」時,switch 比一長串 if-else 清楚得多。Java 14 之後更推薦箭頭 switch:

// 傳統寫法:每個 case 要自己 break,
// 忘了就會「掉落」到下一個 case(fall-through)——經典 bug!
switch (dir) {
    case NORTH:
        y--;
        break;      // 沒寫 break 的話會繼續執行 case EAST!
    case EAST:
        x++;
        break;
    // ...
}

// 箭頭寫法(Java 14+):每個分支獨立,不會掉落,推薦!
switch (dir) {
    case NORTH -> y--;   // 北:列索引減 1(往上)
    case EAST  -> x++;   // 東:行索引加 1(往右)
    case SOUTH -> y++;   // 南:列索引加 1(往下)
    case WEST  -> x--;   // 西:行索引減 1(往左)
    case PEAK  -> { }    // 什麼都不做
}

對 enum 使用箭頭 switch 時,如果漏列了某個列舉值又沒有 default,編譯器會直接報錯。 這是 enum 的另一個好處:編譯器幫你檢查有沒有漏掉情況。

背景知識 C:契約式思考——前置條件、後置條件與 assert

函式是一份契約

一個設計良好的函式,就像一份契約(contract):

  • 前置條件(precondition):呼叫者必須保證的事。 「你給我的參數必須長這樣,否則後果自負。」

  • 後置條件(postcondition):函式必須保證的事。 「只要你遵守前置條件,我保證回傳值一定滿足這些性質。」

例如 lookAround(y, x, heightmap) 的契約可以這樣寫:

前置條件: (1) heightmap 是非空的矩形 2D 陣列; (2) \(0 \le y <\) 列數,且 \(0 \le x <\) 行數。
後置條件: 回傳 PEAK 時,目前格子嚴格高於四個鄰居; 回傳其他方向時,該方向的鄰居高度是四鄰之最大, 且目前格子的高度 \(\le\) 該鄰居的高度。

用 assert 把契約寫進程式裡

Java 的 assert 語句讓契約「活」起來——條件不成立就立刻停下來:

assert 條件;                 // 條件為 false 時拋出 AssertionError
assert 條件 : "錯誤訊息";     // 加上說明訊息,除錯更快

執行 java Mountain 時,所有 assert 都會被 直接跳過。必須加上 -ea (enable assertions)旗標:java -ea Mountain。 忘記 -ea 是「測試明明全過、其實根本沒跑」的頭號原因。

下面這招利用「assert 沒開就不執行」的特性, 在 main 一開始自我檢查(隨附程式就是這樣寫的):

boolean assertionsEnabled = false;
assert assertionsEnabled = true;   // 有開啟才會執行這個賦值
if (!assertionsEnabled) {
    System.out.println("【警告】assert 未啟用!請改用:java -ea Mountain");
}

問題設定:高度圖與黃金山頂

規則整理

把講義的設定翻成精確的規格:

  • 高度圖是 \(m \times n\) 的 2D 整數陣列; heightmap[y][x] 是座標 \((y, x)\) 的海拔。

  • 地圖上有唯一的最高點(黃金山頂)。

  • Mountain Lab 產生的地圖保證:局部最佳就是全域最佳 ——在任何一格往「最高的鄰居」走,都會嚴格更接近山頂。 (這個保證是 climb 與 fastTravel 正確性的基石!)

  • 地圖邊界之外的高度視為 0 或某個非常低的數字。

邊界處理的核心技巧:heightAt

「山頂在角落」時,某些鄰居根本不存在;直接讀 map[-1][0] 會當掉。與其在主邏輯裡寫四段重複的邊界判斷, 不如把「安全讀取」包成一個小函式——這就是抽象化的威力:

/** 「界外」的高度值:選 int 的最小值,約 -21 億。 */
static final int OUT_OF_BOUNDS = Integer.MIN_VALUE;

/** 安全讀取 (y, x) 的高度;越界時回傳 OUT_OF_BOUNDS。 */
static int heightAt(int y, int x, int[][] heightmap) {
    boolean insideRows = 0 <= y && y < heightmap.length;
    boolean insideCols = 0 <= x && x < heightmap[0].length;
    if (!insideRows || !insideCols) {
        return OUT_OF_BOUNDS;   // 界外:回傳「超級低」的高度
    }
    return heightmap[y][x];     // 界內:正常讀取
}

講義允許「0 或某個非常低的數字」。 但如果地圖上真的出現高度 0(甚至負數,例如海溝), 用 0 當界外值就可能把「界外」誤認成最高的鄰居。 Integer.MIN_VALUE 是 int 能表示的最小值, 絕不可能比任何真實高度大,因此永遠不會勝出。 這種「哨兵值」(sentinel value)的選擇是防禦式程式設計的 常見考量。

Activity 1:lookAround——觀察四周

規格

Direction lookAround(int y, int x, int[][] heightmap)

比較 \((y, x)\) 與北、南、東、西四個鄰居的高度,回傳 最高鄰居所在的方向 (NORTH / EAST / SOUTH / WEST); 若目前格子比所有鄰居都高,回傳 PEAK。

逐步推導

整個函式其實只有四個步驟:

  1. 檢查前置條件:地圖是非空矩形、\((y,x)\) 在圖內。

  2. 讀取五個高度:自己一個、鄰居四個 ——用 heightAt,邊界自動安全。

  3. 找出四鄰的最大值:Math.max 一次比兩個, 巢狀呼叫三次就能比出四個值的最大。

  4. 判斷:自己比最大鄰居還高 \(\Rightarrow\) PEAK; 否則回傳最大鄰居的方向。

static Direction lookAround(int y, int x, int[][] heightmap) {
    // ---- 前置條件檢查(執行時要加 -ea 才會生效)----
    assert isRectangular(heightmap)
            : "前置條件違反:heightmap 必須是非空的矩形陣列";
    assert 0 <= y && y < heightmap.length
            : "前置條件違反:y=" + y + " 超出列範圍";
    assert 0 <= x && x < heightmap[0].length
            : "前置條件違反:x=" + x + " 超出行範圍";

    // ---- 讀取自己與四個鄰居的高度 ----
    int current = heightmap[y][x];                 // 腳下的高度
    int north   = heightAt(y - 1, x, heightmap);   // 北:上一列
    int east    = heightAt(y, x + 1, heightmap);   // 東:右一行
    int south   = heightAt(y + 1, x, heightmap);   // 南:下一列
    int west    = heightAt(y, x - 1, heightmap);   // 西:左一行

    // ---- 找出四個鄰居中的最大高度 ----
    int best = Math.max(Math.max(north, east), Math.max(south, west));

    // ---- 判斷結果 ----
    if (current > best) {
        return Direction.PEAK;      // 我比所有鄰居都高:山頂!
    }
    // 依「北 → 東 → 南 → 西」的優先順序,回傳第一個最高的鄰居
    if (north == best) return Direction.NORTH;
    if (east == best)  return Direction.EAST;
    if (south == best) return Direction.SOUTH;
    return Direction.WEST;          // 剩下的只可能是西
}

兩個值得注意的設計決定

(一)「嚴格更高」才算山頂。 判斷式是 current > best 而不是 >=。 若自己跟最高鄰居一樣高,我們選擇回傳那個鄰居的方向, 而不是宣稱自己是山頂——因為山頂的定義是「比鄰居都高」。

(二)打平(tie)時行為要明確。 若兩個鄰居同為最高(例如講義 \(3\times3\) 地圖的角落, 東、南同為 20),程式依「北 \(\to\) 東 \(\to\) 南 \(\to\) 西」 的順序取第一個。Mountain Lab 的有效地圖沿途不會出現這種情況, 但讓程式行為明確且可預測永遠是好習慣—— 這也讓我們的測試可以放心斷言結果。

\(3\times4\) 測試地圖:每一格的小箭頭是 lookAround 的回傳方向(星號=PEAK),對應 Mountain Lab 的 「flow arrows」功能。粗藍色箭頭是第 7 節 climb 從 [0][0] 出發的實際路線(3 步登頂)。

測試策略

講義要求在 main 用 assert 徹底測試, 目標是完整分支覆蓋——五種 Direction 值 每一種都至少被回傳一次,並特別關照陣列邊緣的座標。 隨附 Mountain.java 的做法:

  1. 講義範例圖(\(3\times3\)):山頂、四個邊、 以及「打平」的角落。

  2. 手工設計圖(\(3\times4\),圖 3): 鄰居高度全部相異,12 格逐一斷言, 一張圖就覆蓋全部五種回傳值與四條邊。

  3. 退化地圖:\(1\times1\)(四鄰全是界外)、 單列 \(1\times5\)、單行 \(5\times1\)。

  4. 窮舉測試:程式產生「錐形地圖」 (見第 9 節),讓山頂輪流放在每個位置, 再對每一格比對理論答案——共 87,667 筆。

Activity 2:climb——一步一步爬到山頂

貪婪爬坡法

int[] climb(int[][] heightmap)

策略非常直觀,叫做貪婪爬坡法(greedy hill climbing):

  1. 站在 [0][0]。

  2. lookAround 看看四周。

  3. 是 PEAK?結束!否則往回報的方向走一步,回到步驟 2。

「貪婪」的意思是:每一步只看眼前哪邊最高就往哪邊走, 從不回頭、也不做長遠規劃。一般的地形上貪婪法可能被 「假山頂」(局部最高點)騙住;但 Mountain Lab 的地圖保證 局部最佳就是全域最佳,所以貪婪法在這裡必定成功。

在有效地圖上,每走一步腳下的高度都嚴格上升 (走向的是「比自己高」的最高鄰居)。 高度不可能無限上升(格子數量有限), 所以走的步數不可能超過格子總數,迴圈保證終止。 隨附程式還把這個推理寫成防禦性 assert: 一旦步數超過 \(m \times n\),代表地圖違反前置條件 (例如出現同高的「高原」讓你原地打轉), 與其無窮迴圈,不如立刻失敗、給出清楚的錯誤訊息。

完整實作

static int[] climb(int[][] heightmap) {
    assert isRectangular(heightmap)
            : "前置條件違反:heightmap 必須是非空的矩形陣列";

    int y = 0;          // 目前位置:從左上角 [0][0] 出發
    int x = 0;
    int steps = 0;      // 已經走了幾步

    Direction dir = lookAround(y, x, heightmap);
    while (dir != Direction.PEAK) {       // 還不是山頂就繼續走
        switch (dir) {                    // 依方向更新座標
            case NORTH -> y--;            // 北:往上一列
            case EAST  -> x++;            // 東:往右一行
            case SOUTH -> y++;            // 南:往下一列
            case WEST  -> x--;            // 西:往左一行
            case PEAK  -> { }             // 不可能(while 已排除)
        }
        steps++;

        // 防禦性檢查:步數絕不可能超過格子總數
        assert steps <= heightmap.length * heightmap[0].length
                : "步數超過格子總數:這張地圖大概不是有效的單峰地圖";

        dir = lookAround(y, x, heightmap);   // 到新位置再看一次
    }

    // 依講義要求:印出總步數
    System.out.println("'climb' found the peak in " + steps + " steps!");

    // 後置條件:回傳的座標必須真的是山頂
    assert lookAround(y, x, heightmap) == Direction.PEAK
            : "後置條件違反:climb 停下來的位置不是山頂";

    return new int[]{y, x};   // 打包成長度 2 的新陣列 {y, x}
}

幾個細節值得留意:

  • 回傳值是「新建的長度 2 陣列」 new int[]{y, x}——先 \(y\) 後 \(x\), 跟索引順序一致。

  • 步數訊息的格式依講義: ’climb’ found the peak in 39 steps!。

  • 山頂如果就在起點([0][0] 本身最高), 迴圈一次都不會執行,印出 0 步——這是重要的邊界情況。

  1. 山頂在起點:期望 0 步、回傳 {0, 0}。

  2. 山頂在對角(離起點最遠)與正中央。

  3. 步數驗證:在有效地圖上每步都嚴格接近山頂, 所以步數應該恰好等於起點到山頂的曼哈頓距離 \(|y_{peak}| + |x_{peak}|\)——這是比「答案對不對」 更強的檢查。

  4. 比較 int[] 要用 Arrays.equals(a, b); 寫 a == b 比的是「是不是同一個物件」, 幾乎一定是 false!

Activity 3:fastTravel——矩形二分搜尋

動機:climb 太慢了

climb 可靠但緩慢:\(m \times n\) 的地圖上,最壞要走 \((m-1) + (n-1)\) 步(起點在一角、山頂在對角)。 \(1024 \times 1024\) 的地圖就是最多 2046 步。 能不能不要一格一格走,而是「跳」著找?

回想一維二分搜尋:在排好序的陣列裡找目標時, 每次探測中間元素,就能丟掉一半的搜尋範圍。 關鍵在於:中間元素的資訊告訴我們「目標一定不在哪半邊」。

把二分搜尋升級到 2D

在高度圖上,lookAround 就扮演這個「指路」的角色。 我們隨時維護一個搜尋矩形 \([\texttt{top} .. \texttt{bottom}] \times [\texttt{left} .. \texttt{right}]\), 並維持一個迴圈不變量(loop invariant): 山頂一定在矩形裡。每一輪:

  1. 取矩形中心點 \((\texttt{midY}, \texttt{midX})\), 呼叫 lookAround。

  2. 回傳 PEAK \(\Rightarrow\) 找到了,結束。

  3. 否則依方向砍掉一半:

lookAround 回傳 推論 矩形更新
NORTH 山頂的列 \(<\) midY bottom = midY - 1
SOUTH 山頂的列 \(>\) midY top = midY + 1
EAST 山頂的行 \(>\) midX left = midX + 1
WEST 山頂的行 \(<\) midX right = midX - 1

有效地圖保證:lookAround 給的方向讓你 嚴格更接近山頂。假設中心點得到 NORTH, 表示「往北走一步會更接近山頂」。 如果山頂的列號 \(\ge\) midY(在同一列或更南邊), 往北走只會拉遠或不變與山頂的垂直距離 ——矛盾!所以山頂的列號必定 \(<\) midY, 中心點那一列以南(含該列)可以整片安全地砍掉。 其他三個方向同理。 注意:一次探測只縮小一個維度 (南北向或東西向其中之一),這正是步數上限是 \(\lceil\log_2 m\rceil + \lceil\log_2 n\rceil + 1\) 而不是 \(\max\) 的原因。

fastTravel 在 \(8\times8\) 地圖(山頂在 \((1,6)\))上的 搜尋過程:4 次探測登頂。灰色區域是第一次探測後就再也不用考慮的部分。 同一張圖 climb 要走 \(1+6=7\) 步。

完整實作

static int[] fastTravel(int[][] heightmap) {
    assert isRectangular(heightmap)
            : "前置條件違反:heightmap 必須是非空的矩形陣列";

    // 搜尋範圍:一開始是整張地圖(都是「含」邊界的索引)
    int top    = 0;
    int bottom = heightmap.length - 1;
    int left   = 0;
    int right  = heightmap[0].length - 1;

    int steps = 0;   // 探測(probe)了幾次

    while (true) {
        // 取搜尋矩形的中心點(整數除法自動向下取整)
        int midY = (top + bottom) / 2;
        int midX = (left + right) / 2;

        steps++;
        Direction dir = lookAround(midY, midX, heightmap);

        if (dir == Direction.PEAK) {
            System.out.println(
                    "'fastTravel' found the peak in " + steps + " steps!");
            return new int[]{midY, midX};
        }

        // 根據方向,砍掉「山頂不可能在」的那半邊
        switch (dir) {
            case NORTH -> bottom = midY - 1;  // 山頂在更北:往上縮
            case SOUTH -> top    = midY + 1;  // 山頂在更南:往下縮
            case EAST  -> left   = midX + 1;  // 山頂在更東:往右縮
            case WEST  -> right  = midX - 1;  // 山頂在更西:往左縮
            case PEAK  -> { }                 // 不可能(上面已 return)
        }

        // 迴圈不變量:山頂永遠在 [top..bottom] x [left..right] 裡
        assert top <= bottom && left <= right
                : "搜尋矩形變成空的:這張地圖大概不是有效的單峰地圖";
    }
}

寫任何二分搜尋,先想清楚不變量: 「答案永遠在目前範圍裡」。 每次更新範圍前問自己:這樣砍,會不會把答案砍掉? 砍完範圍有沒有嚴格變小(避免無窮迴圈)? 上面的程式把不變量直接寫成 assert—— 萬一地圖無效(不滿足前置條件),程式會立刻告訴你, 而不是默默跑出錯誤答案。

效能比較

climb(逐步爬坡) fastTravel(矩形二分)
策略 每步走向最高的鄰居 每次探測搜尋矩形的中心
一次動作獲得的資訊 前進一格 排除約一半的搜尋範圍
步數量級(\(m \times n\) 地圖) \(O(m + n)\),最壞 \((m-1)+(n-1)\) 步 \(O(\log m + \log n)\),至多 \(\lceil\log_2 m\rceil + \lceil\log_2 n\rceil + 1\) 次
\(24 \times 12\)、山頂在對角 步 次探測
\(128 \times 128\)、山頂在 \((100,111)\) 步 次探測
\(1024 \times 1024\)(估算) 最多 2046 步 約 21 次探測

表中 \(24\times12\) 與 \(128\times128\) 兩列是隨附程式的實測結果 (見下一節的執行輸出)。地圖越大,差距越驚人—— 這就是對數時間演算法的威力。

測試工程:錐形地圖與窮舉驗證

手寫測試地圖很快就會遇到瓶頸:想測「山頂在任何位置都正確」, 總不能手刻幾百張地圖。隨附程式用了一個小小的測試工程技巧 ——寫一個地圖產生器,再配一個理論答案公式:

/** 以 (peakY, peakX) 為山頂的「錐形」地圖:高度隨距離遞減。 */
static int[][] coneMap(int rows, int cols, int peakY, int peakX) {
    int[][] map = new int[rows][cols];
    int base = 3 * rows + 2 * cols;   // 讓所有高度都是正數
    for (int y = 0; y < rows; y++) {
        for (int x = 0; x < cols; x++) {
            map[y][x] = base - 3 * Math.abs(y - peakY)
                             - 2 * Math.abs(x - peakX);
        }
    }
    return map;
}

兩個方向的權重故意取不同值(3 與 2), 這樣四個鄰居永遠不會同高、每一格的正確答案唯一,而且有封閉公式: 列還沒對齊山頂時答案必是 NORTH/SOUTH (權重 3 壓過 2),列對齊後看東西,兩者都對齊就是 PEAK。 於是可以窮舉——每種地圖尺寸 \(\times\) 每個山頂位置 \(\times\) 每個座標——逐一比對 lookAround 的輸出, 一口氣驗證 87,667 筆。 同一批地圖也拿來系統化測試 climb (斷言步數恰等於曼哈頓距離)與 fastTravel (斷言探測數不超過理論上限)。

實際執行 java -ea Mountain 的輸出節錄:

=== Lab 3: Mountain Climbing — 完整測試 ===
[1] lookAround:3x3 講義範例地圖 ... 通過
[2] lookAround:3x4 地圖 12 格全驗證(分支覆蓋)... 通過
[3] lookAround:1x1/單列/單行 退化地圖 ... 通過
[4] lookAround:錐形地圖窮舉測試,共比對 87667 個座標 ... 通過
[5] climb:手工地圖測試
'climb' found the peak in 0 steps!
'climb' found the peak in 4 steps!
    ...
    地圖 24x12,山頂在 (23,11):
      'climb' found the peak in 34 steps!
      'fastTravel' found the peak in 8 steps!
    ...
[7] 步數比較:128x128 地圖,山頂在 (100, 111)
    'climb' found the peak in 211 steps!
    'fastTravel' found the peak in 9 steps!
    同一座山,climb 走了 211 步;fastTravel 只探測 9 次!

=== 全部測試通過! ===

編譯、執行與繳交

編譯與執行

把 Direction.java 與 Mountain.java 放在同一個資料夾,然後:

$ javac Direction.java Mountain.java   # 編譯(產生 .class 檔)
$ java -ea Mountain                    # 執行,-ea 開啟 assert

沒有 -ea,所有 assert 都不會執行—— 測試「看起來全過」,其實根本沒跑。 隨附程式在 main 開頭會自動偵測並警告這種情況。

用 git 繳交

依照講義,完成後記得 add、commit、push:

$ git add Mountain.java                    # 或你的檔案名稱
$ git commit -m "Completed mountain lab"
$ git push

離開實驗課之前,確認你已經跟助教討論過你的程式, 而且所有工作都已經 commit 並 push 到 GitLab!

延伸挑戰

以下挑戰不在 Lab 要求範圍內,但能大幅加深理解:

  1. 流向圖產生器:寫 printFlowMap(int[][] heightmap), 每格印出一個字元(^、>、v、 <、P)代表 lookAround 的結果, 重現圖 3 的效果。

  2. 記錄完整路徑:讓 climb 除了山頂座標外, 也記下沿途經過的每個座標(提示:使用 ArrayList<int[]>,在 java.util 套件裡)。

  3. 高原問題:手工造一張含相鄰同高格子的地圖, 觀察 climb 的防禦性 assert 如何避免無窮迴圈。 想想:如果把 lookAround 的判斷改成 「不嚴格更高就算 PEAK」,會有什麼新問題?

  4. 多峰地圖:造一張有兩個局部高點的地圖。 climb 會發生什麼事?fastTravel 呢? 哪一個的錯誤比較「安靜」(不容易被發現)?

  5. 證明步數上限:解釋為什麼 fastTravel 的探測次數不超過 \(\lceil\log_2 m\rceil + \lceil\log_2 n\rceil + 1\)。 (提示:每次非 PEAK 探測, 某一個維度的範圍長度至少減半。)

  6. 八方向擴充:若允許斜走(東北、西北……), Direction 的五個值就不夠用了。 在不修改 Direction.java 的前提下, 設計你自己的 Direction8 列舉並改寫三個函式。

完整程式碼:Direction.java

完整程式碼:Mountain.java