一篇文章掌握“双向链表”

目录

一、链表基础认知与分类

[(一)链表的 8 种分类逻辑](#(一)链表的 8 种分类逻辑)

(二)双向链表与单链表、顺序表的本质差异

(三)双向链表的核心概念

二、环境搭建与结构体定义

(一)工程文件结构

[(二)List.h 完整代码](#(二)List.h 完整代码)

三、核心函数实现

(一)辅助函数

(二)初始化操作(LTInit)

(三)增

(四)删

(五)改

(六)查

(七)销毁操作(LTDestroy)

四、完整测试用例(test.c)

五、关键问题与解决方案

[(一)为什么必须校验 pos 的合法性?](#(一)为什么必须校验 pos 的合法性?)

[(二)为什么插入 / 删除操作传一级指针即可?](#(二)为什么插入 / 删除操作传一级指针即可?)

(三)如何避免野指针?

[(四)为什么哨兵位数据域赋值为 - 1?](#(四)为什么哨兵位数据域赋值为 - 1?)

(五)双向链表的时间复杂度分析

(六)工程应用场景

(七)总结


一、链表基础认知与分类

(一)链表的 8 种分类逻辑

1、核心分类逻辑

链表通过3 组二元属性组合形成 8 种类型,每种类型对应不同的应用场景,核心分类维度如下:

(1)带头 / 不带头

带头:含哨兵位结点(不存有效数据,仅标识链表起始)

不带头:无哨兵位

(2)双向 / 单向

双向:结点含next(后继)+prev(前驱)指针

单向:仅含next指针

(3)循环 / 非循环

循环:尾结点next指向头节点,形成闭环;

非循环:尾结点next指向NULL

其中带头双向循环链表是所有类型中操作最便捷的,是工程中最常用的链表结构,也是本文讲解的内容。

(二)双向链表与单链表、顺序表的本质差异

1、带头双向循环链表

(1)物理存储特性: 物理离散,逻辑连续

(2)优点:任意位置插入 / 删除效率高(O (1))

(3)缺点: 额外存储prev指针,空间开销大

2、不带头单向链表

**(1)物理存储特性:**物理离散,逻辑连续

(2)优点: 结构简单,空间开销小

(3)缺点: 尾操作效率低(O (n)),仅支持单向遍历

3、顺序表(数组)

**(1)物理存储特性:**物理离散,逻辑连续

(2)优点:随机访问效率高(O (1))

(3)缺点: 头部 / 中间插入 / 删除效率低(O (n))

(三)双向链表的核心概念

**1、哨兵位 (头结点) :**不存储有效数据,仅用于标识链表起始,空链表时next和prev均指向自身

**2、首元结点:**哨兵位next指向的第一个存储有效数据的结点。

3、尾结点:哨兵位prev指向的最后一个存储有效数据的结点。

**4、闭环特性:**尾结点next指向哨兵位,哨兵位prev指向尾结点,形成循环结构。

二、环境搭建与结构体定义

(一)工程文件结构

需创建 3 个文件,分工明确,便于维护:

**1、List.h:**结构体定义、函数声明、头文件引入(对外提供接口)。

**2、List.c:**所有函数的具体实现(内部逻辑)。

**3、test.c:**测试用例编写,验证功能正确性(调用接口)。

(二)List.h 完整代码
cpp 复制代码
#pragma once
// 引入依赖头文件
#include<stdio.h>    // 输入输出
#include<stdlib.h>   // malloc/free
#include<assert.h>   // 断言(调试用)
#include<stdbool.h>  // 布尔类型(判空用)

// 1. 数据类型重定义(便于后续修改数据类型,如改为char/float)
typedef int LTDataType;

// 2. 双向链表结点结构体定义
typedef struct ListNode {
    LTDataType data;          // 数据域:存储结点值
    struct ListNode* next;    // 后继指针:指向后一个结点
    struct ListNode* prev;    // 前驱指针:指向前一个结点
} LTNode;

// 3. 函数声明(接口)

//(1)初始化与销毁
// 初始化:返回哨兵位指针(推荐方式)
LTNode* LTInit();
// 销毁:释放所有结点(需手动置空实参)

//(2)辅助操作
void LTDestroy(LTNode* phead);
// 打印:输出所有有效结点
void LTPrint(LTNode* phead);
// 判空:判断链表是否为空(仅含哨兵位)
bool LTEmpty(LTNode* phead);

//(3)增
// 插入操作
// 尾插:在链表尾部插入结点
void LTPushBack(LTNode* phead, LTDataType x);
// 头插:在链表头部(哨兵位后)插入结点
void LTPushFront(LTNode* phead, LTDataType x);
// 指定位置后插入:在pos节点后插入新结点
void LTInsert(LTNode* pos, LTDataType x);

//(4)删
// 删除操作
// 尾删:删除链表尾部结点
void LTPopBack(LTNode* phead);
// 头删:删除链表头部(哨兵位后)结点
void LTPopFront(LTNode* phead);
// 指定位置删除:删除pos结点
void LTErase(LTNode* pos);

//(5)改
void LTModify(LTNode* pos, LTNode* phead, LTDataType newVal); 

//(6)查
// 查找:根据值查找结点,返回结点指针(未找到返回NULL)
LTNode* LTFind(LTNode* phead, LTDataType x);

三、核心函数实现

(一)辅助函数

1、创建单个结点(BuyNode)

所有插入操作都需要创建新节点,单独封装避免代码冗余:

cpp 复制代码
// 辅助函数:创建一个新结点,初始化数据和指针
LTNode* BuyNode(LTDataType x) 
{
    // 1. 向操作系统申请节点大小的内存
    LTNode* node = (LTNode*)malloc(sizeof(LTNode));
    // 2. 检查内存申请是否成功(避免malloc失败导致野指针)
    if (node == NULL) {
        perror("malloc fail!");  // 打印错误信息(如内存不足)
        exit(1);                 // 终止程序(非0表示异常退出)
    }
    // 3. 初始化节点的三个成员
    node->data = x;              // 数据域赋值
    node->next = node;           // 初始自指向(便于后续插入)
    node->prev = node;           // 初始自指向(形成闭环)
    // 4. 返回新节点指针
    return node;
}

2、判空操作(LTEmpty)

判断链表是否为空(仅含哨兵位),用于删除操作前的合法性校验;

cpp 复制代码
bool LTEmpty(LTNode* phead) 
{
    assert(phead);  // 断言:确保哨兵位不为空(避免传入NULL)
    // 空链表标识:哨兵位的next指向自身(无有效节点)
    return phead->next == phead;
}

**Tip:**如果链表为空就返回 1,非空就返回 0;用于删除操作的断言是 assert(!LTEmpty(phead));

3、打印操作(LTPrint)

遍历链表并打印所有有效结点,需跳过哨兵位,终止条件为 "回归哨兵位":

cpp 复制代码
void LTPrint(LTNode* phead) 
{
    assert(phead);  // 确保哨兵位有效
    printf("哨兵位 -> ");
    // 1. 从首元结点开始遍历(跳过哨兵位)
    LTNode* pcur = phead->next;
    // 2. 遍历终止条件:pcur回到哨兵位(所有有效结点已遍历)
    while (pcur != phead) {
        printf("%d -> ", pcur->data);  // 打印当前结点值
        pcur = pcur->next;             // 指针后移
    }
    printf("哨兵位\n");  // 标识链表闭环
}
(二)初始化操作(LTInit)

初始化的核心是创建哨兵位结点,确保链表初始状态为 "空链表"(仅含哨兵位,自指向闭环):

cpp 复制代码
// 初始化方式:返回哨兵位指针(推荐,无需传二级指针)
LTNode* LTInit() {
    // 创建哨兵位结点,数据域赋值为-1(无效值,仅标识)
    LTNode* phead = BuyNode(-1);
    return phead;  // 返回哨兵位指针,供外部使用
}

//在Test函数中调用这个方法
//初始化链表,获得哨兵位结点,然后就可以通过插入操作增加数据
LTNode* plist = LTInit();
(三)增

1、尾插(LTPushBack)

**(1)****核心逻辑:**在尾节点(哨兵位prev)后插入新节点,需修改 4 个指针:

新结点的 prev 指向原尾结点;

新结点的 next 指向哨兵位;

原尾结点的 next 指向新结点;

哨兵位的 prev 指向新结点。

Tip:一般就是先操作新结点 、再操作原尾结点 、最后操作哨兵位。【后面基本都是这个顺序】

(2)代码实现

cpp 复制代码
void LTPushBack(LTNode* phead, LTDataType x) {
    assert(phead);  // 确保哨兵位有效(不能向空链表插入)
    
    // 1. 创建新结点
    LTNode* newnode = BuyNode(x);
    // 2. 定位原尾结点(哨兵位的prev)
    LTNode* tail = phead->prev;
    
    // 3. 修改指针(顺序不影响,先处理新节点更安全)
    newnode->prev = tail;    // 新结点前驱 -> 原尾结点
    newnode->next = phead;   // 新结点后继 -> 哨兵位
    tail->next = newnode;    // 原尾结点后继 -> 新结点
    phead->prev = newnode;   // 哨兵位前驱 -> 新结点(新尾结点)
}

2、 头插(LTPushFront)

**(1)核心逻辑:**在哨兵位后插入新节点(成为首元节点),需修改 4 个指针:

新结点的prev指向哨兵位;

新结点的next指向原首元结点;

原首元节结点的prev指向新结点;

哨兵位的next指向新结点。

(2)代码实现

cpp 复制代码
void LTPushFront(LTNode* phead, LTDataType x) 
{
    assert(phead);  // 确保哨兵位有效
    
    // 1. 创建新结点
    LTNode* newnode = BuyNode(x);
    // 2. 定位原首元结点(哨兵位的next)
    LTNode* first = phead->next;
    
    // 3. 修改指针
    newnode->prev = phead;    // 新结点前驱 -> 哨兵位
    newnode->next = first;    // 新结点后继 -> 原首元结点
    first->prev = newnode;    // 原首元结点前驱 -> 新结点
    phead->next = newnode;    // 哨兵位后继 -> 新结点(新首元结点)
}

3、指定位置后插入(LTInsert)

**(1)核心逻辑:**基于目标结点pos,在其后插入新结点,兼容尾插场景(pos为尾结点时自动衔接哨兵位)

(2)代码实现

cpp 复制代码
void LTInsert(LTNode* pos, LTDataType x) 
{
    assert(pos);  // 确保目标位置有效(不能为NULL)
    
    // 1. 创建新结点
    LTNode* newnode = BuyNode(x);
    // 2. 定位pos的后继结点
    LTNode* posNext = pos->next;
    
    // 3. 修改指针
    newnode->prev = pos;      // 新结点前驱 -> pos
    newnode->next = posNext;  // 新结点后继 -> pos的原后继
    posNext->prev = newnode;  // pos原后继的前驱 -> 新结点
    pos->next = newnode;      // pos的后继 -> 新结点
}

Tip:一般先处理新结点 、再从右到左,处理 pos 原后续结点的前驱pos的后继。

(3)应用场景: 结合LTFind函数**(查找)**,实现 "在值为 x 的节点后插入 y":

cpp 复制代码
LTNode* find = LTFind(plist, 2);  // 查找值为2的节点
if (find != NULL) {
    LTInsert(find, 100);          // 在2后插入100
}
(四)删

1、尾删(LTPopBack)

**(1)核心逻辑:**删除尾结点(哨兵位prev),需先判空,再修改 2 个指针,最后释放结点

(2)代码实现

cpp 复制代码
void LTPopBack(LTNode* phead) 
{
    // 1. 合法性校验:哨兵位有效 + 链表非空
    assert(phead);
    assert(!LTEmpty(phead));  // 链表为空时不能删
    
    // 2. 定位尾结点和尾结点的前驱
    LTNode* tail = phead->prev;    // 尾结点
    LTNode* tailPrev = tail->prev; // 尾结点的前驱(新尾结点)
    
    // 3. 修改指针:断开尾结点与链表的连接
    tailPrev->next = phead;    // 新尾结点的后继 -> 哨兵位
    phead->prev = tailPrev;    // 哨兵位的前驱 -> 新尾结点
    
    // 4. 释放尾结点内存,避免内存泄漏
    free(tail);
    tail = NULL;  // 置空,避免野指针
}

2、头删(LTPopFront)

(1)核心逻辑:删除首元结点(哨兵位 next),需先判空,再修改 2 个指针,最后释放结点。

(2)代码实现

cpp 复制代码
void LTPopFront(LTNode* phead) 
{
    // 1. 合法性校验
    assert(phead);
    assert(!LTEmpty(phead));
    
    // 2. 定位首元结点和首元结点的后继
    LTNode* first = phead->next;    // 首元结点(待删除)
    LTNode* firstNext = first->next;// 首元结点的后继(新首元结点)
    
    // 3. 修改指针:断开首元结点与链表的连接
    firstNext->prev = phead;    // 新首元结点的前驱 -> 哨兵位
    phead->next = firstNext;    // 哨兵位的后继 -> 新首元结点
    
    // 4. 释放结点
    free(first);
    first = NULL;
}

3、指定位置删除(LTErase)

**(1)核心逻辑:**删除目标结点pos,需先判空,再修改pos前驱和后继的指针,最后释放pos。

(2)代码实现

cpp 复制代码
void LTErase(LTNode* phead, LTNode* pos) 
{
    assert(phead && pos);       // 哨兵位和pos都必须有效
    assert(!LTEmpty(phead));    // 链表非空
    assert(pos != phead);       // pos不能是哨兵位

    // 直接通过phead和pos操作
    LTNode* posPrev = pos->prev;
    LTNode* posNext = pos->next;
    posPrev->next = posNext;
    posNext->prev = posPrev;
    free(pos);
}
(五)改

**1、核心逻辑:**从头结点开始遍历,找到要修改的结点,然后进行修改。

2、代码实现

cpp 复制代码
void LTModify(LTNode* pos, LTNode* phead, LTDataType newVal) 
{
    //1、合法性验证
    assert(pos != NULL && phead != NULL);  // 禁止野指针,避免野指针访问
    assert(pos != phead);                  // 禁止修改哨兵位,哨兵位不存储有效数据
    assert(!LTEmpty(phead));               // 禁止空链表修改,空链表无有效节点可修改

    //2、寻找要修改的结点
    LTNode* pcur = phead->next;
    while (pcur != phead) {
        if (pcur == pos) {
            break;
        }
        pcur = pcur->next;
    }
   
    //3、判断是否找到
    if(pcur != pos)
    {
        printf("修改失败:pos节点不属于当前链表,无法修改!\n");
        return;
    }
    
    //4、核心操作:修改pos节点的data域
    pos->data = newVal;
    printf("修改成功:节点值已更新为%d\n", newVal);
}
(六)查

**1、核心逻辑:**从首元结点开始线性遍历,匹配到值为 x 的结点则返回指针,未找到返回NULL。

2、代码实现

cpp 复制代码
LTNode* LTFind(LTNode* phead, LTDataType x) 
{
    assert(phead);  // 确保哨兵位有效
    
    // 1. 从首元结点开始遍历(跳过哨兵位)
    LTNode* pcur = phead->next;
   
    // 2. 遍历终止条件:pcur回到哨兵位(所有结点已检查)
    while (pcur != phead) {
        if (pcur->data == x) {
            return pcur;  // 找到,返回节点指针
        }
        pcur = pcur->next;  // 指针后移
    }
    return NULL;  // 未找到,返回NULL
}
(七)销毁操作(LTDestroy)

1、核心逻辑:遍历释放所有有效结点,最后释放哨兵位,需注意一级指针传参需手动置空实参 ;虽然使用二级指针就可以避免这种情况,但是为了保证接口的一致性,我们还是使用一级指针。

2、代码实现

cpp 复制代码
void LTDestroy(LTNode* phead) 
{
    assert(phead);  // 确保哨兵位有效
    
    // 1. 从首元结点开始遍历
    LTNode* pcur = phead->next;
    while (pcur != phead) {
        // 先保存下一个结点地址(避免释放后找不到)
        LTNode* next = pcur->next;
        free(pcur);  // 释放当前节点
        pcur = next; // 指针后移
    }
    
    // 2. 释放哨兵位(最后释放,避免提前释放导致遍历错误)
    free(phead);
    phead = NULL;  // 仅修改形参,实参需外部手动置空
}

这里需要澄清一个常见误区:

在传值调用中,函数的形参确实是实参的 "副本",因此对形参本身的修改(如重新赋值)不会影响外部实参。

但这并不意味着函数无法改变外部数据 ------ 通过形参(例如指针类型的形参)访问和操作其指向的内存空间时,这些修改会直接作用于原始数据,是真实且有效的。

3、调用示例

cpp 复制代码
LTDestroy(plist);  // 释放所有节点
plist = NULL;      // 手动置空实参,避免野指针

四、完整测试用例(test.c)

cpp 复制代码
#include"List.h"

// 测试初始化、插入、打印、删除、查找、修改、销毁
void test01() 
{
    // 1. 初始化链表(获取哨兵位指针)
    LTNode* plist = LTInit();
    printf("初始化后(空链表):");
    LTPrint(plist);  // 输出:哨兵位 -> 哨兵位

    // 2. 尾插测试
    LTPushBack(plist, 1);
    LTPushBack(plist, 2);
    LTPushBack(plist, 3);
    LTPushBack(plist, 4);
    printf("尾插1、2、3、4后:");
    LTPrint(plist);  // 输出:哨兵位 -> 1 -> 2 -> 3 -> 4 -> 哨兵位

    // 3. 头插测试
    LTPushFront(plist, 0);
    printf("头插0后:");
    LTPrint(plist);  // 输出:哨兵位 -> 0 -> 1 -> 2 -> 3 -> 4 -> 哨兵位

    // 4. 查找测试 + 指定位置后插入
    LTNode* find = LTFind(plist, 2);
    if (find != NULL) {
        printf("找到值为2的节点,在其后插入100:");
        LTInsert(find, 100);  // 在2后插入100
        LTPrint(plist);       // 输出:哨兵位 -> 0 -> 1 -> 2 -> 100 -> 3 -> 4 -> 哨兵位
    } else {
        printf("未找到值为2的节点\n");
    }

    // 5. 修改测试:找到值为100的节点并修改为200
    LTFind(plist, 100);
    printf("修改后链表:");
    LTPrint(plist);  // 输出:哨兵位 -> 0 -> 1 -> 2 -> 200 -> 3 -> 4 -> 哨兵位
 
    // 6. 尾删测试
    LTPopBack(plist);
    printf("尾删1次后:");
    LTPrint(plist);  // 输出:哨兵位 -> 0 -> 1 -> 2 -> 200 -> 3 -> 哨兵位

    // 7. 头删测试
    LTPopFront(plist);
    printf("头删1次后:");
    LTPrint(plist);  // 输出:哨兵位 -> 1 -> 2 -> 200 -> 3 -> 哨兵位

    // 8. 指定位置删除测试
    LTNode* delNode = LTFind(plist, 2);
    if (delNode != NULL) {
        printf("删除值为2的节点后:");
        LTErase(delNode);  // 删除2
        LTPrint(plist);    // 输出:哨兵位 -> 1 -> 200 -> 3 -> 哨兵位
    }

    // 9. 销毁测试
    LTDestroy(plist);
    plist = NULL;  // 手动置空
    printf("销毁后,plist = %p\n", plist);  // 输出:plist = 00000000
}

int main() {
    test01();
    return 0;
}

五、关键问题与解决方案

(一)为什么必须校验 pos 的合法性?

1、避免 NULL 指针

若 pos 为 NULL(如 LTFind 未找到目标节点),直接修改 pos->data 会导致程序崩溃。

2、禁止修改哨兵位

哨兵位是链表的 "标志结点",修改其 data 域无意义,还可能导致后续逻辑混乱(如误将哨兵位当作有效结点)。

(二)为什么插入 / 删除操作传一级指针即可?

1、因为不需要修改双向链表的哨兵位地址,插入 / 删除仅修改节点内部的next/prev指针,不会改变哨兵位的地址。

**2、**一级指针足以传递哨兵位地址,无需二级指针,二级指针仅用于修改实参的地址。

(三)如何避免野指针?

**1、malloc 后检查:**创建节点时必须检查malloc是否成功,避免返回NULL导致野指针。

**2、free 后置空:**释放节点后,必须将指针置空(如tail = NULL),避免后续误用。

**3、销毁后手动置空:**调用LTDestroy后,需手动将实参(如plist)置空,因为一级指针传参无法修改实参地址。

(四)为什么哨兵位数据域赋值为 - 1?

**1、**哨兵位不存储有效数据,赋值为 - 1 仅为了标识,也可赋值为其他无效值(如INT_MIN)。

**2、**核心是避免与有效数据冲突,若链表存储的值可能包含 -1,可改为其他值,如 0xCCCCCCCC

(五)双向链表的时间复杂度分析

|-----------------|---------------|--------------------|
| 操作 | 时间复杂度 | 原因 |
| 头插 / 头删、尾插 / 尾删 | O(1) | 直接通过哨兵位定位首尾节点,无需遍历 |
| 指定位置插入 / 删除 | O(1) | 已知节点位置,仅需修改指针 |
| 查找 | O(n) | 线性遍历所有节点 |
| 销毁 | O(n) | 需遍历释放所有节点 |

(六)工程应用场景

**1、编辑器撤销 / 重做功能:**用双向链表存储操作历史,支持向前(撤销)和向后(重做)遍历。

**2、浏览器历史记录:**记录用户访问的网页,支持 "前进""后退" 操作。

**3、双向队列实现:**基于带头双向循环链表,可高效实现队列的首尾操作。

**4、复杂数据结构底层:**如红黑树、哈希表的桶结构,可使用双向链表处理哈希冲突。

(七)总结

带头双向循环链表是功能最完善、操作最高效的链表结构,其核心优势在于:

**1、操作高效:**首尾操作和指定位置操作均为 O (1),远超单链表和顺序表。

**2、结构稳定:**哨兵位机制避免空指针异常,边界处理更简单。

**3、遍历灵活:**支持双向遍历,适应更多场景。

以上即为 一篇文章掌握"双向链表" 的全部内容,创作不易,麻烦三连支持一下呗~

相关推荐
元亓亓亓2 小时前
考研408--数据结构--day14--B树&B+树&散列表
数据结构·b树·散列表·b+树·408
季明洵2 小时前
Java实现循环队列、栈实现队列、队列实现栈
java·数据结构·算法··队列
Non importa2 小时前
二分法:算法新手第三道坎
c语言·c++·笔记·qt·学习·算法·leetcode
qq_454245032 小时前
基于ECS的工作流编排框架
数据结构·c#
iAkuya2 小时前
(leetcode)力扣100 74 数组中的第K个最大元素(快速选择\堆)
数据结构·算法·leetcode
学编程的闹钟2 小时前
安装GmSSL3库后用VS编译CMake源码
c语言·c++·ide·开发工具·cmake·visual studio
云深处@2 小时前
【数据结构】排序
数据结构·算法·排序算法
宇木灵10 小时前
C语言基础学习-二、运算符
c语言·开发语言·学习
想放学的刺客10 小时前
整理了120道单片机嵌入式面试题与答案,覆盖了硬件电路和C语言等核心领域。
c语言·c++·stm32·单片机·嵌入式硬件·mcu·51单片机