Array(陣列):用一段連續(或概念上連續)的索引位置,儲存一組同類型資料。每個元素都有一個索引(index)。
2D Array(二維陣列):可以當作「陣列入面再放陣列」,用兩個索引(row, col)定位。
目標:以圖像化 + 可操作示範 + 小練習,鞏固 DSE 常見數據結構(陣列 Array/堆疊 Stack/隊列 Queue/循環隊列 Circular Queue/線性鏈表 Linked List),並以 Tree / Hash / Heap / Graph 作增潤。
數據結構(Data Structure)是用來組織與儲存資料的一套方法, 目的是令「存取、插入、刪除、搜尋、排序、走訪」等操作更有效率、更易管理。 簡單來說:同一批資料,放法不同,做同一件事所需步數亦會不同。
在 DSE 常見題目中,失分位通常不是語法,而是「用錯結構」或「更新指標次序錯」。
| 類型 | 代表結構 | 直覺 |
|---|---|---|
| 線性 | 陣列、堆疊、隊列、線性鏈表 | 像一條隊伍:有先後次序 |
| 階層 | 樹(Tree) | 像家族/資料夾:一層層分支 |
| 關係網絡 | 圖(Graph) | 像地圖:節點之間可多方向相連 |
| 索引查找 | 雜湊表(Hash Table) | 像字典:用 key 直接查 value |
| 優先次序 | 堆(Heap) | 像候診:先處理「最急」或「最高分」 |
沒有一種結構能在所有操作都最快。你需要根據題目重點,選擇最合適的取捨。 例如:陣列索引讀取快,但中間插入要移位;鏈表插入(定位後)快,但要走訪才能找到位置。
| 你最常做的操作 | 可能較合適的結構 |
|---|---|
| 經常按索引讀取第 k 個 | 陣列(Array) |
| 經常做「最近加入」的回溯 | 堆疊(Stack) |
| 經常按到達次序排隊處理 | 隊列(Queue)/循環隊列 |
| 經常在中間插入/刪除(而且已找到位置) | 鏈表(Linked List) |
以下是 DSE 常見 4 種循環偽代碼寫法(格式重點:關鍵字用設/當/執行/重覆/直至)。 你可以把它當作「模板」,之後每個例子示範我都會使用同一套格式。
設 i 由 1至 9
...
當 x > 5
...
執行
...
當 x > 5
重覆
...
直至 x > 5
以下課題(陣列 Array、堆疊 Stack、隊列 Queue、循環隊列 Circular Queue、線性鏈表 Linked List)是 DSE 常見的基本數據結構。
建議先掌握「規則」(LIFO/FIFO)與「操作成本」(O(1)/O(n)),再做題目整合。
本頁每個課題都附有可執行的 Python 小練習(放在例子示範之後),請邊看邊做。
arr[i],概念上視作 O(1)。arr[row][col];row/col 轉錯會取錯格。row * 欄數 + col(逐列掃描)。
Array(陣列):用一段連續(或概念上連續)的索引位置,儲存一組同類型資料。每個元素都有一個索引(index)。
2D Array(二維陣列):可以當作「陣列入面再放陣列」,用兩個索引(row, col)定位。
成績表:score[i] 代表第 i 位同學分數;或者用 2D:score[row][col] 代表「第 row 位同學、第 col 科」分數。
若要把新同學插入第 0 位,所有人要向後移一格,這個就是移位。
arr[row][col] 寫成 arr[col][row](row/col 顛倒)。
陣列最核心的概念是「位置(index)」。當你寫 arr[i],其實你是要求系統去第 i 個位置取數據。
因為位置是已知,所以不需要逐個比較去搵,讀取通常快。
但陣列要維持「次序」:例如你想將一個新元素插入到中間,為了騰出空位,後面元素就要向後搬。 所以即使讀取快,插入/刪除位置錯了都可能好慢。
二維陣列可以視為「表格」。定位一格需要兩個索引:先列(row)後欄(col)。 如果你用 row-major 角度看,掃描表格其實是逐列掃過去,這個理解對之後 heap / graph 的陣列表示很有幫助。
| 操作 | Array(概念) | 常見原因 |
|---|---|---|
| 讀取 arr[i] | 快(≈ O(1)) | 索引直接定位 |
| 在尾端 append | 通常快 | 不使搬移原有元素 |
| 在中間插入/刪除 | 較慢(≈ O(n)) | 要移位保持次序 |
| 2D 讀取 arr[row][col] | 直觀定位 | 先列後欄;row/col 易出錯 |
點擊任何一格後,系統會顯示 (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… 的元素,必須整體向右移一格,才能騰出空位; 第二,移位的步數與「受影響的元素數量」成正比,所以插入在越前的位置,搬移越多,時間成本越高。
把它想像成一排座位:你在中間插入一位新同學,後面所有同學都要向後移一個座位。
例子:在陣列中找第一個等於 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
系統會隨機產生一個陣列與目標值 target。請由 index = 0 開始,依次把「指針」拖到下一格進行檢查;
當找到 target 後,系統會產生「回傳索引」代幣,請把它拖到回傳框。
重點:線性檢索必須按次序逐格檢查,不能跳格。
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
____ 補上,先按 Run 測試,再按 核對答案 檢查。
按「下一題」開始。
Stack(堆疊):一種只允許在同一端(頂部)加入與移除的線性結構。
append / pop() 都十分自然。括號配對:讀到 ( 就 push;讀到 ) 就 pop;中途 pop 不到或最後仲有剩,就不平衡。
)( 數量一樣但不平衡)。Stack 的特別之處不是它「功能多寡」,而是它限制了你如何存取資料:只准由頂部進出。 這個限制反而令很多演算法更清晰,例如「處理到一半想回到上一層」就直接 pop。
以括號配對為例:每遇到一個左括號,就等於開了一個「未完成的工作」;當遇到右括號,就要把最近未完成那個工作關閉。 由於要處理「最近」那個,LIFO 就非常適合。
實作上,你可以用 Python list 尾端做 stack:append 當 push,pop() 當 pop。
但記住:空 stack pop 會出錯,所以要先檢查長度。
| 操作 | 意思 | 常見寫法(Python list) |
|---|---|---|
| push | 加入頂部 | stack.append(x) |
| pop | 移除並取出頂部 | stack.pop()(先檢查非空) |
| peek | 查看頂部但不移除 | stack[-1](先檢查非空) |
例子:括號配對(只處理 '(' 與 ')')。
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
系統會提供一個初始堆疊、一些「新數據」以及一串指令(push/pop)。
請按指令次序完成操作:
・push:把指定的新數據拖到堆疊區(會放在頂部)。
・pop:把頂部元素拖到「回傳區」。
做錯時會提示「正確下一步」。
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
____ 補上,先按 Run 測試,再按 核對答案 檢查。
按「下一題」開始。
pop(0) 會慢;可用 head 指針/循環隊列思路。collections.deque 做 O(1) 的入隊/出隊(概念更貼近真・隊列)。
隊列(Queue):一種只允許在尾端加入(enqueue),在頭端移除(dequeue)的線性結構。
pop(0)。排隊買票:新來的人排隊尾;窗口每次服務隊首。
BFS:先發現的節點先處理,逐層向外擴散。
pop(0)(會慢)。
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)。
# 假設隊列儲存在 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)
提示:在「指標式隊列」中,dequeue 後不必把元素從陣列刪走;只要 head 往後移一格即可。 你會看到舊值仍留在格子內,但已不再屬於隊列。
例子:處理隊列中所有任務(直到 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
你會得到一個初始隊列與一串指令(enqueue/dequeue)。
請按次序完成:enqueue 時把指定新數據拖到隊尾;dequeue 時把隊首元素拖到回傳區。
重點:隊列是 FIFO(先進先出),只能從隊首取出。
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
____ 補上,先按 Run 測試,再按 核對答案 檢查。
按「下一題」開始。
head 指向下一個出隊位置;tail 指向下一個入隊位置。(i+1) mod n 表示。size 記錄元素數;或用「下一格是否等於 head」判斷滿。
循環隊列(Circular Queue)是一種以固定容量陣列實作的隊列。
當指標走到陣列尾端,會回到 0,形成「環」。
i = (i + 1) mod n,令指標循環。size 判斷空(0)與滿(n);或用「下一個 tail 是否等於 head」判斷滿。列印緩衝(buffer):印表機以固定大小緩衝接收工作,先入先出;完成一個就釋放一格,下一個工作可重用該格。
排隊叫號:叫完號的人離開(dequeue),後面的人進來(enqueue),位置可以重複使用。
mod n,指標越界。
在「隊列」的題目中,我們希望維持 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。
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)
# 假設 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)
設每個 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
當 size > 0
x = buffer[head]
buffer[head] = 空
head = (head + 1) mod n
size = size - 1
輸出 x
執行
x = buffer[head]
buffer[head] = 空
head = (head + 1) mod n
size = size - 1
輸出 x
當 size > 0
重覆
x = buffer[head]
buffer[head] = 空
head = (head + 1) mod n
size = size - 1
輸出 x
直至 size == 0
此小遊戲採用「覆蓋模式」:當循環隊列已滿仍然 enqueue,新元素會覆蓋最舊元素(同時把 head 推進一格)。
請按指令次序完成操作:
・enqueue:把指定新數據拖到tail 指向的格。
・dequeue:把head 指向的格拖到回傳區。
(tail+1) mod n 的循環寫法)。展開卡片 後可直接在頁內 Run。請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
____ 補上,先按 Run 測試,再按 核對答案 檢查。
按「下一題」開始。
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。
k 個節點時,通常要由 start 開始逐個沿指示標前進。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。以下例子全部以 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 的表示方式,幫你練熟走訪、插入與刪除。
____ 補上,先按 Run 測試,再按 核對答案 檢查。
按「下一題」開始。
本部分用較多動畫/互動去建立直覺:Tree Traversal(走訪次序)→ Hash(碰撞處理)→ Heap(優先隊列)→ Graph(BFS/DFS + visited)。 你不需要一次背完所有細節,但要能說得出「為甚麼要用它」以及「它比其他結構好在甚麼地方」。
Tree(樹):一種階層式結構,有根(root)、子節點(children)、葉(leaf)。
Traversal(走訪):按規則訪問每個節點一次,產生一個次序。
檔案系統資料夾:DFS 像「一路開資料夾開到最深再返回上一層」;BFS 像「先掃完同一層資料夾,再到下一層」。
走訪(traversal)其實是為了把「非線性」結構轉成一個「線性次序」。 一旦你得到次序,你就可以做很多線性處理:例如輸出、比較、搜尋。
DFS 三種(前序/中序/後序)最大分別在於「根」在不同時機被 visit。 你可以用一句記法:前序根先,中序根中,後序根後。
層序走訪(level-order)就是 BFS:用 queue 令「先入隊」的節點先被處理,形成逐層效果。 這個概念會延伸到圖(graph)和最短路徑問題。
同一個概念可以用不同迴圈形式表達;考試寫偽代碼時,重點在於「先出隊再入隊」與「直到 queue 清空」的流程。
設 step 由 1至 N
若 Q 為空
離開循環
將 Q 的第一個節點出隊 → u
輸出 u
若 u 有左孩子
將 左孩子 入隊
若 u 有右孩子
將 右孩子 入隊
當 Q 不為空
將 Q 的第一個節點出隊 → u
輸出 u
若 u 有左孩子
將 左孩子 入隊
若 u 有右孩子
將 右孩子 入隊
執行
將 Q 的第一個節點出隊 → u
輸出 u
若 u 有左孩子
將 左孩子 入隊
若 u 有右孩子
將 右孩子 入隊
當 Q 不為空
重覆
將 Q 的第一個節點出隊 → u
輸出 u
若 u 有左孩子
將 左孩子 入隊
若 u 有右孩子
將 右孩子 入隊
直至 Q 為空
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)
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)
選擇一種走訪(前序/中序/後序/層序),然後依次點擊節點。
做錯會提示你「下一個正確節點」。
重點:把走訪規則變成動作記憶——你會更容易分辨「根在前/中/後」。
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
____ 補上,先按 Run 測試,再按 核對答案 檢查。
按「下一題」開始。
Hash Table:用陣列做底,配合 hash function,把 key 映射到某個 bucket/索引,用於快速查找 key→value。
電話簿:用名字快速找電話號碼。Hash table 的目標是令「平均」查找接近 O(1)。
Hash table 的直覺是:不想每次都逐個掃描 key,所以先用 hash function 把 key 變成一個數字,再對表大小取模得到索引。 理想情況下,每個 key 都分散得好平均,就可以用很少步數找到。
但 collision 是一定會出現:因為索引格數有限,而 key 可能無限。 所以 hash table 的真正核心不是「hash」本身,而是「collision 如何處理」。 chaining 是最直觀:同一格存一條清單。
當表愈來愈滿,碰撞機會愈大,查找就愈慢。 所以實際系統會按負載因子擴容,再把所有 key 重新 hash(rehash)。
概念要點:先計算 index = key mod m;若位置已被佔用,就按固定規則(例如 +1)逐格探測(probe),直到找到空位。
設每個 key 依序由 keys 取出
start = key mod m
設 step 由 0至 m-1
idx = (start + step) mod m
若 table[idx] 為空
table[idx] = key
離開循環
設每個 key 依序由 keys 取出
idx = key mod m
當 table[idx] 不為空
idx = (idx + 1) mod m
table[idx] = key
設每個 key 依序由 keys 取出
idx = key mod m
若 table[idx] 不為空
執行
idx = (idx + 1) mod m
當 table[idx] 不為空
table[idx] = key
設每個 key 依序由 keys 取出
idx = key mod m
若 table[idx] 不為空
重覆
idx = (idx + 1) mod m
直至 table[idx] 為空
table[idx] = key
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)
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)
你會得到一些 key(整數)以及 bucket 數 m。請把每個 key 拖到正確 bucket:
bucket = key mod m。若同一 bucket 出現多個 key,代表碰撞(collision),會放在同一條鏈(chain)內。
| bucket | chain |
|---|
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
____ 補上,先按 Run 測試,再按 核對答案 檢查。
按「下一題」開始。
Heap:一種符合 heap property 的完整二元樹。常用作 priority queue。
2i+1,右孩子 2i+2,父節點 (i-1)//2。醫院分流:先處理最高優先級病人(min-heap/max-heap 都可,視乎你如何定義優先級)。
Heap 最大優點是:可以快速取得「最小(或最大)」元素,同時插入新元素亦不會太慢。 因為 heap 是完整二元樹,所以高度約 log n,sift up/down 都只需沿樹高移動。
用陣列表示 heap 是一個非常重要的技巧:你不需要真的指針節點,只需用索引計算父子關係。 例如 i 的左孩子是 2i+1,右孩子是 2i+2。
記住:heap 只保證「父比子更符合規則」(例如 min-heap 父 ≤ 子)。 它不是排序結構,所以中間元素不一定有序。
插入的兩步:① 把新元素放到陣列尾端;② 與 parent 比較,若更小就交換並向上移,直到回復 min-heap 性質。
將 x 放到 heap 尾端
i = 最後索引
設 step 由 1至 H
若 i = 0
離開循環
p = (i - 1) div 2
若 heap[i] < heap[p]
交換 heap[i], heap[p]
i = p
否則
離開循環
將 x 放到 heap 尾端
i = 最後索引
當 i > 0 且 heap[i] < heap[parent(i)]
交換 heap[i], heap[parent(i)]
i = parent(i)
將 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]
將 x 放到 heap 尾端
i = 最後索引
重覆
若 i = 0
離開循環
p = (i - 1) div 2
若 heap[i] < heap[p]
交換 heap[i], heap[p]
i = p
否則
離開循環
直至 i = 0
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)
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)
你會按指令進行 insert(加入)及 extract-min(取出最小值)。
插入時請把指定新數據拖到「下一個空位」;取出時請把根(index 0)拖到回傳區。
系統會自動完成上濾/下濾(sift up/down),你重點觀察:最小堆的最小值永遠在根。
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
____ 補上,先按 Run 測試,再按 核對答案 檢查。
按「下一題」開始。
Graph(圖):由節點(vertices)與邊(edges)構成的關係結構。很多真實問題(社交網絡、路網)都是圖。
地鐵站:站是節點,路線是邊。BFS 可以找「最少轉車/最少站數」路徑(無權時)。
Graph 可以比 tree 更自由:tree 無循環、有唯一父節點;graph 可以有循環、可以多條路。 所以做遍歷時,visited 幾乎是必備。
BFS / DFS 本質都是「從一個起點出發,不斷擴展下一批要處理的節點」; 分別只在於你用 queue(FIFO)或 stack(LIFO)去管理「下一批」。
表示法方面,鄰接表(adjacency list)用「每個節點的鄰居清單」去表示邊,對稀疏圖特別省空間。
BFS 的核心流程:用 queue 逐層擴展;每次取出一個節點後,把未到訪的鄰居加入 queue,並立刻標記 visited(避免重覆與循環)。
Q ← [start]
visited[start] ← True
設 step 由 1至 N
若 Q 為空
離開循環
u ← 出隊(Q)
輸出 u
對於 u 的每個鄰居 v
若 visited[v] 為 False
visited[v] ← True
入隊(Q, v)
Q ← [start]
visited[start] ← True
當 Q 不為空
u ← 出隊(Q)
輸出 u
對於 u 的每個鄰居 v
若 visited[v] 為 False
visited[v] ← True
入隊(Q, v)
Q ← [start]
visited[start] ← True
執行
u ← 出隊(Q)
輸出 u
對於 u 的每個鄰居 v
若 visited[v] 為 False
visited[v] ← True
入隊(Q, v)
當 Q 不為空
Q ← [start]
visited[start] ← True
重覆
u ← 出隊(Q)
輸出 u
對於 u 的每個鄰居 v
若 visited[v] 為 False
visited[v] ← True
入隊(Q, v)
直至 Q 為空
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)
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)
選擇 BFS 或 DFS,然後依次點擊節點。系統會根據「從起點出發」的走訪規則計算正確次序。 做錯會提示下一個正確節點。
請先閱讀上面的例子示範,再完成以下練習。練習按難度由淺入深,並對應本課題的重點。
____ 補上,先按 Run 測試,再按 核對答案 檢查。
按「下一題」開始。