跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
C算法

双向链表实现与核心算法解析

带头双向循环链表通过哨兵节点简化边界处理,支持 O(1) 时间复杂度的头尾插入删除。内容包含结构定义、核心接口实现细节、内存管理策略,以及顺序表与链表的性能对比。最后结合经典题目演示移除元素与链表反转的迭代解法,强调指针修改顺序对防止野指针的重要性。

落日余晖发布于 2026/2/17更新于 2026/10/2384 浏览
双向链表实现与核心算法解析

双向链表概念与结构

双向链表是一种链式存储的数据结构,每个节点包含两个指针:一个指向前驱节点(prev),一个指向后继节点(next),同时包含数据域(data)存储数据。这种结构允许双向遍历(从头到尾或从尾到头),并支持更灵活的插入、删除操作,但相比单链表会增加一定的空间开销。

我们这里采用的是带头双向循环链表。它兼具'头节点''双向指针''循环结构'三大特性,是应用最广泛的双向链表类型。其结构稳定、边界处理简单,支持高效的插入、删除和双向遍历操作。

关于头节点

带头链表中的头节点不存储有效数据,只用来简化边界操作(如插入/删除首节点时无需特殊判断)。在单链表学习中有时会把第一个节点表述为头节点,其实严谨定义下,头节点是链表中第一个节点但不存储有效数据,核心价值是简化边界操作。

双向链表结构定义

根据单向链表的知识,我们可以扩展出双向链表的结构:

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

typedef int type;

typedef struct ListNode {
    type data;              // 数据域
    struct ListNode* prev;  // 前驱指针
    struct ListNode* next;  // 后继指针
} ListNode;

实现双向链表

1. 初始化

我们在双向链表中头节点(哨兵位)是需要初始化的。数据域可以存任意值,前驱指针和后继指针都指向自己即可,形成空循环状态。

void LTInit(ListNode** h) {
    ListNode* ph = (ListNode*)malloc(sizeof(ListNode));
    if (ph == NULL) {
        perror("malloc fail!");
        exit(1);
    }
    *h = ph;
    (*h)->data = -1;      // 哨兵位数据
    (*h)->next = *h;      // 指向自己
    (*h)->prev = *h;      // 指向自己
}

这里要注意,传入的是二级指针 ListNode** h,因为我们需要修改外部传入的头指针变量本身。

2. 创建节点辅助函数

为了复用逻辑,我们封装一个创建节点的函数。无论是初始化哨兵位还是创建普通有效节点,都可以用这个逻辑。

ListNode* LTCreate(type x) {
    ListNode* ph = (ListNode*)malloc(sizeof(ListNode));
    if (ph == NULL) {
        perror("malloc fail!");
        exit(1);
    }
    ph->data = x;
    ph->next = ph;  // 自指
    ph->prev = ph;  // 自指
    return ph;
}

3. 尾插操作

对于带头节点的双向循环链表,尾插可直接通过头节点的 prev 指针定位尾节点,无需遍历链表,时间复杂度为 O(1)。

void LTPushBack(ListNode* h, type x) {
    ListNode* p = LTCreate(x);  // 创建新节点
    
    // 关键步骤:调整指针关系
    p->next = h;                // 新节点 next 指向头节点
    p->prev = h->prev;          // 新节点 prev 指向原尾节点
    h->prev->next = p;          // 原尾节点 next 指向新节点
    h->prev = p;                // 头节点 prev 更新为新节点
}

注意指针顺序:一定要先保存好原尾节点的信息,否则一旦修改了 h->prev,就找不到原来的尾节点了,导致链表断裂。

4. 头插操作

头插是将新节点插入到头节点之后、第一个有效节点之前的位置。

void LTPushFront(ListNode* h, type x) {
    ListNode* p = LTCreate(x);
    
    p->next = h->next;  // 新节点 next 指向原第一个有效节点
    p->prev = h;        // 新节点 prev 指向头节点
    h->next->prev = p;  // 原第一个有效节点 prev 指向新节点
    h->next = p;        // 头节点 next 指向新节点
}

如果是空链表(h->next == h),上述逻辑依然成立,因为此时 h->next 就是 h 自己,逻辑上等同于插入到尾部再调整,但在循环结构中直接视为头部插入。

5. 判空与尾删

删除操作前必须先判空,避免对空链表进行操作导致崩溃。

bool LTEmpty(ListNode* phead) {
    assert(phead);
    return phead->next == phead;  // 头节点 next 指向自身即为空
}

void LTPopBack(ListNode* h) {
    if (LTEmpty(h)) {
        return;
    }
    ListNode* p = h->prev;  // 获取尾节点
    
    // 修复指针
    h->prev = p->prev;      // 头节点 prev 指向新尾节点
    p->prev->next = h;      // 新尾节点 next 指向头节点
    
    free(p);                // 释放内存
}

这里利用了双向循环的特性,h->prev 直接指向尾节点,所以不需要遍历,效率很高。

6. 头删操作

删除链表的第一个有效节点(即头节点后的第一个节点)。

void LTPopFront(ListNode* h) {
    if (LTEmpty(h)) {
        printf("链表为空,无法头删\n");
        return;
    }
    ListNode* p = h->next;  // 待删除节点
    
    h->next = p->next;      // 头节点 next 跳过 p
    p->next->prev = h;      // p 的后继 prev 指向头节点
    
    free(p);                // 释放内存
}

7. 销毁

销毁需遍历所有节点并逐个释放内存,最后释放头结点空间。

void LTDestroy(ListNode* h) {
    if (LTEmpty(h)) {
        free(h);
        return;
    }
    ListNode* p = h->next;
    while (p != h) {
        ListNode* pr = p;
        p = p->next;
        free(pr);
    }
    free(h);
    h = NULL;  // 置空防止悬空指针
}

8. 查找与插入删除

查找

利用临时指针遍历,找到目标返回节点,未找到返回 NULL。

ListNode* LTFind(ListNode* h, type x) {
    if (LTEmpty(h)) {
        return NULL;
    }
    ListNode* p = h->next;
    while (p != h) {
        if (p->data == x) {
            return p;
        }
        p = p->next;
    }
    return NULL;
}
指定位置后插入

在指定节点 pos 之后插入新节点。

void LTInsert(ListNode* pos, type x) {
    assert(pos);
    ListNode* p = pos->next;
    ListNode* newnode = LTCreate(x);
    
    pos->next = newnode;
    newnode->prev = pos;
    newnode->next = p;
    p->prev = newnode;
}
指定位置前插入

在指定节点 pos 的前驱位置插入新节点。

void LTInsertFront(ListNode* h, ListNode* pos, type x) {
    if (LTEmpty(h)) {
        return;
    }
    ListNode* p = h;
    // 找到 pos 的前驱
    while (p->next != h) {
        if (p->next == pos) {
            break;
        }
        p = p->next;
    }
    if (p->next == h) {
        return; // 没找到
    }
    
    ListNode* newnode = LTCreate(x);
    ListNode* pr = p->next;
    
    newnode->next = pr;
    newnode->prev = p;
    p->next = newnode;
    pr->prev = newnode;
}
指定位置删除

删除节点需修改前驱节点的 next 和后继节点的 prev,并释放被删节点内存。

void LTErase(ListNode* pos) {
    assert(pos);
    ListNode* p = pos->prev;
    
    p->next = pos->next;
    pos->next->prev = p;
    
    free(pos);
    pos = NULL; // 局部指针置空
}

测试代码整合

为了方便验证,我们将接口整合到一个测试流程中:

void test() {
    ListNode* h;
    LTInit(&h);
    
    LTPushBack(h, 10);
    LTPushBack(h, 15);
    LTPushBack(h, 111);
    print(h); // 输出:10 15 111
    
    LTPushFront(h, 2);
    LTPushFront(h, 12);
    print(h); // 输出:12 2 10 15 111
    
    LTPopBack(h);
    print(h); // 输出:12 2 10 15
    
    LTPopFront(h);
    print(h); // 输出:2 10 15
    
    ListNode* p = LTFind(h, 10);
    LTInsert(p, 100);
    LTInsert(p, 200);
    print(h); // 输出:2 10 200 100 15
    
    LTErase(p);
    print(h); // 输出:2 200 100 15
    
    LTDestroy(h);
}

顺序表与链表的分析

特性顺序表链表
逻辑结构线性表,一对一顺序关系线性表,一对一顺序关系
存储方式连续内存空间离散内存空间,通过指针连接
随机访问O(1),支持下标访问O(n),需遍历
插入/删除平均 O(n),涉及大量移动平均 O(1),仅需修改指针
内存占用静态分配或动态扩容,可能浪费按需分配,无额外空间浪费
适用场景频繁随机访问、数据量固定频繁插入删除、数据量动态变化

关键结论:顺序表适合存储学生信息表等固定场景;链表适合实现队列、栈等动态场景。

链表算法题实战

一、移除链表元素

题目要求移除值为 val 的节点,返回新的头结点。

思路:创建一个新链表,存储符合要求的节点。虽然不释放原链表空间,但在 OJ 解题中通常可接受,且逻辑清晰。

struct ListNode* removeElements(struct ListNode* head, int val) {
    struct ListNode *h = NULL, *pr = NULL;
    struct ListNode *p = head;
    
    while (p) {
        if (p->val != val) {
            if (h == NULL) {
                h = p;
                pr = p;
            } else {
                pr->next = p;
                pr = p;
            }
        }
        p = p->next;
    }
    
    if (pr) {
        pr->next = NULL; // 断开旧链接,防止野指针
    }
    return h;
}

要点:遍历结束后,务必将新链表尾节点的 next 置为 NULL,避免残留原链表后续节点导致死循环或野指针风险。

二、反转链表

使用三个指针迭代法,时间 O(n),空间 O(1)。

  • s1:指向已反转部分的头节点(初始为 NULL)
  • s2:指向当前待反转节点(初始为 head)
  • s3:临时保存 s2 的下一个节点
struct ListNode* reverseList(struct ListNode* head) {
    struct ListNode *s1 = NULL;
    struct ListNode *s2 = head, *s3 = NULL;
    
    if (s2) {
        s3 = s2->next;
    }
    
    while (s2) {
        s2->next = s1;  // 反转指向
        s1 = s2;        // s1 后移
        s2 = s3;        // s2 后移
        if (s3) {
            s3 = s3->next; // s3 后移
        }
    }
    return s1;
}

图解逻辑:每次循环将 s2 的 next 指向 s1,然后三者同步向后移动。循环结束后,s1 指向原链表的尾节点,即新链表的头节点。

以上涵盖了双向链表的核心实现细节及常见算法题解法,重点在于理解指针操作的顺序和内存管理的安全性。

目录

  1. 双向链表概念与结构
  2. 双向链表结构定义
  3. 实现双向链表
  4. 1. 初始化
  5. 2. 创建节点辅助函数
  6. 3. 尾插操作
  7. 4. 头插操作
  8. 5. 判空与尾删
  9. 6. 头删操作
  10. 7. 销毁
  11. 8. 查找与插入删除
  12. 查找
  13. 指定位置后插入
  14. 指定位置前插入
  15. 指定位置删除
  16. 测试代码整合
  17. 顺序表与链表的分析
  18. 链表算法题实战
  19. 一、移除链表元素
  20. 二、反转链表

更多推荐文章

查看全部
  • 一文系统看懂大模型核心概念与原理
  • OpenClaw 本地部署与飞书机器人接入指南
  • 5 大 WebAssembly 编译工具性能深度评测
  • 双指针算法实战:移动零与复写零详解
  • Spring Boot RESTful API 开发实战与测试指南
  • AI 产品经理指南:面试百人后的洞察与职业建议
  • 无人机视觉任务常用数据集汇总:检测与分割资源整理
  • Java 核心技术面试指南:基础、JVM 与框架实战
  • 网络安全基础概念与加密技术详解
  • 使用 Go 构建命令行 AI 对话客户端:环境部署与核心实现
  • 本地部署 GLM-4-9B-Chat-1M:GGUF 转换与 llama.cpp 实战
  • 二叉树深度优先搜索(DFS)核心算法与实战
  • C++ 二叉搜索树:原理与增删查实现详解
  • Python 构建带记忆与人工干预的搜索机器人
  • Meta-Llama-3-8B-Instruct 本地部署与对话实战
  • 归并排序详解:分治策略与 C 语言实现
  • FPGA 在大模型推理中的应用
  • 渗透测试入门指南:从零掌握安全评估与漏洞挖掘
  • 灵感画廊:基于 Stable Diffusion XL 的极简 AI 绘画体验
  • JavaScript Fetch API:如何正确克隆 Request 对象

相关免费在线工具

  • 加密/解密文本

    使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online

  • Gemini 图片去水印

    基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online