ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

链表基础入门:从指针关系到插入删除实战

2026/9/30 3:12:42 拓冰建站 浏览量
链表基础入门:从指针关系到插入删除实战 在数据结构的所有入门内容里链表恐怕是第一个让不少同学“有点意思但又有点慌”的章节。它本身只解决一件简单的事——把数据用“指向关系”串起来可一旦落到代码上指针怎么改、边界怎么判、空链表怎么处理任何一个细节漏掉程序就可能直接崩掉。这篇文章我打算把链表的基础概念从头到尾梳理一遍重点放在你写实验报告、应付期末考试和考研面试时真正会碰到的那些场景上。我会尽量把“为什么要这样做”解释清楚让没接触过链表的同学也能一天之内建立起一个完整、清晰的框架。1. 为什么要用链表先想清楚存储结构这件事1.1 链表到底解决了什么问题如果你以前只接触过数组那你可能会觉得“数据存储”就是在内存里找一块连续的地方把元素一个个排好。这确实是顺序表的做法但真正写程序的时候数组的插入和删除相当折腾在中间插入一个元素后面所有元素都要向后挪一位删除一个元素后面所有元素又要往前补位。数据量小的时候无所谓数据量一大这种“搬家”的开销就会被无限放大。链表换了一个思路。它不强调物理位置连续而是让每个节点在内存里随便待只要在每个节点身上记录“下一个节点在哪里”就够了。这个用于记录位置的字段在C语言里叫指针在Python、Java里叫引用。这样一来插入和删除变成了纯粹的“接线”操作改一下前后两个节点之间的指向关系完全不需要搬动其他数据。你可以想象一个按学号排队的班级现在要往中间插一个新同学。数组的做法是让后面所有人往后挪链表的做法只是告诉前面那个同学“你后面的人换了”再告诉新同学“你后面是原来的那个人”——队伍其他人都没动。但代价也随之而来。想找到第5个节点你只能从第一个节点开始顺着next一路遍历过去数组则可以用下标一步定位。这就是常说的“顺序表随机访问能力强链表插入删除效率高”。很多初学者觉得链表难其实不是概念本身难而是“改指针的时机”经常出错。一旦你愿意动手画一画内存示意图大部分困惑都会自动消除。1.2 单链表、循环单链表、双链表三个变体解决不同痛点基础的链表只有next方向所以叫单链表。它的实现最简单但也最“一根筋”只能从前往后走想找前一个节点必须从头再来一遍。为了补上这个缺点出现了两个常见变体双链表每个节点增加一个prev指针既知道后继也知道前驱。代价是每个节点多存一个指针内存开销变大了。循环单链表把最后一个节点的next重新指回头节点形成闭环。方便从尾再绕回头部继续遍历但编码时必须小心判断终止条件否则容易死循环。它们之间的取舍可以用一个表格说清楚类型指向关系主要优点主要代价单链表每个节点只存next存储开销小、结构简洁无法直接访问前驱双链表每个节点存prev和next双向遍历、删除操作灵活每个节点多一个指针循环单链表尾节点的next指向头节点环上任意点都能出发遍历整表终止条件要特别注意如果同时把“双链”和“循环”结合就得到循环双链表。考研教材里常把它作为提高内容日常代码里用得不算多但你理解了这三种基本形态后循环双链表基本不需要额外再花什么力气。1.3 头指针、头结点、首元结点三个名字先分清初学者最容易绕晕的三个概念这里先一次性厘清头指针指向链表第一个节点的指针变量。无论链表是否为空头指针都存在。它是你操作整个链表的入口。头结点在第一个数据节点之前额外附加的一个节点。它不存放有意义的数据只作为“哨兵”存在用于统一操作。首元结点链表中第一个真正存放数据的节点。做了头结点之后插入和删除首元结点时和其他位置的处理逻辑完全一致不需要单独写if判断。这个设计非常实用空链表和非空链表都能用同一套代码遍历、插入、删除。很多人写链表总觉得边界情况多八成是因为没有用头结点。2. 从零构建链表节点定义、初始化与遍历2.1 节点结构C语言和Python到底在干什么链表的最小组成单位叫节点一个节点至少包含两个部分数据域和指针域。数据域用来存放实际内容指针域用来存下一个节点的地址。C语言里用结构体来定义typedef struct LNode { int data; // 数据域这里以整型为例 struct LNode *next; // 指针域指向下一个节点 } LNode, *LinkList;这里LNode是结构体类型LinkList等价于LNode*也就是指向节点的指针。很多教材把LinkList L定义为一个“头指针”本质上就是一个指针变量。名字不同而已学习时不用太纠结。Python写起来更简洁class ListNode: def __init__(self, data, next_nodeNone): self.data data self.next next_nodePython用类包装节点属性next保存的是另一个节点对象的引用。语言不同逻辑完全一样内存里每个节点靠next找到自己的邻居。学链表的时候我建议不要同时学太多语言的写法专心把C和Python中任意一种吃透另一个很快就能触类旁通。2.2 初始化链表为什么头结点如此重要建立一个单链表第一步通常不是创建首元结点而是创建一个头结点。头结点的数据域可以不存放任何有意义的值它的next指向链表的第一个实际数据节点。LinkList initList() { LNode *head (LNode*)malloc(sizeof(LNode)); if (head NULL) { printf(内存分配失败\n); return NULL; } head-next NULL; // 空表头结点的next指向NULL return head; }这段代码里的malloc是在向系统申请一块内存因为链表节点是动态创建的节点数量完全可以由程序运行时的需要决定这本身就是链表的另一个优势——不需要预判最大长度。注意清空和销毁不是一回事。清空链表是释放所有数据节点保留头结点让链表回到空表状态销毁链表则是连头结点一起释放整条链不存在了。写实验报告时这两个操作经常被一起考察。2.3 遍历从“读数据”到“定位节点”遍历是所有链表操作的地基。插入、删除、查找、统计长度底层都建立在“摸清每个节点在哪”的能力上。void printList(LinkList L) { LNode *p L-next; // p首先指向第一个数据节点 while (p ! NULL) { printf(%d , p-data); p p-next; } printf(\n); }当p NULL表示已经越过了尾节点遍历结束。空链表时p一开始就是NULL循环体不会执行不会报错。Python版本几乎可以一比一对应def traverse(head): p head.next while p is not None: print(p.data, end ) p p.next如果你要在遍历时顺便统计长度就在循环体里加一个计数器如果你想找到第i个节点就把循环条件改成j i。遍历本身不难难的是把遍历和“停下来时的指针位置”联系起来——后面讲插入删除时你会反复用到这个能力。3. 在指定位置插入和删除实操中最容易翻车的三个细节3.1 头插法建表与尾插法建表建立单链表最常用的两种方法是头插法和尾插法。头插法每次把新节点插到链表的头部最后得到的链表顺序和输入顺序相反尾插法则需要维护一个尾指针让新节点依次往后接顺序保持不变。很多时候实验题要求“输入一组数据按原顺序输出”用尾插法更直观。头插法的核心代码很短但极其容易出错void insertHead(LinkList L, int val) { LNode *newNode (LNode*)malloc(sizeof(LNode)); newNode-data val; newNode-next L-next; L-next newNode; }关键就在两行先让新节点指向原来的首元结点再让头结点指向新节点。这两行一旦调换顺序等于先改写了L-next原链表立刻找不到了新节点反而变成了“孤儿”。尾插法稍长一点但逻辑更符合直觉void append(LinkList L, int val) { LNode *newNode (LNode*)malloc(sizeof(LNode)); newNode-data val; newNode-next NULL; LNode *p L; while (p-next ! NULL) p p-next; // 找到当前尾节点 p-next newNode; }这个版本每次都要从头遍历到尾部时间复杂度是O(n)。如果你频繁尾插可以单独维护一个尾指针tail每次插完更新tail指向新节点这样尾插也能做到O(1)。很多教材会把这个优化留给读者思考面试时主动说出来绝对是加分项。3.2 在指定位置插入先找到第i-1个节点这是一个被考过无数次的场景“在指定位置插入建立单链表”。假设i从1开始计数我们要在第i个位置插入新节点核心步骤是先找到第i-1个节点也就是新节点的直接前驱。为什么一定要找前驱因为新节点插入前只有前驱节点的next知道原来的第i个节点在哪里。只有拿到前驱才能让新节点先指向原第i个节点再让前驱指向新节点。int insertAt(LinkList L, int i, int val) { if (i 1) return 0; LNode *p L; int j 0; while (p ! NULL j i - 1) { p p-next; j; } if (p NULL) return 0; // 第i-1个节点不存在 LNode *newNode (LNode*)malloc(sizeof(LNode)); newNode-data val; newNode-next p-next; p-next newNode; return 1; }循环结束时j i-1p正好是第i-1个节点。如果p为NULL说明i已经超过了链表长度加1插入位置不合法。这里有个细节为什么循环条件不是j i而是j i-1因为p的初始位置是头结点头结点在逻辑上是第0个节点。要找第1个节点头结点走一步就到要找第i个节点的前驱就要从头结点走i-1步。坚持用“头结点是第0个节点”的视角来写代码所有边界判断都会变得清楚。3.3 删除节点核心也是找前驱删除第i个节点思路和插入高度对称。还是先找到第i-1个节点p用临时指针q指向待删节点然后把q从链表中摘掉int deleteAt(LinkList L, int i, int *val) { if (i 1) return 0; LNode *p L; int j 0; while (p ! NULL j i - 1) { p p-next; j; } if (p NULL || p-next NULL) return 0; LNode *q p-next; p-next q-next; *val q-data; free(q); return 1; }p-next NULL意味着第i个节点根本不存在删除失败。如果删除的是双链表节点逻辑会更对称你可以直接定位到要删除的节点q本身然后让q的前驱的next指向q的后继让q的后继的prev指向q的前驱不需要从头找前驱。这也是双链表在“删除”这件事上比单链表舒服的原因。在实际做实验时我建议你把插入和删除封装成独立函数参数统一设计成LinkList L, int i, int val这种形式。这样后续你写“单链表的基本操作实验”报告时主函数只需要调用这几个接口代码结构会清爽很多。4. 链表应用与经典算法题从原理到实战爆点4.1 反转链表三指针法是基本功反转链表是面试和考研出现频率最高的链表算法题。基本思路是用prev表示已经反转好的部分的最前节点cur表示当前待反转的节点nextTemp暂存cur的下一个节点防止cur指向prev后丢失原链表。LNode* reverseList(LNode *head) { LNode *prev NULL; LNode *cur head; while (cur ! NULL) { LNode *nextTemp cur-next; // 暂存后继 cur-next prev; // 指向前驱 prev cur; // prev前移 cur nextTemp; // cur前移 } return prev; }这个函数里head指向首元结点也就是第一个数据节点。做反转时所有节点的next方向都被倒过来了原来的首元结点变成了尾节点所以它最初必须指向NULL。循环结束时prev恰好停在原链表的最后一个节点也就是新链表的头节点所以直接返回prev。如果题目要求“逆置链表”而不允许申请新链表空间那么这三指针法是标准答案。它对空间的消耗是O(1)只增加了常数个指针变量。理解了这个写法再去看递归版本会轻松很多递归的本质只是在系统栈上隐式保存了每一层的“下一个节点”。4.2 判断循环链表和寻找中间节点快慢指针快慢指针是链表算法里的另一个高频套路。判断一个单链表是否有环就让慢指针每次走一步快指针每次走两步def has_cycle(head): slow fast head while fast and fast.next: slow slow.next fast fast.next.next if slow is fast: return True return False如果有环快指针最终一定会在环里把慢指针追上来如果无环快指针会先一步到达NULL。有人问快指针能不能一次走三步可以但终止条件的判断会变复杂面试里统一用两步走最稳妥。快慢指针还有一个经典应用找链表的中间节点。慢指针走一步快指针走两步快指针到达链表末尾时慢指针正好停在中间位置。这个技巧在处理“回文链表”的题目时经常被组合使用——先用快慢指针找中点再把后半段反转最后比较前后两半。4.3 复杂度分析和实际应用链表的真正主场链表常被拿来和数组做复杂度对比这张表值得你直接保存操作数组/顺序表单链表随机访问第i个元素O(1)O(n)头部插入/删除O(n)O(1)带头结点时尾部插入/删除O(1)约等于不考虑扩容O(1)维护尾指针/ O(n)遍历中间插入/删除O(n)O(n)定位 O(1)修改指针注意最后一行的写法链表修改指针本身是O(1)但通常需要先遍历定位到前驱定位是O(n)。如果你在面试里只说“链表插入是O(1)”很可能会被追问更严谨的回答是“修改指针是O(1)但通常需要先找到前驱位置整体是O(n)”。链表在真实系统里也随处可见。哈希表解决冲突时常用的链地址法本质上就是用链表串起哈希到同一个桶的记录LRU缓存淘汰算法里哈希表双链表的组合几乎是教科书级的实现Linux内核里的很多链表结构也是通过内嵌链表节点把任意结构体连成队列。理解了基础的单链表操作这些场景读起来都不会再觉得陌生。5. 排查、调试与复习带我踩过的坑和手把手建议5.1 链表程序崩掉的五大经典原因我观察过很多初学者写链表99%的报错都集中在下面五类操作了NULL指针循环里p已经走到NULL还用p-next或者在空表上直接执行删除。解决办法是操作前先检查指针是否为空。插入顺序写反先改前驱的next再设置新节点的next导致原链表后半段丢失。记住口诀先接新节点再接前驱。头指针被改丢有时候图省事直接对头指针赋值回头想再访问整个链表时发现起点没了。建议始终用临时指针p操作不要乱动头指针。忘记释放内存删除节点时只改了指针忘了free程序跑久了内存越占越多。C语言里每次malloc都必须有对应的free。把p和p-next混为一谈p是当前节点本身p-next是它指向的下一个节点二者指向的是完全不同的内存地址。画图时经常看到有同学把箭头画错就是这个原因。遇到问题先对照这个清单多半能直接定位。5.2 可视化调试画图比盯代码快十倍我在带学生实验时发现代码运行报错后很多人的第一反应是一行一行地盯着代码看越看越晕。其实链表是一个非常适合“画图”的数据结构用方框表示节点用箭头表示next然后按代码的执行顺序一步步画指针的变化。几乎所有链表Bug画完图之后自己就找到了原因。比如插入操作画的时候只需要关心几个指针新节点newNode、前驱p、原后继q。先画newNode-next q再画p-next newNode你就很容易看出两个步骤的顺序为什么不能颠倒。这个过程不需要什么高级工具一张草稿纸或者一块白板就够。另外写“数据结构实验报告”时这类画图思路同样重要。实验报告要求的不只是代码跑通还要能写清楚算法思路、核心代码、输入输出样例、复杂度分析。你把链表操作的“指针变化过程”用画图或表格呈现出来报告的说服力会高出一大截这也是平时最容易拿分的地方。5.3 期末和考研复习重点就那么几条如果你在准备期末考试或者408数据结构考研链表这一章的复习重点可以压缩成几句话概念要能讲清基础操作要能手写复杂度分析要能算明白。具体来说建立单链表头插法、尾插法、遍历、插入、删除、反转这五项必须做到不看书也能默写出来。教材方面严蔚敏的《数据结构》C语言版是很多人的首选李春葆的《数据结构》学习指导勘误汇总也有不少人在用。网上的PDF版本很容易搜到但我要提醒一句千万不要只看PDF不写代码。链表这个知识点动手实践比看书重要得多。电大系统的数据结构形考作业、山东大学软件学院的实训题很多都把“链表应用”作为独立编程题来考只要你亲手写过几遍这类题目基本就是送分题。最后说一点我自己的体会。链表是一个特别“诚实”的知识点你觉得你懂了一动手写代码是不是真的懂就暴露得干干净净。我见过不少同学前面排序、查找都挺熟练一到链表就卡住。原因基本不是智力问题而是没养成“先画图、再写码”的习惯。所以建议别贪多先把插入、删除、遍历、反转这四件事写到滚瓜烂熟再去看双链表、循环链表和更复杂的算法应用。等你真的能在脑子里准确说出每一步操作后哪些指针被修改、改成什么值那些看起来吓人的链表题十有八九只是这四件事的叠加。这个坎迈过去了后面学树和图会顺利得多。