资讯动态

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

发布时间:2026/10/20 23:28:26 来源:尧图企业网站定制
很多人刚接触数据结构时第一个真正需要“手动操作内存”的难题就是链表的增删查改。数组好歹有连续下标可以顺手拿链表却要用指针一个节点一个节点地跳很多初学者在这里第一次体会到什么叫“程序跑崩了但不知道崩在哪”。但链表绝对不是面试题里才会出现的古董。缓存淘汰、内存池、文件系统块管理、操作系统的任务队列底层都离不开这种结构。它的增删查改本质上就是“重新穿针引线”的过程让前一个节点改指目标让目标节点带好新邻居所有操作都不涉及物理搬移只是调整引用关系。这篇内容我会把链表的每个操作都拆开讲清楚包括代码怎么写、边界怎么处理、哪些坑最容易踩以及工程上你会真正遇到什么问题。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 越界、k0即头插等情形。位置插入面世时常常被拿来考“链表特性”为什么插入是 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 查找操作按值查找与按位置访问查找相对温和它不改结构只做遍历。按值查找返回第一个匹配节点的指针找不到返回 NULLNode* 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. 实战手写一个完整的链表操作 Demo3.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 单步跟踪还直观能让你快速定位“哪一步改错了指针”。等链表写顺手了再学习更复杂的数据结构你会觉得一切都很自然——因为节点的世界里最重要的永远是“谁指向谁”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑