双链表与循环链表:从结构到实战

说实话,单链表用久了,你总会遇到一些让人抓狂的场景。比如你想删除某个节点,却发现自己还得从头遍历去找它的前驱节点。我当时在一个嵌入式项目里就吃过这个亏——一个数据采集模块,频繁删除中间节点,每次都要 O(n) 的查找时间,整个系统的实时性直接被拖垮。

嗯,这时候双链表就该登场了。

双链表的节点结构

双链表比单链表多了一个指针——指向前一个节点。说白了,就是每个节点都长了前后眼。

typedef struct DNode {
    int data;               // 数据域
    struct DNode *prior;    // 指向前驱节点
    struct DNode *next;     // 指向后继节点
} DNode, *DLinkList;

我个人习惯把双链表的头节点也做成一个完整的节点,prior 和 next 都指向自己,这样初始化的时候逻辑特别干净。

核心区别:单链表只能从头走到尾,双链表可以双向走。代价就是每个节点多了一个指针的内存开销(8字节在64位系统上)。

双链表的插入操作

插入操作比单链表麻烦一点,因为你要维护两个方向的指针。我在项目中见过不少新手在这里翻车——只更新了 next,忘了更新 prior。

// 在节点 p 之后插入节点 s
bool InsertNextNode(DNode *p, DNode *s) {
    if (p == NULL || s == NULL) return false;
    
    s->next = p->next;      // 1. s 的后继指向 p 的后继
    if (p->next != NULL)    // 2. 如果 p 有后继,更新其后继的 prior
        p->next->prior = s;
    s->prior = p;           // 3. s 的前驱指向 p
    p->next = s;            // 4. p 的后继指向 s
    return true;
}

你想想看,这四步的顺序其实有讲究。我建议先把 s 的指针都挂好,再去动 p 和 p->next 的指针。这样即使中间出问题,也不会破坏原有链表的结构。

小技巧:插入操作画个图,把四个指针的指向画清楚,代码自然就写对了。我每次教新人都是这么干的。

双链表的删除操作

删除操作就优雅多了。因为双链表可以直接拿到前驱节点,不需要像单链表那样从头找。

// 删除节点 p
bool DeleteNode(DNode *p) {
    if (p == NULL) return false;
    
    p->prior->next = p->next;   // 前驱的 next 跳过 p
    if (p->next != NULL)        // 如果 p 有后继
        p->next->prior = p->prior; // 后继的 prior 跳过 p
    free(p);
    return true;
}

我曾经在一个内存池管理模块里用双链表来管理空闲块,删除操作 O(1) 的复杂度让整个分配器的性能提升了一个档次。这就是双链表的魅力——空间换时间。

注意:删除最后一个节点时,p->next 为 NULL,这时候不能访问 p->next->prior,否则就是空指针访问。上面的代码已经用 if 判断处理了这种情况。

循环链表:让遍历不再有终点

循环链表就是把单链表的最后一个节点的 next 指向头节点(或者第一个节点)。这样一来,整个链表就变成了一个环。

为什么要这么做?我举个例子你就明白了。在一个任务调度系统里,我们需要轮询所有任务,每个任务执行一个时间片。如果用普通单链表,执行完最后一个任务后,还得手动把指针重置到头节点。用循环链表,你只管往后走,永远走不到头——走到尾自然就回到了头。

// 循环链表的节点结构(和单链表一样)
typedef struct LNode {
    int data;
    struct LNode *next;
} LNode, *LinkList;

// 初始化循环链表(带头节点)
bool InitList(LinkList &L) {
    L = (LNode *)malloc(sizeof(LNode));
    if (L == NULL) return false;
    L->next = L;  // 头节点的 next 指向自己
    return true;
}

循环链表的遍历终止条件

这是循环链表最核心的问题。普通链表遍历用 p != NULL 作为终止条件,循环链表不行——它永远不会为 NULL。

正确的做法是:判断是否回到了头节点。

// 遍历循环链表
void TraverseList(LinkList L) {
    if (L == NULL) return;
    LNode *p = L->next;  // 从第一个数据节点开始
    while (p != L) {     // 回到头节点就停止
        printf("%d ", p->data);
        p = p->next;
    }
    printf("\n");
}

嗯,这里要注意:如果循环链表是空的,那么 L->next == L,while 条件直接不成立,不会执行任何操作。这个逻辑是自洽的。

判断空表:循环链表判断空表的条件是 L->next == L,而不是 L->next == NULL。这个区别很容易被忽略,我当年面试的时候就栽过这个坑。

约瑟夫环问题实战

约瑟夫环问题,说白了就是一群人围成一圈报数,数到 m 的人出列,然后从下一个人重新开始报数,直到所有人都出列。这个问题用循环链表来解决,简直是天造地设的一对。

我记得第一次在课堂上讲这个问题时,有个学生问:「为什么不用数组?」我说你试试用数组模拟删除,每次删除都要移动大量元素,时间复杂度 O(n²)。而循环链表删除一个节点只需要 O(1),整体复杂度 O(n×m)。

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

typedef struct LNode {
    int number;          // 编号
    struct LNode *next;
} LNode, *LinkList;

// 创建约瑟夫环
LinkList CreateJosephus(int n) {
    LinkList L = NULL;
    LNode *r = NULL;  // 尾指针
    
    for (int i = 1; i <= n; i++) {
        LNode *p = (LNode *)malloc(sizeof(LNode));
        p->number = i;
        p->next = NULL;
        
        if (L == NULL) {
            L = p;
            p->next = p;  // 第一个节点指向自己
            r = p;
        } else {
            r->next = p;
            p->next = L;   // 新节点指向头节点
            r = p;         // 更新尾指针
        }
    }
    return L;
}

// 解决约瑟夫环问题
void Josephus(LinkList L, int m) {
    if (L == NULL) return;
    
    LNode *p = L;
    LNode *prev = NULL;
    
    // 找到尾节点(用于删除头节点时更新)
    while (p->next != L) {
        p = p->next;
    }
    prev = p;  // prev 指向尾节点
    p = L;     // p 指向头节点
    
    while (p->next != p) {  // 只剩一个节点时停止
        // 报数 m-1 次,找到要删除节点的前驱
        for (int i = 1; i < m; i++) {
            prev = p;
            p = p->next;
        }
        
        // 删除 p 节点
        printf("出列: %d\n", p->number);
        prev->next = p->next;
        free(p);
        
        // 从下一个节点重新开始
        p = prev->next;
    }
    
    // 最后一个节点
    printf("最后留下: %d\n", p->number);
    free(p);
}

int main() {
    int n = 7, m = 3;
    LinkList L = CreateJosephus(n);
    Josephus(L, m);
    return 0;
}

这个代码里有一个关键点:我们需要一个 prev 指针来记录当前节点的前驱。因为循环链表删除节点时,虽然可以通过遍历找到前驱,但那样效率就低了。用 prev 指针实时跟踪,删除操作就是 O(1)。

实战经验:我在一个嵌入式系统的任务调度器里用过类似的思想。每个任务是一个循环链表节点,定时器中断触发时,遍历链表执行任务。删除任务时,用 prev 指针避免二次遍历,这在中断服务程序里特别重要——时间就是生命。

知识体系总览

下面这张图把双链表和循环链表的要点都串起来了,你可以对照着复习。

双链表与循环链表知识体系 双链表 节点结构 typedef struct DNode { int data; struct DNode *prior; struct DNode *next; } DNode, *DLinkList; 核心操作 • 插入:维护4个指针指向 • 删除:O(1) 直接操作 • 遍历:可双向遍历 避坑指南 • 删除时检查 p->next 是否为 NULL • 插入时先挂 s 的指针 循环链表 初始化 L->next = L; // 头节点指向自己 遍历终止条件 while (p != L) { // 回到头节点停止 // 处理节点 p = p->next; } 约瑟夫环实战 • 创建环:尾节点指向头节点 • 报数出列:for 循环 m-1 次 • 删除节点:prev->next = p->next • 终止条件:p->next == p

双链表和循环链表,说白了就是单链表的两个变种。一个解决了反向遍历的问题,一个解决了循环遍历的问题。在实际项目中,我建议你根据场景来选择:需要频繁删除中间节点?用双链表。需要轮询调度?用循环链表。

最后送你一句话:数据结构没有银弹,只有最合适的。理解了每种结构的优缺点,你才能在项目中做出正确的选择。

核心总结:

  • 双链表:每个节点多一个 prior 指针,删除操作 O(1)
  • 循环链表:尾节点指向头节点,遍历永不停止
  • 约瑟夫环:循环链表的经典应用,删除操作是关键
  • 实战要点:画图理解指针变化,注意边界条件

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