资讯动态

C语言经典题目:链表反向输出的递归与指针详解

发布时间:2026/10/9 8:24:31 来源:尧图企业网站定制
菜鸟教程的C语言经典100例练到第73题的朋友应该都有同感这个练习序列越往后越硬核。前面几十道题还在if-else、for循环、数组这些舒适区里打转到了第73题反向输出一个链表直接把你从语法区拽进数据结构区。链表这个概念一登场连带着结构体、动态内存分配、指针、递归全都来了一道题顶得上四道题的训练量。我第一次做这道题的时候第一反应是想用数组下标倒序的方式输出——这是前面练习留下的思维惯性。但链表根本没有下标这个东西节点的访问只能顺着next指针一个个往下走想倒着来谈何容易。后来我照着题解写了一个递归版本递归到链表尾部再一层层往回打印当5 4 3 2 1出现在屏幕上的那一刻我才第一次真正理解了递归的调用栈到底是怎么回事。接下来我把它完整拆开讲题目想考察什么、两种主流解法各自有什么优缺点、完整代码怎么组织、以及我在实际练习中摔过的坑。不管你是刚学完指针正在挠头的新手还是刷到一半想回头巩固的老同学这篇应该都能帮上点忙。1. 题目的思路拆解第73题到底在考什么1.1 为什么偏偏选链表来考反向输出先聊聊链表这个数据结构本身。数组和链表是C语言里两种最基本的数据组织方式但它们的访问逻辑完全相反。数组是一片连续内存arr[0]、arr[1]这种下标访问能直接换算成内存地址所以天然支持随机访问链表则是把节点散落在内存的各个角落每个节点里存一个next指针指向下一个节点的地址从任意一个节点出发只能顺着指针往下走无法回头。反向输出一个链表这个题目之所以被选中恰恰是因为它击中了链表的软肋——正着遍历很容易倒着遍历做不到。如果题目换成反向输出一个数组估计一行for循环就结束了完全没有讨论价值。而链表想倒着输出就必须跳出顺着指针走的惯性思维借助其他机制来实现。这就引出了这道题真正的核心考点递归。更妙的是这道题同时还把结构体定义、malloc动态内存分配、指针的传参与解引用全部串了起来。一个节点要存整数数据还得存指针这就是结构体一个节点要用malloc在堆上申请内存就得检查返回值访问节点成员时p-data和(*p).data的关系要分得清。可以说练会这一道题等于把C语言中后期的所有重点知识复习了一遍。1.2 三种解法路线从最笨到最优先说说这道题你能想到的几种做法然后逐个分析。第一种辅助数组法。先把链表遍历一遍把每个节点的data存到一个数组里最后倒着for循环把数组打印一遍。这种办法在思路上零门槛谁都能写出来但它要通过额外的O(n)空间来存储数据属于曲线救国算法的含金量基本为零。如果面试的时候只写出这种解法大概率会被追问一句还有没有更好的办法。第二种递归法。利用函数调用栈天然的后进先出特性先递归深入到链表的最后一个节点然后在回溯过程中打印。整个函数只需要几行代码不需要任何额外数组空间复杂度是O(n)递归栈但胜在思路清晰、代码简洁。这也是大多数教材和题解给出的标准答案。第三种迭代反转法。先通过三根指针prev、curr、next把链表的方向整体反转让原来的尾节点变成头节点然后从头顺序打印。这样做空间复杂度降到了O(1)但代价是改变了链表原有的结构如果你还需要保持原链表不变就得打印完再反转一次代码量上去了。这三种路线没有绝对的对错但站在学习数据结构的角度递归法最值得深入理解。原因很简单递归是理解二叉树、图这类非线性结构的基础技能而辅助数组法和迭代反转法更像巧劲对思维训练的帮助有限。1.3 题目设计的巧妙之处回到菜鸟教程这个系列的定位它是给初学者的练习册。第73题把递归和链表这两个初学者最容易恐惧的知识点绑在一起但难度又控制得刚刚好——链表是单向的递归出口只有一个head NULL递归调用也只有一处head-next没有任何花活。这种用最简单的递归解决最典型的链表难题的设计让这道题成了很多人理解递归的转折点。我在练到这道题之前对递归的理解一直停留在函数调用自己这种字面层面。做完这道题之后我才意识到递归真正的威力在于你不用自己想明白整个过程只需要把递和归的某一步描述清楚剩下的交给调用栈去处理。这道题就像一把钥匙打开了后面理解快速排序、二叉树的先序遍历、汉诺塔等一系列递归问题的门。2. 核心细节解析节点、内存与指针2.1 链表节点的定义方式先回到最基础的节点定义。在写链表之前我们需要一个结构体来同时容纳数据和指针struct Node { int data; struct Node *next; };这里的struct Node *next是一个指向同类型结构体的指针这个写法初学者很容易写错有时候会漏了struct关键字有时候会把类型名写错。注意在结构体定义内部我们还不能直接用typedef后的别名因为别名还没定义完所以在结构体内部必须写完整的struct Node *next。实际项目中更常见的写法是用typedef给结构体起个别名让代码更简洁typedef struct Node { int data; struct Node *next; } Node;这样在后面的函数里我们就能直接写Node *head而不是struct Node *head。别小看这一处简化当代码里到处是指针声明的时候少敲一个struct能省不少事读起来也清爽。还有一派人喜欢直接定义两个别名例如typedef struct Node *LinkList把链表头指针单独抽象出来。这在严蔚敏那套数据结构教材里很常见。不过对于这道练习题我认为一个Node别名就够了过度封装反而会让初学者分不清节点和链表是两个层次的概念。2.2 malloc动态分配与内存安全链表节点不能在编译期静态创建——因为你不知道用户需要几个节点所以必须依赖malloc在堆上动态分配。Node *p (Node *)malloc(sizeof(Node));这一行代码里有三个细节值得较真。第一sizeof(Node)告诉malloc需要多大的内存块初学者在这里最容易犯的错误是写成sizeof(Node *)这拿到的是指针本身的大小64位系统下是8字节而节点体至少需要16字节一个int加一个指针分配空间不足后面写入data和next就会越界属于典型的未定义行为。第二malloc返回的是void *在C语言中其实可以不用强转自动转换成任意对象指针但显式写上(Node *)能让代码的意图更清楚而且如果将来把这套代码拿到C里编译也不会因为类型不匹配报错。第三malloc可能返回NULL尤其是堆空间耗尽的时候所以任何一次malloc之后都应该检查返回值。这里我还想提醒一个隐蔽的坑如果在代码里用了malloc却没有#include stdlib.hC编译器会隐式地认为malloc是一个返回int的外部函数。在64位系统上这会让malloc返回的指针被截断成int然后隐式转换回指针结果就是拿到一个无效地址程序大概率运行到一半直接段错误而且这种错误很难定位。提示malloc之后一定要检查返回值是否为NULL用了malloc就一定要包含stdlib.h最好再开启编译警告选项。这些习惯能帮你避开一大半的崩溃问题。动态内存分配的正确姿势是Node *p (Node *)malloc(sizeof(Node)); if (p NULL) { printf(内存分配失败\n); return NULL; // 或者exit(1) }凡是拿到malloc返回值先检查NULL这应该写进你的肌肉记忆。2.3 指针操作的关键细节链表里的每一个指针操作都要考虑断链的问题。以尾插法创建链表为例核心代码是if (tail NULL) { head tail p; } else { tail-next p; tail p; }这里为什么先tail-next p再tail p因为tail始终要指向链表的最后一个节点。你要先让旧的尾节点指向新节点形成连接再把tail指针滑动到新节点上。顺序反过来的话tail提前跑到了新节点旧的尾节点就没人管了链也就断了。还有一个容易踩的坑是p-next的初始化。malloc返回的内存内容是未定义的所以p-next可能是一个任意值。如果你在创建节点的循环里忘记写p-next NULL那么后续遍历链表时就根本不知道哪里是链表的终点会一路读到野地址大概率在某个时刻段错误。这个错误在编译期完全不会报出来只能靠运行时崩溃或者gdb定位非常折磨人。头插法创建链表则是另一套逻辑每次把新节点插在最前面p-next head; head p。这样创建出来的链表顺序正好和数据输入的次序相反如果之后还要做反向输出结果就负负得正成顺序了。所以很多初学者会在练习时疑惑为什么我创建的是1 2 3 4 5输出却变成了5 4 3 2 1十有八九就是用了头插法创建链表。3. 实操过程完整实现与代码解读3.1 完整代码递归法反向输出把上面的知识点串起来我直接给一份可以编译运行的完整代码。这道题在菜鸟教程里的标准做法是递归所以我把递归作为主版本#include stdio.h #include stdlib.h typedef struct Node { int data; struct Node *next; } Node; /* 尾插法创建一个 n 个节点的链表数据为 1 到 n */ Node *createList(int n) { Node *head NULL; Node *tail NULL; for (int i 1; i n; i) { Node *p (Node *)malloc(sizeof(Node)); if (p NULL) { printf(内存分配失败\n); return NULL; } p-data i; p-next NULL; if (head NULL) { head p; } else { tail-next p; } tail p; } return head; } /* 递归反向输出链表 */ void reversePrint(Node *head) { if (head NULL) { return; } reversePrint(head-next); printf(%d , head-data); } /* 释放链表防止内存泄漏 */ void freeList(Node *head) { Node *p head; while (p ! NULL) { Node *tmp p-next; free(p); p tmp; } } int main() { Node *head createList(5); if (head NULL) { return 1; } printf(链表内容); for (Node *p head; p ! NULL; p p-next) { printf(%d , p-data); } printf(\n反向输出); reversePrint(head); printf(\n); freeList(head); return 0; }保存为list_reverse.c然后编译运行gcc list_reverse.c -o list_reverse ./list_reverse运行结果链表内容1 2 3 4 5 反向输出5 4 3 2 1这份代码里我特意加了一个freeList函数用来释放整个链表。这在菜鸟教程的原始代码里经常被省略因为纯练习代码跑完就退出进程了操作系统会回收所有内存。但写惯这些细节之后你会发现不管练习还是工作养成谁分配谁释放的习惯能省掉后面无数的内存排查时间。3.2 一步步拆解递归调用过程很多初学者看完reversePrint这个函数还是不理解明明第一行是递归调用为什么打印语句反而在递归调用后面这里我把执行过程完完整整列出来。假设链表是1-2-3-4-5调用reversePrint(head)调用层次当前节点执行动作输出时机第1层1调用reversePrint(2)等待返回等更深层返回后打印1第2层2调用reversePrint(3)等待返回等更深层返回后打印2第3层3调用reversePrint(4)等待返回等更深层返回后打印3第4层4调用reversePrint(5)等待返回等更深层返回后打印4第5层5调用reversePrint(NULL)等待返回等更深层返回后打印5第6层NULL直接return什么都不打印当最内层的reversePrint(NULL)返回后第5层才继续执行printf(%d , head-data)打印5。接着第4层打印4第3层打印3第2层打印2第1层打印1。整个调用栈的后进先出特性在这里体现得淋漓尽致——越早进入的函数越晚返回所以打印顺序自然就反过来了。这就是递归的精髓你不需要靠大脑模拟所有层次只需要相信两个事实。第一递归出口正确也就是空链表直接返回不会无限循环第二递归调用处理了剩下的所有节点也就是reversePrint(head-next)已经帮我们把头节点之后的所有节点逆序打印完了那么当前层只需要在它之后打印自己即可。这种只关心当前层剩下的交给递归的思维方式是真正学会递归的标志。提示如果递归过程想不清楚最好的办法是拿小纸片画调用栈。画到第六层返回的时候你会有种恍然大悟的感觉。3.3 迭代反转法不递归也可以如果你不想用递归或者面试官要求空间复杂度O(1)那么迭代反转法是一条更硬核的路线。核心思路是用三根指针遍历链表把每个节点的next指针方向掉转过来。完整实现如下Node *reverseList(Node *head) { Node *prev NULL; Node *curr head; while (curr ! NULL) { Node *next curr-next; // 先保存下一个节点 curr-next prev; // 掉转当前节点的指针 prev curr; // prev 向前移动 curr next; // curr 向前移动 } return prev; // 原链表的尾节点成为新头节点 }然后把它接进主程序int main() { Node *head createList(5); if (head NULL) return 1; printf(链表内容); for (Node *p head; p ! NULL; p p-next) printf(%d , p-data); printf(\n); head reverseList(head); printf(反转后顺序输出); for (Node *p head; p ! NULL; p p-next) printf(%d , p-data); printf(\n); freeList(head); return 0; }运行结果链表内容1 2 3 4 5 反转后顺序输出5 4 3 2 1要注意reverseList直接修改了链表结构原来那串1-2-3-4-5已经变成了5-4-3-2-1。如果你还需要保留原始链表打印完之后再调用一次reverseList把头反转回来即可。我把这三根指针的移动过程描述一下理解之后你就能自己写出来。curr始终指向当前要处理的节点next用来保存curr-next的后续链路因为一旦执行了curr-next prev原来的下游就找不到了。prev是已经处理完的那段链表的头。每一步的结果就是把curr从原来的链上摘下来接到prev那段的头部。循环结束条件是curr走到NULL此时prev恰好是原链表的尾节点也就是反转之后的新链表头。这一遍for循环改变了所有指针的方向时间复杂度O(n)空间复杂度O(1)应该说在性能上是三种解法里最优的。但它牺牲了代码的直观性——新手第一次看这段代码往往要盯着画图才能理解。所以我个人的建议是初学阶段以递归法为主把调用栈想清楚等你需要追求极致性能或者面试时被追问能否不用递归再拿出迭代反转法。3.4 两种解法对比与选型建议对比维度递归法迭代反转法代码量极短核心就三四行稍长需要三指针联动空间复杂度O(n)递归栈深度等于链表长度O(1)只用常数个额外变量是否改变链表结构不改变改变需额外一次反转恢复理解难度对递归不熟的人容易晕对指针不熟的人容易晕适用场景教学、理解递归、链表不长实际工程、链表很长、空间敏感如果链表长度只有几万个节点递归法的栈空间是几万层函数调用在默认的Linux栈大小通常8MB下可能还撑得住但如果有十万、百万级节点递归法就很容易栈溢出了。这种极端情况下迭代反转法是更稳妥的选择。当然对这道练习题来说链表规模很小重点还是吃透原理。4. 常见问题与排查技巧实录4.1 段错误最常见的运行时崩溃反向输出链表这道题新手最常见的失败方式是程序运行时直接段错误Segmentation fault终端报错进程崩溃。原因主要集中在两类。第一类malloc分配内存后没有给p-next赋NULL。malloc返回的内存内容是垃圾值如果你直接使用遍历链表时就会沿着一个不确定的next指针走下去访问到非法地址。判断方法很简单for (Node *p head; p ! NULL; p p-next)这样的循环根本不是按预期退出而是走到某个野地址后崩溃。排查的时候可以先把p-data和p-next的值都打印出来看到某个next是0x7f...这种堆地址之外或者干脆是int截断后的值基本就能锁定问题。第二类createList返回NULL但没有判断。比如malloc失败时我的代码返回NULL如果主函数不检查直接对NULL执行reversePrint(head)递归第一句if (head NULL)其实能兜住但如果在其他代码里对NULL解引用比如printf(%d, head-data)就必然崩溃。所以建议在main里分配完链表之后立即加上if (head NULL) return 1。排查段错误最有效的工具是gdb。编译的时候记得加-g参数gcc -g -o list_reverse list_reverse.c gdb ./list_reverse进入gdb后输入run程序崩溃时会自动停在出错的那一行接着输入btbacktrace查看调用栈你就能看到崩溃发生在哪个函数、哪一行。比如它停在第40行的printf(%d, head-data)再结合局部变量的值一眼就能判断出是不是链表已经断了。4.2 内存泄漏free的正确姿势很多练习代码根本不释放内存原因是程序退出后操作系统会回收。这句话在练习阶段没错但如果你养成了不释放的习惯去做真正的项目内存泄漏是会被线上事故吊打的问题。这道题虽然简单但正好可以用来练习free的正确姿势。释放链表必须一个一个节点释放不能只free(head)。因为每个节点的内存都是独立malloc出来的必须分别free。核心代码是void freeList(Node *head) { Node *p head; while (p ! NULL) { Node *tmp p-next; free(p); p tmp; } }这里必须先用tmp保存p-next再free(p)。如果先free(p)你再用p-next去取下一个节点就是使用已释放的内存属于未定义行为。这个顺序问题相当隐蔽我在初学的时候就犯过错调试了很久才发现是释放顺序导致链表断掉了。检测内存泄漏可以用valgrind这是Linux下很实用的工具valgrind --leak-checkfull ./list_reverse如果代码里所有的malloc都配了对应的freevalgrind会输出All heap blocks were freed -- no leaks are possible看着很清爽。如果漏了free它会列出你在createList里哪一行malloc的内存没有被释放。我建议写完链表相关代码都跑一遍valgrind锻炼自己的内存意识。4.3 递归爆栈链表很长怎么办递归法有个天然的短板调用深度等于链表长度。假设链表的长度是一万个节点reversePrint就要嵌套调用一万层函数每一层都要在栈上分配一个栈帧存参数、存返回地址、存局部变量。如果链表长度继续增大比如百万级栈空间耗尽就会导致栈溢出程序崩溃。此时迭代反转法是更好的选择。不过对于这道菜鸟教程的练习题链表长度通常是个位数到几十个节点递归完全够用。我提这一点是想让你知道递归的使用边界同时理解为什么很多实际工程代码里递归并不总是第一选择。如果一定要用递归又担心栈溢出可以退一步使用显式栈。用一个数组或者链表模拟栈的行为手动压入每个节点然后依次弹出打印。思路和递归一模一样只是不消耗系统调用栈而是用你管理的内存空间。这种用显式栈代替系统调用栈的思路在后续学习二叉树遍历的时候也会反复用到。4.4 排查技巧速查表最后整理一份我在练习链表题时使用的排查清单遇到问题按顺序核查大部分情况都能快速定位。现象可能原因检查方法程序段错误p-next未初始化打印节点地址和next值程序段错误malloc失败未检查在malloc后立即判断p NULL程序段错误缺少#include stdlib.h看编译警告打开-Wall重新编译输出乱序用了头插法创建链表检查createList里插入逻辑死循环链表成环快慢指针检测或打印路径只输出部分节点释放顺序错误检查freeList里的tmp保存递归无法终止递归出口条件写错检查if (head NULL) return;关于编译警告我补充一句初学者一定要习惯用-Wall和-Wextra编译。比如缺头文件、类型不匹配这类隐患编译器会给出警告但你默认忽略的话这些小隐患就会变成运行时的段错误。我通常用gcc -Wall -Wextra -g -o list_reverse list_reverse.c一点多余的成本换来的却是提前暴露问题的机会这笔账怎么算都划算。平时练习就把这些选项当成习惯真正上项目的时候才不会被各种低级问题打得措手不及。这道题我至少写过五遍。第一遍照着参考答案抄似懂非懂第二遍自己默写在freeList上栽了跟头第三遍改成迭代反转法才真正理解了next curr-next; curr-next prev这个三指针联动的过程第四遍、第五遍分别用显式栈和辅助数组重写了一遍。训练多了之后我最大的感觉是第73题的价值不在于会做这题本身而在于它逼着你把链表、指针、递归这三座大山连成一条线翻过去。后面学二叉树、学图的遍历时你会发现到处都是这道题的影子——递归处理子结构、逆序打印、栈的运用全都似曾相识。如果你正在练这一题别急着看答案先把1-2-3-4-5在纸上画出来再试着口述递归的执行过程然后动手实现。这样练完一遍你的收获会远超写一百道循环题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑