本檔提供本單元練習題的完整詳解。先自己動手做:每題先只看「題目」, 寫完、編譯、執行後再對照「解題思路」與「參考解答」。 完整程式碼都在 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)印出。先正向印一次做對照。

解題思路

  1. 索引與數字的映射:陣列索引 0~9、數字 1~10——差了 1。 決定:squares[i] 存 (i+1)²。寫程式前先把這條映射式寫在紙上, 之後所有讀寫都遵循它,就不會亂。
  2. 填值迴圈:標準的 for (i = 0; i < N; i++) 半開區間,跑剛好 N 次。
  3. 反向走訪的三要素:起點是最後一個合法索引 N - 1(不是 N!)、 條件 i >= 0、推進 i--。 起點寫成 N 是最常見的錯——squares[10] 已經越界。
  4. 驗證:正向第一個(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。

解題思路

  1. 長度交給編譯器數:sizeof scores / sizeof scores[0]—— 總位元組數 ÷ 單元素位元組數 = 元素個數。之後增刪成績,程式自動正確。
  2. 極值的初始化是本題的關鍵陷阱:max 初始化成 0 的話, 全班都是負分時答案就錯了;min 初始化成 0 更是直接錯(0 比所有成績都小)。 正解:極值一律初始化成第一個元素 scores[0],讓資料自己說話。
  3. 一趟迴圈做三件事:加總、挑戰 max、挑戰 min—— 這裡三個動作都對「同一次走訪」操作,合在一個迴圈是自然的 (對比單元 06 練習 6 的拆分:那裡拆的是「職責」,不是「迴圈」)。
  4. 平均的老朋友:sum / n 是整數除法,記得 (double)sum / n。
  5. 極差: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]……印出反轉前後的對照。 用「偶數個元素」與「奇數個元素」兩組資料測試。

解題思路

  1. 「就地」的意思:不開第二個陣列,直接在原陣列上交換——空間成本 O(1)。
  2. 雙索引夾擠:i 從頭(0)、j 從尾(n−1), 每次交換 a[i]、a[j] 後互相靠近(i++、j--),相遇或交錯就停: 條件 while (i < j)。
  3. 為什麼條件是 i < j 而不是 i <= j:i == j 時兩個索引指向同一格 (奇數長度的正中央),跟自己交換是白工——不做也對,做了也對,但語意上不必做。
  4. 交換三步舞:tmp = a[i]; a[i] = a[j]; a[j] = tmp;—— 沒有暫存變數的話第一步就把值蓋掉了。
  5. 陣列傳入函式會「改得到本尊」:這是本單元的核心觀念—— 傳的是首元素位址,函式內 a[i] = ... 直接寫到呼叫端的陣列。 對照單元 06 的 swap 陷阱(傳值改不到),陣列是「天生傳位址」的例外。
  6. 測資:偶數個(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。

解題思路

  1. 「陣列 + 映射」的計數模式(K&R §1.6 的精髓): 準備 int counts[6],讓骰子的值自己當索引—— 點數 v 記錄在 counts[v - 1](值 1~6 映射到索引 0~5)。 一行 counts[rolls[i] - 1]++; 完成分類計數,不需要六條 if。
  2. 計數陣列必須清零:int counts[6] = { 0 };—— 忘了初始化,垃圾值 +1 還是垃圾(README 陷阱 4 的實戰場景)。
  3. 長條圖:對每個點數,先印 face count, 再用內層迴圈印 counts[face-1] 顆星——「重複 n 次印同一字元」就是迴圈。
  4. 自我驗算:把六個計數加總,應該恰好等於擲骰總數 60。 多寫三行驗算,換得「統計沒漏算」的信心——輸出裡自帶檢查是好習慣。
  5. 兩個方向的索引轉換:寫入時 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 } 各排一次,統計並比較交換次數。

解題思路

  1. 選擇排序的心智模型:把陣列分成「已排序區(前段)|未排序區(後段)」。 第 i 輪:掃描未排序區 a[i..n-1] 找最小值的索引, 把它跟 a[i] 交換——已排序區長大一格。跑 n−1 輪完工。
  2. 與泡沫排序的本質差異: - 泡沫:邊比邊換,一輪可能換很多次(每次逆序都換); - 選擇:整輪只記索引不動手,最後才交換至多一次。 比較次數兩者同為 O(n²),但交換次數選擇排序至多 n−1 次。
  3. 實作細節: - min_idx = i 起手(未排序區第一個當擂主); - 內層 j = i+1..n-1 挑戰擂主,只更新索引; - 出了內層迴圈,min_idx != i 才交換(已在正位就不白換)。
  4. 公平比較的實驗設計:同一組原始資料複製兩份,各給一種排序—— 陣列不能用 = 整批複製,要用迴圈逐一搬。 排序函式回傳交換次數,main 只管報告。
  5. 預測:這組資料泡沫排序逆序對很多(會換十幾次);選擇排序最多 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 } 就地去除重複元素, 函式回傳去重後的有效長度,印出去重前後的長度與內容。 另外用「全部相同」與「完全無重複」兩組邊界資料驗證。 (提示:讀寫雙索引。)

解題思路

  1. 利用「已排序」這個前提:排序後重複的值必定相鄰—— 所以判斷「是不是新值」只要跟「上一個保留的值」比,不必回頭掃全部。
  2. 讀寫雙索引(two pointers 的陣列版): - read:一路向前,每個元素都看一眼(快索引); - write:下一個「保留值」該放的位置(慢索引), 只有遇到新值才寫入並前進。 兩個索引一快一慢,資料「向前壓縮」,不需要第二個陣列。
  3. 推導初始值:a[0] 一定保留(第一個值沒有「前一個」可比), 所以 write 從 1 開始、read 也從 1 開始。
  4. 迴圈不變式(走訪過程中永遠成立的事實): a[0..write-1] 是「目前為止不重複的結果」。 a[read] != a[write-1] → 新值 → 寫進 a[write]、write++;相同 → 跳過。 結束時 write 恰好是保留個數——這就是回傳值。
  5. 邊界驗證: - 全相同 {7,7,7,7} → 長度 1; - 無重複 {1,2,3} → 長度 3(每個都被「原位重寫」一次,無害); - 空陣列 → 守衛子句直接回 0。
  6. 輸出注意:去重後只印「前 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,出迴圈才換
去重後印整個原陣列 尾端殘留舊值一起印出 只印回傳的有效長度