Phase 03 — Algorithms(Day 21–30)

100 Day Engineer Challenge

Phase 03 — Algorithms(Day 21–30)

這份逐日教材依循內部規劃文件的範圍與排序展開1

本檔案由兩個任務接力完成:本段(T-023)涵蓋 Day 21–25:Complexity /Two Pointers / Sliding Window / Binary Search / Sorting;Day 26–30(Recursion / DFS / BFS / Backtracking / Greedy / Dynamic Programming /Graph algorithms)由 T-024 接續寫在本檔案後半段,不另開新檔。

Day 21–25 的順序依 prerequisite chain2:Complexity 排第一,因為它是評估後面每個 pattern「為什麼比 brute force 好」的共同語言——沒有它,後面每個 pattern 的「優化」都說不出優化了什麼;Two Pointers / Sliding Window 排在 Complexity 之後,兩者都依賴 Phase 02 Array 的隨機存取特性,且是「用 O(1) 額外空間換掉一層迴圈」這個思路最直接的體現,適合在建立 Complexity 語言後立刻練習;Binary Search 同樣依賴 Array 的隨機存取+ Complexity,但比 Two Pointers/Sliding Window 更抽象(要在「排序後的陣列」以外的抽象搜索空間上操作),排在其後;Sorting 放在最後,因為 Binary Search on Answer 與後續 Greedy(T-024)都需要先理解「排序」本身的成本與行為,且 Sorting 是這 5 天中唯一一個「演算法本身」而非「解題技巧」的主題,適合作收尾、承接下一段的 Recursion/DFS/BFS。

每個 pattern 依分類標準要求的 5 段式內容撰寫3(Why / Mechanism /Trade-off / Failure mode / Backend 連結),並依「DSA 的特殊驗收」4,每天至少一題練習留下 Pattern / Why / Mistake / Complexity / Reusable Insight 五欄紀錄,而不是用 Phase 01/02 的 Understand/Recall/Apply/Explain 式 DoD——這是 Algorithms 這個 Phase 特有的驗收格式。

Day 21 — Complexity:讀懂演算法的時間/空間成本

學習目標

看完今天內容後,能夠:

  1. 不看資料,對一段沒看過的程式碼逐行推導出時間與空間複雜度,並指出瓶頸在哪一段。
  2. 解釋「攤銷複雜度」與「最差情況複雜度」的差異,並舉出一個攤銷分析會被誤判的情境。
  3. 判斷同一個問題的兩種解法,在小型輸入與大型輸入下哪個實際更快,而不是只看 Big-O 記號本身。

教材大綱

1

Complexity Analysis(Big-O)

Core FundamentalsLv.4

What:Big-O 描述一個演算法所需的資源(時間或空間)隨輸入規模 n成長時的成長趨勢——只保留成長最快的那一項、丟掉常數與低階項。O(2n + 100) 寫成 O(n),因為當 n 夠大時,+100 這個常數與 2這個係數對「趨勢」的影響可以忽略,重要的是「輸入變兩倍,成本大概變兩倍」這件事本身。

Why

如果沒有一套跟硬體、程式語言無關的共同語言,兩個工程師沒辦法在不跑 benchmark 的情況下討論「哪個解法比較好」;Big-O 讓你能在寫 code 之前,只憑演算法的邏輯結構,就預測出「當資料量從 1000 筆長到 1000 萬筆時,這段程式碼會不會變成系統瓶頸」,這是所有效能設計決策的起點。

Mechanism

對每個成長趨勢,用具體操作說明「為什麼是這個複雜度」:

  • O(1)——常數時間,操作次數不隨 n 改變。例:array 用 index 存取(base + k*size 直接算出位置,見 Day 11)、hash map 平均情況的 get/put(見 Day 12:計算 hash 值、跳到對應 bucket,這兩步都不隨 n 增加而變多)。
  • O(log n)——每一步都把問題規模砍掉一個固定比例(通常是一半)。例:binary search 每比較一次,搜索範圍就減半,n 筆資料最多只需要log₂n 次比較就能縮到剩 1 筆——n = 1,000,000 只需要約 20 次。平衡二元樹(Day 18)的 search/insert/delete 也是 O(log n),理由相同:每往下一層,還沒探索的節點數量減半。
  • O(n)——操作次數與 n 成正比,通常是走訪一次全部資料,不會重複走訪同一筆。例:在未排序陣列裡找最大值,必須每個元素都看過一次,不能跳過任何一個。
  • O(n log n)——對 n 筆資料,每筆資料都要付出 O(log n) 的成本,或是把問題切成 log n 層、每層要處理全部 n 筆。例:merge sort 把陣列切成 log n 層(每層都是切一半),每層都要花 O(n) 把資料合併回去,總成本 O(n) × O(log n) = O(n log n)(Day 25 會展開)。
  • O(n²)——對 n 筆資料中的每一筆,都要再走訪一次其餘的 n 筆。最典型的寫法是雙層巢狀迴圈跑過同一個集合:外層跑 n 次,內層對每個外層元素再跑 n 次,總共 n × n = n² 次操作。例:naive 重複偵測(雙層迴圈比對每一對元素是否相同)。

Space Complexity:跟時間複雜度用同一套記號,但量的是「額外需要的記憶體」,不包含輸入本身占用的空間。要注意兩個容易漏算的地方:(1) recursion 的 call stack——每多一層遞迴呼叫就多佔一份 stack frame,遞迴深度為 d 時 space complexity 至少 O(d),即使函式本身沒有配置任何額外資料結構;(2) 回傳值如果是「新配置的資料結構」(例如回傳一個新陣列而不是原地修改),那份回傳值也要算進 space complexity,除非題目明確說「不計入輸出所需的空間」。

Amortized Complexity(攤銷複雜度):某個操作大部分時候很便宜,但偶爾會觸發一次昂貴的操作,把這個昂貴操作的成本平均攤到所有操作上,得到的長期平均成本。Day 11 動態陣列的 append 是最標準的例子:多數 append 是 O(1)(capacity 還夠用,直接寫入),但每次 capacity 用滿時觸發的 resize 是 O(n)(要配置新陣列並複製全部元素);因為 resize 的頻率隨 n 遞減(capacity 每次翻倍),把 n 次 append 中所有 resize 的複製成本加總、除以 n,平均每次操作只分攤到O(1)攤銷 O(1) 不等於「每次都是 O(1)」——這正是下面 Failure mode 要強調的地方。

Trade-off

時間與空間經常互相交換——用一個 hash set 記錄「看過的元素」可以把 O(n²) 的重複偵測降到 O(n) 時間,代價是多付出 O(n)額外空間;反過來,如果記憶體極度受限(例如嵌入式系統),有時會刻意選擇時間較差但空間 O(1) 的演算法(例如原地排序、原地反轉)。另一個常被忽略的 trade-off:Big-O 相同不代表實際速度相同——O(n) 但每次操作都要算 hash、處理 collision 的雜湊表走訪,實務上經常比 O(n log n) 但每次操作都只是簡單比較、且有良好 cache locality 的排序走訪更慢,尤其當 n 不大、常數因子占主導的時候。

Failure Modes

  1. 誤判「巢狀」一定是 O(n²):巢狀迴圈的複雜度取決於兩層各自的範圍,不是「有巢狀就是平方」。外層跑 n 次、內層固定跑 k次(k 不隨 n 變化)是 O(n·k) = O(n);外層跑 n 次、內層跑m 次(m 是另一個獨立集合的大小)是 O(n·m),不是 O(n)也不是 O(n²)
  2. 誤判 hash map/set 操作一定是 O(1):這是平均情況(好的 hash function、低碰撞率),最差情況(大量 hash 碰撞、退化成 linked list 走訪)是 O(n)。如果一個迴圈裡對同一個 hash set 做n 次操作,平均情況總成本 O(n),但如果 hash function 設計得差、或攻擊者能刻意製造碰撞(hash flooding),總成本可能退化到 O(n²)。
  3. 把攤銷成本當成保證的單次成本:見過某次 append 花了明顯比其他幾百次都久的時間,誤以為「這個函式效能不穩定」,但如果是攤銷 O(1) 的資料結構(動態陣列、Go 的 map),這種偶發的尖峰是設計上預期的行為,不是 bug;但如果這個尖峰發生在延遲敏感的路徑上(例如 API request 的 hot path),攤銷 O(1) 仍然可能造成使用者能感受到的 p99 延遲尖峰——這時該優化的不是「平均值」,而是「最差單次成本」,見下方 Backend 段落。
  4. 在迴圈內對 list/slice 呼叫 contains/inlist 或未排序slice 的成員檢查是 O(n),如果這行程式碼被包在一個跑 n 次的迴圈裡,整段程式碼就從「看起來像 O(n)」的迴圈變成實際 O(n²)——這是 code review 最常漏掉的複雜度陷阱之一,因為兩個 O(n) 「疊」在一起很容易被略讀成「反正都是 O(n)」。

Backend Applications

  • N+1 query 問題:對 n 筆父資料,逐一各發一次 query 抓子資料,總共 n+1 次資料庫往返(round trip),是 O(n) 次網路 I/O;用一次JOIN 或一次 WHERE id IN (...) 批次查詢可以把它降到 O(1)次往返(查詢本身的資料庫端成本另計)。這是複雜度分析在 Backend 最常見的實戰場景——差異不是「演算法複雜度」而是「I/O 次數」,但分析方法完全相同:找出隨 n 成長的操作、算出成長趨勢。
  • p99 延遲尖峰排查:上面 Failure mode 第 3 點提到的攤銷成本尖峰,在真實系統中常表現為「平均延遲很低,但 p99/p999 延遲遠高於平均」。排查時如果懷疑是攤銷操作造成,可以檢查是否有動態陣列 resize、hash map rehash、連線池動態擴充等「平常 O(1)、偶爾 O(n)」的操作發生在 request 處理路徑上,並考慮預先配置足夠容量來消除這類尖峰(Day 11 已示範過 slice 預先分配容量的做法)。
  • 分頁 OFFSET 陷阱SELECT * FROM t ORDER BY id LIMIT 20 OFFSET 100000 表面上看起來是常數時間的分頁,但多數資料庫實作仍要先數過(或掃過)前 OFFSET 筆再開始回傳,等同 O(offset) 而不是 O(1),offset 越大越慢——這是「複雜度直覺被 SQL 語法包裝掉」的典型例子,Day 31+(Phase 04 Database Internals)會從 B-Tree 結構的角度解釋為什麼。
陌生題

以下是一段沒看過的程式碼(虛擬碼,語意等同 Go):

``text func process(records []Record) []string {seen := make(map[string]bool)var result []string for _, r := range records { // (A)key := computeKey(r) // O(1),純字串組合 if !seen[key] { // (B)seen[key] = true // (C)merged := mergeWithExisting(result, r) // (D) 見下方說明 result = merged}}return result}``

其中 mergeWithExisting(result, r) 的實作是:走訪目前的 result(長度最多與 records 同長,設為 m),找出是否有語意相同的項目可以合併,若有就地更新、若無就 append,本身是 O(m) 操作。不能直接看答案,先自己推導:(A) 是一層 O(n) 迴圈;(B)(C) 是 hash map 操作,平均 O(1);(D) 每次呼叫是 O(m),而 mresult 目前長度)最壞情況下會跟著迴圈次數一起成長到接近 n。把 (D) 攤在 (A) 的 n次迴圈裡,最壞情況總成本是 O(1) + O(2) + O(3) + ... + O(n) =O(n²)——即使程式碼裡完全沒有寫出巢狀迴圈的語法(mergeWithExisting的迴圈被包在函式呼叫裡),複雜度依然是 O(n²),瓶頸在 (D)。改善方式:如果「合併」的判斷條件可以轉換成 key 查找(例如用另一個 hash map 記錄「語意 key → result 裡的位置」),(D) 可以降到平均 O(1),整體降到 O(n)。

練習(Day 21)

LeetCode 128 — Longest Consecutive Sequence(給一個未排序整數陣列,找出最長的連續整數序列長度,例如 [100,4,200,1,3,2] → 答案4,序列是 1,2,3,4)。要求依序寫出、比較至少兩種解法的複雜度:

  1. Brute force:對每個數字,往上一個一個數,看能延伸多長。最差情況(例如輸入是 1,2,3,...,n)每個起點都要往上數到底,O(n²)。
  2. Sort 後掃描:先排序 O(n log n),再一次線性掃描,遇到nums[i] == nums[i-1]+1 就延續當前序列長度,否則重置,O(n log n)+ O(n) = O(n log n)。
  3. HashSet 最佳解:把所有數字丟進一個 hash set,對每個數字,只有當 num-1 不在 set 裡時才開始往上數(代表它是某個序列的起點,避免對同一個序列重複計算起點),每個數字只會被「往上數」的過程走訪一次,總成本攤銷 O(n),額外空間 O(n)。

寫出解法 3 的完整程式碼並用 [100,4,200,1,3,2][](空陣列)兩組測資驗證輸出正確。

Day 21 過關標準(DoD)—— DSA 特殊驗收格式

對 LeetCode 128 留下以下紀錄:

``text Pattern: HashSet 判斷序列起點(只對「起點」做延伸掃描)Why: 用 O(1) 平均查找取代「往回找前一個數字是否存在」的重複計算,把每個數字最多只走訪一次,攤銷總成本壓到 O(n)。Mistake: 第一次寫容易忘記「只在 num-1 不在 set 裡才開始往上數」這個判斷,漏掉的話每個數字都會重新往上數一次,退化回 O(n²)(雖然結果依然正確,但複雜度分析會做錯)。Complexity: 時間 O(n)(攤銷,因為每個數字只會被「延伸掃描」走訪一次);空間 O(n)(hash set 存全部數字)。Reusable Insight: 「用 hash set 判斷某個位置是不是某段連續/重複範圍的起點,避免對同一段重複掃描」是可以搬到其他問題的通用技巧(例如區間合併、重複子陣列偵測)。``

同時能在不看資料的情況下,對任一段 10 行以內的陌生程式碼,逐行推導出時間與空間複雜度(比照上方陌生題的推導方式),並指出其中的瓶頸是哪一行/哪一段。

  1. 依 00-master-curriculum.md 第 7 章與 00-knowledge-dependency-graph.md 第 2.3 節
  2. 依 00-knowledge-dependency-graph.md §2.3 的 prerequisite chain 定義
  3. 依 curriculum/content-standard.md 要求的 5 段式內容
  4. 依 00-master-curriculum.md 第 22 節「DSA 的特殊驗收」

Day 22 — Two Pointers

學習目標

看完今天內容後,能夠:

  1. 判斷一個問題是否適合用 Two Pointers 求解(辨識「排序後」或「兩端往中間逼近」的結構特徵)。
  2. 分辨「對向雙指標」(opposite-direction)與「同向雙指標/快慢指標」(fast-slow)兩種變體各自適用的場景。
  3. 用 Two Pointers 把一個 brute force O(n²) 解法優化到 O(n),並解釋為什麼正確性不會因為跳過某些組合而受影響。

教材大綱

1

Two Pointers

Supporting TopicsLv.3

What:用兩個指標(索引)同時走訪一個資料結構(通常是排序後的陣列或字串),依據某個條件決定指標往哪個方向移動,取代原本需要巢狀迴圈才能列舉的「所有配對」。

Why

很多問題的 brute force 解法是「對每一對元素都檢查一次」,需要 O(n²) 時間;但如果資料已排序(或具備某種單調性),大部分配對其實不需要真的檢查——排序後的陣列有個關鍵性質:如果目前這對(left, right) 的和太大,那麼「把 right 換成更小索引」永遠不會讓和變得更大(因為排序過,索引更小的值不會更大),於是可以直接排除一整批不可能的配對,而不用真的逐一檢查。Two Pointers 的本質就是利用這種單調性,把 O(n²) 的窮舉降到 O(n)。

Mechanism

兩種主要變體:

  • 對向雙指標(opposite-direction)left 從頭開始、right從尾開始,往中間逼近。典型應用:在已排序陣列中找「和等於 target 的一對數字」——比較 nums[left]+nums[right]target:太小就 left++(換更大的值,增加和)、太大就right--(換更小的值,減少和)、相等就找到答案。每一步都讓leftright 的距離縮短 1,最多跑 n 步,left 永遠不會超過 right,覆蓋所有「不會被排除」的配對。
  • 同向雙指標/快慢指標(fast-slow):兩個指標從同一端出發,以不同速度或不同觸發條件前進。典型應用一:原地移除/去重slow 指向「下一個要寫入的位置」,fast 逐一檢查每個元素,符合條件才把 fast 位置的值寫到 slow 再讓 slow++);典型應用二:Linked List 判斷是否有環(Floyd's Cycle Detection,slow 每次走一步、fast 每次走兩步,如果鏈表有環,fast 最終一定會追上 slow;如果沒有環,fast 會先走到 nil)——這個應用不需要排序,靠的是「速度差」這個單調遞增的相對位移,而不是數值大小的單調性。
Trade-off

Two Pointers 把時間複雜度從 O(n²) 降到 O(n),且多數情況額外空間只需要 O(1)(兩個索引變數),比起用 hash map 存「看過的元素」換取 O(n) 時間、O(n) 空間的做法(例如 Two Sum 在未排序陣列上的標準解法)更省空間;代價是通常要求輸入已排序(若原本未排序,排序本身要付出 O(n log n),如果只需要求解一次,O(n log n)+ O(n) 仍然比 O(n²) 好,但如果資料是動態變動、需要反覆查詢,維護排序狀態的成本可能不划算,這時 hash map 解法更合適)。

Failure Modes

  1. 在未排序資料上直接套對向雙指標:對向雙指標的正確性建立在「排序後移動指標的方向必然朝正確方向逼近」這個單調性上;資料沒排序時,nums[left]+nums[right] 太大不代表換更小索引的right 一定能修正,會漏掉正確答案。
  2. 移動指標的條件寫反:對向雙指標最常見的錯誤是「和太大時卻移動 left」(應該移動 right 讓和變小);同向雙指標最常見的錯誤是快慢指標的觸發條件寫成 fast == slow(在起點就相等,迴圈根本不會執行)而不是先移動再比較。
  3. 邊界條件 left == rightleft > right 沒有正確處理:對向雙指標的迴圈條件通常是 left < right(找「一對」不同位置的元素)而不是 left <= right,如果題目要求「同一個元素能不能用兩次」沒看清楚,容易在邊界多算或漏算一種情況。

Backend Applications:日誌/事件串流的「去重後寫入」常用同向雙指標的原地去重模式(slow/fast)避免額外配置一份新陣列;Rate limiter 的滑動視窗計數(下一天 Sliding Window 會展開)本質上也是一種同向雙指標,只是移動條件從「數值大小」換成「時間戳記」。

練習

LeetCode 15 — 3Sum(給一個整數陣列,找出所有和為 0、彼此不重複的三元組)。先排序 O(n log n),固定第一個數字後,對剩下的部分用對向雙指標找「兩數和等於 -nums[i]」,把原本 O(n³)的窮舉降到 O(n²)。額外要求:處理「跳過重複數字」的邏輯(固定nums[i] 時若與前一個相同就跳過;left/right 找到答案後也要跳過連續重複值),並用至少一組含重複數字的測資(例如[-1,0,1,2,-1,-4])驗證輸出不含重複三元組。

Day 22 過關標準(DoD)—— DSA 特殊驗收格式

``text Pattern: 排序 + 固定一位 + 對向雙指標(O(n³) 窮舉降到 O(n²))Why: 排序後,內層「找兩數和等於目標值」的子問題可以用對向雙指標一次掃描解決,不必對剩餘元素做巢狀窮舉。Mistake: 忘記跳過重複的 nums[i](外層)或重複的 left/right 值(內層),導致輸出包含重複的三元組;另一個常見錯誤是內層指標移動的方向判斷寫反(和太大該縮小 right,寫成移動 left)。Complexity: 時間 O(n²)(排序 O(n log n) 可忽略,外層 O(n) × 內層 O(n) 的雙指標掃描);空間 O(1) 額外空間(不計輸出本身,若排序為 in-place)。Reusable Insight: 「先排序,把 kSum 問題降維成 (k-1)Sum 問題,最底層用雙指標收斂」是所有 kSum 變體(3Sum/4Sum/3Sum Closest)共用的骨架,差別只在最外層要固定幾個數字。``

Day 23 — Sliding Window

學習目標

看完今天內容後,能夠:

  1. 分辨「固定大小視窗」與「可變大小視窗」兩種 Sliding Window,並判斷一個問題該用哪一種。
  2. 說明 Sliding Window 為什麼能把「對每個子陣列/子字串重新計算」的 O(n²) 或 O(n³) 降到 O(n)。
  3. 用可變大小視窗解一題「找最短/最長滿足條件的子陣列」,並正確處理視窗收縮的時機。

教材大綱

1

Sliding Window

Supporting TopicsLv.3

What:用一對指標 left/right 維護一個連續的「視窗」(子陣列或子字串),right 負責擴張視窗、left 負責收縮視窗,視窗內維護一份隨內容增減增量更新的統計量(總和、字元計數、相異元素個數等),避免每次都重新掃描整個視窗來計算這份統計量。

Why

很多「子陣列/子字串」問題的 brute force 解法是列舉所有O(n²) 個子陣列,每個子陣列再花 O(n) 重新計算一次統計量(總和、是否滿足某條件),總共 O(n³);但相鄰的兩個子陣列之間,統計量其實只差「新加入的一個元素」與「移除的一個元素」,重新算一次完全是浪費。Sliding Window 的本質是:視窗內的統計量用增量更新(加入nums[right] 時更新一次、移除 nums[left] 時更新一次),而不是每次都從頭重新計算,把每個元素「進入視窗」與「離開視窗」各自最多只處理一次,攤銷總成本降到 O(n)。

Mechanism

兩種主要形式:

  • 固定大小視窗:視窗大小 k 固定,right 每前進一步,left就跟著前進一步(right - left + 1 == k 這個不變量全程維持)。典型應用:「大小為 k 的子陣列最大總和」——維護視窗內總和,right 前進時加上 nums[right],同時 left 前進時減去nums[left],每一步都是 O(1) 更新,全程 O(n)。
  • 可變大小視窗right 一路前進以擴張視窗,直到視窗「違反」某個條件(或反過來,「滿足」某個條件),這時讓 left 前進以收縮視窗,直到視窗重新符合要求,如此反覆。典型應用:「不含重複字元的最長子字串」——right 前進時把新字元加進一個統計「視窗內字元出現次數」的 hash map,只要目前字元的計數變成 2(代表視窗內有重複),就持續讓 left 前進、把 left 指向的字元計數減 1,直到重複的字元計數重新降回 1;每一步都用視窗當前的長度(right-left+1)更新答案。關鍵不變量right 從頭到尾只會前進 n 次,left 從頭到尾也只會前進最多 n 次(left 永遠不會超過 right 之後又回頭),所以即使外層迴圈與內層收縮迴圈「看起來」是巢狀結構,兩個指標合計的移動次數是 O(n),不是 O(n²)——這是初學者最容易誤判複雜度的地方,見下方 Failure mode。
Trade-off

Sliding Window 把時間複雜度從 O(n²)/O(n³) 降到 O(n),額外空間通常是 O(1)(固定視窗且統計量是單一數字,例如總和)到 O(k)(視窗內需要一個 hash map/set,k 是字元集大小或視窗最大長度);代價是只能處理「連續」子結構(子陣列、子字串),如果問題要求的是「不連續但保持相對順序的子序列」(subsequence),Sliding Window 不適用,需要換成 Dynamic Programming(T-024 會介紹)。

Failure Modes

  1. 誤判雙指標巢狀迴圈是 O(n²):可變大小視窗的程式碼經常寫成for right := range nums { for 視窗不合法 { left++ } } 這種「看起來像巢狀迴圈」的結構,初學者容易直接判定為 O(n²)。正確判斷方式是看每個指標各自總共移動了多少次,而不是看迴圈的巢狀層數——leftright 全程單調遞增、各自最多移動 n次,兩者相加是 O(n),不是 O(n) × O(n)。
  2. 忘記在收縮視窗時同步更新統計量left 前進、元素離開視窗時,如果忘記把該元素從統計量(hash map 計數、視窗總和等)中移除,會導致後續的判斷條件用到「已經不在視窗內」的過期資訊,產生錯誤答案且不易察覺(因為程式不會 crash,只是結果數字不對)。
  3. 固定大小視窗誤用可變大小視窗的收縮邏輯:固定視窗應該是right 前進一步、left 也跟著前進一步(維持大小恆定),如果誤用「視窗不合法才收縮」的可變邏輯,會導致視窗大小不受控制地變化。

Backend Applications:Rate limiting 的「滑動視窗計數」(sliding window counter)是這個 pattern 在系統設計上最直接的應用——維護「過去 N 秒內的請求數」而不是每次請求都重新掃描全部歷史紀錄,right 對應新進來的請求、left 對應「超過時間視窗因而該被排除」的舊請求;即時監控系統計算「過去 5 分鐘的平均延遲/錯誤率」也是同樣的結構,用增量更新(新資料進來加、舊資料出視窗減)取代每次重新掃描整個時間窗口的資料。

練習

LeetCode 3 — Longest Substring Without Repeating Characters(找出字串中不含重複字元的最長子字串長度,例如"abcabcbb" → 答案 3,對應 "abc")。用可變大小視窗+hash map(記錄視窗內每個字元最後出現的位置,或計數)維護「視窗內是否有重複字元」,right 前進時若遇到重複,直接把 left 跳到「重複字元上次出現位置的下一格」(比起每次只把 left 前進一格,這個優化能避免不必要的逐格檢查,但兩種寫法複雜度都是 O(n),練習時先寫可運作的版本,再選擇是否加這個優化)。用 "abcabcbb""bbbbb"""(空字串)三組測資驗證輸出分別為 310

Day 23 過關標準(DoD)—— DSA 特殊驗收格式

``text Pattern: 可變大小 Sliding Window + hash map 記錄視窗內字元狀態 Why: 相鄰視窗的重複字元判斷只差「新加入」與「被移出」的一個字元,用 hash map 增量更新取代每次重新掃描整個視窗,讓 left/right 各自只需移動 O(n) 次。Mistake: 收縮視窗(left 前進)時忘記把離開視窗的字元從 hash map 計數中移除或更新,導致後續重複判斷用到過期資料,得出錯誤的最長長度;另一種常見錯誤是把「視窗不合法才收縮」的可變邏輯用在該用固定視窗的題目上。Complexity: 時間 O(n)(right/left 各自最多移動 n 次,攤銷);空間 O(min(n, 字元集大小))(hash map 最多存視窗內出現過的相異字元)。Reusable Insight: 「right 負責擴張到不合法為止,left 負責收縮到重新合法為止,統計量全程增量更新」這個骨架可以直接套用到其他「最長/最短滿足條件子陣列」問題(例如最小覆蓋子字串、和至少為 K 的最短子陣列),差別只在「合法/不合法」的判斷條件與統計量本身。``

Day 24 — Binary Search:不只是「在排序陣列找數字」

學習目標

看完今天內容後,能夠:

  1. 正確實作 Lower Bound 與 Upper Bound,並解釋兩者在「找目標值的插入位置」語意上的差異。
  2. 判斷一個問題是否能轉換成「在一個抽象的搜索空間上做 Binary Search」(Search Space / Binary Search on Answer),即使題目本身看起來跟「排序陣列」無關。
  3. 對一個具備單調性(monotonic predicate)的陌生問題,推導出 Binary Search on Answer 的判斷函式與搜索邊界,並寫出可執行的實作。

教材大綱

1

Binary Search

Core FundamentalsLv.4

What:Binary Search 是一種利用單調性(monotonicity)把搜索範圍每次砍半的演算法——不限於「在排序陣列裡找數字」,只要能定義一個搜索空間,以及一個在該空間上「單調」的判斷函式(predicate:給定一個候選值,回答 true/false,且這個 true/false 沿著搜索空間移動時只會翻轉一次,不會忽 true 忽 false),就能用 Binary Search 找到判斷函式翻轉的那個邊界點。

Why

線性掃描(O(n))在資料量大時代價很高;如果資料具備單調性(排序過的陣列、或問題本身的答案空間具有「越大越……/越小越……」的單調趨勢),每次比較都能排除一半的候選,把 O(n) 降到 O(log n)。這個「排除一半」的能力,正是 Day 21 O(log n) 分類的來源。

Mechanism(依 acceptance 要求逐一展開):

  • 標準 Binary Search(找目標值本身)low, high := 0, len(nums)-1;迴圈中 mid := low + (high-low)/2(用這個寫法而不是(low+high)/2,避免 low+high 在極端情況下整數溢位);若nums[mid] == target 回傳 mid;若 nums[mid] < target,代表target 若存在必定在右半邊,low = mid+1;否則 high = mid-1;迴圈條件 low <= high,跳出迴圈仍未命中代表不存在。
  • Lower Bound(第一個 ≥ target 的位置):語意是「target如果要插入這個排序陣列、同時保持陣列有序,最靠左可以插入的位置」——等同標準函式庫的 lower_bound(C++)、bisect_left(Python)。實作:low, high := 0, len(nums)(注意 highlen(nums) 不是 len(nums)-1,因為答案可能是「插在陣列最後面」);迴圈條件 low < highmid := low + (high-low)/2;若nums[mid] < target,代表 mid 連同它左邊都不可能是答案,low = mid+1;否則(nums[mid] >= targetmid 有可能是答案(或答案在它左邊),high = mid;迴圈結束時 low == high,就是答案。
  • Upper Bound(第一個 > target 的位置):與 Lower Bound 幾乎相同,只把判斷條件的 < 改成 <=——若 nums[mid] <= targetlow = mid+1;否則 high = midLower Bound 與 Upper Bound 的差,正好是 target 在陣列中出現的次數upperBound -lowerBound),這是「在排序陣列中找某個值出現次數」的標準做法,不需要真的線性掃描。
  • Search Space(搜索空間):Binary Search 不需要作用在「一個陣列的索引」上——它可以作用在任何具備單調性的抽象數值範圍上。例如:在一個「答案介於 1 到 10^9 之間、且答案越大某個條件越容易滿足(或越難滿足)」的問題裡,low/high 可以直接設成「可能的答案的最小值/最大值」,而不是陣列的索引 0 到len-1——這是 Search Space 這個詞的意思:搜索的對象是「答案本身的值域」,不是資料結構裡的位置。
  • Binary Search on Answer:當一個問題問「求最小的 X 使得某條件成立」(或「求最大的 X 使得某條件成立」),而這個條件對 X 具備單調性(X 越大條件越容易滿足,或越大越難滿足),就可以對 X 的值域做 Binary Search,而不需要、也通常無法直接推導出解析解。流程:(1) 確定 X 的合理範圍 [low, high];(2) 寫一個canAchieve(x) bool 判斷函式,驗證「X = x 時條件是否成立」;(3) 對 [low, high] 做 Binary Search,每次用 canAchieve(mid)決定往哪一半收斂。判斷函式必須是單調的,這是能不能用這個技巧的先決條件——如果條件對 X 不是單調(例如條件時而成立時而不成立、沒有固定的翻轉點),Binary Search on Answer 會得到錯誤答案,這也是它最常被誤用的地方。
Trade-off

Binary Search(含所有變體)用 O(log n) 或O(log(值域大小)) 換掉 O(n) 甚至更差的窮舉,代價是必須先有單調性(排序過的資料,或具備單調 predicate 的問題結構);如果資料本身無序且沒有可排序的理由,套用 Binary Search 前得先付出 O(n log n) 排序成本,這筆成本要跟「之後會查詢幾次」放在一起評估——只查一次的話,O(n log n) 排序 + O(log n) 查詢不會比一次 O(n)線性掃描划算。

Failure Modes

  1. mid 計算用 (low+high)/2 造成整數溢位:在允許索引/數值範圍極大的語言(如固定寬度整數的 Go int32)中,low+high可能超過該型別能表示的最大值而溢位,正確寫法是mid := low + (high-low)/2
  2. Lower/Upper Bound 的邊界寫錯,high 用成 len(nums)-1:Lower/Upper Bound 的答案可能是「插入在陣列最後面」(索引等於len(nums)),如果 high 初始化成 len(nums)-1,會漏掉這個合法答案。
  3. Binary Search on Answer 用在非單調的判斷函式上:這是最容易犯的概念性錯誤——沒有先驗證「條件是否真的單調」就直接套用模板,得到的答案可能剛好落在某個局部符合條件、但不是真正邊界的位置。使用這個技巧前,必須先能口頭證明「X 越大(或越小)條件越容易成立」,證不出來就不能用。
  4. 迴圈不變量與收斂條件不一致:標準 Binary Search 用low <= high 搭配 mid±1 收斂;Lower/Upper Bound 用 low < high搭配 high = mid(不是 mid-1)收斂——兩種模板混用(例如low < high 卻寫 high = mid-1)會導致漏掉邊界值或無窮迴圈。

Backend Applications:資料庫索引本身的查找(Day 31+ B-Tree 會展開)是 Binary Search 思想在儲存層的體現;API 限流/資源分配問題常見「求滿足 SLA 的最小資源配置量」,這正是 Binary Search on Answer 的典型場景(見下方陌生題);版本控制系統的 git bisect(用二分法在一串 commit 中找出第一個引入 bug 的版本)是 Lower Bound 思想在「commit 歷史」這個搜索空間上的應用——canAchieve(commit) 對應「這個 commit 是否還沒有 bug」這個單調判斷(假設 bug 引入後所有之後的 commit 都有這個 bug)。

陌生題(Core Fundamentals 要求):某個批次匯入服務要把 n 份檔案分配給固定數量的 k 台 worker 平行處理,每份檔案有各自的處理秒數 cost[i],檔案不能被拆開、必須整份交給同一台 worker,worker 之間也不能事後搬移工作。要在不重新設計架構的前提下,決定一種分配方式,使得「最慢的那台 worker 的總耗時」盡量小(這樣整批匯入才能盡快全部完成)。不能直接寫 code,先回答:

為什麼這是一個 Binary Search on Answer 問題?

推導:直接窮舉所有分配方式是指數級的(每份檔案有 k 種去處,k^n 種分配方式),不可行。但注意問題其實是在問「最小化最大值」——如果我們換個角度,去猜「最慢 worker 的總耗時上限 X 最小可以是多少」:給定任意一個候選 X,可以用貪婪法在 O(n) 時間內驗證「X 是否可行」——依序把檔案分給目前的 worker,累積耗時一旦超過 X 就換下一台 worker,最後檢查用到的 worker 數量是否 ≤ k。這個「canAchieve(X)」判斷函式對 X 具備單調性:X 越大,越容易用更少的 worker 做到(因為每台 worker 能扛的耗時上限變大),X 越小則越難(甚至可能需要超過k 台 worker 才裝得下)——單調性成立,滿足 Binary Search on Answer 的前提。low 設為 max(cost[i])(至少要能裝下耗時最長的單一檔案,否則怎麼分都會有一台 worker 超過 X)、high 設為sum(cost[i])(全部檔案都給同一台 worker 的耗時,一定可行),對[low, high] 做 Binary Search,每次用 canAchieve(mid) 決定收斂方向,最終收斂到的 low(此時 low == high)就是最小可行的 X。

再寫 code:實作 canAchieve(cost []int, k int, x int) bool(貪婪分配驗證)與外層 Binary Search,對測資cost = [7,2,5,10,8], k = 2 驗證答案為 18(一種可行分配:[7,2,5] 給 worker A 耗時 14、[10,8] 給 worker B 耗時 18;驗證 17 以下不可行、18 可行)。

練習(Day 24)

除上方陌生題外,另解 LeetCode 34 — Find First and Last Position of Element in Sorted Array(在排序陣列中找目標值第一次與最後一次出現的位置),直接用本節的 Lower Bound / Upper Bound 模板實作(lower Bound(target)upperBound(target)-1 分別對應答案的起訖位置,若 lowerBound(target) 位置的值不等於 target 則代表不存在,回傳[-1,-1])。用 nums=[5,7,7,8,8,10], target=8(答案 [3,4])與target=6(答案 [-1,-1])驗證。

Day 24 過關標準(DoD)—— DSA 特殊驗收格式

``text Pattern: Binary Search on Answer(貪婪 canAchieve() 判斷 + 對答案值域二分)Why: 直接窮舉分配方式是指數級的,但「最慢 worker 耗時上限」這個答案本身對「是否可行」具備單調性,可以繞開窮舉分配方式本身,改成對「答案的值」做二分搜索,每次只需 O(n) 驗證一個候選值。Mistake: 沒有事先口頭證明 canAchieve(X) 對 X 的單調性就直接套用二分模板;另一個常見錯誤是 low 的初始值設成 0 或 1 而不是 max(cost[i]),導致驗證一個「連最長的單一檔案都裝不下」的不可能候選值,canAchieve 永遠回傳 false,整個搜索失去意義。Complexity: 時間 O(n log(sum(cost)))(每次 canAchieve 驗證 O(n),二分搜索值域大小為 sum(cost),共 O(log(sum(cost))) 次);空間 O(1) 額外空間。Reusable Insight: 看到「最小化最大值」或「最大化最小值」(minimize the maximum / maximize the minimum)的問題敘述,且存在一個可以快速驗證「某個候選答案是否可行」的貪婪或線性判斷函式時,優先考慮 Binary Search on Answer,而不是嘗試直接推導數學解析解或暴力窮舉分配方式本身。``

Day 25 — Sorting

學習目標

看完今天內容後,能夠:

  1. 說出至少三種排序演算法(含至少一種 O(n²) 與一種 O(n log n))的核心機制,並比較它們的時間、空間複雜度與穩定性(stability)。
  2. 解釋「穩定排序」為什麼在某些場景(例如多欄位排序)是必要條件,舉出一個不穩定排序會產生錯誤結果的具體情境。
  3. 判斷一個排序相關問題該用標準函式庫排序、還是該用計數排序等非比較排序來換取更低的時間複雜度。

教材大綱

1

Sorting

Supporting TopicsLv.3

What:把一組資料依某個比較規則排成有序序列。依「是否透過兩兩比較決定順序」可分成比較排序(comparison-based,如 merge/quick/heap sort)與非比較排序(如 counting sort、radix sort,靠資料本身的數值特性直接決定位置,不透過比較)。

Why

排序本身是很多其他演算法的前置步驟——Binary Search(Day 24)要求資料已排序;Two Pointers(Day 22)的許多應用也要求先排序才能利用單調性;Greedy(T-024)的多數證明第一步就是「先排序」。理解排序演算法的機制與成本,才能判斷「這一步排序划不划算」,以及「該用哪一種排序」。

Mechanism

  • O(n²) 比較排序(以 Insertion Sort 為代表):維護「陣列前半部已排序」的不變量,每次把下一個未排序元素往前插入到正確位置——從右往左逐一比較並搬移比它大的元素,直到找到插入點。最差情況(逆序輸入)每次插入都要搬移前面全部元素,O(n²);但對「幾乎已排序」的資料非常快(每次插入只需搬移極少元素),這是它在n 很小或資料局部有序時,實務上有時比 O(n log n) 排序更快的原因(多數標準函式庫的排序在小規模子陣列會切換成 insertion sort,正是利用這個特性)。
  • O(n log n) 比較排序:Merge Sort:把陣列從中間切一半,遞迴排序左右兩半,再把兩個已排序的子陣列合併(merge)成一個有序陣列——合併時用兩個指標各自指向兩個子陣列的開頭,每次取較小的那個放進結果,指標前進,是 O(m+n) 操作(mn 為兩子陣列長度)。遞迴切一半共 log n 層,每層所有合併操作加總都是 O(n),總成本O(n log n)穩定(相等元素的相對順序不會改變,因為合併時「相等就取左邊那個」);額外空間 O(n)(合併需要暫存空間,不是原地排序)。
  • O(n log n) 比較排序:Quick Sort:選一個 pivot,把陣列 partition 成「比 pivot 小」與「比 pivot 大」兩部分(pivot 落在正確的最終位置),再遞迴排序兩部分。平均情況下(pivot 選得夠隨機)每次 partition 大致把陣列切成兩半,O(n log n);最差情況(pivot 總是選到最小值或最大值,例如對已排序陣列固定選第一個當 pivot)退化成 O(n²)(每次只切掉一個元素)。原地排序,額外空間 O(log n)(遞迴 call stack)。不穩定(partition 過程中相等元素的相對順序可能被打亂)。
  • 非比較排序:Counting Sort:如果資料的值域已知且不大(例如 0~k 的整數),可以不透過比較,直接統計每個值出現的次數,再依值的大小依序輸出——時間複雜度 O(n+k),當 k 是常數或遠小於n log n 時比任何比較排序都快。代價:只適用於離散、值域有限的資料(無法對任意可比較物件排序),且空間複雜度 O(k),k 很大時不划算。

Stability(穩定性):排序後,原本相等的元素是否維持原本的相對順序。多數比較排序演算法本身可以設計成穩定(merge sort 天生穩定;quick sort 天生不穩定,除非額外處理),標準函式庫通常會標明其排序是否穩定(例如 Python sorted/list.sort 保證穩定,Gosort.Slice 不保證穩定、需要用 sort.SliceStable)。

Trade-off

O(n²) 排序實作簡單、對小規模或近乎有序的資料反而更快,且多數是原地排序(O(1) 額外空間);O(n log n) 比較排序是一般情況下的預設選擇,但 merge sort 用 O(n) 額外空間換取穩定性與「最差情況也是 O(n log n)」的保證,quick sort 用犧牲穩定性與最差情況保證,換取平均情況下更好的常數因子(實務上通常比 merge sort 快,因為原地操作對 cache 更友善);非比較排序在值域受限時能打破比較排序 O(n log n) 的理論下界,但犧牲了通用性。

Failure Modes

  1. 需要穩定排序卻用了不穩定的排序函式:多欄位排序(例如先按部門排序、同部門內再按姓名排序)如果分兩次排序(先排姓名、再排部門),必須第二次排序是穩定的,否則同部門內的姓名順序會被打亂——這是實務上很容易忽略、且不會報錯(只是結果「看起來」排序正確,但細看會發現次要排序鍵的順序是錯的)的 bug。
  2. 對已排序或近乎排序的資料選錯演算法:對已排序資料使用固定選第一個元素當 pivot 的 quick sort 實作,會直接觸發 O(n²) 最差情況——這也是為什麼許多標準函式庫改用「隨機選 pivot」或「median-of-three」來避免這個已知的攻擊/退化情境。
  3. 誤用非比較排序在不符合前提的資料上:對浮點數、字串或值域極大的整數套用 counting sort,會導致 k(值域大小)遠大於n,空間與時間都比比較排序更差,完全喪失非比較排序的優勢。

Backend Applications:資料庫的 ORDER BY 執行計畫在資料量小到能放進記憶體時通常用 quick sort/merge sort 的變體(Day 31+ 會展開EXPLAIN 如何顯示排序策略),資料量大到放不進記憶體則用external merge sort(分批排序後合併,merge sort 的「合併」機制天生適合處理超過記憶體容量的資料,這也是為什麼合併排序在資料庫與大數據系統中比 quick sort 更常被選用,即使平均情況比 quick sort 慢);日誌系統依時間戳記排序輸出時,若日誌來源本身已經是局部有序(多個 producer 各自時間遞增),用 merge 的思路合併多個有序串流,會比對全部日誌重新排序快得多。

練習

LeetCode 75 — Sort Colors(給一個只包含 0、1、2 三種值的陣列,原地排序成 0,0,...,1,1,...,2,2,...)。要求只掃描一次(single pass)完成,使用三個指標(lowmidhigh,Dutch National Flag 演算法):mid 從頭掃描,遇到 0 就與 low 位置交換(low++mid++)、遇到 2 就與 high 位置交換(high--,但 mid 不前進,因為交換過來的值還沒檢查過)、遇到 1 就 mid++。說明這個做法本質上是「值域只有 3 個離散值」的 counting sort 特例(甚至不需要額外的計數陣列,直接用三個指標原地完成),對照上方「非比較排序」段落解釋為什麼這題不需要、也不應該用O(n log n) 的比較排序。用 [2,0,2,1,1,0](答案[0,0,1,1,2,2])驗證。

Day 25 過關標準(DoD)—— DSA 特殊驗收格式

``text Pattern: Dutch National Flag(三指標原地分割,值域受限的 counting sort 特例)Why: 值域只有 {0,1,2} 三個離散值,不需要透過比較決定順序,用 low/mid/high 三個指標一次掃描就能把陣列分割成三段,比呼叫 O(n log n) 的通用比較排序更快也不需要額外空間。Mistake: 遇到 2 與 high 交換後誤把 mid 前進,導致交換過來、還沒檢查過的值被跳過,可能把應該搬到 low 或留在中段的值放錯位置;正確做法是交換後 high--,但 mid 停在原地重新檢查。Complexity: 時間 O(n)(每個元素最多被 mid 掃描一次,high 位置的元素可能被交換檢查但整體移動次數仍是 O(n));空間 O(1)(原地排序,不含輸入本身)。Reusable Insight: 「值域是固定小常數集合」時,先問「需要比較排序嗎」——多數這類題目都能用計數/原地分割取代比較排序,把 O(n log n) 降到 O(n),這與 Day 21 複雜度分析、Day 24 二分搜索一樣,都是先看清問題的結構特徵,再選演算法,而不是預設套用最泛用的解法。``

Day 21–25 完成後,已涵蓋第 7 章1 Core Patterns 中的 Complexity / Two Pointers / Sliding Window / Binary Search / Sorting 五項,Binary Search 的 Lower Bound / Upper Bound /Search Space / Binary Search on Answer 四個子項全數展開,Complexity 與 Binary Search(Core Fundamentals)各附一題不能直接寫 code 的陌生題推導。T-024(Day 26–30:Recursion / DFS / BFS / Backtracking /Greedy / Dynamic Programming / Graph)depends_on 本任務(T-023)與 T-006,兩者皆已滿足,可接續在本檔案後半段展開。

Day 26–30 的順序同樣依 prerequisite chain2 展開:Recursion 排在最前,因為它是 DFS、Backtracking、Dynamic Programming 三者共同的前置機制(呼叫自己、call stack 上的往下展開與往上回收);DFS 與 BFS 併入 Day 27 一起教,因為兩者都只依賴 Recursion/Phase 02 Queue 加上 Phase 02 Graph 的鄰接表示法,彼此互不依賴,是天生適合放在同一天比較著教的一對「圖走訪」姊妹技巧;Backtracking 排在 Day 28,因為它直接建立在 DFS 的「往下走、走不通就退回」骨架上,必須先懂 DFS 才看得懂 Backtracking 多出來的「選擇/撤銷選擇」在做什麼;Dynamic Programming 排在 Day 29,因為它的 Transition 步驟本質上是把 Recursion 的子問題呼叫用一張表記下來,且推導範例會用到 Day 25 Sorting;Greedy 與 Graph 併入 Day 30 作收尾,Greedy 本身依賴 Sorting、與 DFS/BFS/Backtracking 沒有直接依賴關係,適合放在最後;Graph(algorithms 層面的最短路徑/連通性/拓樸排序)依賴 DFS 與 BFS 都已教過,且 Dijkstra 本質上是 Greedy 規則套用在圖上的具體案例,把 Greedy 與 Graph 放在同一天,能讓 Dijkstra 的「貪婪選擇目前最近節點」直接呼應剛講完的 Greedy exchange argument。

  1. 依 00-master-curriculum.md 第 7 章
  2. 依 00-knowledge-dependency-graph.md §2.3 的 prerequisite chain 定義

Day 26 — Recursion

學習目標

看完今天內容後,能夠:

  1. 對一個遞迴函式,能明確寫出 base case 與 recursive case,並解釋為什麼缺一不可。
  2. 用 call stack 逐層展開的方式,手動追蹤一個遞迴呼叫的執行順序與回傳值組裝過程。
  3. 判斷一個遞迴解法是否存在重複子問題(overlapping subproblems),並說明重複計算會造成的複雜度後果。

教材大綱

1

Recursion

Core FundamentalsLv.4

What:遞迴是一個函式在自己的定義裡呼叫自己,把一個問題拆成「規模更小、結構相同」的子問題,直到抵達一個可以直接回答、不需要再拆的最小情況(base case),再把子問題的答案逐層組裝回原問題的答案。

Why

很多問題天生具備「自我相似」的結構——樹的每個子樹本身也是一棵樹、圖從任一節點出發能走到的節點集合也可以用「鄰居能走到的集合」遞迴定義——用遞迴描述這類問題,程式碼的結構直接對應問題的數學定義,比硬擠成迭代寫法更貼近問題本身、更不容易寫錯邊界;Day 27 起的 DFS、Day 28 Backtracking、Day 29 DP 全部都是在遞迴這個機制上疊加不同的「額外行為」,不先建立遞迴本身在 call stack 上實際發生的事情的直覺,後面每個 pattern 都會變成死記程式碼骨架而不懂為什麼要這樣寫。

Mechanism

每次呼叫 recursive function 時,程式語言的執行環境(runtime)會在 call stack 上推入一個新的 stack frame,裡面存著這次呼叫的參數、區域變數,以及「這次呼叫執行到哪一行、之後要回到哪裡」的資訊;函式呼叫自己時,目前這一層先暫停(但沒有結束),等被呼叫的那一層完全執行完、回傳結果後,才從暫停的地方繼續。用階乘factorial(n) = n * factorial(n-1)factorial(0) = 1 為例,factorial(4) 的展開過程是:

``text factorial(4)→ 呼叫 factorial(3)(frame: n=4,等待回傳值後要乘以 4)→ 呼叫 factorial(2)(frame: n=3,等待回傳值後要乘以 3)→ 呼叫 factorial(1)(frame: n=2,等待回傳值後要乘以 2)→ 呼叫 factorial(0)(frame: n=1,等待回傳值後要乘以 1)→ factorial(0) 命中 base case,直接回傳 1← factorial(1) = 1 * 1 = 1,回傳給上一層← factorial(2) = 2 * 1 = 2,回傳給上一層← factorial(3) = 3 * 2 = 6,回傳給上一層← factorial(4) = 4 * 6 = 24,回傳給呼叫者``

「往下呼叫」的過程(call stack 從 1 層長到 5 層)與「往上回傳」的過程(call stack 從 5 層縮回 1 層)是遞迴唯一的兩個動作——base case 是唯一不再往下呼叫、只往上回傳的那一層,缺少它會導致遞迴永遠不會停止往下呼叫,直到 call stack 用盡記憶體、丟出 stack overflow。

Trade-off

遞迴讓程式碼結構直接對應問題定義(樹狀/自我相似的問題用遞迴寫通常比迭代版本短、更易讀),代價是每一層呼叫都要付出一份 stack frame 的記憶體(Day 21 提過的 space complexity O(d),d 為遞迴深度),且多數語言(包含 Go)不做尾遞迴優化(tail-call optimization)——即使遞迴呼叫是函式的最後一個動作,Go 仍然會為每次呼叫保留完整 stack frame,深度過深(例如對一個上萬節點的 linked list 做遞迴走訪)會直接 stack overflow,這種情況通常要改寫成迭代版本或手動維護一個 stack 資料結構來模擬遞迴。

Failure Modes

  1. Base case 缺漏或條件寫錯:例如把 factorial(0) = 1 誤寫成只處理 n == 1,呼叫 factorial(0) 時會不斷遞減到負數、永遠碰不到 base case,最終 stack overflow;base case 必須涵蓋所有「不能再往下拆」的輸入,不是只涵蓋「常見」的那個。
  2. 重複子問題沒有被發覺,導致指數級重複計算:naive 遞迴版本的費氏數列 fib(n) = fib(n-1) + fib(n-2) 看似簡單,但 fib(n-2)會在計算 fib(n-1) 的過程中被重複算一次——展開整棵呼叫樹會發現 fib(30) 呼叫了超過兩百萬次函式,時間複雜度是 O(2^n),即使每次呼叫本身只做一次加法。這個問題不是遞迴本身的錯,而是沒有意識到子問題重疊;Day 29 的 Dynamic Programming 就是用一張表把這些重複計算記下來,直接把 O(2^n) 壓到 O(n)。
  3. 每次呼叫都重新配置資料結構,忘記傳遞可重用的參照:例如遞迴組合字串時每層都用 + 產生新字串而不是傳遞可變的 buffer,會讓每層多付出一次複製成本,深度為 d 時額外多花 O(d) 甚至 O(d²) 的隱藏成本。

Backend Applications:Recursive descent parser(遞迴下降剖析器)是解析 JSON、SQL、程式語言語法最常見的實作方式——每個文法規則對應一個函式,規則裡引用其他規則就直接呼叫對應函式,文法本身的巢狀結構(例如 JSON 物件裡可以再放物件)自然對應到函式呼叫的巢狀;檔案系統或巢狀分類(category tree)的走訪(例如計算一個目錄底下所有檔案總大小,含子目錄)用遞迴寫最直觀;GraphQL resolver 對巢狀欄位的解析、protobuf/JSON 深層巢狀結構的序列化與反序列化,本質上都是同一套「遇到巢狀結構就遞迴處理子結構」的思路。

陌生題(Core Fundamentals 要求):實作一個函式myPow(x float64, n int) float64,計算 xn 次方(n 可以是負數,代表 1/x^(-n))。不能直接寫 code,先回答:

為什麼單純遞迴呼叫 x * myPow(x, n-1) 是不夠好的解法?更好的做法是什麼?

推導x * myPow(x, n-1) 每次只把 n 減 1,遞迴深度是 n,時間複雜度 O(n)——雖然正確,但沒有用到「乘法可以合併」這個結構。觀察 x^n:如果 n 是偶數,x^n = (x^(n/2))^2,只需要算一次x^(n/2) 再平方,把問題規模直接砍半;如果 n 是奇數,x^n = x * (x^(n/2))^2(用整數除法,n/2 向下取整)。這是分治(Divide and Conquer)的思路——遞迴,但每次遞迴呼叫都把規模砍成一半而不是減 1,遞迴深度變成 O(log n),每層只做常數次乘法,總時間複雜度降到 O(log n)。負數次方則是先算 myPow(x, -n) 再取倒數,並注意 nmath.MinInt 這種邊界(負數取反會溢位,需要用int64 或先處理特例)。

再寫 code:實作上述「快速冪」(fast exponentiation)遞迴版本,對 myPow(2.0, 10)(答案 1024.0)與 myPow(2.0, -2)(答案0.25)驗證。

練習(Day 26)

除上方陌生題外,另解 LeetCode 206 — Reverse Linked List(遞迴版本:反轉一個單向 linked list),對應 Phase 02 Day 13 的 Linked List 資料結構。遞迴定義:reverse(head) 先遞迴反轉 head.Next之後的整條鏈(newHead := reverse(head.Next)),再把head.Next.Next = head(讓原本指向 head 的下一個節點反過來指回 head)、head.Next = nil(避免產生環),base case 是head == nil || head.Next == nil 時直接回傳 head。用1→2→3→nil(答案 3→2→1→nil)手動追蹤每一層 call stack 的狀態驗證。

Day 26 過關標準(DoD)—— DSA 特殊驗收格式

``text Pattern: 分治遞迴(Divide and Conquer)——每次遞迴呼叫把問題規模砍半而不是減一 Why: 把 O(n) 次「規模減一」的遞迴呼叫,改成 O(log n) 次「規模減半」的遞迴呼叫,是多數「遞迴優化」問題的共同思路,直接把線性複雜度壓成對數複雜度。Mistake: 只處理 n 是正偶數的情況,漏掉負數次方(需要先轉換成正數次方再取倒數)與 n=0(base case,任何數的 0 次方是 1);另一個常見錯誤是奇數次方時忘記多乘一次 x,直接回傳 (x^(n/2))^2 導致結果少乘了一個 x。Complexity: 時間 O(log n)(遞迴深度為 log n,每層常數次乘法);空間 O(log n)(call stack 深度,遞迴版本無法避免這份開銷,除非改寫成迭代版本用變數手動模擬遞迴的乘法累積)。Reusable Insight: 看到「重複套用同一個運算 n 次」的問題(次方、矩陣快速冪、費氏數列的矩陣加速解法),先問「這個運算能不能合併(結合律)」,能的話用分治把 O(n) 降到 O(log n),這與 Day 24 Binary Search「每次砍半」的思路是同一個數學結構的不同應用。``

Day 27 — DFS / BFS:兩種圖與樹的走訪策略

學習目標

看完今天內容後,能夠:

  1. 分辨 DFS(用 stack/遞迴,往深處走到底才回頭)與 BFS(用 queue,一層一層往外擴散)在走訪順序與資料結構選擇上的差異。
  2. 判斷「找是否存在一條路徑」與「找最短路徑(邊數最少)」該分別選用 DFS 還是 BFS,並說明原因。
  3. 用 visited 集合避免圖走訪時的重複訪問與無窮迴圈,並解釋為什麼樹的走訪通常不需要 visited(但圖需要)。

教材大綱

1

DFS(Depth-First Search)

Supporting TopicsLv.3

What:從一個起點出發,沿著一條路徑盡量往深處走,直到走不下去(碰到底、或所有鄰居都走過)才「回溯」(backtrack)到上一個節點,改走其他還沒走過的分支,直到所有可達節點都被訪問過。

Why

DFS 是 Recursion(Day 26)最直接的應用——樹或圖的每個節點都可以看成一個子問題「先處理這個節點、再遞迴處理它的每個子節點」,DFS 的遞迴呼叫骨架幾乎不需要額外設計,直接對應 Phase 02 Day 18(Tree)、Day 20(Graph)介紹過的 Adjacency List/子節點指標;Day 28 的 Backtracking 與 Day 30 的部分 Graph 演算法(拓樸排序、環偵測)都是在 DFS 的骨架上加東西,不先掌握 DFS 本身,後面兩者都會變成死背程式碼。

Mechanism

遞迴版 DFS 的骨架:

``text func dfs(node, visited) {if node == nil || visited[node] { return }visited[node] = true process(node) // 依題目定義的「處理」for neighbor in node.neighbors {dfs(neighbor, visited)}}``

遞迴呼叫本身就是在用 call stack 當作 DFS 需要的「後進先出」順序——遞迴呼叫 dfs(neighbor) 時,目前的 node 暫停在原地(stack frame 還在),等 neighbor 這整條分支完全走完(它自己以及它所有子孫都訪問過)才回到 node,換下一個 neighbor。也可以不用遞迴、改用顯式 stack(Phase 02 Day 15)手動模擬同樣的「後進先出」順序,兩者在走訪順序上等價,差異只在遞迴用的是 call stack(隱式)、迭代版用的是自己配置的 stack(顯式,能避免遞迴深度過深時的 stack overflow)。

Trade-off

DFS 通常記憶體用量較低——同一時間只需要記住「目前這條路徑上的節點」(遞迴深度最多是圖的最長路徑長度,或樹高),不像 BFS 需要同時記住「目前這一整層」的所有節點;代價是 DFS 找到的第一條路徑不保證是最短路徑(它會一路走到底再回頭,不會優先探索「距離起點較近」的節點)。

Failure Modes

  1. 對圖(尤其是含環的圖)忘記標記 visited,導致無窮遞迴:樹不需要 visited 是因為樹沒有環、也沒有指向同一個子節點的多條路徑;圖裡任兩個節點可能互相連結(甚至自環),沒有 visited 集合會讓 DFS 對同一個節點重複遞迴下去,永遠不會終止或造成 stack overflow。
  2. 誤以為 DFS 找到的路徑一定最短:DFS 只保證「找到一條存在的路徑」,不保證是邊數最少的路徑——如果題目要求最短路徑(邊數),必須用 BFS,不是 DFS 加一個「記錄目前最短」就能等價於 BFS 的行為(因為 DFS 會先把某條可能很長的路徑走到底才回頭比較,浪費大量不必要的探索)。

Backend Applications:依賴解析(例如 Go module 的依賴圖、任務排程系統的 task DAG)用 DFS 判斷是否存在循環依賴(見 Day 30 拓樸排序);檔案系統遞迴走訪(列出某個目錄底下所有檔案,含所有子目錄)本質上就是對「目錄樹」做 DFS;垃圾回收(garbage collection)演算法中的 mark-and-sweep,「mark」階段從 root 物件出發,沿著參照關係走訪所有還在被使用的物件,正是圖上的 DFS/BFS(依實作而定)。

練習(DFS):解 LeetCode 200 — Number of Islands(給一個由'1'(陸地)與 '0'(海洋)組成的二維網格,計算島嶼數量,相鄰的陸地上下左右相連算同一座島)。對每個還沒訪問過的 '1',從它出發做 DFS(flood fill),把整座相連的島全部標記成已訪問,島嶼數量加一;繼續掃描網格直到所有格子都處理過。用

``text grid = [["1","1","0","0","0"],["1","1","0","0","0"],["0","0","1","0","0"],["0","0","0","1","1"]]``

驗證答案為 3

2

BFS(Breadth-First Search)

Supporting TopicsLv.3

What:從一個起點出發,先訪問所有「距離起點一步」的節點,再訪問所有「距離起點兩步」的節點,依此類推,一層一層往外擴散,直到所有可達節點都被訪問過。

Why

當問題要求「無權重圖上的最短路徑(邊數最少)」時,BFS 是唯一直接保證正確性的走訪方式——因為它保證「第一次訪問到某個節點時,走過的邊數就是從起點到它的最短距離」,這個保證來自「一層一層擴散」的走訪順序本身,不需要額外證明;Day 30 的 Graph 最短路徑(無權重版本)與許多「最少操作次數」類問題(例如「最少幾步能從狀態 A 變成狀態 B」)都直接建立在這個保證上。

Mechanism

BFS 用 queue(Phase 02 Day 14)維護「下一個要訪問的節點」,FIFO(先進先出)語意保證「較早被加入 queue 的節點(距離起點較近)會較早被處理」:

``text func bfs(start) {queue := [start]visited := {start: true}distance := {start: 0}for len(queue) > 0 {node := queue.popFront()for neighbor in node.neighbors {if !visited[neighbor] {visited[neighbor] = true distance[neighbor] = distance[node] + 1 queue.pushBack(neighbor)}}}return distance}``

關鍵細節:visited 必須在「加入 queue 的當下」就標記,而不是等到「從 queue 取出處理時」才標記——如果延遲標記,同一個節點可能在還沒被處理前就被其他節點的鄰居掃描到、重複加入 queue 好幾次,不只浪費空間,也可能讓 distance 被錯誤地多次覆寫。

Trade-off

BFS 保證找到的第一條路徑是最短路徑(邊數最少),但代價是要同時記住「目前這一層全部節點」,最壞情況下(例如接近完全圖,或一棵非常寬的樹)queue 的大小可以逼近整個圖的節點數,空間複雜度 O(V)(V 為節點數),通常比 DFS 的 O(路徑長度) 更耗記憶體。

Failure Modes

  1. 對加權圖直接套用 BFS 求「最短路徑」:BFS 的「最短路徑」定義是邊數最少,如果每條邊有不同權重(例如道路長度、任務耗時),邊數最少不等於總權重最小,此時需要 Dijkstra(Day 30 會展開,本質是把 BFS 的 queue 換成依權重排序的 priority queue)。
  2. 忘記記錄「怎麼走到這裡」導致無法還原路徑:只求最短距離時上面的 distance map 就夠,但若題目要求印出實際路徑,必須額外維護一個 parent map(記錄每個節點是從哪個節點擴散過來的),走訪結束後從終點沿著 parent 往回追溯到起點。

Backend Applications:網路拓樸中「兩台機器之間最少要經過幾個 hop」的分析、社群網路「兩個使用者之間的最短關係鏈(例如 LinkedIn 的『2 度人脈』)」都是無權重圖上的 BFS 最短路徑;分散式系統的服務探索(service discovery)在拓樸圖上找「離某個節點最近的可用副本」也是同樣的模式;Day 30 的拓樸排序有一個 BFS 版本(Kahn's algorithm),常用於任務排程系統決定「哪些任務可以先平行執行」。

練習(BFS):解 LeetCode 994 — Rotting Oranges(給一個網格,2 代表腐爛的橘子、1 代表新鮮橘子、0 代表空格,每一分鐘腐爛橘子會讓上下左右相鄰的新鮮橘子也腐爛,求讓所有橘子腐爛需要幾分鐘,若無法讓所有橘子腐爛回傳 -1)。把所有初始腐爛橘子同時加入 queue(多起點 BFS),每一輪(一分鐘)處理 queue 裡目前所有節點、讓相鄰新鮮橘子腐爛並加入下一輪,走過的輪數就是答案;最後檢查網格是否還有新鮮橘子殘留(代表無法到達)。用grid = [[2,1,1],[1,1,0],[0,1,1]] 驗證答案為 4

Day 27 過關標準(DoD)—— DSA 特殊驗收格式

``text Pattern: DFS Flood Fill(Number of Islands)Why: 島嶼是「相連陸地」的連通元件,每找到一塊未訪問的陸地,往外擴散到整個連通元件並全部標記,島嶼數量只需要數「觸發擴散」的次數,不需要額外的連通元件演算法。Mistake: 忘記在遞迴進入前就標記 visited(或直接把 grid 裡的 '1'改成 '0' 當作標記),導致同一格被重複計入多次擴散、島嶼數算多。Complexity: 時間 O(rows × cols)(每格最多被訪問一次);空間 O(rows × cols) 最差情況(遞迴深度,整個網格是同一座島時)。Reusable Insight: 「計算網格/圖上連通元件數量」是 DFS flood fill 的標準應用,觸發條件(幾次啟動新的擴散)就是答案本身。``

``text Pattern: 多起點 BFS(Multi-source BFS,Rotting Oranges)Why: 所有初始腐爛橘子是同時、平行擴散的,不是逐一從單一起點做 BFS 再取最小值——把全部起點一次全部加入 queue 的初始狀態,BFS 天生的「一層一層擴散」就直接對應「一分鐘一分鐘腐爛」,輪數即分鐘數。Mistake: 誤用單一起點跑好幾次 BFS 再取最小值,這樣不僅複雜度變差(多做很多次遍歷),且沒有正確模擬「多個腐爛源同時擴散、互相搶時間」的真實情境。Complexity: 時間 O(rows × cols)(每格最多入隊一次);空間 O(rows × cols)(queue 最壞情況存下所有格子)。Reusable Insight: 看到「多個起點同時開始擴散、求全部覆蓋所需時間/步數」的問題,優先考慮多起點 BFS(把所有起點一次塞進初始 queue),而不是對每個起點各自跑一次再合併結果。``

Day 28 — Backtracking

學習目標

看完今天內容後,能夠:

  1. 用「選擇(choose)→ 探索(explore)→ 撤銷選擇(un-choose)」的骨架寫出 backtracking 解法,並解釋為什麼「撤銷」這一步不能省略。
  2. 畫出一個 backtracking 問題的決策樹(decision tree),標出哪些分支被剪枝(pruned)、為什麼。
  3. 判斷一個問題是否適合用 backtracking(窮舉所有可能組合、且能提早放棄不可能的分支),並估計最差情況的分支因子與深度。

教材大綱

1

Backtracking

Supporting TopicsLv.3

What:Backtracking 是在 DFS 的骨架上,加入「在每個節點做一個選擇、往下探索、如果這條路走不通(或已經走完)就撤銷這個選擇、換下一個選擇」的行為,用來窮舉所有滿足條件的組合/排列/子集合,同時能在確定某個分支不可能產生合法解時提早放棄(剪枝),不必把整棵決策樹走完。

Why

很多組合最佳化問題(子集合、排列、棋盤佈局)沒有已知的公式或貪婪解法,唯一正確的做法是系統性地窮舉所有候選——但單純窮舉往往有指數級的候選數量,直接暴力列舉容易寫錯(重複、遺漏、或忘記還原狀態導致後面的候選被污染);backtracking 提供一個可靠、結構化的骨架,保證窮舉不遺漏、不重複,且能透過剪枝避免探索明顯不可能的分支,把「理論上的指數級」降到「實務上可接受」的範圍。

Mechanism

標準骨架:

``text func backtrack(path, choices) {if isComplete(path) {result.append(copy(path)) // 必須複製,見下方 Failure Mode return}for choice in choices {if !isValid(choice, path) { // 剪枝:提早放棄不合法的分支 continue}path.append(choice) // 選擇 backtrack(path, remainingChoices(choices, choice)) // 探索 path.removeLast() // 撤銷選擇}}``

這正是 DFS(Day 27)的骨架,差別在於多了「選擇/撤銷」這一對配對動作,且每個節點代表「目前為止的部分解」而不是「圖上的一個既有節點」——決策樹是動態生成的,每往下一層就是「多做一個選擇」,回到上一層前必須把這個選擇造成的狀態改動全部復原,讓「探索另一個兄弟分支」時看到的狀態跟「還沒做過這個選擇」時完全一樣。

Trade-off

Backtracking 保證窮舉正確、不遺漏,但最差情況複雜度通常是指數級(O(分支因子^深度)),能不能在實務上接受,取決於剪枝的效果——好的剪枝(提早判斷某個分支不可能產生合法解)可以把實際探索的節點數從理論上限大幅砍掉,但最差情況的漸進複雜度分析仍然要以「沒有剪枝時全部展開」為準,不能因為「平均情況跑得很快」就誤判複雜度。

Failure Modes

  1. 忘記撤銷選擇(un-choose),導致狀態污染後續分支:如果path.removeLast() 被省略或寫錯位置,探索完一個分支後,path裡殘留的元素會污染下一個兄弟分支的探索,得到錯誤或重複的結果——這是 backtracking 最常見、也最難除錯的 bug,因為程式不會報錯,只是輸出結果不對。
  2. 加入結果時直接存參照而不是複製result.append(path)(存參照)而非 result.append(copy(path))(存快照)——因為path 這個 slice/陣列會在後續探索中被持續修改,等所有遞迴呼叫結束後,result 裡每個「結果」實際上都指向同一份、已經被清空的 path,得到一堆空結果或完全相同的結果。
  3. 剪枝條件寫得太晚(在遞迴呼叫內部才檢查)而不是在呼叫前檢查:把 isValid 檢查放進遞迴函式的第一行(而不是呼叫前的 for迴圈內),仍然能得到正確結果,但會多付出一次不必要的函式呼叫開銷,對深度/分支因子大的問題會有可觀的效能差異。

Backend Applications:設定檔/依賴組合的驗證(例如給定一組互斥或依賴的 feature flag,窮舉所有合法組合來做測試矩陣)、排班系統窮舉合法班表(每個人的班次組合需滿足連續工時、休息時間等約束,用剪枝提早放棄違反約束的部分排班)、SQL 查詢優化器在決定 join 順序時(多表 join 的順序數量是階乘級,優化器用類似 backtracking + 剪枝的策略搜索代價最小的 join 順序,見 Day 32 Query Planner)都是 backtracking「窮舉+剪枝」思路的實務應用。

練習

LeetCode 46 — Permutations(給一個不含重複數字的陣列,回傳所有可能的排列)。用 used []bool 陣列追蹤目前路徑上已經用過哪些索引,每層迴圈嘗試所有還沒用過的數字:選擇(加入path、標記 used[i] = true)→ 遞迴探索下一層 → 撤銷(從 path移除、used[i] = false)。用 [1,2,3](答案應包含全部 3! = 6種排列:[1,2,3] [1,3,2] [2,1,3] [2,3,1] [3,1,2] [3,2,1])驗證數量與內容皆正確。

Day 28 過關標準(DoD)—— DSA 特殊驗收格式

``text Pattern: Backtracking(選擇/探索/撤銷選擇,用 used[] 追蹤路徑上已用索引)Why: 排列問題的合法性依賴「目前路徑上有哪些索引已經用過」,用 used[] 而非每層重新掃描 path 判斷是否用過,把每層的合法性檢查從 O(n) 降到 O(1),且撤銷選擇(used[i]=false)保證探索完一個分支後狀態正確還原給下一個兄弟分支使用。Mistake: 把找到的 path 直接 append 進 result 而沒有複製一份新的 slice,導致所有排列最後都指向同一份、已被清空的底層陣列,輸出結果全部相同或為空。Complexity: 時間 O(n! × n)(n! 種排列,組裝每個排列需要 O(n)複製);空間 O(n)(遞迴深度 + used 陣列,不含輸出本身)。Reusable Insight: 任何「窮舉所有排列/組合/子集合」的問題,先確認骨架是「選擇→遞迴→撤銷」三步驟,撤銷這一步永遠對應選擇這一步做的狀態改動的反操作,兩者必須成對、且對稱。``

Day 29 — Dynamic Programming

學習目標

看完今天內容後,能夠:

  1. 對一個新問題,能依 State → Transition → Base Case → Order 四步驟推導出完整的 DP 解法,而不是套用背過的題型模板。
  2. 分辨 top-down(memoization,遞迴 + 快取)與 bottom-up(tabulation,迭代填表)兩種實作方式的差異,並說明各自的優缺點。
  3. 判斷一個問題是否具備「重疊子問題」(overlapping subproblems)與「最優子結構」(optimal substructure),確認適合用 DP 而非單純遞迴或貪婪法。

教材大綱

1

Dynamic Programming

Core FundamentalsLv.4

What:Dynamic Programming(DP)是一種系統性地把「有重疊子問題的遞迴」用一張表(快取)記住每個子問題的答案,避免重複計算,把指數級的暴力遞迴壓到多項式級的技巧;DP 不是一種資料結構,也不是特定演算法,而是「辨識問題結構、用表格避免重算」的一套方法論。

Why

Day 26 的 Failure Mode 已經點出 naive 遞迴費氏數列會因為子問題重疊而變成 O(2^n);DP 正是解決這個問題的系統性做法——只要一個問題同時滿足重疊子問題(同一個子問題會被不同路徑重複呼叫到)與最優子結構(整個問題的最優解可以由子問題的最優解組合得到),就可以套用 DP 的四步驟框架,把任何這類問題從指數級降到多項式級,這是 DSA 裡「用額外空間換時間」最典型也最有威力的體現。

Mechanism — State → Transition → Base Case → Order:這四步驟是推導任何 DP 問題的通用框架,缺一不可:

  1. State(狀態):定義「子問題」需要哪些變數才能唯一描述——通常對應遞迴函式的參數。狀態定義錯誤(少了一個維度)是 DP 最常見的失敗原因,因為少了某個維度會讓「同一個 state」實際代表多個不同的子問題,快取會存錯答案。
  2. Transition(轉移方程):描述「一個狀態的答案」怎麼由「更小的狀態」組合得到,這是遞迴關係式本身。
  3. Base Case(邊界情況):最小的、不能再往下拆的狀態,直接給出答案,是遞迴(或填表)的起點。
  4. Order(計算順序):確保計算某個狀態時,它所依賴的所有更小狀態都已經算完——bottom-up 版本要明確決定填表的迴圈方向(例如由小到大),top-down 版本則靠遞迴呼叫本身天然保證順序正確(先呼叫子問題,子問題算完才回傳)。

完整推導範例——0/1 Knapsack(0/1 背包問題):給定 n 件物品,每件有重量 weight[i] 與價值 value[i],背包容量上限 W,每件物品只能選 0 次或 1 次(不能拆分、不能重複拿),求能裝進背包的最大總價值。

  • Statedp[i][w] = 「只考慮前 i 件物品、背包容量上限為w 時,能達到的最大總價值」。需要兩個維度:i(目前考慮到第幾件物品)與 w(目前剩餘容量)——少了 i 這個維度,dp[w]會分不清「這個 w 的最優解是否已經用過某件物品」,導致同一件物品被重複計入或狀態污染。
  • Transition:對第 i 件物品,只有兩種選擇——不拿(總價值等於「前 i-1 件、容量仍是 w」的最優解)或(前提是weight[i] ≤ w,總價值等於「前 i-1 件、容量剩w - weight[i]」的最優解,再加上 value[i]),取兩者較大值:

``text dp[i][w] = dp[i-1][w] 若 weight[i] > w dp[i][w] = max(dp[i-1][w], dp[i-1][w-weight[i]] + value[i]) 若 weight[i] ≤ w``

  • Base Casedp[0][w] = 0(對所有 w,沒有物品可選時總價值是 0);dp[i][0] = 0(對所有 i,容量是 0 時裝不下任何東西)。
  • Orderdp[i][*] 依賴 dp[i-1][*],所以外層迴圈 i 必須由小到大(1n),w 這個維度在同一個 i 內彼此不互相依賴,可以任意順序(但實務上也由小到大迭代)。

Top-down(memoization)vs Bottom-up(tabulation):Top-down 直接把上面的遞迴關係式寫成遞迴函式,額外加一個快取(map[state]answer 或多維陣列),呼叫前先檢查快取是否已有答案;Bottom-up 則反過來,依 Order 步驟決定的順序,用迴圈由小到大把整張表填滿,不使用遞迴。兩者計算的狀態數與最終答案完全相同,差異在於:top-down 只計算「真正被呼叫到」的狀態(某些問題不是所有狀態都會被用到,可能更省時間),且程式碼直接對應遞迴定義、較好懂;bottom-up 沒有遞迴呼叫的 overhead 與 call stack 深度限制(Day 26 提過的 stack overflow 風險),且通常更容易進一步做空間優化(例如發現 dp[i][*] 只依賴 dp[i-1][*],可以把二維陣列壓成一維滾動陣列,空間從 O(n·W) 降到 O(W))。

Trade-off

DP 用額外的 O(狀態數量) 空間換取時間複雜度從指數級降到多項式級,但如果狀態空間本身就很龐大(例如狀態需要好幾個維度、每個維度範圍都很大),即使多項式複雜度也可能因為狀態數過多而不可行——這時需要判斷是否能用「滾動陣列」等技巧壓縮空間,或退而求其次,接受一個近似解(不在本 Phase 討論範圍)。

Failure Modes

  1. 狀態定義少了一個必要維度:0/1 Knapsack 若省略 i 這個維度、只用 dp[w],會導致同一件物品在計算過程中被「拿了不只一次」(因為少了「只考慮前 i 件」這個限制,內層迴圈更新dp[w] 時可能用到「本輪已經更新過、其實包含這件物品」的值),這正是 0/1 Knapsack 與「完全背包(每件物品可以拿無限次)」的關鍵區別——完全背包確實可以只用一維陣列且正向迭代 w,0/1 Knapsack 若要壓成一維陣列,w 必須反向(由大到小)迭代,才能保證同一件物品不會在同一輪內被重複使用。
  2. Order 錯誤,使用了還沒計算的狀態:例如把上面 0/1 Knapsack 的 i 迴圈寫反(由大到小),會讓 dp[i-1][w] 這個查找存取到還沒計算過的值(陣列預設值,通常是 0),得到錯誤答案而不會報錯,這是 DP 最隱蔽的 bug 之一。
  3. 問題其實不具備最優子結構,卻硬套 DP:子問題之間的最優解不一定能簡單組合成全局最優解時(例如額外附加了「相鄰選擇之間差值必須恰好等於某個變動限制」這類會破壞子問題獨立性的條件),套用標準 DP 轉移方程會得到錯誤結果——判斷一個問題是否具備最優子結構,是推導 DP 前必要的第一步,不是所有「看起來需要選擇」的問題都適合 DP(例如需要真正窮舉所有排列的問題可能要 Day 28 的 Backtracking)。

Backend Applicationsdiff 工具(git diff)與拼字檢查/模糊搜尋(fuzzy search,例如搜尋建議「您是不是要找 XXX」)背後常用 Edit Distance(Levenshtein Distance,計算把一個字串轉換成另一個字串所需的最少插入/刪除/替換次數)——這是標準的二維 DP,dp[i][j] 代表「字串 A 前 i 個字元轉換成字串 B 前 j 個字元所需的最少操作數」;API 限流的 token bucket 演算法計算「目前累積可用的請求配額」時,也是一種簡化版的狀態遞推(雖然通常不需要完整的表格,但轉移方程的思路相同:目前配額 = 上一刻配額 + 這段時間補充的量,取上限);資料庫查詢優化器評估多表 join 的執行計畫代價時,某些實作用 DP 記住「join 前 k 張表的最小代價計畫」,避免對每種 join 順序重新算一次代價(這與 Day 28 Backtracking 提到的 join 順序搜索是同一個問題的兩種不同解法策略)。

陌生題(Core Fundamentals 要求):一個文件審核系統要決定「最多可以核准哪些互不衝突的審核任務」——每個任務有 start[i]end[i](佔用審核員的時間區間,視為半開區間 [start, end)end[i] == start[j] 視為首尾相接、不算衝突)與 weight[i](這個任務的優先權重),任務之間如果時間區間重疊就不能同時核准(審核員一次只能審一個),目標是選出一組互不重疊的任務,使總權重最大。不能直接寫 code,先回答:

為什麼貪婪法(例如優先選權重最高的任務)在這裡不一定正確?該怎麼用 DP 推導?

推導:貪婪法的反例——如果權重最高的任務時間區間橫跨了另外幾個較小但總權重更高的任務,優先選權重最高的那個會排擠掉更好的組合,貪婪的「當下最優」不保證「全局最優」(這與 Day 30 會討論的、貪婪法真正適用的場景形成對比)。改用 DP:先把所有任務依 end[i]由小到大排序(排序後才能用「上一個不衝突任務」這個概念,這也是為什麼 Sorting 是 Day 25 排在 DP 之前的原因之一)。Statedp[i] = 「只考慮排序後前 i 個任務時,能達到的最大總權重」。Transition:對第 i 個任務,要嘛不選它(dp[i] = dp[i-1]),要嘛選它(找出排序後所有 end[j] ≤ start[i] 的任務中 j 最大的那個,dp[i] = dp[j] + weight[i],這裡的 j 可以用 Binary Search 在排序後的 end 陣列上找到,呼應 Day 24),取兩者較大值。Base Casedp[0] = 0(沒有任務時總權重是 0)。Orderi 由小到大(依排序後順序),因為 dp[i] 依賴的 dp[j]j < i)一定已經算過。

再寫 code:實作上述解法(含排序、Binary Search 找 j、DP 遞推),對測資tasks = [(1,3,5), (2,5,6), (4,6,5), (6,8,4)](start,end,weight),依 end 排序後恰好是原順序)驗證:dp[1]=5(選任務 1)、dp[2]=max(5, 0+6)=6(任務 2 與任務 1 衝突,不如只選任務 2 本身,但仍不如後面選任務 1+3)、dp[3]=max(6, dp[1]+5=10)=10(選任務 1+3)、dp[4]=max(10, dp[3]+4=14)=14(選任務 1+3+4)——最終答案14(選任務 1、3、4,總權重 5+5+4=14),且直接驗證「優先選權重最高的任務 2(權重 6)」的貪婪解只能再接上任務 4(6+4=10),確實劣於 DP 找到的 14,證實此問題不能用貪婪法。

練習(Day 29)

除上方陌生題外,另解 LeetCode 322 — Coin Change(給一組硬幣面額 coins 與目標金額 amount,求湊出 amount 所需的最少硬幣數,無法湊出回傳 -1)。Statedp[a] = 「湊出金額 a 所需的最少硬幣數」。Transitiondp[a] = min(dp[a - c] + 1),對所有 c in coinsc ≤ aBase Casedp[0] = 0Ordera 由小到大(1amount),因為 dp[a] 依賴dp[a-c]a-c < a)。用 coins=[1,2,5], amount=11(答案 35+5+1)驗證,並用 coins=[2], amount=3(答案 -1,無法用面額 2 湊出奇數金額 3)驗證「無解」分支正確回傳 -1 而不是錯誤地回傳一個未初始化的值。

Day 29 過關標準(DoD)—— DSA 特殊驗收格式

``text Pattern: 區間排程 DP(Weighted Interval Scheduling,依 end 排序 +Binary Search 找上一個相容任務)Why: 貪婪選最高權重任務會被「該任務排擠掉更好組合」的反例推翻,必須改用 DP:dp[i] 同時考慮「不選第 i 個任務」與「選它、接上最後一個不衝突任務的最優解」兩種可能,取較大值才能保證全局最優。Mistake: 忘記先依 end 排序就直接套用「找上一個不衝突任務」的 transition,會導致 j 的搜尋失去單調性,Binary Search 找到錯誤的 j 或根本無法用 Binary Search(因為陣列未排序)。Complexity: 時間 O(n log n)(排序 O(n log n) + 每個任務一次 Binary Search O(log n));空間 O(n)(dp 陣列)。Reusable Insight: 看到「選一組互不衝突的區間/任務,最大化某個加權總和」的問題,且貪婪對權重無法保證最優時,優先考慮「排序 + dp[i] 依賴『上一個相容位置的 dp 值』」這個模板,這與 Day 24 Binary Search on Answer 的『先確保單調性/有序,才能用二分加速』是同一個原則的不同應用。``

Day 30 — Greedy / Graph(收尾)

學習目標

看完今天內容後,能夠:

  1. 用 exchange argument(交換論證)判斷一個 greedy 選擇規則是否真的能保證全局最優解,而不是單憑直覺假設「貪心一定可行」。
  2. 在無權重與有權重圖上,分別選用正確的最短路徑演算法(BFS vs Dijkstra),並說明兩者本質上的共同點與差異。
  3. 給一道沒看過的圖相關題目,能完整走過 brute force → bottleneck→ pattern → optimal approach → complexity → code 六步驟。

教材大綱

1

Greedy

Supporting TopicsLv.3

What:Greedy(貪婪法)在每一步都做「目前看起來最好」的選擇,不回頭、不考慮這個選擇對未來造成的全部後果,直接組裝出最終答案;只有在問題具備特定數學性質(下面會展開)時,這種「短視」的做法才恰好也是全局最優解。

Why

當一個問題確實具備 Greedy 適用的結構時,Greedy 通常比 DP 快得多(不需要維護表格、不需要考慮所有子問題,往往只需要先排序再線性掃描一次);但 Day 29 的陌生題已經示範過,多數看起來「可以貪心」的問題實際上不行——理解 Greedy 何時正確、何時會失敗,比記住「這題可以用貪心」的結論更重要,這是本 Phase 最後一個要建立的判斷力。

Mechanism — Exchange Argument(交換論證):證明一個 Greedy 選擇規則正確的標準方法:假設存在一個「不遵守 Greedy 規則」但比 Greedy 解更好(或至少一樣好)的最優解,證明可以把這個最優解裡「不符合 Greedy 規則」的部分,逐步「交換」成符合 Greedy 規則的選擇,且每次交換後解的品質不會變差——如果能證明這件事,代表「一路都遵守 Greedy 規則」的解至少不劣於任何其他解,也就是 Greedy 解本身就是最優解之一。用活動選擇問題(Activity Selection)示範:給一組有起訖時間的活動,選出最多數量的互不重疊活動(注意這裡是最大化活動數量,跟 Day 29 陌生題的「最大化總權重」是不同的目標函式)。Greedy 規則:永遠優先選「結束時間最早」的活動。交換論證:假設最優解 O 沒有優先選結束最早的活動 A,而是選了另一個結束更晚的活動 B 作為第一個活動——因為 A 結束得比 B 早,把 O 裡的 B 換成 A,A 之後空出的時間只會更多不會更少(A 結束更早),所以 O 剩下的活動安排完全不受影響、還是合法的,活動數量不變——這代表「換成選 A」永遠不會讓解變差,因此「優先選結束最早的活動」這個 Greedy 規則是安全的。

Trade-off

Greedy 一旦確認適用,通常是同類問題裡時間複雜度最低、程式碼最簡單的解法(多數只需要 O(n log n) 排序 + O(n) 線性掃描);代價是「適用性」的判斷本身沒有萬用公式,必須針對每個問題個別用 exchange argument 或反例驗證,誤判的後果是得到一個「看似合理但實際上是錯的」答案,且這種錯誤往往不會在測資不夠刁鑽時被發現。

Failure Modes

  1. 目標函式换了,貪婪規則就不再成立:Day 29 陌生題已經示範——「最大化活動數量」用「結束最早優先」是對的,但换成「最大化總權重」後,同一個「結束最早優先」的規則不再保證最優(可能為了塞更多活動而放棄了少數幾個高權重活動),必須改用 DP。同一個問題只要目標函式一變,原本驗證過的 Greedy 規則就必須重新證明,不能直接沿用。
  2. 找零/背包這類問題裡,貪心對某些輸入集合失效:如果硬幣面額是 {1, 3, 4},要湊出金額 6,貪心(每次拿能拿的最大面額)會先拿 4、剩 2、再拿兩個 1,共 3 枚硬幣;但最優解是 3+3,只需要 2 枚——這說明貪心找零只在面額系統具備「canonical」性質(例如美元硬幣 {1,5,10,25})時才保證最優,一般情況必須用 Day 29 的 DP(Coin Change)。
  3. 忽略「排序依據選錯」導致貪心規則名不符實:例如活動選擇問題若排序依據錯用「開始時間最早」而非「結束時間最早」,會導致優先選到一個佔用時間很長的活動,排擠掉後面原本能塞下的更多活動,貪心規則本身沒錯,但排序的 key 選錯就等於换了一個不成立的規則。

Backend Applications:Rate limiter 的 token bucket 演算法(每個時間點貪婪地用「目前可用的 token 數」決定是否放行請求,不回頭考慮未來的流量分佈);負載平衡器的「最短佇列優先」(shortest-queue-first)策略——每次新請求進來,貪婪地分配給目前佇列最短的伺服器,這在多數流量分佈下接近最優,但在請求處理時間差異極大時可能失效(見 Failure Mode 的類比:目標函式其實是「總完成時間」而非「即時佇列長度」);快取淘汰策略中的 LFU(Least Frequently Used,貪婪地淘汰目前使用頻率最低的項目)也是一種貪婪策略,同樣只在存取模式符合特定假設(近期頻率能代表未來頻率)時才接近最優。

練習(Greedy):解 LeetCode 55 — Jump Game(給一個陣列numsnums[i] 代表在位置 i 最多能往前跳幾步,判斷能不能從位置 0 跳到最後一個位置)。貪婪解法:維護一個變數 maxReach(目前為止,從頭開始所有可達位置中,能到達的最遠位置),從左到右掃描每個位置 i:如果 i > maxReach 代表這個位置根本到不了,直接回傳 false;否則更新 maxReach = max(maxReach, i + nums[i])。用交換論證解釋為什麼「貪婪地維護最遠可達距離」是安全的:如果存在某條路徑能到達位置 j,那麼「用最遠可達距離」這個單一數字,永遠不會漏掉任何一條實際可行的跳躍路徑(因為它是所有已知可達位置中「往前跳最遠」的那個上界,其他較近的可達位置不可能跳得比它更遠)。用 nums=[2,3,1,1,4](答案 true)與 nums=[3,2,1,0,4](答案 false,因為位置 3 的 maxReach 卡在 4、跳不過那個0)驗證。

2

Graph(Algorithms 層面)

Core FundamentalsLv.4

What:Day 27 已經建立 DFS/BFS 這兩種走訪機制本身;本節把它們應用在具體的圖問題上——最短路徑(無權重用 BFS、有權重用 Dijkstra)、連通性(Union-Find 判斷任兩節點是否連通)、拓樸排序(在有向無環圖上,找出一個符合所有依賴順序的線性排列)。

Why

「圖」是 Backend 系統裡最常見的隱藏資料結構——服務依賴、任務排程的先後關係、社群網路的關注關係、路由拓樸——本節的三類問題(最短路徑/連通性/拓樸排序)分別對應「找最快的路」「判斷兩者是否相關」「決定合法的執行順序」這三種在真實系統裡反覆出現的需求,是 Phase 03 最後、也是應用範圍最廣的一組演算法。

Mechanism

  • Dijkstra(單源最短路徑,非負權重):本質上是把 BFS 的 queue 換成依「目前已知最短距離」排序的 priority queue(min-heap,Phase 02 Day 17)——每次從 priority queue 取出「目前距離起點最近、且還沒確定最終距離」的節點,確定它的最短距離(一旦被取出就不會再變小,這是 Dijkstra 正確性的核心:因為所有邊權重非負,之後才被處理的節點距離只會更遠,不可能反過來提供更短的路徑),再用它去更新(relax)所有鄰居的暫定距離。這是一個 Greedy 演算法——「每一步都確定目前已知最近的節點」正是 Greedy 的選擇規則,能證明正確是因為非負權重保證了「確定順序」不會被後來的邊推翻,這也解釋了為什麼 Dijkstra 不能處理負權重邊(負權重會讓「已確定」的最短距離之後被更短的路徑推翻,違反 Greedy 選擇規則的前提)。

``text func dijkstra(graph, start) {dist := {start: 0, 其他節點: infinity}pq := minHeap{(0, start)} // (距離, 節點)while !pq.empty() {(d, node) := pq.popMin()if d > dist[node] { continue } // 已有更短距離被處理過 for (neighbor, weight) in graph[node] {newDist := dist[node] + weight if newDist < dist[neighbor] {dist[neighbor] = newDist pq.push((newDist, neighbor))}}}return dist}``

  • Union-Find(連通性):維護一個「每個節點屬於哪個連通元件」的資料結構,支援兩個操作:find(x)(找出 x 所屬連通元件的代表節點)與 union(x, y)(把 xy 所屬的兩個連通元件合併成一個)。用「每個節點記錄自己的 parent,根節點的 parent 是自己」實作,find 沿著 parent 鏈往上走到根節點;搭配 path compressionfind 過程中把沿途每個節點的 parent 直接指向根節點,壓平樹的高度)與 union by rank/size(合併時把較小的樹接到較大的樹底下),均攤後每次操作接近 O(1)(嚴格來說是 O(α(n)),α 是反阿克曼函數,成長極慢,實務上視為常數)。
  • 拓樸排序(Topological Sort):只對有向無環圖(DAG)有意義,找出一個節點排列順序,使得對每條邊 u → vu 都排在v 前面。兩種標準實作:Kahn's algorithm(BFS 版)——先計算每個節點的入度(in-degree,有幾條邊指向它),把入度為 0 的節點(沒有任何前置依賴)全部加入 queue,每次取出一個節點加入結果、並把它所有鄰居的入度減一,入度變成 0 就加入 queue,直到 queue 空;DFS 版——對每個節點做 DFS,一個節點的所有鄰居都遞迴處理完後,把這個節點加入結果的最前面(或用一個 stack,最後反轉),因為「所有鄰居都處理完」代表這個節點沒有更多依賴需要排在它前面。兩種版本都能順便偵測環:Kahn's algorithm 結束後如果處理過的節點數少於總節點數,代表剩下的節點都卡在環裡(入度永遠不會歸零);DFS 版本則是在遞迴路徑上維護一個「目前在這條路徑上」的標記,若 DFS 過程中碰到一個「目前在路徑上」的節點,代表找到了環。
Trade-off

Dijkstra 的時間複雜度是 O((V+E) log V)(每個節點/邊最多進出 priority queue 一次,每次操作 O(log V)),比 BFS 的 O(V+E) 慢,但能處理帶權重的圖;Union-Find 判斷連通性比每次都重新做一次 DFS/BFS 快得多(DFS/BFS 每次查詢是 O(V+E),Union-Find 均攤每次操作接近 O(1)),但 Union-Find 只能回答「是否連通」,不能像 BFS 一樣順便給出最短路徑;拓樸排序的 Kahn's(BFS)版本天然能在過程中偵測環(不需要額外標記),DFS 版本則需要額外維護遞迴路徑上的狀態才能偵測環,但 DFS 版本不需要額外計算入度陣列,兩者的取捨主要在「是否已經有入度資訊」與「是否需要順便做其他 DFS 相關的處理」。

Failure Modes

  1. 對含負權重邊的圖使用 Dijkstra:如上方 Mechanism 解釋,Dijkstra 的正確性依賴「非負權重」這個前提,含負權重邊時必須改用 Bellman-Ford(能處理負權重,但時間複雜度更高,O(V×E),本 Phase 不展開)。
  2. Union-Find 沒有做 path compression / union by rank,退化成線性鏈:如果每次 union 都不考慮樹的大小、任意把一棵樹接到另一棵樹下面,且 find 不做路徑壓縮,最差情況下(一路都接成一條鏈)find 會退化成 O(n),失去 Union-Find 應有的效率優勢。
  3. 對有環的圖嘗試拓樸排序卻沒有偵測環,得到不完整或錯誤的排序:如果任務排程系統的依賴圖裡不小心出現循環依賴(A 依賴 B、B 依賴 A),拓樸排序理論上無解——沒有偵測環的實作可能只是靜默回傳一個不完整的排序(漏掉環裡的節點),比直接報錯更危險,因為呼叫端可能誤以為排序是完整、正確的。

Backend Applications:建置系統(build system,例如 Bazel、Make)用拓樸排序決定編譯順序(先編譯沒有依賴的模組,再編譯依賴它們的模組),並用環偵測擋下循環依賴的設定;分散式系統中判斷「兩個節點是否在同一個 partition/cluster」用 Union-Find(例如 Kruskal's 最小生成樹演算法,本身就是「依權重排序邊、用 Union-Find 判斷加入這條邊會不會形成環」,常用於網路拓樸設計);路由系統計算「兩個服務之間延遲最低的路徑」用 Dijkstra(把延遲當作邊權重);Day 27 提過的依賴解析(Go module、任務 DAG)在確認「不存在循環依賴」(DFS 環偵測)後,用拓樸排序決定實際的建置/執行順序,兩者往往是同一個依賴圖上的連續兩個步驟。

六步驟陌生題示範(對應 Day 30 收尾驗收):LeetCode 207 —Course Schedule(給定 numCourses 門課與一組先修關係prerequisites[i] = [a, b](代表要修 a 必須先修 b),判斷是否可能修完所有課程)。

  1. Brute Force:對每一門課,遞迴檢查它的所有先修課程是否都能修完(遞迴檢查先修課程的先修課程……),沒有記錄「目前檢查路徑上有哪些課程」時,遇到循環依賴會無窮遞迴下去;即使加上簡單的重複檢查,未經整理的暴力法也可能對同一門課的先修鏈重複驗證多次。
  2. Bottleneck:問題的本質是「這張『先修關係』圖是否存在環」——只要有環(例如 A 依賴 B、B 依賴 A),代表這些課程永遠無法排出合法的修課順序;暴力遞迴檢查的瓶頸在於沒有系統性地追蹤「目前遞迴路徑上」與「已經確認沒問題」的節點,導致重複驗證與無法正確偵測環。
  3. Pattern:這是圖上的環偵測問題,對應 Day 27 DFS 與本節拓樸排序的環偵測技巧——把 prerequisites 轉換成有向圖(b → a代表 ba 的先修課),問題等價於「這個有向圖是否為 DAG(無環)」。
  4. Optimal Approach:對每個節點做 DFS,維護三種狀態:unvisited(還沒檢查)、visiting(目前在這條遞迴路徑上,還沒確認完成)、visited(已確認這個節點與它所有後續依賴都沒有環)。DFS 過程中如果碰到一個標記為 visiting 的節點,代表目前的遞迴路徑繞回了自己,找到環,直接回傳 false;一個節點的所有鄰居都確認沒有環之後,把它標記為 visited(之後不需要再檢查,避免重複驗證同一門課的先修鏈)。也可以改用 Kahn's algorithm(BFS 版拓樸排序):計算所有課程的入度,入度為 0 的先加入 queue,逐一處理並讓後續課程入度遞減,最後檢查「成功排入結果的課程數」是否等於 numCourses,不等於就代表有環。
  5. Complexity:時間 O(V+E)(V 為課程數,E 為先修關係數,DFS 或 Kahn's algorithm 都是每個節點與每條邊最多處理一次);空間 O(V+E)(鄰接表儲存圖 + 狀態標記/入度陣列)。
  6. Code:實作上述 DFS 三態版本,對測資 numCourses=2,prerequisites=[[1,0]](答案 true,修課順序 0 → 1)與numCourses=2, prerequisites=[[1,0],[0,1]](答案 false0 依賴 11 依賴 0,形成環)驗證。

Day 30 過關標準(DoD)—— DSA 特殊驗收格式

``text Pattern: Exchange Argument 驗證 Greedy(Jump Game,貪婪維護最遠可達距離)Why: 用一個單一數字(目前已知最遠可達位置)取代「追蹤所有可能路徑」,因為任何實際可行的跳躍路徑所能到達的位置,都不會超過這個上界,維護上界本身不會漏掉任何真正可行的解。Mistake: 誤以為要窮舉所有跳躍路徑組合才能確定能不能到達終點(誤判成需要 Backtracking/DFS 解),沒發現「最遠可達距離」這個單一貪婪維護的數字就足以正確判斷,多做了不必要的指數級窮舉。Complexity: 時間 O(n)(線性掃描一次);空間 O(1)(只需要一個 maxReach 變數)。Reusable Insight: 看到「能不能到達/最少步數到達」且每一步的『能力』單調不減(走得更遠的位置,可達範圍只會更廣不會更窄)的問題,優先考慮貪婪維護一個『目前已知的最佳上界/下界』,而非窮舉所有路徑;證明正確性前務必先做一次 exchange argument 或找反例,不能單憑直覺假設貪婪一定可行(見上方 Greedy Failure Mode 1、2)。``

``text Pattern: DFS 三態環偵測(unvisited/visiting/visited,Course Schedule)Why: 用『目前在遞迴路徑上』(visiting)這個狀態,把『環』的定義(某條路徑繞回自己)轉換成一個可以在 DFS 過程中直接檢查的條件,同時用『已確認完成』(visited)避免對同一個節點的依賴鏈重複驗證,是圖環偵測的標準做法。Mistake: 只用一個 boolean visited 陣列(沒有區分『在路徑上』與『已確認完成』),會誤把『兩條不同路徑都經過同一個安全節點』判斷成環,因為分不清這個節點是『目前正在被遞迴呼叫』還是『之前已經確認過、現在只是被另一條路徑重新走到』。Complexity: 時間 O(V+E)(每個節點與每條邊最多處理一次);空間 O(V+E)(鄰接表 + 狀態陣列 + 遞迴深度最壞 O(V))。Reusable Insight: 圖上『判斷是否存在環』的題目,DFS 三態法與 Kahn's algorithm(拓樸排序,入度歸零判斷)是兩種等價但實作方式不同的標準解法,兩者都能同時得到『若無環,一組合法拓樸排序』這個額外資訊,遇到需要輸出實際排序結果的題目應優先選這兩種而非單純的 boolean 環偵測。``

Day 26–30 完成後,第 7 章1 Core Patterns 中的 Recursion / DFS / BFS / Backtracking / Greedy / Dynamic Programming / Graph 七項全數涵蓋,Recursion、Dynamic Programming、Graph(Core Fundamentals)各附一題不能直接寫 code 的陌生題推導,Dynamic Programming 額外完整展開 State→Transition→Base Case→Order 四步驟的推導範例(0/1 Knapsack)。加上 T-023 的 Day 21–25,Phase 03(Algorithms)12 個 Core Patterns 至此全部涵蓋完畢,Phase 03 到此結束,Phase 04(Database Internals,Day 31–40)由 T-025 接續。

  1. 依 00-master-curriculum.md 第 7 章