本檔提供本單元練習題的完整詳解。先自己動手做:每題先只看「題目」,
寫完、編譯、執行後再對照「解題思路」與「參考解答」。
完整程式碼都在 exercises/ 資料夾,可直接編譯:
cc -std=c11 -Wall -Wextra -o /tmp/out exercises/ex1_square_cube.c && /tmp/out
練習 1:平方與立方表(難度:基礎)
題目
寫兩個函式 int square(int x) 與 int cube(int x),
在 main 中用迴圈印出 1~10 的平方與立方對照表(三欄對齊)。
要求:cube 內部重用 square,不要再寫一次 x * x。
解題思路
- 函式三件套:原型(檔頭宣告)→ 呼叫(main 裡用)→ 定義(檔尾實作)。 本題就是把這個標準格局完整走一遍。
- 設計函式介面:輸入一個
int、輸出一個int——int square(int x)的簽名把「做什麼」說完了。 - 重用而非複製:
cube(x) = x * square(x)。 雖然x * x * x也只有幾個字,但「立方 = x × 平方」表達了兩者的關係; 函式呼叫函式是組合邏輯的基本手段。 - main 的角色:只負責跑迴圈與排版;「算」的事全部委託出去。 這種分工讓表格範圍要改成 1~20 時,只動 main 一行。
參考解答
#include <stdio.h>
/* 原型:讓 main 在定義之前就能正確呼叫 */
int square(int x);
int cube(int x);
int main(void)
{
printf(" x x^2 x^3\n");
printf("--- ---- -----\n");
for (int x = 1; x <= 10; x++) {
printf("%3d %4d %5d\n", x, square(x), cube(x));
}
return 0;
}
/* 平方:一件事、一句話說得清楚 */
int square(int x)
{
return x * x;
}
/* 立方:重用 square,避免重複 x * x 的邏輯 */
int cube(int x)
{
return x * square(x);
}
(完整檔案:exercises/ex1_square_cube.c)
程式碼講解
- 原型結尾是分號、定義接大括號——初學者常把兩者搞混。
printf的引數裡直接寫square(x):函式呼叫是運算式,值算出來直接填進%4d的洞。- 若刪掉檔頭原型,clang 會報
call to undeclared function(C99 起隱式宣告已移除)—— 可以故意試一次,認識這個錯誤訊息。
範例輸出
x x^2 x^3
--- ---- -----
1 1 1
2 4 8
3 9 27
4 16 64
5 25 125
6 36 216
7 49 343
8 64 512
9 81 729
10 100 1000
練習 2:溫度轉換函式化(難度:基礎,K&R 習題 1-15)
題目
把單元 05 的溫度對照表改寫成「呼叫函式」的版本:
寫 double c_to_f(double c),main 用它印出攝氏 −20 到 100、間隔 20 的攝氏→華氏對照表。
解題思路
- 重構的動機:原版把公式直接寫在迴圈裡;公式與排版攪在一起, 哪天要改公式(或在別處也要換算)就得到處找。把「換算」抽成函式,公式只存在一處。
- 介面設計:攝氏進、華氏出,都是溫度值 →
double c_to_f(double c)。 函式名用「輸入_to_輸出」的模式自我說明。 - 原型為什麼特別重要(K&R §4.2 的重點):這個函式回傳
double。 古早 C 裡沒看到宣告的函式一律假設回傳int,拿到的值會是垃圾。 現代 C 直接把「沒有原型」當錯誤——檔頭那行double c_to_f(double c);不能省。 - 呼叫端的型別轉換:迴圈變數
c是int(步進 20 是整數), 傳給double參數時,原型讓編譯器自動做 int → double 轉換——這是原型的另一個功勞。
參考解答
#include <stdio.h>
/* 原型:回傳 double 的函式一定要讓呼叫端先看到宣告(K&R §4.2) */
double c_to_f(double c);
int main(void)
{
printf("Celsius -> Fahrenheit\n");
printf("---------------------\n");
/* main 只負責「排表」,換算細節全部交給函式 */
for (int c = -20; c <= 100; c += 20) {
printf("%8d %10.1f\n", c, c_to_f(c));
}
return 0;
}
/* 攝氏轉華氏:公式只寫在這一個地方。
* 之後公式要修(或要加華氏轉攝氏),呼叫端一行都不用動。 */
double c_to_f(double c)
{
return c * 9.0 / 5.0 + 32.0;
}
(完整檔案:exercises/ex2_c_to_f_func.c)
程式碼講解
c_to_f(c):引數c是int,經原型自動升為double——呼叫端不需要手動 cast。- 驗算已知點:0°C → 32°F、100°C → 212°F,表中都正確。
- 公式裡用
9.0 / 5.0:函式內部同樣要防整數除法陷阱(參數是 double, 但9 / 5這種「常數對常數」的子運算式不受參數型別影響)。
範例輸出
Celsius -> Fahrenheit
---------------------
-20 -4.0
0 32.0
20 68.0
40 104.0
60 140.0
80 176.0
100 212.0
練習 3:判斷函式(難度:基礎)
題目
寫兩個判斷函式:int is_even(int n) 與 int is_leap_year(int y),
用寫死的測試資料驗證,輸出格式形如 is_leap_year(1900) = 0。
測資要求:is_even 至少覆蓋偶數、奇數、零、負數;
is_leap_year 覆蓋 1900、2000、2024、2025 四種規則路徑。
解題思路
- 判斷函式(predicate)的慣例:回傳
int,1 = 是、0 = 否; 名字用is_/has_開頭,讓呼叫端讀起來像句子:if (is_even(n))。 - 最精簡的實作:比較與邏輯運算式的值本身就是 0 或 1,直接
return:return n % 2 == 0;——不需要if (...) return 1; else return 0;的迂迴。 - is_even 的負數陷阱:有人寫
return n % 2 == 1;判斷奇數—— 對負奇數-3 % 2是 −1 不是 1,會判錯! 用「等於 0」判偶數(或用!= 0判奇數)就完全避開符號問題。 - 測資的完備性:0 是偶數(0 % 2 == 0)、負偶數/負奇數都要測—— 邊界與負值是判斷函式最常翻車的地方。
- is_leap_year:直接搬單元 03 練習 2 的運算式進函式—— 之前寫過的邏輯,包成函式之後才真正「可重用」。
參考解答
#include <stdio.h>
int is_even(int n);
int is_leap_year(int y);
int main(void)
{
/* --- 測 is_even:偶數、奇數、零、負數都要覆蓋 --- */
int even_tests[] = { 4, 7, 0, -2, -3 };
int n1 = (int)(sizeof even_tests / sizeof even_tests[0]);
for (int i = 0; i < n1; i++) {
printf("is_even(%2d) = %d\n",
even_tests[i], is_even(even_tests[i]));
}
printf("\n");
/* --- 測 is_leap_year:四條規則路徑各給一個代表 --- */
int year_tests[] = { 1900, 2000, 2024, 2025 };
int n2 = (int)(sizeof year_tests / sizeof year_tests[0]);
for (int i = 0; i < n2; i++) {
printf("is_leap_year(%d) = %d\n",
year_tests[i], is_leap_year(year_tests[i]));
}
return 0;
}
/* 判斷函式的慣例:回傳 1 表示「是」、0 表示「否」,
* 名字用 is/has 開頭,讓 if (is_even(n)) 讀起來像英文。 */
int is_even(int n)
{
return n % 2 == 0; /* 比較運算式的值就是 0 或 1,直接回傳 */
}
int is_leap_year(int y)
{
return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
}
(完整檔案:exercises/ex3_predicates.c)
程式碼講解
- 兩個函式的本體都只有一行
return 運算式;——小函式不是浪費, 它們給邏輯一個名字,呼叫端從此不必重新理解那條運算式。 is_even(-2) = 1、is_even(-3) = 0:負數行為正確, 因為判斷式是== 0(-3 % 2是 −1,不等於 0 → 回傳 0)。- 輸出把「呼叫的樣子」原樣印出(
is_leap_year(1900) = 0), 這種格式本身就是一份可讀的測試報告。
範例輸出
is_even( 4) = 1
is_even( 7) = 0
is_even( 0) = 1
is_even(-2) = 1
is_even(-3) = 0
is_leap_year(1900) = 0
is_leap_year(2000) = 1
is_leap_year(2024) = 1
is_leap_year(2025) = 0
練習 4:支援負指數的 power(難度:進階)
題目
K&R 的 power 只支援非負指數。寫 double power_d(double base, int n):
n < 0 時回傳 1 / base^(-n)。
測試至少包含:power_d(2.0, -3) 應為 0.125、power_d(7.0, 0) 應為 1、
負底數 power_d(-2.0, 3) 應為 -8。
解題思路
- 數學定義先行:base⁻ⁿ = 1 / baseⁿ。所以演算法是: 「若指數為負 → 記住這件事、把指數翻正 → 照常算正指數次方 → 最後取倒數」。
- 回傳型別必須是 double:負指數的結果是分數(0.125),
int版根本裝不下—— 這正是 K&R §4.2「Functions Returning Non-integers」的主題。 - 傳值呼叫的自由:
n = -n;直接改參數——n是呼叫端引數的複本, 函式內把它當草稿紙用完全安全(K&R 特別強調這是傳值的優點)。 - 旗標變數:
int negative記住「原本是負的」,最後用?:決定要不要取倒數。 - 邊界檢查:
-
n == 0:迴圈跑 0 次,p保持初始值 1.0——「任何數的 0 次方是 1」自然成立; - 負底數:(-2)³ = -8,逐次相乘自然帶出正確符號,不需特殊處理。
參考解答
#include <stdio.h>
double power_d(double base, int n);
int main(void)
{
printf("power_d(2.0, 10) = %g\n", power_d(2.0, 10)); /* 1024 */
printf("power_d(2.0, -3) = %g\n", power_d(2.0, -3)); /* 0.125 */
printf("power_d(10.0, -2) = %g\n", power_d(10.0, -2)); /* 0.01 */
printf("power_d(1.5, 4) = %g\n", power_d(1.5, 4)); /* 5.0625 */
printf("power_d(7.0, 0) = %g\n", power_d(7.0, 0)); /* 1 */
printf("power_d(-2.0, 3) = %g\n", power_d(-2.0, 3)); /* -8 */
return 0;
}
/* 負指數的定義:base^(-n) = 1 / base^n。
* 策略:先把指數轉成正的算出 base^|n|,最後視需要取倒數。 */
double power_d(double base, int n)
{
int negative = 0;
if (n < 0) {
negative = 1;
n = -n; /* n 是複本(傳值呼叫),儘管改 */
}
double p = 1.0; /* 任何數的 0 次方是 1,迴圈跑 0 次時直接回傳它 */
for (int i = 0; i < n; i++) {
p *= base;
}
return negative ? 1.0 / p : p;
}
(完整檔案:exercises/ex4_power_neg.c)
程式碼講解
p初始化為1.0而不是1:雖然會自動轉換,但浮點變數配浮點字面值,型別意圖一致。return negative ? 1.0 / p : p;——?:選值的典型用法, 等價的 if/else 要四行。- 已知極端案例:
base = 0且n < 0會產生除以零(inf)—— 完整的錯誤處理需要之後的知識(錯誤碼或 errno),本題先聚焦主邏輯。 - 驗證:2⁻³ = 1/8 = 0.125、10⁻² = 0.01,輸出吻合。
範例輸出
power_d(2.0, 10) = 1024
power_d(2.0, -3) = 0.125
power_d(10.0, -2) = 0.01
power_d(1.5, 4) = 5.0625
power_d(7.0, 0) = 1
power_d(-2.0, 3) = -8
練習 5:最大公因數(難度:進階)
題目
實作輾轉相除法(Euclidean algorithm):gcd(a, b) = gcd(b, a % b),b == 0 時答案是 a。
- 先寫迴圈版
int gcd_loop(int a, int b); - 再寫遞迴版
int gcd_recursive(int a, int b); - 用測資對照兩版結果:
(48,18)、(17,5)、(100,25)、(0,9)、(42,0)。
解題思路
- 先懂原理再寫碼:為什麼
gcd(a, b) = gcd(b, a % b)? 任何同時整除 a 與 b 的數,也整除a - kb(特別是a % b);反之亦然。 所以「a 與 b 的公因數集合」=「b 與 a%b 的公因數集合」→ 最大公因數相同。 每一步 b 都嚴格變小,最終必到 0,此時gcd(a, 0) = a。 - 手走一遍 (48, 18):
(48,18) → (18, 48%18=12) → (12, 18%12=6) → (6, 12%6=0) → 答案 6。✓ - 迴圈版:
while (b != 0)內做三步舞——存餘數、a←b、b←餘數。 注意順序:先算a % b存進暫存變數,再搬動 a、b,否則舊值被蓋掉。 - 遞迴版:數學定義逐字翻譯成程式——基底情況(
b == 0回傳 a)在前, 遞迴呼叫(縮小問題規模)在後。遞迴版更貼近定義、迴圈版不吃呼叫堆疊,各有所長。 - 測資設計:一般情況、互質(答案 1)、整除(答案是小的那個)、
兩個含 0 的邊界——
gcd(0,9)=9、gcd(42,0)=42都由「b==0 回傳 a」規則自然涵蓋 (gcd(0,9)第一步變成gcd(9, 0))。
參考解答
#include <stdio.h>
int gcd_loop(int a, int b);
int gcd_recursive(int a, int b);
int main(void)
{
/* 測資:一般情況、互質、整除、含 0 的邊界 */
int pairs[][2] = {
{ 48, 18 }, /* gcd = 6 */
{ 17, 5 }, /* 互質:gcd = 1 */
{ 100, 25 }, /* b 整除 a:gcd = 25 */
{ 0, 9 }, /* gcd(0, b) = b */
{ 42, 0 }, /* gcd(a, 0) = a */
};
int n = (int)(sizeof pairs / sizeof pairs[0]);
printf(" a b loop recursive\n");
printf("---- --- ---- ---------\n");
for (int i = 0; i < n; i++) {
int a = pairs[i][0], b = pairs[i][1];
printf("%4d %3d %4d %9d\n",
a, b, gcd_loop(a, b), gcd_recursive(a, b));
}
return 0;
}
/* 迴圈版:每一圈把 (a, b) 換成 (b, a % b),b 變 0 時 a 就是答案。
* 原理:a 與 b 的公因數集合 = b 與 a%b 的公因數集合。 */
int gcd_loop(int a, int b)
{
while (b != 0) {
int remainder = a % b;
a = b;
b = remainder;
}
return a;
}
/* 遞迴版:把數學定義直接寫成程式——
* gcd(a, 0) = a
* gcd(a, b) = gcd(b, a % b)
* 每次遞迴 b 嚴格變小,最終必到 0(保證會停)。 */
int gcd_recursive(int a, int b)
{
if (b == 0) {
return a; /* 基底情況:不再遞迴 */
}
return gcd_recursive(b, a % b); /* 縮小問題,交給「下一層的自己」 */
}
(完整檔案:exercises/ex5_gcd.c)
程式碼講解
- 迴圈版的參數
a、b被直接改寫——又一次傳值呼叫的自由: 呼叫端的變數毫髮無傷。 - 遞迴版沒有任何區域變數:狀態全部裝在參數裡往下傳,這是遞迴思維的特徵。
- 兩版輸出全部一致——「兩種實作互相驗證」是很實用的測試策略; 遞迴的深入探討(呼叫堆疊、停機條件)在中階單元。
pairs[][2]是二維陣列(下一單元的主題),這裡只需看懂pairs[i][0]、pairs[i][1]是第 i 組的兩個數。
範例輸出
a b loop recursive
---- --- ---- ---------
48 18 6 6
17 5 1 1
100 25 25 25
0 9 9 9
42 0 42 42
練習 6:重構練習——拆分 main(難度:挑戰)
題目
下面是一段「什麼都做」的成績統計 main(30 行的縮影):
一個迴圈同時做加總、找極值、數及格人數、列印。
請把它重構成至少三個「一句話(不含『和』『並且』)說得清楚」的函式,
輸出保持不變。比較拆分前後:哪個版本更容易改「及格線從 60 變 70」?
int main(void)
{
int scores[6] = { 72, 89, 65, 94, 58, 77 };
int sum = 0, max = scores[0], min = scores[0], pass = 0;
printf("scores : ");
for (int i = 0; i < 6; i++) {
printf("%d ", scores[i]);
sum += scores[i];
if (scores[i] > max) max = scores[i];
if (scores[i] < min) min = scores[i];
if (scores[i] >= 60) pass++;
}
printf("\n\naverage: %.2f\n", (double)sum / 6);
printf("highest: %d\nlowest : %d\npassing: %d of 6\n", max, min, pass);
return 0;
}
解題思路
- 找出「職責」:讀原始碼,用一句話描述每件事—— 算平均「和」找最大「和」找最小「和」數及格「和」印報表。 每出現一個「和」,就是一個該拆出去的函式。
- 命名即設計:
average_of、max_of、min_of、count_passing、print_report—— 名字取好,函式的輸入輸出幾乎自動決定:前四個收(const int[], int n)回傳數值, 最後一個負責全部排版。 - 計算與列印分離(本題最重要的原則): 計算函式絕不 printf,列印函式絕不計算。 這樣計算函式可以被任何地方重用(也更好測試),排版要改也只動一處。
- 效率的取捨要誠實:拆分後陣列被走訪了四次(原版一次)。 對 6 個元素毫無差別;對億級資料才需要合併迴圈——先求清楚,再求快, 且要快的時候也只改函式內部,介面不動。
- 魔術數字:原版的
6出現三次;重構版用#define NUM_SCORES 6一處定義。 及格線 60 被關進count_passing——要改 70 只改一行,答案揭曉。
參考解答
#include <stdio.h>
#define NUM_SCORES 6
/* ---- 原型區:每個函式的名字就是它的「一句話描述」 ---- */
double average_of(const int scores[], int n); /* 計算平均 */
int max_of(const int scores[], int n); /* 找最高分 */
int min_of(const int scores[], int n); /* 找最低分 */
int count_passing(const int scores[], int n);/* 數及格人數(>= 60) */
void print_report(const int scores[], int n); /* 印完整報表 */
int main(void)
{
/* main 瘦身後只剩兩件事:準備資料、叫人做事 */
int scores[NUM_SCORES] = { 72, 89, 65, 94, 58, 77 };
print_report(scores, NUM_SCORES);
return 0;
}
/* 計算平均分(回傳 double 保住小數) */
double average_of(const int scores[], int n)
{
int sum = 0;
for (int i = 0; i < n; i++) {
sum += scores[i];
}
return (double)sum / n;
}
/* 找最高分:先假設第一個最大,其餘逐一挑戰 */
int max_of(const int scores[], int n)
{
int max = scores[0];
for (int i = 1; i < n; i++) {
if (scores[i] > max) {
max = scores[i];
}
}
return max;
}
/* 找最低分:與 max_of 對稱 */
int min_of(const int scores[], int n)
{
int min = scores[0];
for (int i = 1; i < n; i++) {
if (scores[i] < min) {
min = scores[i];
}
}
return min;
}
/* 數及格人數:判斷條件只出現在這裡一處 */
int count_passing(const int scores[], int n)
{
int count = 0;
for (int i = 0; i < n; i++) {
if (scores[i] >= 60) {
count++;
}
}
return count;
}
/* 印報表:只管「排版」,所有數字跟計算函式要 */
void print_report(const int scores[], int n)
{
printf("scores : ");
for (int i = 0; i < n; i++) {
printf("%d ", scores[i]);
}
printf("\n\n");
printf("average: %.2f\n", average_of(scores, n));
printf("highest: %d\n", max_of(scores, n));
printf("lowest : %d\n", min_of(scores, n));
printf("passing: %d of %d\n", count_passing(scores, n), n);
}
(完整檔案:exercises/ex6_refactor.c,檔尾附拆分前的原始版本供對照)
程式碼講解
main從 15 行變 4 行:main 是目錄,不是內文。讀 main 就知道程式做什麼, 想看細節再進對應函式。- 參數
const int scores[]:這些函式只讀不寫,const讓編譯器把關—— 陣列傳函式的完整機制在下一單元,這裡先照用。 - 及格線改 70?——只改
count_passing裡的一個數字。 原版則要在一坨迴圈裡先「找到」那行再確定不會誤傷別的邏輯。 - 每個函式都通過「一句話測試」:計算平均。找最高分。印報表。沒有「和」。
範例輸出
scores : 72 89 65 94 58 77
average: 75.83
highest: 94
lowest : 58
passing: 5 of 6
常見錯誤解法對照
| 錯誤寫法 | 症狀 | 正解 |
|---|---|---|
| 呼叫點之前沒有原型 | call to undeclared function 錯誤 |
檔頭集中放原型 |
int 版 power 算負指數 |
0.125 變 0 | 回傳型別改 double |
n % 2 == 1 判斷奇數 |
負奇數判錯(−3 % 2 是 −1) | 用 n % 2 != 0 或以 == 0 判偶數 |
| gcd 迴圈內先蓋掉 a | 餘數算錯,結果錯亂 | 先存 remainder 再搬動 |
| 非 void 函式少一條 return 路徑 | -Wall 警告;用到回傳值是 UB |
每條路徑都要 return |
| 計算函式裡夾 printf | 無法重用、無法測試 | 計算與列印分離 |