news 2026/10/2 2:45:11

链表核心原理与双端队列实现:从指针操作到空间复杂度

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
链表核心原理与双端队列实现:从指针操作到空间复杂度

这已经是我啃数据结构的第五天了。前面四天从数组、顺序表、栈、队列一路过来,都是“连续存储”的线性结构,今天终于跳到链表,这一跳让我意识到,数据结构真正的分水岭到了。第五天安排的是手写链表、用链表实现双端队列,顺带把空间复杂度这个老熟人拉出来重新算了一笔账。如果你也在学数据结构,不管是期末复习、考研刷题,还是单纯想补补内功,这篇笔记应该能帮你省下不少试错的时间。我会把今天踩过的坑,还有从《李春葆数据结构》和“王道”考研书里摘出来的关键点,都揉在一起讲。

1. 今天学什么:链表为什么是数据结构的分水岭

1.1 前四天的内容只是“热身”

第一天到第四天,我们处理的基本都是顺序存储结构。数组最简单,逻辑和物理都是连续的,访问第i个元素直接用下标算地址,时间复杂度O(1)。到了顺序表,本质还是数组,只是包了一层动态扩容和增删改查的接口。栈和队列呢,其实是操作受限的线性表,栈只在一端进出,队列一头进另一头出,底层的存储还是连续数组。

这些结构有个通病:插入和删除如果要保持元素的相对顺序不变,就得搬移大量元素。比如在顺序表中间插入一个数,平均要移动n/2个元素,时间复杂度O(n)。数组扩容也很麻烦,通常要新开一块更大的内存,把原数据拷贝过去,这个过程会浪费时间和临时空间。所以连续存储适合“读多写少”的场景,但如果你要频繁在中间插入删除,它就变得很笨重。

链表恰恰是用来解决“动态插入删除”这个痛点的。它不要求物理连续,每个元素单独申请内存,通过指针把各个离散的内存块串起来。第五天学完,我对“逻辑结构”和“物理结构”这两个抽象概念才算真正有了体感。链表就是一个典型的逻辑连续但物理不连续的结构,而之前学的数组是逻辑物理都连续。

1.2 链表要解决的核心痛点

链表引入了一个新概念:节点(Node)。一个节点包含两部分,一部分是数据域,另一部分是指针域。指针域存的是下一个节点的地址,这样一来,每个节点不需要挨在一起,也能通过指针找到彼此。

这个设计带来几个直接好处。第一,插入删除不再需要移动数据,只要修改目标位置前后节点的指针指向就行,时间复杂度降到O(1)。第二,空间是按需分配的,需要用多少节点就申请多少,不会像数组那样预先分配一整块导致内部碎片。第三,可以让多个链表共享同一个节点,或者用指针构造出树、图这种更复杂的结构。

但代价也很明显。每个节点都要额外存一个指针,这是额外的空间开销。更麻烦的是,链表不支持随机访问,想找第k个节点,必须从头开始一节一节跳过去,时间复杂度O(n)。所以链表不是“更好”的顺序表,它和数组各有各的适用场景。今天我自己的体会是:动手实现一遍链表的增删改查,比盯着PPT看十遍都有用。因为只有亲自写出Bug,你才会真正记住指针操作的那些致命细节。

2. 手写链表:从结构定义到节点操作

2.1 链表节点结构和内存布局

先看C语言版的定义,这是数据结构教材最常用的写法:

typedef struct Node { int data; // 数据域,这里先拿整型做例子 struct Node* next; // 指针域,指向下一个节点 } Node;

用Python写就更直观了,因为类对象天然就是这种引用结构:

class Node: def __init__(self, data): self.data = data self.next = None

有的教材会给链表加一个头节点,或者叫哨兵节点。头节点的data域不存有效数据,或者存链表长度,它的next指向第一个真正的元素节点。加头节点的好处是统一空表和非空表的操作,插入删除不需要特殊处理“头部”情况。但要注意,头节点在空间复杂度分析里属于“额外开销”,不过通常忽略不计。

我自己的理解是,链表的内存布局就像一列火车,每一节车厢是一个节点,车厢之间的挂钩就是指针。火车可以拖着车厢到处跑,但车厢本身不需要都停在同一个车库。链表里每个节点都是单独malloc出来的,它们的内存地址散落在堆区的各个位置,是next指针把这份“散装”数据串成了逻辑上的序列。

这里有个很容易忽略的细节:节点的声明里指针指向的是“下一个节点结构体”,不是指向下一个data字段。因为在C语言里,只有知道了结构体的完整类型,才能通过指针访问它的成员。所以写struct Node* next,而不是int* next。这个一开始会绕,但写多了就懂了。

2.2 头插法与尾插法:两种构建方式的差异

构建链表有两种常见方式:头插法和尾插法。它们的代码差异很小,但生成的链表元素顺序完全相反。

头插法,每次把新节点插在头节点之后。核心逻辑是:新节点的next指向当前第一个节点,然后头节点的next指向新节点。代码长这样:

void insertHead(Node* head, int data) { Node* newNode = (Node*)malloc(sizeof(Node)); newNode->data = data; newNode->next = head->next; head->next = newNode; }

注意这里有个顺序问题:必须先“新节点的next指向旧第一个节点”,再“头节点指向新节点”。如果反过来,先让head->next指向newNode,那原来的第一个节点就找不到了,链表直接断掉。这个顺序我第一天写的时候就搞反过,调试到怀疑人生。

尾插法,需要维护一个尾指针tail,新节点插在链表的尾巴上。逻辑是:tail的next指向新节点,然后tail移动到新节点。每次都要用malloc创建新节点,所以没有找尾巴的遍历开销,前提是tail指针一直保持更新。

void insertTail(Node* head, Node** tail, int data) { Node* newNode = (Node*)malloc(sizeof(Node)); newNode->data = data; newNode->next = NULL; (*tail)->next = newNode; *tail = newNode; }

这里用了二级指针,是因为尾指针本身要被修改。如果你不理解二级指针,就用返回值把新tail带回外面,也是一种办法。最笨但稳妥的办法是用一个遍历找到尾节点再插入,但每次插入都是O(n),性能差。实际写代码时,头插法适合用来构建逆序序列,尾插法适合保持输入顺序。比如从键盘读一串数,想原样存进链表,就必须用尾插法;如果只想快速建链不在乎顺序,那头插更快,因为它不需要维护尾指针。

2.3 链表的遍历、插入、删除:指针操作的经典陷阱

遍历链表的动作很简单:从第一个节点开始,用cur = cur->next一路走,直到cur为NULL。但这里藏着最常见的空指针陷阱。比如在删除节点时,你找到了前驱节点pre,要删掉pre->next。你得先保存一下被删节点的下一个节点,不然链接就断了。正确写法:

Node* toDelete = pre->next; pre->next = toDelete->next; free(toDelete);

这段代码的意图是:让前驱节点pre的next直接跳过toDelete,指向toDelete的下一个节点,然后释放掉toDelete所占的内存。很多初学者写成free(pre->next); pre->next = pre->next->next;,想想看,free之后你还能访问pre->next->next吗?不能,因为内存已经释放了,这是悬垂指针。必须先记录后继节点,再释放当前节点。

还有按值删除和按下标删除的区别。按值删除要遍历找到第一个匹配的节点,时间复杂度O(n);按下标删除同样要遍历到指定位置。删除头节点和删除中间节点的处理方式不同,虽然带头节点能统一,但在代码里仍需判断“待删除节点是否为空”。

我自己给链表写测试用例时,习惯在每个关键操作之后打印链表长度和内容,看看结构是否符合预期。如果只是打印next指针地址,很难看出逻辑错误。建议用节点值来验证,比如构造一个递增序列[1,2,3,4,5],然后删除第3个,期待输出[1,2,4,5]。有了标准输入输出,调试一下就看明白了。

3. 双端队列:用链表实现一个“可两边操作”的队列

3.1 双端队列的应用场景

双端队列(Deque)是队列的扩展,“Double Ended Queue”的意思就是两端都可以入队出队。它不像普通队列那样只能尾部进头部出,也不像栈那样只能单端进单端出。双端队列允许你在头部和尾部都执行插入和删除。

这个结构在真实场景里很常见。最经典的是滑动窗口最大值问题,很多算法题用双端队列维护窗口内的候选值,窗口右边进、左边出,两端都要操作。还有一个例子是撤销操作,文本编辑器的撤销历史可以用双端队列来管理,用户撤销很多步之后又想“前进”,这时需要从尾部重新加入历史,同时限制队列长度。任务调度里也常见,某些高优先级任务可以从头部插队,普通任务从尾部入队。

从数据结构学习的角度,双端队列是一个很好的“合体”练习:它既可以基于数组实现,也可以基于链表实现。用链表实现时,我们恰好能把单链表升级成双向链表,因为双向链表天然支持头部和尾部的高效操作。

3.2 基于双向链表的实现思路

要用链表实现双端队列,单链表其实已经能做了:头部插入、头部删除自然没问题,尾部插入如果维护tail指针也可以,但尾部删除就麻烦了,需要找到tail的前驱,而单链表找前驱必须从头遍历,效率O(n)。所以实现真正的双端队列,推荐用双向链表。

双向链表的每个节点有两个指针:prev指向前一个节点,next指向后一个节点。定义长这样:

typedef struct DNode { int data; struct DNode* prev; struct DNode* next; } DNode;

为了统一操作,我们给双向链表加一个头哨兵和一个尾哨兵,这两个哨兵节点不存数据,只是方便处理边界情况。头哨兵的next指向第一个有效节点,尾哨兵的prev指向最后一个有效节点,初始时两个哨兵互相指向对方,表示空队列。

这样一来,在头部插入新节点,就拆成四步:新节点的next指向当前第一个有效节点;新节点的prev指向头哨兵;头哨兵原本的next节点的prev指向新节点;头哨兵的next指向新节点。在尾部插入对称同理。删除头部节点则反过来。这些操作的共同点是只需要修改相邻节点的指针,与队列元素个数无关,所以时间复杂度都是O(1)。

写到这我要强调一下,手写双向链表容易在指针调换顺序上栽跟头。我通常先用纸画节点框,标清楚当前状态和希望的状态,再翻译成代码。很多教材也在反复强调“先读后写”的原则:先把所有需要读取的旧指针保存到临时变量里,再去修改。比如删除节点时,先把待删除节点的前驱和后继记录好,再调整指针,最后free。

3.3 对比循环数组实现的优劣

双端队列也可以使用循环数组来实现。所谓循环数组,就是逻辑上把数组首尾相接,用下标取模来移动头尾指针。比如size=8,tail当前在7,插入新元素时tail = (tail + 1) % 8,下标回到0。这种实现的内存连续,cache友好,访问速度快,而且不需要每个节点存指针,空间开销小。但问题在于数组容量固定,满了以后需要扩容,扩容意味着把旧数组元素搬到新数组,耗时O(n),而且需要拷贝。

链表实现的双端队列反过来:每个节点需要两个指针,空间开销大,节点在堆里离散分布,缓存不友好。但它没有扩容问题,需要多少节点就动态申请多少,插入删除也真正是O(1)。实际工程里,C++标准库的std::deque用的是分段连续数组,Java的ArrayDeque用的是循环数组,它们都很少用链表实现,核心原因就是链表节点分散,内存访问局部性差,性能在大量数据时不如数组。

不过我们学习数据结构,并不是为了“标准库用什么我们就用什么”,而是要理解每种结构背后的取舍。用双向链表实现一遍双端队列,能帮你把“哨兵节点”“双向指针维护”这些硬核技能练扎实,之后再去理解高级的缓存优化结构会容易得多。

4. 空间复杂度分析:链表真的“省空间”吗

4.1 先分清时间复杂度和空间复杂度

学数据结构时,我们经常说某个操作时间复杂度是O(1)还是O(n)。空间复杂度呢,说的是算法在运行过程中额外占用的内存量级,一般用输入规模n的函数来表达。比如顺序表开辟一个大小为n的数组,那空间复杂度就是O(n)。如果只是用几个临时变量,空间复杂度就是O(1)。

对于链表本身,空间复杂度主要取决于节点数量和数据量。这里有个容易被忽略的点:一个链表要存储n个整数,除了n个int数据本身,还要存n个指针。在64位系统上,指针通常是8字节,而int只有4字节。所以只算数据的话需要4n字节,算上指针就变成12n字节,空间占用是原来的3倍。这不是理论上的“常数级”,而是系数问题,在实际场景里影响很大。

我在学习时看到很多考研题目爱比较“数组和链表谁更省空间”,标准答案往往是“链表不一定省,甚至更费”。因为数组一次性申请n个数据空间,最多浪费一些空闲未用的槽位;链表每个节点都带指针,即便数据量小,指针开销也固定。但从动态分配的角度看,数组必须预分配一段连续地址,有时找不到足够大的连续空间,而链表可以用零散内存,这一点在某些受限环境下反而是优势。

4.2 链表 vs 数组:空间开销的量化对比

我们做一个简单的量化。假设存储n个int,每个int占4字节,每个指针占8字节,链表节点结构体在C语言里因为内存对齐,实际可能占16字节(数据4字节+填充4字节+指针8字节)。如果数组也要预留一些空间,比如容量是当前元素的1.5倍,那每个元素的有效空间是6字节。相比之下,链表每个节点16字节,是数组的2.7倍左右。如果数据本身很大,比如是个结构体占256字节,那么指针的额外开销占的比例就小了,链表相对数组的反而不那么浪费。

再看操作的空间开销。用链表做反转,迭代法只需要几个临时指针变量,空间O(1);递归法会占用函数调用栈,递归深度n,空间O(n)。数组反转同样可以用临时变量,O(1)。但数组扩容时,数组内元素要搬移到新数组,旧数组没释放之前,新旧数组共存,那一刻的峰值空间可能接近2n,这也要纳入空间复杂度的考虑。

空间复杂度分析不能只看理论,还要看实际分配器的行为。malloc一个节点,操作系统和C运行时库会在内存块头部附加一些元数据,小对象分配多了,额外元数据的开销可能比数据本身还大。所以“链表比数组省空间”这种说法,在工程里通常不成立。更准确的说法是:链表用“空间换灵活性”,它用额外指针换来了动态插入删除和任意内存碎片场景下的可行性。

4.3 递归深度与空间复杂度的关系

链表相关的算法里,递归特别容易让人忽略空间复杂度。拿反转链表举例,递归解法非常简洁:

Node* reverseList(Node* head) { if (head == NULL || head->next == NULL) { return head; } Node* newHead = reverseList(head->next); head->next->next = head; head->next = NULL; return newHead; }

每次递归调用都要在调用栈上保存一层上下文,包括参数head以及返回地址。链表有n个节点,递归深度就是n,所以空间复杂度O(n)。如果链表有几百万个节点,递归版本很可能栈溢出。迭代版本用三个指针逐个翻转,空间O(1),适用性更强。很多面试官喜欢先让你写递归,再问你能不能优化成迭代,考的就是空间复杂度意识。

学到这里我建议你做一个动作:把所有常用链表操作的时间复杂度和空间复杂度整理成一张表,包括查找、插入、删除、反转、合并、找中点。这样期末复习和面试前看一遍,比重新啃整本书效率高很多。

5. 踩坑实录与学习工具建议

5.1 常见错误速查表

今天写链表和双端队列,我集中踩了好几个坑。趁热打铁整理成一张速查表,你在调试代码时可以直接对照:

常见错误后果排查思路
malloc之后忘记检查返回值内存分配失败时操作空指针每次malloc后if(Node == NULL)报错退出
插入节点时先改头指针再改新节点next丢失原链表第一个节点的引用,造成断链坚持“先连后继,再改前驱”的顺序
删除节点时先free再访问节点的next访问已释放内存,产生悬垂指针先用临时变量保存后继节点,再free当前节点
尾插法忘记更新tail指针后续插入总插在同一个节点后插完立即让tail = newNode
双向链表删除时没修改前驱的next和后继的prev链表前后指针指向不一致,遍历产生环先记录prev和next,再分别更新
递归反转链表深度过大栈溢出 / 运行超时改用迭代法,空间O(1)
混淆链表长度和节点下标越界访问或漏掉最后一个节点用辅助函数printList随时验证

我自己还犯过一个很隐蔽的错误:在C语言里把两个节点结构体直接赋值,比如*a = *b,结果指针字段也被复制了,导致b和a的next指向同一个节点,后续修改a的next会意外影响b。遇到这种诡异问题,第一反应应该是检查浅拷贝和深拷贝的区别。

5.2 参考资料怎么选、怎么用

数据结构教材里,我觉得《大话数据结构》最适合入门,它用大量漫画和生活例子解释各种结构,比如皇帝妃子排队这种梗,虽然有点搞怪,但能让不怕抽象的新手先建立直觉。李春葆老师的《数据结构》教材更严谨,代码和习题非常规范,考研和计算机学科常会用到。第五版配套有学习指导和勘误,如果你用的是这本教材,记得把勘误汇总打印出来,里面有一些印刷错误和习题答案修正,不看会踩坑。

“王道考研”系列是另一条路线,特别适合需要应对笔试的人群。它把知识点按考点整理,每一章都配有习题和解题套路,比如链表和栈的对比,它会给一个很精练的表格。这些书我都翻过,建议不要贪多:选一本主教材用于系统学习,选一本习题集用于刷题巩固,再配合一个在线调试工具就够了。

实际动手时,我推荐用LeetCode或力扣的链表专项题列表。第一题从小题做起,比如“反转链表”“合并两个有序链表”“删除链表的倒数第N个节点”。刷题不是目的,目的是让你通过正确答案验证自己的实现逻辑。每道题提交前,我会在本地把链表打印出来,确认符合预期再提交。

5.3 把学习笔记转成实验报告和期末复习材料

很多学校的数据结构课要求交实验报告,我今天这套链表和双端队列实现可以直接扩展成实验报告。报告我习惯分五个部分:实验目的、需求分析、设计与实现、测试分析、总结心得。其中“设计与实现”必须包含数据结构定义和核心函数的伪代码,配合一两张手绘的节点图,老师会觉得你真的理解了。

如果你是期末复习或考研,一个很有效的方法是“按主题总结复杂度表”。比如每种线性结构(顺序表、单链表、双向链表、循环链表、栈、队列、双端队列)的时间复杂度,访问、查找、插入、删除各列一列。再标注空间复杂度。然后把每一个结构的“适用场景”写在旁边。这份表格就是你的绝杀复习资料。

我自己有一个习惯:学完当天把代码重新抄一遍,不看书,遇到卡住的地方回头看。第二遍往往会发现第一遍理解不到位的地方,特别是指针的更新顺序。这比单纯看别人的代码记忆深刻得多。数据结构不是看会的,一定是写会的。

写在最后的一点体会

学完链表的这一天,我最大的转变是:从“怕指针”变成“理解指针就是在做引用记账”。无论是单链表、双向链表还是双端队列,核心都是管理好节点之间的指向关系。

我发现一个很实用的技巧:在纸上画盒子模型,每个节点画成两格,一格存data,一格存next箭头。执行每一步操作时,先画出旧状态,再画出新状态,然后写下哪几根箭头需要改变方向。写代码的时候照着图改指针,基本不会出错。这个方法对我自己很管用,推荐你也试试。

接下来的day06我打算进入排序算法和查找算法。学完链表和队列,再回头学时最简单的冒泡、选择、插入排序,你会发现数组和链表的区别在排序里更是体现得淋漓尽致。数据结构这条路没有捷径,但每往后走一天,能看懂的东西都会多一层。今天先到这,下次接着聊。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 2:45:07

链表二刷方法论:从快慢指针到归并排序的进阶之路

3月13日,周五,我的刷题记录上多了一行字:二刷基础91、基础84,完成进阶39。懂行的朋友一眼就明白,这是在链表专题上耗掉了一个下午。今天没开新专题,老老实实把旧题翻出来重新做,又啃了一道进阶题…

作者头像 李华
网站建设 2026/10/2 2:44:45

基于Java的心理咨询系统设计与实现:毕设选题与答辩实战指南

带过不少Java方向的毕设,也帮人看过很多类似题目,我得说一句:心理咨询系统这种题,在计算机毕设里算是被严重低估的类型。表面看它就是个普通的预约管理系统,无非是用户注册登录、咨询师列表、选时间预约、后台维护数据…

作者头像 李华
网站建设 2026/10/2 2:44:45

农业管理系统微服务架构实战:SpringBoot+SpringCloud+Vue重构方案

做农业管理系统,最怕的不是功能少,而是功能一多系统就乱成一锅粥。我去年接手了一套农作物果园蔬菜种植管理系统,最初就是单体应用,种植基地、农户档案、农事操作、环境监测、农产品销售全部揉在一个工程里,结果项目运…

作者头像 李华
网站建设 2026/10/2 2:44:20

肝脏癌症2D分割数据集实战:从预处理到训练避坑指南

简介:本资源为面向医学图像分割任务的肝脏癌症数据集,适合从事肝脏及肿瘤分割研究的学生、算法工程师与科研人员使用。原始数据为Liver3d的nii.gz文件,已在x轴方向切分为2D切片,并剔除前景区域不足0.05的样本,共提取8千…

作者头像 李华
网站建设 2026/10/2 2:44:03

从bash到Zsh:Oh My Zsh插件与主题配置实战指南

如果你问我过去几年里最划算的终端升级是什么,我会直接答:把默认 shell 换成 Zsh,再用 Oh My Zsh 做一套趁手的终端配置。这句话我在技术社区里说过很多次,每次都有刚从 bash 迁移过来的人回来说“相见恨晚”。原因很简单&#xf…

作者头像 李华
网站建设 2026/10/2 2:43:59

Python金融风控建模实战:从数据到评分卡部署

简介:这份资源面向金融风控方向的学生与开发者,提供一套基于机器学习的Python大数据风控建模实战项目,可直接用于毕业设计、期末大作业或课程设计。项目围绕信贷违约预测等典型场景展开,涵盖数据清洗、特征工程、模型训练与评估的…

作者头像 李华