本檔收錄 6 題由易到難的練習。建議流程:先蓋住解答自己寫,卡住超過 30 分鐘再看「解題思路」,寫完再對照「參考解答」與「程式碼講解」。 每題的完整程式碼也放在 exercises/ 資料夾,可直接編譯執行:

cc -std=c11 -Wall -Wextra -o /tmp/out exercises/ex1_apply.c && /tmp/out

練習 1:高階函式 apply(難度:基礎)

題目

寫一個函式 int apply(int x, int (*f)(int)),把整數 x 交給函式指標 f 處理並回傳結果。用它套用三種函式:my_abs(絕對值)、square (平方)、inc(加一),對測試資料 -7, 0, 3, 12 各跑一輪。

輸入/輸出範例(節錄):

f = square:
  apply( -7, square) = 49
  apply(  0, square) = 0

解題思路

  1. 分析問題:apply 不關心 f 做什麼,它只負責「轉呼叫」。這是 高階函式(higher-order function)最原始的形式——把「行為」當資料傳遞。
  2. 宣告怎麼寫:參數 int (*f)(int) 的括號不可省略。 int *f(int) 是「回傳 int* 的函式」宣告,完全是另一回事。 右左法則:從 f 出發,先碰括號內的 *(是指標),再往右碰 (int) (指向函式),最後往左(回傳 int)。
  3. 呼叫怎麼寫:f(x) 與 (*f)(x) 等價,現代慣用前者。
  4. 批次測試:三個函式 × 四筆資料,自然想到「函式指標陣列」 int (*fns[])(int) = { my_abs, square, inc }; 搭配平行的名稱表, 雙層迴圈掃過即可——這正是資料驅動(table-driven)風格的起點。
  5. 邊界情況:my_abs(0) 應回 0;本題不處理 INT_MIN(其絕對值 超出 int 範圍,是未定義行為),教學實作以一般值為主。

參考解答

/*
 * ex1_apply.c — 練習 1:高階函式 apply
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_apply.c
 * 執行:/tmp/out
 *
 * 寫一個 int apply(int x, int (*f)(int)),
 * 並用它套用 my_abs、square、inc 三種函式。
 */
#include <stdio.h>

/* ---- 三個「int -> int」的目標函式 ---- */

static int my_abs(int x) { return x < 0 ? -x : x; }
static int square(int x) { return x * x; }
static int inc(int x)    { return x + 1; }

/*
 * apply:把「策略」當參數傳進來的高階函式。
 * f 的型別是「指向 (int)->int 函式的指標」,括號不可省略。
 */
static int apply(int x, int (*f)(int))
{
    return f(x);        /* 透過指標呼叫,寫法與一般函式呼叫相同 */
}

int main(void)
{
    /* 測試資料寫死,涵蓋負數、零、正數 */
    int inputs[] = { -7, 0, 3, 12 };
    int n = (int)(sizeof inputs / sizeof inputs[0]);

    /* 函式指標陣列 + 名稱表:批次驅動同一組測資 */
    int (*fns[])(int) = { my_abs, square, inc };
    const char *names[] = { "my_abs", "square", "inc" };
    int nfns = (int)(sizeof fns / sizeof fns[0]);

    for (int i = 0; i < nfns; i++) {
        printf("f = %s:\n", names[i]);
        for (int j = 0; j < n; j++)
            printf("  apply(%3d, %s) = %d\n",
                   inputs[j], names[i], apply(inputs[j], fns[i]));
    }

    /* 也可以直接以函式名稱呼叫(名稱自動退化為指標) */
    printf("direct: apply(-5, my_abs) = %d\n", apply(-5, my_abs));
    return 0;
}

程式碼講解

  • apply 本體只有一行 return f(x);——高階函式的價值不在函式本身, 而在呼叫端獲得的組合彈性。
  • int (*fns[])(int) 讀法:fns 是陣列,元素是「指向 (int)->int 函式的指標」。名稱表 names[] 與它平行對應,這是 C 沒有反射機制時 印出函式名的標準做法。
  • 易錯處:把參數寫成 int *f(int)(少了括號)會得到一個 函式宣告而非函式指標參數,編譯錯誤訊息往往令人困惑。
  • main 最後一行示範函式名稱在運算式中自動退化為指標, apply(-5, my_abs) 與 apply(-5, &my_abs) 完全等價。

範例輸出

f = my_abs:
  apply( -7, my_abs) = 7
  apply(  0, my_abs) = 0
  apply(  3, my_abs) = 3
  apply( 12, my_abs) = 12
f = square:
  apply( -7, square) = 49
  apply(  0, square) = 0
  apply(  3, square) = 9
  apply( 12, square) = 144
f = inc:
  apply( -7, inc) = -6
  apply(  0, inc) = 1
  apply(  3, inc) = 4
  apply( 12, inc) = 13
direct: apply(-5, my_abs) = 5

練習 2:qsort 由大到小排序 double(難度:基礎)

題目

用標準函式庫的 qsort 將 double 陣列由大到小排序。 測試資料:3.14, -1.5, 2.71, 0.0, 42.0, 2.70, -1.49。 特別要求:說明為什麼浮點數的比較函式不能用相減,並用程式證明。

預期輸出:

after : 42.00 3.14 2.71 2.70 0.00 -1.49 -1.50

解題思路

  1. qsort 的契約:比較函式簽名固定是 int cmp(const void *, const void *),回傳負/零/正表示 「第一個引數應排在前/相等/排在後」。
  2. 遞減怎麼做:把回傳值反過來即可——a > b 時回 -1 (a 排前面)。
  3. 為什麼不能相減:整數常見的 return a - b; 搬到浮點數上有兩個致命傷: - 回傳型別是 int,(int)(2.71 - 2.70) = (int)0.01 = 0, 兩個明明不同的數被判成相等,排序結果不穩定甚至錯誤。 - 極端值相減可能溢位(DBL_MAX - (-DBL_MAX) 是無限大)。 正確做法:用 <、> 明確回傳 -1/0/1。
  4. 驗證設計:故意放入 2.71 與 2.70、-1.49 與 -1.5 這種「差距小於 1」的資料,錯誤的相減法會現形。

參考解答

/*
 * ex2_qsort_desc.c — 練習 2:qsort 將 double 陣列由大到小排序
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_qsort_desc.c
 * 執行:/tmp/out
 *
 * 重點:浮點數比較函式「不能用相減」——
 * 相減轉 int 會截斷小數(0.5 - 0.1 = 0.4 -> 0,誤判相等),
 * 且極端值相減可能溢位或產生 NaN。必須用比較運算子。
 */
#include <stdio.h>
#include <stdlib.h>

/*
 * 遞減比較函式:回傳負值表示 a 應排在 b 前面。
 * qsort 的介面規定參數是 const void *,先轉回真正的型別再比較。
 */
static int cmp_double_desc(const void *pa, const void *pb)
{
    double a = *(const double *)pa;
    double b = *(const double *)pb;

    /* 想要遞減:a 比較大時回負值(a 排前面) */
    if (a > b) return -1;
    if (a < b) return  1;
    return 0;
}

/* 錯誤示範用的「相減法」比較(本程式只展示它為何錯,不用它排序) */
static int cmp_wrong(const void *pa, const void *pb)
{
    /* 0.4 截斷成 0 -> 誤判為相等! */
    return (int)(*(const double *)pa - *(const double *)pb);
}

static void print_array(const char *tag, const double *a, size_t n)
{
    printf("%s", tag);
    for (size_t i = 0; i < n; i++)
        printf(" %.2f", a[i]);
    printf("\n");
}

int main(void)
{
    double data[] = { 3.14, -1.5, 2.71, 0.0, 42.0, 2.70, -1.49 };
    size_t n = sizeof data / sizeof data[0];

    print_array("before:", data, n);

    qsort(data, n, sizeof data[0], cmp_double_desc);
    print_array("after :", data, n);

    /* 展示「相減法」為何錯:差距小於 1 的兩數被判成相等 */
    double x = 2.71, y = 2.70;
    printf("\nwhy subtraction is wrong:\n");
    printf("  cmp_wrong(2.71, 2.70) = %d  (truncated to 0: judged equal!)\n",
           cmp_wrong(&x, &y));
    printf("  cmp_double_desc(2.71, 2.70) = %d  (correct)\n",
           cmp_double_desc(&x, &y));
    return 0;
}

程式碼講解

  • cmp_double_desc 先把 const void * 轉回 const double * 再解參考, 這個「兩段式還原」是所有 qsort 比較函式的固定開場。
  • 遞減的關鍵只在一行:a > b 時回 -1。若想改回遞增,交換 -1/1 即可;更通用的技巧是寫一個遞增版然後回傳它的相反數。
  • cmp_wrong 保留在程式裡當「反面教材」,main 用 2.71 vs 2.70 實測給你看:回傳 0,qsort 會認為它們相等,順序聽天由命。
  • 易錯處:比較函式若回傳不一致(違反全序關係),qsort 的行為是 未定義的——某些平台會當機,不只是排錯而已。

範例輸出

before: 3.14 -1.50 2.71 0.00 42.00 2.70 -1.49
after : 42.00 3.14 2.71 2.70 0.00 -1.49 -1.50

why subtraction is wrong:
  cmp_wrong(2.71, 2.70) = 0  (truncated to 0: judged equal!)
  cmp_double_desc(2.71, 2.70) = -1  (correct)

練習 3:泛型 for_each(難度:進階)

題目

模仿 qsort 的泛型介面,實作:

void for_each(void *base, size_t n, size_t size, void (*visit)(void *));

對「任意型別」陣列的每個元素呼叫 visit。用三種型別驗證: int 陣列(先印出、再就地每個乘 2)、double 陣列、字串(char*)陣列。

預期輸出:

int array   : 1 2 3 4 5
after *2    : 2 4 6 8 10

解題思路

  1. 泛型的本質:C 沒有模板,泛型函式「看不到」元素型別, 呼叫端必須把型別資訊拆成兩個數字傳進來——元素個數 n 與 元素大小 size(用 sizeof 取得)。
  2. 位址計算:第 i 個元素的位址是 (char *)base + i * size。為什麼要轉 char *?因為指標算術以 「指向型別的大小」為單位,char 恰好是 1 位元組,乘上 size 才會正確。void * 在標準 C 中不允許算術。
  3. 回呼端還原型別:visit 收到的是「元素的位址」。對 int 陣列就是 int *;注意字串陣列的元素本身是 char *, 所以元素位址是 char **——這是最容易錯的一層。
  4. 就地修改:因為傳的是位址而非複本,回呼可以直接改元素 (double_int 示範),這也是介面用 void * 而非 const void * 的原因。
  5. 複雜度:走訪一次 O(n),無額外空間。

參考解答

/*
 * ex3_for_each.c — 練習 3:泛型 for_each
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_for_each.c
 * 執行:/tmp/out
 *
 * 模仿 qsort 的泛型介面:
 *   void for_each(void *base, size_t n, size_t size, void (*visit)(void *))
 * 對「任意型別」陣列的每個元素呼叫 visit。
 * 關鍵:泛型程式碼看不到元素型別,只能用 char* 做位元組位移。
 */
#include <stdio.h>

/*
 * 泛型走訪:base 是陣列起點,n 是元素個數,size 是單一元素的位元組數。
 * 第 i 個元素的位址 = (char *)base + i * size。
 * 一定要先轉成 char*(位移單位 = 1 位元組);void* 不能做算術。
 */
static void for_each(void *base, size_t n, size_t size,
                     void (*visit)(void *))
{
    char *p = base;
    for (size_t i = 0; i < n; i++)
        visit(p + i * size);
}

/* ---- 各型別的 visit 回呼:把 void* 轉回真正的型別 ---- */

static void print_int(void *elem)
{
    printf(" %d", *(int *)elem);
}

static void double_int(void *elem)        /* 就地修改:每個元素乘 2 */
{
    *(int *)elem *= 2;
}

static void print_double(void *elem)
{
    printf(" %.2f", *(double *)elem);
}

static void print_str(void *elem)
{
    /* 元素是 char*,elem 是「指向元素的指標」= char** */
    printf(" \"%s\"", *(char **)elem);
}

int main(void)
{
    int    ints[]    = { 1, 2, 3, 4, 5 };
    double doubles[] = { 1.5, 2.25, 3.125 };
    char  *strs[]    = { "alpha", "beta", "gamma" };

    printf("int array   :");
    for_each(ints, 5, sizeof ints[0], print_int);
    printf("\n");

    /* visit 拿到的是元素位址,因此也能「就地修改」 */
    for_each(ints, 5, sizeof ints[0], double_int);
    printf("after *2    :");
    for_each(ints, 5, sizeof ints[0], print_int);
    printf("\n");

    printf("double array:");
    for_each(doubles, 3, sizeof doubles[0], print_double);
    printf("\n");

    printf("string array:");
    for_each(strs, 3, sizeof strs[0], print_str);
    printf("\n");
    return 0;
}

程式碼講解

  • for_each 只有四行,核心是 visit(p + i * size):p 已是 char *,+ i * size 前進到第 i 個元素的第一個位元組。
  • print_str 是本題的「陷阱檢查點」:strs 的元素型別是 char *,所以 visit 收到 char **,要先解一層才得到字串。 寫成 printf("%s", (char *)elem) 會印出垃圾(把指標變數本身的 位元組當字串讀)。
  • 就地修改能成立,是因為 for_each 傳的是元素位址而非值; 對照 qsort 的比較函式收 const void *——人家只想讀不想改。
  • 易錯處:char *p = base; 這行在 C 是合法的隱式轉換 (void * 可自由轉任何物件指標),寫 C++ 時才需要顯式 cast。

範例輸出

int array   : 1 2 3 4 5
after *2    : 2 4 6 8 10
double array: 1.50 2.25 3.12
string array: "alpha" "beta" "gamma"

練習 4:多鍵排序(難度:進階)

題目

為結構 struct student { char name[16]; int score; } 用單一 qsort 呼叫完成多鍵排序:先依分數遞減,同分依姓名字典序遞增。

測試資料(刻意安排三人同分 88、兩人同分 92):

Ivy 88, Frank 92, Alice 88, Dave 75, Carol 92, Bob 88

預期排序結果:Carol 92, Frank 92, Alice 88, Bob 88, Ivy 88, Dave 75。

解題思路

  1. 多鍵比較的固定套路:比較函式內「先比主鍵,不相等就直接回傳; 相等才比次鍵」。串接幾層都行,qsort 只看最後的正負號。
  2. 主鍵遞減:a->score < b->score 時回 1(a 排後面)。 分數範圍 0~100 時 b->score - a->score 也安全,但養成 用比較運算子的習慣可避免大數溢位。
  3. 次鍵遞增:strcmp(a->name, b->name) 的回傳值正好符合 qsort 契約,直接回傳即可。
  4. 為什麼不用「排兩次」:先按姓名排、再按分數「穩定排序」一次 也能達成——但 C 標準的 qsort 不保證穩定,這條路在 C 走不通, 多鍵比較函式才是正解。
  5. 驗收設計:同分組內姓名故意亂序(Ivy、Alice、Bob), 排完必須變成 Alice、Bob、Ivy 才算對。

參考解答

/*
 * ex4_multikey_sort.c — 練習 4:多鍵排序(分數遞減、同分姓名遞增)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_multikey_sort.c
 * 執行:/tmp/out
 *
 * 多鍵比較的固定套路:
 *   先比主鍵,不相等就直接回傳;相等才落到次鍵。
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct student {
    char name[16];
    int  score;
};

/*
 * 主鍵:score 遞減;次鍵:name 字典序遞增。
 * int 相減在這裡安全嗎?score 範圍 0~100,不會溢位,可以用;
 * 但為了通用性,示範用比較運算子的寫法。
 */
static int cmp_student(const void *pa, const void *pb)
{
    const struct student *a = pa;
    const struct student *b = pb;

    if (a->score != b->score)
        return (a->score < b->score) ? 1 : -1;   /* 遞減:小的排後面 */
    return strcmp(a->name, b->name);             /* 同分:姓名遞增 */
}

int main(void)
{
    struct student class[] = {
        { "Ivy",    88 },
        { "Frank",  92 },
        { "Alice",  88 },
        { "Dave",   75 },
        { "Carol",  92 },
        { "Bob",    88 },
    };
    size_t n = sizeof class / sizeof class[0];

    printf("before sort:\n");
    for (size_t i = 0; i < n; i++)
        printf("  %-8s %d\n", class[i].name, class[i].score);

    qsort(class, n, sizeof class[0], cmp_student);

    printf("\nafter sort (score desc, then name asc):\n");
    for (size_t i = 0; i < n; i++)
        printf("  %-8s %d\n", class[i].name, class[i].score);
    return 0;
}

程式碼講解

  • const struct student *a = pa;:void * 到具體型別的隱式轉換, 比到處寫 cast 乾淨。
  • 主鍵那行 (a->score < b->score) ? 1 : -1——進到這個分支時兩者 已確定不等,所以只需分「小於/大於」兩種情況。
  • strcmp 直接當次鍵比較器用:它回傳的正負號語意與 qsort 要求 完全一致,這不是巧合——qsort 的介面就是照著 strcmp 家族設計的。
  • 易錯處:若把兩鍵寫反(先比姓名再比分數),輸出乍看也「有序」, 但同分組不會聚在一起;驗收時務必檢查同分組內的次序。

範例輸出

before sort:
  Ivy      88
  Frank    92
  Alice    88
  Dave     75
  Carol    92
  Bob      88

after sort (score desc, then name asc):
  Carol    92
  Frank    92
  Alice    88
  Bob      88
  Ivy      88
  Dave     75

練習 5:表驅動狀態機(難度:挑戰)

題目

用「函式指標轉移表」實作一個狀態機,判斷字串是否符合 [+-]?digits(可帶正負號的十進位整數)。要求:

  • 狀態以 enum 表示,每個狀態對應一個狀態函式;
  • 轉移表為 state_fn table[N_STATES];
  • 主迴圈只有三行(查表、呼叫、判斷是否終止)。

測試:"123"、"-42"、"+7"、"0" 應 ACCEPT; ""、"+"、"12a"、"a12"、"3.14"、"-" 應 REJECT。

解題思路

  1. 先畫狀態圖(一定要先畫,寫碼只是抄圖):
           '+'/'-'          digit
  START ────────────> SIGN ─────────┐
    │ digit                         ▼
    └──────────────────────────> DIGITS ──┐ digit(自迴圈)
                                    │ '\0'
                                    ▼
                                 ACCEPT
  (其餘輸入一律 -> REJECT)
  1. 為什麼需要獨立的 SIGN 狀態:"+" 後面必須至少有一個數字。 若讓 '+' 直接跳到 DIGITS,"+" 讀到 '\0' 就會被 DIGITS 當成正常收尾而誤判 ACCEPT。狀態的意義是「到目前為止讀了什麼」, 「剛讀完正負號」和「已讀到至少一個數字」是不同的歷史,必須分開。
  2. 狀態函式的介面:enum state (*)(char c)——吃當前字元、 回傳下一個狀態。所有轉移邏輯都封裝在各自的狀態函式裡, 主迴圈完全不含 if/switch。
  3. 終端狀態:ACCEPT/REJECT 不會再被呼叫,表中放 NULL; 主迴圈以「到達終端狀態」為停止條件,所以永遠不會呼叫到 NULL。
  4. 為什麼用表不用 switch:新增狀態時只要「加一個函式+填一格表」, 不必動主迴圈;狀態多達數十個時(如協定解析器),表驅動的 可維護性壓倒性勝出。
  5. 複雜度:每個字元恰好處理一次,O(n)。

參考解答

/*
 * ex5_state_machine.c — 練習 5:函式指標表驅動的狀態機
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex5_state_machine.c
 * 執行:/tmp/out
 *
 * 題材:判斷字串是否符合  [+-]?digits  (帶正負號的整數)。
 *
 * 狀態圖:
 *
 *            '+'/'-'          digit
 *   START ────────────> SIGN ─────────┐
 *     │ digit                         ▼
 *     └──────────────────────────> DIGITS ──┐ digit(自迴圈)
 *                                     │ '\0'
 *                                     ▼
 *                                  ACCEPT
 *   (其餘輸入一律 -> REJECT)
 *
 * 每個狀態是一個函式,回傳下一個狀態;主迴圈只有三行。
 */
#include <stdio.h>
#include <ctype.h>
#include <string.h>

enum state { ST_START, ST_SIGN, ST_DIGITS, ST_ACCEPT, ST_REJECT, N_STATES };

/* 狀態函式:吃目前字元,回傳下一個狀態 */
typedef enum state (*state_fn)(char c);

static enum state do_start(char c)
{
    if (c == '+' || c == '-')      return ST_SIGN;
    if (isdigit((unsigned char)c)) return ST_DIGITS;
    return ST_REJECT;              /* 含空字串(c=='\0'):直接拒絕 */
}

static enum state do_sign(char c)
{
    /* 正負號後「必須」接數字:這就是獨立 SIGN 狀態存在的理由 */
    if (isdigit((unsigned char)c)) return ST_DIGITS;
    return ST_REJECT;              /* "+"、"-a" 都死在這裡 */
}

static enum state do_digits(char c)
{
    if (isdigit((unsigned char)c)) return ST_DIGITS;   /* 自迴圈 */
    if (c == '\0')                 return ST_ACCEPT;   /* 正常收尾 */
    return ST_REJECT;
}

/* 轉移表:狀態 -> 處理函式。終端狀態不會被呼叫,放 NULL。 */
static const state_fn table[N_STATES] = {
    [ST_START]  = do_start,
    [ST_SIGN]   = do_sign,
    [ST_DIGITS] = do_digits,
    [ST_ACCEPT] = NULL,
    [ST_REJECT] = NULL,
};

/* 主迴圈只有三行:查表 -> 間接呼叫 -> 前進 */
static int match_number(const char *s)
{
    enum state st = ST_START;

    for (const char *p = s; st != ST_ACCEPT && st != ST_REJECT; p++)
        st = table[st](*p);

    return st == ST_ACCEPT;
}

int main(void)
{
    const char *tests[] = {
        "123", "-42", "+7", "0", "", "+", "12a", "a12", "3.14", "-",
    };
    size_t n = sizeof tests / sizeof tests[0];

    printf("pattern: [+-]?digits\n\n");
    for (size_t i = 0; i < n; i++) {
        char quoted[16];
        snprintf(quoted, sizeof quoted, "\"%s\"", tests[i]);
        printf("  %-8s -> %s\n", quoted,
               match_number(tests[i]) ? "ACCEPT" : "REJECT");
    }
    return 0;
}

程式碼講解

  • table 用 C99 指定初始化器 [ST_START] = do_start 填表: 就算日後 enum 順序調整,對應關係也不會錯位——這是表驅動程式 的防禦性寫法。
  • 主迴圈 st = table[st](*p); 一行做了三件事:用目前狀態查表、 間接呼叫狀態函式、接收下一個狀態。
  • isdigit((unsigned char)c):ctype.h 函式的參數必須是 unsigned char 值或 EOF,直接丟有號 char(可能為負)是 未定義行為——這是老手也常忘的細節。
  • 迴圈條件保證只在非終端狀態呼叫 table[st],因此 ACCEPT/REJECT 兩格的 NULL 永遠不會被執行。
  • 易錯處:讓 '+' 直接進 DIGITS 會誤收 "+"。狀態機 bug 幾乎 都源於「兩段不同的歷史被塞進同一個狀態」。

範例輸出

pattern: [+-]?digits

  "123"    -> ACCEPT
  "-42"    -> ACCEPT
  "+7"     -> ACCEPT
  "0"      -> ACCEPT
  ""       -> REJECT
  "+"      -> REJECT
  "12a"    -> REJECT
  "a12"    -> REJECT
  "3.14"   -> REJECT
  "-"      -> REJECT

練習 6:自製泛型排序 my_qsort(難度:挑戰)

題目

仿 K&R §5.11,自己實作泛型排序函式(內部演算法用插入排序即可), 介面與標準 qsort 完全相同:

void my_qsort(void *base, size_t n, size_t size,
              int (*cmp)(const void *, const void *));

要求:元素定位用 char * 位移、元素搬移用 memcpy。 用 int 陣列與字串陣列各測一次,並與標準 qsort 的結果比對。

解題思路

  1. 先寫「會型別」的版本:對 int 陣列的插入排序是 「抓出 a[i],往前把比它大的右移,放進空位」。 泛型化就是把這三個動作翻譯成不依賴型別的操作。
  2. 翻譯對照表:
有型別的寫法 泛型寫法
int tmp = a[i]; memcpy(tmp, b + i*size, size);
a[j] = a[j-1]; memcpy(b + j*size, b + (j-1)*size, size);
a[j-1] > tmp cmp(b + (j-1)*size, tmp) > 0
  1. 暫存區:不知道元素型別,就開一塊「夠大的位元組緩衝區」 char tmp[64] 當暫存。生產級寫法會用 VLA 或 malloc; 教學版設個上限並檢查即可。
  2. 為什麼是插入排序:泛型化的重點在介面而非演算法; 插入排序短小、穩定,n 小時甚至比快排快(glibc qsort 的 小區間也是切回插入排序)。
  3. 複雜度:O(n²) 比較與搬移;每次搬移是 memcpy(size), 大元素的常數成本高——這也是為什麼實務上排大結構時常改排 「指標陣列」。
  4. 驗收:同一組資料丟給標準 qsort,memcmp 比對兩者結果。

參考解答

/*
 * ex6_my_qsort.c — 練習 6:自製泛型排序(介面同標準 qsort)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex6_my_qsort.c
 * 執行:/tmp/out
 *
 * 仿 K&R §5.11:實作與標準 qsort 介面完全相同的泛型排序
 * (內部用插入排序即可)。泛型的代價是:
 *   - 位址計算全用 char* + i * size
 *   - 元素搬移全用 memcpy / memmove(不知道型別,不能用 =)
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_ELEM_SIZE 64        /* 暫存單一元素用的緩衝區上限 */

/*
 * my_qsort:插入排序版。
 * 把第 i 個元素抓進 tmp,往前找插入點,路上的元素整段右移一格。
 */
static void my_qsort(void *base, size_t n, size_t size,
                     int (*cmp)(const void *, const void *))
{
    char *b = base;
    char tmp[MAX_ELEM_SIZE];

    if (size > sizeof tmp) {
        fprintf(stderr, "my_qsort: element too large\n");
        exit(1);
    }

    for (size_t i = 1; i < n; i++) {
        memcpy(tmp, b + i * size, size);       /* 抓出待插入元素 */

        size_t j = i;
        /* 從右往左找插入點:比 tmp 大的元素往右挪一格 */
        while (j > 0 && cmp(b + (j - 1) * size, tmp) > 0) {
            memcpy(b + j * size, b + (j - 1) * size, size);
            j--;
        }
        memcpy(b + j * size, tmp, size);       /* 放進插入點 */
    }
}

/* ---- 用兩種型別驗證泛型介面 ---- */

static int cmp_int(const void *a, const void *b)
{
    int x = *(const int *)a, y = *(const int *)b;
    return (x > y) - (x < y);      /* 慣用式:避免溢位的三態比較 */
}

static int cmp_str(const void *a, const void *b)
{
    /* 元素是 char*,參數是「元素的位址」= const char *const * */
    return strcmp(*(const char *const *)a, *(const char *const *)b);
}

int main(void)
{
    int nums[] = { 42, -7, 19, 0, 88, -7, 5 };
    size_t nn = sizeof nums / sizeof nums[0];

    char *words[] = { "pear", "apple", "fig", "banana", "date" };
    size_t nw = sizeof words / sizeof words[0];

    my_qsort(nums, nn, sizeof nums[0], cmp_int);
    printf("ints   :");
    for (size_t i = 0; i < nn; i++) printf(" %d", nums[i]);
    printf("\n");

    my_qsort(words, nw, sizeof words[0], cmp_str);
    printf("strings:");
    for (size_t i = 0; i < nw; i++) printf(" %s", words[i]);
    printf("\n");

    /* 與標準 qsort 對照:同一組資料應得到同樣結果 */
    int check[] = { 42, -7, 19, 0, 88, -7, 5 };
    qsort(check, nn, sizeof check[0], cmp_int);
    printf("match standard qsort? %s\n",
           memcmp(nums, check, sizeof nums) == 0 ? "yes" : "no");
    return 0;
}

程式碼講解

  • 內層 while 是插入排序的心臟:cmp(b + (j-1)*size, tmp) > 0 表示「左邊那個元素比待插入的大」,就把它右移一格。注意比較的 兩個引數都是位址——tmp 本身就是位元組陣列,直接可傳。
  • (x > y) - (x < y) 是三態比較的慣用式:結果必為 -1/0/1, 且完全沒有溢位風險,值得背下來。
  • cmp_str 的 cast *(const char *const *)a:元素是 char *, 所以引數是「指向 char * 的指標」;加上兩層 const 是為了 嚴格對應 const void * 的唯讀承諾。
  • 易錯處一:交錯搬移時若把 memcpy 的來源/目的順序寫反, 資料會被覆蓋掉——先存 tmp 再右移的順序不能亂。
  • 易錯處二:j 用 size_t(無號),迴圈條件必須先寫 j > 0 再取 j - 1,否則 j = 0 時 j - 1 會繞回極大值。

範例輸出

ints   : -7 -7 0 5 19 42 88
strings: apple banana date fig pear
match standard qsort? yes

常見錯誤解法對照

錯誤寫法 症狀 正解
比較函式用 (int)(a - b) 比 double 差距 < 1 被判相等,排序不穩 用 </> 回傳 -1/0/1(練習 2)
int *f(int) 當函式指標參數 編譯錯誤:宣告了一個函式 int (*f)(int)(練習 1)
字串陣列回呼中 cast 成 char * 印出亂碼 元素位址是 char **,先解一層(練習 3)
想用「排兩次」做多鍵排序 C 的 qsort 不穩定,結果錯亂 單一比較函式串接多鍵(練習 4)
'+' 直接跳 DIGITS 狀態 "+" 被誤判 ACCEPT 獨立 SIGN 狀態(練習 5)
泛型搬移用 = 或忘了 char * 轉型 編譯錯誤或位移錯亂 char * 位移 + memcpy(練習 6)