
如果你写过一点 C 语言或者 Java大概率被数组的插入操作恶心过——在一个 10 万长度的数组中间塞一个元素后面的 10 万个元素全得往后挪一位。今天要聊的单链表就是专门收拾这种“数组插入慢”局面的方案。它不要求一整块连续内存插入删除都只改指针时间复杂度能做到 O(1)代价是失去了数组那种按下标直接访问的爽快。这篇内容适合刚学数据结构、正在被指针绕晕的初学者也适合期末复习想快速理清链表脉络的同学我会从数组的痛点开始讲一步步带你把单链表的定义、创建、插入、删除、销毁全部写明白最后附上我在调试链表时踩过的一堆坑。1. 数组插入慢慢在哪一步1.1 数组的先天设计连续内存 偏移寻址先把话说透数组并不慢它慢的是“在中间插入”这个操作。数组之所以能做到 O(1) 随机访问靠的是内存地址连续这个硬条件。你写arr[5]编译器实际干的事情是拿数组首地址加上5 * sizeof(元素类型)跳到一个确定的内存位置不需要从头往后数。这个设计在“读”的时候非常爽但在“写中间”的时候就遭殃了。假设你有一个长度为 n 的数组要在第 i 个位置插入一个新元素那么下标 i 以及 i 之后的所有元素都得整体往后挪一格腾出那个坑。挪动意味着内存拷贝拷贝意味着 O(n) 的时间开销。我举个具体例子一个存了 50 万条用户 ID 的 int 数组你往下标 0 插一个数后面 50 万个 int 全部要移动一次 move 是 4 字节50 万个就是 200 万字节的拷贝。哪怕内存带宽再快这个操作也谈不上“轻量”。更麻烦的是如果数组本身容量不够你还得先realloc找一块更大的连续内存再把旧数据整个搬过去成本直接翻倍。1.2 用生活场景理解“挪位置”的成本你可以把数组想象成一条店铺挨着店铺的商业街门牌号从 1 排到 1000。现在要在一家新店插到 500 号的位置唯一的办法是让 500 号以后的店铺全部搬到新地址重新挂牌。你想想这条街上的商户得有多崩溃。单链表的思路就完全不同了——它不要求大家坐在一起。每家店只需要记住“下一家店在哪”就能从街头一家一家找到街尾。你要在中间插一家新店只需要让前一家店改一下记忆本来是“下一家在 B”改成“下一家在新店”然后让新店记住“下一家在 B”。前后两家店的物理位置完全不用动这就是链表插入 O(1) 的本质。这个类比希望你记牢后面所有链表操作本质上都是“改指针的指向”而不是“搬数据”。2. 单链表的核心设计每个节点都是“数据 指向”2.1 节点结构数据域与指针域单链表的基本单位叫节点Node一个节点包含两块东西一块是真正存数据的叫数据域另一块是存“下一个节点在哪”的地址叫指针域通常命名为next。C 语言里定义节点结构体最简单的方式是typedef struct Node { int data; // 数据域先拿 int 练手 struct Node *next; // 指针域指向下一个节点 } Node;注意一个很关键的细节Node结构体里的next是指向自身结构体的指针不能写成Node *next直接引用刚定义好的别名。在 C 语言里typedef的别名要等结构体定义结束之后才生效所以必须写struct Node *next。这个错误我在刚开始写链表时反复犯编译器每次都给我报“未知类型名 Node”后来才彻底明白这里存在一个“先有鸡还是先有蛋”的边界。有了节点结构链表本身并不需要再建立一个复杂的容器结构只要一个头指针head它指向第一个节点。如果链表是空的head就指向NULL。只要知道头就能沿着next把整条链走完。2.2 为什么“下一站在哪”比“站在哪里”更重要数组用“位置”组织数据链表用“关系”组织数据这是两种完全不同的思维模式。数组的逻辑结构是线性的、连续的它隐含了一个信息arr[i]的后面就是arr[i1]。链表则把这条信息显式地写成一个指针藏在每个节点里。好处是节点之间不再需要物理相邻你可以在堆上随便找一块内存只要把地址填到前一个节点的next里这条链就接上了。代价也很直接你要访问第 100 个节点数组一步就能跳到链表必须从head开始沿next指针走 99 步。单链表天生不支持随机访问查找一个节点的时间复杂度是 O(n)。这不是缺陷这是设计取舍——用“只能顺序访问”换来了“插入删除不挪数据”。在决定用不用单链表之前先问自己一句话你的程序是“读多写少”还是“写多读少”读多选数组写多选链表。3. C 语言手写单链表完整实操与代码解读3.1 先搭骨架节点创建与链表打印学习链表的正确姿势不是看十遍书而是老老实实敲一遍代码。我建议你不要复制我的代码而是看着下面这个极简框架自己写一遍写完再对比。先写两个基础函数一个是创建节点一个是打印链表#include stdio.h #include stdlib.h typedef struct Node { int data; struct Node *next; } Node; // 创建一个新节点data 为节点数据 Node* createNode(int data) { Node *newNode (Node*)malloc(sizeof(Node)); if (newNode NULL) { printf(内存分配失败\n); exit(1); } newNode-data data; newNode-next NULL; return newNode; } // 遍历打印链表所有节点数据 void printList(Node *head) { Node *cur head; while (cur ! NULL) { printf(%d - , cur-data); cur cur-next; } printf(NULL\n); }createNode里有三个细节值得敲黑板。第一一定要malloc之后立刻检查返回值返回NULL就说明内存不够了直接exit或者做异常处理不要带着空指针继续玩。第二next必须初始化为NULL否则它就是一个野指针将来遍历时它会把你带向未知的内存区域轻则打印乱码重则段错误。第三我打印链表用的是while (cur ! NULL)而不是while (cur-next ! NULL)——区别在于前者能把最后一个节点的数据也打出来后者会漏掉尾节点。这两个条件写错是链表代码里最常见的逻辑 bug 之一。3.2 头插法新节点永远插在最前面头插法是最直观、也最容易理解的一种插入新节点插到链表头部成为新的第一个节点。核心操作只有两行Node* insertAtHead(Node *head, int data) { Node *newNode createNode(data); newNode-next head; // 新节点指向原来的头 head newNode; // 新节点成为新的头 return head; }这里必须强调一个问题为什么我要返回新的head而不是像下面这样写void insertAtHead(Node *head, int data) { ... }在 C 语言里函数参数是值传递。你在函数内部把head指向了newNode但这个修改只发生在函数内外面的head指针变量根本不知道。等函数返回外面的head还指着旧的头节点新节点就丢了。解决这个问题的常见方案有两种一是像我这样把新头return出来调用时写head insertAtHead(head, 5)二是传二级指针void insertAtHead(Node **head, int data) { Node *newNode createNode(data); newNode-next *head; *head newNode; }我当时学链表就是被这个“为什么插入之后链表的头没变”的问题卡了很久最后才理解值传递的本质。现在再回头看这两个方案没有谁更高明选一个你顺手的就好但一定要想清楚它到底在改哪个层级的东西。3.3 指定位置插入找到前驱是关键头插法太简单了实际业务里更多是按位置插入。要在链表的第pos个位置从 1 开始计数插入一个节点关键是找到“前驱节点”——也就是原来位于pos位置的节点之前那个节点。插入的本质就是让前驱的next指向新节点让新节点的next指向原本那个节点。代码如下// 在第 pos 个位置之前插入节点pos 从 1 开始 Node* insertAtPos(Node *head, int data, int pos) { if (pos 1) { printf(插入位置不合法\n); return head; } // 插在头部直接复用头插法 if (pos 1) { return insertAtHead(head, data); } Node *cur head; int count 1; // 找到第 pos-1 个节点也就是前驱 while (cur ! NULL count pos - 1) { cur cur-next; count; } // 前驱不存在说明位置已经超出链表长度 if (cur NULL) { printf(插入位置超出链表长度\n); return head; } Node *newNode createNode(data); newNode-next cur-next; // 新节点指向原位置节点 cur-next newNode; // 前驱指向新节点 return head; }这段代码里最精妙、也最容易被忽略的就是最后两步的顺序。一定要先执行newNode-next cur-next;再执行cur-next newNode;。如果顺序反过来先改cur-next那么原位置节点的地址就丢了后面的链表直接断掉内存泄漏都算轻的严重时程序直接崩溃。我在给初学者讲的时候喜欢画一个“两节点夹一个新节点”的图前驱 - 原节点新节点要插到中间所以先用newNode-next抓住原节点再让前驱放手去指新节点。指针操作的顺序原则就一句话先把新节点接到旧链上再断开旧链的连接。3.4 删除节点小心断链前先留后路删除节点比插入更容易出错因为你要free掉一个节点一旦顺序不对你可能会 free 掉一个还被别人引用的节点或者丢了后续节点的访问路径。假设要删除第一个target值的节点Node* deleteNode(Node *head, int target) { Node *cur head; Node *prev NULL; // 如果头节点就是目标换头 if (cur ! NULL cur-data target) { head cur-next; free(cur); return head; } // 否则找目标节点并记录它的前驱 while (cur ! NULL cur-data ! target) { prev cur; cur cur-next; } // 没找到 if (cur NULL) { printf(未找到目标节点 %d\n, target); return head; } // 前驱直接跨过目标节点指向目标的后继 prev-next cur-next; free(cur); // 释放目标节点内存 return head; }删除的核心是“跨链表绕过”prev-next cur-next;这一步相当于把目标节点从链上摘下来之后才允许free(cur)。摘下来之前绝对不能free否则cur-next读到的已经是来路不明的垃圾地址。还有一个小细节删除时如果cur是最后一个节点cur-next刚好是NULLprev-next NULL让前驱变成新的尾节点逻辑完全正确不需要额外分支处理。3.5 翻转链表经典但不简单的思维体操既然讲单链表翻转reverse必须有名字。这是个高频面试考点也是检验你有没有真正理解指针操作的试金石。思路不复杂用三个指针prev、cur、next逐个把cur-next翻转指向prev。Node* reverseList(Node *head) { Node *prev NULL; Node *cur head; Node *next NULL; while (cur ! NULL) { next cur-next; // 先保存后继防止断链 cur-next prev; // 把当前节点的指针指向前驱 prev cur; // prev 前移 cur next; // cur 前移 } return prev; // 新的头 }这个代码的“心脏”就一行cur-next prev;。但外围包裹的三步——保存next、移动prev、移动cur——一个都不能少。初学者最大的问题往往是改完cur-next之后发现找不到原来的下一个节点了。解决办法就是提前把next存下来。我记忆这段代码的口诀是“先保存后翻转一三五齐步走”。你在纸上画一条 5 个节点的链表跟着循环走三遍基本上就忘不掉了。3.6 整表销毁不释放内存就和没学过 C 一样链表使用完毕后必须把每一个节点都free掉这就是教科书里反复强调的“内存泄漏”问题。销毁有一个显而易见的坑不能先free(cur)再取cur cur-next因为cur已经被释放再去读它的next是未定义行为。正确的姿势是用一个临时变量先保存下一个节点再释放当前节点void freeList(Node *head) { Node *cur head; while (cur ! NULL) { Node *temp cur-next; // 先保存后继 free(cur); // 释放当前 cur temp; // 移到后继 } }如果你在之前删除节点的环节已经理解了“先留后路再 free”的原则这里就是同一套逻辑的复用。每次用free都要问自己一句我还能不能从这里的next走到下一站如果答案是不能说明你漏了一个临时保存的步骤。4. 单链表的复杂度分析与适用场景4.1 一张表看清数组和链表的时间复杂度很多同学学完链表会陷入一种错觉链表什么都很强数组该被淘汰了。事实远不是这样。我把两种结构在常见操作下的时间复杂度列一张表你一眼就能看出差别操作数组单链表按下标随机访问O(1)O(n)头部插入 / 删除O(n)需要搬移全部元素O(1)中部插入 / 删除已知位置O(n)搬移后续元素O(1)尾部插入O(1)扩容时需要 O(n)O(n)需要遍历到尾部查找指定值O(n)O(n)额外内存占用几乎无每个节点多一个 next 指针这张表里有几个信息量很大的点值得展开说。第一链表的“尾部插入”并不快。很多人以为链表插入都是 O(1)但如果你没有记录尾指针每次往结尾插都必须从头遍历到尾巴复杂度是 O(n)。所以实际开发里如果高频尾插需要额外维护一个tail指针或者直接改用双向链表。第二数组的“容量不足扩容”是隐藏成本。Java 的ArrayList、C 的vector在扩容时都要申请更大的内存并把旧数据全量拷贝这一下就是 O(n)。但均摊下来因为不是每次插入都触发扩容通常认为尾部插入均摊 O(1)。第三链表的 O(1) 插入有一个严格前提你得先找到插入位置。如果你只有“值”要先把节点找到那找的过程已经是 O(n) 了。链表真正的优势场景是“我已经持有某个节点的指针要在它旁边插入/删除”这个时候才谈得上 O(1) 改指针。4.2 为什么链表在真实世界里不一定“快”这是我想重点讲的个人体会。从大 O 复杂度看链表插入确实比数组漂亮但实际程序跑起来链表的综合性能往往没有想象中那么神。原因是现代 CPU 有缓存机制数组因为是连续内存加载一个元素时CPU 会把它附近的几十个字节一起装进高速缓存后续访问相邻元素时直接从缓存里取非常快。链表就不一样了每个节点是malloc单独分配的物理内存地址大概率分散在各处。你访问完第 1 个节点去访问第 2 个节点时CPU 缓存里大概率没有它不得不回内存去取这就是所谓的“缓存不友好”。链表节点越多这种缓存未命中的惩罚越明显。所以在真实项目里如果你的数据规模不大比如几千条数组和链表在性能上根本看不出差别如果数据规模很大且以读为主数组反而常常碾压链表。链表不是“更快”而是“更灵活”——它真正解决的是频繁插入删除时“不搬数据”的痛点而不是绝对的性能问题。4.3 什么时候该选单链表基于上面的分析我的建议是不要盲目上链表。以下这些场景用单链表是合适的频繁在头部或中部插入/删除元素而且插入位置你大概率知道或者可以通过游标维护数据总量不固定变化剧烈无法预估初始容量动态扩容成本高需要把一个顺序结构拆分成多个子结构比如按条件筛选一条长链表生成多条短链表用链表天然方便对随机访问的需求几乎为零。反过来如果你需要频繁按下标查找元素或者你的数据是只读的、一次性加载后不再变动请老老实实选数组或vector。数据结构没有绝对的好坏只有合不合适。这个理念比会背哪种结构的复杂度重要得多。5. 新手最容易踩的坑链表调试实录与速查表5.1 野指针与无效地址malloc 的“三不原则”链表代码的崩溃十次有八次是野指针惹的祸。我对新手的建议是给自己立三条规矩一malloc之后必须检查不合规就直接报错绝不往下走二是free之后必须立刻把指针置为NULL避免“悬空指针”被二次使用三是不确定一个指针是否有效时绝不拿它去访问成员。尤其是第三条。很多时候程序没有立刻崩溃而是运行到某个随机时刻突然段错误多半就是某处写代码时偷偷使用了一个已经被释放的指针。这种情况下调试器很难定位问题因为出错的位置和释放的位置往往隔得很远。我自己的排查经验是先用-fsanitizeaddress重新编译一次程序这个工具能帮你抓到“释放后使用”“越界访问”这类内存问题输出会直接告诉你出错在哪个源文件的哪一行比人眼盯代码高效太多。5.2 修改头指针却没有同步出去这是一个极其隐蔽的逻辑 bug。很多初学者写完insertAtHead在函数里把head指向了新节点回到main函数后打印链表发现没变化或者丢了一部分数据。原因我已经在前面讲过C 语言值传递函数里改的是形参不会影响实参。记住一句话凡是可能修改链表头的操作函数要么返回新的头指针要么参数传二级指针。每次都问自己“我改的是 head 的值还是 head 指向的内容”想清楚再动手。5.3 遍历条件写错导致漏数据或死循环两种最容易出的毛病一是while (cur-next ! NULL)写完少了最后那个节点二是while (cur ! NULL)写成while (cur-next ! NULL)导致循环少一次。如果你在循环体里写了cur cur-next;那么条件用cur ! NULL就不会漏如果你用cur-next ! NULL循环体内通常要配合cur cur-next-next;之类跳两步的操作这种写法更容易乱。我的习惯是遍历一律用while (cur ! NULL)直到cur为NULL表示走到结尾这样最直观不容易出边界问题。5.4 常见问题速查表我把平时群里问得最多的链表问题汇总成一张表遇到问题先对着查一遍现象可能原因排查方向打印链表时程序崩溃访问了野指针或链表头已变成无效地址检查所有malloc返回值检查是否误改了head打印结果缺少最后一个节点遍历条件用了cur-next ! NULL改用cur ! NULL头插后链表还是老样子值传递导致头部修改未生效改返回新头或使用二级指针插入后链表断成两截newNode-next赋值顺序错误旧连接被提前覆盖先接新指针再断旧连接删除后程序崩溃释放节点前没有保存next或释放了仍在使用的节点确认prev-next已指好再free内存越用越多程序变慢节点删除后没有free或整表销毁遗漏部分节点检查每个删除分支是否都释放了内存链表反转后只有第一个节点循环里next没保存断链后无法继续按 three-pointer 思路重写5.5 调试链表的两个土办法工欲善其事必先利其器。除了用 GDB 这种重型工具我自己调试链表时最依赖两个土办法在命令行里效率极高。第一个办法打印大法。在关键操作前后都加printList(head)把链表状态打出来。头插后打一次插入中节点后打一次删除后打一次。别看它原始链表是线性结构打印一次就能看出整个链条是否完整数据是否错位往往一眼就能定位问题。第二个办法画图。很多逻辑错误不是代码语法问题而是思维里的链没画顺。我在白板上画的典型套路是三个方块排成一行每个方块右边引出一个箭头指向下一个方块。插入时先画新节点的箭头指向旧节点再画前驱的箭头指向新节点箭头改完再动代码。链表这东西代码是箭头的文字化表达脑子里的图清晰了代码就不可能乱。6. 单链表还能往哪走后续扩展的方向6.1 头节点哨兵节点的设计实战项目里单链表通常带一个“头节点”或叫哨兵节点它不存实际数据只用它的next指向真正的第一个数据节点。这样设计的好处是无论链表是否为空head都指向一个有效节点插入和删除的逻辑可以统一处理不需要单独判断“是不是空表”“是不是删除头节点”这种边界情况。代价也很小多一个节点的内存而已。我个人的建议是刷题练手时可以不带头节点逻辑更底层能帮你理解指针的本质但写项目代码时强烈建议带头节点能省掉大量边界判断的精力。6.2 从单链表到双向链表单链表只能从头走到尾想找前驱节点必须重新遍历这个限制在不少场景里很致命。双向链表在每个节点上多了一个prev指针指向前一个节点代价是每个节点多占用一个指针的内存插入删除时要维护两个方向的指针代码复杂度明显上升。如果你将来写 LRU 缓存或者需要频繁“向前找”的场景双向链表基本是标配。我现在给朋友讲数据结构时喜欢把单链表比作“单向街道”双向链表是“双向街道”循环链表是“环形立交”三者各有各的适用场合。没有谁替代谁全是按需求来选型。6.3 链表的排序与经典应用链表虽然不方便随机访问但排序并不吃亏。归并排序天然适合链表因为归并过程只需要按顺序遍历和改指针不需要额外的数组存储空间空间复杂度能控制在 O(1)。很多大厂面试题里“对链表进行排序”标准答案基本都是基于归并排序改造的。另外链表的变体在系统底层无处不在操作系统的进程调度队列、文件系统的空闲块管理、浏览器历史记录的 undo/redo 栈、音乐播放器的播放列表……这些场景的共同点都是“频繁插入删除、长度动态变化、顺序访问为主”。你如果能把单链表学扎实再去看这些系统设计会有一种豁然开朗的感觉。我个人从学数据结构到现在最深的体会有两句话。第一句是写链表代码永远先画出节点关系图再动手脑中的链顺了代码自然顺。第二句是别迷信任何结构的“万能论”数组有数组的强链表有链表的好遇到具体问题把复杂度摆出来算一笔账答案自己会浮出来。希望这篇零基础入门的单链表文章能帮你迈过这道坎数据结构这一关一旦跨过去后面学树和图就会轻松太多。