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

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

練習 1:ASCII 對照表(難度:基礎)

題目

印出 'A' 到 'Z' 的 ASCII 對照表(字元與數值成對),每列 6 組, 最後一列不足 6 組也要正確換行。

解題思路

  1. 字元直接當迴圈計數:'A'~'Z' 在 ASCII 中連續, for (char c = 'A'; c <= 'Z'; c++) 就能走完 26 個字母—— 這是「字元就是小整數」的直接應用。
  2. 同一個值、兩種印法:printf("%c=%2d", c, c)—— 同一個 c 傳兩次,%c 印字母、%d 印編碼。
  3. 每列 6 組的斷行邏輯:維護「本列已印組數」計數器 printed, printed % 6 == 0 時換行。這是排版的萬用模式(每 N 個換一行)。
  4. 收尾: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!" 逐字元轉換,印出全大寫與全小寫兩個版本。

解題思路

  1. 函式的合約先想清楚:輸入任意字元—— 小寫字母 → 轉大寫;其餘(大寫、數字、標點、空白)→ 原樣回傳。 「不該動的不動」跟「該轉的轉對」一樣重要。
  2. 轉換公式(單元 02 練習 5 的成果直接拿來用): ASCII 大小寫差固定 32 = 'a' - 'A'; 小寫轉大寫是 c - ('a' - 'A')、大寫轉小寫是 c + ('a' - 'A')。
  3. 先判斷再轉換:if (c >= 'a' && c <= 'z') 守住範圍, 否則 '5' 也會被減 32 變成別的字元。
  4. 逐字元走訪字串:for (int i = 0; text[i] != '\0'; i++)—— 用哨兵 '\0' 當終點,不需要先算長度。
  5. 輸出用 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"、""(空字串)。

解題思路

  1. 拆成兩個子問題: - 找到「最後一個字元」在哪 → 需要長度 → 先寫 my_strlen(數到 '\0' 為止); - 頭尾配對比較 → 雙索引夾擠(單元 07 練習 3 反轉陣列的同款技巧,這次只比較不交換)。
  2. my_strlen:while (s[i] != '\0') i++;,回傳 i。 注意 '\0' 不算在長度內,所以尾索引是 len - 1。
  3. 夾擠邏輯:i 從 0、j 從 len - 1; s[i] != s[j] → 立刻回傳 0(一個反例就足以下結論); 全部配對成功(i >= j)→ 回傳 1。
  4. 邊界自動正確:空字串 len = 0,j = -1,while (i < j) 一次都不跑 → 回傳 1; 單字元同理。「空字串是回文」是合理的數學約定,而且不需要特判—— 好的迴圈條件常讓邊界不勞而獲。
  5. 參數加 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] 包起來,看得見頭尾空白。

解題思路

  1. 重新表述規則:走訪每個字元—— 非空白 → 一律保留;空白 → 只有「前一個保留的字元不是空白」時才保留一個。 也就是:每串連續空白只留第一個。
  2. 就地改寫 = 讀寫雙索引(單元 07 練習 6 的技巧移植到字串): read 逐字元掃、write 指向下一個保留字元的落點; 保留就 s[write++] = s[read],跳過就只動 read。
  3. 狀態旗標:prev_was_space 記住「上一個寫入的是不是空白」, 決定這個空白要不要寫。這其實是一台兩狀態的狀態機——wc 的 IN/OUT 的近親。
  4. 最容易忘的一步:迴圈結束後 s[write] = '\0';—— 新字串比舊的短,不補結尾的話,%s 會把舊資料的殘尾一起印出來。
  5. 測資與觀察:" 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:

  1. 基本:"1234" → 1234(提示:result = result * 10 + (s[i] - '0'));
  2. 進階:跳過前導空白、處理 +/- 正負號;
  3. 遇到第一個非數字字元就停("56abc" → 56;"abc" → 0)。

解題思路

  1. 把輸入格式拆成三段:[空白][正負號][數字串][其他]—— 程式就照這個順序寫三段,每段一個小迴圈/判斷,結構與格式一一對應。
  2. 第一段:while (s[i] == ' ' || s[i] == '\t') i++; 掃過前導空白。
  3. 第二段:至多一個正負號。用 sign = ±1 記錄,先記下、最後才乘—— 讓累積迴圈只處理非負數,邏輯最單純。
  4. 第三段(核心公式):result = result * 10 + (s[i] - '0')。 推導:讀到新的一位時,舊結果代表的數量級全部進一位(×10), 新位補在個位。"1234" 的過程:0→1→12→123→1234。 s[i] - '0' 靠 '0'~'9' 連續的保證把字元變回數值。
  5. 停止條件就是驗證:s[i] >= '0' && s[i] <= '9' 不成立就跳出—— "56abc" 讀完 5、6 遇 'a' 停,回傳 56; "abc" 與 "" 一位都沒讀,result 保持 0。不需要特判,結構天然正確。
  6. 已知侷限(誠實面對):超出 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;沒出現過的長度不佔版面。

解題思路

  1. 問題拆解: - 子問題 A:怎麼知道「一個單字結束了、它多長」?→ 狀態機; - 子問題 B:長度算出來之後記到哪?→ lengths[k]++(K&R 映射計數)。
  2. 狀態機設計:兩個狀態 IN(單字內)/ OUT(單字外), 加一個累計器 word_len: - 讀到非空白:state = IN、word_len++; - 讀到空白類:若 state == IN,表示一個單字剛結束—— 此刻把 word_len 記帳(lengths[k]++)並歸零;然後 state = OUT。 「記帳的時機在離開單字的那一刻」是整台狀態機的靈魂。
  3. 最後一個單字的陷阱:文字結尾若是字母,迴圈結束時最後一個單字還沒記帳! 解法:把 '\0' 也當成空白處理——掃描到 '\0' 時先走「單字結束」的記帳流程, 再 break。邊界情況融進一般規則,不需要迴圈外補一段重複邏輯。
  4. 計數陣列的防越界:長度理論上可以很大, k = (word_len <= MAX_LEN) ? word_len : MAX_LEN 把超長的歸到最後一格。
  5. 輸出:跳過 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