數據結構(Data Structure)是組織及儲存數據的方法,並配合一組可執行的操作。例如陣列支援按索引讀取,堆疊支援在頂端加入及移除,而隊列則按先到先處理的規則運作。
導言:甚麼是數據結構?
概念總覽重點
- 數據結構把數據的組織方式與可進行的操作結合,目的是更有效地存取、插入、刪除、搜尋及遍歷。
- 選擇結構時,要先找出最常用的操作,再比較所需時間、額外空間及實作複雜程度。
- 陣列、堆疊、隊列、鏈表、樹、圖、雜湊表及堆各有不同規則,不存在所有情況都最好的單一結構。
- 解題時不可只看結構名稱;必須把題目中的現實流程轉化為「數據、操作、限制及成本」。
設計或選擇數據結構時,先確定要保存甚麼數據,再列出最頻繁的操作,例如讀取第 k 項、搜尋特定鍵、在中間插入、取出最高優先項目或遍歷所有節點。之後比較每項操作的時間成本、空間成本及實作限制。
同一批數據用不同方式組織,操作步數可以相差很大;因此數據與操作不能分開考慮。
- 瀏覽器「返回上一頁」可用堆疊保存最近到訪次序。
- 列印工作按到達先後處理,可用隊列。
- 檔案目錄具有父子層次,可用樹狀結構表示。
- 地圖道路連接多個地點,可用圖表示。
- 陣列:按索引讀取快;中間插入或刪除通常需要移位。
- 鏈表:找到位置後更容易改接連結;但按第 k 項讀取通常要逐節點前進。
- 堆疊與隊列:限制加入及移除位置,以換取清晰的 LIFO 或 FIFO 規則。
- 雜湊表與樹:前者著重按鍵查找,後者可表達階層及支援不同遍歷方式。
- 只因題目出現「排隊」二字便選隊列,卻沒有確認真正的加入與移除規則。
- 只背結構名稱及圖形,不分析題目最常進行的操作。
- 聲稱某一結構「一定最快」,忽略操作種類、數據量與空間限制。
- 比較插入成本時忘記計算「先找到位置」所需的時間。
先把問題拆成數據與操作
數據結構不只是盛載資料的容器,而是「如何排列數據」與「允許怎樣操作」的整體設計。相同的學生紀錄可以放在陣列、鏈表或以學號作鍵的雜湊表;讀取第 k 筆、按學號搜尋及在中間加入紀錄時,各種結構的表現會不同。因此,選擇前要先列出真正需要的操作。
評估結構時通常比較時間成本與空間成本。直接按索引讀取可能很快,但保持連續次序會令中間插入需要移位;以指示標連接節點可減少搬移,卻要額外空間保存連結,亦可能需要逐節點搜尋。所謂「合適」是指它配合目前問題的主要操作及限制,而不是在所有方面都最快。
學習每一種結構時,可依次掌握核心規則、可用操作、操作前後的狀態變化及常見錯誤,再把現實情境翻譯成這些規則。以下分類表及成本比較表可作初步地圖;後續課題會以圖像、偽代碼、Python 活動及小測進一步驗證理解。
為何需要數據結構?
- 效率:選對結構,可以由「逐個找」變成「一步到位」,或大幅減少搬移。
- 思維:把問題抽象成「數據 + 操作」,寫程式時更清晰。
- 可維護:結構清楚,程式更容易除錯與擴充。
在 DSE 常見題目中,失分位通常不是語法,而是「用錯結構」或「更新指示標次序錯」。
常見分類(由線性到非線性)
| 類型 | 代表結構 | 直覺 |
|---|---|---|
| 線性 | 陣列、堆疊、隊列、線性鏈表 | 像一條隊伍:有先後次序 |
| 階層 | 樹(Tree) | 像家族/資料夾:一層層分支 |
| 關係網絡 | 圖(Graph) | 像地圖:節點之間可多方向相連 |
| 索引查找 | 雜湊表(Hash Table) | 像字典:用 key 直接查 value |
| 優先次序 | 堆(Heap) | 像候診:先處理「最急」或「最高分」 |
選擇數據結構,其實是在選擇「成本」
沒有一種結構能在所有操作都最快。你需要根據題目重點,選擇最合適的取捨。 例如:陣列索引讀取快,但中間插入要移位;鏈表插入(定位後)快,但要走訪才能找到位置。
| 你最常做的操作 | 可能較合適的結構 |
|---|---|
| 經常按索引讀取第 k 個 | 陣列(Array) |
| 經常做「最近加入」的回溯 | 堆疊(Stack) |
| 經常按到達次序排隊處理 | 隊列(Queue)/循環隊列 |
| 經常在中間插入/刪除(而且已找到位置) | 鏈表(Linked List) |
Check Point:數據結構導言
題目涵蓋定義、操作需求、現實例子、結構比較及常見選擇錯誤。
按「下一題」開始。
一、DSE 循環偽代碼四式速查
考試寫法重點
- for 適合已知次數或清楚範圍;while 適合次數未知、由條件決定是否繼續。
- while 是前測循環,可能一次也不執行;do while 與 repeat until 是後測循環,至少執行一次。
- while 與 do while 的條件是繼續條件;repeat until 的條件是停止條件。
- 追蹤循環時必須檢查初始化、條件、主體及更新,並留意 off-by-one 與忘記更新造成的無限循環。
循環讓一組語句重複執行。DSE 常見四式包括:按範圍計數的 for、前測的 while,以及後測的 do while 和 repeat until。
完整循環需要考慮初始化、條件、主體及更新。for 通常把計數範圍集中表示;while 先檢查繼續條件;do while 先執行主體,再以繼續條件決定是否再做;repeat until 先執行主體,再以停止條件決定是否結束。
設 i 由 1至 9 表示 i 依次取 1 至 9,共執行 9 次。若要讀取輸入直至用戶輸入有效值,而第一次必須先取得輸入,可考慮後測循環;使用 repeat until 時,條件應寫成「輸入已有效」的停止條件。
- 已知次數:for 較直接;未知次數:while 或後測循環較自然。
- 前測:while 可執行 0 次;後測:do while 及 repeat until 至少 1 次。
- 條件為真時:while/do while 繼續;repeat until 停止。
- 把 do while 改寫成 repeat until 時,通常需要把條件取反,才能保持相同邏輯。
- 把
<寫成<=,令執行次數多一次或少一次(off-by-one)。 - 在 while 主體內忘記更新控制變量,令條件永遠保持成立而造成無限循環。
- 把 repeat until 的停止條件誤當成繼續條件。
- 忽略前測與後測的分別,錯誤判斷初始條件不成立時的執行次數。
先判斷次數是否已知
當題目已清楚給出開始值、終止值或重複次數時,for 通常最容易閱讀,例如由 1 至 9 逐項處理。若執行次數要到運行期間才知道,例如「當仍有下一筆紀錄便繼續」,while 較合適。兩者都需要清楚的終止機制,否則可能多做一次、少做一次或永不停止。
前測與後測決定循環主體最少執行多少次。while 在進入主體前檢查條件,初始條件不成立時會執行 0 次;do while 及 repeat until 先做一次再檢查,所以至少執行 1 次。這個差異特別適合用來判斷「必須先取得一次輸入,之後才知道是否重複」的情境。
還要分辨條件的意思。while 與 do while 在條件為真時繼續;repeat until 則在條件為真時停止。追蹤時可依序寫下控制變量的初始值、每次條件結果、主體輸出及更新後的值,便較容易發現 off-by-one、條件方向錯誤與忘記更新等問題。
for loop
設 i 由 1至 9
...
while loop
當 x > 5
...
do while
執行
...
當 x > 5
repeat until
重複
...
直至 x > 5
Check Point:DSE 循環偽代碼四式
題目涵蓋結構辨認、執行次數、條件意義、前測/後測及常見錯誤。
按「下一題」開始。
DSE 課程部分
核心以下課題(陣列 Array、堆疊 Stack、隊列 Queue、循環隊列 Circular Queue、線性鏈表 Linked List)是 DSE 常見的基本數據結構。
建議先掌握「規則」(LIFO/FIFO)與「操作成本」(O(1)/O(n)),再做題目整合。
本頁每個課題都附有可執行的 Python 小練習(放在例子示範之後),請邊看邊做。
二、陣列(Array)與二維陣列(2D Array)
DSE 核心重點
-
索引存取:用 index 直接讀取
arr[i],概念上視作 O(1)。 -
插入/刪除會移位:若不是尾端操作,往往要搬移元素,時間多為 O(n)。
-
二維陣列定位:先列後欄,用
arr[row][col];row/col 轉錯會取錯格。 -
Row-major 理解:2D 可以攤平成 1D;線性索引 ≈
row * 欄數 + col(逐列掃描)。 - 走訪與搜尋:用循環逐格處理(例如找最大值、計算總和、掃描 2D 表格)。
Array(陣列):用一段連續(或概念上連續)的索引位置,儲存一組同類型資料。每個元素都有一個索引(index)。
2D Array(二維陣列):可以當作「陣列內再放入陣列」,用兩個索引(row, col)定位。
- 索引定位:知道 i 就可以直接定位第 i 格,所以讀取快。
- 移位成本:中間插入/刪除要保持次序,就要搬移後面元素。
- 2D 其實是 1D + 計算:row-major 時,線性索引 = row×欄數 + col。
成績表:score[i] 代表第 i 位同學分數;或者用 2D:score[row][col] 代表「第 row 位同學、第 col 科」分數。
若要把新同學插入第 0 位,所有人要向後移一格,這個就是移位。
- Array vs Linked List:Array 索引快,但中間插入/刪除慢;Linked list 相反(定位後插入/刪除快,但索引慢)。
- 2D Array vs 1D Array:2D 直觀表格;1D 更容易做連續掃描與部分算法(如 heap)。
- 把
arr[row][col]寫成arr[col][row](row/col 顛倒)。 - 忘記索引由 0 開始(Python list)。
- 以為「任何插入」都是 O(1);其實中間插入通常要移位。
概念講解:陣列的優勢與限制
陣列最核心的概念是「位置(index)」。當你寫 arr[i],其實你是要求系統去第 i 個位置取數據。
因為位置是已知,所以不需要逐個比較來尋找,讀取通常快。
但陣列要維持「次序」:例如你想將一個新元素插入到中間,為了騰出空位,後面元素就要向後搬。 所以即使讀取快,插入/刪除位置錯了都可能很慢。
二維陣列可以視為「表格」。定位一格需要兩個索引:先列(row)後欄(col)。 如果你用 row-major 角度看,掃描表格其實是逐列掃過去,這個理解對之後 heap / graph 的陣列表示很有幫助。
表格整理:常見操作與直覺成本
| 操作 | Array(概念) | 常見原因 |
|---|---|---|
| 讀取 arr[i] | 快(≈ O(1)) | 索引直接定位 |
| 在尾端 append | 通常快 | 不需搬移原有元素 |
| 在中間插入/刪除 | 較慢(≈ O(n)) | 要移位保持次序 |
| 2D 讀取 arr[row][col] | 直觀定位 | 先列後欄;row/col 易出錯 |
圖像化 + 可操作示範
2D 格子:點擊任意格
點擊任何一格後,系統會顯示 (row, col) 以及 row-major 的線性索引。
二維陣列可以視作一張表格:row 代表第幾列(由 0 開始),col 代表第幾欄(由 0 開始)。
你每次點擊,其實就是在理解 arr[row][col] 這句在取哪一格。
另一個容易忽略但很重要的觀念是:電腦在記憶體中常把 2D 表格用「一條連續位置」存放(row-major:逐列存放)。
因此,同一格的線性索引可用公式表示:row × 欄數 + col。
你在下方看到的線性索引,就是在訓練你把 2D 與 1D 的位置關係連起來。
中間插入:觀察「移位」
改 p(位置)/ x(新值)後按「插入」,比較前後。
這個示範想你觀察兩件事:第一,當你把新元素插入在中間位置 p 時,原本位於 p、p+1、p+2… 的元素,必須整體向右移一格,才能騰出空位; 第二,移位的步數與「受影響的元素數量」成正比,所以插入在越前的位置,搬移越多,時間成本越高。
把它想像成一排座位:你在中間插入一位新同學,後面所有同學都要向後移一個座位。
例子示範:偽代碼(4 種循環) vs Python(for + while)
例子:在陣列中找第一個等於 target 的索引;找不到回傳 -1。
arr = [4, 9, 2, 9]
target = 9
ans = -1
for i in range(len(arr)):
if arr[i] == target:
ans = i
break
print(ans)
arr = [4, 9, 2, 9]
target = 9
ans = -1
i = 0
while i < len(arr):
if arr[i] == target:
ans = i
break
i += 1
print(ans)
ans ← -1
設 i 由 0至 n-1
若 arr[i] = target
ans ← i
離開循環
ans ← -1
i ← 0
當 i < n
若 arr[i] = target
ans ← i
離開循環
i ← i + 1
ans ← -1
i ← 0
執行
若 arr[i] = target
ans ← i
離開循環
i ← i + 1
當 i < n
ans ← -1
i ← 0
重覆
若 arr[i] = target
ans ← i
離開循環
i ← i + 1
直至 i ≥ n
小遊戲:線性檢索(Linear Search)拖放挑戰
系統會隨機產生一個陣列與目標值 target。請由 index = 0 開始,依次把「指示標」拖到下一格進行檢查;
當找到 target 後,系統會產生「回傳索引」代幣,請把它拖到回傳框。
重點:線性檢索必須按次序逐格檢查,不能跳格。
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
Python 小練習(填充題)
____ 補上,先按 Run 測試,再按 核對答案 檢查。
Check Point:小測(Array / 2D Array)
按「下一題」開始。
三、Stack(堆疊)
DSE 核心重點
-
LIFO:後進先出,只能由頂部進出。
-
三大操作:push(加入頂部)、pop(移除頂部)、peek(查看頂部)。
-
常見用途:括號配對、回溯(backtracking)、撤銷(undo)、深度優先搜索(DFS)。
- 空堆疊與安全檢查:pop 前先判斷是否為空,避免錯誤與邏輯漏洞。
- 封裝成 ADT:用函數/類別把 push/pop/peek/is_empty 包起來,令程式更清晰。
Stack(堆疊):一種只允許在同一端(頂部)加入與移除的線性結構。
- 最後推入(push)的會最先彈出(pop)。
- 可以用陣列/清單模擬:把「頂部」放在清單尾端,
append/pop()都十分自然。
括號配對:讀到 ( 就 push;讀到 ) 就 pop;中途 pop 不到或最後仍有剩餘元素,就不平衡。
- Stack vs Queue:Stack LIFO;Queue FIFO(先入先出)。
- 用途角度:Stack 較像「回到上一個狀態」;Queue 較像「按排隊次序處理」。
- 對空 stack 直接 pop(會出錯)。
- 把「頂部」定義錯(用 list 頭部做頂部會令某些操作變慢)。
- 括號題只檢查數量,沒有檢查次序(例如
)(數量一樣但不平衡)。
概念講解:LIFO 的典型應用
Stack 的特別之處不是它「功能多寡」,而是它限制了你如何存取資料:只准由頂部進出。 這個限制反而令很多算法更清晰,例如「處理到一半想回到上一層」就直接 pop。
以括號配對為例:每遇到一個左括號,就等於開了一個「未完成的工作」;當遇到右括號,就要把最近未完成那個工作關閉。 由於要處理「最近」那個,LIFO 就非常適合。
實作上,你可以用 Python list 尾端做 stack:append 當 push,pop() 當 pop。
但記住:空 stack pop 會出錯,所以要先檢查長度。
表格整理:Stack 操作
| 操作 | 意思 | 常見寫法(Python list) |
|---|---|---|
| push | 加入頂部 | stack.append(x) |
| pop | 移除並取出頂部 | stack.pop()(先檢查非空) |
| peek | 查看頂部但不移除 | stack[-1](先檢查非空) |
可操作示範:Stack push/pop
例子示範:偽代碼(4 種循環) vs Python(for + while)
例子:括號配對(只處理 '(' 與 ')')。
s = "(()())()"
stack = []
ok = True
for ch in s:
if ch == "(":
stack.append(ch)
else:
if len(stack) == 0:
ok = False
break
stack.pop()
if ok and len(stack) != 0:
ok = False
print(ok)
s = "(()())()"
stack = []
ok = True
i = 0
while i < len(s):
ch = s[i]
if ch == "(":
stack.append(ch)
else:
if len(stack) == 0:
ok = False
break
stack.pop()
i += 1
if ok and len(stack) != 0:
ok = False
print(ok)
ok ← True
設 i 由 0至 n-1
ch ← s[i]
若 ch = "("
push(stack, ch)
否則
若 stack 為空
ok ← False
離開循環
pop(stack)
若 ok = True 且 stack 非空
ok ← False
ok ← True
i ← 0
當 i < n
ch ← s[i]
若 ch = "("
push(stack, ch)
否則
若 stack 為空
ok ← False
離開循環
pop(stack)
i ← i + 1
若 ok = True 且 stack 非空
ok ← False
ok ← True
i ← 0
執行
ch ← s[i]
若 ch = "("
push(stack, ch)
否則
若 stack 為空
ok ← False
離開循環
pop(stack)
i ← i + 1
當 i < n
若 ok = True 且 stack 非空
ok ← False
ok ← True
i ← 0
重覆
ch ← s[i]
若 ch = "("
push(stack, ch)
否則
若 stack 為空
ok ← False
離開循環
pop(stack)
i ← i + 1
直至 i ≥ n
若 ok = True 且 stack 非空
ok ← False
小遊戲:堆疊(Stack)指令拖放練習
系統會提供一個初始堆疊、一些「新數據」以及一串指令(push/pop)。
請按指令次序完成操作:
・push:把指定的新數據拖到堆疊區(會放在頂部)。
・pop:把頂部元素拖到「回傳區」。
做錯時會提示「正確下一步」。
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
Python 小練習(填充題)
____ 補上,先按 Run 測試,再按 核對答案 檢查。
Check Point:小測(Stack)
按「下一題」開始。
四、隊列(Queue)
DSE 核心重點
-
FIFO:先進先出;入隊加尾、出隊移頭。
-
避免慢操作:如果用 list 直接
pop(0)會慢;可用 head 指示標/循環隊列思路。 -
常見用途:排隊系統、BFS、任務排程。
- 常見實作:在 Python 可用
collections.deque做 O(1) 的入隊/出隊(概念更貼近真・隊列)。 - 封裝成 ADT:把 enqueue / dequeue / front / is_empty / size 寫成一套介面,做題目更穩。
隊列(Queue):一種只允許在尾端加入(enqueue),在頭端移除(dequeue)的線性結構。
- FIFO:最早入隊的最先出隊。
- 實作常見:循環陣列(用 head/tail 指示標)或 linked list。
- 用 Python list 模擬時,建議用「head 指示標」避免
pop(0)。
排隊買票:新來的人排隊尾;窗口每次服務隊首。
BFS:先發現的節點先處理,逐層向外擴散。
- Queue vs Stack:Queue FIFO;Stack LIFO。
- Queue vs Priority Queue:Queue 按到達次序;Priority Queue 按優先級(多用 heap)。
- 用 list 做 queue 而頻繁使用
pop(0)(會慢)。 - 把 head/tail 指示標更新錯,導致漏元素或重覆出隊。
- 忘記檢查空 queue 就 dequeue。
概念講解:FIFO 與實作技巧
Queue 的概念其實非常貼近生活:排隊就是 FIFO。
但寫程式時,重點是你用甚麼方式實作 dequeue。
如果你用 Python list 的 pop(0),每次都要把後面元素向前搬,會慢。
一個常見技巧是用 head 指示標:入隊仍然 append,出隊不搬陣列,只是 head += 1。
最後真正隊列內容就是 q[head:]。
這個思路亦是循環隊列(circular queue)的基礎。
在偽代碼中,亦常見用一個(可以很長的)陣列配合「隊列開始 front」及「隊列結束 rear」(或 head / tail),再加上一個「項目數量 count」。入隊時把新值寫到 rear;出隊時不需要把元素真的從陣列移除,只要把 front 往後移一格(或把 count 減一)即可完成更新。陣列內舊位置的值可以保留,但會被視為『不再屬於隊列』。
| 實作方式 | enqueue | dequeue | 備註 |
|---|---|---|---|
| Python list + pop(0) | append(x) | pop(0) | dequeue 需搬移元素,時間較慢(O(n))。 |
| Python list + head 指示標 | append(x) | head += 1 | 不搬移元素,dequeue 可做到 O(1),但 head 需定期清理或重設。 |
| 長陣列 + front/rear + count | arr[rear]=x;rear+=1;count+=1 | front+=1(或 count-=1) | 最貼近 DSE 偽代碼;rear 走到尾端後,需搬移/重設,或改用循環隊列。 |
| 循環隊列(覆蓋模式) | tail=(tail+1)%n | head=(head+1)%n | 當已滿再入隊,會覆蓋最舊元素並推進 head。 |
當你理解到 queue 的 FIFO 特性,你就會自然明白 BFS 為甚麼會「逐層」走訪:因為先發現的節點先出隊、先展開。
可操作示範(一):移位式隊列(像排隊般全隊向前)
這個版本最貼近「現實排隊」的直覺:enqueue 就在隊尾加入新元素;dequeue 就把隊首元素移走, 然後整隊人向前移一格(也就是把陣列內的元素逐個向前搬移)。
你應該觀察:每次 dequeue 後,原本在第 1、2、3… 位的人都會「搬位」,因此在陣列實作中,dequeue 往往是 O(n)。
示範代碼:移位式隊列(偽代碼 + Python)
# 假設隊列儲存在 arr[0..size-1]
# enqueue(x):加到尾端
arr[size] ← x
size ← size + 1
# dequeue():取 arr[0],其餘向前移一格
若 size = 0
回傳 NULL
y ← arr[0]
設 i 由 0至 size-2
arr[i] ← arr[i+1]
size ← size - 1
回傳 y
q = [10, 20, 30]
# enqueue
x = 99
q.append(x) # 加到隊尾
# dequeue(會把後面元素向前搬)
front = q.pop(0) # 取走隊首
print("dequeue ->", front)
print("queue =", q)
可操作示範(二):指示標式隊列(只移動 head / tail,不搬移元素)
提示:在「指示標式隊列」中,dequeue 後不必把元素從陣列刪走;只要 head 往後移一格即可。 你會看到舊值仍留在格子內,但已不再屬於隊列。
例子示範:偽代碼(4 種循環) vs Python(for + while)
例子:處理隊列中所有任務(直到 queue 清空),並累計總時間。
tasks = [2, 5, 1, 3]
total = 0
for t in tasks:
total += t
print(total)
tasks = [2, 5, 1, 3]
q = tasks[:]
head = 0
total = 0
while head < len(q):
total += q[head]
head += 1
print(total)
total ← 0
設 i 由 0至 n-1
total ← total + task[i]
total ← 0
head ← 0
當 head < n
total ← total + task[head]
head ← head + 1
total ← 0
head ← 0
執行
total ← total + task[head]
head ← head + 1
當 head < n
total ← 0
head ← 0
重覆
total ← total + task[head]
head ← head + 1
直至 head ≥ n
小遊戲:隊列(Queue)指令拖放練習(FIFO)
你會得到一個初始隊列與一串指令(enqueue/dequeue)。
請按次序完成:enqueue 時把指定新數據拖到隊尾;dequeue 時把隊首元素拖到回傳區。
重點:隊列是 FIFO(先進先出),只能從隊首取出。
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
Python 小練習(填充題)
____ 補上,先按 Run 測試,再按 核對答案 檢查。
Check Point:小測(隊列)
按「下一題」開始。
五、循環隊列(Circular Queue)
DSE 核心重點
-
固定容量:用一個大小為 n 的陣列作為儲存區(buffer),空位可重用。
-
兩個指示標:
head指向下一個出隊位置;tail指向下一個入隊位置。 -
循環(wrap-around):指示標到達尾端後回到 0;用
(i+1) mod n表示。 -
滿/空判斷:常見方法是用
size記錄元素數;或用「下一格是否等於 head」判斷滿。 - 覆蓋模式:當隊列已滿仍入隊時,會覆蓋最舊元素,並把 head 亦向前推一格(常見於資料串流/固定緩衝區)。
-
避免假溢出:普通隊列若只向前移 head,尾端可能有空位但無法再入隊;循環隊列可重用空位。
循環隊列(Circular Queue)是一種以固定容量陣列實作的隊列。
當指示標走到陣列尾端,會回到 0,形成「環」。
- head:下一個出隊(dequeue)的位置。
- tail:下一個入隊(enqueue)的位置。
- wrap-around:
i = (i + 1) mod n,令指示標循環。 - 滿/空:可用
size判斷空(0)與滿(n);或用「下一個 tail 是否等於 head」判斷滿。
列印緩衝(buffer):印表機以固定大小緩衝接收工作,先入先出;完成一個就釋放一格,下一個工作可重用該格。
排隊叫號:叫完號的人離開(dequeue),後面的人進來(enqueue),位置可以重複使用。
- 普通隊列(用陣列 + head 只向前走):可能出現「尾端滿了,但前面其實有空位」的假溢出。
- 循環隊列:能重用空位;在固定容量情況下更有效率。
- 忘記用
mod n,指示標越界。 - 滿/空判斷混亂(例如 head==tail 既可代表空亦可代表滿,必須配合 size 或額外旗標)。
- enqueue/dequeue 次序錯(先移指示標再寫入,或先清空再讀取)。
概念講解:為何需要循環隊列
在「隊列」的題目中,我們希望維持 FIFO(先進先出)。如果用固定大小的陣列實作,最直觀的方法是用兩個指示標:
head 指向隊首、tail 指向下一個可放入的位置。每次出隊後,head 向前移動。
問題是:當 tail 走到陣列尾端時,即使前面已經出隊騰出了空位,尾端也「看似」沒有位置可以再入隊,這就是假溢出(false overflow)。
循環隊列的做法是把陣列當作一個「環」,讓指示標到尾端後回到 0,從而重用空位。
實作時最關鍵是兩點:第一是用 (i+1) mod n 處理循環;第二是清楚定義「空」與「滿」。
最穩的方法是用 size 記錄目前元素數,避免 head==tail 造成判斷混亂。
本頁示範採用「覆蓋模式」:當 size==n(已滿)仍然 enqueue,新元素會寫入 tail(此時 tail==head),然後 head 會同步向前推一格,代表最舊元素被覆蓋而離開隊列。這種設計常用於固定容量的緩衝區,例如保留最近 n 筆資料。
表格整理:操作、指示標與條件
| 操作 | 主要動作 | 指示標更新(以 size 判斷) |
|---|---|---|
| enqueue(x) | 把 x 放到 buffer[tail] | tail=(tail+1) mod n;size++ |
| dequeue() | 取出 buffer[head] 並清空 | head=(head+1) mod n;size-- |
| is_empty | 是否沒有元素 | size==0 |
| is_full | 是否已滿 | size==n |
可操作示範:循環隊列(固定容量)
提示:本示範採用「覆蓋模式」。當 size==n(已滿)仍 enqueue,新值會覆蓋最舊元素(head 會同步向前移)。
例子示範:用循環隊列處理一串操作
示範重點:mod 處理循環;先判斷滿/空;enqueue 先寫入再移 tail,dequeue 先讀取再移 head。
Python(for loop:逐個處理操作)
ops = ["enq 5", "enq 8", "enq 3", "deq", "enq 9", "deq", "enq 1", "enq 7", "enq 6"]
n = 5
buf = [None] * n
head = 0
tail = 0
size = 0
for op in ops:
if op.startswith("enq"):
x = int(op.split()[1])
if size == n:
# 覆蓋最舊元素:在 tail(此時 tail==head)寫入,並同步推進 head
buf[tail] = x
tail = (tail + 1) % n
head = (head + 1) % n
print("滿:覆蓋最舊 -> enqueue", x)
else:
buf[tail] = x
tail = (tail + 1) % n
size += 1
else: # deq
if size == 0:
print("空:不能 dequeue")
else:
x = buf[head]
buf[head] = None
head = (head + 1) % n
size -= 1
print("dequeue ->", x)
print("buf =", buf)
print("head =", head, "tail =", tail, "size =", size)
Python(while loop:一直 dequeue 直到清空)
# 假設 buf / head / tail / size 已由上面程式得到
out = []
while size > 0:
x = buf[head]
buf[head] = None
head = (head + 1) % n
size -= 1
out.append(x)
print("全部出隊次序 =", out)
for loop
設每個 op 依序由 ops 取出
若 op 為 enqueue(x)
若 size == n
輸出「滿」
否則
buffer[tail] = x
tail = (tail + 1) mod n
size = size + 1
否則(op 為 dequeue)
若 size == 0
輸出「空」
否則
x = buffer[head]
buffer[head] = 空
head = (head + 1) mod n
size = size - 1
輸出 x
while loop
當 size > 0
x = buffer[head]
buffer[head] = 空
head = (head + 1) mod n
size = size - 1
輸出 x
do while
執行
x = buffer[head]
buffer[head] = 空
head = (head + 1) mod n
size = size - 1
輸出 x
當 size > 0
repeat until
重覆
x = buffer[head]
buffer[head] = 空
head = (head + 1) mod n
size = size - 1
輸出 x
直至 size == 0
小遊戲:循環隊列(Circular Queue)拖放挑戰(覆蓋模式)
此小遊戲採用「覆蓋模式」:當循環隊列已滿仍然 enqueue,新元素會覆蓋最舊元素(同時把 head 推進一格)。
請按指令次序完成操作:
・enqueue:把指定新數據拖到tail 指向的格。
・dequeue:把head 指向的格拖到回傳區。
(tail+1) mod n 的循環寫法)。展開卡片 後可直接在頁內 Run。請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
Python 小練習(填充題)
____ 補上,先按 Run 測試,再按 核對答案 檢查。
Check Point:小測(循環隊列)
按「下一題」開始。
六、線性鏈表(Linear Linked List)
DSE 核心重點
-
start找首個節點:在這一節,start用來指出首個節點的索引;D[i]儲存內容,NP[i]儲存下一個節點的索引。 -
真正次序要跟指示標讀:節點可以分散在不同索引位置,但整條鏈的先後次序要由
start出發,沿NP[current]逐個讀出。 -
插入/刪除主要是重新接駁:若改的是首個節點,便更新
start;否則通常只需改動附近的少量NP。 -
只有走得到的節點才算入鏈表:即使某些索引位置有資料,只要不能由
start到達,它們便不屬於目前這條鏈表。 -
鏈表與陣列各有長短:鏈表方便重新接駁;陣列則較擅長直接存取/隨機存取。
線性鏈表可想像成一組分散放在不同櫃格的導賞卡。每個節點都儲存自己的內容,以及一個指出「下一個節點在哪個索引」的指示標。
start 會指出第一張卡所在的位置;若某節點已經沒有下一個節點,便以空值指示標(例如 -1 或 null)表示結束。
D[i]:第i個索引位置儲存的內容,例如站點名稱。NP[i]:由第i個節點前往下一個節點時,要跳去的索引。- 走訪時,先令
current ← start,然後重覆current ← NP[current],直到遇到空值指示標。 - 因此,鏈表屬於順序存取;它不像陣列那樣可直接以索引立刻取得第
k個節點。
想像這是一條校園導賞路線:start = 5,而 NP[5] = 3、NP[3] = 1、NP[1] = 2、NP[2] = -1。
若 D[5] = "校門"、D[3] = "禮堂"、D[1] = "圖書館"、D[2] = "電腦室",則由 start 走訪的次序便是:校門 → 禮堂 → 圖書館 → 電腦室 → NULL。
- 鏈表 vs 陣列:陣列擅長直接存取/隨機存取;鏈表則較適合在已定位的位置附近插入或刪除。
- 鏈表 vs 堆疊/隊列:堆疊和隊列的加入/刪除位置較固定;鏈表則可在中間重新接駁節點。
- 鏈表的代價:要找第
k個節點時,通常要由start開始逐個沿指示標前進。
- 刪除第 1 個節點時,只清空原本節點,卻忘記把
start改到下一個節點。 - 插入新節點時,只改了
NP[prev],卻沒有先設定NP[i],結果把後半段鏈表弄丟。 - 把所有有資料的索引都當成鏈表的一部分;事實上,只有由
start能走到的節點,才屬於目前這條鏈表。
概念講解:把節點想像成分散放置的導賞卡
想像學校要為開放日準備一條「校園導賞路線」。每一張導賞卡都記錄一個站點名稱,以及「下一站放在哪個索引」。 這些卡片未必按路線先後整齊排在 1、2、3、4;它們可以分散放在不同位置,但只要每張卡都寫清楚下一站在哪裏,整條路線仍然能順利走完。
例如 start = 5,便表示第一站放在索引 5;若 NP[5] = 3,就代表下一站在索引 3。
於是實際次序是 5 → 3 → 1 → 2,而不是單看索引大小。這就是線性鏈表最重要的觀念:索引只是一個存放位置,真正的先後次序由連接關係決定。
也正因如此,線性鏈表較適合描述「順著一條路逐站前進」的情況。要找第 3 個節點,你必須由第一個節點開始逐個跟著指示標前進; 但如果你已經知道前一個節點在哪裏,插入或刪除時通常只需改動少量連接,不必像陣列那樣把後面所有資料搬位。
圖像化 + 可操作示範:由 start 出發,沿路線前進
請按正確次序點擊索引:先找出 start 指向的站點,再沿 NP[current] 前進,直到 NULL。
start 開始。)可操作示範:修改校園導賞路線(以 D 及 NP 顯示)
你可以把新站點插入為第 n 個節點,或刪除第 n 個節點。
請留意:n 代表路線中的次序;i 只代表新節點存放在表內哪一個空位索引,兩者不一定相同。
每次操作後,下方都會列出哪些索引被更新,並顯示目前路線,方便你觀察「重新接駁」究竟發生了甚麼。
| 索引 | 內容 D[i] | 指示標 NP[i] |
|---|---|---|
| 1 | 圖書館 | 2 |
| 2 | 電腦室 | NULL |
| 3 | 禮堂 | 1 |
| 4 | (空) | — |
| 5 | 校門 | 3 |
| 6 | (空) | — |
| 7 | (空) | — |
| 8 | (空) | — |
D 與 NP;即使有資料,也不一定仍屬於目前路線,要看能否由 start 走到。start 出發)加入新節點
先輸入新站點名稱,再決定它要成為第 n 個節點,最後選擇新節點要存放在哪一個空位索引 i。insert(n, name, i) 的意思是:把 name 放入索引 i,再把這個新節點接駁成路線中的第 n 個節點;i 只是儲存位置,不表示它在路線中排第 i 位。
刪除節點
delete(n) 的意思是刪除路線中的第 n 個節點。這裏的「刪除」主要是改 start 或前一個節點的 NP,令路線跳過該節點;該索引原本的 D 和 NP 可以保留在表內,不一定要清空。
- (尚未操作)
start 開始,把每個節點內容分別填入下方方格。獨立小練習:看表格寫出目前次序
下方會隨機生成一個新的線性鏈表例子。表內會故意保留一兩個仍有資料、但已不屬於目前鏈表的節點;它們像刪除後留下的舊資料,不應寫進答案。
start 指向哪一個索引,再沿 NP 一直前進,直到 NULL。例子示範:以校園導賞路線理解偽代碼與 Python
以下例子全部以 start、D、NP 為主,讓你集中理解節點如何連接、更新與走訪。
start 逐個輸出D = ["", "圖書館", "電腦室", "禮堂", "", "校門", ""]
NP = [-1, 2, -1, 1, -1, 3, -1]
start = 5
current = start
out = []
while current != -1:
out.append(D[current])
current = NP[current]
print(" -> ".join(out))
n 個節點;通常只改連接,舊資料可留在表內def delete_n(D, NP, start, n):
if n == 1:
return NP[start]
prev = start
current = NP[start]
for _ in range(2, n):
prev = current
current = NP[current]
NP[prev] = NP[current]
return start
子程式 printList()
current ← start
當 current ≠ NULL
輸出 D[current]
current ← NP[current]
子程式 delete(n)
如果 n = 1 則
start ← NP[start]
否則
prev ← start
current ← NP[start]
設 k 由 2 至 n-1
prev ← current
current ← NP[current]
NP[prev] ← NP[current]
子程式 insert(n, name, i)
D[i] ← name
如果 n = 1 則
NP[i] ← start
start ← i
否則
prev ← start
current ← NP[start]
設 k 由 2 至 n-1
prev ← current
current ← NP[current]
NP[i] ← current
NP[prev] ← i
子程式 length()
count ← 0
current ← start
當 current ≠ NULL
count ← count + 1
current ← NP[current]
傳回 count
參考代碼:線性鏈表(完整版,可展開)
這個版本一次過展示初始化、取得新節點位置、在尾部插入、刪除指定位置,以及分別顯示目前鏈表和整個 array。這段程式採用 head、data、next_ptr 的命名,而且不回收已刪除節點,因此某些舊資料仍可能留在 array 內,只是已不再由 head 到達。
上面的互動示範為了讓變化更易看清,會把被移除的格顯示成空位;這裡則讓你看到另一個完整實作版本。
# ==========================================
# 最大可用節點數量
MAX_SIZE = 10
# data[i] 儲存第 i 個節點的資料
data = [None] * MAX_SIZE
# next_ptr[i] 儲存第 i 個節點下一個節點的索引
next_ptr = [-1] * MAX_SIZE
# head 儲存 linked list 第一個節點的位置
# 若 head = -1,代表 linked list 為空
head = -1
# free_ptr 只是記錄下一個未曾使用的空位
# 注意:這裡不會回收已刪除節點,所以 free_ptr 只會一路增加
free_ptr = 0
def initialize():
"""
初始化 linked list
一開始 linked list 為空,所以 head = -1
下一個可用新位置由 0 開始,所以 free_ptr = 0
"""
global head, free_ptr
head = -1
free_ptr = 0
# 初始時將所有資料清空
for i in range(MAX_SIZE):
data[i] = None
next_ptr[i] = -1
def get_node():
"""
取得一個新節點位置
由於本版本不做空位回收,
所以每次只使用下一個未用過的位置。
如果 free_ptr >= MAX_SIZE,代表已沒有新空位。
"""
global free_ptr
if free_ptr >= MAX_SIZE:
return -1
p = free_ptr
free_ptr += 1
return p
def is_empty():
"""
檢查 linked list 是否為空
"""
return head == -1
def insert_end(value):
"""
在 linked list 尾部加入一個新節點
步驟:
1. 取一個新空位
2. 將 value 存入該節點
3. 若 linked list 原本為空,head 直接指向它
4. 否則由 head 開始走到尾,再接上新節點
"""
global head
new_ptr = get_node()
if new_ptr == -1:
print("No more new space.")
return
data[new_ptr] = value
next_ptr[new_ptr] = -1
# 若原本為空表
if head == -1:
head = new_ptr
else:
current = head
# 找最後一個節點
while next_ptr[current] != -1:
current = next_ptr[current]
# 尾節點接去新節點
next_ptr[current] = new_ptr
def display_list():
"""
顯示 linked list 內容
注意:
只會由 head 開始沿 next_ptr 顯示真正屬於 linked list 的節點。
即使陣列內有舊資料、已刪除節點,都不會顯示出來。
"""
current = head
if current == -1:
print("Linked list is empty.")
return
print("Linked list:")
while current != -1:
print(f"[{current}] Data = {data[current]}, Next = {next_ptr[current]}")
current = next_ptr[current]
def display_array():
"""
顯示整個 array 狀態
用來觀察:即使節點從 linked list 中刪除,
它原本在 array 裡的資料仍可能保留。
"""
print("Whole array:")
for i in range(MAX_SIZE):
print(f"[{i}] Data = {data[i]}, Next = {next_ptr[i]}")
def delete_at_position(position):
"""
刪除 linked list 中第 position 個節點
position 由 1 開始計
本版本不做空位回收,
所以 delete 的意思只是:
將該節點從 linked list 中移除,
即修改前一個節點的 next pointer,跳過它。
被刪除節點原本在 array 中的 data 和 next_ptr 可以保留不變,
因為它已不再由 head 可到達。
"""
global head
# 空表不能刪除
if head == -1:
print("Linked list is empty.")
return
# 位置必須大於 0
if position <= 0:
print("Invalid position.")
return
# -------------------------
# 刪除第一個節點
# -------------------------
if position == 1:
delete_ptr = head
head = next_ptr[head]
print("Deleted from list:", data[delete_ptr])
return
# -------------------------
# 刪除第二個或之後的節點
# -------------------------
# prev 用來找被刪除節點的前一個節點
prev = head
count = 1
# 由 head 開始沿 linked list 走到第 position-1 個節點
while prev != -1 and count < position - 1:
prev = next_ptr[prev]
count += 1
# 若 prev 不存在,或 prev 後面已沒有節點
# 代表 position 超出範圍
if prev == -1 or next_ptr[prev] == -1:
print("Position out of range.")
return
# delete_ptr 是要刪除的節點
delete_ptr = next_ptr[prev]
# 將 prev 直接連到 delete_ptr 的下一個節點
# 即跳過 delete_ptr
next_ptr[prev] = next_ptr[delete_ptr]
print("Deleted from list:", data[delete_ptr])
# ==========================================
# Example usage
# ==========================================
initialize()
insert_end("A")
insert_end("B")
insert_end("C")
insert_end("D")
print("Before delete:")
display_list()
print("\nDelete position 3:")
delete_at_position(3)
display_list()
print("\nWhole array after delete:")
display_array()
小遊戲:線性鏈表(start / D / NP)操作練習
系統會給你一條以陣列顯示的線性鏈表,以及一條操作指令。標準版會要求你先填出需要更新的 start 或 NP;
困難版則要直接把 start 和整張 D / NP 表改成操作後的結果,系統不會預先指出要改哪一格。做對後,再寫出更新後的節點次序,看看自己是否真的掌握「重新接駁」這個核心觀念。
start 或 NP。start 出發可到達的所有節點,可用來檢查目前路線。start 開始的次序。- (尚未作答)
start 沿 NP 逐個輸出整條路線)。展開卡片 後可直接在頁內 Run。start 開始計算站點數量)。展開卡片 後可直接在頁內 Run。n 個站點,重點是更新 start 或 NP[prev])。展開卡片 後可直接在頁內 Run。stops、nextStop 及 tourStart 印出整條路線)。展開卡片 後可直接在頁內 Run。請先閱讀上面的例子示範,再完成以下練習。以下填充題全部採用 start、D、NP 的表示方式,幫你練熟走訪、插入與刪除。
Python 小練習(填充題)
____ 補上,先按 Run 測試,再按 核對答案 檢查。
Check Point:小測(線性鏈表)
按「下一題」開始。
增潤部分預設收起;按此展開 Tree Traversal / Hash Table / Heap / Graph。延伸
本部分用較多動畫/互動去建立直覺:Tree Traversal(走訪次序)→ Hash(碰撞處理)→ Heap(優先隊列)→ Graph(BFS/DFS + visited)。 你不需要一次背完所有細節,但要能說得出「為甚麼要用它」以及「它比其他結構好在甚麼地方」。
七、Tree Traversal(樹的走訪)
增潤重點
-
走訪目的:用一個固定次序,把每個節點「visit」一次。
-
DFS 家族:前序(根→左→右)、中序(左→根→右)、後序(左→右→根)。
-
BFS 層序:逐層走訪,通常配合 queue。
Tree(樹):一種階層式結構,有根(root)、子節點(children)、葉(leaf)。
Traversal(走訪):按規則訪問每個節點一次,產生一個次序。
- DFS:沿一路深入再回溯(可用 stack 或遞歸)。
- BFS:按距離/層次逐層擴散(用 queue)。
檔案系統資料夾:DFS 像「一路開資料夾開到最深再返回上一層」;BFS 像「先掃完同一層資料夾,再到下一層」。
- 中序(BST):可得到排序結果。
- 前序:常用於「先處理根再處理子樹」的情景(例如複製樹)。
- 後序:常用於「先處理子樹再處理根」(例如刪樹)。
- 層序:適合找最短層數或逐層處理。
- 把前序/中序/後序次序記錯(尤其根的位置)。
- BFS 忘記用 queue 或忘記入隊子節點。
- 用遞歸時漏了 base case,導致無限遞歸。
概念講解:走訪次序的意義
走訪(traversal)其實是為了把「非線性」結構轉成一個「線性次序」。 一旦你得到次序,你就可以做很多線性處理:例如輸出、比較、搜尋。
DFS 三種(前序/中序/後序)最大分別在於「根」在不同時機被 visit。 你可以用一句記法:前序根先,中序根中,後序根後。
層序走訪(level-order)就是 BFS:用 queue 令「先入隊」的節點先被處理,形成逐層效果。 這個概念會延伸到圖(graph)和最短路徑問題。
圖像化:一步一步看走訪次序
例子示範:層序走訪(level-order)如何配合 queue
同一個概念可以用不同循環形式表達;考試寫偽代碼時,重點在於「先出隊再入隊」與「直到 queue 清空」的流程。
for loop
設 step 由 1至 N
若 Q 為空
離開循環
將 Q 的第一個節點出隊 → u
輸出 u
若 u 有左孩子
將 左孩子 入隊
若 u 有右孩子
將 右孩子 入隊
while loop
當 Q 不為空
將 Q 的第一個節點出隊 → u
輸出 u
若 u 有左孩子
將 左孩子 入隊
若 u 有右孩子
將 右孩子 入隊
do while
執行
將 Q 的第一個節點出隊 → u
輸出 u
若 u 有左孩子
將 左孩子 入隊
若 u 有右孩子
將 右孩子 入隊
當 Q 不為空
repeat until
重覆
將 Q 的第一個節點出隊 → u
輸出 u
若 u 有左孩子
將 左孩子 入隊
若 u 有右孩子
將 右孩子 入隊
直至 Q 為空
Python(for loop 版本:最多處理 N 次,queue 清空就 break)
val = ["A", "B", "C", "D", "E"]
left = [ 1, 3, -1, -1, -1]
right = [ 2, 4, -1, -1, -1]
root = 0
N = len(val)
q = [root]
head = 0
order = []
for _ in range(N):
if head == len(q): # queue 已空
break
u = q[head]
head += 1
order.append(val[u])
if left[u] != -1:
q.append(left[u])
if right[u] != -1:
q.append(right[u])
print(order)
Python(while loop 版本:最常見寫法)
val = ["A", "B", "C", "D", "E"]
left = [ 1, 3, -1, -1, -1]
right = [ 2, 4, -1, -1, -1]
root = 0
q = [root]
head = 0
order = []
while head < len(q):
u = q[head]
head += 1
order.append(val[u])
if left[u] != -1:
q.append(left[u])
if right[u] != -1:
q.append(right[u])
print(order)
小遊戲:Tree Traversal 次序挑戰(點擊節點)
選擇一種走訪(前序/中序/後序/層序),然後依次點擊節點。
做錯會提示你「下一個正確節點」。
重點:把走訪規則變成動作記憶——你會更容易分辨「根在前/中/後」。
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
Python 小練習(填充題)
____ 補上,先按 Run 測試,再按 核對答案 檢查。
Check Point:小測(Tree Traversal)
按「下一題」開始。
八、Hash Table(雜湊表)
增潤重點
-
key → index:用 hash function 把 key 映射到陣列索引位置。
-
collision 必然出現:要用 chaining / open addressing 處理。
-
負載因子:太滿會變慢;常用 rehash(擴容後重算)改善。
Hash Table:用陣列做底,配合 hash function,把 key 映射到某個 bucket/索引,用於快速查找 key→value。
- index = hash(key) mod m(m 是表大小)。
- collision:不同 key 可能映射到同一 index。
- chaining:每格存一條清單;open addressing:同一陣列內用 probing 找空位。
電話簿:用名字快速找電話號碼。Hash table 的目標是令「平均」查找接近 O(1)。
- Hash table vs BST:Hash table 平均快,但最壞情況可能退化;BST 可保持有序(可做 range query)。
- chaining vs open addressing:chaining 容易實作;open addressing 需要 probing 策略。
- 以為 hash table 永遠 O(1)(其實是平均)。
- collision 沒有處理就直接覆蓋資料。
- 表太滿但沒有 rehash,令效能下降。
概念講解:雜湊與碰撞處理
Hash table 的直覺是:不想每次都逐個掃描 key,所以先用 hash function 把 key 變成一個數字,再對表大小取模得到索引。 理想情況下,每個 key 都分佈得很均勻,就可以用很少步數找到。
但 collision 是一定會出現:因為索引格數有限,而 key 可能無限。 所以 hash table 的真正核心不是「hash」本身,而是「collision 如何處理」。 chaining 是最直觀:同一格存一條清單。
當表愈來愈滿,碰撞機會愈大,查找就愈慢。 所以實際系統會按負載因子擴容,再把所有 key 重新 hash(rehash)。
可操作示範:簡易 hashing(chaining)
例子示範:Linear Probing(開放定址)處理 collision
概念要點:先計算 index = key mod m;若位置已被佔用,就按固定規則(例如 +1)逐格探測(probe),直到找到空位。
for loop
設每個 key 依序由 keys 取出
start = key mod m
設 step 由 0至 m-1
idx = (start + step) mod m
若 table[idx] 為空
table[idx] = key
離開循環
while loop
設每個 key 依序由 keys 取出
idx = key mod m
當 table[idx] 不為空
idx = (idx + 1) mod m
table[idx] = key
do while
設每個 key 依序由 keys 取出
idx = key mod m
若 table[idx] 不為空
執行
idx = (idx + 1) mod m
當 table[idx] 不為空
table[idx] = key
repeat until
設每個 key 依序由 keys 取出
idx = key mod m
若 table[idx] 不為空
重覆
idx = (idx + 1) mod m
直至 table[idx] 為空
table[idx] = key
Python(for loop 版本:用 step 探測,最多試 m 次)
keys = [15, 11, 27, 8, 12]
m = 5
table = [None] * m
for key in keys:
start = key % m
for step in range(m):
idx = (start + step) % m
if table[idx] is None:
table[idx] = key
break
print(table)
Python(while loop 版本:不停 +1,直到遇到空位)
keys = [15, 11, 27, 8, 12]
m = 5
table = [None] * m
for key in keys:
idx = key % m
while table[idx] is not None:
idx = (idx + 1) % m
table[idx] = key
print(table)
小遊戲:Hash Table(Chaining)拖放練習
你會得到一些 key(整數)以及 bucket 數 m。請把每個 key 拖到正確 bucket:
bucket = key mod m。若同一 bucket 出現多個 key,代表碰撞(collision),會放在同一條鏈(chain)內。
| bucket | chain |
|---|
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
Python 小練習(填充題)
____ 補上,先按 Run 測試,再按 核對答案 檢查。
Check Point:小測(Hash Table)
按「下一題」開始。
九、Heap(堆)
增潤重點
-
Heap property:最小堆(min-heap)根最小;最大堆(max-heap)根最大。
-
完整二元樹:可以用陣列表示;parent/child 用索引計算。
-
插入/移除根:靠 sift up/down 維持性質,通常 O(log n)。
Heap:一種符合 heap property 的完整二元樹。常用作 priority queue。
- 用陣列表示:左孩子
2i+1,右孩子2i+2,父節點(i-1)//2。 - 插入:加到尾端再上濾(sift up)。
- 移除根:用尾端補根再下濾(sift down)。
醫院分流:先處理最高優先級病人(min-heap/max-heap 都可,視乎你如何定義優先級)。
- Heap vs Sorted Array:Sorted array 取最小快,但插入慢;heap 則在插入與取出最小值兩種操作之間取得平衡(O(log n))。
- Heap vs BST:heap 只保證根是極值,不保證全排序。
- 以為 heap 內部是完全排序(其實不是)。
- parent/child 索引公式寫錯(0-based / 1-based 混淆)。
- sift down 時沒有選擇「較小的孩子」(min-heap)。
概念講解:堆與優先隊列
Heap 最大優點是:可以快速取得「最小(或最大)」元素,同時插入新元素亦不會太慢。 因為 heap 是完整二元樹,所以高度約 log n,sift up/down 都只需沿樹高移動。
用陣列表示 heap 是一個非常重要的技巧:你不需要真的指示標節點,只需用索引計算父子關係。 例如 i 的左孩子是 2i+1,右孩子是 2i+2。
記住:heap 只保證「父比子更符合規則」(例如 min-heap 父 ≤ 子)。 它不是排序結構,所以中間元素不一定有序。
可操作示範:min-heap 插入/取出根
例子示範:Min-Heap 插入(sift up)
插入的兩步:① 把新元素放到陣列尾端;② 與 parent 比較,若更小就交換並向上移,直到回復 min-heap 性質。
for loop
將 x 放到 heap 尾端
i = 最後索引
設 step 由 1至 H
若 i = 0
離開循環
p = (i - 1) div 2
若 heap[i] < heap[p]
交換 heap[i], heap[p]
i = p
否則
離開循環
while loop
將 x 放到 heap 尾端
i = 最後索引
當 i > 0 且 heap[i] < heap[parent(i)]
交換 heap[i], heap[parent(i)]
i = parent(i)
do while
將 x 放到 heap 尾端
i = 最後索引
若 i > 0
執行
p = (i - 1) div 2
若 heap[i] < heap[p]
交換 heap[i], heap[p]
i = p
當 i > 0 且 heap[i] < heap[(i - 1) div 2]
repeat until
將 x 放到 heap 尾端
i = 最後索引
重覆
若 i = 0
離開循環
p = (i - 1) div 2
若 heap[i] < heap[p]
交換 heap[i], heap[p]
i = p
否則
離開循環
直至 i = 0
Python(for loop 版本:最多做 len(heap) 次上濾)
heap = [3, 5, 8, 10, 9]
x = 1
heap.append(x)
i = len(heap) - 1
for _ in range(len(heap)):
if i == 0:
break
p = (i - 1) // 2
if heap[i] < heap[p]:
heap[i], heap[p] = heap[p], heap[i]
i = p
else:
break
print(heap)
Python(while loop 版本:最常見寫法)
heap = [3, 5, 8, 10, 9]
x = 1
heap.append(x)
i = len(heap) - 1
while i > 0:
p = (i - 1) // 2
if heap[i] < heap[p]:
heap[i], heap[p] = heap[p], heap[i]
i = p
else:
break
print(heap)
小遊戲:Heap(最小堆)加入/取出拖放練習
你會按指令進行 insert(加入)及 extract-min(取出最小值)。
插入時請把指定新數據拖到「下一個空位」;取出時請把根(index 0)拖到回傳區。
系統會自動完成上濾/下濾(sift up/down),你重點觀察:最小堆的最小值永遠在根。
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
Python 小練習(填充題)
____ 補上,先按 Run 測試,再按 核對答案 檢查。
Check Point:小測(Heap)
按「下一題」開始。
十、Graph(圖)
增潤重點
-
節點 + 邊:可有向/無向,可加權/無權。
-
表示法:鄰接表(稀疏圖常用)或鄰接矩陣(查邊快但耗空間)。
-
遍歷:BFS 用 queue,DFS 用 stack/遞歸;必須記 visited 避免循環。
Graph(圖):由節點(vertices)與邊(edges)構成的關係結構。很多真實問題(社交網絡、路網)都是圖。
- BFS:逐層擴散,常用於無權圖最短路徑。
- DFS:一路深入再回溯,適合找連通性、拓撲相關概念。
- visited:避免在循環中無限循環。
地鐵站:站是節點,路線是邊。BFS 可以找「最少轉車/最少站數」路徑(無權時)。
- 鄰接表 vs 鄰接矩陣:鄰接表省空間;矩陣可 O(1) 查兩點是否相連。
- BFS vs DFS:BFS 更像波浪擴散;DFS 更像走迷宮。
- 忘記 visited,導致走訪重覆或無限循環。
- BFS/DFS push/pop/enqueue/dequeue 用錯結構(次序會變)。
- 把有向圖當無向圖(或相反)建 adjacency。
概念講解:BFS/DFS 的核心思路
Graph 可以比 tree 更自由:tree 無循環、有唯一父節點;graph 可以有循環、可以多條路。 所以做遍歷時,visited 幾乎是必備。
BFS / DFS 本質都是「從一個起點出發,不斷擴展下一批要處理的節點」; 分別只在於你用 queue(FIFO)或 stack(LIFO)去管理「下一批」。
表示法方面,鄰接表(adjacency list)用「每個節點的鄰居清單」去表示邊,對稀疏圖特別省空間。
圖像化:BFS / DFS 步進示範
例子示範:BFS(寬度優先搜索)與 visited
BFS 的核心流程:用 queue 逐層擴展;每次取出一個節點後,把未到訪的鄰居加入 queue,並立刻標記 visited(避免重覆與循環)。
for loop
Q ← [start]
visited[start] ← True
設 step 由 1至 N
若 Q 為空
離開循環
u ← 出隊(Q)
輸出 u
對於 u 的每個鄰居 v
若 visited[v] 為 False
visited[v] ← True
入隊(Q, v)
while loop
Q ← [start]
visited[start] ← True
當 Q 不為空
u ← 出隊(Q)
輸出 u
對於 u 的每個鄰居 v
若 visited[v] 為 False
visited[v] ← True
入隊(Q, v)
do while
Q ← [start]
visited[start] ← True
執行
u ← 出隊(Q)
輸出 u
對於 u 的每個鄰居 v
若 visited[v] 為 False
visited[v] ← True
入隊(Q, v)
當 Q 不為空
repeat until
Q ← [start]
visited[start] ← True
重覆
u ← 出隊(Q)
輸出 u
對於 u 的每個鄰居 v
若 visited[v] 為 False
visited[v] ← True
入隊(Q, v)
直至 Q 為空
Python(for loop 版本:最多處理 N 次,queue 清空就 break)
adj = {
1: [2, 3],
2: [1, 4],
3: [1, 4],
4: [2, 3]
}
start = 1
N = len(adj)
q = [start]
head = 0
visited = set([start])
order = []
for _ in range(N):
if head == len(q):
break
u = q[head]
head += 1
order.append(u)
for v in adj[u]:
if v not in visited:
visited.add(v)
q.append(v)
print(order)
Python(while loop 版本:最常見寫法)
adj = {
1: [2, 3],
2: [1, 4],
3: [1, 4],
4: [2, 3]
}
start = 1
q = [start]
head = 0
visited = set([start])
order = []
while head < len(q):
u = q[head]
head += 1
order.append(u)
for v in adj[u]:
if v not in visited:
visited.add(v)
q.append(v)
print(order)
小遊戲:Graph 走訪次序挑戰(BFS / DFS)
選擇 BFS 或 DFS,然後依次點擊節點。系統會根據「從起點出發」的走訪規則計算正確次序。 做錯會提示下一個正確節點。
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
Python 小練習(填充題)
____ 補上,先按 Run 測試,再按 核對答案 檢查。
Check Point:小測(Graph)
按「下一題」開始。
十一、小結與下一步
- 掌握 DSE 核心:陣列 Array/堆疊 Stack/隊列 Queue/循環隊列 Circular Queue/線性鏈表 Linked List(重點是「操作成本」與「適用情景」)。
- 增潤部分先建立直覺:Traversal(DFS/BFS)、Hash(collision)、Heap(priority queue)、Graph(visited)。
- 做練習時,先寫到「可運行」,再追求「寫得漂亮/快」。
自我檢查清單
- 我能否說明:為甚麼 array 中間插入要移位?
- 我能否用一句話分辨 stack / queue / circular queue?
- 我能否畫出 linked list 插入/刪除時 next 如何修改?
- 我能否寫出 BFS/DFS 並記 visited?
課文腦圖總結
概念重整重點
- 先由需求選結構:辨認最常做的存取、插入、刪除、搜尋、遍歷或優先處理,再比較操作成本。
- 分清規則與實作:堆疊、隊列等抽象數據類型規定取出次序;陣列、鏈表或 deque 則是底層表示法。
- 追蹤狀態不變量:索引範圍、head/tail/size、start/NP、heap property 及 visited 都必須在每次操作後仍然正確。
- 把結構互相連結:隊列支援寬度優先搜索,堆疊支援深度優先搜索,鏈表可處理雜湊碰撞,陣列可表示堆及鄰接矩陣。
- 以測試驗證邊界:空、單一元素、已滿、找不到、碰撞、循環及不連通情況,都是最容易揭示錯誤的測試。
數據結構規定數據如何排列及可用哪些操作。同一批數據使用不同結構,讀取、插入、刪除、搜尋及遍歷的步數可以完全不同。
先找題目最常執行的操作,再選能以較低成本完成的結構。實作時要維持結構不變量,例如循環隊列的 0 ≤ size ≤ n、鏈表由 start 可到達的節點,以及最小堆的父值不大於子值。
在無權圖找最少邊數路徑時,圖保存關係、鄰接表保存鄰居、隊列控制寬度優先搜索次序,而集合保存 visited。一個完整算法往往同時使用多個結構。
陣列按索引讀取通常為 O(1),但中間插入要移位;線性鏈表取得第 k 個節點通常為 O(n),但已知前一節點後,插入或刪除只需改少量指示標。
常見失誤包括混淆 LIFO 與 FIFO、先移動指示位置才讀寫、鏈表更新次序令後半段遺失、BFS/DFS 忘記 visited,以及把平均 O(1) 當作雜湊表的絕對保證。
使用腦圖整理本章概念:先由「問題需求與操作成本」開始,再比較按索引定位、端點限制、循環緩衝、指示標連接、階層、鍵值索引及關係網絡等不同儲存模型。
右上角的「樹狀圖導覽」會以文字顯示完整層級;點擊任何項目,畫布會以動畫移動及縮放到對應節點。開啟「聚焦模式」後,直接點擊畫布節點亦會自動移至合適大小,方便閱讀程式碼、公式、比較及常見錯誤。
腦圖亦整合 20 條偽代碼轉 Python 綜合挑戰。學生可在瀏覽器本機新增、刪除、編輯或重新接駁節點,並以 .ictmap 匯入及匯出;這些修改不會寫回網站原本的 JSON 檔案。