在打鋪克牌時,你會怎麼整理?有人會先找出最小的那張放到最左邊,再從剩下的牌裡找最小的;有人會從左到右一張一張看,把每張牌插進左手邊已經排好的牌之間。這兩種直覺的做法,正好就是今天要介紹的選擇排序和插入排序。再加上讓大的數字像泡泡一樣往後浮的氣泡排序,就是最基本的三種排序方法。
**排序(sorting)**是把一群資料依照某個欄位,由小到大(遞增)或由大到小(遞減)重新排列。資料排好之後,很多事情都會變簡單:查資料時可以用二分搜尋,每比一次就排除一半,只要 O(log n);最大值、最小值就在頭尾;重複的資料也會排在一起,很容易找出來。
要比較排序方法的快慢,通常看兩件事:比較次數(兩筆資料比大小幾次)和搬移次數(資料換位置幾次)。以下 n 代表資料筆數,範例都是把 8 筆整數由小到大排序:
26 59 1 61 5 15 11 37
**選擇排序(selection sort)**的規則是:

每一輪的結果如下,| 左邊是已經排好的部分:
| 輪 | 最小值 | 交換 | 這一輪結束後 |
|---|---|---|---|
| 1 | 1 | 1 和 26 | 1 | 59 26 61 5 15 11 37 |
| 2 | 5 | 5 和 59 | 1 5 | 26 61 59 15 11 37 |
| 3 | 11 | 11 和 26 | 1 5 11 | 61 59 15 26 37 |
| 4 | 15 | 15 和 61 | 1 5 11 15 | 59 61 26 37 |
| 5 | 26 | 26 和 59 | 1 5 11 15 26 | 61 59 37 |
| 6 | 37 | 37 和 61 | 1 5 11 15 26 37 | 59 61 |
| 7 | 59 | 已在正確位置,不用移動 | 1 5 11 15 26 37 59 | 61 |
只剩最後一筆時,它一定是最大的,所以 8 筆資料只要 7 輪。
比較次數是固定的。 第 1 輪要在 8 筆中找最小值,比較 7 次;第 2 輪比較 6 次……最後一輪比較 1 次,總共 7 + 6 + … + 1 = 28 次。一般來說是 (n − 1) + (n − 2) + … + 1 = n(n − 1) / 2 次。不管資料原本是亂的還是已經排好,每一輪都得把剩下的資料看完,才能確定誰最小,所以時間一律是 O(n²)。
但選擇排序的優點是交換次數少:每輪最多交換一次,總共不超過 n − 1 次。如果搬動一筆資料的代價很高,例如每筆資料很大,這個特性就有用。
選擇排序不穩定,因為交換可能改變相同數字的先後順序。例如「紅 5、黑 5、2」,把 2 和紅 5 交換後,變成「2、黑 5、紅 5」,兩張 5 的順序就反過來了。
**插入排序(insertion sort)**就像一張一張摸牌:左手的牌永遠是排好的,每摸到一張新牌,就把它插進正確的位置。
以第 6 輪為例,前 6 筆已經排好,要插入的 key 是 11:

步驟 2 的「往右搬」,和在陣列中間插入資料時,把後面的資料往右移的做法相同:要從右往左搬,才不會蓋掉還沒搬的資料。完整的 7 輪如下:
| 輪 | key | 往右搬的資料 | 這一輪結束後 |
|---|---|---|---|
| 1 | 59 | 無 | 26 59 | 1 61 5 15 11 37 |
| 2 | 1 | 59、26 | 1 26 59 | 61 5 15 11 37 |
| 3 | 61 | 無 | 1 26 59 61 | 5 15 11 37 |
| 4 | 5 | 61、59、26 | 1 5 26 59 61 | 15 11 37 |
| 5 | 15 | 61、59、26 | 1 5 15 26 59 61 | 11 37 |
| 6 | 11 | 61、59、26、15 | 1 5 11 15 26 59 61 | 37 |
| 7 | 37 | 61、59 | 1 5 11 15 26 37 59 61 |
要注意,插入排序 | 左邊的資料只是「彼此之間排好了」,位置還可能被後面插進來的資料擠動,例如 26 在第 2 輪後排在第 2 個,最後卻在第 5 個。這點和選擇排序不同,選擇排序每一輪排好的那一筆就是最終位置。
插入排序的時間取決於資料原本有多亂。
插入排序是穩定的。 往左比較時,遇到「不比 key 大」的資料就停下來,相等的資料不會被跨過去,原本在前面的仍然在前面。
**氣泡排序(bubble sort)**每一輪都從頭開始,兩兩比較相鄰的資料,如果前面比後面大就交換。這樣一路比到最後,最大的資料會被一路推到最後面,就像水中的氣泡往上浮。

第 1 輪結束,最大的 61 已經到了最後的位置,下一輪只要比到倒數第二筆就好。每一輪的結果如下,| 右邊是已經到達最終位置的資料:
| 輪 | 比較次數 | 這一輪結束後 | 有沒有交換 |
|---|---|---|---|
| 1 | 7 | 26 1 59 5 15 11 37 | 61 | 有 |
| 2 | 6 | 1 26 5 15 11 37 | 59 61 | 有 |
| 3 | 5 | 1 5 15 11 26 | 37 59 61 | 有 |
| 4 | 4 | 1 5 11 15 | 26 37 59 61 | 有 |
| 5 | 3 | 1 5 11 15 | 26 37 59 61 | 沒有,結束 |
第 5 輪從頭比到尾都沒有交換,代表每一對相鄰資料都已經是小的在前,整個陣列已經排好,可以提早結束,不必做滿 7 輪。總比較次數是 7 + 6 + 5 + 4 + 3 = 25 次。
氣泡排序是穩定的。 只有前面「大於」後面才交換,相等的兩筆不會互換。
下面的程式把三種排序寫成函式,每個函式都回傳比較了幾次,再分別對亂序、已排好、完全相反三組資料排序:
#include <stdbool.h>
#include <stdio.h>
#define SIZE 8
void printArray(const int data[], int length) {
for (int i = 0; i < length; i++) {
printf("%d ", data[i]);
}
printf("\n");
}
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
/* 選擇排序:每一輪從未排序區挑出最小值,換到未排序區的最前面 */
int selectionSort(int data[], int length) {
int comparisons = 0;
for (int i = 0; i < length - 1; i++) {
int minIndex = i;
for (int j = i + 1; j < length; j++) {
comparisons++;
if (data[j] < data[minIndex]) {
minIndex = j;
}
}
if (minIndex != i) {
swap(&data[i], &data[minIndex]); /* 最小值已在原位時,不用交換 */
}
}
return comparisons;
}
/* 插入排序:把下一筆資料插進前面已排好的部分,比它大的往右搬 */
int insertionSort(int data[], int length) {
int comparisons = 0;
for (int i = 1; i < length; i++) {
int key = data[i];
int j = i - 1;
while (j >= 0) {
comparisons++;
if (data[j] <= key) {
break;
}
data[j + 1] = data[j];
j--;
}
data[j + 1] = key;
}
return comparisons;
}
/* 氣泡排序:相鄰兩筆比較,前面比較大就交換;一整輪沒有交換就提早結束 */
int bubbleSort(int data[], int length) {
int comparisons = 0;
for (int i = 0; i < length - 1; i++) {
bool swapped = false;
for (int j = 0; j < length - 1 - i; j++) {
comparisons++;
if (data[j] > data[j + 1]) {
swap(&data[j], &data[j + 1]);
swapped = true;
}
}
if (!swapped) {
break;
}
}
return comparisons;
}
void copyArray(int target[], const int source[], int length) {
for (int i = 0; i < length; i++) {
target[i] = source[i];
}
}
int main(void) {
const int inputs[3][SIZE] = {
{26, 59, 1, 61, 5, 15, 11, 37},
{1, 5, 11, 15, 26, 37, 59, 61},
{61, 59, 37, 26, 15, 11, 5, 1},
};
const char *names[3] = {"亂序", "已排好", "完全相反"};
int data[SIZE];
for (int k = 0; k < 3; k++) {
printf("輸入(%s):", names[k]);
printArray(inputs[k], SIZE);
copyArray(data, inputs[k], SIZE);
int count = selectionSort(data, SIZE);
printf(" 選擇排序 比較 %2d 次:", count);
printArray(data, SIZE);
copyArray(data, inputs[k], SIZE);
count = insertionSort(data, SIZE);
printf(" 插入排序 比較 %2d 次:", count);
printArray(data, SIZE);
copyArray(data, inputs[k], SIZE);
count = bubbleSort(data, SIZE);
printf(" 氣泡排序 比較 %2d 次:", count);
printArray(data, SIZE);
}
return 0;
}
執行結果:
輸入(亂序):26 59 1 61 5 15 11 37
選擇排序 比較 28 次:1 5 11 15 26 37 59 61
插入排序 比較 20 次:1 5 11 15 26 37 59 61
氣泡排序 比較 25 次:1 5 11 15 26 37 59 61
輸入(已排好):1 5 11 15 26 37 59 61
選擇排序 比較 28 次:1 5 11 15 26 37 59 61
插入排序 比較 7 次:1 5 11 15 26 37 59 61
氣泡排序 比較 7 次:1 5 11 15 26 37 59 61
輸入(完全相反):61 59 37 26 15 11 5 1
選擇排序 比較 28 次:1 5 11 15 26 37 59 61
插入排序 比較 28 次:1 5 11 15 26 37 59 61
氣泡排序 比較 28 次:1 5 11 15 26 37 59 61
幾個值得注意的地方:
temp 暫存,三個函式除了陣列本身,都只用了幾個變數,額外空間是 O(1)。| 方法 | 最好的情況 | 平均 | 最壞的情況 | 額外空間 | 穩定 |
|---|---|---|---|---|---|
| 選擇排序 | O(n²) | O(n²) | O(n²) | O(1) | 否 |
| 插入排序 | O(n) | O(n²) | O(n²) | O(1) | 是 |
| 氣泡排序 | O(n) | O(n²) | O(n²) | O(1) | 是 |
表中氣泡排序的最好情況,指的是有「沒有交換就提早結束」檢查的版本。選擇排序的比較次數固定,插入、氣泡排序在最壞情況下也要比較 n(n − 1) / 2 次,大約是 n² / 2。以這些情況來看,1 萬筆資料大約要比較 5 千萬次;資料量變成 10 倍,比較次數大約變成 100 倍。但資料已經排好時,插入、氣泡排序都只需要 n − 1 次比較。
雖然三種方法的最壞情況都是 O(n²),但各有適合的情況:
排序是依鍵值重新排列資料。選擇排序每輪挑最小值,插入排序把下一筆放進已排好的部分,氣泡排序則讓最大值一路往後移。三種方法都只需要 O(1) 額外空間,最壞時間都是 O(n²);選擇排序交換少,插入、氣泡排序則能利用資料已排好的特性,而且是穩定排序。
今日重點:
這三種方法都得一筆一筆慢慢比,資料一多就吃不消。下一篇的快速排序換個思路:先挑一個數字當基準,比它小的站左邊、比它大的站右邊,再讓兩邊各自照做。它的名字就叫「快速」,到底有多快?又為什麼遇到已經排好的資料,反而會慢下來?