
单链表这个知识点在数据结构初阶里算是一道分水岭。学顺序表的时候数组下标、连续存储这些概念都靠物理结构撑着理解起来不费劲到了单链表数据不再连续存放节点之间靠一个 next 指针串起来很多人第一次面对这类“指针操作”会有点发蒙。这篇笔记是我整理的单链表实现归纳包含结构体设计、初始化、增删查改、完整代码和调试经验适合刚学完顺序表、准备啃链表的人。我会把几个容易绕晕的点——头节点和头指针、插入删除的指针顺序、二级指针什么时候用——都用尽量直白的话说清楚。1. 为什么先把单链表放在这个位置设计思路拆解1.1 从顺序表到链表到底改变了什么顺序表的核心是“数组”元素在内存里连续存放通过下标可以直接找到第 i 个元素这是它随机访问的优势。但一旦涉及插入和删除问题就来了在数组中间插一个数后面的元素全部要往后挪删一个数后面的全部要往前挪。最坏情况下每次都要移动 n 个元素时间复杂度是 O(n)。单链表换了一个思路不追求数据在内存里的“物理连续”只追求“逻辑连续”。数据分散在内存各处每个节点里额外存一个指针指向下一个节点的位置。这样一来插入和删除不需要移动其他元素只要把相邻节点的指针重新接一下就行。代价也很直观——为了找到某个位置的节点必须从头开始顺着指针一个一个走随机访问的能力没有了查找变成了 O(n)。这个“用空间换时间、用时间换空间”的权衡会贯穿整个数据结构课程。搞清楚顺序表和单链表的差异后面理解哈希表、树、图的时候很多取舍逻辑都会轻松很多。1.2 单链表的最小表示节点单链表的基本单元叫“节点”。一个节点通常分成两部分数据域存放实际数据指针域存放下一个节点的地址。用文字表示就是head - [data | next] - [data | next] - NULL最后一个节点的 next 指向 NULL表示“后面没有了”。整条链的入口叫头指针它本身不一定是节点就是一个指针变量存着第一个节点的地址。这里有个关键点只有知道头指针才能顺着 next 指针把整条链走完。一旦头指针丢了整条链表就找不到入口了所有节点都会变成内存里的孤儿。所以写代码时头指针的位置非常敏感任何可能修改头指针的操作都要格外小心。1.3 头节点、头指针和首元节点先把概念掰开很多初学者一开始会被这三个词绕晕。简单区分一下头指针指向链表第一个节点的指针变量。它是链表存在的“门牌号”。头节点在第一个有效数据节点之前额外附加的一个节点数据域不存有效数据next 指向真正的首元节点。它不是必须的但加了会让操作统一很多。首元节点第一个存放有效数据的节点。我后面的实现统一使用带头节点的单链表。为什么带头节点因为带头节点之后即使是空链表也有一个实实在在的节点存在head-next 是 NULL。这样在插入、删除时不需要单独区分“是不是第一个节点”——所有插入位置都在某个节点的后面所有删除操作都要找到前驱节点头节点就是一个天然的哨兵。不带头节点的写法在逻辑上也不是不行但处理空链表和删除第一个节点时必须用二级指针或者返回新头指针对初学者来说更容易翻车。既然是初阶笔记我建议先踏实掌握带头节点的写法理解透了再回去对比不带头节点的差异。2. 结构体定义与初始化先把地基打稳2.1 用 typedef 定义数据域类型方便后面换模板在 C 语言里实现单链表第一步是把节点类型定义出来。我习惯先给数据域起一个别名typedef int ElemType;这样写的好处是后面想改存 float、char 或者自定义结构体只需要改这一行。数据域类型一变所有函数的参数和返回值也跟着变维护成本低很多。虽然初阶阶段都在存 int但养成这个习惯不亏。节点结构体定义如下typedef struct Node { ElemType data; struct Node* next; } Node;这里有一个很经典的疑惑结构体里面为什么不能写Node* next而要写struct Node* next因为在定义结构体时typedef还没有生效Node这个名字还不存在。C 语言里必须用完整的struct Node来声明指针。等整个typedef语句结束后后面才可以直接写Node *。如果用的是 C编译器会宽松一些但初学阶段建议还是严格按 C 规则写清楚。2.2 初始化链表new 出头节点并置空带头节点的链表初始化就是申请一个头节点让它的 next 指向 NULL#include stdio.h #include stdlib.h Node* initList() { Node* head (Node*)malloc(sizeof(Node)); if (head NULL) { printf(内存分配失败\n); exit(1); } head-next NULL; return head; }几个容易忽略的点malloc的参数是sizeof(Node)不是sizeof(Node*)。Node*只是指针大小通常 8 字节Node才是节点结构体本身。写错的话申请的内存不够用后面访问 data 或 next 就可能导致越界。malloc的返回值需要转成Node*。C 语言允许 void* 隐式转换所以不写(Node*)也能编译过但写了更明确C 则必须强转。head-next一定要置 NULL否则这个头节点的 next 是野指针后面遍历时会发生未知行为。初始化到这里只是把手里的“地基”打好头节点本身没有有效数据它存在的意义是作为链表的起点和一个统一的“前驱哨兵”。2.3 销毁链表自己申请的内存自己还之前用malloc申请过多少个节点释放的时候就一个都不能少。销毁链表不能用一句free(head)带过因为头节点后面可能挂了一长串节点。正确写法是“先保存下一个再释放当前”void destroyList(Node* head) { if (head NULL) { return; } Node* cur head; while (cur ! NULL) { Node* next cur-next; free(cur); cur next; } }这里最关键的就是在free(cur)之前先把cur-next存到next变量里。很多人第一次写会写成while (cur ! NULL) { free(cur); cur cur-next; // 错free(cur) 之后 cur 指向的内存已经失效了 }free(cur)之后再去取cur-next属于访问已经归还给系统的内存行为未定义大概率会崩溃。虽然有些环境下碰巧还能读出值但这绝对是运气不是技巧。我还习惯在销毁函数最后把head置 NULL但那只是函数内的形参置不置影响不大。真正要在外部把头指针置空得靠二级指针或者返回新值这个我们在后面的排查部分专门讲。3. 增删查改核心操作逐个拆3.1 打印链表理解遍历的结束条件打印链表是检验初始化最简单的手段。从首元节点开始一个一个往后走直到 NULLvoid printList(Node* head) { if (head NULL) { return; } Node* cur head-next; while (cur ! NULL) { printf(%d - , cur-data); cur cur-next; } printf(NULL\n); }这里有两个细节值得停下来看一看。第一个是cur head-next而不是cur head。因为头节点不存有效数据如果从头节点开始打印会把头节点那个没初始化的 data 也打印出来。虽然我们通常不往头节点的 data 里放东西但打印出来毫无意义还容易造成误导。干脆直接从第一个有效节点开始走。第二个是循环条件用cur ! NULL不是cur-next ! NULL。用cur-next ! NULL做条件时循环会在最后一个有效节点停住因为它的 next 是 NULL这样最后一个节点的数据就会漏掉。想要少跑一次循环逻辑上不是不行但要在循环体里单独再处理一次尾节点初阶阶段完全没必要绕这个弯。每次访问cur-next之前前提都是cur本身不可能是 NULL。用cur ! NULL作为条件进入循环体时 cur 必然有效访问 data 和 next 都安全。3.2 尾插先找到尾巴再接尾插就是往链表最后面加一个节点。因为单链表只能从前往后走所以必须先走到最后一个节点才能操作bool insertTail(Node* head, ElemType value) { Node* newNode (Node*)malloc(sizeof(Node)); if (newNode NULL) { return false; } newNode-data value; newNode-next NULL; Node* cur head; while (cur-next ! NULL) { cur cur-next; } cur-next newNode; return true; }注意循环条件是cur-next ! NULL而不是cur ! NULL。为什么如果写成while (cur ! NULL) cur cur-next;循环结束时 cur 已经等于 NULL此时再执行cur-next newNode就是在空指针上操作程序直接崩溃。我们真正想要的是停在“最后一个有效节点”上而不是停在“NULL”上。判断cur-next为空说明 cur 就是最后一个节点这时候把新节点挂上去正好。尾插的时间复杂度是 O(n)因为必须先一路找到尾巴。在初阶阶段这个开销可以接受。等学到队列相关章节时有人会用“尾指针”来优化让尾插变成 O(1)那是后话这里先不主动加复杂度。3.3 头插不需要找尾但要注意操作顺序头插是把新节点插入到头节点后面、当前首元节点前面。它比尾插快时间复杂度 O(1)因为只需要知道头节点在哪里bool insertHead(Node* head, ElemType value) { Node* newNode (Node*)malloc(sizeof(Node)); if (newNode NULL) { return false; } newNode-data value; newNode-next head-next; head-next newNode; return true; }这段代码只有两行关键操作但却是新手最容易写反的地方。很多人会先执行head-next newNode再执行newNode-next head-next这就出问题了。画个图理解一下。假设当前链表是head - [1] - [2] - NULL我们要插入一个值为 0 的新节点希望变成head - [0] - [1] - [2] - NULL正确顺序是先让新节点的 next 指向原来的首元节点[1]也就是newNode-next head-next再让 head 的 next 指向新节点也就是head-next newNode。如果反过来head-next 先指向 newNode 后原来的[1]的地址就丢了新节点后面也不知道接谁链表在这里断成两截。可以这样记“新节点先伸手接住后面的头节点再放手指向新节点。”这就像排队时插队你插进去之前先要让自己和身后的人手拉手然后再让人家把你也当成队伍的一员。3.4 按值查找返回第一个匹配节点按值查找比较简单顺着链表走一遍找到就返回节点地址找不到返回 NULLNode* findNode(Node* head, ElemType target) { if (head NULL) { return NULL; } Node* cur head-next; while (cur ! NULL) { if (cur-data target) { return cur; } cur cur-next; } return NULL; }这里返回值是Node*意味着调用方可以通过这个指针修改节点里的 data。如果你只是想判断“存不存在”也可以改成返回 bool。如果后续想按“位置”而不是“节点地址”操作通常需要另外写一个定位函数或者用计数器记录走了几步。找到节点本身不麻烦麻烦的是很多操作需要“前驱节点”。比如要在某个值后面插入或者删除某个值所在的节点光知道当前节点还不够必须知道它的前一个节点因为单链表不能回头。这也是单链表对比双链表最大的短板。3.5 按位置插入找对前驱是关键按位置插入一般约定第 1 个位置是首元节点位置也就是头节点后面的第一个有效节点。要在第 i 个位置插入新节点必须找到第 i-1 个节点作为前驱。bool insertAtIndex(Node* head, int index, ElemType value) { if (index 1) { return false; } Node* pre head; int count 0; while (pre ! NULL count index - 1) { pre pre-next; count; } if (pre NULL) { return false; } Node* newNode (Node*)malloc(sizeof(Node)); if (newNode NULL) { return false; } newNode-data value; newNode-next pre-next; pre-next newNode; return true; }边界情况当 index 1 时count 初始 0循环条件count 0一开始就不满足pre 直接就是头节点。这正好对应“插入到第一个有效节点之前”。当 index 超过链表长度 1 时循环会把 pre 一直走到 NULL然后返回 false表示插入位置非法。当 index 恰好等于链表长度 1 时pre 会停在当前的尾节点上新节点挂上去正好成为新的尾节点。这个函数里前两步和尾插很像本质上也是“往 pre 后面插入节点”。只要指针顺序保持先接后面、再改前驱就不会出大问题。3.6 删除节点释放前先保存后继按位置删除的思路是找到第 i-1 个节点作为前驱 pre要删除的节点就是 pre-next。然后先让 pre-next 指向要删除节点的后继再释放要删除的节点。bool deleteAtIndex(Node* head, int index) { if (head NULL || index 1) { return false; } Node* pre head; int count 0; while (pre ! NULL count index - 1) { pre pre-next; count; } if (pre NULL || pre-next NULL) { return false; } Node* del pre-next; pre-next del-next; free(del); return true; }注意两个判断条件pre NULL表示前驱没找到链表长度不够。pre-next NULL表示当前 pre 已经是尾节点后面没有可删的节点了。删除的核心操作是pre-next del-next。如果要删除的是中间节点del-next 指向下一个节点这样前驱直接跨过 del 连接到后面如果要删除的是尾节点del-next 是 NULL前驱的 next 被置空链表长度减一逻辑依然正确。有同学会问是不是应该先pre-next del-next再free(del)没错。如果先 free(del)再访问del-next那又是野指针问题。free 一定要放在指针重接全部完成之后。4. 完整代码与测试可以直接抄作业的版本4.1 完整代码清单前面为了拆解原理代码是分散的。这里把完整可运行版本放在一起方便本地测试。我把测试用的 main 也写了可以直接跑#include stdio.h #include stdlib.h #include stdbool.h typedef int ElemType; typedef struct Node { ElemType data; struct Node* next; } Node; Node* initList() { Node* head (Node*)malloc(sizeof(Node)); if (head NULL) { printf(内存分配失败\n); exit(1); } head-next NULL; return head; } void destroyList(Node* head) { if (head NULL) { return; } Node* cur head; while (cur ! NULL) { Node* next cur-next; free(cur); cur next; } } void printList(Node* head) { if (head NULL) { return; } Node* cur head-next; while (cur ! NULL) { printf(%d - , cur-data); cur cur-next; } printf(NULL\n); } bool insertHead(Node* head, ElemType value) { Node* newNode (Node*)malloc(sizeof(Node)); if (newNode NULL) { return false; } newNode-data value; newNode-next head-next; head-next newNode; return true; } bool insertTail(Node* head, ElemType value) { Node* newNode (Node*)malloc(sizeof(Node)); if (newNode NULL) { return false; } newNode-data value; newNode-next NULL; Node* cur head; while (cur-next ! NULL) { cur cur-next; } cur-next newNode; return true; } Node* findNode(Node* head, ElemType target) { if (head NULL) { return NULL; } Node* cur head-next; while (cur ! NULL) { if (cur-data target) { return cur; } cur cur-next; } return NULL; } bool insertAtIndex(Node* head, int index, ElemType value) { if (index 1) { return false; } Node* pre head; int count 0; while (pre ! NULL count index - 1) { pre pre-next; count; } if (pre NULL) { return false; } Node* newNode (Node*)malloc(sizeof(Node)); if (newNode NULL) { return false; } newNode-data value; newNode-next pre-next; pre-next newNode; return true; } bool deleteAtIndex(Node* head, int index) { if (head NULL || index 1) { return false; } Node* pre head; int count 0; while (pre ! NULL count index - 1) { pre pre-next; count; } if (pre NULL || pre-next NULL) { return false; } Node* del pre-next; pre-next del-next; free(del); return true; } int main() { Node* list initList(); insertTail(list, 10); insertTail(list, 20); insertTail(list, 30); printList(list); // 10 - 20 - 30 - NULL insertHead(list, 5); printList(list); // 5 - 10 - 20 - 30 - NULL insertAtIndex(list, 2, 15); printList(list); // 5 - 15 - 10 - 20 - 30 - NULL Node* p findNode(list, 20); if (p ! NULL) { printf(找到了: %d\n, p-data); } deleteAtIndex(list, 1); printList(list); // 15 - 10 - 20 - 30 - NULL destroyList(list); return 0; }这段代码就是前面所有核心函数的集合直接复制到一个 C 文件里编译就能跑。如果编译器不支持stdbool.h可以把 bool 换成 int0 表示 false非 0 表示 true不影响逻辑。4.2 测试用例设计思路很多同学写完代码main 里随手加几个数字打印出来没错就以为结束了。其实链表代码最容易在边界问题上翻车所以我建议至少测这几类用例空链表初始化后直接打印应该输出 NULL。再调用删除、查找都应该安全返回 false 或 NULL。只有一个节点的链表测试删除第 1 个节点后链表为空继续删除第 1 个节点应该失败。常规多节点链表测试按位置插入到头部、中间、尾部以及删除这几种位置。非法参数index 为 0、负数、远大于链长函数应该返回 false而不是崩溃。上面完整代码里 main 只覆盖了常规流程你本地练习时可以再加几组边界测试。熟练之后编译时加一个-Wall -g选项能帮助发现很多隐藏问题。5. 常见问题与排查技巧实录5.1 “坑”其一传指针就以为能改实参很多初学者写一个“清空链表”函数时会这样写void clearList(Node* head) { destroyList(head); head NULL; }调用完发现外部头指针还是原来的地址于是很疑惑我都传指针了为什么不置空原因很朴素函数参数传的是指针的值也就是地址的副本。在函数里写head NULL只是让局部变量 head 不再指向那块内存外部的指针变量一点事都没有。既然外部头指针还在后续如果再调用printList(list)操作的就是一块已经释放掉的内存程序很容易崩溃。要真正把头指针置空常见两种做法函数返回值Node* clearList(Node* head)内部销毁后返回 NULL外部用list clearList(list);接住。二级指针void clearList(Node** ppHead)内部用*ppHead NULL修改外部指针。带头节点虽然能让我们避开设链表第一个节点时改头指针的麻烦但“销毁后把头指针置空”这类需求依然存在。我建议一开始就养成“想改指针本身就用二级指针或者返回值”的意识后面学二叉树、树的各种操作时会非常受用。5.2 “坑”其二释放节点后继续访问未定义内存遇到报错“段错误”或者“core dumped”第一反应先回忆最近有没有对已经 free 过的节点做过读写。一个经典误操作是删除节点后还用这个节点指针去访问链表Node* p findNode(list, 20); deleteAtIndex(list, 1); if (p ! NULL) { printf(%d\n, p-data); // p 可能已经被 free 了 }deleteAtIndex删除的节点不一定就是 p但当 p 恰好是被删节点时p指向的内存已经归还系统。即使p ! NULL这也不意味着指针合法。C 语言的堆指针在 free 之后处于“悬空”状态读它的 data轻则读到垃圾值重则直接地址访问错误。我自己的习惯是在删除或销毁逻辑里一旦 free 完立刻在意识里给这个指针“判死刑”。如果需要继续遍历链表一定先从之前保存过的 next 节点继续走而不是回头去访问已经释放的前驱。5.3 “坑”其三删除尾节点时忘了把前驱的 next 置空其实用标准的“找前驱、重接 next、再 free”流程删除尾节点时pre-next del-next这行代码已经把 pre-next 置为 NULL 了因为 del-next 本来就是 NULL。容易出问题的是另一种需求场景已知某个节点的地址想“直接”删除它但没有前驱节点信息。例如Node* p findNode(list, 30); deleteNodeDirect(p); // 想直接删掉 p 这个节点如果在 deleteNodeDirect 里只执行free(p)那 p 前驱的 next 还指向这块已经释放的内存链表等于断成了两截。后面遍历时走到前驱再取 next就会访问悬空内存。要解决这个问题要么重新从头找到 p 的前驱节点再重接链表要么在结构设计上就加入前驱指针——那其实就是双链表了。单链表设计上的“只能向前不能回退”决定了它非常依赖“前驱节点”这个概念。每个会写单链表的人都要把这句话刻在脑子里没有前驱单链表寸步难行。5.4 “坑”其四不检查 malloc 和函数返回值直接空指针访问malloc 返回 NULL 的情况在平时练习的电脑上不常见因为内存足够充裕。但考试、面试、以及嵌入式环境里内存不足是真实存在的。不检查 malloc 就往下用一旦真返回 NULLnewNode-data value这行就是在空指针上写数据程序直接崩。另一个更常见的场景是调用findNode后没有判断返回值直接访问p-data。如果链表里没有这个值p 就是 NULLp-data属于空指针解引用。写代码时每次调用返回指针的函数之后先做判空处理。虽然多几行代码看起来麻烦但很多偶发的运行时崩溃都是这些“我以为它不可能为空”的地方埋下的雷。稳健的代码不是靠运气跑通的是靠一个个if (p NULL) return;堆出来的。5.5 排查链表问题的通用思路链表出 bug 时不要急着盯着屏幕瞎猜我给你几个实用的排查路径画出当前链表的结构示意图把每个节点的 next 指向画清楚很多指针顺序问题一眼就能看出来。在关键节点前后加调试打印比如打印当前节点的地址、cur-next 地址、即将 free 的地址。这样能很快定位是“走串了”还是“释放错了”。插入、删除后立刻打印整条链表。如果链表断节打印出来的内容会明显变少或出现乱码。用调试器打断点观察变量窗口里各指针的值。重点关注有没有两个节点同时指向同一个地址、某个指针变成未初始化地址等。其实链表调试并不难难的是静下心来理清指针指向。我见过不少同学在一个“插入后没打印出来”的问题上卡了很久结果只是先把 head-next 改成了新节点新节点又没接上原来的首元节点。画个图三步之内就能定位。6. 复杂度小结与后续方向6.1 单链表各操作的时间复杂度一览我把常用操作的时间复杂度整理成一张表方便复习时对照操作时间复杂度简要说明头插O(1)直接修改头节点和新节点指针尾插O(n)必须遍历到尾节点按位置插入O(n)找前驱需要遍历按值查找O(n)只能顺序访问按位置删除O(n)找前驱需要遍历打印遍历O(n)访问每个节点一次空间复杂度方面单链表存储 n 个数据需要 n 个节点每个节点除了数据外还多存一个 next 指针所以空间开销比顺序表略高。这个多出来的空间换来了中间插入删除不需要大量移动元素的优势。看到这张表你会发现单链表真正擅长的是“频繁在头部插入、不定长、删除操作多但位置只需要从头找”的场景。如果应用场景里主要靠下标随机访问数据那顺序表依然是好选择。没有绝对的结构只有合适的取舍。6.2 从单链表到双链表、循环链表单链表是后续很多结构的基础因为它建立了一个很重要的思维模型数据分散存储用指针串联。在这个基础上往后延伸至少有两个明确的方向双向链表每个节点多一个 prev 指针指向它前面的节点。这样找前驱就不再需要从头遍历代价是每个节点多占一个指针空间插入和删除需要多维护一个方向。循环链表最后一个节点的 next 不再指向 NULL而是指向头节点或首元节点。这样从任意一个节点出发都能走回原点适合解决“约瑟夫环”这类需要循环报数的问题。学完单链表再去看双链表和循环链表时就会发现核心思想没有变只是在“指针数量”和“边界约定”上做了调整。所以这篇文章里每一个函数我都尽量讲透了指针操作的本质后面学别的结构会顺利很多。6.3 一点个人体会我自己写链表时养成一个习惯每写完一个操作先用三条数据的链表测一遍边界——空链表、只有一个节点、正常多个节点。这比写完直接跑大用例高效得多。单链表这一章代码量不大但它考验的是对指针和内存管理的掌控感。把插入、删除、查找这些动作练到肌肉记忆后面遇到栈、队列、二叉树这些嵌套型结构时就不会再对“谁是前驱”“谁被释放”“谁还指着它”这些问题发怵。回头再看这篇笔记也许你会发现最难的不是代码本身而是建立“指针视角”看内存的那一刻。