一、双链表:让链表学会“倒车”

单链表有个很烦人的问题——你只能从头往后走。想找前一个节点?没门。我当年刚学链表时,写了个约瑟夫环问题,用单链表实现,结果每次删除节点都得从头遍历找前驱,效率低得让人抓狂。后来我才知道,双链表就是来解决这个问题的。

1.1 双链表的定义

说白了,双链表就是在每个节点里多存一个指针,指向前一个节点。这样你就能双向跑了。

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

你看,比单链表就多了一个 prior 指针。但就是这个指针,让很多操作变得简单了。

核心区别:单链表只能 next,双链表可以 prior 和 next 双向移动。

1.2 双链表的插入操作

双链表插入节点,比单链表多了一步——要处理前驱指针。我习惯先画个图再写代码,不然很容易漏掉某个指针的赋值。

// 在 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 有后继
        p->next->prior = s; //    让 p 的后继的前驱指向 s
    s->prior = p;           // 3. s 的前驱指向 p
    p->next = s;            // 4. p 的后继指向 s
    return true;
}
我曾经踩过的坑:第2步容易忘掉判断 p->next 是否为空。如果 p 是尾节点,p->next 是 NULL,你直接访问 p->next->prior 就会段错误。这个 bug 我调了半小时才找到。

1.3 双链表的删除操作

删除操作比插入简单一点,但同样要注意边界情况。

// 删除 p 节点的后继节点
bool DeleteNextNode(DNode *p) {
    if (p == NULL || p->next == NULL) return false;
    
    DNode *q = p->next;          // q 是要删除的节点
    p->next = q->next;           // 1. p 跳过 q 指向 q 的后继
    if (q->next != NULL)         // 2. 如果 q 不是尾节点
        q->next->prior = p;     //    让 q 的后继的前驱指向 p
    free(q);                     // 3. 释放 q 的内存
    return true;
}

嗯,这里同样要判断 q->next != NULL。你想想看,如果 q 是最后一个节点,那 q->next 就是 NULL,访问 prior 就出事了。

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

2.1 循环单链表

循环单链表就是把尾节点的 next 指向头节点。这样做的好处是——从任意节点出发都能遍历整个链表。

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

判断循环单链表是否为空,就看 L->next == L。判断 p 是否为尾节点,就看 p->next == L

小技巧:循环链表配合尾指针使用,可以在 O(1) 时间内找到尾节点。我写循环队列时经常这么干。

2.2 循环双链表

把双链表和循环结合起来,就是循环双链表。头节点的 prior 指向尾节点,尾节点的 next 指向头节点。

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

循环双链表的判断条件很统一:p->next == L 表示 p 是尾节点,p->prior == L 表示 p 是头节点后的第一个节点。

三、静态链表:用数组模拟链表

静态链表这个概念,说实话,我工作这么多年用的不多。但在一些不支持动态内存分配的场景下(比如嵌入式系统),它还是很有用的。

静态链表用数组来存储节点,每个节点包含数据域和游标(cur)。游标代替了指针,指向下一个节点的数组下标。

#define MaxSize 100

typedef struct {
    int data;   // 数据域
    int cur;    // 游标,指向下一个节点的下标
} SLinkList[MaxSize];

静态链表的插入和删除操作,本质上和动态链表一样,只是把指针换成了数组下标。我当年在单片机项目里用过一次,因为那个芯片的内存只有 2KB,malloc 根本不敢用。

静态链表的优缺点:
  • 优点:不需要动态内存分配,适合内存受限环境
  • 缺点:长度固定,不能动态扩展

四、知识体系总览

下面这张图总结了本章的核心内容,我建议你把它记在脑子里。

线性表之链表知识体系 双链表 每个节点有 prior 和 next 插入/删除需处理两个方向 可双向遍历 循环链表 尾节点指向头节点 从任意节点可遍历全表 配合尾指针 O(1) 找尾节点 静态链表 用数组 + 游标实现 适合内存受限环境 长度固定,不能动态扩展 核心要点 双链表解决单向遍历问题 | 循环链表解决首尾连接问题 | 静态链表解决内存受限问题

五、总结与避坑

这一章的内容,说白了就是三种链表变种。双链表让你能倒着走,循环链表让你能绕圈走,静态链表让你在没 malloc 的地方也能用链表。

我最后给你几个实战建议:

  • 写双链表代码时,先画图。我每次写指针操作前都会在纸上画一下,画清楚了再写代码,能省一半调试时间。
  • 循环链表的判空和判尾条件要记牢。面试时经常考这个,我面过的人里十个有八个会搞混。
  • 静态链表在实际项目中用得少,但考研和面试常考。理解它的思想就好,不用死记硬背代码。
我曾经犯过的错:有一次写双链表删除,忘了释放被删除节点的内存。程序跑了三天,内存泄漏导致系统崩溃。从那以后,我每次写 free 都会在注释里标个「别忘了释放」。

好了,这一章就到这里。链表这东西,多写几遍就熟了。你动手试试,把代码敲一遍,比看十遍都管用。


专注资料整理