news 2026/10/20 23:28:22

链表增删查改全解析:从指针操作到工程实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
链表增删查改全解析:从指针操作到工程实战

很多人刚接触数据结构时,第一个真正需要“手动操作内存”的难题就是链表的增删查改。数组好歹有连续下标可以顺手拿,链表却要用指针一个节点一个节点地跳,很多初学者在这里第一次体会到什么叫“程序跑崩了但不知道崩在哪”。

但链表绝对不是面试题里才会出现的古董。缓存淘汰、内存池、文件系统块管理、操作系统的任务队列,底层都离不开这种结构。它的增删查改,本质上就是“重新穿针引线”的过程:让前一个节点改指目标,让目标节点带好新邻居,所有操作都不涉及物理搬移,只是调整引用关系。这篇内容我会把链表的每个操作都拆开讲清楚,包括代码怎么写、边界怎么处理、哪些坑最容易踩,以及工程上你会真正遇到什么问题。

1. 链表的基本盘:先搞清楚你操作的是什么东西

1.1 节点是什么,head指针是怎么回事

要用好链表,第一步不是背代码,而是理解它的存储模型。链表之所以叫“链”,是因为它靠指针把一个个零散的内存节点串起来。每个节点包含两部分:数据域存放实际内容,指针域指向下一个节点。C/C++的表述就是结构体里带一个自身类型的指针,Java/Python则是类里放一个引用字段。

“head”这个词很多人知道是头节点,但没理解它的真实含义:head 不是一个节点,而是一个指针变量,它存的是第一个节点的内存地址。当头指针指向空(nullptr/NULL)时,说明链表为空。这里有个关键认知——操作链表时,如果操作会改变头指针本身的值,必须传二级指针(或引用);如果只是修改节点内部的指针域,一级指针就够了。很多人后续写代码出错,90%都是在这个地方绕晕了。

我在带新人看代码时常用一个比喻:head 相当于队伍的队首名牌,它不参与排队,但它告诉所有人“第一棒是谁”。假如你要把新的第一个人插到最前面,队首名牌就必须改写,否则队伍就断了。这个比喻对理解头插法和二级指针特别有帮助。

1.2 增删查改的共性思维:先找前驱,再动指针

数组增删要搬动后续元素,因为存储连续;链表增删只需要修指针,因为存储分散。但修指针有一个铁律:先处理“新关系”,再断开“旧关系”。新手最容易犯的错误是,一上来就释放旧节点或者改动原链表的前驱指针,结果后面的节点找不到了。

拿插入操作举例,在节点 prev 后面插入新节点 newNode 的标准顺序是:

newNode->next = prev->next; // 先把 newNode 的 next 指向 prev 原来的下一个 prev->next = newNode; // 再把 prev 的 next 改成 newNode

这两行顺序不能反。如果先执行 prev->next = newNode,那 prev 原本的下一个节点就“丢失”了,newNode 也接不到它。这在单链表里是致命的。所以我把这套逻辑总结为一句话:“先挂新绳,再剪旧绳”。这个原则在删除、反转、排序等几乎所有链表操作里都适用。

2. 链表的增删查改,核心实现逐个拆解

2.1 插入操作:头插、尾插、指定位置插

链表的插入按位置分三种:头部插入、尾部插入、指定节点插入。三者难度递增,但核心逻辑是一致的。

头插法是最简单的,因为它不需要遍历。

Node* insertAtHead(Node* head, int val) { Node* newNode = createNode(val); newNode->next = head; return newNode; // 返回新头指针,调用方必须接收返回值 }

这里有个容易忽略的点:由于头插改变了 head 本身,所以函数必须返回新 head 给调用方。很多人在 main 里写insertAtHead(head, 5);然后打印链表发现啥也没变,就是因为没有接收返回值。正确的调用方式是head = insertAtHead(head, 5);。

尾插法要先找到最后一个节点,然后把新节点接上去。

Node* insertAtTail(Node* head, int val) { Node* newNode = createNode(val); if (head == NULL) { // 空链表时,新节点即头节点 return newNode; } Node* cur = head; while (cur->next != NULL) { cur = cur->next; } cur->next = newNode; return head; // 尾部插入不改变头指针 }

查找最后一个节点的终止条件一定是cur->next != NULL,而不是cur != NULL。如果你写成后者,循环会走到 NULL 上,之前那个节点是谁就丢了——这又是一处新手高频错误。

指定位置插入,比如在第 k 个节点后面插入一个节点,核心是“先遍历到第 k 个节点,然后执行插入逻辑”。这时你需要处理 k 越界、k=0(即头插)等情形。位置插入面世时常常被拿来考“链表特性”:为什么插入是 O(1)?因为插入动作本身是 O(1),但找到插入位置要遍历,所以整体是 O(n)。

2.2 删除操作:单个删除与批量删除

删除节点比插入更危险,因为涉及释放内存。单链表删除的核心也一样是“找前驱”,唯一换了个思路的是:如果允许“狸猫换太子”,我们可以不找前驱,而是把后继节点的值复制到当前节点,然后删除后继。

先看常规删除指定节点值:

Node* deleteNode(Node* head, int val) { if (head == NULL) return NULL; if (head->val == val) { // 删除的是头节点,头指针要变 Node* tmp = head; head = head->next; free(tmp); return head; } Node* cur = head; while (cur->next != NULL && cur->next->val != val) { cur = cur->next; // 找前驱,而不是找目标本身 } if (cur->next != NULL) { Node* tmp = cur->next; cur->next = tmp->next; free(tmp); } return head; }

注意查找条件是cur->next != NULL && cur->next->val != val,目的是让 cur 停在目标节点的前驱上。这个“找前驱而不是找目标”的思路,是链表删除与数组删除最大的思维差异。

那么“狸猫换太子”的做法是这样的:我们不删除目标任务本身,而是把它的后继值拷过来,再删后继。这样不需要前驱指针,对“只知道当前节点指针”的场景(比如要求 O(1) 删除某个给定节点)尤其有用。不过有个前提:被删除的节点不能是尾节点,因为没有后继可供替换。这个技巧面试经常考,但在生产代码里我不建议频繁使用,因为它会覆盖数据,如果节点带指针域或者外边有引用指向这个节点,容易引发更隐蔽的问题。

批量删除则没有特殊技巧,就是一个循环遍历,把符合条件的所有节点删掉。注意删除完当前节点后,不要急着 cur = cur->next,因为删完后 cur 不变,cur->next 已经变了,直接继续循环即可;只有没删除时才向前走。这个微妙的节奏感很多人写不对,原因就是删完节点后把 cur 也往后挪了,导致跳过检查下一个节点。

2.3 查找操作:按值查找与按位置访问

查找相对温和,它不改结构,只做遍历。按值查找返回第一个匹配节点的指针,找不到返回 NULL:

Node* findByVal(Node* head, int val) { Node* cur = head; while (cur != NULL) { if (cur->val == val) return cur; cur = cur->next; } return NULL; }

按位置访问,就是类似数组下标list[k]的操作。这里要特别注意计数从 0 开始还是从 1 开始,不同项目习惯不同,最好在函数注释里写清楚。而且同样要防越界,k 超出范围时返回 NULL 或抛出异常,而不是越界访问内存。

2.4 修改操作:改 value 还是改结构

修改数据域是最简单的,找到节点后直接赋值即可。难得是“修改引用结构”,比如链表反转、相邻节点交换、重排奇数位节点等。这些操作本质上都是“改指针指向”,比改数据需要更谨慎的指针暂存。

以反转链表为例,代码不长,但思维密度很高:

Node* reverseList(Node* head) { Node* prev = NULL; Node* cur = head; while (cur != NULL) { Node* next = cur->next; // 先暂存后继,防止断链后丢失 cur->next = prev; // 当前节点指向前驱 prev = cur; // 前驱更新为当前 cur = next; // 当前节点移动到原后继 } return prev; // 循环结束时 prev 就是新头 }

这个操作里最关键的就是Node* next = cur->next;这行。如果不先暂存后继,等 cur 的 next 被改成 prev 之后,原本的后继节点就找不到了。很多人面试写反转链表卡住,核心就是没有建立“先暂存再改指针”的习惯。

3. 实战:手写一个完整的链表操作 Demo

3.1 准备一个可运行的最小框架

代码光看没用,我建议你亲手把所有操作串起来跑一遍。下面这个 Demo 我把上面讲的增删查改都整合在一起,可以直接编译运行。我用的是 C,因为 C 对指针和内存的管理最直观,学懂了 C 版,再去看 Java/Python 版会非常轻松。

#include <stdio.h> #include <stdlib.h> typedef struct Node { int val; struct Node* next; } Node; Node* createNode(int val) { Node* node = (Node*)malloc(sizeof(Node)); if (node == NULL) { printf("内存分配失败\n"); exit(1); } node->val = val; node->next = NULL; return node; } void printList(Node* head) { if (head == NULL) { printf("链表为空\n"); return; } Node* cur = head; while (cur != NULL) { printf("%d -> ", cur->val); cur = cur->next; } printf("NULL\n"); } Node* insertAtHead(Node* head, int val) { Node* newNode = createNode(val); newNode->next = head; return newNode; } Node* insertAtTail(Node* head, int val) { Node* newNode = createNode(val); if (head == NULL) { return newNode; } Node* cur = head; while (cur->next != NULL) { cur = cur->next; } cur->next = newNode; return head; } Node* insertAfter(Node* prev, int val) { if (prev == NULL) return NULL; Node* newNode = createNode(val); newNode->next = prev->next; prev->next = newNode; return newNode; } Node* deleteNode(Node* head, int val) { if (head == NULL) return NULL; if (head->val == val) { Node* tmp = head; head = head->next; free(tmp); return head; } Node* cur = head; while (cur->next != NULL && cur->next->val != val) { cur = cur->next; } if (cur->next != NULL) { Node* tmp = cur->next; cur->next = tmp->next; free(tmp); } return head; } Node* findByVal(Node* head, int val) { Node* cur = head; while (cur != NULL) { if (cur->val == val) return cur; cur = cur->next; } return NULL; } void freeList(Node* head) { Node* cur = head; while (cur != NULL) { Node* tmp = cur; cur = cur->next; free(tmp); } } int main() { Node* head = NULL; head = insertAtHead(head, 10); head = insertAtHead(head, 20); head = insertAtTail(head, 30); printf("当前链表: "); printList(head); // 20 -> 10 -> 30 -> NULL Node* target = findByVal(head, 10); if (target != NULL) { insertAfter(target, 99); } printf("在10后面插入99: "); printList(head); // 20 -> 10 -> 99 -> 30 -> NULL head = deleteNode(head, 20); printf("删除20: "); printList(head); // 10 -> 99 -> 30 -> NULL freeList(head); head = NULL; return 0; }

这段代码不追求炫技,就是最直白的教学实现。注意freeList、createNode这些收尾工作,很多人学链表只关心增删查改,忽略了内存释放,结果 valgrind 跑一遍全是泄漏报告。养成随手释放的习惯,才是真正的工程素养。

3.2 跑一遍验证:把推导和结果对上

编译运行上面的代码,输出应该依次是:

当前链表: 20 -> 10 -> 30 -> NULL 在10后面插入99: 20 -> 10 -> 99 -> 30 -> NULL 删除20: 10 -> 99 -> 30 -> NULL

我建议你不要只抄代码,而是拿着纸笔把每个操作画一遍。比如head = insertAtHead(head, 20)时,你的脑子里应该出现“新节点20的 next 指向原来的头10,然后 head 名牌换到20”。这个画图的过程是把链表知识内化的最短路径。我见过太多人代码敲得飞起,但被问到“删除一个节点需要改几条指针”时答不上来——那说明理解还是浮在表面。

3.3 搭配尾指针优化:别让尾插每次都 O(n)

前面写的尾插每次都要遍历到结尾,复杂度 O(n)。如果你频繁执行尾插,可以引入 tail 指针专门指向最后一个节点,这样尾插就变成 O(1) 了。代价是每次头部插入或删除头节点时,需要检查是否要更新 tail,维护成本会上升。

这段代码我省略了,但思路值得你想想:头插时如果链表为空,tail 需要指向新节点;删除最后一个节点后,tail 需要重新定位或者置空;删除中间节点时,如果删的是 tail 指向的节点,tail 要回退到前驱。这些边界条件很容易漏,所以引入 tail 不是白送的,它是对“逻辑完备性”的一次考验。工程上很多双向队列就是这么实现的。

4. 常见问题与排查技巧实录

4.1 头指针被修改了但没人接收返回值

这个我再强调一次,因为它是链表新手第一大坑。在 C 里,函数参数是值传递,你在函数内部对 head 指针重新赋值,不会影响外面的 head 变量。几乎所有头插、删除头节点的函数都需要返回新的 head,或者传二级指针。

两种做法我都可以接受,但一定要统一。如果函数原样返回 head(不论改没改),那调用方一律写成head = func(head, ...);如果传的是Node** head,那函数内部要写*head = newNode这种形式。最怕的就是同一个项目里两种混着用,看得人精神分裂。

4.2 空指针解引用:忘了判断 head 为 NULL

空链表调用查找、删除,或者链表只剩一个节点时删除该节点,都是空指针问题高发区。我在工程代码里见过最典型的错误是:

while (cur->next != NULL) { // 如果 cur 本身是 NULL,这里就崩了 }

要是 cur 已经是 NULL,访问 cur->next 直接段错误。正确做法是先判断cur != NULL再判断cur->next != NULL。C 语言的 && 是从左往右计算的,所以while (cur != NULL && cur->next != NULL)是安全的,短路求值会保护你。

4.3 内存泄漏与重复释放

C 里用 malloc 创建节点,删除时必须 free,否则就是泄漏。但 free 之后还有一个更隐蔽的坑:悬空指针。如果 free 之后没有把指针置为 NULL,后续代码若再访问它,就是未定义行为。典型的连续删除多个节点的错误是,释放了当前节点后还继续使用它的 next 字段。

批量删除时建议的做法是:先保存Node* nextNode = cur->next;,再 free(cur),最后cur = nextNode;。这一步看起来多此一举,却是把代码从“侥幸能跑”变成“稳定可靠”的关键差异。

4.4 循环链表与双向链表的边界混乱

如果你后续学习双向链表,会发现删除节点时不需要找前驱,因为每个节点都有 prev 指针。但这带来了新的麻烦:更新指针时要同时维护两个方向的引用。很多人删除双向链表节点时只改了 next,没改 prev,导致链表分裂成两段看起来又能串起来的怪胎。

我建议初学者先熟练掌握单链表再上双向链表,不要两头同时学。一个重要的验证技巧是:写完一个操作后,分别沿 next 遍历一遍,再从 tail 沿 prev 反向遍历一遍,两次结果应该互相印证。如果两个方向对不上,说明某个 prev 或 next 没有维护好。

5. 链表在更多场景里的变形与选择建议

5.1 双向链表、循环链表、带头节点链表怎么取舍

双向链表牺牲一个指针空间,换来逆向遍历和 O(1) 删除指定节点。循环链表则让尾部能直接回到头部,适合轮询调度类场景。带头节点(dummy node)的做法是固定一个哨兵节点作为链表的起点,它不存实际数据,好处是——头节点永远是 dummy,插入和删除不需要特殊处理头指针,代码逻辑可以统一。

我个人非常推荐在实际项目里用带头节点的方式,尤其是写底层库时。它能省掉大量“如果是空链表还要单独处理”的分支,让代码更短、更不容易出边界错误。很多开源项目的链表实现都是带头节点的,不是没有原因的。

5.2 链表的性能到底行不行:别只会说 O(1)

在很多技术交流里,新人喜欢背“链表插入 O(1),数组插入 O(n)”,这其实只对了一半。链表插入 O(1) 的前提是你已经拿到了插入位置的指针;如果要从头找位置,那还是 O(n)。再说真实场景里,内存访问是有局部性的,数组的连续内存天然对 CPU 缓存友好,链表节点分散存储容易 cache miss,在小数据量下链表反而不一定比数组快。

所以在选型时,我的建议是:大部分需要随机访问的场景,直接选动态数组;需要频繁在中间插入删除,并且数据量较大时,考虑链表;如果只需要队尾队首操作,考虑 deque。数据结构没有银弹,只有“适合当前读写模式的方案”。把这个认知写进你的基本功,比背一百个 API 都有用。

5.3 从链表看数据结构的通用学习方法

很多初学者会在学到链表时止步,因为指针、内存、边界条件、逻辑顺序,哪一样出错都会爆。但我想说,链表恰恰是数据结构里最有“复利”的一章,因为后面树、图、跳表、红黑树,全是节点+引用的组合游戏。搞懂了链表,你就搞懂了“引用关系”的本质。

我在带新人时建议的方法是:不要先写代码。先在纸上画一个链表,假设要删除第三个节点,用手把纸上的箭头改掉,同时考虑“会不会断链”“谁还指着被删节点”。这个过程重复十遍,再上机写代码,你会发现自己突然就会了。这也解释了为什么我一直在强调“画图”——写代码的手指是大脑的延伸,但大脑里必须有一幅清晰的图。

写在最后

链表的增删查改,说难也难,说简单也简单。难的是它要求你在头脑里维护一个动态的内存结构,每一步改指针都要预判会发生什么;简单的是它本质只有四个动作:“生成节点”“改指针”“遍历节点”“释放节点”,所有复杂操作都是这四个动作的排列组合。我自己刚学链表时,大概花了一个星期才真正把指针和节点的关系理顺,中间经历了无数次段错误和内存泄漏,所以如果你是正处于这个阶段的读者,大可不必着急。

最后再分享一个小技巧:调试链表代码,写一个printList函数,每执行完一步操作就调用一次。很多人觉得这个不够高级,但这比 debugger 单步跟踪还直观,能让你快速定位“哪一步改错了指针”。等链表写顺手了,再学习更复杂的数据结构,你会觉得一切都很自然——因为节点的世界里,最重要的永远是“谁指向谁”。

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

ASP.NET值班管理系统:排班规则建模与EF Core领域设计

简介&#xff1a;这是一套基于ASP.NET Web Forms开发的值班管理信息系统&#xff0c;面向初学者与中小型单位信息化建设者&#xff0c;解决排班登记、人员调度、值班记录查询等日常行政管理痛点。系统采用B/S架构&#xff0c;适配Windows Server 2003VS2010开发环境&#xff0c…

作者头像 李华
网站建设 2026/10/20 23:25:24

域环境下的信息收集:从活动目录到组策略的实操指南

1. 域环境里的"信息收集"到底在收集什么&#xff1a;先讲清楚概念很多刚接触内网安全的朋友第一次听到"域环境下的信息收集"这个说法&#xff0c;第一反应是"拿工具扫一扫"。实际上&#xff0c;在活动目录的环境里做信息摸排&#xff0c;和我们平…

作者头像 李华
网站建设 2026/10/20 23:25:14

LangGraph实战:从链式到图编排,构建可控的大模型工作流

1. 从“链”到“图”&#xff1a;为什么工作流编排是大模型应用的分水岭1.1 线性链的局限&#xff1a;当业务逻辑开始分叉在LangGraph出现之前&#xff0c;多数基于大模型的应用都是“链式”结构&#xff1a;先把用户问题拼成提示词&#xff0c;丢给模型&#xff0c;拿回结果&a…

作者头像 李华
网站建设 2026/10/20 23:25:14

配电网改进灵敏度分析:从IEEE33节点到无功补偿选点实践

1. IEEE33测试系统为什么是灵敏度分析的标配平台 做配电网研究的人&#xff0c;几乎绕不开IEEE 33节点系统。这个拓扑结构最早源于某地区的标准配电网络&#xff0c;经过行业多年整理后&#xff0c;成为电力系统领域最常用的测试算例之一&#xff1a;33个节点、32条支路&#x…

作者头像 李华
网站建设 2026/10/20 23:24:00

华为OLT版本拆包工具v1r19c12:从版本包结构到固件提取实战

简介&#xff1a;华为OLT版本拆包工具v1r19c12_packet_tool是一款面向电信网络运维人员的专用程序&#xff0c;用于解决OLT设备软件版本解压、校验与升级规划中的复杂操作。该工具可拆解大型版本文件&#xff0c;辅助备份恢复、安全补丁更新和批量升级&#xff0c;适合具备一定…

作者头像 李华
网站建设 2026/10/20 23:23:12

AI记忆劫持与防护:构建可打开、可修改的透明记忆层

一句 “update” 就劫持了 AI 的记忆&#xff0c;所以记忆必须能打开改如果你正在用任何带记忆功能的大模型助手&#xff0c;我建议你亲自做一个实验&#xff1a;先告诉它“记住我晚上11点睡”&#xff0c;等它确认写入后&#xff0c;再补一句“update&#xff1a;改成凌晨3点睡…

作者头像 李华