30. 【C语言】链表进阶:双向链表与循环链表

上一篇我们掌握了单向链表——每个结点只有一个 next 指针,指向后继。这种简洁带来一个痛点:只能向前走,不能回头。删尾部结点要遍历整条链;想在某个结点前插入,也得找到它的前驱。

如果能给每个结点加一个“回头的钩子”,指向它的前驱,双向穿梭就自由了。如果再让头尾相连,就形成了环。这就是今天的主角——双向链表循环链表


一、双向链表的结点定义

双向链表每个结点有两个指针:

  • next:指向后继(下一个结点)
  • prev:指向先驱(上一个结点)
typedef struct DNode {
    int data;
    struct DNode *next;
    struct DNode *prev;
} DNode;

形象记忆:每个车厢前后各有一个钩子。

NULL ← [prev | data | next] ↔ [prev | data | next] → NULL

头结点的 prevNULL,尾结点的 nextNULL。两个方向的 NULL 是边界哨兵。


二、创建结点与插入操作

创建结点

DNode *dnode_create(int data) {
    DNode *node = (DNode*)malloc(sizeof(DNode));
    if (node) {
        node->data = data;
        node->next = NULL;
        node->prev = NULL;
    }
    return node;
}

头插法

新结点放在最前面,成为新的头。需要处理旧头结点的 prev 指针。

void dlist_insert_head(DNode **head, int data) {
    DNode *node = dnode_create(data);
    if (!node) return;

    node->next = *head;
    if (*head != NULL) {
        (*head)->prev = node;
    }
    *head = node;
}

注意:当链表为空(*head == NULL)时,没有 prev 需要设置。

尾插法

若维护了尾指针 tail,尾插是 O(1)。如果没有尾指针,需要遍历找到尾部(O(n))。为了最佳性能,实际项目常同时维护 headtail

void dlist_insert_tail(DNode **head, int data) {
    DNode *node = dnode_create(data);
    if (!node) return;

    if (*head == NULL) {
        *head = node;
        return;
    }

    DNode *cur = *head;
    while (cur->next != NULL) {
        cur = cur->next;
    }
    cur->next = node;
    node->prev = cur;
}

在指定结点后面插入

有了双向指针,插入操作异常简洁——不需要遍历找前驱。

void dlist_insert_after(DNode *target, int data) {
    if (!target) return;
    DNode *node = dnode_create(data);
    if (!node) return;

    node->next = target->next;
    node->prev = target;
    if (target->next != NULL) {
        target->next->prev = node;
    }
    target->next = node;
}

在指定结点前面插入逻辑对称,可自行练习。


三、删除结点

双向链表的删除非常舒服:当前结点 target 的前驱和后继可以直接通过 prevnext 找到,无需从头发起遍历。

void dlist_delete(DNode **head, DNode *target) {
    if (!target || !*head) return;

    // 调整前驱的 next
    if (target->prev != NULL) {
        target->prev->next = target->next;
    } else {
        // target 是头结点
        *head = target->next;
    }

    // 调整后继的 prev
    if (target->next != NULL) {
        target->next->prev = target->prev;
    }

    free(target);
}

配合查找函数(遍历比较),就能实现按值删除:

void dlist_delete_by_value(DNode **head, int value) {
    DNode *cur = *head;
    while (cur) {
        if (cur->data == value) {
            DNode *to_delete = cur;
            cur = cur->next;   // 先移到下一个,避免悬空
            dlist_delete(head, to_delete);
            // 注意:删除后继续查找(链表中可能还有同值结点)
            continue;
        }
        cur = cur->next;
    }
}

四、遍历双向链表

双向遍历可以正序也可以逆序。正序和单向链表完全一样;逆序需要先找到尾结点,然后沿 prev 向前走。

正序

void dlist_print_forward(DNode *head) {
    DNode *cur = head;
    while (cur) {
        printf("%d ↔ ", cur->data);
        cur = cur->next;
    }
    printf("NULL\n");
}

逆序:先走到尾,再倒回来。

void dlist_print_backward(DNode *head) {
    if (!head) return;
    DNode *cur = head;
    while (cur->next) {
        cur = cur->next;
    }
    // cur 现在是尾结点
    while (cur) {
        printf("%d ↔ ", cur->data);
        cur = cur->prev;
    }
    printf("NULL\n");
}

五、释放整个双向链表

释放逻辑和单向链表一样,逐个结点 free。双向链表的 next 已经够用。

void dlist_free(DNode *head) {
    DNode *cur = head;
    while (cur) {
        DNode *tmp = cur;
        cur = cur->next;
        free(tmp);
    }
}

双向链表的完整操作示例和单向链表整合在一起,放在后面的代码块里供你运行体会。


六、循环链表:让首尾相连

如果把单向链表尾结点的 nextNULL 改成指向头结点,就变成了循环单向链表。如果双向链表的尾 next 指向头,头的 prev 指向尾,就是循环双向链表

循环链表的本质特点:没有绝对的起点和终点,从任一结点出发都能遍历整个链表

1. 循环单向链表

头结点 → [1] → [2] → [3] → [4]
          ↑                   |
          └───────────────────┘  (尾结点的 next 指向头)

我们通常用尾指针(而不是头指针)来管理循环单向链表,因为从尾部获取头部只需 tail->next

typedef struct CNode {
    int data;
    struct CNode *next;
} CNode;

// 在循环链表末尾插入(返回新尾)
CNode *clist_insert_tail(CNode *tail, int data) {
    CNode *node = (CNode*)malloc(sizeof(CNode));
    if (!node) return tail;
    node->data = data;

    if (tail == NULL) {
        // 第一个结点:自己指向自己
        node->next = node;
        return node;
    }

    node->next = tail->next;   // 新结点的 next 指向头
    tail->next = node;         // 旧尾指向新结点
    return node;               // 新尾
}

void clist_print(CNode *tail) {
    if (!tail) return;
    CNode *head = tail->next;
    CNode *cur = head;
    do {
        printf("%d -> ", cur->data);
        cur = cur->next;
    } while (cur != head);
    printf("(回到头)\n");
}

判断循环链表的结束不再是 NULL,而是回到了起点:cur != head

2. 循环双向链表

在循环双向链表中,头结点的 prev 指向尾,尾结点的 next 指向头。判空即检查 head->next == head(或 head->prev == head)。

这种结构常用于操作系统中管理进程、内存页的调度队列。在 C 语言的 sys/queue.h 中就提供了名为 TAILQCIRCLEQ 的宏实现。


七、一个小应用:简单任务队列(循环单向链表)

循环链表天然适合“轮转调度”:比如一个简单的定时任务队列,每次执行一个任务后把它放到队尾,然后取下一个。

#include <stdio.h>
#include <stdlib.h>

typedef struct TaskNode {
    int id;
    struct TaskNode *next;
} TaskNode;

TaskNode *add_task(TaskNode *tail, int id) {
    TaskNode *node = (TaskNode*)malloc(sizeof(TaskNode));
    if (!node) return tail;
    node->id = id;
    if (tail == NULL) {
        node->next = node;
        return node;
    }
    node->next = tail->next;
    tail->next = node;
    return node;
}

void round_robin(TaskNode **tail, int rounds) {
    if (!*tail) return;
    TaskNode *cur = (*tail)->next;  // 从头开始
    for (int i = 0; i < rounds; i++) {
        printf("执行任务 %d\n", cur->id);
        cur = cur->next;
    }
    *tail = cur;  // 更新尾,下次从尾部下一个开始(其实等价)
}

int main(void) {
    TaskNode *tail = NULL;
    tail = add_task(tail, 1);
    tail = add_task(tail, 2);
    tail = add_task(tail, 3);
    // 循环执行 10 次
    round_robin(&tail, 10);
    return 0;
}

虽然这个小例子没有实际功能,但它展示了循环链表在轮询调度中的核心思想——你永远不需要判断 NULL,也永远能轮转下去。


八、双向链表 vs 单向链表:性能对比

操作单向链表双向链表
头插O(1)O(1)
尾插(无尾指针)O(n)O(n)(若维护尾指针可 O(1))
给定结点前插入O(n)(需找前驱)O(1)
删除给定结点O(n)(需找前驱)O(1)(有 prev)
正向遍历O(n)O(n)
反向遍历O(n²) 或额外空间O(n)
内存开销1 个指针/结点2 个指针/结点

双向链表以额外的指针为代价,换来了更灵活的插入删除和双向遍历能力。当你需要频繁地在任意位置插入/删除、或者需要反向遍历时,选它准没错。


九、常见错误与陷阱

1. 插入时忘记更新 prev 指针

单向链表转双向,最易犯的错就是只更新了 next,忘记更新 prev,导致逆序遍历时出现断裂。

2. 删除尾部/头部时未更新边界

删除头结点要更新 head;如果还维护了尾指针,删除尾结点也要更新 tail。忘记更新会导致野指针。

3. 循环链表的死循环

遍历循环链表必须用 do-while 并判断是否回到了起始点,如果用 while (cur != NULL) 永远不会结束。

4. 对空循环链表解引用

空循环链表指针为 NULL,直接访问 tail->next 会崩溃。所有操作前先判空。


十、小结

今天我们在单向链表的基础上做了两件重要扩展:

  • 双向链表:每个结点同时有 nextprev,插入删除更灵活,逆向遍历轻松。
  • 循环链表:首尾相连,消除 NULL 边界,适合轮转调度、缓冲区管理等场景。

这些链表变体是更高层数据结构的基础——比如队列、栈(用链表实现)、哈希表的冲突链、以及操作系统中的进程管理队列。

现在你已经有了三个有力的动态数据结构工具:数组(连续存储)、单向链表(单向前进)、双向/循环链表(灵活穿梭)。下一章,我们将用它们来实现两个经典的抽象数据结构——栈(Stack)和队列(Queue),分别用数组和链表两种方式构造,揭示接口与实现分离的设计之美。


课后小练习

  1. 实现一个完整的双向链表模块(dlist.h / dlist.c),包含:dlist_createdlist_insert_headdlist_insert_taildlist_deletedlist_finddlist_free
  2. 基于双向链表实现一个函数 void dlist_reverse(DNode **head),原地反转双向链表(交换每个结点的 nextprev)。
  3. 判断一个单向链表是否为循环链表(有环),是的话返回环的入口结点;不是返回 NULL。提示:快慢指针找相遇点,再找入口。
  4. (小挑战)用循环双向链表实现约瑟夫环(Josephus)问题:n 个人围成一圈,从第 k 个人开始报数,数到 m 的人出列,求出列顺序。用 n=7, k=3, m=4 测试。

我们下期见!

💡获取本系列示例代码请访问 GitCode 仓库

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值