数据结构入门 Day3:双向链表与双向循环链表全解析(核心操作 + 易错坑点 + 三类链表对比)


前言

今天是数据结构学习的第三天,在掌握单向链表的基础上,我们进阶学习了双向链表双向循环链表,核心聚焦「创建初始化、头插、尾插、销毁」四大基础操作。本文结合实操中踩过的编译、逻辑坑点,梳理完整知识点,并横向对比单向链表、双向链表、双向循环链表的异同


提示:以下是本篇文章正文内容,下面案例可供参考

一、双向链表基础结构

1.1 节点结构体定义

双向链表的核心是每个节点拥有两个指针域

  • pNext:指向后继节点(下一个节点)

  • pPre:指向前驱节点(上一个节点)

  • 相比单向链表,它支持双向遍历,插入删除时无需额外遍历查找前驱节点,操作灵活性更高。

    复制代码
    // 数据类型重定义,方便后续统一修改数据类型
    typedef int DataType;
    
    // 双向链表节点结构体
    typedef struct node
    {
        DataType Data;       // 数据域:存储有效数据
        struct node *pPre;   // 前驱指针:指向直接前一个节点
        struct node *pNext;  // 后继指针:指向直接后一个节点
    }Node_t;

    本文统一采用带头结点的设计:头结点不存储有效数据,仅作为链表的起始标记,能够统一空表、非空表的操作逻辑,大幅减少边界条件判断。

    二、双向链表(非循环)核心操作

    2.1 链表初始化(创建头结点)

    初始化即创建一个空的带头结点链表,头结点的两个指针都置为NULL

    复制代码
    // 初始化双向链表,返回头结点指针
    Node_t *InitDouList()
    {
        Node_t *pHead = (Node_t *)malloc(sizeof(Node_t));
        if(pHead == NULL)
        {
            printf("malloc failed!\n");
            return NULL;
        }
        pHead->pPre = NULL;
        pHead->pNext = NULL;
        return pHead;
    }

    2.2 尾插法(尾部插入节点)

    尾插核心逻辑:遍历找到链表最后一个节点,将新节点双向绑定在尾部。

    实操易错坑点
  • 新节点创建后,必须手动将pNext置为NULL ,否则malloc的随机脏值会导致遍历死循环、输出刷屏

  • 遍历找尾时,起始指针从头结点出发,避免空链表时NULL->pNext触发段错误

复制代码
  // 双向链表尾插
  int InsertTailDouList(Node_t *pHead, DataType TmpData)
  {
      // 1. 创建并初始化新节点
      Node_t *pNewNode = (Node_t *)malloc(sizeof(Node_t));
      if(pNewNode == NULL)
      {
          printf("malloc failed!\n");
          return -1;
      }
      pNewNode->Data = TmpData;
      pNewNode->pPre = NULL;
      pNewNode->pNext = NULL;  // 尾节点后继必须置空,核心易错点

      // 2. 遍历找尾节点(从头结点出发,天然兼容空表)
      Node_t *pCur = pHead;
      while(pCur->pNext != NULL)
      {
          pCur = pCur->pNext;
      }

      // 3. 双向指针绑定
      pCur->pNext = pNewNode;
      pNewNode->pPre = pCur;

      return 0;
  }

2.3 头插法(头部插入节点)

头插核心逻辑:将新节点插入到头结点与第一个有效节点之间,注意双向指针的修改顺序。

复制代码
  // 双向链表头插
  int InsertHeadDouList(Node_t *pHead, DataType TmpData)
  {
      Node_t *pNewNode = (Node_t *)malloc(sizeof(Node_t));
      if(pNewNode == NULL)
      {
          printf("malloc failed!\n");
          return -1;
      }
      pNewNode->Data = TmpData;

      // 先绑定新节点的前后指针
      pNewNode->pNext = pHead->pNext;
      pNewNode->pPre = pHead;

      // 如果链表非空,修改原第一个节点的前驱指针
      if(pHead->pNext != NULL)
      {
          pHead->pNext->pPre = pNewNode;
      }

      // 最后修改头结点的后继
      pHead->pNext = pNewNode;

      return 0;
  }

2.4 链表销毁(内存释放)

销毁需要逐个释放所有节点,最后将外部头指针置为NULL,避免产生野指针。

常见疑问解答

函数内的临时遍历指针(如pCurpFree)不需要手动置空。它们是栈上的局部变量,函数结束后栈帧销毁,变量会自动回收;只要函数内不再解引用,就不会产生风险。真正必须置空的是外部传入的头指针

复制代码
  // 销毁双向链表,二级指针用于修改外部的头指针
  void DestroyDouList(Node_t **ppHead)
  {
      // 入参合法性校验
      if(ppHead == NULL || *ppHead == NULL)
      {
          return;
      }

      Node_t *pCur = *ppHead;
      Node_t *pFree = NULL;

      while(pCur != NULL)
      {
          pFree = pCur;       // 保存当前待释放节点
          pCur = pCur->pNext; // 先移动到下一个节点,再释放当前节点
          free(pFree);
      }

      *ppHead = NULL; // 关键:外部头指针置空,防止野指针
  }

三、双向循环链表核心操作

3.1 结构特点

双向循环链表在双向链表的基础上,让尾节点的后继指向头结点,头结点的前驱指向尾节点,整个链表形成一个闭环。

  • 空表状态:头结点的pNextpPre都指向自己
  • 遍历终止标志:指针回到头结点,不再是NULL(这是最容易踩的致命坑点)

3.2 初始化

复制代码
  // 初始化双向循环链表
  Node_t *InitCirList()
  {
      Node_t *pHead = (Node_t *)malloc(sizeof(Node_t));
      if(pHead == NULL)
      {
          printf("malloc failed!\n");
          return NULL;
      }
      // 空循环表:自己指向自己,形成闭环
      pHead->pNext = pHead;
      pHead->pPre = pHead;
      return pHead;
  }

3.3 尾插法

致命踩坑点(实操验证)

❌ 错误写法:while(pCur->pNext != NULL) → 循环链表永远碰不到NULL,程序直接死循环、终端刷屏 ✅ 正确写法:while(pCur->pNext != pHead) → 下一个节点是头结点,说明当前就是尾节点

复制代码
  // 双向循环链表尾插
  int InsertTailCirList(Node_t *pHead, DataType TmpData)
  {
      Node_t *pNewNode = (Node_t *)malloc(sizeof(Node_t));
      if(pNewNode == NULL)
      {
          printf("malloc failed!\n");
          return -1;
      }
      pNewNode->Data = TmpData;

      // 找尾节点:下一个节点是头结点,即为尾部
      Node_t *pCur = pHead;
      while(pCur->pNext != pHead)
      {
          pCur = pCur->pNext;
      }

      // 双向指针 + 循环闭环绑定
      pCur->pNext = pNewNode;
      pNewNode->pPre = pCur;
      pNewNode->pNext = pHead; // 新尾节点后继指向头结点,维持循环
      pHead->pPre = pNewNode;  // 头结点前驱指向新尾节点,维持循环

      return 0;
  }

3.4 头插法

复制代码
  // 双向循环链表头插
  int InsertHeadCirList(Node_t *pHead, DataType TmpData)
  {
      Node_t *pNewNode = (Node_t *)malloc(sizeof(Node_t));
      if(pNewNode == NULL)
      {
          printf("malloc failed!\n");
          return -1;
      }
      pNewNode->Data = TmpData;

      // 新节点前后指针绑定
      pNewNode->pNext = pHead->pNext;
      pNewNode->pPre = pHead;

      // 原第一个节点的前驱指向新节点
      pHead->pNext->pPre = pNewNode;
      // 头结点后继指向新节点
      pHead->pNext = pNewNode;

      return 0;
  }

3.5 销毁操作

循环链表销毁的终止条件:遍历指针回到头结点时停止,最后单独释放头结点。

复制代码
  void DestroyCirList(Node_t **ppHead)
  {
      if(ppHead == NULL || *ppHead == NULL)
      {
          return;
      }

      Node_t *pCur = (*ppHead)->pNext; // 从第一个有效节点开始遍历
      Node_t *pFree = NULL;

      // 遍历回到头结点则停止
      while(pCur != *ppHead)
      {
          pFree = pCur;
          pCur = pCur->pNext;
          free(pFree);
      }

      // 最后释放头结点,并置空外部指针
      free(*ppHead);
      *ppHead = NULL;
  }

四、三类链表横向对比

4.1 核心差异对照表

对比维度 单向链表 双向链表 双向循环链表
指针域 仅后继指针pNext 前驱pPre+ 后继pNext 前驱pPre+ 后继pNext
遍历方向 只能从前往后单向遍历 支持双向遍历 支持双向遍历,首尾循环贯通
尾节点标记 pNext == NULL pNext == NULL pNext == 头结点
尾插时间复杂度 O (n),必须遍历找尾 O (n),必须遍历找尾 遍历实现为 O (n);若记录尾指针可优化为 O (1)
删除已知节点效率 O (n),需额外遍历找前驱 O (1),直接通过自身pPre获取前驱 O (1),直接通过自身pPre获取前驱
空表状态 头结点pNext == NULL 头结点pNext == NULL 头结点pNext/pPre均指向自身
空间开销 最小(1 个指针 / 节点) 中等(2 个指针 / 节点) 中等(2 个指针 / 节点)
典型易错点 尾插遍历越界、空表操作 新节点指针未初始化、断链 误用NULL作为循环终止条件

4.2 相同点总结

  • 都属于链式存储结构,节点内存不连续,通过指针串联,支持动态扩容
  • 都可采用带头结点设计,统一空表与非空表的操作逻辑,简化边界判断
  • 插入、删除节点都仅需修改指针,无需移动大量数据,操作效率高于顺序表
  • 都需要手动管理内存(malloc/free),存在内存泄漏、野指针的风险

4.3 适用场景

  • 单向链表:仅需正向遍历、对空间开销敏感的场景,如哈希表拉链法、链式队列实现
  • 双向链表:需要频繁双向遍历、频繁插入删除中间节点的场景,如 LRU 缓存、浏览器前进后退功能
  • 双向循环链表:需要循环访问、首尾操作频繁的场景,如操作系统进程调度队列、循环缓冲区

五、实操高频踩坑汇总

结合本次编码调试遇到的实际问题,整理核心避坑清单:

  • 循环条件写错 :双向循环链表终止条件是头结点,不是NULL,否则会触发死循环、终端无限刷屏

  • 语法细节笔误if/while后多打分号,会导致大括号内代码无条件执行,逻辑完全失效

  • 新节点指针未初始化malloc申请的内存是随机脏值,尾节点不手动置NULL会导致遍历异常

  • 空链表解引用 :直接访问pHead->pNext->pNext,空表时会触发空指针访问、程序段错误崩溃

  • 销毁漏置空头指针:只释放节点不置空头指针,会导致外部指针变成野指针,后续访问触发崩溃

  • 编译链接报错 :Makefile 中-o输出文件名与已有目录重名,会导致链接器ld报错无法生成可执行文件

六、学习总结

双向链表通过增加前驱指针,解决了单向链表无法反向遍历、删除节点需额外查找前驱的痛点;而循环链表则消除了头尾的边界限制,让首尾操作的逻辑更连贯。三类链表没有绝对的优劣,核心是根据业务场景的遍历方向、操作频率、空间限制来选择。

掌握基础的增删操作后,后续可以继续练习指定位置插入、按值查找、按位置删除等进阶操作,进一步夯实链式存储的核心逻辑与指针操作能力。

相关推荐
无聊的菜鸟1 小时前
TMS320F2806X学习笔记(零)—— C2000、CCS、新建工程
笔记·mcu·c2000·f2806x
dear_bi_MyOnly1 小时前
AI人工智能分类识别——机器如何学习
人工智能·学习·分类
caimouse1 小时前
ReactOS 图形系统分析(16):字符串对象 — STROBJ(string.c)
c语言·开发语言
Aphelios3802 小时前
一次锁内网络IO引发的Tomcat线程池“饿死”事故
java·开发语言·spring boot·elasticsearch·tomcat·网络io阻塞·线程池耗尽
不可求~2 小时前
C++ std::string_view 不是字符串:从悬空引用到安全用法
java·开发语言·c++
ljt27249606612 小时前
Compose笔记(八十三)--onVisibilityChanged
笔记
en.en..2 小时前
数据结构考点解析:逻辑闭环与存储循环辨析
数据结构
90后小陈老师3 小时前
PHP 配置默认值失效排查实录:isset 兜底遇上表单空串回填,首页文案是怎么集体消失的
开发语言·php
JJJennie7774 小时前
Gemini 3.7 Flash 上线,Google 想让 AI 多干活
开发语言·javascript·ecmascript