本檔提供本單元練習題的完整詳解。先自己動手做:每題先只看「題目」,
寫完、編譯、執行後再對照「解題思路」與「參考解答」。
完整程式碼都在 exercises/ 資料夾,可直接編譯:
cc -std=c11 -Wall -Wextra -o /tmp/out exercises/ex1_ascii_table.c && /tmp/out
練習 1:ASCII 對照表(難度:基礎)
題目
印出 'A' 到 'Z' 的 ASCII 對照表(字元與數值成對),每列 6 組,
最後一列不足 6 組也要正確換行。
解題思路
- 字元直接當迴圈計數:
'A'~'Z'在 ASCII 中連續,for (char c = 'A'; c <= 'Z'; c++)就能走完 26 個字母—— 這是「字元就是小整數」的直接應用。 - 同一個值、兩種印法:
printf("%c=%2d", c, c)—— 同一個c傳兩次,%c印字母、%d印編碼。 - 每列 6 組的斷行邏輯:維護「本列已印組數」計數器
printed,printed % 6 == 0時換行。這是排版的萬用模式(每 N 個換一行)。 - 收尾:26 = 6×4 + 2,最後一列只有 2 組、迴圈裡不會觸發換行——
結束後檢查
printed % 6 != 0補一個\n,輸出才完整。
參考解答
#include <stdio.h>
int main(void)
{
int printed = 0; /* 本列已印出的組數 */
/* 字元可以直接當迴圈計數:'A' 到 'Z' 在 ASCII 中連續 */
for (char c = 'A'; c <= 'Z'; c++) {
printf("%c=%2d ", c, c); /* 同一個值印兩次:%c 與 %d */
printed++;
if (printed % 6 == 0) { /* 每 6 組換一列 */
printf("\n");
}
}
/* 26 不是 6 的倍數,最後一列要補換行 */
if (printed % 6 != 0) {
printf("\n");
}
return 0;
}
(完整檔案:exercises/ex1_ascii_table.c)
程式碼講解
%c與%d收到的都是(升型後的)int——差別只在「怎麼呈現」。- 條件
c <= 'Z':迴圈結束後c是'Z' + 1(91)。char裝 91 沒問題;若上限接近char的極值就要小心環繞,此處安全。 - 這張表印出來後,「大寫從 65 開始」「大小寫差 32」都一目瞭然—— 自己生一張表比背表有效。
範例輸出
A=65 B=66 C=67 D=68 E=69 F=70
G=71 H=72 I=73 J=74 K=75 L=76
M=77 N=78 O=79 P=80 Q=81 R=82
S=83 T=84 U=85 V=86 W=87 X=88
Y=89 Z=90
練習 2:大小寫轉換(難度:基礎)
題目
不使用 <ctype.h>,自己寫 char to_upper(char c) 與 char to_lower(char c):
是目標字母就轉換,其他任何字元原樣回傳。
對字串 "Hello, World 123!" 逐字元轉換,印出全大寫與全小寫兩個版本。
解題思路
- 函式的合約先想清楚:輸入任意字元—— 小寫字母 → 轉大寫;其餘(大寫、數字、標點、空白)→ 原樣回傳。 「不該動的不動」跟「該轉的轉對」一樣重要。
- 轉換公式(單元 02 練習 5 的成果直接拿來用):
ASCII 大小寫差固定 32 =
'a' - 'A'; 小寫轉大寫是c - ('a' - 'A')、大寫轉小寫是c + ('a' - 'A')。 - 先判斷再轉換:
if (c >= 'a' && c <= 'z')守住範圍, 否則'5'也會被減 32 變成別的字元。 - 逐字元走訪字串:
for (int i = 0; text[i] != '\0'; i++)—— 用哨兵'\0'當終點,不需要先算長度。 - 輸出用
putchar:一次一個字元,與逐字元處理的節奏一致。
參考解答
#include <stdio.h>
/* 小寫轉大寫;不是小寫字母就原樣回傳 */
char to_upper(char c)
{
if (c >= 'a' && c <= 'z') {
return c - ('a' - 'A'); /* 減去大小寫差值 32 */
}
return c; /* 守衛:非小寫字母不動 */
}
/* 大寫轉小寫;不是大寫字母就原樣回傳 */
char to_lower(char c)
{
if (c >= 'A' && c <= 'Z') {
return c + ('a' - 'A');
}
return c;
}
int main(void)
{
char text[] = "Hello, World 123!";
printf("original: %s\n", text);
/* 逐字元轉換:s[i] != '\0' 就是「還沒到字串結尾」 */
printf("upper : ");
for (int i = 0; text[i] != '\0'; i++) {
putchar(to_upper(text[i]));
}
printf("\n");
printf("lower : ");
for (int i = 0; text[i] != '\0'; i++) {
putchar(to_lower(text[i]));
}
printf("\n");
return 0;
}
(完整檔案:exercises/ex2_case_convert.c)
程式碼講解
- 觀察輸出:逗號、空白、數字、驚嘆號全部原樣保留——守衛條件在工作。
- 兩個函式不修改原字串(回傳新字元、由呼叫端決定怎麼用),
所以
text印三次內容都正確。想「就地」改字串也行:text[i] = to_upper(text[i])。 - 這對函式就是標準庫
toupper/tolower的核心邏輯 (標準版還處理了 locale 與 EOF,之後改用它們時心裡有底)。
範例輸出
original: Hello, World 123!
upper : HELLO, WORLD 123!
lower : hello, world 123!
練習 3:回文判斷(難度:進階)
題目
寫 int is_palindrome(const char s[]),判斷字串正讀反讀是否相同。
先用自製的 my_strlen 求長度,再用雙索引頭尾夾擠。
測試:"level"、"radar"、"hello"、"noon"、"a"、""(空字串)。
解題思路
- 拆成兩個子問題:
- 找到「最後一個字元」在哪 → 需要長度 → 先寫
my_strlen(數到'\0'為止); - 頭尾配對比較 → 雙索引夾擠(單元 07 練習 3 反轉陣列的同款技巧,這次只比較不交換)。 - my_strlen:
while (s[i] != '\0') i++;,回傳i。 注意'\0'不算在長度內,所以尾索引是len - 1。 - 夾擠邏輯:
i從 0、j從len - 1;s[i] != s[j]→ 立刻回傳 0(一個反例就足以下結論); 全部配對成功(i >= j)→ 回傳 1。 - 邊界自動正確:空字串
len = 0,j = -1,while (i < j)一次都不跑 → 回傳 1; 單字元同理。「空字串是回文」是合理的數學約定,而且不需要特判—— 好的迴圈條件常讓邊界不勞而獲。 - 參數加
const:判斷函式只讀不寫,讓編譯器把關。
參考解答
#include <stdio.h>
/* 自製 strlen:數到 '\0' 為止('\0' 不算在長度內) */
int my_strlen(const char s[])
{
int i = 0;
while (s[i] != '\0') {
i++;
}
return i;
}
/* 回文判斷:頭尾兩索引夾擠,任何一對不相等就不是回文 */
int is_palindrome(const char s[])
{
int i = 0;
int j = my_strlen(s) - 1; /* 最後一個「字元」的索引(不含 '\0') */
while (i < j) {
if (s[i] != s[j]) {
return 0; /* 找到反例,立刻下結論 */
}
i++;
j--;
}
return 1; /* 全部配對成功(空字串與單字元自然回傳 1) */
}
int main(void)
{
/* 字串陣列:每個元素是一個字串(const char * 的完整機制在指標單元) */
const char *tests[] = { "level", "radar", "hello", "noon", "a", "" };
int n = (int)(sizeof tests / sizeof tests[0]);
for (int i = 0; i < n; i++) {
printf("is_palindrome(\"%s\") = %d\n", tests[i], is_palindrome(tests[i]));
}
return 0;
}
(完整檔案:exercises/ex3_palindrome.c)
程式碼講解
"hello":第一對就失配('h'vs'o'),立刻回傳 0——提早返回讓失敗案例最省力。"noon"(偶數長度):兩對都配對成功,i、j交錯後停;"level"(奇數長度):中央的'v'不需要跟誰比。兩種長度都被i < j正確處理。const char *tests[]是「字串的陣列」——測資表模式在字串題同樣好用; 指標的完整故事在高階單元,這裡先會用。
範例輸出
is_palindrome("level") = 1
is_palindrome("radar") = 1
is_palindrome("hello") = 0
is_palindrome("noon") = 1
is_palindrome("a") = 1
is_palindrome("") = 1
練習 4:壓縮連續空白(難度:進階,K&R 習題 1-18 精神)
題目
寫 void squeeze_spaces(char s[]),就地把字串中的連續空白壓縮成單一空白:
"a b c" → "a b c"。
測試三組:一般情況、含前後空白的字串、完全沒有多餘空白的字串。
輸出用 [%s] 包起來,看得見頭尾空白。
解題思路
- 重新表述規則:走訪每個字元—— 非空白 → 一律保留;空白 → 只有「前一個保留的字元不是空白」時才保留一個。 也就是:每串連續空白只留第一個。
- 就地改寫 = 讀寫雙索引(單元 07 練習 6 的技巧移植到字串):
read逐字元掃、write指向下一個保留字元的落點; 保留就s[write++] = s[read],跳過就只動read。 - 狀態旗標:
prev_was_space記住「上一個寫入的是不是空白」, 決定這個空白要不要寫。這其實是一台兩狀態的狀態機——wc 的 IN/OUT 的近親。 - 最容易忘的一步:迴圈結束後
s[write] = '\0';—— 新字串比舊的短,不補結尾的話,%s會把舊資料的殘尾一起印出來。 - 測資與觀察:
" leading and trailing "壓縮後頭尾各留一個空白 (本題規則只壓縮、不修剪;修剪頭尾是另一題)。用[%s]印才看得出來。
參考解答
#include <stdio.h>
/* 就地壓縮連續空白:
* 規則——空白只有在「前一個寫入的字元不是空白」時才寫入 */
void squeeze_spaces(char s[])
{
int write = 0; /* 下一個字元要寫到哪 */
int prev_was_space = 0;
for (int read = 0; s[read] != '\0'; read++) {
if (s[read] == ' ') {
if (!prev_was_space) {
s[write] = ' '; /* 這一串空白的第一個:保留 */
write++;
}
prev_was_space = 1; /* 之後的連續空白全部跳過 */
} else {
s[write] = s[read];
write++;
prev_was_space = 0;
}
}
s[write] = '\0'; /* 別忘了補上新的字串結尾! */
}
int main(void)
{
/* 用陣列(可寫)而不是指向字串常值的指標(不可寫) */
char t1[] = "a b c";
char t2[] = " leading and trailing ";
char t3[] = "no-extra-spaces";
printf("before: [%s]\n", t1);
squeeze_spaces(t1);
printf("after : [%s]\n\n", t1);
printf("before: [%s]\n", t2);
squeeze_spaces(t2);
printf("after : [%s]\n\n", t2);
printf("before: [%s]\n", t3);
squeeze_spaces(t3);
printf("after : [%s]\n", t3);
return 0;
}
(完整檔案:exercises/ex4_squeeze_spaces.c)
程式碼講解
- 測資宣告成
char t1[] = "..."(陣列,內容可寫)—— 寫成char *t1 = "..."的話,squeeze_spaces一動手就是未定義行為 (字串常值可能在唯讀記憶體,README 陷阱 4)。 write <= read恆成立,所以「邊讀邊寫同一個陣列」不會蓋掉還沒讀的資料—— 就地演算法安全性的關鍵論證。- 第三組測資(無多餘空白)輸出與輸入相同——「什麼都不該做時真的什麼都沒做」 也是需要驗證的行為。
範例輸出
before: [a b c]
after : [a b c]
before: [ leading and trailing ]
after : [ leading and trailing ]
before: [no-extra-spaces]
after : [no-extra-spaces]
練習 5:自製 atoi(難度:進階)
題目
寫 int my_atoi(const char s[]),把數字字串轉成 int:
- 基本:
"1234"→ 1234(提示:result = result * 10 + (s[i] - '0')); - 進階:跳過前導空白、處理
+/-正負號; - 遇到第一個非數字字元就停(
"56abc"→ 56;"abc"→ 0)。
解題思路
- 把輸入格式拆成三段:
[空白][正負號][數字串][其他]—— 程式就照這個順序寫三段,每段一個小迴圈/判斷,結構與格式一一對應。 - 第一段:
while (s[i] == ' ' || s[i] == '\t') i++;掃過前導空白。 - 第二段:至多一個正負號。用
sign = ±1記錄,先記下、最後才乘—— 讓累積迴圈只處理非負數,邏輯最單純。 - 第三段(核心公式):
result = result * 10 + (s[i] - '0')。 推導:讀到新的一位時,舊結果代表的數量級全部進一位(×10), 新位補在個位。"1234"的過程:0→1→12→123→1234。s[i] - '0'靠'0'~'9'連續的保證把字元變回數值。 - 停止條件就是驗證:
s[i] >= '0' && s[i] <= '9'不成立就跳出——"56abc"讀完5、6遇'a'停,回傳 56;"abc"與""一位都沒讀,result保持 0。不需要特判,結構天然正確。 - 已知侷限(誠實面對):超出
int範圍的輸入(如"9999999999")會溢位, 標準atoi同樣未定義;工業級的作法是strtol(中階單元)。
參考解答
#include <stdio.h>
int my_atoi(const char s[])
{
int i = 0;
/* 第一步:跳過前導空白(空格與 tab) */
while (s[i] == ' ' || s[i] == '\t') {
i++;
}
/* 第二步:處理正負號(至多一個) */
int sign = 1;
if (s[i] == '+' || s[i] == '-') {
if (s[i] == '-') {
sign = -1;
}
i++;
}
/* 第三步:逐位累積——「左移一位再加新位」
* '7' - '0' == 7:字元差值把數字字元變回數值 */
int result = 0;
while (s[i] >= '0' && s[i] <= '9') {
result = result * 10 + (s[i] - '0');
i++;
}
/* 遇到第一個非數字字元(含 '\0')自然停止 */
return sign * result;
}
int main(void)
{
const char *tests[] = {
"1234", /* 基本 */
" 42", /* 前導空白 */
"-273", /* 負號 */
"+99", /* 正號 */
"56abc", /* 尾隨垃圾:讀到 56 停 */
"abc", /* 開頭就不是數字:0 */
"", /* 空字串:0 */
};
int n = (int)(sizeof tests / sizeof tests[0]);
for (int i = 0; i < n; i++) {
printf("my_atoi(\"%s\") = %d\n", tests[i], my_atoi(tests[i]));
}
return 0;
}
(完整檔案:exercises/ex5_my_atoi.c)
程式碼講解
result * 10 + digit與單元 05 數字反轉的公式同源——「逐位建數」是通用模式, 只是這次數字來自字元。- 三段式結構讓每個需求(空白、符號、數字)各有歸屬, 之後要支援十六進位或溢位偵測,都知道該改哪一段。
- 索引
i貫穿三段不歸零——它是「目前讀到哪」的單一事實來源。
範例輸出
my_atoi("1234") = 1234
my_atoi(" 42") = 42
my_atoi("-273") = -273
my_atoi("+99") = 99
my_atoi("56abc") = 56
my_atoi("abc") = 0
my_atoi("") = 0
練習 6:單字長度直方圖(難度:挑戰,K&R 習題 1-13)
題目
統計一段寫死文字中每個「單字長度」的出現次數,畫出水平直方圖。 單字的定義:連續的非空白字元。需要組合兩個技巧: wc 的 IN/OUT 狀態機(偵測單字邊界)+「長度當索引」的計數陣列。 超過 15 的長度歸入 15;沒出現過的長度不佔版面。
解題思路
- 問題拆解:
- 子問題 A:怎麼知道「一個單字結束了、它多長」?→ 狀態機;
- 子問題 B:長度算出來之後記到哪?→
lengths[k]++(K&R 映射計數)。 - 狀態機設計:兩個狀態
IN(單字內)/OUT(單字外), 加一個累計器word_len: - 讀到非空白:state = IN、word_len++; - 讀到空白類:若state == IN,表示一個單字剛結束—— 此刻把word_len記帳(lengths[k]++)並歸零;然後state = OUT。 「記帳的時機在離開單字的那一刻」是整台狀態機的靈魂。 - 最後一個單字的陷阱:文字結尾若是字母,迴圈結束時最後一個單字還沒記帳!
解法:把
'\0'也當成空白處理——掃描到'\0'時先走「單字結束」的記帳流程, 再 break。邊界情況融進一般規則,不需要迴圈外補一段重複邏輯。 - 計數陣列的防越界:長度理論上可以很大,
k = (word_len <= MAX_LEN) ? word_len : MAX_LEN把超長的歸到最後一格。 - 輸出:跳過
lengths[k] == 0的長度(continue); 內層迴圈印星號;順手加總驗證單字總數。
參考解答
#include <stdio.h>
#define MAX_LEN 15 /* 只統計 1~15 的長度,更長的歸到 15 */
#define IN 1 /* 狀態:在單字內 */
#define OUT 0 /* 狀態:在單字外 */
int main(void)
{
/* 寫死的測試文字(K&R 開頭段落的精神) */
const char text[] =
"C is a general purpose programming language which features "
"economy of expression modern control flow and data structures "
"and a rich set of operators";
int lengths[MAX_LEN + 1] = { 0 }; /* lengths[k]:長度 k 的單字數(索引 0 不用) */
int state = OUT; /* 一開始在單字外 */
int word_len = 0; /* 目前單字累積的長度 */
/* 狀態機掃描:
* 空白 → 若剛結束一個單字,把它的長度記帳
* 非空白 → 進入/停留在單字內,長度 +1 */
for (int i = 0; ; i++) {
char c = text[i];
if (c == ' ' || c == '\t' || c == '\n' || c == '\0') {
if (state == IN) { /* 單字剛結束:記帳 */
int k = (word_len <= MAX_LEN) ? word_len : MAX_LEN;
lengths[k]++;
word_len = 0;
}
state = OUT;
if (c == '\0') { /* 字串結尾:連最後一個單字都記完了 */
break;
}
} else {
state = IN;
word_len++;
}
}
/* 印水平直方圖 */
printf("word length histogram:\n\n");
printf("len count histogram\n");
printf("--- ----- ---------\n");
int total = 0;
for (int k = 1; k <= MAX_LEN; k++) {
if (lengths[k] == 0) {
continue; /* 沒出現過的長度不佔版面 */
}
printf("%3d %5d ", k, lengths[k]);
for (int j = 0; j < lengths[k]; j++) {
printf("*");
}
printf("\n");
total += lengths[k];
}
printf("\ntotal words: %d\n", total);
return 0;
}
(完整檔案:exercises/ex6_word_histogram.c)
程式碼講解
for (int i = 0; ; i++)條件留空 = 永真,靠'\0'分支裡的break離開—— 因為「結尾處理」與「空白處理」共用邏輯,把終止判斷放進本體反而最乾淨。- 把
'\0'併入空白判斷,一石二鳥:終止掃描 + 結算最後一個單字。 自己重寫時最常見的 bug 就是漏掉最後一個單字——測一段「結尾不是空白」的文字即可抓到。 #define IN 1 / OUT 0給狀態取名字:state == IN讀起來是句子,state == 1是謎語。- 這台狀態機加上「印星號」的迴圈,就是 K&R 習題 1-13 的完整解—— 狀態機是文字處理的基本功,中階的詞法分析會再見到它。
範例輸出
word length histogram:
len count histogram
--- ----- ---------
1 3 ***
2 3 ***
3 3 ***
4 3 ***
5 1 *
6 1 *
7 4 ****
8 2 **
9 1 *
10 2 **
11 1 *
total words: 24
常見錯誤解法對照
| 錯誤寫法 | 症狀 | 正解 |
|---|---|---|
char c; while ((c = getchar()) != EOF) |
EOF 裝不進 char:無窮迴圈或提早停 | int c; |
| 轉大寫不判斷範圍 | 數字、標點被減 32 變亂碼 | 先 if (c >= 'a' && c <= 'z') |
尾索引用 my_strlen(s) |
指到 '\0',第一對就失配 |
len - 1 |
就地改寫後忘記補 '\0' |
%s 印出舊資料殘尾 |
迴圈後 s[write] = '\0'; |
char *t = "..." 再就地修改 |
未定義行為(唯讀記憶體) | 用 char t[] = "..." |
| 狀態機漏掉最後一個單字 | 總數少 1 | 把 '\0' 當空白一起處理 |
用 == 比較字串 |
比的是位址,恆假 | 逐字元比較或 strcmp |