本檔收錄 5 題由易到難的練習。建議流程:先蓋住解答自己寫,卡住再看 「解題思路」,寫完對照「參考解答」。每題完整程式碼在 exercises/:

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

練習 1:滿時自動倍增的動態堆疊(難度:基礎)

題目

為陣列堆疊加上 stack_grow:滿時用 realloc 把容量倍增。 從初始容量 2 開始 push 1..10,每次 push 印出目前 size/cap, 觀察成長事件;最後全部 pop 驗證 LIFO 順序。 realloc 失敗時不得洩漏原緩衝區。

解題思路

  1. 為什麼倍增而不是 +1:每次 +1 的話,第 n 次 push 都要搬 n 個元素,總成本 O(n²)。倍增讓搬移攤在指數成長的間隔上: push n 個元素的總搬移量 ≤ 2n,攤銷 O(1)——這是 std::vector、Python list 等所有動態陣列的共同策略。
  2. realloc 的兩條安全規則: - 結果先存暫時變數。s->data = realloc(s->data, ...) 在 失敗(回 NULL)時把唯一的舊指標蓋掉——那塊記憶體再也 free 不到,教科書級洩漏。 - realloc 可能搬家:舊指標一律作廢;程式印出 buffer moved 事件讓你親眼看到搬家發生。
  3. grow 的時機:push 開頭檢查 top == cap 才 grow—— 懶惰成長,pop 不縮容(實務上縮容要遲滯處理防抖動)。
  4. 結構設計:top 同時是「元素個數」與「下一個空位索引」, push 是 data[top++]、pop 是 data[--top],對稱好記。

參考解答

/*
 * ex1_stack_grow.c — 練習 1:滿時自動倍增的動態堆疊
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_stack_grow.c
 * 執行:/tmp/out
 *
 * 為陣列堆疊加上 stack_grow:滿時用 realloc 倍增容量。
 * 倍增(而非 +1)讓 push 的攤銷(amortized)成本是 O(1)。
 */
#include <stdio.h>
#include <stdlib.h>

struct stack {
    int    *data;
    size_t  top;        /* 下一個空位的索引 = 目前元素數 */
    size_t  cap;
};

static void stack_init(struct stack *s, size_t initial_cap)
{
    s->data = malloc(initial_cap * sizeof *s->data);
    if (s->data == NULL) {
        fprintf(stderr, "out of memory\n");
        exit(1);
    }
    s->top = 0;
    s->cap = initial_cap;
}

/*
 * 倍增容量。realloc 重點:
 *   1. 結果先存暫時變數——直接寫 s->data = realloc(s->data, ...)
 *      失敗時舊指標被蓋掉,記憶體洩漏
 *   2. realloc 可能搬家:舊指標一律作廢,透過 s->data 重新使用
 */
static void stack_grow(struct stack *s)
{
    size_t new_cap = s->cap * 2;
    int *p = realloc(s->data, new_cap * sizeof *p);

    if (p == NULL) {
        free(s->data);
        fprintf(stderr, "out of memory growing stack\n");
        exit(1);
    }
    printf("    [grow: %zu -> %zu slots%s]\n",
           s->cap, new_cap, p == s->data ? "" : ", buffer moved");
    s->data = p;
    s->cap  = new_cap;
}

static void push(struct stack *s, int v)
{
    if (s->top == s->cap)           /* 滿了才長大 */
        stack_grow(s);
    s->data[s->top++] = v;
}

static int pop(struct stack *s)
{
    if (s->top == 0) {
        fprintf(stderr, "stack underflow\n");
        exit(1);
    }
    return s->data[--s->top];
}

static void stack_free(struct stack *s)
{
    free(s->data);
    s->data = NULL;
    s->top = s->cap = 0;
}

int main(void)
{
    struct stack s;
    stack_init(&s, 2);              /* 刻意從 2 開始,逼出幾次 grow */

    printf("push 1..10 into a stack with initial capacity 2:\n");
    for (int i = 1; i <= 10; i++) {
        push(&s, i);
        printf("  push %2d  (size=%zu, cap=%zu)\n", i, s.top, s.cap);
    }

    printf("\npop everything (LIFO order):\n ");
    while (s.top > 0)
        printf(" %d", pop(&s));
    printf("\n");

    stack_free(&s);
    return 0;
}

程式碼講解

  • stack_grow 的暫時變數 p:失敗路徑先 free(s->data) 再退出 ——即使要死,也不留下洩漏(若程式要繼續跑,則保留舊緩衝區 並回報錯誤,由呼叫端決定)。
  • sizeof *p 而非 sizeof(int):型別改了配置大小自動跟上, 是 malloc/realloc 的防禦性寫法。
  • 輸出顯示 grow 發生在 push 3、5、9 之前——容量 2、4、8 的 邊界,成長次數是 log₂(n) 次,不是 n 次。
  • 易錯處:把 grow 寫在 push 之後(先寫入越界位置再擴容), heap 溢位;或忘記更新 s->cap 導致每次 push 都 realloc。

範例輸出

push 1..10 into a stack with initial capacity 2:
  push  1  (size=1, cap=2)
  push  2  (size=2, cap=2)
    [grow: 2 -> 4 slots]
  push  3  (size=3, cap=4)
  push  4  (size=4, cap=4)
    [grow: 4 -> 8 slots, buffer moved]
  push  5  (size=5, cap=8)
  push  6  (size=6, cap=8)
  push  7  (size=7, cap=8)
  push  8  (size=8, cap=8)
    [grow: 8 -> 16 slots, buffer moved]
  push  9  (size=9, cap=16)
  push 10  (size=10, cap=16)

pop everything (LIFO order):
  10 9 8 7 6 5 4 3 2 1

(buffer moved 是否出現取決於配置器當下的空間狀況。)


練習 2:用兩個堆疊實作佇列(難度:基礎)

題目

只用兩個堆疊實作 FIFO 佇列:enqueue 推入堆疊 A(in), dequeue 時若堆疊 B(out)為空,把 A 全部倒進 B 再從 B pop。 測試需交錯進行 enqueue 與 dequeue,驗證輸出順序仍是 FIFO, 並印出「倒堆疊」事件觀察它何時發生。

解題思路

  1. 原理:負負得正。堆疊反轉順序(LIFO),倒兩次就恢復 原順序(FIFO):
enqueue 1,2,3:      pour(倒過去):      dequeue:
in:  3              in:  (空)            out: 3
     2              out: 1  <- pop 端         2
     1                   2                   (pop 得 1、2…FIFO!)
                         3
  1. 倒的時機是效率關鍵:只在「out 空了且要 dequeue」時倒。 - 若每次 dequeue 都倒來倒去:O(n) 每次; - 懶惰法:每個元素一生至多被搬一次(in→out), 攤銷 O(1)。
  2. 正確性論證:out 裡的元素永遠比 in 裡的早進入佇列 (它們是上一批倒過來的),所以 out 沒空之前絕不能碰 in—— 演算法的順序保證就從這條不變量來。
  3. q_empty:兩個堆疊都空才是空;只看 in 或只看 out 都是 bug。
  4. 測試設計:enqueue 1,2,3 → dequeue ×2 → enqueue 4,5 → 全部 dequeue。中途的 enqueue 4,5 進了 in,而 3 還在 out—— 輸出必須仍是 3,4,5,第二次 pour 發生在 3 被取走之後。

參考解答

/*
 * ex2_queue_two_stacks.c — 練習 2:用兩個堆疊實作佇列
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_queue_two_stacks.c
 * 執行:/tmp/out
 *
 * enqueue 一律推入 in 堆疊;dequeue 時若 out 空,
 * 就把 in 全部倒進 out(順序反轉兩次 = 恢復 FIFO),再從 out pop。
 * 每個元素最多被搬一次:攤銷 O(1)。
 */
#include <stdio.h>
#include <stdlib.h>

#define CAP 16

/* 簡單的固定容量 int 堆疊 */
struct stack {
    int data[CAP];
    int top;                        /* 元素個數 */
};

static void  s_init(struct stack *s)        { s->top = 0; }
static int   s_empty(const struct stack *s) { return s->top == 0; }
static void  s_push(struct stack *s, int v)
{
    if (s->top == CAP) {
        fprintf(stderr, "stack overflow\n");
        exit(1);
    }
    s->data[s->top++] = v;
}
static int s_pop(struct stack *s)
{
    if (s->top == 0) {
        fprintf(stderr, "stack underflow\n");
        exit(1);
    }
    return s->data[--s->top];
}

/* 佇列 = 兩個堆疊 */
struct queue {
    struct stack in;                /* enqueue 進這裡 */
    struct stack out;               /* dequeue 從這裡 */
};

static void q_init(struct queue *q)
{
    s_init(&q->in);
    s_init(&q->out);
}

static int q_empty(const struct queue *q)
{
    return s_empty(&q->in) && s_empty(&q->out);
}

static void enqueue(struct queue *q, int v)
{
    s_push(&q->in, v);              /* O(1):只推 in */
}

/*
 * dequeue:out 有東西直接 pop;
 * out 空了才把 in 整批倒過來(LIFO 反轉一次成 FIFO)。
 * 「倒」的時機是關鍵:不能每次 enqueue/dequeue 都倒,
 * 只在 out 耗盡時倒一次,每個元素一生至多搬一次。
 */
static int dequeue(struct queue *q)
{
    if (s_empty(&q->out)) {
        if (s_empty(&q->in)) {
            fprintf(stderr, "queue underflow\n");
            exit(1);
        }
        printf("    [pour: in -> out]\n");
        while (!s_empty(&q->in))
            s_push(&q->out, s_pop(&q->in));
    }
    return s_pop(&q->out);
}

int main(void)
{
    struct queue q;
    q_init(&q);

    printf("enqueue 1, 2, 3:\n");
    enqueue(&q, 1);
    enqueue(&q, 2);
    enqueue(&q, 3);

    printf("dequeue twice (FIFO order expected):\n");
    printf("  dequeue = %d\n", dequeue(&q));
    printf("  dequeue = %d\n", dequeue(&q));

    printf("\nenqueue 4, 5 (interleaved with dequeues):\n");
    enqueue(&q, 4);
    enqueue(&q, 5);

    printf("drain the queue:\n");
    while (!q_empty(&q))
        printf("  dequeue = %d\n", dequeue(&q));

    printf("\nFIFO preserved: 1 2 3 4 5\n");
    return 0;
}

程式碼講解

  • dequeue 的巢狀檢查順序:先看 out、再看 in、真的都空才報 underflow——與不變量「out 優先」一致。
  • pour 迴圈 while (!s_empty(&q->in)) s_push(&q->out, s_pop(&q->in)); 一行完成反轉——堆疊的 pop 順序天然是反的。
  • 輸出中兩次 [pour]:第一次在最初的 dequeue(out 空), 第二次在 3 取完、要拿 4 的時候——證明「懶惰倒」的行為。
  • 這個結構在函數式語言叫 Banker's Queue,是純函數式資料 結構的經典;面試也常考(LeetCode 232)。
  • 易錯處:dequeue 時 out 非空卻先倒 in——順序錯亂 (後來的元素插隊到 out 頂端)。

範例輸出

enqueue 1, 2, 3:
dequeue twice (FIFO order expected):
    [pour: in -> out]
  dequeue = 1
  dequeue = 2

enqueue 4, 5 (interleaved with dequeues):
drain the queue:
  dequeue = 3
    [pour: in -> out]
  dequeue = 4
  dequeue = 5

FIFO preserved: 1 2 3 4 5

練習 3:最小值堆疊(難度:進階)

題目

實作「最小值堆疊」:除 push/pop 外,get_min 回傳目前 堆疊中的最小值,三個操作都必須 O(1)。 push 序列 5 3 8 1 4,每步印出 get_min;再逐一 pop, 觀察最小值如何「回復」到先前的狀態。

解題思路

  1. 難點:單一變數記最小值不夠——pop 掉目前最小值後, 「前一個最小值」是誰?要 O(1) 回答就得把歷史存起來。
  2. 輔助堆疊:mins 與主堆疊同步成長, mins[i] = 「主堆疊長到第 i 層時,整體的最小值」:
push:   5    3    8    1    4
data:   5    3    8    1    4
mins:   5    3    3    1    1
                  ^ 8 沒有更小,繼承前值 3
  1. push:mins 推 min(新值, mins 頂端); pop:兩疊同步縮短——mins 頂端自動變回舊的最小值, 這就是「歷史回復」的機制,pop 不需任何額外邏輯。
  2. get_min:讀 mins[top-1],一次陣列存取。
  3. 空間換時間:多一倍空間換三個 O(1)。省空間的變體 (mins 只在「新最小值出現」時 push)留作延伸。
  4. 邊界:空堆疊的 get_min 是使用錯誤,明確報錯。

參考解答

/*
 * ex3_min_stack.c — 練習 3:get_min 也是 O(1) 的最小值堆疊
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_min_stack.c
 * 執行:/tmp/out
 *
 * 除 push/pop 外,get_min 也要 O(1)。
 * 技巧:輔助堆疊 mins 與主堆疊同步——
 * mins 頂端永遠是「目前整個堆疊的最小值」。
 */
#include <stdio.h>
#include <stdlib.h>

#define CAP 32

struct min_stack {
    int data[CAP];      /* 主堆疊:真正的元素 */
    int mins[CAP];      /* 輔助堆疊:每層的「當時最小值」 */
    int top;
};

static void ms_init(struct min_stack *s) { s->top = 0; }

/*
 * push:主堆疊照常推;
 * mins 推「min(新值, 舊的最小值)」——每一層都記錄
 * 「堆疊長到這裡時的最小值」,pop 時歷史自動回復。
 */
static void ms_push(struct min_stack *s, int v)
{
    if (s->top == CAP) {
        fprintf(stderr, "stack overflow\n");
        exit(1);
    }
    int cur_min = (s->top == 0 || v < s->mins[s->top - 1])
                      ? v
                      : s->mins[s->top - 1];
    s->data[s->top] = v;
    s->mins[s->top] = cur_min;
    s->top++;
}

static int ms_pop(struct min_stack *s)
{
    if (s->top == 0) {
        fprintf(stderr, "stack underflow\n");
        exit(1);
    }
    return s->data[--s->top];       /* mins 同步縮短,不需額外處理 */
}

static int ms_get_min(const struct min_stack *s)
{
    if (s->top == 0) {
        fprintf(stderr, "get_min on empty stack\n");
        exit(1);
    }
    return s->mins[s->top - 1];     /* O(1):直接看 mins 頂端 */
}

static void ms_dump(const struct min_stack *s)
{
    printf("    data:");
    for (int i = 0; i < s->top; i++)
        printf(" %3d", s->data[i]);
    printf("\n    mins:");
    for (int i = 0; i < s->top; i++)
        printf(" %3d", s->mins[i]);
    printf("   <- top\n");
}

int main(void)
{
    struct min_stack s;
    ms_init(&s);

    int seq[] = { 5, 3, 8, 1, 4 };
    printf("push sequence: 5 3 8 1 4\n\n");

    for (size_t i = 0; i < sizeof seq / sizeof seq[0]; i++) {
        ms_push(&s, seq[i]);
        printf("push %d  -> get_min = %d\n", seq[i], ms_get_min(&s));
    }
    printf("\nstack layers (mins[i] = min of data[0..i]):\n");
    ms_dump(&s);

    printf("\npop and watch the minimum recover:\n");
    while (s.top > 0) {
        int v = ms_pop(&s);
        if (s.top > 0)
            printf("pop %d  -> get_min = %d\n", v, ms_get_min(&s));
        else
            printf("pop %d  -> stack empty\n", v);
    }
    return 0;
}

程式碼講解

  • push 的三元運算式處理兩種情況:堆疊空(新值就是最小)、 新值 vs 舊最小取小者。s->top == 0 的檢查放在前面靠 短路求值保護 mins[top-1] 的存取。
  • ms_pop 只有一行 --top——兩個陣列共用同一個 top, 同步縮短是「免費」的。這是平行陣列(parallel array)設計的 甜頭。
  • ms_dump 印出兩排陣列讓不變量可視化:mins[i] 恆等於 min(data[0..i]),肉眼即可驗證。
  • pop 輸出展示「最小值回復」:pop 掉 1 之後 get_min 回到 3, 再 pop 到剩 5 時回到 5——歷史完整保存。
  • 易錯處:只用一個 cur_min 變數 + 重新掃描找次小值—— get_min 退化成 O(n),不符題目要求。

範例輸出

push sequence: 5 3 8 1 4

push 5  -> get_min = 5
push 3  -> get_min = 3
push 8  -> get_min = 3
push 1  -> get_min = 1
push 4  -> get_min = 1

stack layers (mins[i] = min of data[0..i]):
    data:   5   3   8   1   4
    mins:   5   3   3   1   1   <- top

pop and watch the minimum recover:
pop 4  -> get_min = 1
pop 1  -> get_min = 3
pop 8  -> get_min = 3
pop 3  -> get_min = 5
pop 5  -> stack empty

練習 4:RPN 計算機加上 %、neg、dup(難度:進階)

題目

為逆波蘭(RPN)計算機新增三個指令:

  • %:取餘(二元;除數為 0 要報錯,用整數語意);
  • neg:頂端取負(一元);
  • dup:複製頂端(堆疊操作,不是算術)。

測資需展示:17 5 % = 2、7 neg = -7、4 dup * = 16(平方)、 3 dup dup * * = 27(立方)、組合 10 3 % neg = -1。

解題思路

  1. RPN 求值的核心不變量:掃到數字就 push;掃到運算子就 pop 出運算元、算完 push 回去。運算式合法 ⇔ 結束時堆疊 恰剩一個值。
  2. 二元運算子的 pop 順序(老陷阱):先 pop 出來的是 右運算元:
double b = pop(&s), a = pop(&s);   /* b 右、a 左 */
push(&s, a - b);                   /* 17 5 - 是 17-5 */
  1. % 的型別問題:C 的 % 只接受整數;堆疊存 double, 所以轉 (long) 再取餘。除數為 0 直接報錯(整數除以 0 是 UB, 不像浮點有 inf)。
  2. neg 是一元:只 pop 一個。為什麼需要它?RPN 的數字 token-7 可以直接寫,但「對運算結果取負」沒有中序的一元 減號可用,neg 補上這個表達力。
  3. dup 是堆疊操作:pop 一個、push 兩次。有了 dup, 「平方」不需要把運算元寫兩遍——這正是 Forth/PostScript 這類堆疊語言的日常詞彙(還有 swap、drop、over…)。
  4. 收尾檢查:s.top != 1 時報「剩餘值」錯誤—— 抓出 3 4(缺運算子)這類壞運算式。

參考解答

/*
 * ex4_rpn_extended.c — 練習 4:RPN 計算機加上 %、neg、dup
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_rpn_extended.c
 * 執行:/tmp/out
 *
 * 逆波蘭(後序)計算機:數字推入堆疊,運算子取出運算元再推回結果。
 * 新增三個指令:
 *   %    取餘(二元;除數為 0 報錯)
 *   neg  頂端取負(一元)
 *   dup  複製頂端(堆疊操作)
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>

#define CAP 64

struct stack {
    double data[CAP];
    int    top;
};

static void push(struct stack *s, double v)
{
    if (s->top == CAP) {
        fprintf(stderr, "rpn: stack overflow\n");
        exit(1);
    }
    s->data[s->top++] = v;
}

static double pop(struct stack *s)
{
    if (s->top == 0) {
        fprintf(stderr, "rpn: stack underflow (missing operand?)\n");
        exit(1);
    }
    return s->data[--s->top];
}

/*
 * 求值一個以空白分隔的 RPN 運算式。
 * strtok 會改寫字串,呼叫端傳入可寫的複本。
 */
static double rpn_eval(char *expr)
{
    struct stack s = { .top = 0 };
    char *tok = strtok(expr, " ");

    while (tok != NULL) {
        if (isdigit((unsigned char)tok[0]) ||
            (tok[0] == '-' && isdigit((unsigned char)tok[1]))) {
            push(&s, atof(tok));            /* 數字(含負數字面值) */
        } else if (strcmp(tok, "+") == 0) {
            double b = pop(&s), a = pop(&s);
            push(&s, a + b);
        } else if (strcmp(tok, "-") == 0) {
            double b = pop(&s), a = pop(&s);
            push(&s, a - b);                /* 順序:a 先進 = 左運算元 */
        } else if (strcmp(tok, "*") == 0) {
            double b = pop(&s), a = pop(&s);
            push(&s, a * b);
        } else if (strcmp(tok, "/") == 0) {
            double b = pop(&s), a = pop(&s);
            if (b == 0.0) {
                fprintf(stderr, "rpn: division by zero\n");
                exit(1);
            }
            push(&s, a / b);
        } else if (strcmp(tok, "%") == 0) {         /* 新增:取餘 */
            double b = pop(&s), a = pop(&s);
            if ((long)b == 0) {
                fprintf(stderr, "rpn: modulo by zero\n");
                exit(1);
            }
            push(&s, (double)((long)a % (long)b)); /* 整數語意 */
        } else if (strcmp(tok, "neg") == 0) {       /* 新增:取負 */
            push(&s, -pop(&s));                    /* 一元:只取一個 */
        } else if (strcmp(tok, "dup") == 0) {       /* 新增:複製頂端 */
            double v = pop(&s);
            push(&s, v);
            push(&s, v);
        } else {
            fprintf(stderr, "rpn: unknown token '%s'\n", tok);
            exit(1);
        }
        tok = strtok(NULL, " ");
    }

    if (s.top != 1) {                       /* 好的運算式恰剩一個值 */
        fprintf(stderr, "rpn: %d values left on stack\n", s.top);
        exit(1);
    }
    return pop(&s);
}

static void demo(const char *expr)
{
    char buf[128];
    strncpy(buf, expr, sizeof buf - 1);
    buf[sizeof buf - 1] = '\0';
    printf("  %-24s = %g\n", expr, rpn_eval(buf));
}

int main(void)
{
    printf("RPN calculator with %%, neg, dup:\n\n");

    demo("3 4 +");                  /* 基本:3+4 */
    demo("1 2 + 3 *");              /* (1+2)*3 */
    demo("17 5 %");                 /* 17 mod 5 = 2 */
    demo("7 neg");                  /* -7 */
    demo("5 neg 3 +");              /* (-5)+3 = -2 */
    demo("4 dup *");                /* 4*4 = 16:dup 做平方 */
    demo("3 dup dup * *");          /* 3^3 = 27:dup 兩次做立方 */
    demo("10 3 % neg");             /* -(10 mod 3) = -1 */
    return 0;
}

程式碼講解

  • token 分類的第一個 if 同時接受 123 與 -7: 「- 後面跟數字」是負數字面值,不是減號—— 這是 RPN tokenizer 唯一需要「往前看」的地方。
  • strtok 會把分隔符改寫成 '\0',所以 demo 先把 字串常數複製進可寫的緩衝區——對字串常數呼叫 strtok 是 未定義行為。
  • 三個新指令各代表一類擴充:算術(%)、一元運算(neg)、 堆疊操作(dup)——加新指令只是多一個 else if 分支, 核心迴圈完全不動。
  • 3 dup dup * * 的執行軌跡值得手演一次: [3] → [3 3] → [3 3 3] → [3 9] → [27]。
  • 易錯處:a、b 順序寫反——加法乘法看不出來, 減法除法取餘立刻出錯;測資 17 5 % = 2(而非 5%17)就是驗這個。

範例輸出

RPN calculator with %, neg, dup:

  3 4 +                    = 7
  1 2 + 3 *                = 9
  17 5 %                   = 2
  7 neg                    = -7
  5 neg 3 +                = -2
  4 dup *                  = 16
  3 dup dup * *            = 27
  10 3 % neg               = -1

練習 5:中序轉後序(shunting-yard)(難度:挑戰)

題目

實作 Dijkstra 的調度場(shunting-yard)演算法:把中序運算式 (支援非負整數、+ - * /、括號)轉成後序,再餵給 RPN 求值器 驗證結果。要求正確處理:

  • 優先權(1+2*3 → 1 2 3 * +);
  • 左結合(1-2-3 → 1 2 - 3 -,等於 -4 而非 2);
  • 巢狀括號;
  • 括號不平衡時回報語法錯誤。

解題思路

  1. 為什麼可行:後序運算式不需要括號與優先權——運算順序 完全由 token 排列決定。轉換的本質是把「何時執行運算子」 的資訊從語法規則翻譯成排列順序,而堆疊恰好是暫存 「還不能輸出的運算子」的地方。
  2. 規則表(整個演算法就這五條):
看到 動作
數字 直接送輸出
運算子 op 疊頂優先權 ≥ op 者依序彈出到輸出,然後 op 入疊
( 入疊(在疊內優先權視為最低)
) 彈出到 ( 為止,( 丟棄
輸入結束 堆疊全部彈出(若遇 ( → 括號不平衡)
  1. 為什麼是「≥」不是「>」:同優先權時先彈舊的 = 左結合。1-2-3:第二個 - 進來時,疊裡的第一個 - 優先權相等 → 先彈出 → 輸出 1 2 - 之後才處理 -3, 結果 (1-2)-3。若用 > 就變右結合(1-(2-3) = 2,錯)。
  2. ( 的兩個角色:入疊時像「屏障」——規則 2 的彈出遇到 ( 要停(實作上給它優先權 0 並在 while 條件排除它); ) 來時它是「配對終點」。
  3. 錯誤偵測:) 彈到疊空還沒遇到 ( → 多了右括號; 結尾彈出時遇到 ( → 少了右括號。
  4. 驗證:轉出的後序字串直接餵 RPN 求值器(練習 4 的核心), 對照心算結果——兩個演算法互相背書。

手演 (1+2)*3:

token   輸出            運算子疊
(                       (
1       1               (
+       1               ( +
2       1 2             ( +
)       1 2 +           (空)      <- 彈到 ( 為止
*       1 2 +           *
3       1 2 + 3         *
結尾    1 2 + 3 *       (空)

參考解答

/*
 * ex5_shunting_yard.c — 練習 5:中序轉後序(shunting-yard)+ RPN 求值
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex5_shunting_yard.c
 * 執行:/tmp/out
 *
 * Dijkstra 的調度場演算法:
 *   數字   -> 直接送輸出
 *   運算子 -> 先把「優先權 >= 自己」的運算子從堆疊彈到輸出,再入疊
 *   (      -> 入疊
 *   )      -> 彈到 ( 為止(( 丟棄)
 *   結尾   -> 堆疊全部彈出
 * 轉出的後序字串餵給 RPN 求值器驗證。
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>

#define CAP 64
#define OUTSZ 256

/* ---------- 運算子堆疊(char) ---------- */
static char op_stack[CAP];
static int  op_top;

static void op_push(char c)
{
    if (op_top == CAP) {
        fprintf(stderr, "operator stack overflow\n");
        exit(1);
    }
    op_stack[op_top++] = c;
}

static char op_pop(void)  { return op_stack[--op_top]; }
static char op_peek(void) { return op_stack[op_top - 1]; }

/* 優先權:* / 高於 + -;( 在疊內優先權最低(擋住彈出) */
static int prec(char op)
{
    switch (op) {
    case '*': case '/': return 2;
    case '+': case '-': return 1;
    default:            return 0;      /* '(' */
    }
}

/* ---------- 輸出緩衝:空白分隔的後序 token ---------- */
static void out_token(char *out, const char *tok)
{
    if (out[0] != '\0')
        strncat(out, " ", OUTSZ - strlen(out) - 1);
    strncat(out, tok, OUTSZ - strlen(out) - 1);
}

/*
 * 中序 -> 後序。支援非負整數、+ - * /、括號。
 * 回傳 0 成功、-1 語法錯誤(括號不平衡)。
 */
static int infix_to_postfix(const char *infix, char *out)
{
    out[0] = '\0';
    op_top = 0;

    for (const char *p = infix; *p != '\0'; ) {
        if (*p == ' ') {
            p++;
        } else if (isdigit((unsigned char)*p)) {
            char num[32];
            int len = 0;
            while (isdigit((unsigned char)*p) && len < 31)
                num[len++] = *p++;
            num[len] = '\0';
            out_token(out, num);            /* 數字直接輸出 */
        } else if (*p == '(') {
            op_push(*p);
            p++;
        } else if (*p == ')') {
            /* 彈出到 ( 為止 */
            while (op_top > 0 && op_peek() != '(') {
                char op[2] = { op_pop(), '\0' };
                out_token(out, op);
            }
            if (op_top == 0)
                return -1;                  /* 沒有配對的 ( */
            op_pop();                       /* 丟棄 '(' */
            p++;
        } else if (strchr("+-*/", *p) != NULL) {
            /*
             * 左結合:疊頂優先權 >= 自己就先彈出。
             * 這保證 1-2-3 轉成 1 2 - 3 -(由左往右算)。
             */
            while (op_top > 0 && op_peek() != '(' &&
                   prec(op_peek()) >= prec(*p)) {
                char op[2] = { op_pop(), '\0' };
                out_token(out, op);
            }
            op_push(*p);
            p++;
        } else {
            fprintf(stderr, "unexpected char '%c'\n", *p);
            return -1;
        }
    }

    while (op_top > 0) {                    /* 收尾:全部彈出 */
        if (op_peek() == '(')
            return -1;                      /* 多的 ( 沒關 */
        char op[2] = { op_pop(), '\0' };
        out_token(out, op);
    }
    return 0;
}

/* ---------- 簡易 RPN 求值器(同練習 4 的核心) ---------- */
static double rpn_eval(char *expr)
{
    double st[CAP];
    int top = 0;

    for (char *tok = strtok(expr, " "); tok != NULL;
         tok = strtok(NULL, " ")) {
        if (isdigit((unsigned char)tok[0])) {
            st[top++] = atof(tok);
        } else {
            double b = st[--top];
            double a = st[--top];
            switch (tok[0]) {
            case '+': st[top++] = a + b; break;
            case '-': st[top++] = a - b; break;
            case '*': st[top++] = a * b; break;
            case '/': st[top++] = a / b; break;
            }
        }
    }
    return st[0];
}

static void demo(const char *infix)
{
    char postfix[OUTSZ];

    if (infix_to_postfix(infix, postfix) != 0) {
        printf("  %-18s -> syntax error\n", infix);
        return;
    }

    char copy[OUTSZ];
    strcpy(copy, postfix);
    printf("  %-18s -> %-22s = %g\n", infix, postfix, rpn_eval(copy));
}

int main(void)
{
    printf("shunting-yard: infix -> postfix -> evaluate\n\n");

    demo("(1+2)*3");            /* 題目指定的例子 */
    demo("1+2*3");              /* 優先權:乘先算 */
    demo("1-2-3");              /* 左結合:(1-2)-3 = -4 */
    demo("2*(3+4)-5");
    demo("10/(5-3)");
    demo("((2+3)*(4-1))");      /* 巢狀括號 */
    demo("(1+2");               /* 括號不平衡:報錯 */
    return 0;
}

程式碼講解

  • prec() 給 ( 回 0:讓規則 2 的 prec(op_peek()) >= prec(*p) 自然擋住 ((雙保險:while 條件也明確排除 ()。
  • 多位數字的 tokenize:while (isdigit...) 收集連續數字字元—— 比逐字元處理的玩具版實用一大步。
  • 輸出緩衝用「空白分隔字串」而非陣列:轉換結果本身就是 可讀的後序運算式,直接印出、直接餵求值器。
  • infix_to_postfix 回傳錯誤碼而不是 exit:語法錯誤是 輸入的錯不是程式的錯,讓呼叫端決定如何處理 (demo 印 syntax error 繼續跑下一筆)。
  • RPN 求值器是練習 4 的精簡版——管線「轉換 → 求值」的兩級 各自可測,這是編譯器前端(parser → evaluator)的微縮模型。
  • 易錯處一:規則 2 用 > 導致左結合變右結合,1-2-3 算出 2。
  • 易錯處二:) 處理完忘記丟棄 ((只彈不丟), 之後的運算子被擋在假屏障後面,輸出順序全亂。

範例輸出

shunting-yard: infix -> postfix -> evaluate

  (1+2)*3            -> 1 2 + 3 *              = 9
  1+2*3              -> 1 2 3 * +              = 7
  1-2-3              -> 1 2 - 3 -              = -4
  2*(3+4)-5          -> 2 3 4 + * 5 -          = 9
  10/(5-3)           -> 10 5 3 - /             = 5
  ((2+3)*(4-1))      -> 2 3 + 4 1 - *          = 15
  (1+2               -> syntax error

常見錯誤解法對照

錯誤寫法 症狀 正解
s->data = realloc(s->data, ...) 失敗時洩漏舊緩衝區 結果先存暫時變數(練習 1)
容量 +1 成長 push 總成本 O(n²) 倍增,攤銷 O(1)(練習 1)
out 非空就倒 in FIFO 順序錯亂 out 耗盡才倒(練習 2)
單變數記最小值 pop 後找不回前最小 輔助堆疊存歷史(練習 3)
二元運算 pop 順序反了 17 5 - 算成 -12 先 pop 的是右運算元(練習 4)
shunting-yard 用 > 比優先權 左結合變右結合 用 >=(練習 5)
) 彈完不丟 ( 後續輸出全亂 配對後 ( 丟棄(練習 5)