链表进阶:双向链表、循环链表、链表的反转操作

说实话,单链表虽然好用,但真到了实战项目里,你很快就会发现它有点「不够用」。我记得刚入行那会儿,用单链表做任务队列,每次要删除某个节点都得从头遍历找前驱节点,那个效率低得让人抓狂。后来老工程师甩给我一句话:「小子,试试双向链表。」

嗯,这一章我们就来聊聊链表的进阶玩法。双向链表、循环链表,还有那个面试最爱考的链表反转。我会把我在项目中踩过的坑、总结的技巧都抖出来。

一、双向链表:给节点装上「前后眼」

双向链表,说白了就是在每个节点里多存一个指针——指向前一个节点。你想想看,单链表只能往后走,双向链表可以往前走,这就灵活多了。

核心区别: 双向链表的节点结构多了一个 prev 指针。
// 双向链表节点
typedef struct DNode {
    int data;
    struct DNode *prev;  // 前驱指针
    struct DNode *next;  // 后继指针
} DNode;

我在做嵌入式设备的数据缓存时,就吃过单链表的亏。当时需要频繁删除尾部节点,单链表得遍历到倒数第二个节点才能删,O(n)的时间复杂度在实时系统里根本扛不住。换成双向链表后,直接通过尾指针就能O(1)删除,爽多了。

双向链表的插入操作

插入操作比单链表多一步——要同时更新新节点的prev和next,还要调整前后节点的指针。我建议你画个图再写代码,不然很容易搞乱指针指向。

// 在节点 p 后面插入新节点 newNode
void insertAfter(DNode *p, DNode *newNode) {
    if (p == NULL || newNode == NULL) return;
    
    newNode->next = p->next;
    newNode->prev = p;
    
    if (p->next != NULL) {
        p->next->prev = newNode;  // 这步容易忘!
    }
    p->next = newNode;
}
我曾经踩过的坑: 写双向链表插入时,忘了判断 p->next 是否为 NULL。如果 p 是尾节点,直接访问 p->next->prev 就是野指针访问,程序直接崩。所以一定要先判空。

双向链表的删除操作

删除操作就优雅多了。因为有了prev指针,你不需要像单链表那样找前驱节点。直接拿着目标节点就能删。

// 删除节点 del
void deleteNode(DNode *del) {
    if (del == NULL) return;
    
    if (del->prev != NULL) {
        del->prev->next = del->next;
    }
    if (del->next != NULL) {
        del->next->prev = del->prev;
    }
    free(del);
}

你看,代码比单链表简洁不少吧?但要注意,如果删除的是头节点,你得额外更新头指针。

二、循环链表:让链表「首尾相连」

循环链表,就是把尾节点的next指向头节点。如果是双向循环链表,头节点的prev还要指向尾节点。说白了,就是让链表变成一个环。

我为什么喜欢循环链表?因为它在需要「轮询」的场景下特别好用。比如操作系统的任务调度,每个任务轮流执行,用循环链表天然就支持这种逻辑。

小技巧: 循环链表的遍历结束条件不是 p == NULL,而是 p == head。很多新手在这里翻车,写了个死循环出来。
// 遍历循环链表
void traverseCircular(DNode *head) {
    if (head == NULL) return;
    
    DNode *p = head;
    do {
        printf("%d ", p->data);
        p = p->next;
    } while (p != head);  // 注意:用 do-while 保证至少执行一次
}

嗯,这里要注意。用while循环的话,你得先处理头节点再进入循环,不然第一次判断就退出了。我个人习惯用do-while,代码更干净。

三、链表的反转操作

链表反转,面试题里的常客。我当年面试时就被问过,当时手写代码还出了bug,尴尬得很。后来我总结了一套「三指针法」,再也没写错过。

单链表的反转

核心思路:用三个指针——prev、curr、next——依次把每个节点的next指向前一个节点。

// 反转单链表
Node* reverseList(Node *head) {
    Node *prev = NULL;
    Node *curr = head;
    Node *next = NULL;
    
    while (curr != NULL) {
        next = curr->next;   // 先保存下一个节点
        curr->next = prev;   // 反转指针
        prev = curr;         // 前移
        curr = next;         // 前移
    }
    return prev;  // 新的头节点
}

你想想看,这个过程中最关键的一步是什么?是 next = curr->next。如果不先保存next,等你把curr->next指向prev后,后面的节点就找不到了。我曾经在项目里写过一个类似的逻辑,忘了保存next,结果链表后半截全丢了,调试了一下午才找到原因。

双向链表的反转

双向链表反转其实更简单——把每个节点的prev和next互换就行了。

// 反转双向链表
DNode* reverseDList(DNode *head) {
    DNode *curr = head;
    DNode *temp = NULL;
    
    while (curr != NULL) {
        // 交换 prev 和 next
        temp = curr->prev;
        curr->prev = curr->next;
        curr->next = temp;
        
        // 移动到下一个节点(注意:此时下一个节点是 prev)
        curr = curr->prev;
    }
    
    // 返回新的头节点
    if (temp != NULL) {
        head = temp->prev;
    }
    return head;
}
关键点: 反转后,原来的尾节点变成了头节点。所以最后要找到新的头节点返回。

四、知识体系总览

下面这张图是我画的本章知识结构,你可以对照着梳理一下思路。

链表进阶知识体系 双向链表 节点含 prev + next 插入需更新前后指针 删除无需找前驱 循环链表 尾节点指向头节点 遍历条件 p != head 适合轮询/调度场景 链表反转 单链表:三指针法 双向链表:交换指针 注意更新头节点 核心要点 双向链表解决「找前驱」痛点 | 循环链表适合轮询场景 | 反转操作注意指针保存

五、实战中的选择建议

说了这么多,到底什么时候用哪种链表?我根据项目经验给你列个表:

场景 推荐类型 原因
频繁删除尾部节点 双向链表 O(1)删除,无需遍历
任务轮询调度 循环链表 天然支持循环遍历
内存极度受限 单链表 少一个指针,省空间
需要双向遍历 双向链表 前后都能走
面试手写代码 单链表反转 考基本功,三指针法必会
我的个人习惯: 在嵌入式项目中,如果内存够用,我优先用双向链表。虽然多占一个指针的空间(8字节在64位系统上),但换来的是操作上的便利性,值。你想想看,调试的时候少出几个bug,省下的时间可比那点内存值钱多了。

好了,这一章的内容就到这儿。双向链表、循环链表、反转操作,这三个知识点你掌握了吗?代码多写几遍,尤其是反转操作,画图+手写代码,比光看强十倍。


公众号:蓝海资料掘金营,微信deep3321