4. 线性表之双向链表与循环链表

说实话,单链表虽然好用,但有个问题一直让我头疼——你想找前一个节点,得从头开始遍历。我在做嵌入式项目时,有一次需要频繁地双向移动数据,单链表的效率实在让人抓狂。后来我换成了双向链表,问题迎刃而解。

今天我们就来聊聊双向链表和循环链表。这两个结构在实际开发中非常常见,尤其是循环链表,约瑟夫环问题就是它的经典应用。

4.1 双向链表的结构

双向链表,说白了就是每个节点多了一个指针——指向前一个节点。你看,单链表的节点只有一个 next 指针,指向后面。双向链表呢,多了一个 prev 指针,指向前面。

结构体定义是这样的:

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

嗯,这里要注意:头节点的 prev 指针通常指向 NULL,尾节点的 next 指针也指向 NULL。这样我们就能从两个方向遍历了。

我个人习惯用一张图来理解它的结构:

头节点 prev=NULL 节点1 data=10 节点2 data=20 节点3 data=30 尾节点 next=NULL → next 指针 ← prev 指针

你看,每个节点都有一条向前的路和一条向后的路。这就是双向链表的核心思想。

4.2 双向链表的操作

双向链表的操作和单链表类似,但多了 prev 指针的处理。我重点讲插入和删除,因为这两个操作最容易出错。

4.2.1 插入操作

假设我们要在节点 p 后面插入一个新节点 s。步骤是这样的:

// 在节点 p 后面插入节点 s
s->next = p->next;      // 1. s 的后继指向 p 的后继
s->prev = p;            // 2. s 的前驱指向 p
if (p->next != NULL)    // 3. 如果 p 不是尾节点
    p->next->prev = s;  //    让 p 原来的后继的前驱指向 s
p->next = s;            // 4. p 的后继指向 s

我曾经在项目中犯过一个错误——忘了判断 p->next 是否为 NULL。结果在链表尾部插入时,程序直接崩溃了。你想想看,如果 p 是尾节点,p->next 是 NULL,你再去访问 p->next->prev,那不就是空指针解引用吗?

注意:插入操作一定要先处理 s 的指针,再处理 p 的指针。顺序搞反了,链表就断了。

4.2.2 删除操作

删除节点 p 就简单一些:

// 删除节点 p
p->prev->next = p->next;  // 1. 前驱的后继指向 p 的后继
if (p->next != NULL)      // 2. 如果 p 不是尾节点
    p->next->prev = p->prev; // 让后继的前驱指向 p 的前驱
free(p);                  // 3. 释放 p

这里同样要注意边界条件。如果 p 是头节点,p->prev 是 NULL,那 p->prev->next 就会出错。所以实际代码中需要单独处理头节点的情况。

4.3 循环链表的概念

循环链表,就是把单链表的尾节点指向头节点,形成一个环。你想想看,这样有什么好处?

好处很明显——从任何一个节点出发,都能遍历整个链表。我在做嵌入式系统时,经常用循环链表来实现轮询调度。每个任务就是一个节点,CPU 轮流执行,永远不会走到尽头。

循环链表的结构定义和单链表一样:

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

区别在于:尾节点的 next 指针指向头节点,而不是 NULL。

核心区别:
  • 单链表:尾节点 next = NULL
  • 循环链表:尾节点 next = 头节点

判断循环链表是否遍历完,不能看 next 是否为 NULL,而要看 next 是否等于头节点。

4.4 约瑟夫环问题

约瑟夫环问题,是循环链表的经典应用。故事是这样的:

有 n 个人围成一圈,从第一个人开始报数,数到 m 的人出列,然后从下一个人重新报数,直到所有人都出列。问出列顺序是什么?

你看,这天然就是一个循环链表。每个人是一个节点,围成一圈就是循环链表。报数就是遍历节点,数到 m 就删除节点。

代码实现如下:

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

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

// 创建循环链表,n 个人
Node* createList(int n) {
    Node *head = NULL, *prev = NULL;
    for (int i = 1; i <= n; i++) {
        Node *p = (Node*)malloc(sizeof(Node));
        p->data = i;
        if (head == NULL) {
            head = p;
        } else {
            prev->next = p;
        }
        prev = p;
    }
    prev->next = head;  // 尾节点指向头节点,形成环
    return head;
}

// 约瑟夫环求解
void josephus(int n, int m) {
    Node *head = createList(n);
    Node *p = head, *prev = NULL;

    // 找到最后一个节点(尾节点)
    while (p->next != head) {
        p = p->next;
    }
    prev = p;  // prev 指向尾节点
    p = head;  // p 指向头节点

    while (p->next != p) {  // 直到只剩一个节点
        // 报数 m-1 次
        for (int i = 1; i < m; i++) {
            prev = p;
            p = p->next;
        }
        // 删除 p 节点
        printf("%d 出列\n", p->data);
        prev->next = p->next;
        free(p);
        p = prev->next;  // 从下一个节点重新开始
    }
    printf("最后剩下: %d\n", p->data);
    free(p);
}

int main() {
    josephus(7, 3);  // 7个人,数到3出列
    return 0;
}

运行结果:

3 出列
6 出列
2 出列
7 出列
5 出列
1 出列
最后剩下: 4
小技巧:约瑟夫环问题也可以用数学公式直接求解,时间复杂度 O(n)。但用循环链表实现,思路更直观,适合理解问题本质。

我在面试中遇到过好几次约瑟夫环问题。说实话,第一次让我手写代码时,我还在循环边界上卡了一会儿。后来我总结了一个经验:画图!把节点画出来,指针指来指去,比干想代码清晰多了。

嗯,双向链表和循环链表就讲到这里。这两种结构在实际开发中非常实用,尤其是当你需要频繁双向遍历或者处理环形数据时。记住,代码写出来是一回事,能跑对是另一回事。多动手,多调试,慢慢就有感觉了。


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