Phase 02 — Data Structures(Day 11–20)

100 Day Engineer Challenge

Phase 02 — Data Structures(Day 11–20)

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

本檔案由兩個任務接力完成:本段(T-021)涵蓋 Day 11–15:Array /String / HashMap / HashSet / Linked List / Stack / Queue;Day 16–20(Deque / Heap / Binary Tree / BST / Trie / Graph)由 T-022 接續寫在本檔案後半段,不另開新檔。

Day 11–15 的順序依 prerequisite chain2 排列:Array / String 無前置、是後面所有結構的參照基準,最先教;HashMap 的 bucket 底層是 Array、Resize 的搬遷成本要用 Array「連續配置」的特性才能解釋,故排在 Array 之後;HashSet 是 HashMap 拿掉 value 的特化版,必須先懂 HashMap 的 bucket/collision 才有意義,排在 HashMap 之後;Linked List 與 Array 無依賴關係、是對照學習的獨立起點,排在這裡是為了在 Day 15 教 Stack / Queue 之前,讓兩種底層實作方式(Array 或 Linked List)都已經介紹過;Stack / Queue 可用 Array 或 Linked List 任一種實作,排在兩者之後最合理。

每個資料結構依內部規劃文件列出的 9 個面向撰寫3(What / Why /Internal Structure / Core Operations / Complexity / Strength /Weakness / Typical Problems / Backend Applications),並依分類標準4標註分類(Core Fundamentals / Supporting Topics / Awareness Topics)與對應的目標精熟等級;9 個面向的安排本身即涵蓋 content-standard 要求的 Why / Mechanism / Trade-off / Failure mode / Backend 連結五段式內容(What+Why→Why、Internal Structure+Core Operations→Mechanism、Strength+Weakness→Trade-off、Typical Problems 內含常見誤用/退化情境→Failure mode、Backend Applications→Backend 連結),不重複但也不遺漏。

Day 11 — 線性資料結構 I:Array / String

學習目標

看完今天內容後,能夠:

  1. 說明 Array 為什麼能用 index 做到 O(1) 存取,並解釋動態陣列(dynamic array)擴充時的攤銷(amortized)複雜度分析。
  2. 判斷什麼情境該用 Array、什麼情境插入/刪除代價太高不該用。
  3. 解釋字串 immutable 的設計為什麼會讓迴圈內重複拼接產生 O(n²) 陷阱,並知道正確的替代做法。

教材大綱

1

Array

Core FundamentalsLv.4

What:Array 是一段在記憶體中連續配置、每個元素大小相同、可用 index 直接定址的資料結構。「連續配置+固定元素大小」是它所有特性(無論優勢或限制)的根源。

Why

如果資料在記憶體中隨意散落,要找到「第 k 個元素」就必須從頭走訪、逐一確認——這是 O(n)。Array 存在的目的就是用「連續配置+固定大小」換取一個數學捷徑:只要知道起始位置與元素大小,任何位置的元素都能用一次乘法加法直接算出來,不需要走訪。

Internal Structure:第 k 個元素的記憶體位址 =base_address + k * element_size。因為每個元素大小相同且緊鄰排列,這個公式永遠成立,這就是 index 存取為何是 O(1) 的全部原因——它不是「查表」也不是「跳過去」,是直接算出來的。多數語言的「動態陣列」(Python list、Java ArrayList、Go slice、C++ vector)底層仍然是一段連續記憶體,只是實際配置的容量(capacity)大於目前使用的長度(length),多留的空間讓 append 不必每次都重新配置整段記憶體。

Core Operations:- Access by index:O(1)——直接用上面的公式算位置。- Search(未排序):O(n)——沒有任何捷徑可跳過任何一個元素。- Search(已排序):O(log n)——binary search,靠 index 隨機存取才有意義(Linked List 就算排序好也做不到 O(log n) search,因為找中點本身要 O(n))。- Insert / Delete at end:平均(攤銷)O(1),見下方 Resize 分析。- Insert / Delete at middle 或 front:O(n)——必須把插入點之後(或刪除點之後)的所有元素往後(或往前)搬移一格,維持「連續、無空隙」的不變量。

動態陣列擴充(Resize)的攤銷分析:當陣列已滿還要再 append 一個元素時,動態陣列會:(1) 配置一塊新的、容量通常是原本兩倍的連續記憶體;(2) 把舊陣列所有元素複製過去;(3) 釋放舊陣列,之後的 append 才真的寫進新配置的空間。單次觸發 resize 的那次操作是 O(n),但這種情況平均每 n 次 append 只發生一次;把 n 次 append 中所有 resize 的複製成本加總(1 + 2 + 4 + ... + n ≈ 2n),除以 n 次操作,平均每次操作只分攤到 O(1) 的成本——這就是「攤銷 O(1)」的意思:不是每次都真的是 O(1),是長期平均下來是 O(1),代價是偶爾會有一次明顯比較貴的操作。

Complexity 總表

| 操作 | 複雜度 ||---|---|| Access by index | O(1) || Search(未排序) | O(n) || Search(已排序,binary search) | O(log n) || Insert/Delete at end | 攤銷 O(1) || Insert/Delete at middle/front | O(n) |

Strength:O(1) random access;記憶體連續配置讓 cache locality 極好——CPU 讀取記憶體時會一次預抓一整條 cache line,Array 相鄰元素大機率已經被一起載入 cache,實務上即使理論複雜度相同,Array 走訪往往比指標追蹤的結構快上數倍。

Weakness:中間位置的插入/刪除代價高(O(n) 搬移);容量固定或需要 resize 時有一次性的搬遷成本;如果事先不知道資料量,過度保守配置會浪費記憶體、配置不足又會觸發多次 resize。

Typical Problems:Two Pointers、Sliding Window、Prefix Sum、原地反轉/旋轉(in-place reversal/rotation)——這些 pattern(Phase 03 會深入)全部建立在「O(1) random access」之上,沒有這個前提,這些技巧都無法用常數額外空間完成。

Backend Applications:連線池(connection pool)用固定大小的 slice/array 存放活躍連線;資料庫的 page 內部本質上是固定大小 slot 的陣列;分頁(pagination)用 LIMIT/OFFSET 時,OFFSET 的語意就是「陣列的第幾格開始」,這也是為什麼資料量大時 OFFSET 分頁會變慢(資料庫仍要數過前面所有列才能定位,等同陣列的線性特性在磁碟上的延伸)。

陌生題

一個服務把使用者上傳的紀錄先塞進一個記憶體內的 slice,用來累積批次寫入資料庫。上線後偶爾觀察到批次量大時,這段程式碼的延遲會突然飆高一下(其餘時間都很快,飆高後又恢復正常)。用 Array/動態陣列的機制解釋可能原因,並說出你會怎麼驗證。(提示:這是動態陣列觸發 resize 的特徵——resize 時要配置新陣列並複製全部既有元素,是一次性的 O(n) 成本,混在其餘 O(1) 的 append 操作中間就會表現成偶發的延遲尖峰;驗證方式是在配置 slice 時就用已知的預期批次量預先分配容量(例如 Go 的 make([]T, 0, expectedSize)),觀察尖峰是否消失。)

練習

用任何語言手刻一個「動態陣列」(不能用內建 slice/list 的自動擴充機制,自己控制底層陣列的 capacity 與目前 length),實作appendget(i)set(i, v)deleteAt(i) 四個操作;append在陣列滿了的時候,手動配置一塊兩倍容量的新陣列並搬移資料。印出每次觸發擴充時 capacity 的變化(1→2→4→8→...)驗證攤銷行為確實發生。

2

String

Supporting TopicsLv.3

What:String 是字元組成的序列,可以視為 Array 的特化版本(元素類型固定為字元),多數語言底層用一段連續的 byte 或 code point 陣列儲存。

Why

文字資料(使用者輸入、API 回應內容、SQL 查詢語句、設定檔)幾乎無所不在,需要一個標準方式表示與操作;把字串視為「元素是字元的 Array」,可以直接套用 Array 已經建立的存取與複雜度直覺,不用重新發明一套規則。

Internal Structure:兩個關鍵設計選擇決定了字串的行為:(1)編碼:Go 的 string 底層是 byte slice(UTF-8 編碼),用 index 存取拿到的是一個 byte,非 ASCII 字元(例如中文)會佔多個 byte,逐 byte 存取不等於逐字元存取,直接 s[i] 可能切到一個多位元組字元的中間;Python 3 的 str 則是 Unicode code point 序列,s[i] 保證拿到完整一個字元,但語言要在底層額外維護「code point → 記憶體位置」的對應(視內部編碼寬度而定)。(2) Immutability:多數語言(Java、Python、Go、JavaScript)的字串一旦建立就不可變,任何看似「修改」字串的操作(拼接、取代)實際上都是配置一段全新記憶體、複製舊內容加新內容,原本的字串物件本身完全沒被動到。

Core Operations:- Access by index:O(1)(byte 陣列語言中,注意這是 byte 不是字元,見上)。- Concatenation:單次 s1 + s2O(n+m)(配置新記憶體、複製兩段內容);在迴圈裡重複 s += x 執行 n 次,每次都要複製整個「目前已經有多長」的舊字串,總成本是 1+2+...+n ≈ O(n²),而不是直覺以為的 O(n)。- Substring:多數現代語言是 O(k)k 為子字串長度),需要複製一份新記憶體(少數語言的舊版實作曾經共享底層陣列做到 O(1),但這會導致「小的子字串佔住整個大字串的記憶體無法釋放」的記憶體洩漏問題,因此現代主流實作已改為複製)。- Compare:O(n)——逐字元比較,除非字串本身有快取 hash 值(如 Java StringhashCode 欄位,第一次算過就快取起來)。

Complexity 總表

| 操作 | 複雜度 ||---|---|| Access by index | O(1)(注意 byte vs 字元的落差) || 單次 Concatenation | O(n+m) || 迴圈內重複 Concatenation(n 次) | O(n²) || Substring | O(k) || Compare | O(n) |

Strength:語意清楚、標準函式庫支援完整;immutable 帶來 thread-safety——多個執行緒同時讀同一個字串完全不需要加鎖,因為沒有任何操作能真的修改它。

Weakness:immutable 意味著任何「修改」都要重新配置記憶體,迴圈拼接因此有 O(n²) 陷阱;多位元組編碼下,逐 byte 的 index 操作容易產生把字元切一半的 bug(例如對中文字串做固定 byte 長度截斷,可能切出無法解碼的殘缺字元)。

Typical Problems:Substring search(Phase 03 會深入 KMP/Rabin-Karp)、Palindrome check、Anagram 判斷(常搭配 HashMap 計數字元出現次數)、大量拼接時改用 builder pattern(如 JavaStringBuilder、Go strings.Builder)取代 +=

Backend Applications:SQL 查詢語句組裝——禁止用字串拼接把使用者輸入直接接進 SQL(同時踩到效能與安全性兩個問題:immutable 拼接的複製成本、以及 SQL Injection 風險),必須用 parameterized query;高頻路徑的 log 訊息組裝應使用 builder 而非 +=,避免在高 QPS 下產生大量短命的中繼字串物件、增加 GC 壓力;HTTP body 在[]bytestring 之間轉換也有複製成本,高吞吐服務會盡量減少不必要的轉換次數。

練習

寫一段程式碼比較「用 += 迴圈拼接 10,000 次字串」與「用該語言的 builder(StringBuilder / strings.Builder / "".join)達成相同結果」兩種方式的執行時間,實際量測並記錄兩者耗時的數量級差距,驗證 O(n²) 與 O(n) 的理論差異在真實執行時間上確實存在。

練習(Day 11 綜合)

把 Day 11 手刻的動態陣列練習與字串拼接效能比較練習的原始碼與實測數字整理成一份簡短報告:動態陣列擴充時容量變化序列(附印出的 log)、字串拼接兩種寫法的耗時對照表,並各自寫一句話說明背後的複雜度原因。

Day 11 過關標準(DoD)

  • Understand:能解釋 Array index 存取為何是 O(1)、動態陣列擴充為何是攤銷 O(1)、字串 immutable 為何讓迴圈拼接變成 O(n²)。
  • Recall:不看資料列出 Array 六種操作(access / search-unsorted/ search-sorted / insert-end / insert-middle / delete)與 String 四種操作(access / concat / substring / compare)各自的複雜度。
  • Apply:完成手刻動態陣列練習(跑得動、印出容量擴充序列)與字串拼接效能比較練習(跑得動、印出實測耗時數字)。
  • Explain:針對陌生題情境,正確解釋延遲尖峰的根因並提出具體的驗證方法(預先分配容量後觀察尖峰是否消失)。
  • Implement(Array 為 Core Fundamentals,額外要求):手刻的動態陣列需正確處理邊界情況——初始 capacity 為 0 時第一次 append 該如何配置、滿了才擴充(不能提前或延後)、擴充後舊資料需完整搬移不遺漏。
  1. 依 00-master-curriculum.md 第 6 章與 00-knowledge-dependency-graph.md 第 2.2 節
  2. 依 00-knowledge-dependency-graph.md §2.2 的 prerequisite chain 定義
  3. 依 00-master-curriculum.md 第 6 節列出的 9 個面向
  4. 依 curriculum/content-standard.md 的分類標準

Day 12 — HashMap 深入

學習目標

看完今天內容後,能夠:

  1. 畫出「key 進來到資料被存好/取出」的完整流程,包含 hash function、bucket、collision resolution 三個環節。
  2. 解釋 load factor 與 resize 如何共同讓 HashMap 維持平均 O(1),並說出 resize 造成的一次性成本會如何反映在真實系統的延遲上。
  3. 判斷一個 HashMap 退化成接近 O(n) 的情境可能是什麼原因造成的。

教材大綱

1

HashMap

Core FundamentalsLv.4

What:HashMap(也稱 Hash Table、Dictionary)是一種用 key 直接算出儲存位置、達到平均 O(1) 存取的資料結構。本質是「Array + Hash Function」的組合:底層仍然是一段連續配置的陣列(bucket array),Hash Function 負責把任意型別的 key 轉換成這個陣列的一個合法 index。

Why

Array 用 index 存取是 O(1),但現實中我們常常想用「有意義的 key」(使用者 ID 字串、物件)當索引,而不是連續整數。HashMap 存在的目的就是提供一個方法,把任意 key 轉換成一個可以直接定址的 array index,讓「用任意 key 查資料」也能接近 Array 的 O(1)。

Internal Structure(依內部規劃文件「特別深入」段落逐項展開1):

  • Hash function:把 key 轉成一個整數(hash code),再對 bucket array 的長度取模(hash(key) % bucket_count)得到實際存放的 index。一個好的 hash function 要讓不同 key 的 hash 值均勻分布,避免大量 key 集中落在同一個 bucket。
  • Bucket:bucket array 裡每一格稱為一個 bucket。因為多個不同的 key 有機會 hash 到同一個 index(見下),一個 bucket 實際上可能同時存放 0 筆或多筆資料。
  • Collision:兩個不同的 key 經過 hash 與取模後落到同一個 bucket index,稱為碰撞(collision)——即使 hash function 設計得再好,只要 key 的數量夠多、bucket 數量有限,碰撞在數學上是必然會發生的(鴿籠原理)。常見的兩種解法:1. Separate chaining:每個 bucket 存一個 linked list(或資料量大時退化成小型樹),碰撞的 key 全部掛在同一條 list 上;查詢時先用 hash 算出 bucket index,再走訪該 bucket 的 list 逐一比對 key 找到正確的那筆。2. Open addressing(如 linear probing):碰撞時不额外配置 list,而是依規則往後尋找下一個空的 slot 存放;查詢時也依同樣規則往後找,直到找到相符的 key 或遇到一個空 slot(代表 key 不存在)。
  • Load factorload factor = 元素數量 / bucket 數量,代表 bucket array 的擁擠程度。Load factor 越高,碰撞機率越高,每個 bucket 平均要比對的元素數也越多,效能會逐漸從 O(1) 退化向 O(n)。
  • Resize:當 load factor 超過設定門檻(常見預設 0.75)時,HashMap 會配置一個更大(通常是兩倍)的新 bucket array,並把所有既有元素重新計算 hash、依新的 bucket 數量重新分配位置(rehash)——這一步是必要的,因為 index 是靠 hash(key) %bucket_count 算出來的,bucket_count 一旦改變,同一個 key 對應的 index 幾乎必然也會跟著改變,舊的擺放位置全部失效。

Core Operationsget(key) / put(key, value) /delete(key) / containsKey(key)——四者的機制完全相同:先算hash(key) % bucket_count 定位到 bucket,再視碰撞解法在該 bucket 內找到(或建立)正確的 entry。

Complexity

| 情境 | 複雜度 ||---|---|| 平均情況(hash 分布均勻、load factor 受控) | O(1) || 最差情況(大量 key 碰撞到同一個 bucket) | O(n) |

最差情況並非只是理論假設——如果攻擊者能故意構造大量 hash 值相同的 key 送進一個公開接受使用者輸入當 key 的 HashMap(例如某些語言早期版本的 hash function 可被預測),可以把整個 HashMap 的操作拖到 O(n),這稱為 hash flooding attack,是部分語言後來把預設 hash function 加上隨機種子(每次程式啟動不同)的原因之一。

Strength:平均 O(1) 的 get / put / delete,遠快於 Array 的線性搜尋,是「用任意 key 快速查值」場景的預設選擇。

Weakness:最差情況會因碰撞退化;resize 觸發時有一次性的 O(n) 搬遷成本,即使長期攤銷後仍是 O(1),單一次操作仍可能踩到這個 O(n) 尖峰;沒有固定的遍歷順序(除非用 LinkedHashMap 等維護插入順序的變體);記憶體開銷比 Array 大——bucket array 通常留有尚未使用的空位,加上 separate chaining 額外的 linked list node overhead。

Typical Problems:用 HashMap 做 O(1) 查找/去重(如 Two Sum)、計數(字元頻率、Anagram 判斷)、快取查找(LRU Cache 的 O(1) 查找部分,見 Day 14)、集合運算(交集/聯集靠底層是 HashMap 的 HashSet,見 Day 13)。

Backend Applications:Session store 用 user id 當 key 查 session 資料;快取系統(如 Redis)核心概念是分散式版本的 hash table;PostgreSQL 支援 Hash Index,直接對應這裡「用 hash 值定位」的原理(適合等值查詢,不支援範圍查詢,因為 hash 值不保留大小順序);Rate limiter 用 client id 當 key 記錄該 client 目前的請求次數。

陌生題

一個服務用 HashMap 在記憶體裡快取十萬筆使用者資料,平常查詢都很快,但你發現每隔一段時間會出現一次明顯的延遲尖峰(其餘時間都很快,尖峰後又恢復正常);而且尖峰發生的間隔不是固定週期,資料量越大,兩次尖峰之間的間隔反而越長。用 HashMap 的機制解釋可能發生什麼,並說出你會怎麼驗證。(提示:這是 resize/rehash 觸發的特徵——load factor 超過門檻時要配置更大的 bucket array 並搬遷全部既有元素,是一次性 O(n) 成本;資料量越大,累積到下一次超過門檻所需插入的元素數也越多,兩次 resize 間隔自然隨資料量增加而變長;驗證方式是在建立 HashMap 時就用已知的預期容量預先配置(如 Java new HashMap<>(expectedSize) 或 Go make(map[K]V, n)),觀察尖峰是否消失。)

練習

手刻一個簡化版 HashMap(不能用語言內建的 map/dict),至少支援 put(key, value)get(key)delete(key),底層用一個固定大小的陣列(bucket array)加上你自選的碰撞解法(separate chaining 或 linear probing 擇一)。並手動觸發一次 resize(例如插入超過0.75 * 目前容量 個元素後,配置一個兩倍大小的新陣列並把全部既有元素重新 hash 搬過去),印出 resize 前後每個 key 對應的新 bucket index,驗證 rehash 確實正確發生。

練習(Day 12 綜合)

對上面手刻的 HashMap,人工構造至少兩個會碰撞到同一個 bucket 的 key(可以先印出每個候選 key 的 hash % 初始 bucket 數,挑出結果相同的兩個),驗證你的碰撞解法能讓兩者都被正確 putget回來、不會互相覆蓋遺失;接著把兩者的 value 都改掉,確認各自被正確更新且沒有互相影響。

Day 12 過關標準(DoD)

  • Understand:能解釋 hash function、bucket、load factor、resize 四個機制如何共同讓 HashMap 平均達到 O(1)。
  • Recall:不看資料畫出「key 進來 → hash → mod bucket 數 →定位 bucket →(碰撞處理)→ 找到/寫入資料」的完整流程圖。
  • Apply:完成手刻 HashMap 練習,含至少一次 resize,並印出 resize 前後的 bucket index 變化作為證據。
  • Explain:針對陌生題情境,正確指出 resize 是延遲尖峰的根因,並提出「預先分配容量」的具體驗證方法。
  • Implement(HashMap 為 Core Fundamentals,額外要求):碰撞處理需真正正確——完成 Day 12 綜合練習,證明兩個碰撞到同一 bucket 的 key 都能被正確存取、更新、彼此不覆蓋。
  1. 依 00-master-curriculum.md「特別深入」段落

Day 13 — HashSet

學習目標

看完今天內容後,能夠:

  1. 說明 HashSet 與 HashMap 的關係,判斷什麼情境該用 HashSet 而非 HashMap。
  2. 用 HashSet 完成集合運算(去重、交集、聯集、差集)並說出各自的複雜度。

教材大綱

1

HashSet

Supporting TopicsLv.3

What:HashSet 是「只存 key、不存 value」的 HashMap——本質上是把 HashMap 的 value 型別固定成一個沒有意義的哨兵值(或直接省略 value 欄位),只利用 HashMap 的「key 唯一、O(1) 查找」能力來判斷「這個元素存不存在」。

Why

很多場景只需要問「這個東西有沒有出現過」,不需要對應到任何值(去重、判斷是否已處理過)。如果硬用 HashMap 塞一個沒有語意的 value(例如永遠塞 true),程式碼讀起來會讓人疑惑「這個 value 到底代表什麼」;HashSet 直接把「這是一個集合,只關心存在與否」的意圖表達出來。

Internal Structure:與 HashMap 完全相同的 bucket array + hash function + collision resolution(不少語言的標準函式庫實作,例如 Java 的 HashSet,內部就是包裝一個 HashMap,把每個加入的元素當作 key,value 統一塞一個共用的哨兵物件)。因此 Day 12 學到的 hash function / bucket / load factor / resize 全部原封不動適用在 HashSet 上。

Core Operationsadd(element) / contains(element) /remove(element),機制與複雜度與 HashMap 的 put / get /delete 完全相同。

Complexity:與 HashMap 相同——平均 O(1),最差情況(大量元素碰撞)O(n)。

Strength:語意清楚(明確表達「這是一個集合」而不是「這是一個映射」);O(1) 去重/查找;可以直接組合出數學上的集合運算——交集(遍歷較小的集合,逐一 contains 另一個集合)、聯集(把兩個集合的元素都 add 進第三個集合,重複元素自然只會被存一次)、差集(遍歷集合 A,只保留不在集合 B 裡的元素)。

Weakness:與 HashMap 完全相同——最差情況會退化、無序、resize 時有一次性尖峰成本;因為本質只是 HashMap 的特化用法,HashSet 沒有比 HashMap 更多的能力,改用 HashSet 純粹是語意上更精確,不是效能上的額外優化。

Typical Problems:陣列去重(distinct)、判斷陣列中是否存在重複元素、集合的交集/聯集/差集運算、圖走訪(BFS/DFS)時用來記錄「哪些節點已經走訪過(visited)」避免重複處理同一個節點。

Backend Applications:記錄已經處理過的訊息 id,避免同一則訊息被重複消費(是實作 idempotency 的其中一種簡易手段,完整的 idempotency 設計還需考慮持久化與過期,這裡只涵蓋記憶體內判斷存在與否的部分);權限系統用 HashSet 存放一個使用者擁有的角色集合,用 O(1) 的 contains 判斷「這個使用者有沒有某個角色」;走訪服務依賴圖(找出循環依賴、拓樸排序)時用 visited set 避免重複走訪同一個服務節點。

練習

以 Day 12 手刻的簡化版 HashMap 為底,包一層 HashSet(add 時把 value 固定塞一個共用的哨兵值),實作 add /contains / remove。寫一個測試:對一個含重複元素的陣列(例如[3, 1, 4, 1, 5, 9, 2, 6, 5, 3])逐一 add 進 HashSet,印出去重前的元素個數與去重後 HashSet 的元素個數,驗證重複元素確實只被存一次。

練習(Day 13 綜合)

用上面實作的 HashSet,寫一個函式接收兩個整數陣列,回傳它們的交集(重複元素只回傳一次);再寫一個函式回傳聯集與差集;對至少 3 組測資(含完全不重疊、部分重疊、完全相同三種情境)驗證三個函式的輸出都正確。

Day 13 過關標準(DoD)

  • Understand:能解釋 HashSet 與 HashMap 的關係(是同一套機制、只是把 value 拿掉的特化版),以及為什麼改用 HashSet 是語意優化而非效能優化。
  • Recall:不看資料說出用 HashSet 求交集/聯集/差集三種運算各自的實作方式與複雜度(皆為 O(n) 或 O(n+m),n/m 為兩集合大小)。
  • Apply:完成去重練習與交集/聯集/差集練習,皆能跑得動並印出驗證結果。
  • Explain:能舉出至少一個「該用 HashSet 而非直接用 HashMap 塞哨兵 value」的具體理由(語意清楚、避免誤用 value)。

Day 14 — Linked List

學習目標

看完今天內容後,能夠:

  1. 解釋 Linked List 為什麼插入/刪除「已知節點」是 O(1),但存取第 k 個元素是 O(n),並說出這與 Array 的取捨恰好相反。
  2. 手刻 Singly Linked List 的核心操作,並實作 Floyd's cycle detection 判斷是否有環。
  3. 推導「LRU Cache 需要 HashMap 與 Doubly Linked List 兩者組合」的原因。

教材大綱

1

Linked List

Core FundamentalsLv.4

What:Linked List 是一連串「節點(node)」透過指標(pointer /reference)串接起來的資料結構,每個節點存放資料本身與指向下一個節點的指標,節點彼此不要求在記憶體中連續。

Why

Array 插入/刪除中間元素要搬移大量資料(O(n)),如果應用場景的特徵是「頻繁在任意位置插入/刪除、較少需要隨機存取第 k 個元素」,就需要一個插入/刪除不必搬移其他元素的結構。Linked List 存在的目的,就是把「插入/刪除」的代價從「搬移資料」換成「改指標」,恰好與 Array 的取捨相反。

Internal Structure:每個 node 包含 (data, next) 兩個欄位(Doubly Linked List 多一個 prev);整個 list 只需要記住 head指標(若有維護 tail 指標,尾端操作也能加速,見下)。因為節點各自獨立配置在記憶體中的任意位置(不連續),走訪必須沿著 next指標一個一個跳過去,不像 Array 可以用算術公式直接算出第 k 個元素的位置。

Core Operations:- Access by index:O(n)——必須從 head 沿著 nextk 步,沒有任何算術捷徑可以跳過中間的節點。- Insert / Delete at head:O(1)——只需要改 head 指標本身。- Insert / Delete at tail:若有維護 tail 指標為 O(1),否則要先走到底才能操作,退化成 O(n)。- Insert / Delete at 已知節點(手上已經有該節點的指標,例如走訪過程中):O(1)——只需要改前後節點的指標,不需要搬移任何其他元素的資料。這是 Linked List 相對 Array 的核心優勢,前提是「已經定位到那個節點」;定位本身仍然是 O(n),優勢只發生在「已經在那裡」之後的操作。

Complexity 總表

| 操作 | 複雜度 ||---|---|| Access by index | O(n) || Insert/Delete at head | O(1) || Insert/Delete at tail(有維護 tail 指標) | O(1) || Insert/Delete at 已知節點 | O(1) |

Strength:插入/刪除已知節點是 O(1),不需搬移其他元素;大小可以彈性增長,不需要像 Array 一次性配置一整段連續記憶體,對「大小事先不確定」或「記憶體較零碎」的環境更友善。

Weakness:沒有隨機存取能力(找第 k 個是 O(n));每個節點都多了一份指標的記憶體開銷;節點分散在記憶體各處,cache locality 差——CPU 每次跳到下一個節點都可能是一次 cache miss。實務上即使理論複雜度看起來更好,Linked List 在現代硬體上經常反而比 Array 慢,因為一次 cache miss 的實際代價,遠高於「少搬幾個元素」省下的時間;這也是為什麼許多語言的標準函式庫在資料量不大時,仍傾向優先使用 Array 為底的結構。

Typical Problems:反轉 Linked List(迭代改指標方向)、判斷是否有環(Floyd's cycle detection,快慢指標)、找中間節點(快慢指標的另一個應用)、合併兩個已排序的 Linked List、LRU Cache 的雙向 Linked List 部分(見下方陌生題)。

Backend Applications:LRU Cache 是後端最常見的 Linked List 應用——用 HashMap 存 key → node 的對應做到 O(1) 查找,用 Doubly Linked List 維護存取順序,讓「把某個元素標記為最近使用」做到 O(1)(見下方陌生題完整推導);訊息/任務佇列的底層有時用 Linked List 實作,方便在任一端便宜地插入/移除;資料庫的 undo log / redo log 有時用鏈結串列串接一連串的變更記錄,方便照順序回放或回溯。

陌生題

設計一個記憶體內的 LRU Cache(固定容量,容量滿了要淘汰最久未被使用的元素),要求 getput 都必須是 O(1)。只用 Array 做得到嗎?為什麼 Linked List 是關鍵?(提示:若只用 Array,「把某個元素標記為最近使用」意味著要把它搬到陣列的某一端(例如最前面代表最近使用),這個搬移在陣列中間位置發生時是 O(n)——不可能達成 O(1)。改用 Doubly Linked List 維護存取順序,只要已經手上有那個節點的指標,把它從目前位置摘下、接到另一端都是 O(1)的改指標操作;但光有 Linked List,沒辦法在 O(1) 內「用 key 找到對應哪個節點」——走訪找節點是 O(n)。所以正確答案是 HashMap(O(1) 用 key 查到節點指標)+ Doubly Linked List(O(1) 用節點指標調整順序)兩者組合,缺一不可:HashMap 負責「快速定位」,Linked List 負責「快速調整順序」,各自解決對方解決不了的那一半問題。)

練習

手刻一個 Singly Linked List,實作 insertAtHead /insertAtTail / deleteByValue / reverse 四個操作;額外實作 Floyd's cycle detection(快慢指標同時從 head 出發,快指標一次走兩步、慢指標一次走一步,若兩者相遇代表有環)。用一個手動製造的有環 list(讓某個節點的 next 指回前面某個節點)驗證你的實作能正確偵測到環;再用一個正常(無環)的 list 驗證不會誤判為有環。

練習(Day 14 綜合)

手寫一個小型鄰接串列(adjacency list,用 Linked List 表示每個節點的鄰居),用 Day 13 實作的 HashSet 記錄 visited 節點,實作一個 BFS 或 DFS 走訪(擇一),印出走訪順序,驗證每個節點只被處理過一次(即使圖中有環也不會重複走訪、不會無限迴圈)。

Day 14 過關標準(DoD)

  • Understand:能解釋 Linked List 為什麼插入/刪除已知節點是 O(1)、但存取第 k 個是 O(n),並說出這與 Array 取捨相反的原因。
  • Recall:不看資料畫出 Singly Linked List insertAtHead操作前後的指標變化圖。
  • Apply:完成四操作+環偵測練習(皆能跑得動並印出驗證結果),以及 Day 14 綜合的圖走訪練習。
  • Explain:針對 LRU Cache 陌生題,正確說出為什麼需要 HashMap 與 Doubly Linked List 組合、缺一不可,並分別指出兩者各自解決哪一半的問題。
  • Implement(Linked List 為 Core Fundamentals,額外要求):環偵測需同時對「有環」與「無環」兩種輸入驗證正確,不能只測其中一種情境;圖走訪練習需驗證圖中有環時不會無限迴圈。

Day 15 — Stack / Queue

學習目標

看完今天內容後,能夠:

  1. 區分 Stack(LIFO)與 Queue(FIFO)的存取順序,並各自舉出至少一個對應的實務場景。
  2. 解釋為什麼 Queue 用 Array 實作時,天真地在頭部做 dequeue 會是 O(n),以及環狀陣列(circular buffer)如何解決這個問題。
  3. 推導「用兩個 Stack 組成一個 Queue」為什麼能保證 FIFO 順序,並分析其攤銷複雜度。

教材大綱

1

Stack

Supporting TopicsLv.3

What:Stack 是一種「後進先出(LIFO, Last-In-First-Out)」的資料結構,只能在同一端(稱為 top)做新增(push)與移除(pop)。

Why

很多場景天生就有「最後發生的事要最先被處理/復原」的順序特性(函式呼叫的返回順序、undo 操作、巢狀括號的配對),需要一個資料結構直接對應這種存取順序,而不必每次都在一堆資料裡搜尋「最後一個是誰」。

Internal Structure:可以用 Array(維護一個 top index,push 時 index 加一並寫入,pop 時讀出當前值後 index 減一)或 Linked List(永遠在 head 端 push/pop)實作,兩者都能讓 push/pop 做到 O(1)。用 Array 實作時,如果容量不足,會觸發跟 Day 11 動態陣列完全相同的攤銷擴充機制。

Core Operationspush O(1)、pop O(1)、peek(查看 top 但不移除)O(1)。Stack 刻意不提供隨機存取中間元素的操作——這是設計上的限制,用來保證「只能從一端進出」的語意不會被繞過。

Complexity:push / pop / peek 皆為 O(1)(若底層用 Array 實作,是攤銷 O(1),因為可能觸發 resize)。

Strength:O(1) 的兩端操作;天然對應「巢狀」、「回溯」語意(函式呼叫棧、括號匹配、瀏覽器上一頁)。

Weakness:無法隨機存取中間元素;無法直接查詢「目前最小值」等聚合資訊,除非額外維護輔助結構(例如 min stack 用一個平行的輔助 stack 隨時記錄目前最小值)。

Typical Problems:括號匹配(valid parentheses)、逆波蘭表達式(RPN)計算、單調棧(monotonic stack,找下一個更大元素,Phase 03 會深入)、用顯式 stack 取代遞迴實作 DFS、undo/redo 功能。

Backend Applications:函式呼叫棧本身就是一個 Stack——每次函式呼叫會 push 一個 stack frame,return 時 pop 掉,這也是為什麼遞迴呼叫太深會發生 stack overflow;許多語言的 middleware/interceptor chain 用 stack 概念管理「進入時依序執行、離開時反序執行」(例如 Go 的 defer 機制本質上就是一個 stack:多個 defer會依「後宣告先執行」的順序跑);分散式追蹤(tracing)中,span 的父子呼叫關係常用 stack 追蹤「目前執行在哪一層呼叫中」。

練習

用 Array 手刻一個 Stack(push / pop / peek /isEmpty),並用它實作「括號匹配驗證」——輸入一串包含(){}[] 的字串,判斷括號是否成對且正確巢狀。對至少 3 個測試案例(例如合法的 "([{}])"、不成對的 "(("、順序錯誤的 "([)]")驗證輸出正確。

2

Queue

Supporting TopicsLv.3

What:Queue 是一種「先進先出(FIFO, First-In-First-Out)」的資料結構,新增(enqueue)發生在一端(tail),移除(dequeue)發生在另一端(head)。

Why

很多場景需要「照抵達順序處理」的公平性保證(排隊系統、訊息處理、任務排程),需要一個資料結構直接對應這種順序,而不是每次都要找出「最早進來的那一個」。

Internal Structure:若天真地用 Array 實作、在 index 0 做 dequeue,會需要把後面所有元素往前搬一格(O(n));實務上會改用環狀陣列(circular buffer)——維護 headtail 兩個 index,dequeue 時只需要把 head index 往前移動一格(用取模繞回陣列開頭,例如 head = (head + 1) % capacity),完全不搬移任何資料。若改用 Linked List 實作(同時維護 headtail 指標),enqueue 在 tail 端、dequeue 在 head 端,兩者都是 O(1) 改指標,不需要環狀邏輯,但代價是 Linked List 本身的 cache locality 較差(見 Day 14)。

Core Operationsenqueue O(1)、dequeue O(1)(前提是用環狀陣列或雙指標 Linked List 實作,天真的陣列搬移實作是 O(n))、peek(查看最前面但不移除)O(1)。

Complexity

| 實作方式 | dequeue 複雜度 ||---|---|| Naive array(dequeue 時整體往前搬) | O(n) || Circular buffer(環狀陣列) | O(1) || Linked List(維護 head/tail 指標) | O(1) |

這張表刻意強調:Queue 的複雜度不是資料結構本身決定的,是實作方式決定的——選錯實作方式(天真陣列搬移)會讓 Queue 整組操作退化成 O(n),即使概念上 Queue「應該」是 O(1)。

Strength:正確實作(環狀陣列或 Linked List)前提下,兩端操作皆為 O(1);天然對應「公平排隊」語意。

Weakness:與 Stack 相同,無法隨機存取中間元素;環狀陣列實作需要額外處理「陣列滿了要擴充」與「head/tail index 繞回陣列開頭」的邊界邏輯,寫錯容易出現 off-by-one 或「滿」與「空」兩種狀態難以區分(head == tail 時到底是空還是滿)的 bug,通常需要額外一個計數欄位或刻意保留一格不使用來消除歧義。

Typical Problems:BFS 用 Queue 維護待走訪節點(Day 14 已用過的圖走訪、樹的層序走訪)、任務排程(依 FIFO 順序處理)、滑動窗口(Sliding Window,Phase 03 會用到 Deque,這裡先用一般 Queue 鋪墊概念)、生產者-消費者模式的緩衝佇列。

Backend Applications:訊息佇列(RabbitMQ / Kafka 的基本語意都是 FIFO,雖然實務上會有 partition、priority 等變化打破嚴格 FIFO);非同步任務佇列(background job queue),worker 依序 dequeue 處理任務;request buffering——在同時湧入的連線數超過處理能力時,先排隊而非直接拒絕;部分 rate limiter 的實作方式是用 Queue 存最近一段時間內請求的時間戳,判斷視窗內請求數是否超過限制。

練習

用「環狀陣列(circular buffer)」手刻一個固定容量的 Queue(enqueue / dequeue / isFull / isEmpty),正確處理head/tail index 繞回陣列開頭的情況——先塞滿、dequeue 掉幾個騰出空間、再繼續 enqueue,驗證新資料能正確寫進被騰出的位置,且不會覆蓋還沒被 dequeue 的既有資料。額外實作一個「naive 陣列版」(dequeue 時把陣列剩餘元素整體往前搬一格),對兩種實作各自執行一萬次 enqueue+dequeue 並計時比較,驗證環狀陣列版本明顯較快、且耗時不隨資料量成長而顯著上升。

練習(Day 15 綜合)

實作「用兩個 Stack 組成一個 Queue」(經典面試題):enqueue 時只 push 進 stack A;dequeue 時如果 stack B 是空的,把 stack A 的元素全部 pop 出來、依序 push 進 stack B,再從 stack B pop 出來回傳。寫下為什麼這樣操作能保證 FIFO 順序(提示:stack A 是「後進先出」,把它整批倒進 stack B 會讓順序反轉一次,兩次反轉等於恢復原本的先進先出順序),並分析:雖然某一次 dequeue可能要搬移 stack A 全部的元素(那一次是 O(n)),但每個元素一生只會被搬移一次(從 A 到 B),所以攤銷下來每次 dequeue 平均仍是 O(1)。

Day 15 過關標準(DoD)

  • Understand:能解釋 Stack(LIFO)與 Queue(FIFO)的存取順序差異,以及為什麼 Queue 用 Array 實作時需要環狀陣列、而不能用天真的 shift。
  • Recall:不看資料說出 Stack 的 push/pop/peek 與 Queue 的 enqueue/dequeue/peek 各自的複雜度,包含「naive array queue 的 dequeue 是 O(n)」這個常見陷阱。
  • Apply:完成括號匹配(Stack)與環狀陣列 Queue 兩個練習,皆能跑得動並印出驗證結果,含環狀陣列與 naive 陣列的實測效能比較數字。
  • Explain:完成「兩個 Stack 組成 Queue」的攤銷複雜度分析,正確說出為什麼平均下來每次 dequeue 仍是 O(1)。

Day 11–15 完成後,已涵蓋第 6 章1Array / String / HashMap / HashSet / Linked List / Stack / Queue 七個資料結構的 9 個面向;Day 16–20(Deque / Heap / Binary Tree /BST / Trie / Graph)由 T-022 接續,順序依 prerequisite chain:Deque 是 Stack+Queue 的合併泛化,接在兩者之後;Heap 與 Tree(Binary Tree/BST/Balanced Tree)分別是 Array 分支與 Linked List 分支的下一層,彼此無互相依賴,順序不影響理解;Trie 是 Binary Tree「分支數放寬」的特化,排在 Tree 之後;Graph 是 Tree「拿掉無環限制」的推廣,是本 Phase 依賴鏈最末端,排在最後,也剛好承接 Day 20 收尾的陌生題(找出最近 K 個元素,需要用到 Day 17 的 Heap)。

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

Day 16 — Deque

學習目標

看完今天內容後,能夠:

  1. 說明 Deque 為什麼是 Stack 與 Queue 的合併泛化,兩端都能 O(1)進出。
  2. 解釋 monotonic deque 如何在 O(n) 內解出 Sliding Window Maximum,以及為什麼一般 Queue 做不到。

教材大綱

1

Deque

Supporting TopicsLv.3

What:Deque(double-ended queue)是兩端都能新增/移除的資料結構——同時擁有 Stack「兩端各自都能 push/pop」與 Queue「一端進一端出」的能力,把「只能操作固定端」的限制完全拿掉,任何一端都能pushFront / pushBack / popFront / popBack

Why

有些場景需要同時從頭尾兩端動態調整內容,例如維護一個「候選視窗」時,新元素要從尾端塞進去,但不合格的舊候選要能從頭尾任一端移除(見下方 monotonic deque)。Stack 限制只能一端操作、Queue 進與出被綁死在不同端,都無法表達這種「兩端都要能自由進出」的需求,Deque 補上這個空缺。

Internal Structure:延伸 Day 15 Queue 的兩種實作方式,差別在於兩端都要能操作:- 環狀陣列(circular buffer):延伸 Day 15 的作法,headtail 兩個 index 都可以往前也可以往後移動(用取模讓 index 在兩個方向繞回陣列邊界),陣列滿了時的擴充機制與 Day 11 動態陣列相同。- Doubly Linked List:延伸 Day 14 的 prev/next 雙向指標,同時維護 headtail 指標,任一端的 push/pop 都只需要改最多兩個節點的指標。

Core OperationspushFront / pushBack / popFront /popBack / peekFront / peekBack,六者皆為 O(1)(環狀陣列版本是攤銷 O(1),理由與 Day 11 動態陣列相同)。

Complexity 總表

| 操作 | 複雜度 ||---|---|| pushFront / pushBack | 攤銷 O(1) || popFront / popBack | O(1) || peekFront / peekBack | O(1) |

Strength:可以完全取代 Stack(只用一端)或 Queue(一端進一端出)的功能,是兩者的超集合;特別適合「候選視窗需要兩端都能移除」的演算法(monotonic deque)。

Weakness:與底層實作方式綁定的既有取捨仍然存在——環狀陣列版兩端都要處理 index 繞回陣列邊界的邊界邏輯(比單端的 Queue 多一倍的邊界情境要處理,off-by-one 風險更高);Doubly Linked List 版仍然有 Day 14 提過的 cache locality 較差的問題。

Typical ProblemsSliding Window Maximum/Minimum——維護一個 monotonic deque(例如求最大值時,deque 內由頭到尾遞減排列):新元素從尾端加入前,先把尾端所有小於等於新元素的候選都 pop 掉(它們不可能再是視窗內的最大值,因為新元素比它們晚離開視窗又比它們大);視窗左邊界移動時,若 deque 頭端元素的 index 已經滑出視窗,從頭端 pop 掉。因為每個元素最多被 push 一次、pop 一次,整個過程總成本是 O(n),遠比每次視窗移動都重新掃描視窗內全部元素找最大值(O(n·k),k 為視窗大小)快;回文(palindrome)檢查也可以用 deque 從兩端同時取值比對。

Backend Applications:Sliding Window 限流演算法(Phase 05/07 會深入的 rate limiter 之一種實作方式)用 deque 存最近一段時間內的請求時間戳,兩端都需要移除(頭端移除過期的舊時間戳、尾端可能需要移除不再有效的候選);部分語言 runtime 的 work-stealing scheduler(例如 Go runtime 每個 P 的本地 run queue)讓 owner 從一端存取(低成本、無競爭),其他閒置的 worker 從另一端「偷」工作,兩端各自有不同用途正是 Deque 語意的體現。

練習

用環狀陣列手刻一個 Deque(pushFront / pushBack /popFront / popBack),正確處理 head/tail 兩個 index 在兩個方向繞回陣列邊界的情況——分別測試「先從尾端塞滿、再從頭端全部清空」與「先從頭端塞滿、再從尾端全部清空」兩種操作序列,驗證資料順序在兩種情境下都正確。

練習(Day 16 綜合)

用上面實作的 Deque,實作 Sliding Window Maximum:給定一個整數陣列與視窗大小 k,回傳每個視窗的最大值組成的陣列。用 monotonic deque 維護視窗內的遞減候選序列。對至少一組手算過答案的測資驗證輸出正確,並記錄總共發生了幾次 push 與 pop,確認總次數是 O(n)量級(不隨 k 增加而顯著上升)。

Day 16 過關標準(DoD)

  • Understand:能解釋 Deque 為什麼是 Stack 與 Queue 的合併泛化,以及兩端操作為何都能做到 O(1)。
  • Recall:不看資料列出 Deque 六種操作(pushFront/pushBack/popFront/popBack/peekFront/peekBack)皆為 O(1)。
  • Apply:完成環狀陣列 Deque 練習與 Sliding Window Maximum 綜合練習,皆能跑得動並印出驗證結果與 push/pop 總次數。
  • Explain:能說明 monotonic deque 為什麼整體是 O(n) 而不是 O(n·k),指出「每個元素最多被 push/pop 各一次」是關鍵理由。

Day 17 — Heap(含 Priority Queue)

學習目標

看完今天內容後,能夠:

  1. 畫出 Heap 用 Array 表示完全二元樹時,parent/child index 的算術關係,並說明為什麼這個關係只在「完全二元樹」的前提下成立。
  2. 推導 Insert(sift-up)與 Extract-Min/Max(sift-down)為什麼都是 O(log n),以及從 n 個元素直接建堆(heapify)為什麼是 O(n) 而不是直覺以為的 O(n log n)。
  3. 判斷什麼情境該用 Heap 而不是排序或 BST。

教材大綱

1

Heap

Core FundamentalsLv.4

What:Heap 是一棵「完全二元樹(complete binary tree)」——除了最後一層,每一層都必須填滿,且最後一層由左至右依序填入不留空隙——並滿足 heap property:Max Heap 中每個父節點都 ≥ 子節點;Min Heap 相反,每個父節點都 ≤ 子節點。Priority Queue 是「每次都能取出優先權最高(或最低)的元素」這個抽象介面,Heap 是它最主流的具體實作。

Why

如果只需要「不斷取出目前最小/最大的元素」,完整排序全部元素是浪費的(O(n log n) 建立完整順序,但我們其實只在乎「下一個」是誰)。Heap 存在的目的,是用完全二元樹「結構規律、可以用 Array 表示」的特性,換取「只維持局部順序(父子關係),不維持全域排序」的更便宜的操作——insert 與 extract 都只要 O(log n),遠比每次都重新排序全部元素便宜。

Internal Structure:因為完全二元樹「除了最後一層都填滿、最後一層由左至右填」的規則,樹的形狀是完全確定的,不需要任何指標,可以直接用一個 Array 表示:0-indexed 陣列中,index i 的節點,其左子節點在 2*i + 1、右子節點在 2*i + 2、父節點在(i - 1) / 2(整數除法向下取整)。這個公式之所以永遠成立,正是因為 Array 本身「用 index 直接算出位置」的 O(1) 定址能力(Day 11)加上完全二元樹「不留空隙」的形狀規則——兩者缺一,這個算術關係都不成立。

Core Operations(依文件「特別深入」段落逐項展開,以 Min Heap 為例):- Insert:把新元素放進陣列最後一個位置(樹的最底層、最右邊第一個空位,維持完全二元樹形狀),再「sift up(上浮)」:把它與父節點比較,若比父節點小就與父節點交換,重複這個比較-交換,直到不再違反 heap property 或到達 root。因為完全二元樹高度是O(log n),sift up 最多走這麼多步,是 O(log n)。- Extract-Min:root(index 0)永遠是最小值,O(1) 就能讀到;但移除它需要維持完全二元樹形狀:把陣列最後一個元素搬到 root 位置、刪除原本最後一個位置,再對新的 root「sift down(下沉)」:與左右子節點中較小的那個比較,若新 root 更大就與它交換,重複直到不再違反 heap property 或到達葉節點。同樣因為樹高O(log n),是 O(log n)。- Heapify(從 n 個元素直接建堆):天真做法是逐一 Insert n 個元素,每次 O(log n),總共 O(n log n)。更好的做法:把 n 個元素先全部塞進陣列(此時還不滿足 heap property),從「最後一個非葉節點」開始由後往前,對每個節點做一次 sift down。這樣做總成本是 O(n),不是 O(n log n)——關鍵在於:完全二元樹裡,越靠近底部的節點數量越多,但底部節點的高度(到葉節點的距離)越小,sift down 的成本正是與節點高度成正比;把每一層「節點數量 ×該層高度」加總(n/4 * 1 + n/8 * 2 + n/16 * 3 + ...),這個級數收斂到 O(n),不是把每個節點都當作 O(log n) 那樣天真估計(那是 Insert n 次的分析,不是由下往上 sift down 的分析)。

Complexity 總表

| 操作 | 複雜度 ||---|---|| Insert | O(log n) || Extract-Min/Max | O(log n) || Peek Min/Max | O(1) || Heapify(從 n 個元素直接建堆) | O(n) || 任意元素的 Search | O(n)(無捷徑,見 Weakness) |

Strength:O(log n) 的 insert / extract,O(1) 的 peek;heapify 是 O(n),當只需要「重複取出最小/最大值」而不需要完整排序時,比先排序全部元素(O(n log n))划算;用 Array 表示,不需要額外的指標記憶體開銷。

Weakness:無法有效搜尋任意元素——heap property 只保證「父子之間」的順序,同一層或跨子樹的元素之間沒有大小關係,要找特定值只能 O(n) 全部掃過;不維護全域排序,直接走訪陣列拿到的順序不是排序好的順序(要拿到排序結果得不斷 extract);decrease-key(把某個已在堆中的元素的優先權調低/調高)不是天生 O(log n)——標準 Array-backed heap 沒有「已知某元素在陣列的哪個位置」的捷徑,要先花 O(n) 找到它,才能再花 O(log n) sift up/down 調整位置;Dijkstra 最短路徑演算法需要頻繁 decrease-key,這也是為什麼進階應用會改用額外維護「元素 → 陣列位置」對應的 indexed heap,或更複雜的 Fibonacci heap(本教材不要求手刻,只需知道這是一個真實存在的限制與對應解法)。

Typical Problems:Kth Largest/Smallest Element(維護一個大小為 K 的 heap,見下方陌生題完整示範)、Top K Frequent Elements(HashMap 計數+大小 K 的 heap,Day 20 綜合練習會實作)、Median Maintenance(用一個 max-heap 存較小的一半、一個 min-heap 存較大的一半,兩個 heap 大小差距維持在 1 以內,median 就是其中一個 heap 的 root,或兩個 root 的平均)、Merge K Sorted Lists(heap 裡放 K 個 list 目前的最小候選,每次 extract 後把該 list 的下一個元素放入)、Dijkstra 最短路徑(每次取出目前已知距離最短的未定節點)。

Backend Applications:任務排程器用 heap 依「下次執行時間」或優先權挑出下一個要跑的工作(Phase 07 T-032 的 Job Scheduler 會再深入);連線池/快取淘汰策略用 heap 找出「優先權最低」該被淘汰的項目;負載平衡器用 min-heap 依目前負載挑選要分派請求的伺服器;Kafka 等訊息系統的 consumer 在多個 partition 間追蹤「目前最小的未提交 offset」時,概念上就是在維護一個 min-heap 的語意。

陌生題

一個背景排程服務要管理最多上千個工作,每個工作有一個nextRunTime,服務要能隨時回答「下一個該執行的工作是誰」,而且新工作會持續被加進來(每秒可能新增/移除數十個)。如果每次都把全部工作依 nextRunTime 重新排序再取第一個,效能會隨工作數量增加而變差。該用什麼資料結構?為什麼?(提示:需要的操作是「持續插入」+「不斷取出目前最小值」,不需要維護其餘工作之間的完整順序——這正是 Heap 存在的理由;用一個 Min Heap(依nextRunTime 排序)取代排序,插入與取出下一個工作都只要O(log n),不必為了取一個最小值付出 O(n log n) 排序全部工作的代價。)

練習

用 Array 手刻一個 Min Heap,實作 insert(sift up)與extractMin(sift down)。用一組亂數序列反覆呼叫 insert,再反覆呼叫 extractMin 直到清空,印出 extractMin 回傳的順序,驗證結果是嚴格遞增排序(這就是 heap sort 的原理:n 次 insert +n 次 extractMin 可以把任意序列排序)。

練習(Day 17 綜合)

實作「Kth Largest Element in a Stream」:維護一個大小固定為 K 的 Min Heap,每來一個新元素,若 heap 目前大小小於 K 直接 insert;否則若新元素比 heap 的 root(目前 heap 內最小值)大,就 pop掉 root 再 insert 新元素。任何時刻,heap 的 root 就是目前看過的所有元素中第 K 大的值。對一組手算過答案的資料流驗證每一步的 root 值都正確,並解釋為什麼這個做法的空間複雜度是 O(K) 而不是 O(n)(n 為資料流總長度)。

Day 17 過關標準(DoD)

  • Understand:能解釋 Array 表示完全二元樹的 parent/child index 公式為何成立,以及 heapify 為什麼是 O(n) 而非 O(n log n)。
  • Recall:不看資料寫出 Insert(sift up)與 Extract-Min(sift down)的步驟,並說出各自的複雜度。
  • Apply:完成手刻 Min Heap 練習(含驗證 heap sort 行為)與 Kth Largest in a Stream 綜合練習,皆能跑得動並印出驗證結果。
  • Explain:針對陌生題情境,正確指出「持續插入+不斷取最小值」是選擇 Heap 而非排序的關鍵理由,並說出兩者的複雜度差距(O(log n) vs O(n log n))。
  • Implement(Heap 為 Core Fundamentals,額外要求):手刻的insert/extractMin 需通過 heap sort 驗證(完整清空後的輸出必須是嚴格排序),且需能正確說出 decrease-key 在標準 Array-backed heap 上不是天生 O(log n) 的限制與原因。

Day 18 — Tree:Binary Tree / BST / Balanced Tree / Traversal

學習目標

看完今天內容後,能夠:

  1. 說出四種 Traversal(前序/中序/後序/層序)各自的走訪順序、適合的場景,並知道層序走訪為什麼要用 Queue、其餘三種為什麼自然適合用遞迴(或顯式 Stack)。
  2. 推導 BST 的 search/insert/delete 為什麼平均是 O(log n),並解釋為什麼插入順序不同會讓同一組資料的 BST 高度差距巨大。
  3. 說明 Balanced Tree(如 AVL/Red-Black)如何避免 BST 退化,維持 O(log n) 保證。

教材大綱

1

Binary Tree

Core FundamentalsLv.4

What:Binary Tree 是由節點(node)組成的階層式資料結構,每個節點最多有兩個子節點(left、right),有一個唯一的 root 節點做為起點,是 Linked List「node + 指標」概念的推廣——差別在於 Linked List 每個節點只有一個 next,Binary Tree 每個節點有兩個子指標,形狀從「一直線」變成「可以分岔的階層」。

Why

Linked List 只能表達線性順序,沒有「階層」或「分岔」的語意;很多現實資料本身就是階層式的(檔案系統目錄、組織架構、決策樹),或者需要「每一步都有兩個選擇」的搜尋結構(見下方 BST)。Binary Tree 存在的目的,就是把 Linked List 的指標概念從「一條線」推廣成「一棵樹」,讓資料結構能表達分岔與階層關係。

Internal Structure:每個節點包含 (data, left, right) 三個欄位,left/right 各自指向另一個節點或為空(null)。整棵樹只需要記住 root 指標。節點彼此不要求連續記憶體,走訪必須沿著指標移動(與 Linked List 相同的取捨:換取形狀彈性、犧牲 cache locality)。

Core Operations(Traversal,四種走訪順序):- 前序(Pre-order)root → left → right——先處理節點本身,再處理左子樹、右子樹。適合「複製整棵樹」或「先取得節點資訊再往下展開」的場景(例如把樹結構序列化成一份可以照原樣重建的紀錄)。- 中序(In-order)left → root → right——先處理完左子樹,再處理節點本身,最後處理右子樹。對 BST 而言,中序走訪保證拿到嚴格遞增排序的輸出(見下方 BST 小節,這是 BST 最重要的性質之一)。- 後序(Post-order)left → right → root——先處理完兩個子樹,最後才處理節點本身。適合「刪除整棵樹」(必須先刪光子節點才能安全刪除父節點)或「先算出子節點的值才能算出父節點的值」的場景(例如計算子樹大小、子樹高度)。- 層序(Level-order / BFS):由上而下、同一層由左至右依序走訪。前三種都是「沿著一條路徑先走到底」的深度優先(DFS),自然適合用遞迴實作(呼叫堆疊本身就是一個隱式的 Stack,見 Day 15);層序走訪則是廣度優先(BFS),需要顯式維護一個 Queue:從 root 開始入隊,每次取出一個節點處理,再把它的子節點依序入隊,直到 Queue 清空——這正是 Day 15 Queue「FIFO」語意的直接應用:先進的節點(較早那一層)保證先被處理完。

Complexity 總表

| 操作 | 複雜度 ||---|---|| 任一種 Traversal(走訪全部 n 個節點) | O(n) || Traversal 額外空間(遞迴/Stack 版本) | O(h),h 為樹高 || Traversal 額外空間(層序/Queue 版本) | O(w),w 為樹的最大寬度 |

Strength:能表達階層/分岔關係,是後面 BST、Trie、Graph 的共同基礎;四種走訪順序涵蓋大部分「處理階層資料」的需求(序列化、排序輸出、由下而上聚合、由上而下逐層處理)。

Weakness:普通 Binary Tree(沒有 BST 排序不變量)沒有 O(log n)搜尋能力,找特定值仍是 O(n);遞迴走訪在樹很深時可能有 stack overflow 風險(呼叫堆疊本身是有限資源,見 Day 15 Stack 的 Backend Applications)。

Typical Problems:樹的序列化/反序列化(常用前序+記錄 null 節點)、計算樹高/節點數(後序遞迴,子節點算完才能算父節點)、判斷兩棵樹是否結構相同、層序走訪輸出每一層的節點清單。

Backend Applications:檔案系統目錄結構、組織架構圖、UI 元件的階層結構(DOM tree)、決策規則/設定的階層繼承(子設定覆寫父設定)都是 Binary Tree(或其推廣為多元樹)的實例;DB 的 B-Tree 索引(Phase 04 深入)本質上也是這裡「階層式指標結構」概念的推廣,只是每個節點的子節點數遠大於 2。

2

BST(Binary Search Tree)+ Balanced Tree

Core FundamentalsLv.4

What:BST 是在 Binary Tree 之上多加一條不變量(invariant)的特化版:對任何節點,其左子樹所有節點的值都小於它、右子樹所有節點的值都大於它(假設沒有重複值)。Balanced Tree 是在 BST 之上再多加「維持樹高在 O(log n)」的保證,AVL Tree、Red-Black Tree 是最主流的兩種具體實作。

Why

普通 Binary Tree 沒有排序不變量,搜尋一個值只能整棵樹全部比對(O(n))。BST 存在的目的,是讓「往左走一定變小、往右走一定變大」這件事在每一層都成立,這樣搜尋時每比對一次就能排除掉半邊子樹(前提是樹夠平衡,見下方 Balanced Tree),把 O(n) 的搜尋壓到 O(log n)——這正是 Binary Search 概念(Phase 03 會系統化深入)在指標結構上的實現。

Internal Structure:延續 Binary Tree 的 (data, left, right)節點結構,額外要求任何時刻都要維持「左小右大」的不變量。

Core Operations:- Search(value):從 root 開始,若目前節點值等於目標,找到;若目標較小,往 left 走;若目標較大,往 right 走;走到null 還沒找到代表不存在。每一步都排除掉半邊子樹(樹夠平衡的前提下),是 O(h)(h 為樹高,平衡時 h = O(log n))。- Insert(value):與 Search 相同的走法找到應該插入的空位(第一個走到 null 的位置),把新節點接在那裡。O(h)。- Delete(value):先用 Search 找到節點,再依三種情況處理:1. 該節點是葉節點(無子節點):直接移除。2. 該節點只有一個子節點:用該子節點取代自己的位置(讓父節點直接指向孫節點)。3. 該節點有兩個子節點:不能直接移除(會斷開兩棵子樹),標準做法是找到「中序後繼(in-order successor,即右子樹中最小的節點——一路往右子樹的 left 走到底)」,把後繼節點的值複製到目前節點,再遞迴刪除右子樹中那個後繼節點(後繼節點依定義最多只有右子節點,遞迴刪除時只會落入情況 1 或 2,不會再落入情況 3)。三種情況合計仍是 O(h)

Complexity 總表

| 操作 | 平衡樹(h = O(log n)) | 極端退化(h = O(n)) ||---|---|---|| Search | O(log n) | O(n) || Insert | O(log n) | O(n) || Delete | O(log n) | O(n) || 中序走訪拿到排序輸出 | O(n) | O(n) |

Balanced Tree(維持 O(log n) 保證):BST 的複雜度分析全部建立在「樹高 h = O(log n)」這個假設上,但這個假設不會自動成立——如果插入順序恰好是已排序的資料(例如依序插入1, 2, 3, 4, 5),每個新節點都只會往右子樹走到底,整棵 BST 會退化成一條單向鏈結串列(等同 Day 14 的 Linked List),樹高變成 O(n),search/insert/delete 全部退化成 O(n)——BST 存在的優勢完全消失。Balanced Tree 就是為了避免這個退化,在 insert/delete 之後主動檢查、修正樹的形狀:- AVL Tree:每個節點額外記錄「左右子樹高度差(balance factor)」,insert/delete 後沿著剛剛異動的路徑往上檢查,一旦某節點的 balance factor 超出 [-1, 1],透過一次或兩次rotation(把子樹的節點關係做局部重新排列,同時保持 BST「左小右大」不變量不被破壞)把該節點的子樹重新拉平。因為異動路徑長度是 O(log n)(平衡樹的高度),每層最多做一次 O(1) 的 rotation 檢查,整體 insert/delete 維持 O(log n)。- Red-Black Tree:每個節點多一個顏色(紅/黑)欄位,靠一組「紅節點不能有紅子節點」「root 到任一 null 的路徑上黑節點數相同」等規則,保證樹高不超過 2 * log(n+1)——平衡程度比 AVL 略鬆(AVL 更接近完美平衡,查詢略快;Red-Black 允許的重新平衡操作較少,插入/刪除略快),因此 Red-Black Tree 是多數語言標準函式庫(如 Java TreeMap、C++ std::map)的預設選擇,AVL 更常見於「查詢遠多於插入」的場景。

Strength:平衡狀態下 O(log n) 的 search/insert/delete,同時中序走訪還能拿到排序好的完整輸出(Heap 拿不到這個——Day 17 Weakness 提過 Heap 不維護全域排序,這是 BST 相對 Heap 的關鍵優勢,代價是 BST 沒有 Heap 那樣 O(1) 直接拿到 min/max,要沿著一路 left/right 走到底,是 O(log n))。

Weakness:不維持平衡時可能退化到 O(n)(見上);維持平衡(AVL/Red-Black)需要額外的欄位(balance factor 或顏色)與 rotation 邏輯,實作複雜度遠高於普通 BST;rotation 本身雖是 O(1),但頻繁的 insert/delete 觸發的重新平衡仍是額外的常數開銷。

Typical Problems:驗證一棵樹是否為合法 BST、BST 中找第 K 小/大的元素(中序走訪數到第 K 個)、把已排序陣列轉換成高度平衡的 BST、BST 上找最近公共祖先(LCA,可以利用左小右大的性質,比一般 Binary Tree 的 LCA 更快找到)。

Backend Applications:資料庫索引的底層概念與 BST 一脈相承(Phase 04 的 B-Tree 是「每個節點子節點數遠大於 2」的推廣版,解決的正是「磁碟 I/O 昂貴,要讓樹更矮、更胖」的問題);語言標準函式庫的有序 map/set(Java TreeMap/TreeSet、C++ std::map/std::set)底層是 Red-Black Tree,提供「有序遍歷+O(log n)查找」的組合能力,是需要「既要快速查找、又要維持排序」時 HashMap 做不到而 BST 系列能做到的場景。

陌生題

一個服務把使用者依註冊時間戳依序(由舊到新)插入一棵 BST 來維護「依 ID 排序查找使用者」的功能,上線初期查詢很快,但隨著使用者數量增加,查詢延遲卻不成比例地急遽變差(遠超過 O(log n) 該有的增長速度)。用今天學到的機制解釋可能發生什麼,並說出你會怎麼驗證與修正。(提示:因為使用者是依註冊時間戳「已排序」的順序插入,這正是 BST 最容易退化的插入模式——每個新節點都比前一個更大,只會往右子樹走到底,整棵樹退化成一條鏈結串列,樹高變成 O(n)、查詢也跟著變成 O(n);驗證方式是實際測量樹的高度是否接近 log(n)(平衡)還是接近 n(退化);修正方式是改用 Balanced Tree(AVL/Red-Black)或改用語言標準函式庫已經實作好平衡邏輯的有序容器,插入後主動 rotation 維持樹高在 O(log n)。)

練習

手刻一個 BST,實作 insert / search / delete(含上述三種刪除情況,特別要正確處理「兩個子節點」的中序後繼替換邏輯)與 inOrderTraversal(驗證回傳結果是嚴格遞增排序)。額外實作 height(node)(遞迴計算樹高:1 + max(height(left),height(right)),空節點高度為 0)。

練習(Day 18 綜合)

用上面手刻的 BST,分別對「隨機順序插入 1000 個相異整數」與「已排序順序插入 1000 個相異整數」兩種情境各建一棵樹,用height 函式印出兩棵樹的實際高度,對照 log2(1000) ≈ 10,驗證隨機插入的樹高接近 O(log n)、已排序插入的樹高接近 O(n)(應該非常接近 1000),用實測數字證實 Balanced Tree 小節裡「插入順序決定樹高」的推論。額外實作四種 Traversal(前/中/後/層序,層序需用 Day 15 的 Queue),對同一棵樹印出四種走訪順序的節點序列,確認彼此不同且各自符合定義。

Day 18 過關標準(DoD)

  • Understand:能解釋 BST 的「左小右大」不變量為何讓搜尋能排除半邊子樹,以及為什麼插入順序會決定樹高、進而決定搜尋是 O(log n) 還是 O(n)。
  • Recall:不看資料說出四種 Traversal 各自的走訪順序、哪一種用 Queue、哪三種自然適合遞迴/Stack;不看資料寫出 BST Delete 三種情況各自的處理方式。
  • Apply:完成 BST 四操作+height 練習與 Day 18 綜合的樹高對照實驗、四種 Traversal 練習,皆能跑得動並印出驗證結果。
  • Explain:針對陌生題情境,正確指出「依序插入導致退化成鏈結串列」是根因,並提出「量測樹高+改用 Balanced Tree」的具體驗證與修正方法;能用自己的話說明 AVL 或 Red-Black 其中一種如何靠 rotation/額外欄位維持 O(log n)。
  • Implement(BST 為 Core Fundamentals,額外要求):Delete 的「兩個子節點」情況需正確用中序後繼替換並遞迴刪除,Day 18 綜合的樹高對照實驗需附實際印出的高度數字作為證據,不能只是文字敘述「應該會退化」。

Day 19 — Trie

學習目標

看完今天內容後,能夠:

  1. 說明 Trie 的節點結構如何從 BST「固定兩個子指標」推廣成「任意數量子指標(依字元集大小)」。
  2. 解釋為什麼 Trie 的搜尋/插入複雜度是 O(L)(L 為字串長度),與資料集裡存了多少字串無關,這一點與 BST 的 O(log n)(隨資料量增加而增加)本質不同。

教材大綱

1

Trie(Prefix Tree)

Supporting TopicsLv.3

What:Trie(也稱 Prefix Tree)是專門用來儲存與查詢字串集合的樹狀結構:每個節點代表「一個字元」,從 root 到某個節點的路徑組成一個字首(prefix);每個節點額外有一個 isEndOfWord 標記,代表「從 root 走到這裡剛好組成一個完整儲存過的字串」。

Why

BST 或 HashMap 存字串時,比較/計算 hash 都需要看過整個字串;如果應用場景大量需要「找出所有以某個字首開頭的字串」(自動完成、拼字檢查),用 BST/HashMap 得先取出全部字串再逐一比對字首,沒有捷徑。Trie 存在的目的,就是讓「共用字首的字串」在樹狀結構中天生共用同一段路徑,查詢字首時只要沿著路徑走到底,不需要看過整個資料集。

Internal Structure:每個節點包含一組「子節點指標」,依字元集大小決定實作方式——如果字元集固定且小(例如僅小寫英文字母),可以用一個固定大小 26 的 Array(index 對應 字元 - 'a');字元集較大或不固定(Unicode、任意字元)則用一個 Map(字元 → 子節點)。這是 Binary Tree「每個節點最多兩個子指標(left/right)」的推廣——Trie 把「最多兩個」放寬成「最多字元集大小個」,子指標從「左/右」變成「依字元索引」。

Core Operations:- Insert(word):從 root 開始,依序處理 word 每個字元:若目前節點沒有該字元對應的子節點,建立一個新節點;往該子節點移動。處理完最後一個字元後,把該節點標記 isEndOfWord = true。複雜度 O(L),L 為 word 長度——每個字元只需要一次子節點查找/建立,跟 Trie 裡已經存了多少字串完全無關。- Search(word):與 Insert 相同的走法,若中途任何字元找不到對應子節點,代表不存在;走完後檢查最後節點的 isEndOfWord是否為 true(否則代表 word 只是別的字串的字首,本身沒被存過)。O(L)。- StartsWith(prefix):與 Search 相同的走法,但只需要確認路徑存在(不檢查 isEndOfWord),代表存在至少一個字串以prefix 開頭。O(L),L 為 prefix 長度。

Complexity 總表

| 操作 | 複雜度 ||---|---|| Insert | O(L),L 為字串長度 || Search | O(L) || StartsWith(字首查詢) | O(L) |

Strength:搜尋/插入/字首查詢的複雜度只跟字串本身的長度有關,與資料集存了多少個字串無關(BST 的 O(log n) 是隨資料量增加而增加,Trie 的 O(L) 不會);共用字首的字串天生共用記憶體路徑,字首查詢不需要先取出候選再逐一比對。

Weakness:記憶體開銷可能很大——每個節點若用固定大小 Array(例如 26 個指標)存子節點,即使實際只用到 1、2 個,其餘欄位仍佔用空間,字串集合稀疏(彼此字首重疊少)時浪費明顯(改用 Map 可以省掉沒用到的欄位,但犧牲一點查找常數時間);只適合以「字首匹配」為核心的查詢,不適合「找出所有包含某個子字串(不一定是字首)的詞」這類需求(那需要其他結構,如後綴樹/陣列,超出本教材範圍)。

Typical Problems:自動完成(autocomplete,見下方綜合練習)、拼字檢查(spell checker,檢查字典裡有沒有這個字,或找出編輯距離最近的候選)、IP 路由表的最長前綴匹配(longest prefix match,概念與字串字首查詢相同,只是把「字元」換成「IP 位址的位元」)、文字接龍/單字搜尋類遊戲盤面上的合法單字判斷。

Backend Applications:自動完成服務用 Trie 存熱門搜尋詞,使用者每打一個字元就沿著 Trie 往下走一層,回傳目前節點以下所有完整字串作為建議;網路路由器用 Trie(或其變體)儲存 IP 前綴與對應的下一跳,靠「最長前綴匹配」決定封包往哪裡送;設定檔/feature flag 系統若 key 有階層命名慣例(例如service.api.timeout),用 Trie 可以支援「列出某個命名空間下所有 key」這類字首查詢。

練習

手刻一個 Trie,實作 insert(word) / search(word) /startsWith(prefix)。用一組小型字典(例如 ["cat", "car", "card","care", "dog"])插入後,驗證:search("car") 為真、search("ca") 為假(只是字首,本身沒被存過)、startsWith("ca") 為真、startsWith("do") 為真、startsWith("x") 為假。

練習(Day 19 綜合)

實作自動完成(autocomplete):給定一個已插入若干單字的 Trie 與一個字首字串,先沿著字首走到對應節點(不存在則回傳空清單),再從該節點開始做一次走訪(DFS 或 BFS 皆可,收集路徑上所有isEndOfWord = true 的完整字串),回傳所有以該字首開頭的完整單字。對上面的小型字典測試 autocomplete("ca") 應回傳["cat", "car", "card", "care"](順序不拘),autocomplete("do")應回傳 ["dog"]

Day 19 過關標準(DoD)

  • Understand:能解釋 Trie 節點的子指標如何從 BST 的「固定兩個」推廣成「依字元集大小」,以及 isEndOfWord 標記存在的必要性(否則無法區分「完整字串」與「只是別的字串的字首」)。
  • Recall:不看資料說出 Insert/Search/StartsWith 皆為 O(L)、且與資料集字串數量無關,並能解釋原因。
  • Apply:完成 Trie 三操作練習與 autocomplete 綜合練習,皆能跑得動並印出驗證結果。
  • Explain:能舉出至少一個「該用 Trie 而非 BST/HashMap」的具體理由(字首查詢不需要看過整個資料集)。

Day 20 — Graph + Phase 02 收尾陌生題

學習目標

看完今天內容後,能夠:

  1. 區分 Directed/Undirected/Weighted 三種圖的差異,並選擇 Adjacency List 或 Adjacency Matrix 表示一張圖。
  2. 用 DFS+遞迴堆疊(recursion stack)偵測有向圖中的環,並說出這與 Day 14 用 visited set 偵測無向圖環的差異。
  3. 面對「找出最近 K 個元素」這道陌生題,能在寫 code 之前先回答「為什麼 Heap/Sorting/Quickselect 是候選方案」。

教材大綱

1

Graph

Core FundamentalsLv.4

What:Graph 是由節點(vertex/node)與連接節點的邊(edge)組成的資料結構,是 Tree「拿掉『無環、每個節點只有一個父節點』限制」後的推廣——Tree 是 Graph 的特例(連通、無環),Graph 允許任意節點之間有任意數量的連接,包含環與多個入邊。

Why

現實世界大量關係不是階層式的——服務之間互相呼叫、使用者互相追蹤、道路網路互相連通——任何節點都可能連到任何其他節點,也可能形成環(A 呼叫 B、B 又呼叫回 A)。Tree 的「無環、單一父節點」假設無法表達這些關係,Graph 存在的目的就是提供一個不設這些限制的通用結構。

Internal Structure(依文件「特別深入」段落逐項展開):- Directed(有向):邊有方向性,A → B 不代表 B → A(例如「服務 A 呼叫服務 B」)。- Undirected(無向):邊沒有方向性,A—B 代表兩個方向都連通(例如「使用者 A 與使用者 B 是朋友」,關係是互相的)。- Weighted(加權):每條邊額外帶一個權重值,代表「走這條邊的代價」(例如道路的距離、網路連線的延遲);沒有權重的圖視為每條邊代價相同(unweighted,可視為權重恆為 1)。- Adjacency List:每個節點對應一份「它連到誰」的清單(用 Day 14 的 Linked List 或 Day 11 的 Array 存放),是目前最常見的表示法。- Adjacency Matrix:一個 V × V 的二維陣列(V 為節點數),matrix[i][j] 代表節點 i 到節點 j 是否有邊(或邊的權重);無向圖的 Matrix 沿對角線對稱。

Core Operations 與複雜度比較(Adjacency List vs Adjacency Matrix,V 為節點數、E 為邊數)

| 操作 | Adjacency List | Adjacency Matrix ||---|---|---|| 空間 | O(V + E) | O(V²) || 檢查 i,j 是否有邊 | O(degree(i))——需掃過 i 的清單 | O(1)——直接查 matrix[i][j] || 列出節點 i 的所有鄰居 | O(degree(i)) | O(V)——需掃過整列 || 新增一個節點 | O(1) | O(V²)——需重新配置整個矩陣 |

這張表說明兩種表示法是明確的取捨:圖是稀疏的(E 遠小於 V²,多數真實系統的依賴圖/社交圖都是如此)時,Adjacency List 在空間與「列出鄰居」上都遠勝 Matrix;圖是稠密的(E 接近 V²)或需要頻繁「檢查任兩節點是否直接相連」時,Matrix 的 O(1)查找更有優勢。

Complexity(走訪類演算法,以 Adjacency List 為底)

| 操作 | 複雜度 ||---|---|| BFS / DFS 走訪全部節點與邊 | O(V + E) || 檢查是否有環(見下方陌生題) | O(V + E) |

Strength:能表達任意複雜的關係(環、多對多連接),是 Tree 無法表達的更通用結構;Adjacency List 表示稀疏圖時空間效率高。

Weakness:Adjacency Matrix 在稀疏圖時嚴重浪費空間(多數格子是「無邊」);圖演算法(最短路徑、環偵測、拓樸排序)普遍比 Tree 的對應演算法複雜,因為節點可能被多條路徑到達,需要額外的 visited 機制避免重複處理或無限迴圈(Day 14 已示範無向圖 BFS/DFS 用 HashSet 記錄 visited)。

Typical Problems:BFS/DFS 走訪(Day 14 已練習過基礎版本)、連通元件(connected components)計數、環偵測(cycle detection,有向圖與無向圖做法不同,見下方陌生題)、拓樸排序(topological sort,只對有向無環圖 DAG 有意義,決定任務執行順序)、最短路徑(Dijkstra 演算法用到 Day 17 的 Heap 取出目前已知距離最短的節點,是 Heap 與 Graph 兩個資料結構的直接組合)。

Backend Applications:微服務之間的呼叫關係、.ai/tasks/*.yaml裡任務的 depends_on 關聯、內部規劃文件1描述的知識點先後關係,本質上都是有向圖;社交網路的好友/追蹤關係是無向或有向圖;網路拓樸/路由表用加權圖表示,邊權重是延遲或頻寬成本;Build 系統/CI pipeline 的任務依賴(哪個任務要等哪個任務完成)用有向圖表示,並且必須是無環的(有環代表循環依賴,任何一個任務都無法真正開始執行),這正是下方陌生題的情境。

陌生題

一個微服務團隊想在部署前自動偵測「服務呼叫關係中是否存在循環依賴」(例如服務 A 呼叫 B、B 呼叫 C、C 又呼叫回 A),避免上線後死鎖或無限重試。該用什麼資料結構與演算法?為什麼不能直接套用 Day 14 對無向圖用過的「visited set」做法?(提示:用 Adjacency List 表示「誰呼叫誰」的有向圖;但 Day 14 的無向圖環偵測只用一個 visited set 記錄「走訪過的節點」是不夠的——在有向圖中,走到一個「已經 visited 過,但不在目前這條遞迴路徑上」的節點是合法的(例如 A 呼叫 B 也呼叫 C,B 跟 C 都呼叫 D,D 被 visited 兩次但沒有環),只有走到「目前這條遞迴路徑上、還沒返回的節點」才代表真正的環(這條邊叫做 back edge)。正確做法是額外維護一個「目前遞迴堆疊中的節點集合(recursion stack,可以用 Day 13 的 HashSet 實作,進入該節點的 DFS 時加入、離開時移除)」,DFS 過程中若碰到一個「在 recursion stack 中」的節點,代表找到環;若只是「visited 過但不在 recursion stack 中」,則安全,不是環。整體仍是 O(V + E)。)

練習

手刻 Graph 的 Adjacency List 表示(用一個 Map<節點,Array<節點>>),實作 addEdge(分別支援有向與無向兩種模式:無向時同時把兩個方向的邊都加進去)、hasEdgegetNeighbors。額外手刻一個 Adjacency Matrix 版本的 addEdge/hasEdge,對同一組資料(節點數 V 固定,但邊的數量遠小於 V²,模擬稀疏圖)分別量測兩種表示法實際佔用的儲存空間(例如印出 List 版總共儲存了幾個節點物件、Matrix 版陣列總共有幾個格子),驗證稀疏圖下 Adjacency List 明顯較省空間。接著依上方陌生題的提示,用 Adjacency List 手刻有向圖的環偵測:DFS 走訪時同時維護 visited(Day 13 HashSet,記錄所有走訪過的節點)與 recursionStack(另一個 HashSet,記錄目前這條遞迴路徑上、尚未返回的節點,進入節點時加入、該節點的 DFS 呼叫返回前移除),若碰到一個「在 recursionStack中」的節點回傳 true(找到環)。用兩組測資驗證:(1) 手動建構一個含環的有向圖(例如 A→B→C→A),確認回傳 true;(2) 手動建構一個節點與邊數相同、但不含環的有向圖(例如把 C→A 改成 C→D),確認回傳 false——兩組測資都要跑過,只測其中一種不算完成。

練習(Day 20 綜合)—— Phase 02 收尾陌生題

依第 6 章驗收要求2,完成陌生題:「找出最近 K 個元素」——給定一組點與一個查詢點,找出距離查詢點最近的 K 個點。不能直接寫 code,先回答:

為什麼 Heap / Sorting / Quickselect 是候選方案?

候選方案分析

  1. Sorting(排序):把全部 n 個點依「與查詢點的距離」排序,取前 K 個。複雜度 O(n log n)。優點是實作最直覺、除錯容易,而且排序完的結果本身是完全有序的(如果後續還需要「第 K+1 近的是誰」也能直接取)。缺點是當 n 遠大於 K 時很浪費——我們其實不需要知道「第 K+1 近到第 n 近」彼此的順序,卻花了O(n log n) 把全部都排好。
  1. Heap(大小為 K 的 Max Heap):維護一個大小固定為 K 的 Max Heap,存放「目前看過、距離最近的 K 個候選」(Max Heap 的 root 是這 K 個候選裡「距離最遠」的那個,代表最容易被淘汰的候選)。走訪 n 個點,每個點:若 heap 大小小於 K,直接插入;否則若這個點的距離比 heap 的 root(目前 K 個候選裡最遠的)更近,就把 root pop 掉、插入這個新點。複雜度 O(n log K)——這正是 Day 17 提過的優勢:只需要維持大小為 K 的局部順序,不需要對全部 n 個點排序。當 K 遠小於 n 時,O(n log K)明顯優於 O(n log n)。額外優點:這是唯一支援串流(點一個一個抵達,不需要事先擁有完整陣列)的做法——Sorting 與 Quickselect 都需要一次拿到完整資料才能操作。
  1. Quickselect(基於 partition 的選擇演算法):借用 Quicksort 的 partition 步驟(挑一個 pivot,把比 pivot 近的點分到左邊、比 pivot 遠的分到右邊),但只需要遞迴進入「包含第 K 個邊界」的那一側,不必像 Quicksort 兩側都遞迴。平均複雜度 O(n)(隨機選 pivot 時的期望複雜度;最差情況O(n²),但用隨機化或 median-of-medians 選 pivot 可以避免最差情況),是三者中平均情況最快的,而且是原地(in-place)分割,不需要額外 O(K) 空間。缺點是分割後的前 K 個元素彼此之間不保證有序(如果需要「這 K 個裡誰第一近、誰第二近」還得再排一次),而且最差情況的複雜度沒有保證(除非額外處理 pivot 選擇)。

如何選擇(trade-off 總結):資料是串流、K 遠小於 n、需要「隨時知道目前最近的 K 個」→ Heap;資料一次到齊、nK量級接近、或後續還需要完整排序結果 → Sorting 最簡單也不吃虧;資料一次到齊、只要「這 K 個是誰」不在乎彼此順序、追求平均最快且能接受最差情況風險 → Quickselect。

再寫 code(示範 Heap 做法,延用 Day 17 手刻的 Max Heap;若 Day 17 只手刻了 Min Heap,可直接把比較邏輯反過來,或對距離取負值重複使用同一份 Min Heap 實作):對輸入的 n 個點與查詢點,逐一計算距離,依上方「候選方案 2」的邏輯維護大小為 K 的 Max Heap,走訪完成後 heap 內剩下的 K 個點即為答案。用至少一組手算過答案的測資(例如查詢點在原點、給 6~8 個座標點,K=3)驗證輸出的 K 個點確實是距離最近的三個(可不要求輸出本身有序,只要求集合正確)。

Day 20 過關標準(DoD)

  • Understand:能解釋 Adjacency List 與 Adjacency Matrix 的空間/時間取捨,並判斷稀疏圖/稠密圖分別該用哪一種。
  • Recall:不看資料說出有向圖環偵測需要「visited set +recursion stack」兩者缺一不可,並解釋各自的作用。
  • Apply:完成 Graph 兩種表示法的手刻練習(含空間量測比較)與 Phase 02 收尾陌生題(找出最近 K 個元素,含 Heap 版 code),皆能跑得動並印出驗證結果。
  • Explain:針對陌生題情境,正確指出有向圖 back edge 的判斷依據;針對「找出最近 K 個元素」,能在不看提示的情況下,重新講出 Heap/Sorting/Quickselect 三者的複雜度與適用情境差異,並說出為什麼 Heap 是唯一支援串流輸入的做法。
  • Implement(Graph 為 Core Fundamentals,額外要求):環偵測練習需分別對「含環」與「不含環」的有向圖測資驗證正確,不能只測其中一種;「找出最近 K 個元素」的 Heap 版 code 需與手算答案對照驗證通過。

Day 11–20 完成後,已涵蓋第 6 章3全部 13 個資料結構(Array/String/HashMap/HashSet/Linked List/Stack/Queue/Deque/Heap/Binary Tree/BST/Trie/Graph)的 9 個面向,「特別深入」段落(HashMap/Heap/Tree/Graph)逐項補齊,並完成第 6 章驗收陌生題(找出最近 K 個元素:Heap/Sorting/Quickselect 候選分析+Heap 版實作)。Phase 02 到此結束,T-023(Phase 03 Algorithms)depends_on 只有 T-006,與本 Phase 無直接依賴,可獨立開始。

  1. 依 00-knowledge-dependency-graph.md
  2. 依 00-master-curriculum.md 第 6 章驗收要求
  3. 依 00-master-curriculum.md 第 6 章