B2.4 程式演算法
程式演算法(Programming algorithms)介紹最常用的搜尋與排序演算法,以及如何用大 O 表示法(Big O notation)比較演算法效率。本頁涵蓋線性搜尋、二元搜尋、氣泡排序、選擇排序,HL 再加上遞迴、合併排序與快速排序。
如何比較演算法效率?
大 O 表示法 描述資料量 n 變大時,演算法執行步驟數的成長趨勢(通常指最差情況):
| 大 O | 名稱 | 例子 | n = 1000 時約幾步 |
|---|---|---|---|
| O(1) | 常數 | 用索引存取陣列元素 | 1 |
| O(log n) | 對數 | 二元搜尋 | 10 |
| O(n) | 線性 | 線性搜尋 | 1,000 |
| O(n log n) | 線性對數 | 合併排序 | 10,000 |
| O(n²) | 平方 | 氣泡排序、選擇排序 | 1,000,000 |
除了時間複雜度,也要考慮 空間複雜度:演算法需要多少額外記憶體。
線性搜尋和二元搜尋怎麼選?
線性搜尋(linear search):從頭到尾逐一比對。資料不必排序,時間 O(n)。
二元搜尋(binary search):資料 必須已排序。每次比對中間元素,排除一半資料,時間 O(log n)。
def linear_search(items, target):
for i in range(len(items)):
if items[i] == target:
return i
return -1
def binary_search(items, target):
low, high = 0, len(items) - 1
while low <= high:
mid = (low + high) // 2
if items[mid] == target:
return mid
elif items[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
氣泡排序與選擇排序
氣泡排序(bubble sort):重複比較相鄰兩個元素,順序錯就交換。每一輪會把目前最大值「浮」到尾端。若某一輪完全沒有交換,代表已排好,可提早結束。
選擇排序(selection sort):每一輪找出未排序部分的最小值,和未排序部分的第一個元素交換。
def bubble_sort(a):
n = len(a)
for i in range(n - 1):
swapped = False
for j in range(n - 1 - i):
if a[j] > a[j + 1]:
a[j], a[j + 1] = a[j + 1], a[j]
swapped = True
if not swapped:
break
return a
def selection_sort(a):
n = len(a)
for i in range(n - 1):
min_index = i
for j in range(i + 1, n):
if a[j] < a[min_index]:
min_index = j
a[i], a[min_index] = a[min_index], a[i]
return a
| 演算法 | 最差時間 | 最佳時間 | 特色 |
|---|---|---|---|
| 氣泡排序 | O(n²) | O(n)(已排序且有提早結束) | 簡單;交換次數多 |
| 選擇排序 | O(n²) | O(n²) | 交換次數最少(最多 n − 1 次) |
遞迴
HL 延伸:本節僅 HL 需要
SL 學生可略過遞迴、合併排序與快速排序。
遞迴(recursion) 是函式呼叫自己來解決較小的同類問題。每個遞迴函式必須有:
- 基本情況(base case):不再遞迴、直接回傳的條件,否則會無限遞迴導致 堆疊溢位(stack overflow)。
- 遞迴情況(recursive case):把問題縮小後呼叫自己。
def factorial(n):
if n <= 1: # 基本情況
return 1
return n * factorial(n - 1) # 遞迴情況
# factorial(4) = 4 * factorial(3)
# = 4 * 3 * factorial(2)
# = 4 * 3 * 2 * factorial(1)
# = 24
每次呼叫都會在 呼叫堆疊(call stack) 上新增一層,因此遞迴比迴圈耗記憶體;但對樹狀結構、分治法等問題,遞迴的程式碼更簡潔。
合併排序與快速排序
兩者都是 分治法(divide and conquer):
- 合併排序(merge sort):把陣列不斷對半切到只剩 1 個元素,再兩兩合併成已排序的陣列。時間永遠 O(n log n),但需要額外 O(n) 記憶體。
- 快速排序(quicksort):選一個基準值(pivot),把小於它的放左邊、大於它的放右邊,再分別遞迴排序兩邊。平均 O(n log n),最差(pivot 每次都選到最大或最小)O(n²),通常不需要額外陣列。
def merge_sort(a):
if len(a) <= 1:
return a
mid = len(a) // 2
left = merge_sort(a[:mid])
right = merge_sort(a[mid:])
result, i, j = [], 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i]); i += 1
else:
result.append(right[j]); j += 1
return result + left[i:] + right[j:]
推薦影音
大 O 表示法
簡述:HackerRank 用實例說明如何從程式碼推算時間複雜度,以及為什麼可以省略常數與低次項,對應本頁效率比較一節。
二元搜尋
簡述:說明二元搜尋的運作方式,並示範迴圈與遞迴兩種寫法,特別提醒邊界條件的處理。
氣泡排序
簡述:用圖解與程式碼說明氣泡排序,並介紹「沒有交換就提早結束」的最佳化。
遞迴
簡述:HackerRank 說明遞迴的基本情況與遞迴情況,以及如何避免重複計算,適合 HL 學生入門遞迴。
合併排序
簡述:逐步示範合併排序的切分與合併過程,並分析為什麼是 O(n log n)。
快速排序
簡述:說明 pivot 的選擇與分割(partition)過程,並比較快速排序與合併排序的優缺點。
練習題
練習 1:二元搜尋追蹤
在 [3, 8, 12, 17, 25, 31, 40] 中用二元搜尋找 31,寫出每一步的 low、high、mid。
參考答案
| 步驟 | low | high | mid | items[mid] | 動作 |
|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 17 | 17 < 31,low = 4 |
| 2 | 4 | 6 | 5 | 31 | 找到,回傳 5 |
練習 2:氣泡排序一輪
對 [5, 1, 4, 2, 8] 執行一輪氣泡排序,結果是什麼?
參考答案
比較 5,1 → 交換 [1,5,4,2,8];5,4 → 交換 [1,4,5,2,8];5,2 → 交換 [1,4,2,5,8];5,8 → 不換。結果 [1, 4, 2, 5, 8]。
練習 3:選擇搜尋法
一份 100 萬筆、未排序的資料只需要搜尋一次,應該先排序再二元搜尋,還是直接線性搜尋?
參考答案
直接線性搜尋。排序至少需要 O(n log n),比一次線性搜尋的 O(n) 還慢。只有需要搜尋很多次時,先排序再用二元搜尋才划算。
練習 4:寫出演算法
寫一個函式,回傳串列中第二大的數(假設至少有兩個不同的數)。
HL 練習 1:遞迴
寫一個遞迴函式 power(base, exp) 計算 base 的 exp 次方(exp ≥ 0),並指出基本情況。
HL 練習 2:比較排序演算法
一台記憶體非常有限的嵌入式裝置要排序資料,應選合併排序還是快速排序?為什麼?
參考答案
快速排序。它可以在原陣列上就地(in-place)分割,不需要合併排序那樣 O(n) 的額外陣列。若擔心最差情況 O(n²),可以用隨機或三數取中選 pivot。