☰
反转链表三种解法详解:迭代、递归与头插法
2026/10/20 3:24:53 网站建设 项目流程

要说面试里出现频率最高的链表题,反转链表排第二,估计没谁敢排第一。我当年准备校招的时候,第一次看到这道题,内心是有点懵的——明明代码就那么几行,为什么一上手不是段错误就是死循环?后来刷多了才明白,这道题考察的根本不是“能不能背出答案”,而是你是不是真的理解指针是怎么把一个节点串起来的,以及改动指针方向时,哪些瞬间会丢节点。

这篇就用C语言把剑指 Offer 24 讲透。核心内容包含三种解法——迭代法、递归法、头插法——以及我在本地调试时踩过的各种边界条件的坑。不管你是刚接触链表的小白,还是正在冲刺面试的求职者,这篇文章的目标只有一个:让你看完之后,能独立手写出这段代码,并且敢在面试官面前讲清楚每一步在干什么。

1. 为什么面试官都爱考这道题:一次考察三种底层能力

很多新手不理解,反转链表这么“简单”的题,为什么能成为剑指 Offer 的高频题。我在准备面试阶段刷了上百道题之后,最大的体会是:这道题表面上考的是链表翻转,实际上考的是三件更底层的本事——指针操作的手感、边界条件的敏感度、递归思想的熟练度。这三个能力恰恰是做任何 C/C++ 项目都绕不开的基本功。

1.1 链表结构体定义与基本打印逻辑

先把最基础的准备做扎实。C语言里单链表的节点定义几乎是固定的,面试时直接默写:

struct ListNode { int val; struct ListNode *next; };

光有节点还不够,我习惯先在本地把“打印链表”这个辅助函数写好。别小看这个动作,调试链表题时,你每次都靠它确认反转结果对不对,省下的时间远比写的几分钟多。

void printList(struct ListNode *head) { struct ListNode *cur = head; while (cur != NULL) { printf("%d ", cur->val); cur = cur->next; } printf("\n"); }

创建一条简单测试链表:

struct ListNode* createTestList() { struct ListNode *n1 = (struct ListNode*)malloc(sizeof(struct ListNode)); struct ListNode *n2 = (struct ListNode*)malloc(sizeof(struct ListNode)); struct ListNode *n3 = (struct ListNode*)malloc(sizeof(struct ListNode)); struct ListNode *n4 = (struct ListNode*)malloc(sizeof(struct ListNode)); n1->val = 1; n1->next = n2; n2->val = 2; n2->next = n3; n3->val = 3; n3->next = n4; n4->val = 4; n4->next = NULL; return n1; }

这段测试代码看起来很笨,但它对初学者的好处是:每个节点的内存都清清楚楚,你能直观看到指针之间是怎么连接的。很多搞不懂反转的人,不是不理解算法,而是根本不熟悉链表的“物理结构”。

1.2 这道题在面试题体系中的“地基”位置

反转链表之所以被反复考,还因为它是很多复杂链表题的基础零件。你不信的话可以想想这几道题:整个链表反转、反转链表的前N个节点、区间反转、K个一组反转链表、回文链表判断……这些题的核心思想,都建立在单链表反转这一个操作上。

拿K个一组反转来举例,它其实就是在链表上分段做反转,每段的内部反转方法和剑指 Offer 24一模一样。也就是说,如果你能把这个基础版写得既快又对,后面做进阶题时,你只需要把注意力放在“每一段的边界怎么衔接”,而不用再纠结反转本身怎么写。地基打得稳,往上盖楼才不慌。

我自己的建议是:练习时不要只满足于“调通了”,而是要把迭代法、递归法、头插法都写一遍,并且每个版本都讲得出“为什么这样写”。下面几个章节,我分别把三种解法拆开聊。

2. 迭代法:三个指针轮流“调头”,一步步把箭头掰过来

迭代法是我面试时最优先写的解法。理由很简单:时间复杂度 O(n)、空间复杂度 O(1),思路直白,不容易写崩。它本质上做的一件事是:遍历链表的过程中,逐个把每个节点的 next 指向前一个节点,同时用额外的指针保住“下一个要处理的节点”。

2.1 三指针思路与代码实现

核心操作可以概括成一句话:prev 指向当前节点的前一个,cur 指向当前节点,next 暂存 cur 原本的下一个节点。每轮循环,把 cur->next 改成 prev,然后三个指针集体往前挪一格。

struct ListNode* reverseList_iterative(struct ListNode* head) { struct ListNode *prev = NULL; struct ListNode *cur = head; struct ListNode *next = NULL; while (cur != NULL) { next = cur->next; // 先保存下一个节点,防止断链 cur->next = prev; // 当前节点掉头指向前一个 prev = cur; // prev 前移 cur = next; // cur 前移 } return prev; }

这里最需要注意的是next = cur->next;这一行。我第一次写的时候漏掉了它,直接把cur->next = prev写上去,结果链表从第一个节点开始就断掉了——因为原来 cur->next 指的那个节点,没有任何变量还指向它,谁也找不回去了。你可以把链表想象成一列手拉手的人,现在第一个人要回头牵住身后的人,那就必须先用另一只手拉住第二个人,否则第二个人就掉队了。

2.2 为什么 prev 初始化为 NULL 而不是 head

很多初学者会问:prev 为什么不初始化为 head?我先不直接回答,请你想象一下链表反转完之后,原来第一个节点的 next 应该指向谁?答案是 NULL,因为它变成了新的尾节点。所以 prev 一开始就是 NULL,当循环走到最后时,prev 恰好会停在原来的最后一个节点上,而且这个节点的 next 已经被修改为它的前一个节点,没有残留的野指针。

另外,prev 从 NULL 开始还能顺带处理一个特殊情况:如果传入的是一个空链表(head 本身是 NULL),循环一次都不执行,直接返回 prev(也就是 NULL)。这个写法干净利落地把空链表和单节点链表的情况一并解决了。单节点链表也是同理,cur->next 指向 NULL,反转后还是那一个节点,不会出问题。

2.3 本地完整测试:把算法跑起来才算学会

光看代码不运行,和没学区别不大。我把测试代码补全,你复制到本地就能看到效果:

int main() { struct ListNode *head = createTestList(); printf("反转前: "); printList(head); struct ListNode *reversed = reverseList_iterative(head); printf("反转后: "); printList(reversed); return 0; }

输出结果应该是:

反转前: 1 2 3 4 反转后: 4 3 2 1

我建议你把printList加进循环里,每反转一步打印一次当前链表状态。这样你能亲眼看到节点一个接一个“掉头”的过程,比单纯盯着脑内推演要直观得多。我当时就是这么干的,打印几次之后,再也没忘记过三指针的移动顺序。

2.4 一个常见的笔误:cur 和 next 的赋值顺序

我见过不少人(包括我自己早期)把循环体写成了这样:

next = cur->next; cur->next = prev; next = cur; // 笔误,应该是 prev = cur prev = next; // 笔误,顺序乱了

这种错的离谱的写法,表面上看起来逻辑很“像”,跑起来却会得到完全不同的结果,甚至死循环。我的经验是:三指针移动的时候,眼睛要盯着“谁应该成为新的 prev,谁应该成为新的 cur”。记住一个口诀:先断后接,再人齐步走——先处理 cur->next 的指向,再用 next 带着 cur 往前走,prev 紧跟其后。

3. 递归法:让指针在“原路返回”的过程中回头

递归解法在代码量上比迭代法还要短,但理解门槛高不少。很多教程直接甩代码,然后说“这个递归压栈再弹栈就好了”,听懂了也只是似懂非懂。这一节我会用两种递归写法来拆,重点讲清楚每一步回溯时到底发生了什么。

3.1 理解递归的关键:先递到链尾,再归中改指向

先看最经典的递归写法:

struct ListNode* reverseList_recursive(struct ListNode* head) { if (head == NULL || head->next == NULL) { return head; } struct ListNode *newHead = reverseList_recursive(head->next); head->next->next = head; head->next = NULL; return newHead; }

这段代码的核心逻辑只有两行:

  • head->next->next = head;是把当前节点的下一个节点的 next 指回当前节点,也就是完成一次“反向钩连”。
  • head->next = NULL;是为了让当前节点当前处于链表尾部(相对回溯过程而言)时,不再指向原来的下一个节点,防止形成环。

许多人的困惑在于:“newHead 到底是从哪来的?”答案来自递归的终止条件:当递归递到链表的最后一个节点时,head->next == NULL,直接返回这个节点。这个节点就是整个链表反转后的新头节点。它会被一层层原封不动地传回去,所以代码里return newHead;是一条“接力棒”路径,不是在每层生成新东西。

3.2 一条四节点链表的手推全过程

我们拿 1→2→3→4 来走一遍:

  • reverseList_recursive(1) 进入,head=1,1->next 不是 NULL,调用 reverseList_recursive(2)
  • reverseList_recursive(2) 进入,head=2,2->next 不是 NULL,调用 reverseList_recursive(3)
  • reverseList_recursive(3) 进入,head=3,3->next 不是 NULL,调用 reverseList_recursive(4)
  • reverseList_recursive(4) 进入,head=4,4->next 是 NULL,返回 4

到这里“递”的过程结束,开始“归”的过程:

  • 回到调用者 head=3 的那一层,head->next是 4,执行4->next = 3,再把3->next = NULL,返回 newHead=4
  • 回到调用者 head=2 的那一层,此时 3 的 next 已经变成 NULL,但 head->next 仍然指向 3,所以执行3->next = 2,再把2->next = NULL,返回 newHead=4
  • 回到调用者 head=1 的那一层,此时 2 的 next 已经是 NULL,但 head->next 仍然指向 2,执行2->next = 1,再把1->next = NULL,返回 newHead=4

最后从函数外面拿到的 newHead 就是节点 4,从 4 出发依次是 3、2、1,反转完成。

这个过程我建议你也亲手在纸上画一遍,箭头改来改去的样子很像在做手工折纸——每次把折好的方向调转一次。画过一遍之后,你对递归的理解会比背十遍代码都深刻。

3.3 递归解法版本二:自顶向下,返回值携带新头

还有另一种递归写法,它的思路是“把当前节点放到已反转链表的头部”,方向是从前往后处理。这种写法稍微绕一点,但对理解递归返回值很有帮助:

struct ListNode* reverseList_recursive2(struct ListNode* head) { return reverseHelper(head, NULL); } struct ListNode* reverseHelper(struct ListNode* cur, struct ListNode* prev) { if (cur == NULL) { return prev; } struct ListNode *next = cur->next; cur->next = prev; return reverseHelper(next, cur); }

这个版本本质上是用递归的形式做了迭代法的事情:把 prev 作为参数一路传递下去,当前节点 cur 的 next 直接改为 prev,然后递归处理原来的 next 节点。它避免了显式写循环,但又和迭代法的思路完全一致。面试时,如果你的递归版写得够熟练,也可以顺带提一句这个自顶向下的写法,展示你对递归和迭代关系的理解。

3.4 递归和迭代怎么选:一个务实的判断标准

我自己的选择标准很朴素:如果只是解这道题,用迭代;如果聊到递归思想,用版本一;如果想展示对“递归携带状态”的掌握,可以用版本二。

但必须提醒你一个实际问题:递归版本在链表很长的时候会有风险。C语言的栈空间有限,链表长度达到几十万甚至上百万节点时,递归深度过大会导致栈溢出。所以面试场景下,如果题目没有特别说明“允许递归”,我建议默认先给迭代解,再把递归解作为补充方案讲解。这样既保证了代码的健壮性,又展示了你对两种思路都吃得透。

4. 头插法与边界条件:从 Dummy Node 到空链表,所有坑一次排完

第三类常见解法是“头插法”。它的思路是:新建一个哑巴节点(dummy node)作为哨兵,遍历原链表,把每个节点拆下来,插入到 dummy 节点之后。最终,dummy->next 就是反转后的新链表头。

4.1 头插法代码实现

struct ListNode* reverseList_headInsert(struct ListNode* head) { struct ListNode dummy; dummy.next = NULL; struct ListNode *cur = head; while (cur != NULL) { struct ListNode *next = cur->next; // 暂存剩余链表 cur->next = dummy.next; // 当前节点指向新链表头 dummy.next = cur; // 更新新链表头为当前节点 cur = next; // 继续处理剩余链表 } return dummy.next; }

注意这里的dummy我是直接定义在栈上的结构体变量,而不是用 malloc 分配。原因很简单:这个节点的唯一作用是提供“插入位置的前驱”,不需要长期存活,用完即弃。用栈上变量还能省掉手动 free 的麻烦,代码也更干净。如果你喜欢,也可以用 malloc 分配一个struct ListNode *dummy = (struct ListNode *)malloc(sizeof(struct ListNode));,但记得在返回前 free 掉,否则就有内存泄漏。

4.2 头插法的适用场景:理解它的“原型”反而更好进阶

说实话,单就这道题而言,头插法不是主流答案。但理解头插法,对后续很多题目帮助很大。举个例子,经典的两两交换链表节点、重排链表等问题,都需要“从原链表拆除一个节点,再插到另一个位置”的能力。头插法练熟了,你会形成一种“拆装节点”的肌肉记忆——先存 next,再修改指针,再移动 cur。

而且头插法有一个迭代法不具备的心智模型:它在持续构建“一段已经反转好的新链表”,新链表的头始终在 dummy->next。这种“边遍历边构建新链表”的思维,也是链表排序题(如插入排序)的底层逻辑。所以即使不常用,我也强烈建议你写一遍。

4.3 边界条件清单:空链表、单节点、双节点、大链表

我最近在做题目复盘时,把边界条件整理成了一个小清单,每次写完反转代码都用它来自检:

场景预期结果容易踩的坑
空链表 head=NULL返回 NULL循环不会执行,注意返回值别写成野指针
单节点链表返回原节点确保该节点的 next 变成 NULL
双节点链表返回原第二个节点注意原头节点的 next 必须置空
长链表全部倒序防止递归版本栈溢出,优先用迭代
链表有环(理论上不会有)死循环可思考该算法为什么能检测出环(其实不能)

最后一行“链表有环”虽然在题目约定下不会出现,但我在实际面试追问环节被问过“如果链表带环会怎样”。我当时答得不够好,这里提醒你:迭代法面对有环节点时,cur 永远不会走到 NULL,循环不会结束。所以在真实工程中,如果你不确定链表是否干净,先做环检测再说。

4.4 内存管理的细节:C语言版本绝不能漏掉的问题

这道题用 C 语言写还有一个需要留意的点:反转操作本身不改变节点的数量,也不会新分配节点。所以在纯反转场景下,你不需要考虑 free 每个节点。但如果你的测试代码里像上面的 createTestList 一样用 malloc 建了一批节点,那在测试结束后,最好把它们都 free 掉,避免内存泄漏。

调试阶段的习惯也很重要:printList之后,遍历完链表,注意别对已经 free 的节点再访问。我曾经因为先 free 再 print 的顺序搞反,直接触发了“use after free”,排查了半天才反应过来。后来我把辅助函数都统一成“先打印、后释放”的顺序,再也没出过这类问题。

5. 面试现场的完整应对思路:从白板画图到 Bug 复现

算法题刷得再多,面试时如果表达不清楚,效果都会打折扣。我在经历了几轮真正的技术面之后,最大的感受是:面试官真正想看的,不是答案,而是你思考问题的路径。所以反转链表这道题,准备的不应该是“背代码”,而是一套可以现场展示的解题流程。

5.1 动笔之前的固定动作:先画链表,再标指针

我每次面试遇到链表题,都会先画一个至少包含三到四个节点的链表图,节点用方框表示,next 用箭头表示。然后在这条链表上方标出三个位置:prev、cur、next。接着逐步演示第一轮循环怎么改箭头方向,第二轮怎么移动指针。

这个动作看似浪费时间,实际上有双倍收益:一方面帮你自己理清思路,减少写代码时的手忙脚乱;另一方面让面试官看到你是“有结构地思考”而不是“硬背答案”。我遇到好几个面试官在我画图时频频点头,后面追问的问题也明显友善了很多。

5.2 一套可以现场复现的自测用例

写完代码之后,千万别直接说“写完了”。我在最后一次找工作时学到的是:主动提出“跑几个用例验证一下”。面试官通常会让你口头走一遍,你可以按这个顺序来:

  1. 空链表:head 是 NULL,循环体不执行,直接返回 NULL。
  2. 单节点:一系列 1→NULL,循环执行一次,返回原节点。
  3. 普通链表:1→2→3,走完三轮。

如果面试官让你在白板上写完整的 main 测试,也完全不用慌,把 createTestList 和 printList 写出来就行。能当场写出测试代码,是很加分的行为,因为这表明你不只会写算法,还知道怎么验证结果。

5.3 进阶追问的延伸思考:反转前 K 个节点与区间反转

面试官很可能在你写出基础反转后,追问一句:“那反转前 K 个节点你会吗?”这个问题其实是剑指 Offer 24 的直接变体。

思路和迭代法一致,只是只循环 K 次,反转前 K 个节点。反转完成后,原来的第 K+1 个节点需要接在链表末尾,所以代码里要保存一下原链表的“后续部分”:

struct ListNode* reverseK(struct ListNode* head, int k) { struct ListNode *prev = NULL; struct ListNode *cur = head; struct ListNode *next = NULL; for (int i = 0; i < k && cur != NULL; i++) { next = cur->next; cur->next = prev; prev = cur; cur = next; } // 此时 head 是反转后的尾节点,它的 next 应接上 cur if (head != NULL) { head->next = cur; } return prev; }

然后,如果把这个基础版再升级成区间反转(比如反转第 m 到第 n 个节点),思路就是先找到前驱节点,然后对区间内部做同样的反转,再把前驱和区间尾接好。你会发现,所有升级版的核心零件,还是今天这道题。这也就是我开头说的“地基”含义。

5.4 我自己刷这道题的真实体会

回头来看,反转链表是我刷题阶段“从背答案到懂答案”的转折点。刚开始我只能默写迭代法,面试被问“为什么 next 要先保存”时,我只会说“怕断链”,但根本讲不清断链的瞬间发生了什么。后来我强迫自己把递归版也手推三遍,又在本地把三种解法全部跑通并打印每一步,之后遇到任何链表反转变体,我都不慌。

这里也分享一个小技巧:学链表题时不要只用眼睛看,一定要动手打印。我见过许多人连printList都不会写就来刷链表题,效率低很多。先把辅助函数准备好的习惯养成,后面刷 K 个一组反转、回文链表时都不会吃亏。

另外我也想说,面试时如果一时紧张忘了代码,不妨先从画图开始。把箭头一个个掰过去,代码自然就顺着画出来了。这个动作我已经重复无数遍,每次仍然管用。

反转链表这道题,说难不难,说容易也不容易。真正把它吃透的标准不是“能复制答案”,而是“指针在每一轮循环里指向谁、下一步怎么走、为什么不会丢节点”都能脱口而出。做到这一点之后,你会发现不只是这道题,整个链表分类的题目,思路都通了不少。

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

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

立即咨询