先问一个选择题。给你一条单链表,要求原地逆序,你会怎么写?我见过太多人第一反应是:遍历两遍,第一遍把所有节点的 data 存进数组,第二遍从后往前把值原样赋回去。这个思路不算错,但放到数据结构期末考试、考研 408 或者面试白板题上,基本只能拿一点同情分。因为单链表题的考察重点从来不是“结果对不对”,而是你会不会通过修改指针的指向来改变节点之间的关系。
这话听起来有点不客气,却是我自己在学单链表时最大的体会。数组用久了你容易形成一种“下标思维”:想知道谁在谁后面,查下标;想交换两个元素,直接改 data;想定位某个位置,直接 O(1) 跳过去。可链表这个结构恰恰把这种习惯全部打碎——它不给你下标,只给你一个当前位置和一个指向下一个节点的指针。你想插入、删除、逆序,都不能靠赋值完成,只能靠重新接线。所以我决定在讲具体题型之前,先把“链式思维”这件事聊透。
这篇文章会以 C 语言的单链表为例,覆盖课堂上反复出现的基础操作、逆序类问题、快慢指针类问题、合并排序去重,以及最后怎么搭一个自测环境来检验自己写的代码。不管你是正在赶数据结构实验报告,还是为了期末考试或者考研复习,只要还在跟链表指针较劲,这篇文章应该能帮你省下不少查资料的力气。
1. 先把基本功磨明白:节点定义与插入删除的边界
写单链表代码的第一步不是急着写算法,而是把节点定义写对。这里有个很容易被忽略的细节:结构体成员里包含一个指向同类型结构体的指针,这种写法叫自引用结构,而自引用正是链表所有操作的根基。
c复制typedef struct Node {
int data;
struct Node* next;
} ListNode;
注意 next 的类型必须是 struct Node*,不能在 typedef 还没结束的时候直接写 ListNode* next,因为此时 ListNode 这个别名还不存在。很多教材会把 typedef 放在结构体定义之后,函数里统一用 ListNode* 声明指针,看着干净,但笔试填空题喜欢反过来考你:如果提前用了 ListNode*,编译根本过不了。别看这是小问题,往年确实有不少同学在实验室上机时,第一个报错就卡在这里。
建表也是实验报告里的常见考点。链表构建的两种方式,头插法和尾插法,虽然最终都是往链表里塞节点,但效果差别很大。头插法每次把新节点放到表头,所以输入顺序会被反转;尾插法每次都把新节点接到链尾,所以能保持原始顺序。如果题目没有特殊要求,写实验报告一定分清楚,很多同学就是因为“头插尾插结果不一样”在答辩时被老师追问。
c复制ListNode* buildListHead(int arr[], int n)
{
ListNode* head = NULL;
for (int i = 0; i < n; i++) {
ListNode* s = (ListNode*)malloc(sizeof(ListNode));
s->data = arr[i];
s->next = head;
head = s;
}
return head;
}
头插法的逻辑很直白:新节点的 next 指向当前头,然后头变成新节点。如果数组是 {1,2,3},得到的链表是 3->2->1。这个“反转特性”以后经常被用来做链表逆序的辅助手段,不过真要逆序时还是应该用专门的指针操作,而不是再造一条新链表,否则空间和思路都绕远了。
尾插法稍微复杂一点,因为需要维护一个 tail 指针,始终指向当前链表的最后一个节点。
c复制ListNode* buildListTail(int arr[], int n)
{
ListNode* head = NULL;
ListNode* tail = NULL;
for (int i = 0; i < n; i++) {
ListNode* s = (ListNode*)malloc(sizeof(ListNode));
s->data = arr[i];
s->next = NULL;
if (head == NULL) {
head = tail = s;
} else {
tail->next = s;
tail = s;
}
}
return head;
}
为什么要单独维护 tail?如果不维护,每次插入都要从头走一遍找尾部,往 n 个节点里做插入的总耗时就从 O(n) 变成 O(n²)。数据量小的时候感觉不出来,一旦拿到 OJ 上一跑,超时点就出来了。还有一处很多人第一遍会写错:第一次插入时,head 和 tail 必须同时指向新节点,不能漏掉 head == NULL 这个分支。否则空链表里执行 tail->next = s,本质是在对 NULL 解引用,程序直接崩,而且越是想调试越找不着原因,因为错误根本不在插入那几行,而在前面空链表状态没有被单独处理。
插入和删除题目里最常出问题的其实是“前驱”概念。单链表每个节点只知道自己的后继是谁,不知道前驱是谁,所以要在第 i 个位置插入或删除,必须先找到第 i-1 个节点,而不是第 i 个。删除一个节点时,表面上是改一条 next,实际顺序很重要:先让前驱节点的 next 指向待删节点的后继,再把待删节点释放。我见过有同学先 free 再改 next,结果前驱指向了一块已经释放的内存,变成常见的悬垂指针,后续打印链表时输出乱码或者根本停不下来。要养成一个好习惯:涉及修改 next 的代码,先画“改前”和“改后”两张图,再对照着写。链表这种结构最怕脑内编译,你以为自己懂了,实际每一步指针去向都可能是错的。
还有一种非常实用的处理技巧是引入哑节点。比如删除链表中所有值等于 x 的节点,如果不带头节点,就得单独判断删除的是不是第一个节点,头指针要不要更新;有了哑节点之后,所有删除都变成了“操作前驱节点的 next”,处理逻辑完全统一。哑节点本身不是有效数据,它只是帮我们把“头节点变化”这个特殊情况吸收掉,最后返回 dummy->next 即可。后面聊到合并、去重这一类需要频繁处理头部变化的题,哑节点几乎是无脑解法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 逆序类题型:从三指针迭代到递归,掌握“改链”的通用思维
单链表逆序是一道出现频率极高的经典题。期末考里它能单独出一道大题,考研模拟题里它经常作为中间步骤嵌套进回文链表、按 k 个一组翻转这些更复杂的题中。很多同学把逆序代码背得滚瓜烂熟,但换个场景就写错,核心问题是没有理解三个指针各自扮演什么角色。
2.1 三指针迭代:先保存、再反指、后移动
迭代版逆序的代码非常短,但越是短越容易在细节上翻车。
c复制ListNode* reverseListIterative(ListNode* head)
{
ListNode* pre = NULL;
ListNode* cur = head;
while (cur != NULL) {
ListNode* next = cur->next;
cur->next = pre;
pre = cur;
cur = next;
}
return pre;
}
循环体里真正关键的是四行代码的执行顺序:先用一个临时变量 next 保存原后继,再把当前节点的 next 反指到 pre,然后 pre 和 cur 分别前移。为什么必须先保存 next?因为 cur->next = pre 一旦执行完,原先的“下一个节点”就再也找不到了。如果不提前存下来,循环没法继续。我见过有人把最后一行误写成 cur = cur->next,这几乎必然导致死循环——此时 cur->next 已经被改成指向 pre,cur 根本没有往前移动。遇到这种问题不要盯着屏幕发愁,把三指针在每一轮循环结束后的位置画出来,立刻就能看清。
循环结束时,cur 是 NULL,pre 停在旧链表的尾节点,也就是反转后的新头。所以返回值是 pre,不是 cur。这一点也常被当作判断题考试:反转链表后,新的头节点到底是循环结束时哪个指针指向的节点?答案就在画图里。
如果原链表是空表,循环不执行,pre 返回 NULL,结果正确;如果只有一个节点,循环最多执行一轮,pre 最后指向那个唯一节点,也正确。所以这个版本天然兼容空表和单节点边界,不需要额外在函数开头加 if。
2.2 递归逆序:先逆转后面的链表,再接回头节点
递归版逆序的代码更短,但理解门槛更高,核心在于你一定要相信递归子调用已经完成了“把后半段链表逆序”这件事。
c复制ListNode* reverseListRecursive(ListNode* head)
{
if (head == NULL || head->next == NULL) {
return head;
}
ListNode* newHead = reverseListRecursive(head->next);
head->next->next = head;
head->next = NULL;
return newHead;
}
用 1->2->3 来推演一下。递归层层深入到节点 3 时,链表只剩一个节点,head->next == NULL,直接返回 3。回到节点 2 这一层,此时 head->next 还是 3,所以 head->next->next = head 实际上是在执行 3->next = 2,再把 2->next = NULL,于是从节点 2 开始的子链表变成了 3->2。回到节点 1 这一层,执行 2->next = 1,再把 1->next = NULL,整条链表变成了 3->2->1。每次返回的 newHead 都是节点 3,它从最深一层一路回到最外层,始终是反转后的头。
递归版最容易犯的两个错误,第一是忘记把当前节点的 next 置空。如果 1->next 没有变 NULL,原来1 的后继仍然是 2,而 2 的 next 又被改成了 1,两个节点互相指,链表中出现环。第二个错误是返回值写错,最后 return head 而不是 return newHead,导致整个函数虽然做了大量反转,最终却只把原链表头那个节点当成结果返回,后面的节点全军覆没。需要说明的是,递归深度等于链表长度,如果题目给的是特别长的链表,OJ 上可能出现栈溢出。这时迭代版更稳。递归版的价值主要在于理解“把大问题拆成小问题”的思维,方便后续学习二叉树、图论里的递归遍历。
2.3 逆序思想怎么扩展到其它题
掌握了基础逆序,很多“升级版”其实是在同一个模板上加条件。按 k 个一组翻转链表,做法是先数出 k 个节点,对这一小段调用 reverseList,再把翻转后的一段接回原链表中。判断回文链表,做法是先找到中点,把后半段逆序,再从头开始逐个比较前后两段节点。这些题并不需要你背一套新模板,需要的是你清楚“在一个局部范围内进行反转时,局部之前的节点、局部内部的头尾、局部之后的节点,应该分别用什么指针保存”。画状态图永远比背代码高效,这是链表题一个很反直觉的规律:代码越短,画图越重要。
3. 快慢指针:一趟遍历解决中间节点、倒数第k个和环判定
单链表只能从头到尾单向走,这让很多数组里很自然的操作变得别扭。快慢指针就是为了应对这种结构限制而出现的手段:两个指针从同一起点出发,一个每次走一步,一个每次走两步,利用速度差去感知链表里的长度、距离和周期。
3.1 找中间节点:循环条件决定成败
找链表中间节点的代码,几乎可以当选单链表最常背题型。
c复制ListNode* middleNode(ListNode* head)
{
ListNode* slow = head;
ListNode* fast = head;
while (fast != NULL && fast->next != NULL) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
slow 每次走一步,fast 每次走两步,所以当 fast 到达表尾时,slow 大致停在链表中间。奇数个节点时,slow 正好停在正中间;偶数个节点时,slow 停在偏右的那一个中间节点。不同教材对偶数的定义有细微差异,做题时先看题目要求返回哪个位置,再决定要不要在开头调整。
循环条件里两个判断缺一不可。如果少了 fast != NULL,空链表传入后第一步空指针就崩;如果少了 fast->next != NULL,链表长度为偶数时,fast 可能先走到 NULL,下一轮循环里再执行 fast->next->next 就越界访问。这个坑看起来很小,实际调试时最容易浪费大量时间,因为报错点往往不在你视线聚焦的逻辑里。我的建议是,凡是写了 while (fast && fast->next) 这种条件的题,都要额外测一遍空表、单节点、两个节点、三个节点四种输入。
3.2 判环与找环入口:相遇之后还有后半场
判断链表是否有环,同样是快慢指针的经典场景。
c复制bool hasCycle(ListNode* head)
{
ListNode* slow = head;
ListNode* fast = head;
while (fast != NULL && fast->next != NULL) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
return true;
}
}
return false;
}
为什么两个指针如果有环就一定会相遇?进入环之后,每次循环 fast 比 slow 多走一步,两者之间的距离会逐渐缩小,就像两个人在环形跑道上快慢不一致地跑,只要时间够长,快的人必然追上慢的人。如果没有环,fast 会先到达 NULL,循环自然结束并返回 false。
如果题目还要求找环的入口节点,那就需要第二个阶段:在 fast 和 slow 第一次相遇后,把一个指针移回链表头部,另一个留在相遇点,然后两个指针每次都走一步,它们再次相遇的位置就是环的入口。这个结论初看很神奇,但本质是追及问题里的距离关系推导出来的。考试里如果出证明题,建议把环外长度、slow 进入环后到相遇点走的长度、环的一圈长度这几个关系写清楚,步骤分基本就能拿满。实际写代码时,第二阶段只需要改一次指针指向,再继续 while 循环直到相遇。
3.3 倒数第 k 个节点:把“不知道链表长度”变成“距离差已知”
还有一个常见的面试题是找倒数第 k 个节点。数组里可以用下标直接定位,链表不行,所以如果先遍历一次求长度,再走第二次找倒数第 k,虽然能实现但比较笨。标准解法是让 fast 先走 k 步,然后 slow 和 fast 同步一次走一步,fast 到达 NULL 时,slow 正好停在倒数第 k 个节点。这个方法的本质是把“不知道总长度”的问题,转换成“两个指针之间保持距离 k”的问题,快指针走的路径就充当了尺子。
实现时需要特别处理 k 的合法性:k 必须大于 0,而且不能大于链表长度,否则 fast 先走 k 步时就已经越界,后面同步移动毫无意义。很多同学只在主逻辑里纠结,却忘了在函数开头判断 k <= 0 或者 fast 提前为 NULL 的情况。链表题的设计往往就是把边界处理清楚就成功了一半,算法本身反而不是主要失分点。
4. 合并、排序、去重:把“有序”这个条件吃透
单链表另一条高频题型线全都围绕“有序”展开。有序链表的合并、归并排序、去重,都是同一个主题的不同变体。这类题特别适合锻炼你组织链式操作的能力,因为每一题都要频繁地改变 next 指向,一步漏连就是整链断掉。
4.1 合并两个有序链表的两种经典写法
合并两个有序链表,教材和面试里都绕不开,常见的有递归版和哑节点迭代版。
c复制ListNode* mergeTwoListsRecursive(ListNode* a, ListNode* b)
{
if (a == NULL) return b;
if (b == NULL) return a;
if (a->data < b->data) {
a->next = mergeTwoListsRecursive(a->next, b);
return a;
} else {
b->next = mergeTwoListsRecursive(a, b->next);
return b;
}
}
递归版的思考方式很直接:当前两个链表的头节点里,值更小的那个应该充当合并结果的头,这个头后面的部分继续交给递归函数处理。边界条件是两个链表其中一个为空时,直接返回另一个链表,不需要再比较。想理解这段代码,需要“相信递归已经帮你处理完了子问题”,而不是一层层跳到最深再走出来。
迭代版更推崇的是哑节点写法。
c复制ListNode* mergeTwoListsIterative(ListNode* a, ListNode* b)
{
ListNode dummy;
ListNode* tail = &dummy;
dummy.next = NULL;
while (a != NULL && b != NULL) {
if (a->data < b->data) {
tail->next = a;
a = a->next;
} else {
tail->next = b;
b = b->next;
}
tail = tail->next;
}
if (a != NULL) tail->next = a;
else tail->next = b;
return dummy.next;
}
这里第一次展示哑节点的威力:合并结果的头到底来自 a 还是 b,事先不知道,但有了 dummy 后不用分支判断,只要把更小节点不断接到 tail 后面就行,最后返回 dummy.next。如果不用哑节点,就必须单独判断第一个节点来自哪条链,代码会多出几个 if,还容易漏。
4.2 链表归并排序:链表版的快排和堆排都比较别扭
“排序算法”在数组里实现,你习惯用下标做分治;但链表没有随机访问能力,这决定了很多数组排序写法没法直接迁移。单链表排序最合适、也最常被考试讨论的是归并排序:先找中点把链表一分为二,递归排序左右两半,再像上一节那样合并两个有序链表。找中点正好复用快慢指针,合并正好复用 merge 逻辑,整个算法的复杂度是 O(n log n),空间复杂度主要来自递归栈,通常记为 O(log n)。
有同学会问,能不能用快速排序的思路做链表排序?可以,但链表快速排序每次选 pivot 后需要同时维护两条子链表,操作麻烦,而且在随机性不足时容易退化。归并排序在这方面表现稳定得多,加上稳定排序这个优点,在“要求把链表排序,还要保持稳定性”的场景下有天然优势。这里要特别留意:如果真的要求稳定性,合并时遇到相等元素应该优先让前一段的节点先接入结果链。如果你偷懒直接交换节点里的 data 而不改链,稳定性其实会被破坏,面试官如果追问这一点,很多人会答不上来。
4.3 有序链表去重:保留一个和全部删除是两套思路
有序链表的去重题有两种常见问法。第一种是重复元素保留一个,第二种是重复元素一个不留。
保留一个的写法比较短:
c复制ListNode* deleteDuplicatesKeepOne(ListNode* head)
{
ListNode* cur = head;
while (cur != NULL && cur->next != NULL) {
if (cur->data == cur->next->data) {
cur->next = cur->next->next;
} else {
cur = cur->next;
}
}
return head;
}
关键点在于,遇到重复节点并把它跳过之后,cur 不能急着往后走。如果链上连续有三个 1,第一次删除时 cur 还在第一个 1 上,这时必须再比较一次 cur 的 data 和新的 next 的 data,才能把第二个 1 也删掉。所以“相等就只改 next 不移动 cur,不等才移动 cur”这个分支必须死记。
删除所有重复元素是更进阶版,思路也从“删掉后面那个重复的”变成了“找到一段连续相同值,整段跳过”。这种题往往需要 dummy 节点,因为极端情况是头节点也被删除,用不带 dummy 的写法要不停更新 head,非常容易乱。具体做法是一个 pre 指针指向已处理部分的尾部,一个 cur 指针负责探测连续重复段;如果发现 cur->data == cur->next->data,就内层循环一直扫到不相等的位置,然后让 pre->next 跳过整个重复段;否则 pre 和 cur 同时前移。很多同学在这个版本上出错,是因为让 pre 兼任了 cur 的职责,跳过重复段之后 pre 没停在该停的地方。把 pre 和 cur 两个指针各司其职分开,思路会清晰很多。
