C语言单链表全面解析:从原理到插入删除逆序的完整实现

发布时间:2026/10/1 14:52:57
C语言单链表全面解析:从原理到插入删除逆序的完整实现 1. 内容整体设计与思路拆解先聊一个经常被新手忽略的事实单链表几乎是所有指针类数据结构里最“劝退”的一个但它同时也是面试、课程设计、底层系统开发里出现频率最高的一种结构。很多人在学 C 语言的时候数组用得贼溜一碰到链表就懵——原因很简单数组是“静态思维”链表是“动态思维”前者靠下标说话后者靠指针“穿针引线”。这篇就把单链表从原理到 C 语言实现完整拆一遍包括初始化、插入、删除、逆序、清空这些核心操作顺便把那些教材里不写但实际非常容易踩的坑都拉出来讲清楚。适合谁看正在学 C 语言的学生、准备面试的求职者以及想回头补基础的自学者。看完之后你不仅能写出一个能跑的单链表还能理解为什么头结点存在、为什么有人用二级指针、为什么逆序链表不能随便改指针——这些“为什么”才是单链表真正的价值所在。单链表的核心思想其实特别生活化。想象一列火车每节车厢只知道自己后面那节车厢在哪前面的车厢不知道后面的车厢长什么样但通过“车钩”一串就能从头走到尾。C 语言里这个“车钩”就是指针而车厢就是结构体节点。每个节点由两部分组成数据域装货的地方和指针域指向下一节车厢的钩子。最后一个节点的指针必须指向 NULL否则遍历的时候就会冲出轨道访问到野内存程序直接崩溃。数组和单链表的本质区别在于内存布局。数组是一整块连续内存逻辑相邻的元素物理上也相邻所以按下标随机访问是 O(1)链表则是节点分散在堆内存的各个角落只能通过指针一个个找过去随机访问是 O(n)。但链表也有数组比不了的优势插入和删除只需要改指针不需要搬动大量数据时间复杂度 O(1)前提是你已经站在目标位置了。所以当你面对“频繁插入删除、不常随机访问”的场景时链表是更合理的选择比如实现队列、LRU 缓存、图的邻接表底层都是链表的思想。2. 核心细节解析与实操要点2.1 结构体定义与节点设计C 语言里链表节点必须用结构体定义因为我们希望把“数据”和“指向下一个节点的指针”打包在一个类型里。最常见的写法是typedef struct Node { int data; // 数据域这里以 int 为例 struct Node *next; // 指针域指向下一个节点 } Node;这里有一个新手最容易懵的点为什么结构体内部能声明一个“指向自己类型”的指针因为指针本身只占固定大小在 64 位系统上是 8 字节编译器不需要知道完整结构体就能确定指针的尺寸。这就像你只需要知道一个人的门牌号不需要知道他家全套家具长什么样就能把这个门牌号记下来。如果这里写的是Node next;而不是Node *next;那是非法的——结构体还没定义完编译器无法计算它自身的大小死循环了。typedef struct Node {} Node;的作用是把struct Node这个类型名简化成Node后续声明指针、函数参数都会方便很多。不过要注意在结构体内部仍然需要写struct Node *next因为 typedef 的别名此时还没生效。2.2 为什么创造头结点这绝对不是可有可无的单链表有两种存储方式带头结点和不带头结点。很多人想不明白头结点到底图个啥。头结点head node是一个数据域无意义、指针域指向第一个真正存储数据的节点。它存在的最核心价值是统一化首节点的插入和删除操作。如果不带头结点链表为空时头指针指向 NULL。此时你要在头部插入一个节点需要让头指针指向新节点而如果链表非空插入头部的逻辑是让新节点的 next 指向原来的第一个节点再让头指针指向新节点。这两个分支代码逻辑完全不同每次都要判断空和非空。删除头部节点也一样需要判断有没有第二个节点。带头结点之后无论链表是否为空头指针永远指向那个“哨兵”头结点插入和删除第一个数据节点的代码和其他位置完全一致不需要对“头部”做特殊处理。教科书里经常强调“带头结点更简单”实际写代码时你才真正感受到这句话的分量——少写一半的 if else。也有场景不用头结点比如某些算法题要求返回头指针用不带头结点的方式更直观或者你非常清楚自己在做什么就想省掉那一个节点的内存。但对初学者和通用工程代码来说带头结点是最稳妥的默认选择。2.3 动态内存分配与节点创建链表节点不是定义个结构体变量就完事的每个新节点必须用malloc在堆上分配。malloc 返回的是 void*C 语言中会自动转换不需要强制类型转换但 C 里需要所以为了代码能同时兼容 C/C习惯上可以做一次强转Node* createNode(int value) { Node *newNode (Node*)malloc(sizeof(Node)); if (newNode NULL) { printf(内存分配失败\n); exit(1); } newNode-data value; newNode-next NULL; return newNode; }这里有两个关键点。第一用sizeof(Node)而不是直接写8或12不同平台上结构体大小可能因为内存对齐而变化写死数字是灾难的开始。第二malloc 之后必须检查空指针一旦堆内存耗尽malloc 返回 NULL如果你不检查直接往下用就会发生空指针解引用程序崩溃得很莫名其妙。还有一种不检查的写法在刷题时很常见因为在线判题环境内存足够大很多代码直接new Node不做检查。但工程实践里尤其是长期运行的服务端程序内存分配失败是必须要处理的否则就是一颗定时炸弹。2.4 初始化链表两种思路都没毛病初始化带头结点的链表有两种常见做法。第一种最直接在 main 里声明头指针后直接 mallocNode *head (Node*)malloc(sizeof(Node)); if (head NULL) { // 处理失败 } head-next NULL;第二种是封装成 init 函数通过二级指针把分配好的头结点传回去void initList(Node **head) { *head (Node*)malloc(sizeof(Node)); if (*head NULL) { printf(初始化失败\n); exit(1); } (*head)-next NULL; }为什么用二级指针因为 C 语言函数参数是值传递如果你直接传Node *head在函数内部给head赋值新地址外面调用者的head并不会变。这是 C 语言指针的一个经典陷阱。二级指针相当于一个“指针的指针”在函数内部修改*head的值实际上修改的是调用者那个头指针变量本身。如果你不想用二级指针另一个方案是让初始化函数返回Node*Node* initList() { Node *head (Node*)malloc(sizeof(Node)); if (head NULL) return NULL; head-next NULL; return head; }两种都可以但从可读性和接口设计角度看返回值方式更受人欢迎因为它不用理解二级指针但很多数据结构教材出于教学目的喜欢用二级指针因为后面“删除节点并更新头指针”的场景中二级指针确实有不可替代的角色。后面我们会看到删除整个链表时二级指针也是一把利器。3. 实操过程与核心功能实现这一节直接上完整代码每一段函数后面都会解释为什么要这么写以及在什么情况下会翻车。3.1 基本框架创建、遍历、长度计算下面的代码把前面讲的初始化、节点创建和遍历全部串起来#include stdio.h #include stdlib.h typedef struct Node { int data; struct Node *next; } Node; // 创建新节点 Node* createNode(int value) { Node *newNode (Node*)malloc(sizeof(Node)); if (newNode NULL) { printf(内存分配失败\n); exit(1); } newNode-data value; newNode-next NULL; return newNode; } // 初始化带头结点的空链表 Node* initList() { Node *head (Node*)malloc(sizeof(Node)); if (head NULL) { printf(初始化失败\n); exit(1); } head-next NULL; return head; } // 头插法建立链表 void insertAtHead(Node *head, int value) { Node *newNode createNode(value); newNode-next head-next; head-next newNode; } // 尾插法建立链表 void insertAtTail(Node *head, int value) { Node *newNode createNode(value); Node *p head; while (p-next ! NULL) { p p-next; } p-next newNode; } // 遍历打印 void printList(Node *head) { Node *p head-next; while (p ! NULL) { printf(%d - , p-data); p p-next; } printf(NULL\n); } // 求链表长度 int listLength(Node *head) { int len 0; Node *p head-next; while (p ! NULL) { len; p p-next; } return len; }头插法建立链表的结果是逆序的比如依次插入 3、5、7最终链表是 7 - 5 - 3。这是因为每一次新节点都被放在头部后插入的节点反而排在前面。尾插法则保持原顺序。头插法优点是不需要遍历到链表尾部时间复杂度 O(1)尾插法每次都要从头跑到尾时间复杂度 O(n)。如果你频繁需要保持顺序地构建链表可以考虑用一个tail指针专门指向尾部这样插入也能做到 O(1)。遍历链表是一个高频操作每次都要创建一个临时遍历指针p千万不要直接移动head。因为head是链表的“锚点”一旦丢了整个链表就找不回来了。我在调试时遇到最经典的 bug 就是有人直接while (head ! NULL) head head-next;遍历完链表没了。3.2 在指定位置插入节点先找前驱再改指针在指定位置插入节点是链表操作里最容易出错的一个。以“在第 pos 个位置插入节点”为例位置从 1 开始计数核心逻辑是先找到第 pos-1 个节点也就是前驱节点然后让新节点先指向后继再让前驱指向新节点。这一步的顺序极其重要。// 在指定位置 pos 插入节点pos 从 1 开始 int insertAtPos(Node *head, int pos, int value) { if (pos 1) { printf(位置不合法\n); return 0; } Node *prev head; // 从头结点开始头结点是第 0 个节点 int k 0; // 找到第 pos-1 个节点 while (prev ! NULL k pos - 1) { prev prev-next; k; } if (prev NULL) { printf(插入位置越界\n); return 0; } Node *newNode createNode(value); newNode-next prev-next; prev-next newNode; return 1; }为什么是newNode-next prev-next;在前prev-next newNode;在后因为如果先把prev-next改成 newNode那原来“前驱后面的那一串”就找不到了newNode 就变成一个断链的孤儿节点原来的后半段也彻底丢失。这就像你要在火车中间挂一节新车厢必须先让新车厢的车钩挂住后面的车厢再把前车厢的车钩解开接到新车厢上。因为头结点的存在prev初始化为head可以让插入位置 1 的情况直接运行这个逻辑不需要单写一个“带头部插入”的分支。如果你用不带头结点的链表这段代码需要额外判断pos 1时更新头指针代码会明显变长。3.3 删除指定节点改前驱的 next 就行删除节点比插入稍微简单一点核心也是找到前驱节点然后把前驱的 next 直接指向后继节点最后释放被删除节点。释放这一步不能省否则会内存泄漏。int deleteAtPos(Node *head, int pos) { if (pos 1) { printf(位置不合法\n); return 0; } Node *prev head; int k 0; while (prev-next ! NULL k pos - 1) { prev prev-next; k; } if (prev-next NULL) { printf(删除位置越界\n); return 0; } Node *toDelete prev-next; prev-next toDelete-next; free(toDelete); return 1; }删除时有一个致命陷阱如果先执行free(toDelete)再访问toDelete-next那这一步就变成读取已释放内存属于典型的 use-after-free 错误。正确顺序一定是先用指针变量toDelete记录待删除节点取到它的next并赋值给前驱之后再 free。这里我还犯过另一个错误忘记把prev的 next 连接好就直接 free结果链表断成两截。如果被删除的节点是最后一个节点prev-next本来就是 NULL执行完删除后前驱的 next 变成 NULL遍历正常结束。整个过程不需要特殊处理因为节点之间是靠指针串联的物理上并没有“最后一个”这一说。3.4 单链表逆序三个指针反复翻转 next单链表逆序是面试中出现频率最高的操作没有之一。网上最常见的方案是“三指针法”用prev、cur、next三个指针边遍历边把cur-next翻转指向前一个节点。void reverseList(Node *head) { if (head NULL || head-next NULL) { return; } Node *prev NULL; // 新链表的头 Node *cur head-next; // 当前遍历节点 Node *next NULL; // 暂存当前节点的下一个节点 while (cur ! NULL) { next cur-next; // 先保存下一个节点 cur-next prev; // 翻转指针 prev cur; // prev 后移 cur next; // cur 后移 } head-next prev; // 头结点指向新链表的第一个节点 }为什么需要next这个临时变量因为一旦执行cur-next prevcur 原来的 next 指针就被覆盖了如果不提前保存后面的链表就断了。这相当于你在拆一串珠子先把下一颗珠子的线头攥在手里才能放心地解开当前这一颗的结。这个函数会让原链表的顺序完全反过来。比如原链表 1 - 2 - 3逆序后变成 3 - 2 - 1。头结点的 next 最终指向最后一个非空节点也就是新链表的开头。递归逆序也是可行的但递归深度等于链表长度链表很长时容易爆栈所以三指针法是更稳妥的工程选择。如果你是在刷题递归写起来确实很优雅但如果是生产环境处理十万个节点的链表递归可能直接把系统栈耗尽。3.5 查找、修改与按值删除查找指定值的节点位置是链表的常见需求。因为链表不支持随机访问只能从头部开始逐个遍历。如果链表很长这个操作的时间复杂度就是 O(n)这是链表的固有短板没法优化。int findNode(Node *head, int target) { Node *p head-next; int pos 1; while (p ! NULL) { if (p-data target) { return pos; } p p-next; pos; } return -1; }按值删除需要遍历整个链表把所有匹配到 target 的节点都删除。这一步要注意遍历指针p和它的前驱prev要同时移动否则删除p后就找不到下一个节点了。void deleteByValue(Node *head, int target) { Node *prev head; Node *p head-next; while (p ! NULL) { if (p-data target) { Node *tmp p; prev-next p-next; p p-next; // p 先移动到下一个节点 free(tmp); // 再释放当前节点 } else { prev p; p p-next; } } }这里有个很容易忽略的细节删除后p已经指向下一个节点了所以prev不用变但如果没删除prev就要同步跟上。如果把prev p写在 if 外面删除后 prev 会指向一个已经释放的节点下一次循环就出错了。3.6 清空链表与销毁链表内存安全的重头戏“清空”和“销毁”是两件不同的事情。清空是保留头结点把所有数据节点删掉链变回一个空链表销毁是把整个链表包括头结点全部释放掉。这个区分在面试题“清空单链表”里经常出现必须先和面试官确认需求。void clearList(Node *head) { Node *p head-next; while (p ! NULL) { Node *tmp p; p p-next; free(tmp); } head-next NULL; }清空链表时必须先保存下一个节点再 free 当前节点。很多人写出while (p ! NULL) { free(p); p p-next; }这又是一个 use-after-free你访问p-next时p 本身已经被释放了。销毁整个链表时因为要释放头结点所以必须用二级指针否则函数结束后头指针还是指向那块已经释放的内存变成悬空指针void destroyList(Node **head) { Node *p *head; while (p ! NULL) { Node *tmp p; p p-next; free(tmp); } *head NULL; // 让调用者的头指针变为 NULL }如果不用二级指针销毁函数执行完之后调用者那边的head还残留着旧地址你以为链表没了实际上head是个“不知道指到哪去”的悬空指针。后面如果不小心再用它程序直接出问题。所以销毁必须用二级指针或者让函数返回 NULL 然后调用处自行赋值head destroyList(head);。4. 循环单链表与常见问题实录单链表还有一种变体叫循环单链表核心区别就是最后一个节点的 next 不再指向 NULL而是指回头结点或第一个节点。这个变体在实现“约瑟夫环”、循环队列、操作系统进程调度轮转时有独特的价值——因为没有 NULL 作为终止标记遍历时需要通过判断是否回到头结点来结束循环。循环单链表的插入、删除逻辑和普通单链表差别不大主要区别在于遍历的终止条件从while (p ! NULL)变成了while (p ! head)。如果代码里还留着 NULL 判断循环链表会直接死循环。在循环单链表中头结点既是起点也是终点这让某些需要在首尾之间频繁跳转的场景省去了每次遍历到尾部的开销。下面是我在实操中遇到的高频问题每个都附上原因和解决办法基本覆盖了新手会踩到的坑。问题现象根本原因解决方法程序打印链表时崩溃最后一个节点的 next 没置 NULL遍历越界创建节点时强制newNode-next NULL检查插入逻辑链表遍历结束后 head 变了直接用 head 遍历使用临时遍历指针 phead 永远不动插入节点后顺序是反的用了头插法新节点被插在头部需要顺序时用尾插法或维护 tail 指针删除节点后链表断成两截前驱的 next 没有指向后继就 free 了先改前驱 next再 free程序提示 “double free”同一个节点被 free 两次删除后把 p 移动到 next销毁后把 head 置 NULLmalloc 返回值没检查堆内存耗尽时直接解引用createNode 函数内部统一检查不要在外面重复写逆序后链表只剩一个节点逆序过程中断了链使用 next 临时变量保存后继再翻转结构体内部写Node next编译失败递归声明编译器无法确定大小必须用struct Node *next指针这里特别想单独说一个我非常常见的教训链表操作里所有“释放内存”的动作都要在释放前确认没有其他指针还指着这块内存。最稳妥的习惯是把要 free 的节点指针再赋值为 NULL虽然这一步不是强制要求但它能帮你在调试时更容易发现悬空指针问题。我见过很多同学 debug 半天结果问题就是某个局部指针还残留着已释放节点的地址后面无意中再解引用就崩了。另一个实战中经常犯的错是remove和free混用。在 C 语言里链表节点用malloc分配的就必须用free释放如果某个节点是在栈上定义的结构体变量那就绝不能 free。跨分配方式释放内存属于未定义行为程序可能当时看起来没问题但内存管理器的数据已经被破坏了等运行一段时间才在莫名其妙的地方炸掉。5. 完整可运行示例从建链表到逆序、清空把前面的函数拼起来写一个完整的 demo。这段代码可以直接复制进你的编辑器在 Linux 上用 gcc 编译或者 Windows 的 Visual Studio / Dev-C 里跑都行。#include stdio.h #include stdlib.h typedef struct Node { int data; struct Node *next; } Node; Node* createNode(int value) { Node *newNode (Node*)malloc(sizeof(Node)); if (newNode NULL) { printf(内存分配失败\n); exit(1); } newNode-data value; newNode-next NULL; return newNode; } Node* initList() { Node *head (Node*)malloc(sizeof(Node)); if (head NULL) { printf(初始化失败\n); exit(1); } head-next NULL; return head; } void insertAtTail(Node *head, int value) { Node *newNode createNode(value); Node *p head; while (p-next ! NULL) { p p-next; } p-next newNode; } void printList(Node *head) { Node *p head-next; while (p ! NULL) { printf(%d - , p-data); p p-next; } printf(NULL\n); } int insertAtPos(Node *head, int pos, int value) { if (pos 1) return 0; Node *prev head; int k 0; while (prev ! NULL k pos - 1) { prev prev-next; k; } if (prev NULL) return 0; Node *newNode createNode(value); newNode-next prev-next; prev-next newNode; return 1; } int deleteAtPos(Node *head, int pos) { if (pos 1) return 0; Node *prev head; int k 0; while (prev-next ! NULL k pos - 1) { prev prev-next; k; } if (prev-next NULL) return 0; Node *toDelete prev-next; prev-next toDelete-next; free(toDelete); return 1; } void reverseList(Node *head) { Node *prev NULL; Node *cur head-next; Node *next NULL; while (cur ! NULL) { next cur-next; cur-next prev; prev cur; cur next; } head-next prev; } void clearList(Node *head) { Node *p head-next; while (p ! NULL) { Node *tmp p; p p-next; free(tmp); } head-next NULL; } int main() { Node *head initList(); // 尾插法建立链表3 - 5 - 7 - 9 insertAtTail(head, 3); insertAtTail(head, 5); insertAtTail(head, 7); insertAtTail(head, 9); printf(初始链表\n); printList(head); // 指定位置插入在位置 2 插入 10 insertAtPos(head, 2, 10); printf(在位置 2 插入 10\n); printList(head); // 删除位置 3 的节点 deleteAtPos(head, 3); printf(删除位置 3 的节点\n); printList(head); // 逆序 reverseList(head); printf(逆序后\n); printList(head); // 清空 clearList(head); printf(清空后\n); printList(head); // 销毁 destroyList(head); return 0; }注意我在 main 里调用了destroyList(head)但这个函数还没有实现需要补充。你可以在 clearList 后面加上void destroyList(Node **head) { Node *p *head; while (p ! NULL) { Node *tmp p; p p-next; free(tmp); } *head NULL; }这样整个 demo 就能完整跑通。编译命令用gcc main.c -o main或者直接在 IDE 里点运行控制台会依次输出每一步变化非常直观。6. 单链表的扩展场景学完单链表之后你会发现它几乎是所有高级数据结构的“地基”。队列的链式实现、栈的链式实现、哈希表的链地址法解决冲突、图的邻接表、操作系统的进程管理、浏览器的前进后退历史底层全部依赖链表思想。尤其是 LRU 缓存那个“最近最久未使用”淘汰策略就靠双向链表配合哈希表实现查得快、删得快、移位也快。学完单链表下一步自然延伸是双向链表和循环链表。双向链表每个节点多了一个prev指针插入删除时多了一个“回看”的能力但代价是每个节点多 8 字节64 位系统上内存翻了一倍。循环链表则是解决“走到终点还想继续绕圈”的问题。我自己做实际项目时很少直接在业务代码里手写裸链表因为工程里更多用现成的库和容器。但理解链表的内存布局和指针操作对排查性能问题有极大的帮助。有一次线上服务内存越来越高就是因为某个缓存列表删除节点时没有把前驱的 next 接好节点虽然被 free 了但链表里还残留着悬空指针后续遍历访问了已释放内存内存分配器的状态被破坏越修越乱。最后定位到问题恰恰靠的是对链表指针语义的熟悉。所以我的建议是在校学习阶段一定要手写至少三遍单链表。第一遍照着教材抄理解函数拼起来的感觉第二遍关上书写默写核心操作第三遍不写 delete 和 reverse逼自己从零推导。这个过程比背十道题有用得多。链表的指针操作逻辑绕不过去绕过去的代价就是未来某一天在某个隐蔽 bug 里加倍偿还。把单链表彻底写透后面所有数据结构的坑都会轻松很多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询