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

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

貫穿本單元的功夫:走訪模式 for (p = head; p; p = p->next)、 釋放模式「先存 next 再 free」、以及指標的指標消除特例。


練習 1:list_min / list_max / list_sum(難度:基礎)

題目

為單向串列加上三個查詢函式:

int  list_min(const struct node *head, int *found);
int  list_max(const struct node *head, int *found);
long list_sum(const struct node *head);

空串列時 min/max 要能明確回報「沒有結果」(不能拿 0 或隨機值 混充),sum 對空串列回 0。所有配置的節點最後必須釋放。

解題思路

  1. 三個函式共用同一個走訪骨架:
for (const struct node *p = head; p != NULL; p = p->next)

差別只在迴圈體做什麼(比較 vs 累加)。 2. 空串列的 min/max 是介面設計題:最小值不存在時回什麼? - 回 0:與「真的最小值是 0」無法區分——錯誤設計; - 回 INT_MAX:同樣可能撞到真值; - 輸出參數 int *found:把「有沒有結果」與「結果是什麼」 分成兩個通道,呼叫端無從誤解。這是 C 沒有 optional 型別時 的標準解法。 3. sum 不需要旗標:空和 = 0 在數學上與工程上都自然。 累加器用 long 防 int 溢位(元素多時)。 4. 初始化技巧(同函式指標單元的 max_n):min/max 用第一個 節點的值起手,從第二個節點開始比,不要用魔術常數。 5. 釋放模式:next = head->next; free(head); head = next;—— free 之後不能再讀節點,順序不能倒。

參考解答

/*
 * ex1_list_queries.c — 練習 1:list_min / list_max / list_sum
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_list_queries.c
 * 執行:/tmp/out
 *
 * 為單向串列加上三個查詢函式。走訪模式都一樣:
 * for (p = head; p != NULL; p = p->next)
 */
#include <stdio.h>
#include <stdlib.h>

struct node {
    int          value;
    struct node *next;
};

/* 頭插建立節點(O(1),串列順序與插入順序相反) */
static struct node *push_front(struct node *head, int value)
{
    struct node *n = malloc(sizeof *n);
    if (n == NULL) {
        fprintf(stderr, "out of memory\n");
        exit(1);
    }
    n->value = value;
    n->next  = head;
    return n;
}

/* min/max 對空串列沒有意義:用回傳值 + found 旗標處理 */
static int list_min(const struct node *head, int *found)
{
    if (head == NULL) {
        *found = 0;
        return 0;
    }
    int best = head->value;
    for (const struct node *p = head->next; p != NULL; p = p->next)
        if (p->value < best)
            best = p->value;
    *found = 1;
    return best;
}

static int list_max(const struct node *head, int *found)
{
    if (head == NULL) {
        *found = 0;
        return 0;
    }
    int best = head->value;
    for (const struct node *p = head->next; p != NULL; p = p->next)
        if (p->value > best)
            best = p->value;
    *found = 1;
    return best;
}

/* sum 對空串列自然回 0,不需旗標 */
static long list_sum(const struct node *head)
{
    long sum = 0;
    for (const struct node *p = head; p != NULL; p = p->next)
        sum += p->value;
    return sum;
}

static void print_list(const char *tag, const struct node *head)
{
    printf("%s", tag);
    for (const struct node *p = head; p != NULL; p = p->next)
        printf(" %d", p->value);
    printf("\n");
}

static void free_list(struct node *head)
{
    while (head != NULL) {
        struct node *next = head->next;   /* 先存 next 再 free */
        free(head);
        head = next;
    }
}

int main(void)
{
    /* 頭插 7 個值:串列順序是插入順序的反轉 */
    int data[] = { 42, -7, 19, 0, 88, -3, 5 };
    struct node *head = NULL;
    for (size_t i = 0; i < sizeof data / sizeof data[0]; i++)
        head = push_front(head, data[i]);

    print_list("list:", head);

    int found;
    int mn = list_min(head, &found);
    printf("min = %d (found=%d)\n", mn, found);
    int mx = list_max(head, &found);
    printf("max = %d (found=%d)\n", mx, found);
    printf("sum = %ld\n", list_sum(head));

    /* 空串列的行為 */
    int f2;
    list_min(NULL, &f2);
    printf("empty list: found=%d, sum=%ld\n", f2, list_sum(NULL));

    free_list(head);
    return 0;
}

程式碼講解

  • 參數型別是 const struct node *:查詢函式不改串列, 用 const 把這個承諾寫進介面,編譯器會幫忙看守。
  • list_min 的迴圈從 head->next 開始——head 的值已經當初始值 用掉了,再比一次不錯但多餘。
  • 測資用頭插建立,所以印出順序與插入順序相反——這不影響 min/max/sum(它們與順序無關),但提醒你頭插的特性。
  • 易錯處:釋放迴圈寫成 free(head); head = head->next;—— 讀取已釋放的記憶體(use-after-free),ASan 一抓一個準。

範例輸出

list: 5 -3 88 0 19 -7 42
min = -7 (found=1)
max = 88 (found=1)
sum = 144
empty list: found=0, sum=0

練習 2:O(1) push_back(難度:基礎)

題目

樸素的尾插要先走到尾巴(O(n))。實作 O(1) 版本: 維護一個含 head 與 tail 的包裝結構

struct list { struct node *head; struct node *tail; size_t len; };

提供 push_back、push_front、print_list、list_free, 並示範 tail 在各操作後的位置。

解題思路

  1. 問題根源:單向串列只記 head,找尾巴必須走完整條。 空間換時間:多存一個 tail 指標,尾插變成三行。
  2. 包裝結構的價值:head/tail/len 三個欄位必須同進退, 包成一個 struct 之後,所有操作收一個 struct list *, 不變量(tail 永遠指向最後一個節點)由這組函式共同維護。
  3. push_back 的兩種情況:
空串列:                   非空串列:
head ──> NULL              head ──> [1] ──> [2]
tail ──> NULL                                ^tail
插入後:                   插入後:
head ──> [n]               head ──> [1] ──> [2] ──> [n]
tail ──> [n]                                         ^tail

空串列時 head 也要指向新節點;非空時接在 tail->next。 4. push_front 也要顧 tail:對空串列頭插,tail 同樣要設—— 忘記這條是最常見的 bug(之後第一次 push_back 會接到 NULL 上)。 5. 釋放後歸零:list_free 最後把三個欄位重設, 防止呼叫端拿懸空的 head/tail 繼續用。

參考解答

/*
 * ex2_push_back_o1.c — 練習 2:O(1) 的 push_back(head + tail 包裝結構)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_push_back_o1.c
 * 執行:/tmp/out
 *
 * 樸素的 push_back 要先走到尾巴:O(n)。
 * 解法:把串列包進 struct list { head, tail, len },
 * tail 永遠指向最後一個節點,尾插變 O(1)。
 */
#include <stdio.h>
#include <stdlib.h>

struct node {
    int          value;
    struct node *next;
};

/* 包裝結構:多存一個 tail 指標(與長度,順便) */
struct list {
    struct node *head;
    struct node *tail;
    size_t       len;
};

static void list_init(struct list *l)
{
    l->head = NULL;
    l->tail = NULL;
    l->len  = 0;
}

/*
 * O(1) 尾插。兩種情況:
 *   空串列:head 與 tail 都指向新節點
 *   非空  :接在 tail 後面,tail 前進
 */
static void push_back(struct list *l, int value)
{
    struct node *n = malloc(sizeof *n);
    if (n == NULL) {
        fprintf(stderr, "out of memory\n");
        exit(1);
    }
    n->value = value;
    n->next  = NULL;                /* 新節點永遠是最後一個 */

    if (l->tail == NULL)            /* 空串列 */
        l->head = n;
    else
        l->tail->next = n;
    l->tail = n;
    l->len++;
}

/* 對照組:O(1) 頭插 */
static void push_front(struct list *l, int value)
{
    struct node *n = malloc(sizeof *n);
    if (n == NULL) {
        fprintf(stderr, "out of memory\n");
        exit(1);
    }
    n->value = value;
    n->next  = l->head;
    l->head = n;
    if (l->tail == NULL)            /* 原本是空串列:tail 也要設 */
        l->tail = n;
    l->len++;
}

static void print_list(const char *tag, const struct list *l)
{
    printf("%s (len=%zu):", tag, l->len);
    for (const struct node *p = l->head; p != NULL; p = p->next)
        printf(" %d", p->value);
    if (l->tail != NULL)
        printf("   [tail=%d]", l->tail->value);
    printf("\n");
}

static void list_free(struct list *l)
{
    struct node *p = l->head;
    while (p != NULL) {
        struct node *next = p->next;
        free(p);
        p = next;
    }
    list_init(l);                   /* 歸零,防懸空指標 */
}

int main(void)
{
    struct list l;
    list_init(&l);

    printf("push_back 1..5 (order preserved, each O(1)):\n");
    for (int i = 1; i <= 5; i++)
        push_back(&l, i);
    print_list("  list", &l);

    printf("\npush_front 0:\n");
    push_front(&l, 0);
    print_list("  list", &l);

    printf("\npush_back 99 (tail moves):\n");
    push_back(&l, 99);
    print_list("  list", &l);

    list_free(&l);
    print_list("\nafter free", &l);
    return 0;
}

程式碼講解

  • n->next = NULL 先設好:不管接到哪,新節點都是最後一個。
  • 分支只有一個:「空或非空」。兩種情況最後都做 l->tail = n; l->len++;,共同動作提出到分支外——重複程式碼是 bug 的溫床。
  • print_list 順便印出 [tail=..],每個操作後都能目視驗證 不變量(tail 是最後一個元素)成立。
  • 易錯處:push_front 忘記「空串列時設 tail」;或 push_back 忘記更新 l->tail(下次插入會接錯位置,串列斷裂)。

範例輸出

push_back 1..5 (order preserved, each O(1)):
  list (len=5): 1 2 3 4 5   [tail=5]

push_front 0:
  list (len=6): 0 1 2 3 4 5   [tail=5]

push_back 99 (tail moves):
  list (len=7): 0 1 2 3 4 5 99   [tail=99]

after free (len=0):

練習 3:有序插入與插入排序(難度:進階)

題目

實作 insert_sorted,把新值插入正確位置、維持串列遞增; 用它對亂序測資 42 -7 19 0 88 -3 19 5 做插入排序, 每插入一個值就印出目前串列。要求用「指標的指標」寫法, 頭插與中間插不得有特例分支。重複值要能處理。

解題思路

  1. 樸素寫法的問題:用 prev/cur 兩個指標找插入點, 插在頭部時 prev 是 NULL,要寫 if (prev == NULL) head = n; else prev->next = n; 的特例——兩條路徑、雙倍測試。
  2. 指標的指標:觀察「插入」實際改的是什麼—— 要嘛改 head 變數、要嘛改某個節點的 next 欄位。 兩者都是「一個 struct node * 大小的格子」! 讓 pp 指向這個格子,兩種情況就統一了:
插入 5 到 -7 -3 19 42:

pp 一路指向「格子」而非節點:
  pp = &head      → *pp 是 -7,-7 < 5,前進
  pp = &(-7)->next → *pp 是 -3,-3 < 5,前進
  pp = &(-3)->next → *pp 是 19,19 >= 5,停!

接線:n->next = *pp(19 那截);*pp = n(-3 的 next 指向 5)
  -7 ──> -3 ──> [5] ──> 19 ──> 42
  1. 迴圈條件:*pp != NULL && (*pp)->value < value—— 走到底(插尾)或找到第一個 ≥ value 的節點(插它前面)。 用 < 使重複值插在既有值後面(穩定)。
  2. 插入排序:對每個輸入值呼叫一次 insert_sorted, 每步之後串列都有序——輸出的過程軌跡就是證明。 複雜度 O(n²)(每次插入 O(n)),與陣列版插入排序相同。
  3. Linus Torvalds 在 TED 訪談舉的 "good taste" 例子 正是這個模式:用間接層消除特例。

參考解答

/*
 * ex3_insert_sorted.c — 練習 3:有序插入與串列插入排序
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_insert_sorted.c
 * 執行:/tmp/out
 *
 * insert_sorted:把節點插入正確位置、維持串列遞增。
 * 用「指標的指標」寫法:頭插與中間插共用同一段程式碼,沒有特例。
 * 插入排序 = 把亂序資料逐一 insert_sorted 進新串列。
 */
#include <stdio.h>
#include <stdlib.h>

struct node {
    int          value;
    struct node *next;
};

static struct node *new_node(int value)
{
    struct node *n = malloc(sizeof *n);
    if (n == NULL) {
        fprintf(stderr, "out of memory\n");
        exit(1);
    }
    n->value = value;
    n->next  = NULL;
    return n;
}

/*
 * 有序插入(遞增)。
 * pp 一路指著「待檢查的 next 欄位」:
 *   一開始指向 head 指標本身,之後指向前一節點的 next。
 * 找到第一個 >= value 的節點就插在它前面。
 * 頭插(串列空或 value 最小)與中間插走的是同一條路——
 * 這就是 Linus 說的 "good taste":用間接層消除特例。
 */
static void insert_sorted(struct node **pp, int value)
{
    struct node *n = new_node(value);

    while (*pp != NULL && (*pp)->value < value)
        pp = &(*pp)->next;          /* 前進:改指向下一個 next 欄位 */

    n->next = *pp;                  /* 新節點接上「剩下的串列」 */
    *pp = n;                        /* 前一個 next(或 head)指向新節點 */
}

static void print_list(const char *tag, const struct node *head)
{
    printf("%s", tag);
    for (const struct node *p = head; p != NULL; p = p->next)
        printf(" %d", p->value);
    printf("\n");
}

static void free_list(struct node *head)
{
    while (head != NULL) {
        struct node *next = head->next;
        free(head);
        head = next;
    }
}

int main(void)
{
    int data[] = { 42, -7, 19, 0, 88, -3, 19, 5 };
    size_t n = sizeof data / sizeof data[0];

    printf("insertion sort by repeated insert_sorted:\n\n");
    printf("input:");
    for (size_t i = 0; i < n; i++)
        printf(" %d", data[i]);
    printf("\n\n");

    /* 插入排序:逐一插入,每步之後串列都保持有序 */
    struct node *sorted = NULL;
    for (size_t i = 0; i < n; i++) {
        insert_sorted(&sorted, data[i]);
        printf("after insert %3d:", data[i]);
        print_list("", sorted);
    }

    print_list("\nsorted:", sorted);

    /* 邊界:插進頭、插進尾 */
    insert_sorted(&sorted, -100);
    insert_sorted(&sorted, 999);
    print_list("after -100, 999:", sorted);

    free_list(sorted);
    return 0;
}

程式碼講解

  • pp = &(*pp)->next:這行是全題精華。*pp 是目前節點, &(*pp)->next 是它 next 欄位的位址——pp 永遠指向 「下一步可能要改寫的格子」。
  • 接線兩行的順序不能倒:先 n->next = *pp 保住後半段, 再 *pp = n 接上前半段。倒過來後半段就丟了。
  • 呼叫端傳 &sorted(head 指標的位址),讓函式有能力改 head—— 這也是為什麼參數型別是 struct node **。
  • 測資含兩個 19:輸出可見第二個 19 插在第一個後面(< 的效果)。
  • 易錯處:迴圈條件寫成 (*pp)->value < value && *pp != NULL ——順序反了,*pp 為 NULL 時先解參考,當機。 短路求值的檢查順序是安全性的一部分。

範例輸出

insertion sort by repeated insert_sorted:

input: 42 -7 19 0 88 -3 19 5

after insert  42: 42
after insert  -7: -7 42
after insert  19: -7 19 42
after insert   0: -7 0 19 42
after insert  88: -7 0 19 42 88
after insert  -3: -7 -3 0 19 42 88
after insert  19: -7 -3 0 19 19 42 88
after insert   5: -7 -3 0 5 19 19 42 88

sorted: -7 -3 0 5 19 19 42 88
after -100, 999: -100 -7 -3 0 5 19 19 42 88 999

練習 4:合併兩條已排序串列(難度:進階)

題目

實作 struct node *list_merge(struct node *a, struct node *b): 把兩條遞增串列合併成一條,不配置新節點、不複製資料, 只重接 next 指標。要求 O(m+n) 時間、O(1) 額外空間、 穩定(相等時 a 的元素在前)。

測資:a = 1 4 6 10 15、b = 2 3 6 7 20 30; 另測「與空串列合併」。

解題思路

  1. 這是 merge sort 的合併步驟,但串列版比陣列版優雅: 陣列合併需要輔助陣列,串列只要改指標。
  2. 核心迴圈:每步比較 a、b 目前的頭,較小者摘下來接到 結果尾端:
a: 1 ─> 4 ─> 6          結果: (空)
b: 2 ─> 3 ─> 7
1 <= 2 → 接 1;a 前進
a: 4 ─> 6               結果: 1
2 <  4 → 接 2;b 前進 …以此類推
  1. 「第一個節點」的特例:結果串列一開始沒有尾巴可接。 dummy 節點技巧:在堆疊上放一個假頭 struct node dummy, tail 從 &dummy 出發,每次都是 tail->next = ...—— 零特例。最後回傳 dummy.next(真正的頭)。 dummy 不用 malloc(函式結束自動消失),它的 value 從不使用。
  2. 收尾:一邊耗盡後,另一邊整段接上(tail->next = a ?: b 的語意)——串列的殺手級優勢,陣列版要逐個搬。
  3. 穩定性:比較用 <=,相等時取 a——排序演算法的穩定性 由合併的這個小於等於決定。
  4. 所有權轉移:合併後 a、b 的節點全進了結果串列, 原本的頭指標不可再使用(也不可分開 free——會 double free)。

參考解答

/*
 * ex4_list_merge.c — 練習 4:合併兩條已排序串列(只改指標)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_list_merge.c
 * 執行:/tmp/out
 *
 * list_merge(a, b):把兩條遞增串列合併成一條,不配置新節點、
 * 不複製資料,只重接 next 指標。O(m+n) 時間、O(1) 額外空間。
 * 技巧:dummy(啞)節點消除「第一個節點」的特例。
 */
#include <stdio.h>
#include <stdlib.h>

struct node {
    int          value;
    struct node *next;
};

static struct node *new_node(int value)
{
    struct node *n = malloc(sizeof *n);
    if (n == NULL) {
        fprintf(stderr, "out of memory\n");
        exit(1);
    }
    n->value = value;
    n->next  = NULL;
    return n;
}

/* 從陣列建有序串列(測資本身已排序) */
static struct node *from_array(const int *a, size_t n)
{
    struct node *head = NULL, **pp = &head;
    for (size_t i = 0; i < n; i++) {
        *pp = new_node(a[i]);
        pp = &(*pp)->next;
    }
    return head;
}

/*
 * 合併:每一步從 a、b 的頭挑較小者接到結果尾端。
 * dummy 節點是棧上的假頭:讓「接第一個節點」與「接後續節點」
 * 都是 tail->next = ...,一個特例都沒有。
 * 相等時取 a(保持穩定性:a 的元素排在 b 的前面)。
 */
static struct node *list_merge(struct node *a, struct node *b)
{
    struct node dummy;              /* 不 malloc:只是暫時的接頭點 */
    struct node *tail = &dummy;

    dummy.next = NULL;

    while (a != NULL && b != NULL) {
        if (a->value <= b->value) {     /* <=:穩定合併 */
            tail->next = a;
            a = a->next;
        } else {
            tail->next = b;
            b = b->next;
        }
        tail = tail->next;
    }
    /* 有一邊耗盡:另一邊整段接上(串列版不用逐一搬) */
    tail->next = (a != NULL) ? a : b;

    return dummy.next;              /* 真正的頭在 dummy 後面 */
}

static void print_list(const char *tag, const struct node *head)
{
    printf("%s", tag);
    for (const struct node *p = head; p != NULL; p = p->next)
        printf(" %d", p->value);
    printf("\n");
}

static void free_list(struct node *head)
{
    while (head != NULL) {
        struct node *next = head->next;
        free(head);
        head = next;
    }
}

int main(void)
{
    int va[] = { 1, 4, 6, 10, 15 };
    int vb[] = { 2, 3, 6, 7, 20, 30 };

    struct node *a = from_array(va, sizeof va / sizeof va[0]);
    struct node *b = from_array(vb, sizeof vb / sizeof vb[0]);

    print_list("a     :", a);
    print_list("b     :", b);

    struct node *merged = list_merge(a, b);
    print_list("merged:", merged);
    /* 注意:a、b 的節點已全數併入 merged,原頭指標不可再用 */

    /* 邊界:與空串列合併 */
    struct node *c = from_array((int[]){ 5, 9 }, 2);
    struct node *m2 = list_merge(c, NULL);
    print_list("merge with empty:", m2);

    free_list(merged);              /* 釋放一次即可:節點沒有重複 */
    free_list(m2);
    return 0;
}

程式碼講解

  • dummy 放在堆疊上:它只是「接頭點」,函式回傳前取出 dummy.next 後就功成身退——不佔 heap、不用釋放。
  • 迴圈體四行 × 2 分支 + 尾段一行,就是完整的合併; 對照練習 3 的指標的指標,dummy 是另一種消特例手法 (加一個假物件 vs 加一層間接)。
  • 測資兩邊各有一個 6:輸出 ... 6 6 7 ...,a 的 6 在前 (用 <= 的證據)。
  • free_list(merged) 一次釋放全部——因為節點沒有複製、 沒有共享,每個節點恰好被結果串列擁有一次。
  • 易錯處一:忘記最後接上殘餘段,尾段資料無聲消失。
  • 易錯處二:合併後又對舊頭 a 呼叫 free_list——同一批節點 釋放兩次(double free),未定義行為。

範例輸出

a     : 1 4 6 10 15
b     : 2 3 6 7 20 30
merged: 1 2 3 4 6 6 7 10 15 20 30
merge with empty: 5 9

練習 5:Floyd 龜兔賽跑偵測環(難度:進階)

題目

實作 int has_cycle(const struct node *head):用 Floyd 龜兔賽跑演算法偵測串列是否帶環(O(n) 時間、O(1) 空間)。 自建一條帶環串列測試(尾節點指回中間節點), 測完把環拆掉再釋放。邊界:空串列、單節點、自環。

解題思路

  1. 為什麼是問題:帶環串列的走訪永不終止—— for (p = head; p; p = p->next) 變成無窮迴圈, free_list 也會爆炸。偵測環是操作「來路不明串列」的前置檢查。
  2. 笨方法:走訪時把看過的節點存進集合,重複出現即有環—— O(n) 空間。Floyd 的精妙在 O(1) 空間。
  3. 龜兔賽跑:慢指標每輪 1 步、快指標每輪 2 步。 - 無環:快指標(或它的 next)先撞到 NULL,結束; - 有環:兩者遲早都進環。進環後把「快追慢」看成相對運動—— 快指標每輪拉近 1 步,距離嚴格遞減,必定相遇 (每輪縮 1 不可能「跳過」)。
0 ─> 1 ─> 2 ─> 3 ─> 4 ─> 5
          ^______________|      (5 指回 2,環長 4)
  1. 迴圈條件:fast != NULL && fast->next != NULL—— fast 要跳兩步,兩步都要先確認存在;相遇判斷 slow == fast 比的是位址(同一個節點),不是值。
  2. 拆環再釋放:帶環串列不能直接走訪 free。 測試程式自己造的環自己知道接點(tail->next = NULL 即拆); 真實場景要先找出環的入口(Floyd 第二階段,延伸閱讀)。
  3. 邊界:空串列(fast 起手就是 NULL)、單節點無環 (fast->next 是 NULL)、自環(node->next = node, 第一輪 slow == fast)——三個都要測。

參考解答

/*
 * ex5_floyd_cycle.c — 練習 5:Floyd 龜兔賽跑偵測環
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex5_floyd_cycle.c
 * 執行:/tmp/out
 *
 * has_cycle:慢指標走 1 步、快指標走 2 步;
 * 有環必相遇、無環快指標先到 NULL。O(n) 時間、O(1) 空間。
 * 自建帶環串列測試,測完拆環再釋放(帶環串列不能直接 free 走訪)。
 */
#include <stdio.h>
#include <stdlib.h>

struct node {
    int          value;
    struct node *next;
};

static struct node *new_node(int value)
{
    struct node *n = malloc(sizeof *n);
    if (n == NULL) {
        fprintf(stderr, "out of memory\n");
        exit(1);
    }
    n->value = value;
    n->next  = NULL;
    return n;
}

/*
 * Floyd 龜兔賽跑。
 * 迴圈不變量:fast 永遠在 slow 前面(或相遇)。
 * 若有環:兩者都進環後,fast 每輪拉近 1 步,必相遇(不會跳過)。
 * 若無環:fast 或 fast->next 先變 NULL,回報無環。
 */
static int has_cycle(const struct node *head)
{
    const struct node *slow = head;
    const struct node *fast = head;

    while (fast != NULL && fast->next != NULL) {
        slow = slow->next;              /* 龜:1 步 */
        fast = fast->next->next;        /* 兔:2 步 */
        if (slow == fast)
            return 1;                   /* 相遇 = 有環 */
    }
    return 0;                           /* 兔子撞到 NULL = 無環 */
}

/* 建一條 0..n-1 的直串列,回傳頭;tail_out 給呼叫端拿尾節點 */
static struct node *make_list(int n, struct node **tail_out)
{
    struct node *head = NULL, **pp = &head, *last = NULL;
    for (int i = 0; i < n; i++) {
        *pp = new_node(i);
        last = *pp;
        pp = &(*pp)->next;
    }
    *tail_out = last;
    return head;
}

static struct node *nth(struct node *head, int idx)
{
    while (idx-- > 0)
        head = head->next;
    return head;
}

static void free_list(struct node *head)
{
    while (head != NULL) {
        struct node *next = head->next;
        free(head);
        head = next;
    }
}

int main(void)
{
    printf("=== straight list: no cycle expected ===\n");
    struct node *tail;
    struct node *a = make_list(6, &tail);
    printf("  0 -> 1 -> 2 -> 3 -> 4 -> 5 -> NULL\n");
    printf("  has_cycle = %s\n", has_cycle(a) ? "yes" : "no");

    printf("\n=== make a cycle: tail(5) points back to node 2 ===\n");
    /*
     *   0 -> 1 -> 2 -> 3 -> 4 -> 5
     *             ^______________|
     */
    struct node *entry = nth(a, 2);
    tail->next = entry;                 /* 造環 */
    printf("  has_cycle = %s\n", has_cycle(a) ? "yes" : "no");

    printf("\n=== break the cycle, then free safely ===\n");
    tail->next = NULL;                  /* 拆環:不拆就不能走訪釋放 */
    printf("  has_cycle after break = %s\n", has_cycle(a) ? "yes" : "no");
    free_list(a);
    printf("  freed 6 nodes.\n");

    printf("\n=== edge cases ===\n");
    printf("  empty list        : %s\n", has_cycle(NULL) ? "yes" : "no");
    struct node *one = new_node(42);
    printf("  single node       : %s\n", has_cycle(one) ? "yes" : "no");
    one->next = one;                    /* 自環 */
    printf("  self-loop         : %s\n", has_cycle(one) ? "yes" : "no");
    one->next = NULL;
    free(one);
    return 0;
}

程式碼講解

  • 兩個指標從同一起點出發,但先走再比(slow = ...; fast = ...; if (slow == fast))——若先比,起點相同會立刻誤判有環。
  • fast->next->next 前迴圈條件已保證 fast 與 fast->next 皆非 NULL——條件與迴圈體是一組不可拆的契約。
  • 測試流程「造環 → 偵測 → 拆環 → 釋放」順序不能亂: 拆環前 free_list 會走訪到已釋放節點(環讓它繞回來)。
  • 自環測試 one->next = one 後記得 one->next = NULL 再 free ——雖然 free 單一節點不走訪,但把懸空自指清掉是好習慣。
  • 易錯處:迴圈條件只檢查 fast != NULL——奇數長度的無環串列 會在 fast->next->next 解參考 NULL 當機。

範例輸出

=== straight list: no cycle expected ===
  0 -> 1 -> 2 -> 3 -> 4 -> 5 -> NULL
  has_cycle = no

=== make a cycle: tail(5) points back to node 2 ===
  has_cycle = yes

=== break the cycle, then free safely ===
  has_cycle after break = no
  freed 6 nodes.

=== edge cases ===
  empty list        : no
  single node       : no
  self-loop         : yes

練習 6:LRU 快取骨架(難度:挑戰)

題目

用雙向串列實作 LRU(最近最少使用)快取骨架,容量固定為 3:

  • lru_get(key):命中時把節點搬到頭部並回傳值,未命中回 -1;
  • lru_put(key, value):已存在則更新並搬頭;新 key 且已滿時 淘汰尾部(最久未用)再插入;
  • 展示一段存取序列造成的淘汰過程,每步印出快取內容 (MRU→LRU 順序)與淘汰事件。

解題思路

  1. LRU 的兩個操作需求: - 「這個節點剛被用過」→ 把它搬到某個「最近」端: 需要從中間摘除節點——單向串列摘除要知道前驅(O(n)), 雙向串列 O(1) 自摘,這就是用雙向的理由; - 「淘汰最久未用」→ 直接拿尾巴。
  2. 不變量:串列順序 = 使用時間順序(頭最新、尾最舊)。 get/put 每次都透過「摘下 + 掛頭」維持它。
head(MRU)                    tail(LRU)
  ┌─────┐   ┌─────┐   ┌─────┐
  │ 3:30│<─>│ 2:20│<─>│ 1:10│     get(1) 之後:
  └─────┘   └─────┘   └─────┘
  ┌─────┐   ┌─────┐   ┌─────┐
  │ 1:10│<─>│ 3:30│<─>│ 2:20│     1 搬到頭部
  └─────┘   └─────┘   └─────┘
  1. 摘除的四條線:雙向節點摘除要接好 prev/next 兩側, 且 e 是頭(prev 為 NULL)或尾(next 為 NULL)時要改 head/tail——四種組合寫成兩組 if/else 即可(見 unlink_entry)。
  2. put 的三條路:命中(更新+搬頭);未命中且滿(淘汰尾+插入); 未命中未滿(直接插入)。淘汰時印出 victim 的 key, 輸出軌跡讓策略行為可驗證。
  3. 教學版的妥協:find 用線性掃描 O(n)。生產版加一張 key→節點的雜湊表,get/put 全 O(1)——LeetCode 146 的完整解, 也是 09-hash-tables 的自然延伸。
  4. 記憶體:節點在 put 時 malloc、在淘汰或 lru_free 時 free, 一進一出配對清楚。

參考解答

/*
 * ex6_lru_cache.c — 練習 6:LRU 快取骨架(雙向串列)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex6_lru_cache.c
 * 執行:/tmp/out
 *
 * LRU(Least Recently Used)淘汰策略:
 *   - 雙向串列維持「最近使用在頭、最久未用在尾」
 *   - 存取(get/put 命中):節點搬到頭部
 *   - 放入新項目且已滿:淘汰尾部節點
 * 本骨架查找用 O(n) 線性掃描(教學版);
 * 生產版會加雜湊表 key->node 讓查找也是 O(1)(見 09-hash-tables)。
 */
#include <stdio.h>
#include <stdlib.h>

struct entry {
    int           key;
    int           value;
    struct entry *prev;
    struct entry *next;
};

struct lru {
    struct entry *head;         /* 最近使用 */
    struct entry *tail;         /* 最久未用(淘汰對象) */
    int           size;
    int           capacity;
};

static void lru_init(struct lru *c, int capacity)
{
    c->head = c->tail = NULL;
    c->size = 0;
    c->capacity = capacity;
}

/* 把節點從串列摘下(不釋放):雙向串列 O(1) 自摘 */
static void unlink_entry(struct lru *c, struct entry *e)
{
    if (e->prev != NULL) e->prev->next = e->next;
    else                 c->head = e->next;       /* e 是頭 */
    if (e->next != NULL) e->next->prev = e->prev;
    else                 c->tail = e->prev;       /* e 是尾 */
    e->prev = e->next = NULL;
}

/* 掛到頭部(最近使用位置) */
static void push_head(struct lru *c, struct entry *e)
{
    e->prev = NULL;
    e->next = c->head;
    if (c->head != NULL)
        c->head->prev = e;
    c->head = e;
    if (c->tail == NULL)        /* 原本是空的 */
        c->tail = e;
}

/* 線性查找 key(教學版;生產版用雜湊表加速) */
static struct entry *find(struct lru *c, int key)
{
    for (struct entry *e = c->head; e != NULL; e = e->next)
        if (e->key == key)
            return e;
    return NULL;
}

/* get:命中就搬到頭部並回傳值;未命中回 -1 */
static int lru_get(struct lru *c, int key)
{
    struct entry *e = find(c, key);
    if (e == NULL)
        return -1;
    unlink_entry(c, e);         /* 摘下再掛頭 = 「最近使用」 */
    push_head(c, e);
    return e->value;
}

/* put:已存在則更新並搬頭;不存在則插入,滿了先淘汰尾部 */
static void lru_put(struct lru *c, int key, int value)
{
    struct entry *e = find(c, key);
    if (e != NULL) {
        e->value = value;
        unlink_entry(c, e);
        push_head(c, e);
        return;
    }

    if (c->size == c->capacity) {           /* 滿了:淘汰最久未用 */
        struct entry *victim = c->tail;
        printf("    [evict key=%d]\n", victim->key);
        unlink_entry(c, victim);
        free(victim);
        c->size--;
    }

    e = malloc(sizeof *e);
    if (e == NULL) {
        fprintf(stderr, "out of memory\n");
        exit(1);
    }
    e->key = key;
    e->value = value;
    e->prev = e->next = NULL;
    push_head(c, e);
    c->size++;
}

/* 從頭到尾印出(頭 = 最近使用) */
static void lru_dump(const struct lru *c)
{
    printf("    cache (MRU->LRU):");
    for (const struct entry *e = c->head; e != NULL; e = e->next)
        printf(" (%d:%d)", e->key, e->value);
    printf("\n");
}

static void lru_free(struct lru *c)
{
    struct entry *e = c->head;
    while (e != NULL) {
        struct entry *next = e->next;
        free(e);
        e = next;
    }
    c->head = c->tail = NULL;
    c->size = 0;
}

int main(void)
{
    struct lru cache;
    lru_init(&cache, 3);                    /* 容量 3 */

    printf("LRU cache, capacity = 3\n\n");

    printf("put(1,10) put(2,20) put(3,30):\n");
    lru_put(&cache, 1, 10);
    lru_put(&cache, 2, 20);
    lru_put(&cache, 3, 30);
    lru_dump(&cache);

    printf("\nget(1) -> touches key 1, moves it to front:\n");
    printf("    get(1) = %d\n", lru_get(&cache, 1));
    lru_dump(&cache);

    printf("\nput(4,40) -> cache full, evicts LRU (key 2):\n");
    lru_put(&cache, 4, 40);
    lru_dump(&cache);

    printf("\nget(2) -> was evicted:\n");
    printf("    get(2) = %d (miss)\n", lru_get(&cache, 2));

    printf("\nput(3,99) -> update existing key, moves to front:\n");
    lru_put(&cache, 3, 99);
    lru_dump(&cache);

    printf("\nput(5,50) -> evicts key 1 (now the LRU):\n");
    lru_put(&cache, 5, 50);
    lru_dump(&cache);

    lru_free(&cache);
    return 0;
}

程式碼講解

  • unlink_entry + push_head 是整個 LRU 的原子操作對: 「摘下再掛頭」四個字就是 LRU 策略的全部。兩個函式各自維護 head/tail 的邊界情況,組合起來便處處正確。
  • 「搬到頭部」不配置新節點——搬的是指標不是資料, 這是串列相對陣列的本質優勢(陣列版搬頭要 memmove 整段)。
  • 淘汰訊息印在 put 內部:對快取這種「行為重於結果」的結構, 把內部事件輸出成軌跡是最好的測試方式。
  • 存取序列的設計有意圖:get(1) 把 1 救活,隨後 put(4) 淘汰的就變成 2(而不是最早插入的 1)——證明淘汰的是 「最久未用」而非「最早插入」(那是 FIFO)。
  • 易錯處一:unlink 忘了處理「e 是尾」的情況,tail 懸空, 下次淘汰 free 到已摘除的節點。
  • 易錯處二:put 對已存在的 key 走「插入」路徑, 造成同 key 兩個節點——find-first 掩蓋問題,直到淘汰時錯亂。

範例輸出

LRU cache, capacity = 3

put(1,10) put(2,20) put(3,30):
    cache (MRU->LRU): (3:30) (2:20) (1:10)

get(1) -> touches key 1, moves it to front:
    get(1) = 10
    cache (MRU->LRU): (1:10) (3:30) (2:20)

put(4,40) -> cache full, evicts LRU (key 2):
    [evict key=2]
    cache (MRU->LRU): (4:40) (1:10) (3:30)

get(2) -> was evicted:
    get(2) = -1 (miss)

put(3,99) -> update existing key, moves to front:
    cache (MRU->LRU): (3:99) (4:40) (1:10)

put(5,50) -> evicts key 1 (now the LRU):
    [evict key=1]
    cache (MRU->LRU): (5:50) (3:99) (4:40)

常見錯誤解法對照

錯誤寫法 症狀 正解
free(p); p = p->next; use-after-free 先存 next 再 free(練習 1)
push_front 空串列不設 tail 之後 push_back 接錯位置 空串列兩指標都設(練習 2)
條件寫 (*pp)->value < v && *pp NULL 解參考當機 先判 NULL 再解參考(練習 3)
合併後忘接殘餘段 尾段資料消失 tail->next = a ? a : b(練習 4)
Floyd 只檢查 fast != NULL 奇數長度串列當機 同時檢查 fast->next(練習 5)
帶環串列直接 free_list 無窮迴圈/二次釋放 先拆環再釋放(練習 5)
LRU 淘汰「最早插入」 是 FIFO 不是 LRU get 也要搬頭(練習 6)