双向循环链表

双向循环链表详解(C 语言手写实现):一个头结点,怎么同时解决头插、尾插和反向遍历

本文代码来自我的数据结构练习工程 dslearn/List,Visual Studio 2022 + C 语言实现。

源码文件用 GBK 编码保存,本文展示时已转为 UTF-8。

一、先说问题:单链表卡在哪

先看一眼单链表尾插的实现(List.cpp):

cpp 复制代码
//尾插
void ListPushBack(LNode* L, LDataType x)
{
    LNode* tail = L;
    while (tail->next != NULL)   // ← 必须一路走到尾
    {
        tail = tail->next;
    }
    tail->next = BuyListNode(x);
}

想往末尾加一个元素,得从头开始一步步走到尾。链表有 n 个元素,这次插入就是 n 次指针跳转,O(n) 。尾删(ListPopBack)同理,也是 O(n)。

单链表还有两个更硬的限制:

  • 没法反着走 。每个结点只有 next,一旦走到尾就是 NULL,你没有任何线索回到头。想反向遍历只能从头再走一遍,或者把链表反转(这需要 O(n) 的额外空间)。
  • 已知结点地址也没法在它前面插 。你手里拿着 p,想把新结点插到 p 前面,你不知道 p 的前驱是谁。

这两个问题的根源是同一个:单链表的结点只知道"我后面是谁",不知道"我前面是谁",也不知道"我是不是最后一个"。

二、双向循环链表的结构

解决办法是给每个结点装两个指针:prev(前驱)和 next(后继)。

cpp 复制代码
typedef int DCLDataType;
typedef struct DCListNode {
    DCLDataType data;          // 存储数据元素的值
    struct DCListNode* prev;   // 存放前驱结点的指针
    struct DCListNode* next;   // 存放后继结点的指针
}DCListNode;

再在前面加一个不存业务数据的头结点 L,然后把链表首尾接起来,形成一个环。

空表长什么样

关键点来了 ------ 空的时候,L 的 prev 和 next 都指向它自己:

复制代码
空表(整条链上只有头结点自己):

    ┌─────────────┐
    │  头结点 L   │   data = -1(哨兵值,不参与业务)
    └─────────────┘
        ↺         ↺
     L->prev    L->next     都指回 L 自己

这就是"循环 "二字的含义:链表没有 NULL 结尾,绕一圈会回到头结点。

为什么空表要让指针指回自己?因为这样空表和非空表就长得一模一样了,代码里不需要写"如果空表就特判"这种分支。你看初始化:

cpp 复制代码
//初始化
DCListNode* DCListInit()
{
    DCListNode* L = BuyDCListNode(-1);
    assert(L);
    //空的循环链表:头结点的后继和前驱都指向自己
    L->next = L;
    L->prev = L;
    return L;
}

头结点一初始化成自环,后面所有操作都只有一套代码。这叫哨兵结点技巧。

三个结点时是什么样

复制代码
非空表(元素依次是 1, 2, 3):

    ┌─────┐    ┌───┐    ┌───┐    ┌───┐
    │  L  │ ⇄  │ 1 │ ⇄  │ 2 │ ⇄  │ 3 │
    └─────┘    └───┘    └───┘    └───┘
     头结点

    L->next → 1      1->prev → L     ← 正向走
    L->prev → 3      3->next → L     ← 反向走
    1->next → 2      2->prev → 1
    2->next → 3      3->prev → 2

读这张图时请注意两件事:

  1. 头结点 L 的 next 指向第一个数据结点(1) 。所以第一个数据结点的前驱是头结点,不是 NULL。
  2. 头结点 L 的 prev 指向最后一个数据结点(3) 。所以尾结点的后继是头结点,不是 NULL。

头结点站在环的两端,它既是"1 号的前面",也是"3 号的后面"。

三、本文的核心:头结点是一个万能锚点

现在来看整篇文章最值钱的地方 ------ DCListInsert 只有一份代码,却能表达"在任意结点后面插入":

cpp 复制代码
// 在pos位置后插入值为x的结点
void DCListInsert(DCListNode* pos, DCLDataType x)
{
    assert(pos);
    DCListNode* newNode = BuyDCListNode(x);
    assert(newNode);
    newNode->prev = pos;
    newNode->next = pos->next;      //① 先记住pos原来的后继
    pos->next->prev = newNode;      //② 此时pos->next还是原后继,把它回指给新结点
    pos->next = newNode;            //③ 最后才改pos的后继
}

就这四行。把 pos 换成不同的结点,同一段代码就自动变成不同语义:

传入的 pos 插入位置 实际效果
L(头结点) 头结点的后面 头插
L->next 第 0 个数据结点后面 在第 1 位插入
某个数据结点 p p 的后面 在 p 位后面插入
L->prev(尾结点) 尾结点的后面 尾插

因为环是闭的,"尾结点的后面"就是"头结点的前面",这两句话说的是同一个位置。指针操作天然就兼容了这两种特例,一行 if 都不用加。

所以 DCListPushFront 就退化成了薄薄一层包装:

cpp 复制代码
// 头插
void DCListPushFront(DCListNode* L, DCLDataType x)
{
    assert(L);
    //头结点之后插入 == 头插
    DCListInsert(L, x);
}

对比一下单链表里头插和尾插是两段完全不同的代码 ------ 头插 O(1),尾插要一个 while。这里统一成了"在头结点后面插入",也就是"头插"。

尾插 O(1):双链表真正值钱的地方

尾插虽然也能用 DCListInsert(L->prev, x) 一行搞定,但为了让"循环结构"更直白,工程里是手写了这三行:

cpp 复制代码
// 尾插
void DCListPushBack(DCListNode* L, DCLDataType x)
{
    assert(L);
    DCListNode* newNode = BuyDCListNode(x);
    assert(newNode);
    newNode->next = L;             //① 循环的"出口"永远回到头结点
    newNode->prev = L->prev;
    L->prev->next = newNode;      //② 让原来的尾结点后继指向新结点
    L->prev = newNode;             //③ 让头结点的前驱指向新结点
}

没有任何循环 。因为 L->prev 一直就是尾结点,尾插就是"把新结点挂到 L->prev 和 L 之间",一次定位,零次遍历。这就是 O(1)。

对比单链表尾插的 4 行代码 ------ 一边是"一路 while 走到尾",一边是"直接取 L->prev",这就是双链表相对单链表最直接的性能提升。

四、插入的三步,顺序绝对不能换

这是新手最容易踩的坑,我第一次写也写反过。上面 DCListInsert 里那三行注释标了 ①②③,顺序是有讲究的。

先看正确的效果。假设链表是 L → A ⇄ B(B 是尾),现在要在 A 后面插入 x:

复制代码
插入前:

    ┌───┐    ┌───┐    ┌─────┐
    │ L │ ⇄  │ A │ ⇄  │  B  │
    └───┘    └───┘    └─────┘

插入后:

    ┌───┐    ┌───┐    ┌───┐    ┌─────┐
    │ L │ ⇄  │ A │ ⇄  │ x │ ⇄  │  B  │
    └───┘    └───┘    └───┘    └─────┘

对照代码逐行看,每一行执行后 pos->next 是什么值:

步骤 执行 pos->next 的值
没执行 --- B(原后继)
① newNode->next = pos->next; 还是 B(只是被复制了一份给新结点)
② pos->next->prev = newNode; 还是 B(此时 pos->next 依然是原后继 B,所以这行等于 B->prev = newNode)
③ pos->next = newNode; 变成 newNode(此刻才改)

② 和 ③ 换一下会怎样? 假设写成 pos->next = newNode 在前:

  • pos->next = newNode; → A->next = x,B 被顶掉了

  • newNode->next = pos->next; → 这时 pos->next 已经是 x 了,所以 x->next = x,新结点自己指自己

  • pos->next->prev = newNode; → x->prev = x(本来就是 x)

    写反了的后果:

    复制代码
      ┌───┐    ┌─────┐        ┌─────┐
      │ L │ ⇄  │  B  │   +    │  x  │     B 完全脱离链表
      └───┘    └─────┘        └─────┘
                                       ↺  x 的 next 和 prev 都指向自己
    
      从 L 走到 x 就再也走不出去了 ------ 死循环,同时 B 内存泄漏

所以记住这条规律:"记住原值"和"覆盖原值"这两类操作,原值必须在覆盖之前取出来。 这跟单链表 ListInsert 里的 newNode->next = i_1Node->next; i_1Node->next = newNode; 是同一个道理,只是双链表多了一步"改回去",所以顺序更敏感。

五、遍历终止:没有 NULL,就用"走回头结点"

这是循环链表和普通链表差别最大的地方。

单链表遍历靠 cur != NULL:

cpp 复制代码
while (cur != NULL)   // 单链表:撞到 NULL 就停

循环链表永远撞不到 NULL(除非链表被写坏了),所以只能靠 cur != L 判断"我是不是绕回起点了":

cpp 复制代码
// 获取链表的下标i的结点
// 位序从0开始;空链表或下标越界返回NULL
DCListNode* DCListGetElem(DCListNode* L, int i)
{
    assert(L);
    assert(i >= 0);
    int j = 0;
    DCListNode* cur = L->next;    //从头结点的后继开始走
    while (j < i && cur != L)     // 走出圈了就停
    {
        cur = cur->next;
        j++;
    }
    if (cur == L)
    {
        //两种情况都到这里:链表本来是空的,或者下标i超出了实际长度
        return NULL;
    }
    return cur;
}

注意 while 的条件是 j < i && cur != L,两个条件缺一不可:

  • j < i ------ 还没数够下标,继续走
  • cur != L ------ 还没绕回头结点,说明链里还有数据

只要任意一个 条件不成立就停下来。停下来之后 cur == L 这个检查巧妙地合并了两种失败原因:链表本来是空的(下标 0 就撞上 L),或者下标太大(走完全部数据又撞回 L)。两种情况一律返回 NULL,调用方不用区分。

销毁链表也是同样的原理 ------ 从 L->next 出发,一直走到撞回 L 为止:

cpp 复制代码
void DCListDestroy(DCListNode* L)
{
    assert(L);
    DCListNode* cur = L->next;    // 必须从第一个结点开始,不然循环条件进不去
    while (cur != L)
    {
        DCListNode* next = cur->next;   // 存下下个结点地址,再释放当前结点
        free(cur);
        cur = next;
    }
    free(L);                     // 头结点最后单独释放
}

注释里那句"必须从第一个结点开始"也是踩过坑才写上的 ------ 如果写成 cur = L,那 while (cur != L) 一开始就不成立,循环体一次都不执行,整个链表直接内存泄漏。

六、删除:为什么禁止传入头结点

cpp 复制代码
// 删除pos位置的结点
void DCListDelete(DCListNode* pos)
{
    assert(pos);
    pos->prev->next = pos->next;    // 让前驱和后继互相跨过 pos
    pos->next->prev = pos->prev;
    free(pos);
}

两行摘链,一行释放,都很干净。但是它的参数只有 pos,没有 L。

假设你手滑,把头结点传进去了(DCListDelete(L)):

  • pos->prev 就是 L 自己,pos->next 也是 L 自己
  • 于是执行成 L->next = L; L->prev = L; ------ 链表被清空成"空表"状态
  • 然后 free(L) 把头结点释放了

但调用方的 L 指针还指向那块已经被释放的内存。 下一次调 DCListPrint(L) 就会访问野指针。这是实打实的 UB,不是"报错但还能跑"的那种。

更麻烦的是,接口内部没有任何办法自己校验 ------ 它只拿到一个 pos,无从判断这个指针是不是头结点。

那怎么保证调用方不传错?靠两个接口的配合。回到 DCListGetElem:它在越界时返回 NULL,永远不可能返回头结点 L (因为判断条件是 cur != L)。所以菜单层拿到的一定是个数据结点:

cpp 复制代码
// DCListDelete禁止传入头结点,而DCListGetElem永远不会返回头结点
//(越界时返回NULL),所以这里的pos一定是个数据结点,可以放心传
DCListNode* pos = DCListGetElem(L, i);
if (pos == NULL) { /* 越界,直接退出 */ }
DCListDelete(pos);

这种"一个接口的失败输出正好落在另一个接口的危险输入之外"的配合,比在每个调用点写 if 判断更可靠,也更省事。

顺带说,头删尾删就变成了一行:

cpp 复制代码
// 头删
void DCListPopFront(DCListNode* L)
{
    assert(L);
    assert(L->next != L);      // 空链表不能头删(L->next == L 说明是空表)
    DCListDelete(L->next);
}

// 尾删
void DCListPopBack(DCListNode* L)
{
    assert(L);
    assert(L->prev != L);      // 空链表不能尾删
    DCListDelete(L->prev);
}

判空的条件又变成了一句 L->next != L ------ 空表的定义就是"L 自己指向自己",不需要额外维护 length 或 empty 标志位。

七、正反打印:一个免费的校验手段

cpp 复制代码
// 打印链表中的元素(正向 + 反向,便于验证前驱指针是否正确)
void DCListPrint(DCListNode* L)
{
    assert(L);
    DCListNode* cur = L->next;
    printf("正向:头结点");
    while (cur != L)
    {
        printf("->%d", cur->data);
        cur = cur->next;
    }
    printf("->头结点\n");

    //借助prev指针从尾结点倒着走一遍,这是单链表做不到的
    cur = L->prev;
    printf("反向:头结点");
    while (cur != L)
    {
        printf("->%d", cur->data);
        cur = cur->prev;
    }
    printf("->头结点\n");
}

这里有个很实用的技巧:prev 指针写对了没有,不用靠脑子判断,看两行输出对不对得上就行。

假设链表内容是 1, 2, 3:

  • 正向输出 头结点->1->2->3->头结点
  • 反向输出必须镜像对称 ,即 头结点->3->2->1->头结点

两行放一起对不对称,直接就能定位是哪个结点的前驱没接对。反过来,单链表想这样自检是不可能的 ------ 它压根没有 prev,反向遍历这个动作不存在。

八、完整代码

DCList.h

cpp 复制代码
#pragma once

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

typedef int DCLDataType;
typedef struct DCListNode {
    DCLDataType data;          // 存储数据元素的值
    struct DCListNode* prev;   // 存放前驱结点的指针
    struct DCListNode* next;   // 存放后继结点的指针
}DCListNode;

// 创建一个新结点(申请失败返回NULL)
DCListNode* BuyDCListNode(int data);

// 链表初始化
DCListNode* DCListInit();

// 销毁链表(连头结点一起释放)
void DCListDestroy(DCListNode* L);

// 获取链表的位序i的结点:位序从0开始,空链表或下标越界返回NULL
DCListNode* DCListGetElem(DCListNode* L, int i);

// 在pos位置后插入值为x的结点
// 传入pos为头结点L则等价于头插,传入pos为L->prev则等价于尾插
void DCListInsert(DCListNode* pos, DCLDataType x);

// 删除pos位置的结点
// 禁止传入头结点L:本接口拿不到L,无法自行校验,误传会破坏循环使后续遍历死循环
void DCListDelete(DCListNode* pos);

// 头插
void DCListPushFront(DCListNode* L, DCLDataType x);

// 尾插(O(1),直接从L->prev接上)
void DCListPushBack(DCListNode* L, DCLDataType x);

// 头删(空链表不可调用)
void DCListPopFront(DCListNode* L);

// 尾删(空链表不可调用)
void DCListPopBack(DCListNode* L);

// 打印链表中的元素(正向 + 反向)
void DCListPrint(DCListNode* L);

DCList.cpp

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS
#include "DCList.h"

//创建一个新结点
DCListNode* BuyDCListNode(int data)
{
    DCListNode* newNode = (DCListNode*)malloc(sizeof(DCListNode));
    if (newNode == NULL)
    {
        //申请失败后就直接返回,绝不能再往下走,否则下一行就是对NULL解引用
        printf("BuyDCListNode:申请空间失败\n");
        return NULL;
    }
    //申请成功之后,对数据域和指针域进行初始化
    newNode->data = data;
    newNode->next = NULL;
    newNode->prev = NULL;
    return newNode;
}

//初始化
DCListNode* DCListInit()
{
    DCListNode* L = BuyDCListNode(-1);
    assert(L);
    //空的循环链表:头结点的后继和前驱都指向自己
    L->next = L;
    L->prev = L;
    return L;
}

// 销毁链表
void DCListDestroy(DCListNode* L)
{
    assert(L);
    DCListNode* cur = L->next;//我们必须是从第一个结点开始,不然循环条件进不去
    while (cur != L)
    {
        //存下一个结点的地址
        DCListNode* next = cur->next;
        free(cur);
        cur = next;
    }
    free(L);
}

// 获取链表的下标i的结点
DCListNode* DCListGetElem(DCListNode* L, int i)
{
    assert(L);
    assert(i >= 0);
    int j = 0;
    DCListNode* cur = L->next;//从头结点的后继开始走
    //循环链表没有NULL作为结束标志,只能靠"走回头结点"来发现越界
    while (j < i && cur != L)
    {
        cur = cur->next;
        j++;
    }
    if (cur == L)
    {
        return NULL;
    }
    return cur;
}

// 在pos位置后插入值为x的结点
void DCListInsert(DCListNode* pos, DCLDataType x)
{
    assert(pos);
    DCListNode* newNode = BuyDCListNode(x);
    assert(newNode);
    //把新结点挂到 pos 和 pos->next 之间,三步的顺序不能颠倒:
    //第②步要用到pos->next的"原值",所以必须排在第③步之前
    newNode->prev = pos;
    newNode->next = pos->next;      //① 先记住pos原来的后继
    pos->next->prev = newNode;      //② 此时pos->next还是原后继,把它回指给新结点
    pos->next = newNode;            //③ 最后才改pos的后继
}

// 删除pos位置的结点
void DCListDelete(DCListNode* pos)
{
    assert(pos);
    //摘链:让pos的前驱和后继互相跨过pos
    pos->prev->next = pos->next;
    pos->next->prev = pos->prev;
    free(pos);
}

// 头插
void DCListPushFront(DCListNode* L, DCLDataType x)
{
    assert(L);
    //头结点之后插入 == 头插,与单链表"遍历到尾再插"相比是O(1)
    DCListInsert(L, x);
}

// 尾插
void DCListPushBack(DCListNode* L, DCLDataType x)
{
    assert(L);
    DCListNode* newNode = BuyDCListNode(x);
    assert(newNode);
    //双链表的优势:直接从L->prev(尾结点)接上,不需要像单链表那样先遍历一遍
    newNode->next = L;             //① 循环的"出口"永远回到头结点
    newNode->prev = L->prev;
    L->prev->next = newNode;      //② 让原来的尾结点后继指向新结点
    L->prev = newNode;             //③ 让头结点的前驱指向新结点
}

// 头删
void DCListPopFront(DCListNode* L)
{
    assert(L);
    assert(L->next != L);// 空链表不能头删
    DCListDelete(L->next);
}

// 尾删
void DCListPopBack(DCListNode* L)
{
    assert(L);
    assert(L->prev != L);// 空链表不能尾删
    DCListDelete(L->prev);
}

// 打印链表中的元素(正向 + 反向)
void DCListPrint(DCListNode* L)
{
    assert(L);
    DCListNode* cur = L->next;
    printf("正向:头结点");
    while (cur != L)
    {
        printf("->%d", cur->data);
        cur = cur->next;
    }
    printf("->头结点\n");

    //借助prev指针从尾结点倒着走一遍,这是单链表做不到的
    cur = L->prev;
    printf("反向:头结点");
    while (cur != L)
    {
        printf("->%d", cur->data);
        cur = cur->prev;
    }
    printf("->头结点\n");
}

九、跑起来看看

工程的 Test.cpp 是个二级菜单交互程序,主菜单选 2 进入双向循环链表部分:

复制代码
=========== 链表操作菜单 ===========
1. 单链表
2. 双向循环链表
0. 退出
===================================
请选择:2

========= 双向循环链表测试菜单 =========
1. 打印链表(正向 + 反向)
2. 求链表长度
3. 判断链表是否为空
4. 按位查找(返回结点地址)
5. 头插
6. 尾插
7. 在第i个结点后插入值x
8. 头删
9. 尾删
10. 删除第i个结点
11. 销毁链表
0. 退出
=======================================

依次尾插 1、2、3:

复制代码
请选择:6
请输入尾插的值:1
尾插成功:插入了值 1
正向:头结点->1->头结点
反向:头结点->1->头结点

请选择:6
请输入尾插的值:2
尾插成功:插入了值 2
正向:头结点->1->2->头结点
反向:头结点->2->1->头结点

请选择:6
请输入尾插的值:3
尾插成功:插入了值 3
正向:头结点->1->2->3->头结点
反向:头结点->3->2->1->头结点

在第 0 个结点(即值 1)后面插入 0:

复制代码
请选择:7
请输入位置i和插入的值x:0 0
插入成功:在第 0 个结点后插入了值 0
正向:头结点->1->0->2->3->头结点
反向:头结点->3->2->0->1->头结点

连着头删 4 次,一直删空:

复制代码
请选择:8
头删成功
正向:头结点->0->2->3->头结点
反向:头结点->3->2->0->头结点

请选择:8
头删成功
正向:头结点->2->3->头结点
反向:头结点->3->2->头结点

请选择:8
头删成功
正向:头结点->3->头结点
反向:头结点->3->头结点

请选择:8
头删成功
正向:头结点->头结点
反向:头结点->头结点

注意最后一行 ------ 空表打印出来是两行 头结点->头结点。空表没有崩溃、没有死循环,说明 L->next == L->prev == L 这个闭环自始至终是成立的。每一步的正反两行输出都严格镜像,这也验证了 prev 指针每一步都接对了。

十、单链表 vs 双向循环链表

操作 单链表 List.h 双向循环链表 DCList.h
头插 O(1) ListPushFront O(1) DCListPushFront
尾插 O(n),需 while 遍历到尾 O(1) ,直接接 L->prev
头删 O(1) ListPopFront O(1) DCListPopFront
尾删 O(n),需遍历到尾 O(1) ,直接摘 L->prev
已知 p,在 p 后插 O(1) O(1)
已知 p,在 p 前插 做不到 O(1) ,DCListInsert(p->prev, x)
反向遍历 做不到 O(n) ,从 L->prev 倒着走
判空 L->next == NULL L->next == L
遍历终止标志 NULL 走回头结点
每个结点开销 1 个指针 2 个指针

双链表的代价 :每个结点多一个指针的内存;插入删除要维护两处引用,出错的面翻倍。双链表的收益:尾插尾删 O(1)、支持前插、支持反向遍历。

所以不是所有场景都该上双链表 。如果只需要从头往后遍历,堆一个多余的 prev 指针纯属浪费(缓存局部性变差)。双链表真正不可替代的地方是:

  • LRU 缓存 ------ 访问过的结点挪到链表头部(后移是删除+头插),淘汰尾部是尾删,两者都是 O(1)
  • 浏览器前进/后退历史 ------ 天生是双向的
  • 文本编辑器光标上下移动 ------ prev 就是往上一行
  • 内存分配器的空闲链表 ------ 按地址或大小排序时需要双向遍历和摘除

十一、进阶:当前实现里几个真实存在的隐患

上面这套实现能跑,但对得更深一点还能看到几个问题,一并记下来。

1. 缺 DCListSize / DCListEmpty,判空退化成 O(n)

接口层没有提供长度和判空函数,导致菜单层只能自己写一个遍历:

cpp 复制代码
static int DCListLength(DCListNode* L)
{
    int n = 0;
    DCListNode* cur = L->next;
    while (cur != L) { n++; cur = cur->next; }
    return n;
}

菜单里的"判断链表是否为空"就变成了 if (DCListLength(L) == 0),每次判空都要走完整条链。虽然判空本身有 L->next != L 这个 O(1) 写法,但既然是循环带头结点链表,在结点里加一个 size 字段就能让长度和判空都变成 O(1),代价只是每个结点多几个字节。

2. assert 在 Release 下会被禁用,空表尾删直接 free 掉头结点

cpp 复制代码
void DCListPopBack(DCListNode* L)
{
    assert(L);
    assert(L->prev != L);      // Release 下这行被编译掉了
    DCListDelete(L->prev);
}

Debug 下 assert 拦得住。Release 下 assert 是空操作 ,空链表调 DCListPopBack 会让 L->prev == L,于是 DCListDelete(L) 执行 L->next = L; L->prev = L; 然后 free(L) ------ 头结点被释放,调用方的 L 变野指针,之后任何操作都是 UB。

这是 assert 做参数校验的固有缺陷:它只保护调试期 。真要健壮,判空应该走返回值(返回 int 表示成功与否),或者在 DCListDelete 里额外传 L 让它能自校验。

3. DCListInsert 只能后插,但前插只差一行

想在 pos 前面插入,其实就是 DCListInsert(pos->prev, x)。接口文档里没把这个用法点出来,容易让人以为"双链表只能后插",白白浪费了 prev 的价值。

4. 头结点 data = -1,和业务数据有潜在冲突

目前 DCListGetElem 永远不会返回头结点,所以就算链表里真有一个 -1 的元素也不会被误认。但这个安全性是依赖于"没有任何接口会返回头结点"这个约定 的。哪天加一个"取头结点"或者"按值查找"的接口,就得重新审视这个 -1。

5. 单元测试只覆盖了单链表,双向循环链表一个用例都没有

工程里有个 ListTest 工程(Visual Studio 自带的 Google Test 框架),给单链表的 14 个接口写了 40 多个用例,测得很细。但 DCList 这 10 个接口完全没被测到。至少该补上这几个:

  • 空表初始化后 L->next == L && L->prev == L
  • DCListGetElem 越界返回 NULL
  • 正向遍历与反向遍历结果镜像一致
  • DCListPushBack 之后 L->prev 确实指向新结点(这是尾插最容易写错的地方)
  • 连删到空表不崩溃

十二、小结

双向循环链表的核心,其实就三句话:

  1. 循环 + 带头结点 ,让 L->prev 永远指向尾、L->next 永远指向头,空表时两者都指向自己。
  2. 因为 1,"在 pos 后面插入"一个接口就够用了 ------ pos 传头结点就是头插,传尾结点就是尾插,特例不需要写分支。
  3. 因为有 prev,尾插尾删变成 O(1),前插和反向遍历从"做不到"变成"一行搞定"。

代价是每个结点多一个指针,以及维护两处引用的难度翻倍。

写这类链表代码时,最该养成的两个习惯是:插入/摘链的语句顺序要想清楚"谁在用原值" ,以及用正反双向遍历的输出来自动校验指针接得对不对。

相关推荐
dtq04243 小时前
数据结构 - 堆
数据结构
nhdh10 小时前
B+树揭秘:MySQL索引核心原理全解析
数据结构·b树·mysql
蛋蛋的就会顺顺的15 小时前
hot100——矩阵
java·数据结构·算法·leetcode·力扣
学代码的CJY16 小时前
从代码出发理解时间复杂度与空间复杂度
数据结构·算法
字节跳动的猫18 小时前
LikeShop 积分体系全链路改造:获取规则、订单抵扣与积分商品兑换二开
运维·数据结构
上位机妹子18 小时前
C 语言 数组删除指定元素(快慢指针法)
c语言·数据结构·算法
C++ 老炮儿的技术栈21 小时前
有符号变量与无符号变量的区别
服务器·开发语言·前端·数据结构·c++·算法·c
keyipatience1 天前
跳表详细解析
开发语言·数据结构·c++·算法·跳表
小小、码农1 天前
〖Linux文件系统〗:彻底打通文件 IO 全链路
linux·开发语言·数据结构·c++·系统