本檔提供本單元練習題的完整詳解。先自己動手做:每題先只看「題目」,
寫完、編譯、執行後再對照「解題思路」與「參考解答」。
完整程式碼都在 exercises/ 資料夾,可直接編譯:
cc -std=c11 -Wall -Wextra -o /tmp/out exercises/ex1_squares.c && /tmp/out
練習 1:平方表存入陣列、反向印出(難度:基礎)
題目
宣告 10 個元素的 int 陣列,用迴圈把 1~10 的平方(1, 4, 9, …, 100)填進去,
然後反向(100 到 1)印出。先正向印一次做對照。
解題思路
- 索引與數字的映射:陣列索引 0~9、數字 1~10——差了 1。
決定:
squares[i]存(i+1)²。寫程式前先把這條映射式寫在紙上, 之後所有讀寫都遵循它,就不會亂。 - 填值迴圈:標準的
for (i = 0; i < N; i++)半開區間,跑剛好 N 次。 - 反向走訪的三要素:起點是最後一個合法索引
N - 1(不是 N!)、 條件i >= 0、推進i--。 起點寫成N是最常見的錯——squares[10]已經越界。 - 驗證:正向第一個(1)應該等於反向最後一個;兩行輸出應互為鏡像。
參考解答
#include <stdio.h>
#define N 10
int main(void)
{
int squares[N]; /* 未初始化沒關係:馬上會整個填滿 */
/* 填值:索引 i 放 (i+1) 的平方——索引 0 對應數字 1 */
for (int i = 0; i < N; i++) {
squares[i] = (i + 1) * (i + 1);
}
/* 正向印一次當對照 */
printf("forward : ");
for (int i = 0; i < N; i++) {
printf("%d ", squares[i]);
}
printf("\n");
/* 反向:從最後一個合法索引 N-1 走到 0 */
printf("backward: ");
for (int i = N - 1; i >= 0; i--) {
printf("%d ", squares[i]);
}
printf("\n");
return 0;
}
(完整檔案:exercises/ex1_squares.c)
程式碼講解
int squares[N];不初始化是刻意的:下一行就會填滿全部 10 格。 「宣告後立刻全部填值」是少數可以不初始化的正當情況。- 反向迴圈的計數變數必須是有號的
int:走到 0 之後還要再減一次變 −1 才能讓條件不成立——unsigned在這裡會環繞成極大值、無窮迴圈(單元 05 陷阱 4)。 #define N 10:長度集中一處,改成 20 全程式自動適應。
範例輸出
forward : 1 4 9 16 25 36 49 64 81 100
backward: 100 81 64 49 36 25 16 9 4 1
練習 2:平均與極差(難度:基礎)
題目
對寫死的成績陣列 { 72, 89, 65, 94, 58, 77 } 求:
平均(浮點)、最高分、最低分、極差(最高減最低)。
陣列長度用 sizeof 慣用法計算,不要寫死 6。
解題思路
- 長度交給編譯器數:
sizeof scores / sizeof scores[0]—— 總位元組數 ÷ 單元素位元組數 = 元素個數。之後增刪成績,程式自動正確。 - 極值的初始化是本題的關鍵陷阱:
max初始化成0的話, 全班都是負分時答案就錯了;min初始化成 0 更是直接錯(0 比所有成績都小)。 正解:極值一律初始化成第一個元素scores[0],讓資料自己說話。 - 一趟迴圈做三件事:加總、挑戰 max、挑戰 min—— 這裡三個動作都對「同一次走訪」操作,合在一個迴圈是自然的 (對比單元 06 練習 6 的拆分:那裡拆的是「職責」,不是「迴圈」)。
- 平均的老朋友:
sum / n是整數除法,記得(double)sum / n。 - 極差:
max - min,一行。
參考解答
#include <stdio.h>
int main(void)
{
int scores[] = { 72, 89, 65, 94, 58, 77 };
int n = (int)(sizeof scores / sizeof scores[0]); /* 讓編譯器數個數 */
/* 一趟迴圈同時累加與追蹤極值 */
int sum = 0;
int max = scores[0]; /* 極值初始化成「第一個元素」而不是 0! */
int min = scores[0];
for (int i = 0; i < n; i++) {
sum += scores[i];
if (scores[i] > max) {
max = scores[i];
}
if (scores[i] < min) {
min = scores[i];
}
}
printf("scores : ");
for (int i = 0; i < n; i++) {
printf("%d ", scores[i]);
}
printf("\n\n");
printf("count : %d\n", n);
printf("average: %.2f\n", (double)sum / n); /* cast 保住小數 */
printf("highest: %d\n", max);
printf("lowest : %d\n", min);
printf("range : %d\n", max - min); /* 極差 */
return 0;
}
(完整檔案:exercises/ex2_stats.c)
程式碼講解
int scores[] = {...}:省略長度,編譯器依初始化列表自動決定——與sizeof慣用法是絕配。(int)(sizeof ... ):sizeof的結果是無號的size_t, 轉成int再與有號迴圈變數比較,避免-Wextra的 sign-compare 警告。- 手算驗證:總和 455,455/6 = 75.83;最高 94、最低 58、極差 36。輸出全部吻合。
範例輸出
scores : 72 89 65 94 58 77
count : 6
average: 75.83
highest: 94
lowest : 58
range : 36
練習 3:就地反轉陣列(難度:進階)
題目
寫函式 void reverse(int a[], int n),就地(in-place)反轉陣列:
交換 a[0] 與 a[n-1]、a[1] 與 a[n-2]……印出反轉前後的對照。
用「偶數個元素」與「奇數個元素」兩組資料測試。
解題思路
- 「就地」的意思:不開第二個陣列,直接在原陣列上交換——空間成本 O(1)。
- 雙索引夾擠:
i從頭(0)、j從尾(n−1), 每次交換a[i]、a[j]後互相靠近(i++、j--),相遇或交錯就停: 條件while (i < j)。 - 為什麼條件是
i < j而不是i <= j:i == j時兩個索引指向同一格 (奇數長度的正中央),跟自己交換是白工——不做也對,做了也對,但語意上不必做。 - 交換三步舞:
tmp = a[i]; a[i] = a[j]; a[j] = tmp;—— 沒有暫存變數的話第一步就把值蓋掉了。 - 陣列傳入函式會「改得到本尊」:這是本單元的核心觀念——
傳的是首元素位址,函式內
a[i] = ...直接寫到呼叫端的陣列。 對照單元 06 的 swap 陷阱(傳值改不到),陣列是「天生傳位址」的例外。 - 測資:偶數個(8 個)與奇數個(5 個)各一組,驗證中央元素的處理。
參考解答
#include <stdio.h>
/* 印出整個陣列(加標籤),重複的排版工作交給函式 */
void print_array(const char *label, const int a[], int n)
{
printf("%s: ", label);
for (int i = 0; i < n; i++) {
printf("%d ", a[i]);
}
printf("\n");
}
/* 就地反轉:i 從頭、j 從尾,交換後互相靠近,相遇就停 */
void reverse(int a[], int n)
{
int i = 0;
int j = n - 1;
while (i < j) {
int tmp = a[i]; /* 經典三步交換 */
a[i] = a[j];
a[j] = tmp;
i++;
j--;
}
/* 元素個數是奇數時,中間那個不需要動(i == j 時停) */
}
int main(void)
{
int data[] = { 3, 1, 4, 1, 5, 9, 2, 6 }; /* 偶數個 */
int n = (int)(sizeof data / sizeof data[0]);
print_array("before ", data, n);
reverse(data, n); /* 陣列傳位址:函式內的修改「改得到」本尊 */
print_array("after ", data, n);
int odd[] = { 10, 20, 30, 40, 50 }; /* 奇數個 */
int m = (int)(sizeof odd / sizeof odd[0]);
printf("\n");
print_array("before ", odd, m);
reverse(odd, m);
print_array("after ", odd, m);
return 0;
}
(完整檔案:exercises/ex3_reverse.c)
程式碼講解
reverse的參數沒有const(要改)、print_array的有(只讀)—— 用const標注意圖,讓編譯器替你抓「不小心改到」的錯。- 長度必須另外傳:函式內
sizeof a只會得到指標大小(README 陷阱 2)。 - 奇數組輸出
50 40 30 20 10:中央的 30 留在原位,證明i < j的停止條件正確。
範例輸出
before : 3 1 4 1 5 9 2 6
after : 6 2 9 5 1 4 1 3
before : 10 20 30 40 50
after : 50 40 30 20 10
練習 4:骰子次數統計與長條圖(難度:進階)
題目
擲骰子 60 次的結果已寫死在陣列中(值 1~6)。統計每個點數出現的次數,
印出統計表與水平長條圖(* 重複次數次)。
最後驗算:六個次數的總和應等於 60。
解題思路
- 「陣列 + 映射」的計數模式(K&R §1.6 的精髓):
準備
int counts[6],讓骰子的值自己當索引—— 點數 v 記錄在counts[v - 1](值 1~6 映射到索引 0~5)。 一行counts[rolls[i] - 1]++;完成分類計數,不需要六條 if。 - 計數陣列必須清零:
int counts[6] = { 0 };—— 忘了初始化,垃圾值 +1 還是垃圾(README 陷阱 4 的實戰場景)。 - 長條圖:對每個點數,先印
face count, 再用內層迴圈印counts[face-1]顆星——「重複 n 次印同一字元」就是迴圈。 - 自我驗算:把六個計數加總,應該恰好等於擲骰總數 60。 多寫三行驗算,換得「統計沒漏算」的信心——輸出裡自帶檢查是好習慣。
- 兩個方向的索引轉換:寫入時
v - 1(值→索引)、輸出時face(1~6)直接印—— 內部用索引、對人用點數,不要混。
參考解答
#include <stdio.h>
int main(void)
{
/* 60 次擲骰結果(寫死的測試資料) */
int rolls[] = {
3, 6, 1, 4, 2, 5, 3, 3, 6, 2, 1, 4, 5, 6, 3, 2, 4, 1, 6, 5,
2, 3, 4, 6, 1, 3, 5, 2, 6, 4, 3, 1, 5, 6, 2, 3, 4, 5, 1, 6,
4, 2, 3, 5, 6, 1, 2, 4, 3, 6, 5, 3, 2, 1, 4, 6, 5, 3, 2, 4,
};
int n = (int)(sizeof rolls / sizeof rolls[0]);
/* 計數陣列一定要清 0:counts[v-1] 統計點數 v 的次數。
* 「值 → 索引」的映射正是 K&R §1.6 的核心技巧 */
int counts[6] = { 0 };
for (int i = 0; i < n; i++) {
counts[rolls[i] - 1]++; /* 點數 1~6 映射到索引 0~5 */
}
/* 印統計表 + 水平長條圖 */
printf("rolls: %d\n\n", n);
printf("face count histogram\n");
printf("---- ----- ---------\n");
for (int face = 1; face <= 6; face++) {
printf("%4d %5d ", face, counts[face - 1]);
for (int j = 0; j < counts[face - 1]; j++) {
printf("*");
}
printf("\n");
}
/* 驗算:各點數次數加總應該等於總擲骰數 */
int total = 0;
for (int i = 0; i < 6; i++) {
total += counts[i];
}
printf("\ncheck: counts sum to %d (expected %d)\n", total, n);
return 0;
}
(完整檔案:exercises/ex4_dice_histogram.c)
程式碼講解
counts[rolls[i] - 1]++:一行裡有兩層索引——先用i取出這次的點數, 再用「點數 − 1」定位計數格。拆開來讀就不難。- 這個模式的威力在於分類數再多也是 O(1) 分派:統計 26 個字母、 10 個數字(K&R 原題)都是同一招。
- 若測資中出現 0 或 7 這種非法值,
counts[-1]/counts[6]就越界了—— 實務上要先驗證輸入範圍;本題資料寫死可控,故從簡。
範例輸出
rolls: 60
face count histogram
---- ----- ---------
1 8 ********
2 10 **********
3 12 ************
4 10 **********
5 9 *********
6 11 ***********
check: counts sum to 60 (expected 60)
練習 5:選擇排序 vs 泡沫排序(難度:進階)
題目
實作選擇排序:每輪從未排序區「選出最小值」放到最前面。
與範例的泡沫排序比較:對同一組資料 { 64, 25, 12, 22, 11, 90, 34, 8 }
各排一次,統計並比較交換次數。
解題思路
- 選擇排序的心智模型:把陣列分成「已排序區(前段)|未排序區(後段)」。
第 i 輪:掃描未排序區
a[i..n-1]找最小值的索引, 把它跟a[i]交換——已排序區長大一格。跑 n−1 輪完工。 - 與泡沫排序的本質差異: - 泡沫:邊比邊換,一輪可能換很多次(每次逆序都換); - 選擇:整輪只記索引不動手,最後才交換至多一次。 比較次數兩者同為 O(n²),但交換次數選擇排序至多 n−1 次。
- 實作細節:
-
min_idx = i起手(未排序區第一個當擂主); - 內層j = i+1..n-1挑戰擂主,只更新索引; - 出了內層迴圈,min_idx != i才交換(已在正位就不白換)。 - 公平比較的實驗設計:同一組原始資料複製兩份,各給一種排序——
陣列不能用
=整批複製,要用迴圈逐一搬。 排序函式回傳交換次數,main 只管報告。 - 預測:這組資料泡沫排序逆序對很多(會換十幾次);選擇排序最多 7 次。
參考解答
#include <stdio.h>
#define N 8
void print_array(const char *label, const int a[], int n)
{
printf("%s: ", label);
for (int i = 0; i < n; i++) {
printf("%d ", a[i]);
}
printf("\n");
}
/* 泡沫排序:相鄰兩兩比較,順序錯就交換;回傳交換次數 */
int bubble_sort(int a[], int n)
{
int swaps = 0;
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - 1 - i; j++) {
if (a[j] > a[j + 1]) {
int tmp = a[j];
a[j] = a[j + 1];
a[j + 1] = tmp;
swaps++;
}
}
}
return swaps;
}
/* 選擇排序:第 i 輪從 a[i..n-1] 找最小值的「索引」,
* 整輪只在最後交換一次;回傳交換次數 */
int selection_sort(int a[], int n)
{
int swaps = 0;
for (int i = 0; i < n - 1; i++) {
int min_idx = i; /* 先假設未排序區第一個最小 */
for (int j = i + 1; j < n; j++) {
if (a[j] < a[min_idx]) {
min_idx = j; /* 只記索引,先不交換 */
}
}
if (min_idx != i) { /* 需要時才交換(最多一次) */
int tmp = a[i];
a[i] = a[min_idx];
a[min_idx] = tmp;
swaps++;
}
}
return swaps;
}
int main(void)
{
/* 同一組資料複製兩份,兩種排序各排一份才公平 */
int original[N] = { 64, 25, 12, 22, 11, 90, 34, 8 };
int for_bubble[N];
int for_select[N];
for (int i = 0; i < N; i++) {
for_bubble[i] = original[i]; /* 陣列不能整批指定,逐一複製 */
for_select[i] = original[i];
}
print_array("original ", original, N);
int bubble_swaps = bubble_sort(for_bubble, N);
print_array("bubble sorted ", for_bubble, N);
printf("bubble swaps : %d\n\n", bubble_swaps);
int select_swaps = selection_sort(for_select, N);
print_array("selection sorted", for_select, N);
printf("selection swaps : %d\n", select_swaps);
printf("\nsame comparisons O(n^2), but selection sort swaps at most n-1 times.\n");
return 0;
}
(完整檔案:exercises/ex5_selection_sort.c)
程式碼講解
- 實測:泡沫 18 次交換 vs 選擇 4 次——排序結果相同、成本結構不同。 「交換昂貴、比較便宜」的場景(例如元素是大結構)選擇排序有實際優勢。
min_idx記的是索引不是值:最後交換需要知道「最小值在哪」, 只記值的話就找不回位置了——初學者常在這裡卡住。- 如果不小心把兩種排序餵了同一個陣列(第二個排的是已排序的資料), 交換次數會是 0——「實驗汙染」的教訓,複製資料的那幾行不是多餘的。
範例輸出
original : 64 25 12 22 11 90 34 8
bubble sorted : 8 11 12 22 25 34 64 90
bubble swaps : 18
selection sorted: 8 11 12 22 25 34 64 90
selection swaps : 4
same comparisons O(n^2), but selection sort swaps at most n-1 times.
練習 6:已排序陣列就地去重(難度:挑戰)
題目
對已排序的陣列 { 1, 1, 2, 3, 3, 3, 5, 8, 8 } 就地去除重複元素,
函式回傳去重後的有效長度,印出去重前後的長度與內容。
另外用「全部相同」與「完全無重複」兩組邊界資料驗證。
(提示:讀寫雙索引。)
解題思路
- 利用「已排序」這個前提:排序後重複的值必定相鄰—— 所以判斷「是不是新值」只要跟「上一個保留的值」比,不必回頭掃全部。
- 讀寫雙索引(two pointers 的陣列版):
-
read:一路向前,每個元素都看一眼(快索引); -write:下一個「保留值」該放的位置(慢索引), 只有遇到新值才寫入並前進。 兩個索引一快一慢,資料「向前壓縮」,不需要第二個陣列。 - 推導初始值:
a[0]一定保留(第一個值沒有「前一個」可比), 所以write從 1 開始、read也從 1 開始。 - 迴圈不變式(走訪過程中永遠成立的事實):
a[0..write-1]是「目前為止不重複的結果」。a[read] != a[write-1]→ 新值 → 寫進a[write]、write++;相同 → 跳過。 結束時write恰好是保留個數——這就是回傳值。 - 邊界驗證:
- 全相同
{7,7,7,7}→ 長度 1; - 無重複{1,2,3}→ 長度 3(每個都被「原位重寫」一次,無害); - 空陣列 → 守衛子句直接回 0。 - 輸出注意:去重後只印「前
new_len個」—— 後面的格子還留著舊資料,但已在有效長度之外,不屬於結果。
參考解答
#include <stdio.h>
/* 就地去重:回傳去重後的有效長度。
* write:下一個「保留值」要放的位置(慢索引)
* read :目前檢查到哪裡(快索引)
* 已排序 → 重複值必相鄰 → 只要跟「上一個保留的值」比即可 */
int dedup(int a[], int n)
{
if (n == 0) {
return 0; /* 空陣列:沒事做(守衛子句) */
}
int write = 1; /* a[0] 一定保留,從位置 1 開始寫 */
for (int read = 1; read < n; read++) {
if (a[read] != a[write - 1]) { /* 與最後保留的值不同 → 是新值 */
a[write] = a[read];
write++;
}
/* 相同 → 跳過(read 前進、write 停留) */
}
return write; /* write 恰好是保留的元素個數 */
}
void print_array(const char *label, const int a[], int n)
{
printf("%s (length %d): ", label, n);
for (int i = 0; i < n; i++) {
printf("%d ", a[i]);
}
printf("\n");
}
int main(void)
{
int data[] = { 1, 1, 2, 3, 3, 3, 5, 8, 8 };
int n = (int)(sizeof data / sizeof data[0]);
print_array("before", data, n);
int new_len = dedup(data, n);
print_array("after ", data, new_len); /* 只印「有效長度」內的元素 */
/* 邊界測試:全部相同、完全沒有重複 */
int all_same[] = { 7, 7, 7, 7 };
int len2 = dedup(all_same, 4);
print_array("\nall-same after ", all_same, len2);
int no_dup[] = { 1, 2, 3 };
int len3 = dedup(no_dup, 3);
print_array("no-dup after ", no_dup, len3);
return 0;
}
(完整檔案:exercises/ex6_dedup.c)
程式碼講解
- 手走一遍主測資:read 掃過
1,2,3,3,3,5,8,8, 依序寫入2,3,5,8(加上原有的a[0]=1),write 停在 5——輸出1 2 3 5 8,長度 5。✓ - 「陣列長度不會真的變短」:C 的陣列大小固定,
dedup用回傳值告訴呼叫端 「前幾個才算數」——「緩衝區 + 有效長度」是 C 處理可變長度資料的標準模式。 - 讀寫雙索引是大量演算法的基石(快慢指標、原地過濾、合併), 本題是它最單純的形態,值得把「迴圈不變式」想到透徹。
範例輸出
before (length 9): 1 1 2 3 3 3 5 8 8
after (length 5): 1 2 3 5 8
all-same after (length 1): 7
no-dup after (length 3): 1 2 3
常見錯誤解法對照
| 錯誤寫法 | 症狀 | 正解 |
|---|---|---|
反向迴圈從 i = N 開始 |
a[N] 越界(UB) |
從 N - 1 開始 |
| 極值初始化成 0 | 全負資料時 max 錯、min 永遠 0 | 初始化成 a[0] |
| 計數陣列不清零 | 垃圾值 + 1 = 垃圾,統計全毀 | int counts[6] = { 0 }; |
函式內 sizeof arr 算長度 |
得到指標大小(8),永遠 2 個元素 | 長度由呼叫端算好傳入 |
b = a; 整批複製陣列 |
編譯錯誤 | 迴圈逐一複製(或 memcpy) |
| 選擇排序內層直接交換 | 變成另一種排序,交換次數暴增 | 只記 min_idx,出迴圈才換 |
| 去重後印整個原陣列 | 尾端殘留舊值一起印出 | 只印回傳的有效長度 |