C语言数据结构之双向链表

目录

一、双向链表的核心结构:带头双向循环链表

[1.1 什么是 "哨兵位"?](#1.1 什么是 “哨兵位”?)

[1.2 带头双向循环链表的完整结构](#1.2 带头双向循环链表的完整结构)

[二、双向链表的完整实现(C 语言)](#二、双向链表的完整实现(C 语言))

[2.1 辅助函数:创建新节点(LTBuyNode)](#2.1 辅助函数:创建新节点(LTBuyNode))

[2.2 初始化链表(LTInit)](#2.2 初始化链表(LTInit))

[2.3 销毁链表(LTDestroy)](#2.3 销毁链表(LTDestroy))

[2.4 打印链表(LTPrint)](#2.4 打印链表(LTPrint))

[2.5 判断链表是否为空(LTEmpty)](#2.5 判断链表是否为空(LTEmpty))

[2.6 尾部插入(LTPushBack)](#2.6 尾部插入(LTPushBack))

[2.7 头部插入(LTPushFront)](#2.7 头部插入(LTPushFront))

[2.8 头部删除(LTPopFront)](#2.8 头部删除(LTPopFront))

[2.9 尾部删除(LTPopBack)](#2.9 尾部删除(LTPopBack))

[2.10 查找节点(LTFind)](#2.10 查找节点(LTFind))

[2.11 指定位置插入(LTInsert)](#2.11 指定位置插入(LTInsert))

[2.12 指定位置删除(LTErase)](#2.12 指定位置删除(LTErase))

三、双向链表的测试实例(test.c)

四、顺序表与双向链表的深度对比

[4.1 核心差异对比表](#4.1 核心差异对比表)

[4.2 应用场景选择建议](#4.2 应用场景选择建议)

[五、扩展:链表与数组的 "移除元素" 实现对比](#五、扩展:链表与数组的 “移除元素” 实现对比)

[5.1 数组移除元素(removeElement)](#5.1 数组移除元素(removeElement))

[5.2 双向链表移除元素](#5.2 双向链表移除元素)

[5.3 两种结构移除元素的核心差异](#5.3 两种结构移除元素的核心差异)

六、总结


在数据结构的学习中,链表是与顺序表并列的重要线性表结构,而双向链表作为链表的进阶形式,凭借其前后双向访问的特性,在诸多场景中展现出比单链表更灵活的优势。本文将从双向链表的核心结构入手,完整讲解其实现细节,对比顺序表与双向链表的差异,并通过实例代码演示双向链表的实际应用,帮助读者全面掌握这一数据结构。

一、双向链表的核心结构:带头双向循环链表

双向链表的结构有多种形式,其中带头双向循环链表是实现最便捷、实用性最高的版本。在深入结构细节前,需先明确一个关键概念 ------"哨兵位"。

1.1 什么是 "哨兵位"?

在单链表中,我们常将第一个存储有效数据的节点称为 "头节点",但这种称呼并不严谨。而在带头双向循环链表中,"头节点" 的真正身份是哨兵位节点

  • 哨兵位节点不存储任何有效数据,仅作为链表的 "标志位" 存在。
  • 其核心作用是避免遍历循环链表时出现死循环,同时统一链表的插入、删除操作逻辑(无需单独处理表头、表尾的特殊情况)。

1.2 带头双向循环链表的完整结构

该结构的核心特征可概括为 "双向" 与 "循环":

  • 双向 :每个节点包含两个指针,prev指向前驱节点,next指向后继节点,实现前后双向访问。
  • 循环 :链表的尾节点的next指针指向哨兵位,哨兵位的prev指针指向尾节点,形成闭合循环。

结构示意图如下(head为哨兵位节点):

cpp 复制代码
head(哨兵位)
  ↓    ↑
prev  next
  ↓    ↑
节点1 ←→ 节点2 ←→ 节点3 ←→ ... ←→ 尾节点

节点的结构体定义(C 语言):

cpp 复制代码
// 定义双向链表节点的数据类型
typedef int LTDataType;

// 双向链表节点结构体
typedef struct ListNode
{
    struct ListNode* prev;  // 指向前驱节点的指针
    struct ListNode* next;  // 指向后继节点的指针
    LTDataType data;        // 存储节点的有效数据
} LTNode;

二、双向链表的完整实现(C 语言)

双向链表的实现围绕 "初始化、销毁、增删查改、判空、打印" 等核心操作展开。以下基于带头双向循环链表结构,逐一讲解实现细节,并附上完整代码。

2.1 辅助函数:创建新节点(LTBuyNode)

所有插入操作都需要先创建新节点,因此封装一个通用的 "创建节点" 函数,避免代码冗余:

cpp 复制代码
// 创建一个新的双向链表节点,数据为x,初始时prev和next均指向自身
LTNode* LTBuyNode(LTDataType x)
{
    // 为新节点分配内存
    LTNode* node = (LTNode*)malloc(sizeof(LTNode));
    // 检查内存分配是否成功
    if (node == NULL)
    {
        perror("malloc failed");  // 打印错误信息
        exit(-1);                 // 终止程序(内存分配失败属于严重错误)
    }
    // 初始化节点数据和指针
    node->data = x;
    node->prev = node;  // 初始时自环(便于后续插入逻辑统一)
    node->next = node;
    return node;
}

2.2 初始化链表(LTInit)

初始化的核心是创建哨兵位节点,此时链表仅包含哨兵位,且哨兵位的prevnext均指向自身:

cpp 复制代码
// 初始化双向链表,返回哨兵位节点的地址
LTNode* LTInit()
{
    // 创建哨兵位节点,数据设为-1(无实际意义,仅占位)
    LTNode* phead = LTBuyNode(-1);
    return phead;
}

2.3 销毁链表(LTDestroy)

链表的销毁需要释放所有节点(包括哨兵位),避免内存泄漏。由于是循环结构,需从哨兵位的下一个节点开始遍历,逐个释放:

cpp 复制代码
// 销毁双向链表,释放所有节点内存
void LTDestroy(LTNode* phead)
{
    assert(phead);  // 断言:确保phead不为空(避免传入无效指针)
    
    LTNode* pcur = phead->next;  // 从哨兵位的下一个节点开始遍历
    while (pcur != phead)        // 循环终止条件:回到哨兵位(所有节点已遍历)
    {
        LTNode* next = pcur->next;  // 先保存下一个节点的地址(避免释放后找不到)
        free(pcur);                 // 释放当前节点
        pcur = next;                // 移动到下一个节点
    }
    free(phead);  // 最后释放哨兵位节点
    phead = NULL; // 将指针置空(避免野指针)
}

2.4 打印链表(LTPrint)

打印链表时,从哨兵位的下一个节点开始,遍历到哨兵位结束(避免打印哨兵位的无效数据):

cpp 复制代码
// 打印双向链表的所有有效数据
void LTPrint(LTNode* phead)
{
    assert(phead);  // 确保phead不为空
    
    LTNode* pcur = phead->next;
    printf("哨兵位 -> ");
    while (pcur != phead)
    {
        printf("%d -> ", pcur->data);  // 打印当前节点数据
        pcur = pcur->next;             // 移动到下一个节点
    }
    printf("哨兵位\n");  // 打印结尾,体现循环结构
}

2.5 判断链表是否为空(LTEmpty)

链表为空的标志是:哨兵位的next指针指向自身(无任何有效节点):

cpp 复制代码
// 判断双向链表是否为空(无有效节点),空则返回true,否则返回false
bool LTEmpty(LTNode* phead)
{
    assert(phead);
    // 哨兵位的next指向自身 → 无有效节点
    return phead->next == phead;
}

2.6 尾部插入(LTPushBack)

尾部插入的核心是找到尾节点(哨兵位的prev指向尾节点),然后将新节点插入到尾节点与哨兵位之间:

cpp 复制代码
// 在双向链表尾部插入数据x
void LTPushBack(LTNode* phead, LTDataType x)
{
    assert(phead);  // 确保phead不为空(哨兵位存在)
    
    LTNode* newnode = LTBuyNode(x);  // 创建新节点
    LTNode* tail = phead->prev;      // 找到尾节点(哨兵位的前驱)
    
    // 调整指针,插入新节点
    tail->next = newnode;      // 尾节点的next指向新节点
    newnode->prev = tail;      // 新节点的prev指向尾节点
    newnode->next = phead;     // 新节点的next指向哨兵位
    phead->prev = newnode;     // 哨兵位的prev指向新节点(新节点成为新尾)
}

2.7 头部插入(LTPushFront)

头部插入是在哨兵位与第一个有效节点之间插入新节点,直接利用哨兵位的next指针即可定位插入位置:

cpp 复制代码
// 在双向链表头部(哨兵位之后)插入数据x
void LTPushFront(LTNode* phead, LTDataType x)
{
    assert(phead);
    
    LTNode* newnode = LTBuyNode(x);
    LTNode* first = phead->next;  // 找到第一个有效节点(哨兵位的后继)
    
    // 调整指针,插入新节点
    phead->next = newnode;      // 哨兵位的next指向新节点
    newnode->prev = phead;      // 新节点的prev指向哨兵位
    newnode->next = first;      // 新节点的next指向原第一个节点
    first->prev = newnode;      // 原第一个节点的prev指向新节点
}

2.8 头部删除(LTPopFront)

头部删除需先判断链表是否为空,然后删除哨兵位后的第一个有效节点:

cpp 复制代码
// 删除双向链表头部(哨兵位之后)的第一个有效节点
void LTPopFront(LTNode* phead)
{
    // 断言:链表不为空(哨兵位存在且有有效节点)
    assert(phead && !LTEmpty(phead));
    
    LTNode* del = phead->next;      // 找到要删除的节点(第一个有效节点)
    LTNode* second = del->next;     // 保存删除节点的下一个节点
    
    // 调整指针,跳过删除节点
    phead->next = second;      // 哨兵位的next指向原第二个节点
    second->prev = phead;      // 原第二个节点的prev指向哨兵位
    
    free(del);  // 释放删除节点的内存
    del = NULL; // 避免野指针
}

2.9 尾部删除(LTPopBack)

尾部删除与头部删除逻辑对称,删除哨兵位的前驱节点(尾节点):

cpp 复制代码
// 删除双向链表尾部的节点
void LTPopBack(LTNode* phead)
{
    assert(phead && !LTEmpty(phead));
    
    LTNode* del = phead->prev;      // 找到要删除的尾节点
    LTNode* prevTail = del->prev;   // 保存尾节点的前驱节点
    
    // 调整指针,跳过删除节点
    prevTail->next = phead;      // 原尾节点的前驱指向哨兵位
    phead->prev = prevTail;      // 哨兵位的prev指向原尾节点的前驱
    
    free(del);
    del = NULL;
}

2.10 查找节点(LTFind)

遍历链表,查找数据等于x的节点,找到则返回节点地址,未找到则返回NULL

cpp 复制代码
// 在双向链表中查找数据x,找到返回节点地址,未找到返回NULL
LTNode* LTFind(LTNode* phead, LTDataType x)
{
    assert(phead);
    
    LTNode* pcur = phead->next;
    while (pcur != phead)
    {
        if (pcur->data == x)
        {
            return pcur;  // 找到目标节点,返回地址
        }
        pcur = pcur->next;
    }
    return NULL;  // 遍历结束未找到
}

2.11 指定位置插入(LTInsert)

在指定节点pos后面插入新节点,利用双向链表的前后指针,无需遍历即可完成插入:

cpp 复制代码
// 在指定节点pos的后面插入数据x
void LTInsert(LTNode* pos, LTDataType x)
{
    assert(pos);  // 确保pos是有效的节点地址
    
    LTNode* newnode = LTBuyNode(x);
    LTNode* posNext = pos->next;  // 保存pos的下一个节点
    
    // 调整指针,插入新节点
    pos->next = newnode;      // pos的next指向新节点
    newnode->prev = pos;      // 新节点的prev指向pos
    newnode->next = posNext;  // 新节点的next指向posNext
    posNext->prev = newnode;  // posNext的prev指向新节点
}

2.12 指定位置删除(LTErase)

删除指定节点pos,同样利用双向指针直接调整,无需遍历:

cpp 复制代码
// 删除指定节点pos
void LTErase(LTNode* pos)
{
    assert(pos);  // 确保pos有效
    
    LTNode* posPrev = pos->prev;  // 保存pos的前驱节点
    LTNode* posNext = pos->next;  // 保存pos的后继节点
    
    // 调整指针,跳过pos节点
    posPrev->next = posNext;  // 前驱节点的next指向后继节点
    posNext->prev = posPrev;  // 后继节点的prev指向前驱节点
    
    free(pos);  // 释放pos节点内存
    pos = NULL; // 避免野指针
}

三、双向链表的测试实例(test.c)

为验证双向链表的所有操作是否正确,编写测试代码,涵盖初始化、插入、查找、删除、销毁等流程:

cpp 复制代码
#include "List.h"  // 包含双向链表的头文件

int main()
{
    // 1. 初始化链表
    LTNode* plist = LTInit();
    printf("初始化后链表(空):");
    LTPrint(plist);

    // 2. 尾部插入4个元素:1、2、3、4
    LTPushBack(plist, 1);
    LTPushBack(plist, 2);
    LTPushBack(plist, 3);
    LTPushBack(plist, 4);
    printf("尾部插入1、2、3、4后:");
    LTPrint(plist);  // 预期输出:哨兵位 -> 1 -> 2 -> 3 -> 4 -> 哨兵位

    // 3. 头部插入元素0
    LTPushFront(plist, 0);
    printf("头部插入0后:");
    LTPrint(plist);  // 预期输出:哨兵位 -> 0 -> 1 -> 2 -> 3 -> 4 -> 哨兵位

    // 4. 查找元素3,并在其后面插入30
    LTNode* find = LTFind(plist, 3);
    if (find != NULL)
    {
        LTInsert(find, 30);
        printf("在3后面插入30后:");
        LTPrint(plist);  // 预期输出:... -> 3 -> 30 -> 4 -> ...
    }

    // 5. 删除元素30
    if (find != NULL)
    {
        LTErase(find->next);  // find->next是30的节点
        printf("删除30后:");
        LTPrint(plist);  // 预期输出:... -> 3 -> 4 -> ...
    }

    // 6. 头部删除和尾部删除
    LTPopFront(plist);
    LTPopBack(plist);
    printf("头部删除和尾部删除后:");
    LTPrint(plist);  // 预期输出:哨兵位 -> 1 -> 2 -> 3 -> 哨兵位

    // 7. 销毁链表
    LTDestroy(plist);
    plist = NULL;  // 避免野指针

    return 0;
}

四、顺序表与双向链表的深度对比

顺序表(如动态数组)和双向链表是两种常用的线性表结构,二者在存储空间、访问效率、插入删除效率等方面差异显著,选择需结合具体应用场景。

4.1 核心差异对比表

对比维度 顺序表(动态数组) 双向链表(带头循环)
存储空间 物理上连续(依赖数组) 逻辑上连续,物理上不连续(节点分散)
随机访问 支持 O (1)(通过下标直接访问) 不支持,需遍历 O (N)
插入 / 删除效率 任意位置插入 / 删除需搬移元素,O (N) 已知位置时仅需调整指针,O (1)
容量管理 动态扩容(可能存在内存浪费) 无容量概念,按需分配节点
内存利用率 扩容后可能有空闲内存(如 2 倍扩容) 无空闲内存,每个节点仅存储必要数据
缓存友好性 好(连续存储,命中缓存概率高) 差(节点分散,缓存命中率低)

4.2 应用场景选择建议

  • 优先选顺序表:当需求以 "频繁访问"(如随机查询、遍历)为主,插入 / 删除仅在尾部进行时(如日志存储、数组排序),顺序表的 O (1) 访问和缓存优势更明显。
  • 优先选双向链表:当需求以 "频繁插入 / 删除" 为主(如链表中部插入、删除),且无需随机访问时(如链表式队列、栈、LRU 缓存),双向链表的 O (1) 插入 / 删除效率更优。

五、扩展:链表与数组的 "移除元素" 实现对比

为进一步理解链表与数组的差异,我们对比 "移除元素" 功能的实现(分别基于单链表和数组)。

5.1 数组移除元素(removeElement)

数组移除元素需通过 "双指针" 实现,本质是 "覆盖无效元素",时间复杂度 O (N):

cpp 复制代码
// 移除数组nums中值为val的元素,返回剩余元素个数
int removeElement(int* nums, int numsSize, int val)
{
    int src = 0;  // 遍历指针:寻找非val的元素
    int dst = 0;  // 目标指针:指向待覆盖的位置
    while (src < numsSize)
    {
        if (nums[src] != val)
        {
            nums[dst] = nums[src];  // 用非val元素覆盖dst位置
            dst++;                  // 目标指针后移,准备下一次覆盖
        }
        src++;                      // 遍历指针后移,继续检查下一个元素
    }
    return dst;  // dst的值即为剩余有效元素的个数
}

数组移除元素的核心局限在于物理存储连续:即使找到需移除的元素,也需通过后续元素 "向前覆盖" 来实现 "移除" 效果,若数组长度较大,大量元素的搬移会导致效率降低,这与顺序表 "任意位置插入删除效率低" 的特性一致。

5.2 双向链表移除元素

基于本文讲解的带头双向循环链表,移除指定值元素的操作可结合LTFindLTErase函数实现,时间复杂度取决于查找过程(遍历链表为 O (N)),移除操作本身仅需调整指针(O (1)):

cpp 复制代码
// 移除双向链表中所有值为val的元素
void LTRemoveVal(LTNode* phead, LTDataType val)
{
    assert(phead);  // 确保哨兵位节点有效
    LTNode* pcur = phead->next;  // 从第一个有效节点开始遍历
    while (pcur != phead)  // 遍历终止条件:回到哨兵位
    {
        LTNode* nextNode = pcur->next;  // 提前保存下一个节点地址(避免删除后丢失)
        if (pcur->data == val)
        {
            LTErase(pcur);  // 调用已有删除函数,调整指针并释放节点
        }
        pcur = nextNode;  // 遍历到下一个节点
    }
}

双向链表移除元素的优势在于无需搬移元素 :找到目标节点后,仅需通过prevnext指针连接其前驱与后继节点,再释放目标节点内存即可,完全规避了数组 "元素覆盖" 的效率问题,这也体现了链表 "任意位置插入删除灵活" 的核心特性。

5.3 两种结构移除元素的核心差异

对比维度 数组(顺序表) 双向链表
实现核心 双指针覆盖无效元素 调整节点指针 + 释放目标节点内存
时间复杂度 O (N)(遍历 + 元素覆盖) O (N)(遍历查找)+ O (1)(移除操作)
空间复杂度 O (1)(原地操作) O (1)(仅需指针临时存储)
关键局限 元素搬移导致效率损耗 需提前遍历查找目标节点(无随机访问)

六、总结

本文系统梳理了双向链表的核心知识:首先明确了带头双向循环链表的结构,重点区分了 "哨兵位" 与单链表 "头节点" 的差异,阐述了哨兵位避免死循环、统一操作逻辑的关键作用;其次围绕专题给出的函数声明,讲解了双向链表初始化、增删查改、销毁等操作的实现逻辑,突出其 "双向指针调整" 的核心优势;最后通过与顺序表的对比,明确了二者在存储空间、效率、应用场景上的差异。

双向链表的核心价值在于解决了单链表 "只能单向访问" 和顺序表 "插入删除效率低" 的问题,尤其适合频繁在任意位置进行插入删除的场景。掌握其结构设计与实现逻辑,不仅能提升数据结构的应用能力,也能为后续更复杂的链表或数据结构学习奠定基础。

相关推荐
沐怡旸7 小时前
【底层机制】malloc 在实现时为什么要对大小内存采取不同策略?
c++
max5006008 小时前
期货交易策略自动化实现
运维·开发语言·人工智能·算法·自动化·线性回归
白熊1888 小时前
【论文精读】基于YOLOv3算法的高速公路火灾检测
算法·yolo·yolov3
HABuo8 小时前
【C++进阶篇】学习C++就看这篇--->多态超详解
c语言·开发语言·c++·后端·学习
l1t8 小时前
利用DeepSeek编写验证xlsx格式文件中是否启用sharedStrings.xml对读写效率影响python程序
xml·开发语言·python·算法·xlsx
1白天的黑夜19 小时前
哈希表-1.两数之和-力扣(LeetCode)
c++·leetcode·哈希表
500佰9 小时前
Nano Banana 复刻分镜,多图结合片刻生成想要的视频
人工智能·算法
小O的算法实验室9 小时前
2023年IEEE IOTJ SCI1区TOP,动态环境下无人机目标覆盖任务路径规划,深度解析+性能实测
算法·无人机·论文复现·智能算法·智能算法改进
evolution_language9 小时前
LintCode第401题-排序矩阵中的从小到大第k个数
java·算法·矩阵·排序算法·堆排序·练码精选100题