📖 第 12 章:使用结构和指针(Using Structures and Pointers)

🔤 Kenneth Reek《Pointers on C》(C和指针)· Chapter 12
🗨️ 本章金句:「链表的一切难点都在「改指针」:插入时先接右边,再接左边,顺序反了丢链。」
这一章在干嘛? 把结构 + 指针 + 动态内存三者合体,实现真正的数据结构:单链表的有序插入(重点)、双链表,以及「指向 link 字段的指针」这一教科书级技巧。
12.1 单链表与有序插入12.2 消灭特例:link-in-node 技巧12.3 双链表:删除 O(1) 的代价

12.1 单链表与有序插入

typedef struct Node {
    int          value;
    struct Node *next;
} Node;

在有序链表中插入 value,要找到「最后一个小于 value 的节点」,新节点插在它后面。朴素写法要为「插在表头」单独写分支,因为改的是根指针本身;更漂亮的做法是用「指向 link 字段的指针」当游标

/* 返回值:是否插入成功(内存不足失败) */
int sll_insert(Node **rootp, int new_value)
{
    Node *cur  = *rootp;          /* cur:当前 link 字段指向的节点 */
    Node *prev = NULL;

    while (cur != NULL && cur->value < new_value) {
        prev = cur;
        cur  = cur->next;
    }
    /* 此时应插在 prev(可能 NULL)之后、cur(可能 NULL)之前 */

    Node *fresh = malloc(sizeof *fresh);
    if (fresh == NULL)
        return 0;
    fresh->value = new_value;

    if (prev == NULL) {           /* 插表头:改根指针 */
        fresh->next = *rootp;
        *rootp      = fresh;
    } else {                      /* 插中间/尾部:改 prev 的 link */
        fresh->next = cur;
        prev->next  = fresh;
    }
    return 1;
}
/* 调用:sll_insert(&root, 42); —— 传根指针的地址 */

12.2 消灭特例:link-in-node 技巧

上面的 if/else 两个分支本质是同一句话:「让某个 link 字段指向新节点,新节点指向 cur」。表头分支改的是根指针,中间分支改的是某节点的 next——如果游标直接落在「link 字段本身」,两种情况就统一了:

int sll_insert2(Node **rootp, int new_value)
{
    Node **linkp = rootp;              /* linkp:下一个 link 字段的位置 */
    Node  *cur;

    while ((cur = *linkp) != NULL && cur->value < new_value)
        linkp = &cur->next;            /* 游标推进到下一个 link 字段 */

    Node *fresh = malloc(sizeof *fresh);
    if (fresh == NULL)
        return 0;
    fresh->value = new_value;
    fresh->next  = cur;
    *linkp       = fresh;              /* 统一写法:改 link 字段,一招通吃 */
    return 1;
}
为什么这招妙: 游标类型是 Node**——「指向 link 字段的指针」。表头时它 = &root,中间时它 = &prev->next,对 *linkp 赋值两种情况行为一致。特例消失 = bug 消失。
接线顺序铁律: 先 fresh->next = cur 接「右边」,再 *linkp = fresh 接「左边」。反过来先把 *linkp 指向 fresh,cur 那一半链就丢了。

12.3 双链表:删除 O(1) 的代价

单链表删除某节点需要先找到前驱(O(n));双链表每个节点带 prev 指针,拿到节点就能自删。代价是多一个指针的存储和维护:

typedef struct DNode {
    int            value;
    struct DNode  *fwd;    /* 向后 */
    struct DNode  *bwd;    /* 向前 */
} DNode;

int dll_insert(DNode **rootp, int new_value)
{
    DNode **fwdp = rootp, *bwd = NULL, *cur;

    while ((cur = *fwdp) != NULL && cur->value < new_value) {
        bwd  = cur;
        fwdp = &cur->fwd;
    }
    if (cur != NULL && cur->value == new_value)
        return 1;                        /* 已存在 */

    DNode *fresh = malloc(sizeof *fresh);
    if (fresh == NULL)
        return 0;
    fresh->value = new_value;

    fresh->fwd = cur;                    /* 右接 */
    *fwdp      = fresh;                  /* 左接(统一 link 技巧) */
    fresh->bwd = bwd;                    /* 反向指针两个方向都要修 */
    if (cur != NULL)
        cur->bwd = fresh;
    return 1;
}

双链表的检查口诀:每个操作后,正向走一遍、反向走一遍,都通才算对。fwd 和 bwd 共 4 处指针(2 组),改漏任何一处链就断。

本章通关标准: 能手推「插入 3 节点链表中间」的每条指针变化;理解 Node** 游标如何统一表头/表内两种插入;记住接线顺序「先右后左」。
🧠 小测验
1. 单链表插入为什么可能需要修改根指针?什么情况下不用?
插在表头时,根指针本身必须改指向新节点(改的是指针变量,不是节点内容),所以函数要接收 Node**(根指针的地址)。插在中间/尾部只改前驱节点的 next 字段,不需要动根指针。用 link 字段游标(Node**)可把两种情况统一。
2. 链表插入时先接右边还是先接左边?为什么?
先接右边:fresh->next = cur。若先让左边指向 fresh,原来指向 cur 的那条链被覆盖,cur 之后的整段链表就找不到了。先右后左保证任何时刻链不断。
3. 双链表比单链表多付出什么、换来什么?
每个节点多一个 bwd 指针的存储、每次插入/删除要多维护两个方向的指针。换来:拿到任意节点可 O(1) 删除自身、可反向遍历。检查时正反各遍历一遍验证四条指针都改对。
← 上一篇🏠 顶层目录下一篇 →