6、双链表与循环链表:双链表的定义与实现、循环链表的定义与实现、双向循环链表

说实话,单链表在实际项目中用起来有时候挺憋屈的。你想想看,想找前一个节点,得从头遍历一遍,效率低得让人抓狂。我当年做嵌入式协议栈的时候,就因为这个吃了不少苦头。后来换了双链表,整个世界都清净了。

这一节,咱们就把双链表、循环链表、还有双向循环链表这几个"亲戚"一次性讲透。代码我会手写给你看,坑我也会一个一个指出来。

6.1 双链表的定义

双链表,说白了就是每个节点多了一个指向前驱的指针。单链表只有一个 next,双链表多了一个 prev。

结构体定义长这样:

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

嗯,这里要注意:头节点的 prev 永远是 NULL,尾节点的 next 永远是 NULL。这是双链表的基本约束,破坏了就会出大问题。

6.2 双链表的实现

我个人习惯把双链表的操作封装成几个核心函数。咱们一个一个来。

6.2.1 初始化

DLinkList InitDLinkList() {
    DNode *head = (DNode *)malloc(sizeof(DNode));
    if (head == NULL) {
        return NULL;  // 内存分配失败
    }
    head->prev = NULL;
    head->next = NULL;
    return head;
}

初始化很简单,就是创建一个头节点,两个指针都置空。我在项目中遇到过有人忘了给 prev 赋 NULL,结果遍历的时候野指针乱飞,查了半天才找到原因。

6.2.2 插入节点

双链表的插入比单链表麻烦一点,因为要同时维护两个方向的指针。顺序很重要,搞反了就会丢节点。

int InsertDNode(DLinkList head, int pos, int data) {
    DNode *p = head;
    int i = 0;
    while (p && i < pos - 1) {
        p = p->next;
        i++;
    }
    if (!p || i > pos - 1) return 0;  // 位置不合法

    DNode *newNode = (DNode *)malloc(sizeof(DNode));
    if (!newNode) return 0;
    newNode->data = data;

    // 关键:先连后断
    newNode->next = p->next;
    newNode->prev = p;
    if (p->next) {
        p->next->prev = newNode;  // 这步容易忘
    }
    p->next = newNode;
    return 1;
}

我曾经在写这个函数的时候,把 p->next->prev = newNode 写成了 p->prev = newNode,结果链表直接断成了两截。调试了一下午才发现,真是血的教训。

6.2.3 删除节点

int DeleteDNode(DLinkList head, int pos) {
    DNode *p = head;
    int i = 0;
    while (p && i < pos - 1) {
        p = p->next;
        i++;
    }
    if (!p || !p->next) return 0;

    DNode *q = p->next;
    p->next = q->next;
    if (q->next) {
        q->next->prev = p;
    }
    free(q);
    return 1;
}

删除的时候,记得判断 q->next 是不是 NULL。如果是尾节点,就不需要更新它的 prev 了,因为本来就没有。

6.3 循环链表的定义

循环链表,就是把单链表的尾巴和头连起来。尾节点的 next 不再指向 NULL,而是指向头节点。这样整个链表就变成了一个环。

结构体定义和单链表一模一样:

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

区别在哪?在操作逻辑上。你想想看,遍历的时候怎么判断结束?不能再用 p == NULL 了,得用 p == head。

6.4 循环链表的实现

6.4.1 初始化

CLinkList InitCLinkList() {
    CNode *head = (CNode *)malloc(sizeof(CNode));
    if (!head) return NULL;
    head->next = head;  // 自己指向自己,形成环
    return head;
}

初始化的时候,头节点的 next 指向自己。这是一个空循环链表的状态。我第一次写的时候,习惯性地写了 head->next = NULL,结果后面插入的时候直接死循环了。

6.4.2 遍历

void TraverseCLinkList(CLinkList head) {
    CNode *p = head->next;
    while (p != head) {
        printf("%d ", p->data);
        p = p->next;
    }
    printf("\n");
}

遍历的终止条件是 p == head。这个和单链表完全不同,写代码的时候要时刻提醒自己。

注意:循环链表如果操作不当,很容易陷入死循环。尤其是在插入和删除的时候,一定要确保环的完整性。我曾经在调试一个循环队列的时候,因为忘记更新尾节点的 next,结果程序跑起来就卡死了,CPU 占用率直接飙到 100%。

6.5 双向循环链表

把双链表和循环链表结合起来,就是双向循环链表。头节点的 prev 指向尾节点,尾节点的 next 指向头节点。这样前后都能走通,而且没有死角。

结构体定义:

typedef struct DCLNode {
    int data;
    struct DCLNode *prev;
    struct DCLNode *next;
} DCLNode, *DCLinkList;

6.5.1 初始化

DCLinkList InitDCLinkList() {
    DCLNode *head = (DCLNode *)malloc(sizeof(DCLNode));
    if (!head) return NULL;
    head->prev = head;
    head->next = head;
    return head;
}

初始化的时候,头节点的 prev 和 next 都指向自己。这样既满足双链表的要求,也满足循环链表的要求。

6.5.2 插入操作

int InsertDCLNode(DCLinkList head, int pos, int data) {
    DCLNode *p = head->next;
    int i = 1;
    while (p != head && i < pos) {
        p = p->next;
        i++;
    }
    if (i != pos) return 0;

    DCLNode *newNode = (DCLNode *)malloc(sizeof(DCLNode));
    if (!newNode) return 0;
    newNode->data = data;

    // 双向循环链表的插入,四个指针都要更新
    newNode->next = p;
    newNode->prev = p->prev;
    p->prev->next = newNode;
    p->prev = newNode;
    return 1;
}

这里有个小技巧:先处理新节点的两个指针,再处理前驱和后继的指针。顺序搞反了,就会丢失节点引用。

个人经验:我在做嵌入式消息队列的时候,用的就是双向循环链表。因为消息需要从两端插入和删除,而且经常需要反向遍历。双向循环链表在这种场景下,效率是最高的。插入和删除都是 O(1) 的复杂度,前提是你已经找到了位置。

6.6 三种链表的对比

特性 双链表 循环链表 双向循环链表
前驱指针
环状结构
遍历终止条件 p == NULL p == head p == head
反向遍历 支持 不支持 支持
内存占用 较大(多一个指针) 较小 最大
适用场景 需要双向操作 循环队列、轮询 复杂数据结构

说白了,没有最好的数据结构,只有最合适的。你想想看,如果你的应用只需要单向遍历,用双链表就是浪费内存。反过来,如果需要频繁反向遍历,单链表就是给自己挖坑。

6.7 知识体系总览

下面这张图,把三种链表的关系和核心操作都串起来了。我建议你多看几遍,理解它们之间的演变逻辑。

链表家族知识体系 单链表 next 指针,单向遍历 +prev指针 双链表 prev + next,双向遍历 尾连头 循环链表 尾->next = head 尾连头 +prev指针 双向循环链表 prev+next+环 核心操作对比 插入:维护指针顺序 删除:注意内存释放 遍历:终止条件不同 查找:O(n) 平均复杂度 修改:先查后改 销毁:逐个释放节点

核心要点:

  • 双链表用空间换时间,多一个 prev 指针,换来 O(1) 的前驱访问
  • 循环链表用结构换便利,环状结构适合轮询和循环队列场景
  • 双向循环链表是集大成者,但内存开销也最大,按需选用
  • 写代码时,指针操作的顺序是最大的坑,先连后断是铁律

好了,这三种链表的核心内容就这些。代码我都手写了一遍,你最好也照着敲一遍。光看是学不会的,指针这东西,只有自己调试过几次才能真的理解。

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