本檔提供本單元練習題的完整詳解。先自己動手做:每題先只看「題目」, 寫完、編譯、執行後再對照「解題思路」與「參考解答」。 完整程式碼都在 exercises/ 資料夾,可直接編譯:

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

練習 1:三種迴圈印 1 到 10(難度:基礎)

題目

分別用 while、for、do-while 印出 1 到 10(同一行、空格分隔), 三段輸出各占一行並標明迴圈種類。寫完後回答:三種寫法中, 「初始化、條件、推進」三要素各在哪裡?

解題思路

  1. 先抓住共同骨架:任何計數迴圈都有三要素—— 初始化(i = 1)、條件(i <= 10)、推進(i++)。三種迴圈只是把它們放在不同位置。
  2. while:初始化在迴圈前、條件在開頭、推進在本體內。 三件事分散三處——這正是「忘記推進造成無窮迴圈」好發於 while 的原因。
  3. for:三要素全部收進標頭 for (init; cond; step),一眼看完, 所以「已知次數」的迴圈永遠優先用 for。
  4. do-while:本體先執行一次、條件最後檢查。1~10 這種「至少一次」的情境結果相同, 差異要到「條件一開始就不成立」時才顯現(見練習 5)。
  5. 細節:for 的計數變數用 C99 寫法 for (int j = ...) 宣告在標頭, 作用域只在迴圈內,不污染外面。

參考解答

#include <stdio.h>

int main(void)
{
    /* --- while:初始化、條件、推進三件事分散在三處 --- */
    printf("while   : ");
    int i = 1;                  /* 初始化在迴圈外 */
    while (i <= 10) {           /* 先檢查 */
        printf("%d ", i);
        i++;                    /* 推進寫在本體內,容易忘! */
    }
    printf("\n");

    /* --- for:三件事集中在標頭,一眼看完 --- */
    printf("for     : ");
    for (int j = 1; j <= 10; j++) {
        printf("%d ", j);
    }
    printf("\n");

    /* --- do-while:先做一次再問,條件寫在最後 --- */
    printf("do-while: ");
    int k = 1;
    do {
        printf("%d ", k);
        k++;
    } while (k <= 10);          /* 注意結尾分號 */
    printf("\n");

    return 0;
}

(完整檔案:exercises/ex1_three_loops.c)

程式碼講解

  • 三段輸出完全相同——證明三種迴圈可以互相改寫;選哪種是可讀性問題。
  • do { ... } while (k <= 10); 結尾的分號不能少,少了是編譯錯誤。
  • while 版的 i++ 若忘記寫,條件永遠成立、1 印到天荒地老—— 這是所有 C 初學者遲早要中一次的獎。

範例輸出

while   : 1 2 3 4 5 6 7 8 9 10 
for     : 1 2 3 4 5 6 7 8 9 10 
do-while: 1 2 3 4 5 6 7 8 9 10 

練習 2:華氏轉攝氏對照表(難度:基礎,K&R 習題 1-4)

題目

單元範例印的是「攝氏→華氏」;請反過來印出華氏 0 到 300、間隔 20 的 華氏→攝氏對照表。公式:C = (5/9) × (F − 32)。 用 #define 定義上限、下限與間隔,輸出要對齊。

解題思路

  1. 反推公式:從 F = C × 9/5 + 32 解出 C = (5/9)(F − 32)。
  2. 迴圈設計:華氏值 0, 20, 40, …, 300——典型的「已知範圍 + 固定步進」, 用 for (fahr = LOWER; fahr <= UPPER; fahr += STEP)。 注意這裡要跑到含 300,所以條件是 <=(半開區間慣例適用於「跑 n 次」, 對照表這種「含上限」場景用閉區間更自然)。
  3. 老陷阱新警惕:5 / 9 是整數除法 = 0,整張表會全是 0! 寫成 5.0 / 9.0。計數變數 fahr 保持 int(步進是整數),換算時自然升型。
  4. 符號常數:上下限與間隔是「表的規格」,用 #define 集中在檔頭, 要改表的範圍時不必動迴圈邏輯——這正是 K&R §1.4 引入符號常數的動機。
  5. 對齊:%10d、%10.1f 固定欄寬,負數(−17.8)也不會破壞排版。

參考解答

#include <stdio.h>

#define LOWER 0     /* 表的下限 */
#define UPPER 300   /* 表的上限 */
#define STEP  20    /* 間隔 */

int main(void)
{
    printf("Fahrenheit -> Celsius\n");
    printf("---------------------\n");

    /* 計數變數用 int(間隔固定 20),換算時才轉浮點 */
    for (int fahr = LOWER; fahr <= UPPER; fahr += STEP) {
        double celsius = 5.0 / 9.0 * (fahr - 32);   /* 5.0/9.0 保住小數 */
        printf("%10d %10.1f\n", fahr, celsius);
    }

    return 0;
}

(完整檔案:exercises/ex2_f_to_c.c)

程式碼講解

  • 5.0 / 9.0 * (fahr - 32):乘除由左到右——先算 0.5556 再乘。 fahr - 32 是 int,但與 double 相乘時自動升型,不丟精度。
  • 0°F 對應 −17.8°C、32°F 對應 0°C(表裡看得到 20→−6.7、40→4.4 夾住它)、 212°F 對應 100°C——挑幾個已知點驗算是查表程式的基本驗證法。
  • #define 之後改成 STEP 10 重編譯,整張表自動變密——「規格與邏輯分離」的甜頭。

範例輸出

Fahrenheit -> Celsius
---------------------
         0      -17.8
        20       -6.7
        40        4.4
        60       15.6
        80       26.7
       100       37.8
       120       48.9
       140       60.0
       160       71.1
       180       82.2
       200       93.3
       220      104.4
       240      115.6
       260      126.7
       280      137.8
       300      148.9

練習 3:倒序與步進(難度:基礎,K&R 習題 1-5)

題目

  1. 把溫度對照表改成由高到低印出(華氏 300 到 0,間隔 60)。
  2. 印出 1~20 之間的偶數,用兩種寫法各做一次: (a) 步進 2;(b) 走訪 1~20、用 continue 跳過奇數。

解題思路

  1. 倒數迴圈的三要素鏡像:起點變上限(300)、條件變 >= 0、推進變 -= 60。 三個地方同時改,漏一個就是空迴圈或無窮迴圈。
  2. 倒數的著名地雷(README 陷阱 4):計數變數若是 unsigned,i >= 0 恆真! 這裡用 int 就沒事——記住「倒數用有號整數」。
  3. 偶數寫法 (a):直接讓迴圈「只踩偶數」——起點 2、步進 2。 迴圈次數少一半,意圖也最清楚。
  4. 偶數寫法 (b):走訪全部、用 continue 把奇數「濾掉」。 本題中 (a) 更好;但當「跳過的條件」複雜到無法用步進表達時(例如跳過質數), continue 是唯一解。兩種都要會,並理解取捨。
  5. continue 的語意:跳過本圈剩下的敘述,直接進入下一圈的推進與條件檢查。

參考解答

#include <stdio.h>

int main(void)
{
    /* --- 第一部分:由高到低的溫度表 --- */
    printf("Fahrenheit -> Celsius (descending)\n");
    /* 倒數三要素:從上限開始、條件 >= 下限、每圈遞減 */
    for (int fahr = 300; fahr >= 0; fahr -= 60) {   /* 間隔放大到 60 節省版面 */
        printf("%6d %8.1f\n", fahr, 5.0 / 9.0 * (fahr - 32));
    }

    /* --- 第二部分:1~20 的偶數,寫法一:步進 2 --- */
    printf("\nevens by stepping 2 : ");
    for (int i = 2; i <= 20; i += 2) {   /* 起點就是第一個偶數 */
        printf("%d ", i);
    }
    printf("\n");

    /* --- 寫法二:走訪全部、用 continue 跳過奇數 --- */
    printf("evens via continue  : ");
    for (int i = 1; i <= 20; i++) {
        if (i % 2 != 0) {
            continue;           /* 奇數:跳過本圈剩餘部分 */
        }
        printf("%d ", i);
    }
    printf("\n");

    return 0;
}

(完整檔案:exercises/ex3_reverse_even.c)

程式碼講解

  • 兩種偶數寫法輸出完全相同——用輸出驗證等價性,之後重構程式時也是同樣手法。
  • continue 版的 if 條件是「要跳過的情況」(奇數), 守衛式寫法讓主邏輯(printf)保持在最外層縮排。
  • 倒數表的驗證:第一列該是 300、最後一列該是 0,中間每列差 60。

範例輸出

Fahrenheit -> Celsius (descending)
   300    148.9
   240    115.6
   180     82.2
   120     48.9
    60     15.6
     0    -17.8

evens by stepping 2 : 2 4 6 8 10 12 14 16 18 20 
evens via continue  : 2 4 6 8 10 12 14 16 18 20 

練習 4:圖形輸出(難度:進階)

題目

用巢狀迴圈印出 n = 5 的三種圖形:直角三角形、倒三角形、金字塔。

*          *****        *
**         ****        ***
***        ***        *****
****       **        *******
*****      *        *********

(提示:金字塔每列先印空格再印星號。)

解題思路

  1. 建立「列 × 欄」的心智模型:外層迴圈 = 第幾列(i), 內層迴圈 = 這一列要印幾個什麼字元。每種圖形的差別只在「第 i 列印什麼」的公式。
  2. 推公式(拿紙筆數!): - 直角三角形:第 i 列印 i 顆星(i = 1..5); - 倒三角形:第 1 列 5 顆、第 2 列 4 顆…→ 第 i 列印 N - i + 1 顆; - 金字塔:觀察第 i 列——先有 N - i 個空格,接著奇數顆星 1, 3, 5, … → 2i - 1 顆。
  3. 驗算公式:代入邊界值——金字塔第 1 列:4 空格 + 1 星;第 5 列:0 空格 + 9 星。吻合。
  4. 換行的位置:內層迴圈負責「一列的內容」,printf("\n") 放在內層結束後、 外層之內——放錯位置是本題最常見的 bug。

參考解答

#include <stdio.h>

#define N 5

int main(void)
{
    /* --- 直角三角形:第 i 列印 i 顆星 --- */
    printf("right triangle:\n");
    for (int i = 1; i <= N; i++) {          /* 外層:第幾列 */
        for (int j = 1; j <= i; j++) {      /* 內層:印 i 顆星 */
            printf("*");
        }
        printf("\n");
    }

    /* --- 倒三角形:第 i 列印 N - i + 1 顆星 --- */
    printf("\ninverted triangle:\n");
    for (int i = 1; i <= N; i++) {
        for (int j = 1; j <= N - i + 1; j++) {
            printf("*");
        }
        printf("\n");
    }

    /* --- 金字塔:每列先印空格再印星號
     * 第 i 列(1 起算):N - i 個空格 + (2i - 1) 顆星 --- */
    printf("\npyramid:\n");
    for (int i = 1; i <= N; i++) {
        for (int j = 1; j <= N - i; j++) {  /* 前導空格:把星星推到中間 */
            printf(" ");
        }
        for (int j = 1; j <= 2 * i - 1; j++) {
            printf("*");
        }
        printf("\n");
    }

    return 0;
}

(完整檔案:exercises/ex4_patterns.c)

程式碼講解

  • 金字塔的一列由兩個並排的內層迴圈組成(空格迴圈 + 星號迴圈)—— 內層迴圈不一定只有一個。
  • 兩個內層迴圈都用 j,互不衝突:各自宣告在自己的 for 標頭,作用域不重疊。
  • 把 N 改成 8 重新編譯,三個圖形自動長大——公式對了,規模就是免費的。

範例輸出

right triangle:
*
**
***
****
*****

inverted triangle:
*****
****
***
**
*

pyramid:
    *
   ***
  *****
 *******
*********

練習 5:數字反轉(難度:進階)

題目

用 do-while 把 12345 反轉成 54321 印出(提示:% 10 取尾數、/ 10 去尾數)。 用測資 12345、100、7、0 驗證,並回答: 為什麼處理 n = 0 時,do-while 比 while 更貼切?

解題思路

  1. 拆解演算法:反轉 = 不斷「取出最後一位、接到結果尾巴」: - digit = n % 10 取出個位(12345 → 5); - reversed = reversed * 10 + digit——舊結果整體左移一位、空出的個位放新數字 (0→5→54→543→5432→54321); - n /= 10 丟掉已處理的個位(12345 → 1234)。
  2. 迴圈選擇:n = 0 時它仍然「有一位數字要處理」(就是 0 本身)。 while (n > 0) 一次都不執行(結果碰巧也是 0); do-while 則忠實執行一次「處理一位」——語意上它才是對的: 任何整數至少有一位。
  3. 追蹤驗證(拿 12345 走一遍):
圈 digit reversed n(處理後)
1 5 5 1234
2 4 54 123
3 3 543 12
4 2 5432 1
5 1 54321 0
  1. 邊界觀察:reverse(100) = 1——前導零在整數的世界自然消失,這不是 bug 而是數學。

參考解答

#include <stdio.h>

/* 反轉一個非負整數:12345 -> 54321 */
int reverse_number(int n)
{
    int reversed = 0;

    /* do-while:n 是 0 也會執行一次,反轉結果正確地是 0。
     * 若用 while (n > 0),n = 0 時一次都不跑,
     * 雖然結果碰巧也是 0,但「至少處理一位」的語意用 do-while 才貼切 */
    do {
        int digit = n % 10;                 /* 取出最後一位 */
        reversed = reversed * 10 + digit;   /* 舊結果左移一位、接上新位 */
        n /= 10;                            /* 去掉最後一位 */
    } while (n > 0);

    return reversed;
}

int main(void)
{
    int tests[] = { 12345, 100, 7, 0 };
    int n = (int)(sizeof tests / sizeof tests[0]);

    for (int i = 0; i < n; i++) {
        printf("reverse(%5d) = %d\n", tests[i], reverse_number(tests[i]));
    }

    /* 觀察 100 -> 1:前導零在整數世界自然消失 */
    printf("\nnote: reverse(100) = 1, leading zeros vanish in integers.\n");

    return 0;
}

(完整檔案:exercises/ex5_reverse_digits.c)

程式碼講解

  • 解答把演算法包成 reverse_number 函式,方便用四筆測資重複呼叫—— 函式的完整介紹在下一單元,這裡先體驗「一次定義、多次使用」的好處。
  • reversed * 10 + digit 是「逐位建數」的萬用模式, 單元 08 的 my_atoi(字串轉整數)用的是同一條公式。
  • 侷限:極大的數(如 1999999999)反轉會超過 INT_MAX——溢位防護屬於進階話題, 此處測資刻意避開。

範例輸出

reverse(12345) = 54321
reverse(  100) = 1
reverse(    7) = 7
reverse(    0) = 0

note: reverse(100) = 1, leading zeros vanish in integers.

練習 6:質數判斷優化(難度:挑戰)

題目

質數判斷的天真寫法是「用 2 到 n−1 逐一試除」。請:

  1. 實作天真版,統計對 n = 1000003(質數)的試除次數;
  2. 優化成「試除到 √n」——但不用浮點,改用條件 i * i <= n;
  3. 印出兩版的判斷結果與試除次數,計算加速倍率。

解題思路

  1. 為什麼 √n 就夠(本題的數學核心):若 n = d × e(d ≤ e), 則 d ≤ √n(否則 d × e > √n × √n = n,矛盾)。 所以只要 √n 以內沒有因數,√n 以外也不可能有——搜尋範圍從 n−2 縮到 √n−1。
  2. 為什麼用 i * i <= n 而不是 i <= sqrt(n): - 不需要 <math.h> 與浮點運算; - sqrt 的浮點誤差在邊界值可能差一(例如完全平方數); - 乘法比開根號快。 唯一要防的是 i * i 本身溢位——在比較前把它轉成 long 計算。
  3. 試除次數的預測:n = 1000003 是質數(最壞情況:整個範圍都要走完)—— 天真版試 2..1000002 共 1000001 次;√1000003 ≈ 1000.0015, 優化版試 2..1000 共 999 次。約 1000 倍差距。
  4. break 的角色:兩版都在「找到因數」的瞬間 break—— 合數的判斷通常很快結束;質數才要走完全程。
  5. 變數設計:is_prime 旗標先設 1(假設是質數),找到反例改 0; trials 計數器統計試除次數,用 long 保險。

參考解答

#include <stdio.h>

int main(void)
{
    int n = 1000003;            /* 一個 7 位數的質數 */

    /* --- 天真版:從 2 試除到 n-1 --- */
    int is_prime_naive = 1;     /* 先假設是質數 */
    long trials_naive = 0;      /* 試除次數統計 */

    for (int i = 2; i < n; i++) {
        trials_naive++;
        if (n % i == 0) {
            is_prime_naive = 0;
            break;              /* 找到因數就能下結論,提早離開 */
        }
    }

    /* --- 優化版:只試除到 √n ---
     * 原理:因數成對出現(d 與 n/d),兩者必有一個 <= √n,
     * 所以 √n 以內都沒有因數的話,以上也不可能有。
     * 用 i * i <= n 而不是 i <= sqrt(n):不碰浮點、不需 <math.h>;
     * i*i 先轉 long 計算,防止接近 INT_MAX 時溢位。 */
    int is_prime_fast = 1;
    long trials_fast = 0;

    for (int i = 2; (long)i * i <= n; i++) {
        trials_fast++;
        if (n % i == 0) {
            is_prime_fast = 0;
            break;
        }
    }

    /* --- 結果對照 --- */
    printf("n = %d\n\n", n);
    printf("naive (up to n-1) : prime = %d, trial divisions = %ld\n",
           is_prime_naive, trials_naive);
    printf("fast  (up to sqrt): prime = %d, trial divisions = %ld\n",
           is_prime_fast, trials_fast);
    printf("\nspeedup: %ld / %ld  =  about %ldx fewer divisions\n",
           trials_naive, trials_fast, trials_naive / trials_fast);

    return 0;
}

(完整檔案:exercises/ex6_prime_opt.c)

程式碼講解

  • (long)i * i <= n:先把 i 轉成 long 再乘,整條乘法在 long 域進行。 若寫 i * i(int 乘 int),i 接近 46341 時就會有號溢位(UB)。 本題 n 不大所以踩不到,但寫防溢位的形式是免費的保險。
  • 兩版對同一個 n 得到相同結論(prime = 1),但成本差了約 1000 倍—— 演算法層面的優化遠勝任何微調。
  • 進一步的優化方向(有興趣可自行延伸):先判 2 的倍數、之後只試奇數(再省一半)、 或試除改用已知質數表(埃拉托斯特尼篩法,中階單元的好題材)。

範例輸出

n = 1000003

naive (up to n-1) : prime = 1, trial divisions = 1000001
fast  (up to sqrt): prime = 1, trial divisions = 999

speedup: 1000001 / 999  =  about 1001x fewer divisions

常見錯誤解法對照

錯誤寫法 症狀 正解
while 本體忘記 i++ 無窮迴圈 推進放進 for 標頭最保險
for (i = 0; i <= n; i++) 想跑 n 次 差一錯誤:多跑一次 半開區間 i < n
for (...); 標頭後多分號 迴圈空轉、區塊只執行一次 刪分號(看 -Wempty-body 警告)
for (unsigned i = 10; i >= 0; i--) i >= 0 恆真,無窮迴圈 倒數用 int,或 i-- > 0
5 / 9 * (fahr - 32) 整數除法先變 0,整張表全 0 5.0 / 9.0
金字塔忘了前導空格 星星全部靠左,變直角三角形 每列先印 N - i 個空格
i <= sqrt(n) 判質數 引入浮點誤差與 math.h 依賴 (long)i * i <= n