☰
C语言单向链表手把手教程:从指针原理到核心操作实现
2026/10/14 3:54:43 网站建设 项目流程

我一直觉得,数据结构课程里劝退率最高的知识点,不是树,不是图,而是很多人入门数据结构遇到的第一座山头——单向链表。说它难吧,逻辑上其实只有两句话:每个结点存一个数据,再存一个指向下一个结点的指针。说它简单吧,作业里写出来的代码一运行就崩,经典问题“指针变量的值到底是什么”几乎可以登上编程新手劝退语录榜首。

链表这个知识点关键不在于背概念,而在于建立“脑内模拟”指针变化的能力。很多人代码写错,不是因为不懂原理,而是因为脑子里想象不出指针的指向过程。这篇文章会从C语言指针出发,把一个单向链表从结点定义、初始化、创建、插入、删除、查找,一直到反转链表全部手写一遍,每一步都解释为什么要这么写。内容适合刚开始学数据结构的新手,也适合那些学过链表但始终没搞明白指针操作为什么会断、为什么会崩的同学。

1. 为什么先学单向链表:它解决了什么问题

1.1 数组的局限:为什么不能只靠数组

在学习链表之前,大部分人对数据的组织方式理解都停留在数组上。数组有它的优势:连续存储、按下标随机访问非常快,arr[5]直接就能取到第6个元素,时间代价是常数级别。但数组有一个非常尴尬的短板——在任意位置插入或删除元素很痛。

举个例子,有一个按学号排好的名单数组,现在来了一个转学生,要插到第三个位置,那第四个到最后一个所有元素都要往后挪一格。如果是删除第三个位置,后面的元素又得全部往前挪。这个“挪”的过程不是概念上的麻烦,而是实打实的时间消耗,数组越长,挪得越慢,复杂度是O(n)。

链表的解法思路完全不同:我不需要一大块连续内存,也不要求所有数据在物理上挨在一起。每个数据独立分配一块小空间,再通过一个“线索”串起来,这个线索就是指针。数据可以零散地分布在内存各处,只要有线索能从上到下找到它们就行。

用生活中的场景来类比,数组就像一列焊死的火车车厢,想在一节车厢的位置插进一节新车厢,后面所有车厢都得重新编组。链表则像货车车厢用挂钩连接,想换掉中间一节,把两头的挂钩解开,换上一节新的,再把挂扣扣好,前后都不用挪动。这就是链表的核心价值——在不搬动整体数据的前提下,完成局部的增删。

1.2 链表的核心模型:结点、指针域、头指针

单向链表的构成单位叫结点,每个结点至少有两样东西:

  • 数据域:存真正的数据,比如一个数字、一个字符串。
  • 指针域:存下一个结点的地址,C语言里就是一个指向结点结构体的指针。

最后一个结点的指针域指向NULL,表示“后面没有了”,这是链表遍历的终止条件。你从第一个结点出发,通过next一路走到NULL,就走完了整条链表。

头指针(head)则是指向第一个结点的入口。它本身不是结点,而是一个指针变量,里面存着第一个结点的地址。链表空的时候,head指向NULL就行。

用一个最直白的比喻:每个结点是一张写着字的小纸条,纸条背面写着下一张纸条放在哪个位置。你想看全部内容,就拿着第一张纸条的位置出发,看完一张就按背面的地址找下一张,直到某一张贴纸上写着“没了”。这就叫链式存储。

1.3 复杂度对比:什么时候该用链表

很多人学链表时候会纠结一个问题:既然链表插入删除方便,那是不是把所有数组都换成链表就对了?

当然不是。链表的优势是有代价的。最明显的问题是不支持随机访问,想找第3个结点,必须从head开始一个一个next跳过去,复杂度O(n);而数组直接arr[2]就能拿到,O(1)。

我做了一张对比表,方便大家直接对着看:

操作数组单向链表
按下标随机访问O(1),直接算地址O(n),必须从头遍历
已知位置插入/删除O(n),要搬移元素O(1),只要改指针
在头部插入/删除O(n),整体后移/前移O(1),改head即可
尾部插入O(1),数组尾插很快O(n),要遍历到尾;有尾指针可优化为O(1)
内存要求需要连续空间无连续要求,按需分配
额外空间几乎没有每个结点多一个指针字段

从这张表能看出来一个关键点:链表插入删除快的真正前提是“已经找到了位置”。如果只是给定一个值要插到某个位置,找位置这个过程依然是O(n)。初学者最容易犯的理解偏差就是认为“链表插入复杂度是O(1)”,严格说应该是“插入动作本身O(1),查找到前驱结点需要O(n)”。

工程上什么时候用链表?典型的场景是:数据量不确定、需要频繁在中部插入删除、但对随机访问没有要求。这类场景在操作系统内存管理、图形编辑器的操作历史、缓存淘汰策略里都很常见。学链表不只是为了考试,它确实能解决实际问题。

2. 从零实现:3步搭好链表基本骨架

2.1 为什么选择C语言:指针最能暴露本质

讲链表我坚决用C语言,不用Python、不用Java。原因很简单:链表的本质就是操纵内存地址,C语言的指针就是这个东西本身。

Java里的引用本质上也类似指针,但有垃圾回收兜底,你不用手动释放内存,很多隐藏的问题根本暴露不出来。Python就更不必说了,list底层已经封装好了动态数组,你用Python写链表,写着写着容易陷入“我在干嘛”的迷惑。

C语言里你必须要自己malloc分配内存、自己free释放内存、自己保证指针正确连接。每一个概念都绕不开,每一个坑都会真实地砸到你头上。这个过程虽然痛苦,但恰恰是学习数据结构最值得的部分。链表学明白了,后面二叉树、哈希表、图的邻接表都会顺很多。

2.2 结点这样定义:3行代码搞定

C语言定义链表结点的标准写法是:

typedef struct Node { int data; struct Node *next; } Node;

这里有个非常经典的疑问:为什么next的类型是struct Node *,不是Node *?

原因是C语言编译器是顺序处理的,当你在结构体内部声明next指针时,Node这个别名还没定义完成,所以必须写成完整的struct Node *。等typedef执行完,后面代码里才可以直接用Node代替struct Node。

用int做数据域是为了方便演示,实际项目中你完全可以把这个数据域改成其他类型,比如结构体、字符串、浮点数,甚至再套一层链表,都行。结点的数据域类型完全取决于你的业务需求。

2.3 头指针和头结点:新手最容易混淆的两个概念

写链表之前必须先想清楚一个设计问题:到底要不要带头结点。

先解释这两个词:

  • 头指针:指向链表第一个结点的指针变量,是访问整个链表的入口。
  • 头结点:在第一个有效数据结点之前额外附加的一个结点,它的数据域不存业务数据,指针域指向第一个有效结点。

这两种做法的区别在于链表为空时的状态。不带头结点时,空链表就是head等于NULL;带头结点时,空链表是head本身存在,只是head->next等于NULL。

我强烈建议学习阶段全部使用带头结点的写法。原因非常实际:带头结点之后,插入和删除第一个有效元素时,操作逻辑和操作中间元素完全一致,不需要单独为“第一个结点”写特殊分支。不带头结点的话,删除第一个结点时必须额外处理head指针的更新,很容易在特殊情况下漏掉操作,白白制造bug。

图示一下带头结点的链表结构:

head -> [头结点|next] -> [data1|next] -> [data2|next] -> NULL

看到没有,所有的插入删除只需要改“前一个结点的next指针”,对于第一个有效数据结点来说,它的前驱就是头结点。这就把特殊情况归一化了,代码会简洁很多。

2.4 初始化和创建结点:malloc之后必须马上初始化

创建一个新结点的函数是学习的基石,所有插入操作都会调用它:

#include <stdio.h> #include <stdlib.h> Node *createNode(int data) { Node *node = (Node *)malloc(sizeof(Node)); if (node == NULL) { fprintf(stderr, "内存分配失败\n"); exit(1); } node->data = data; node->next = NULL; return node; } Node *initList() { Node *head = createNode(0); // 头结点数据域暂时不用 head->next = NULL; return head; }

这里有几个点需要仔细说。

malloc分配的是堆内存,它只负责给你一块空间,不会负责清空这块空间里的旧数据。如果我们申请完结点只赋值了data,却忘了给next赋值,那next里面会存着一个随机的垃圾值。之后遍历链表走到这个结点,以为是地址,顺着跳过去,程序瞬间崩溃或者行为诡异。所以malloc之后,data和next必须都显式初始化。

另一个点是malloc返回值必须做空判断。虽然学习阶段的demo通常内存充足,不会申请失败,但工程实践中内存不足是真实存在的。如果malloc返回NULL而你直接使用node->data,那就等于对空指针解引用,程序必崩。检查一下返回值不会浪费你多少时间,但能避免一个巨大的隐性问题。

initList里头结点的data随便给了个0,它在带头结点设计中本身不参与业务数据,这个值没有意义。当然也有一种设计是把头结点的data域用来存链表长度,插入删除时同步更新,这样获取长度就是O(1)。但学习阶段不建议这么做,容易混淆“头结点的数据域到底算什么”,不如老老实实把data字段当作占位。

2.5 遍历打印:从head出发走完整条链

创建完链表,第一件要做的事就是把它打印出来看看。遍历是链表最基础的操作,所有查找、修改都建立在它之上:

void printList(Node *head) { Node *p = head->next; while (p != NULL) { printf("%d -> ", p->data); p = p->next; } printf("NULL\n"); }

注意两个细节。

第一,为什么p = head->next而不直接p = head?因为头结点不存业务数据,直接打印会把头结点的占位数据也打出来,不是我们想看的结果。所以从head->next开始,也就是第一个有效数据结点。

第二,为什么用临时变量p而不是直接移动head?因为head是链表的入口,如果我们在遍历中移动了head,打印完之后head就跑到链表中间去了,链表的入口就丢了。以后再想从头遍历,已经没有地方能拿到入口了。所以统一用临时指针p来做移动,head这个入口变量保持不动。

理解这句话,其实就理解了链表的遍历逻辑:一个指针变量,不断读取当前结点的next,跳转到下一个结点,直到next是NULL。就这么简单。

3. 手把手实现核心操作:插、删、查一个不落

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

插入是链表最核心的操作,也是最容易写错的操作。在带头结点的前提下,三种插入的代码和注意事项不一样,我一个个说。

头插法

void insertAtHead(Node *head, int data) { Node *newNode = createNode(data); newNode->next = head->next; head->next = newNode; }

头插法的思路是让新结点顶替原来的第一个有效结点,整个过程只需要改两个指针。很多人第一次写头插会犯这样一个错误:

head->next = newNode; newNode->next = head->next;

这个写法是错的,而且错得非常隐蔽。先把head->next改成了newNode,再执行newNode->next = head->next时,取到的head->next已经变成了newNode自己。结果就是newNode指向自己,链表原来的所有结点全部丢失,一个都找不回来了。

所以插入操作的黄金准则是:先连新链,再断旧链。先把newNode接到原有链路上,再修改head的指向。

尾插法

void insertAtTail(Node *head, int data) { Node *newNode = createNode(data); Node *p = head->next; if (p == NULL) { head->next = newNode; return; } while (p->next != NULL) { p = p->next; } p->next = newNode; }

尾插要先找到最后一个结点,判断条件是p->next == NULL,注意不是p == NULL。如果条件是p==NULL,那退出循环时p已经是NULL了,根本没办法给它赋值。这又是一个典型的边界问题。

有的实现会把尾插优化成O(1),做法是额外维护一个tail指针指向最后一个结点。但代价是删除尾结点时tail的更新会很麻烦,因为你要找到倒数第二个结点才能更新tail。学习阶段不推荐一上来就用尾指针,先把遍历版本的逻辑吃透再说。

指定位置插入

int insertAtPos(Node *head, int data, int pos) { Node *p = head; int i = 0; while (p != NULL && i < pos - 1) { p = p->next; i++; } if (p == NULL) { return -1; // 位置不合法 } Node *newNode = createNode(data); newNode->next = p->next; p->next = newNode; return 0; }

这个函数约定pos从1开始,pos=1表示插在第一个有效结点前面。思路是先找到位置pos对应的前驱结点p,p从head出发,走pos-1步。比如pos=1,循环0次,p就是head,插在头结点后面,等价于头插。

返回值设计成int,成功返回0,失败返回-1,方便外部判断位置是否合法。不要省略这个校验,否则用户传一个很大的pos,p会一路走成NULL,对NULL->next赋值必崩。

3.2 删除操作:核心是保存待删结点的前驱

删除操作比插入多一步,除了改指针,还要释放被删结点的内存,否则就内存泄漏了。

int deleteAtPos(Node *head, int pos) { Node *p = head; int i = 0; while (p != NULL && p->next != NULL && i < pos - 1) { p = p->next; i++; } if (p == NULL || p->next == NULL) { return -1; // 位置不合法 } Node *toDelete = p->next; p->next = toDelete->next; free(toDelete); return 0; }

这里遍历条件多了p->next != NULL,目的是确保p不是倒数第一个结点。如果p已经指向最后一个有效结点的前驱,那p->next刚好就是这个结点;如果p->next已经是NULL说明这个位置没有结点可删。

删除的本质就是让前驱的next绕过待删结点直接指向后继,然后free掉待删结点。这里必须用一个临时变量toDelete保存待删结点,否则执行完p->next = toDelete->next之后,你就丢失了待删结点的地址,没法free,内存就泄漏了。

再强调一遍free的时机:先让链路绕过它,再释放它。顺序不能反,这也是换链条场景里的标准动作。

3.3 查找与销毁:两个绕不开的辅助操作

查找的位置很朴素,就是从头走到尾挨个比:

Node *findNode(Node *head, int target) { Node *p = head->next; while (p != NULL) { if (p->data == target) { return p; } p = p->next; } return NULL; }

返回的是结点指针,改数据直接改found->data就行。注意返回值类型是Node *,不能返回int,否则你得到的是值而不是位置,后面想拿这个结点做操作还得再遍历一次。

销毁链表是链表学习中最容易被忽视的操作,C语言中不手动释放就会内泄漏:

void destroyList(Node *head) { Node *p = head; Node *tmp; while (p != NULL) { tmp = p; p = p->next; free(tmp); } }

这里的关键是:先保存next,再free当前结点。如果先free(p)再去读p->next,那就是典型的悬垂指针非法访问,程序行为未定义。tmp变量就是用来暂存当前结点地址的。

把所有函数串起来的完整main函数长这样:

int main() { Node *head = initList(); insertAtHead(head, 10); insertAtTail(head, 20); insertAtPos(head, 15, 2); printList(head); // 期望输出: 10 -> 15 -> 20 -> NULL deleteAtPos(head, 2); printList(head); // 期望输出: 10 -> 20 -> NULL Node *found = findNode(head, 20); if (found != NULL) { printf("找到了: %d\n", found->data); } destroyList(head); return 0; }

把这份代码完整跑通,链表的基本功就算过关了。

4. 指针操作5个高频坑:排查与调试实录

4.1 空指针解引用:编译能过,运行就崩

链表最经典的段错误就在这一条:对空指针取成员。比如p是NULL,你还去执行p->next,程序直接段错误。

空指针解引用的常见场景有三个:

  1. malloc失败后没有检查返回值,直接用返回的NULL。
  2. 遍历时循环条件写错,p已经变成NULL还在循环体里取p->next。
  3. 外部传入的head本身就是NULL,函数内部没做参数校验。

排查方式很简单:用调试器看崩溃位置的调用栈,或者用printf在怀疑的步骤打印p的地址。现在的集成开发环境基本都支持断点调试,遇到段错误不要慌,看一眼崩在哪一行,十有八九就是那一行某个指针是NULL。

一定要给指针操作加上防御性判断。接口函数开头先检查传入参数,遍历时循环条件写严谨,malloc之后判断返回值。这些习惯能让你的链表代码稳很多。

4.2 断链:插入顺序写反的后果

断链就是链表后半截突然找不到了。最典型的场景我已经在头插法里演示过:先改head->next,再去取head->next赋值给新结点,结果后半截被丢了个精光。

这种bug麻烦在程序不一定崩溃,打印链表时只是少了一截数据,很难肉眼看出来。尤其是数据量小的时候,输出看起来还有点道理,数据量一大就露馅了。

排查手法是利用打印函数打印每个结点的地址:

void debugList(Node *head) { Node *p = head->next; int count = 0; while (p != NULL) { printf("[%d] data=%d addr=%p next=%p\n", count, p->data, (void *)p, (void *)p->next); p = p->next; count++; if (count > 20) { printf("[警告] 超过20个结点,疑似死循环\n"); break; } } }

把地址打出来之后,你就能清楚地看到每个结点的next到底指向谁。如果某个结点的next一片混乱,或者一堆结点的next都是NULL,说明断链了。再回头检查插入操作的顺序即可。

4.3 死循环:指针没前进或链表成了环

程序莫名卡死,CPU占满,链表打印不完,多半是遍历指针没有前进,或者链表本身成了环。

没有前进是最常见的原因:while循环里写了printf语句,但忘记写p = p->next,循环永远在第一个结点出不去。成环则更隐蔽,比如插入时顺序写反导致newNode的next指向自己,或者删除时把next赋错了地方,让链表尾结点的next指回中间某个结点,形成环形结构。

排查死循环用刚才的debugList函数就很舒服,超过20个结点主动提示。另一个技巧是快慢指针判断法:慢指针每次走一步,快指针每次走两步,如果链表有环,这俩指针终会在环里相遇。这个方法后面也常用,不只是用来排查bug。

4.4 内存泄漏与悬垂指针:free之后还有事

写链表不得不住的两个内存问题是泄漏和悬垂。

内存泄漏是指malloc出来的空间没有被free。链表中常见于:删除结点时改了指针就完事,忘了free;销毁链表时只free了head,链表里的其他结点全丢在了堆里。程序一直跑下去内存会越占越多,最后被系统杀掉。用valgrind或者编译器自带的地址检测工具都能扫出来。

悬垂指针则是另一面:free之后,指针的地址值还在,只是指针指向的内存已经被释放回收了。这时候访问它,行为未定义,程序时好时坏。最直接的防范方式就是free之后立刻给指针置NULL,后续操作前先判断NULL。习惯养成后,这类问题能规避一大半。

我整理过一张速查表,每次写完链表代码都会顺手检查一遍:

症状可能原因排查要点
段错误对NULL指针解引用检查遍历条件、参数校验、malloc判断
打印出垃圾数据结点data或next未初始化malloc后立即赋值data和next=NULL
链表输出少一截插入顺序写反导致断链debugList打印每个结点的next地址
程序卡死成环或遍历指针未前进限制循环次数,用快慢指针查环
内存越用越多删除/销毁时漏free用内存检测工具扫描
free后偶发崩溃悬垂指针二次访问free后置NULL,再操作前判空

5. 学完基础后的扩展:反转链表与经典应用

5.1 反转链表:一次经典的三指针练习

反转链表是检验链表掌握程度的标准题目,几乎每个面试过算法的人都会遇到。它的难度在于:你每修改一个next指针,就会丢掉原本的下一个结点地址,所以必须提前保存后继。

迭代版三指针法是最容易理解的做法:

void reverseList(Node *head) { Node *prev = NULL; Node *curr = head->next; while (curr != NULL) { Node *next = curr->next; // 先保存后继 curr->next = prev; // 反转当前结点的next prev = curr; // prev后移 curr = next; // curr后移 } head->next = prev; // 头结点指向新的首结点 }

三个指针的职责很清晰:prev指向已经反转好的部分链的头,curr指向待处理的当前结点,next保存当前结点的后继。每轮循环做一件事——把当前结点的next指向prev,然后三个指针整体后移一步。循环结束时,prev就是原链表的尾结点,也就是新链表反转后的头部,头结点的next指向它即可。

建议不要直接背代码,而是拿一张纸画三个指针的变化过程,画三轮就彻底明白了。反转链表理解了,说明你对“修改next之前必须保存后继”这句铁律已经内化。

5.2 快慢指针:查找中间结点与环检测

链表定位中间结点比数组麻烦,因为不知道长度。但如果用快慢指针,不需要求长度也能轻松搞定:

Node *findMiddle(Node *head) { Node *slow = head->next; Node *fast = head->next; while (fast != NULL && fast->next != NULL) { slow = slow->next; fast = fast->next->next; } return slow; }

慢指针每次走一步,快指针每次走两步。快指针先到头,慢指针正好在中间位置。这个技巧是链表算法里最常见的一类,也是后面双向链表、循环链表学习的入口。掌握了它,链表就不再只是“数据结构课本上那个只能增删查的东西”,而是可以玩出花样的算法结构。

5.3 链表在工程中的身影:不只是考试题

有些人觉得自己未来不做算法岗,链表学了没用。这个想法低估了链表的实际工程价值。链表在很多底层系统里都有真实应用:

  • 操作系统的内存管理器用空闲块链表来跟踪可分配的内存区域,分配和回收频繁发生,链表很适合。
  • 图的邻接表用链表存储每个顶点的邻居,添加边时只需要在头部插入,很快。
  • 哈希表的链地址法直接用链表挂在每个桶下面解决冲突。
  • 图形编辑器的撤销重做历史记录,常用双向链表来组织操作节点,来回移动指针就是撤销和重做的过程。

你不需要现在就把所有场景都搞清楚,但当你看到某个模块里出现了“链”、“指针串接”、“不连续存储”这些词,能联想到链表,你就已经超越了很多只会背代码的人。

我个人学习链表最大的体会是,代码写不出来的时候,就在纸上画箭头。每一个next都是一条箭头,画清楚了,bug基本就能看出来。不少人觉得画图浪费时间,但我在链表这道坎上发现,画图反而是调代码最快的办法。这篇文章里的代码我全部按顺序跑过,有坑的写法也整理成了踩坑表,建议你亲手敲一遍,不要只看不练。敲完之后再去把反转链表独立实现一次,你会发现后面学二叉树、哈希表都会顺不少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询