写在前面
承接上篇的三道基石题,最近继续刷了四道链表与数组的高频题,从数组原地合并到链表反转、归并、分割,核心都围绕双指针思想展开,但每道题都踩了实打实的坑。这篇把我的最终写法、踩过的真实坑点、以及其他可行思路一起整理出来,既是个人复盘,也做思路拓展。
上篇回顾:单链表专题(应用篇):三道经典题吃透删除、反转与快慢指针
本文章代码仓库位置(具体说明和使用方法在模块六仓库说明)
数据结构/单链表专题二(刷题复盘篇):四道经典题踩坑记录+多思路对比 · Luminous/Code_2026 - 码云 - 开源中国
一、合并两个有序数组(LeetCode 88)
题目描述
给你两个按非递减顺序排列的整数数组nums1和nums2,另有两个整数m和n,分别表示nums1和 nums2中的元素数目。请你合并nums2到nums1中,使合并后的数组同样按非递减顺序排列。
注意:nums1的初始长度为m+n,前m个是有效元素,后n个为0占位;最终结果直接存放在nums1 中,不需要返回值。
示例:输入:nums1 = [1,2,3,0,0,0], m=3 , nums2 = [2,5,6] ,n = 3输出:[1,2,2,3,5,6]
题意分析
这道题本质是数组版的归并排序合并步骤,但约束很明确:必须原地合并,不能开额外结果数组。 最开始我本能照搬链表归并的思路,想从前向后双指针遍历,很快就发现问题:nums1前面的有效元素会被写入的值覆盖,数据直接丢失。所以最优解一定是从后往前倒着填,利用尾部的空闲空间,谁大放谁,完全不会影响前面的有效数据。
我的写法:三指针从后向前归并
用三个下标分别指向nums1有效末尾、nums2末尾、nums1整体末尾,循环比较取大值写入,最后单独处理nums2的剩余元素。
void merge(int* nums1, int nums1Size, int m, int* nums2, int nums2Size, int n) {
int l1 = m - 1;
int l2 = n - 1;
int l3 = m + n - 1;
while((l1 >= 0) && (l2 >= 0))
{
if(nums1[l1] < nums2[l2])
{
nums1[l3] = nums2[l2];
l3--;
l2--;
}
else
{
nums1[l3] = nums1[l1];
l1--;
l3--;
}
}
while (l2 >= 0)
{
nums1[l3] = nums2[l2];
l2--;
l3--;
}
}
我踩过的坑
- 思维惯性坑:一开始直接套链表归并的“从前向后双指针”,写了两行才反应过来数组是连续内存,向前写会覆盖未读取的有效数据,这也是数组和链表归并最核心的区别。
- 多余操作误区:循环结束后还补了一段处理l1剩余的循环,写完才反应过来:nums1剩下的元素本来就在数组前部,本身就在正确位置,完全不需要额外移动。
- 边界忽略:一开始没特意考虑m=0的场景,也就是nums1全是占位0、所有元素都要从 nums2迁入;幸好第二个while循环天然覆盖了这个情况,没有出问题。
其他可行思路
- 暴力排序法:把nums2的元素全部复制到nums1后半段的空位,直接对整个nums1做一次排序。思路零门槛,完全不用设计指针逻辑,缺点是时间复杂度高,属于“想不出最优解时的保底写法”。
- 辅助数组法:新开一个长度为m+n的临时数组,两个指针从前向后正常归并写入,最后再整体拷贝回nums1。逻辑和链表归并完全一致,最好理解,缺点是占用额外空间,不符合原地合并的进阶要求。
二、反转链表(LCR 024)
题目描述
给你单链表的头节点head,请你反转链表,并返回反转后的链表头节点。
提示:
- 链表中节点的数目范围是
[0, 5000] -5000 <= Node.val <= 5000

题意分析
这是链表题的“基本功天花板”,后续很多进阶题都会把反转当作子步骤调用。核心本质就是把每个节点的next指针,从指向后继改为指向前驱。我选了最稳妥的迭代双指针写法,全程原地修改指针,空间复杂度O(1),也没有递归栈溢出的风险。
我的写法:双指针迭代原地反转
用前驱指针和当前指针逐步推进,每次修改指向前先保存后继节点,避免断链。
typedef struct ListNode ListNode;
struct ListNode* reverseList(struct ListNode* head){
ListNode* pcur = head;
ListNode* next;
ListNode* store = NULL;
while(pcur != NULL)
{
next = pcur->next;
pcur->next = store;
store = pcur;
pcur = next;
}
return store;
}
我踩过的坑
- 经典断链坑:最开始写的时候手快,先改了
pcur->next再去取下一个节点,直接把后半段链表弄丢了,遍历当场中断。链表操作铁则:改next之前,一定先存好后继节点。 - 返回值搞反:第一次写完下意识返回了pcur,结果直接返回空。循环结束时pcur已经走到了NULL,store才停在原链表最后一个节点,也就是反转后的新头。
- 认知误区:一开始以为反转要新建节点、拷贝数值,后来才明白只需要改指针指向,节点本身完全可以复用,空间开销直接从O(n)降到O(1)。
其他可行思路
- 递归反转法:核心思想是“先递归反转后面的子链表,再把当前节点接到子链表的尾部”。代码写出来非常精简,不需要手动管理多个指针,但理解门槛更高,链表过长时还会有递归栈溢出的风险。
- 新链表头插法:遍历原链表的每个节点,每次都把当前节点插到新链表的最头部,遍历完成后天然就是反转后的链表。和迭代法本质原理一致,只是表述和操作视角不同。
三、合并两个有序链表(LeetCode 21)
题目描述
将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
提示:
- 两个链表的节点数目范围是
[0, 50] -100 <= Node.val <= 100l1和l2均按非递减顺序排列

题意分析
这就是链表版的归并合并,和第88题是同一套思想,区别只在于数组操作下标、链表操作指针。我采用的是虚拟头节点+尾插法,也是个人认为最不容易写错、边界最少的写法。
我的写法:虚拟头+双指针尾插
新建一个哨兵节点当临时头,游走指针负责尾部拼接,两个链表同步遍历,谁小接谁,最后接上剩余部分。
typedef struct ListNode ListNode;
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2)
{
ListNode* head1 = list1;
ListNode* head2 = list2;
ListNode* dummy = (ListNode*)malloc(sizeof(ListNode));
ListNode* cur = dummy;
while((head1 != NULL) && (head2 != NULL))
{
if(head1->val < head2->val)
{
cur->next = head1;
head1 = head1->next;
}
else
{
cur->next = head2;
head2 = head2->next;
}
cur = cur->next;
}
cur->next = head1 != NULL ? head1 : head2;
ListNode* Res = dummy->next;
free(dummy);
return Res;
}
我踩过的坑
- 致命低级坑:第一次写完返回了
cur->next而不是dummy->next。cur到最后已经走到了链表尾部,cur->next只是后半段剩余节点,前面的所有节点直接全部丢失,这个bug排查了很久。记住:虚拟头的next才是新链表真正的头。 - 内存困惑:纠结过malloc的哨兵要不要free、free后要不要把指针置NULL。刷题场景下不 free也能AC,但工程代码必须配对释放;至于free后置NULL,因为函数马上就要返回、局部变量立刻销毁,这里置空属于多余操作,没有实际意义。
- 走弯路:最开始想新建节点拷贝val来拼接,后来发现完全可以直接复用原链表节点,只改 next指针,不用额外申请业务节点。
其他可行思路
- 递归合并法:每次比较两个链表的头节点,把值更小的作为结果头,它的next指向剩下两个链表的递归合并结果。不用虚拟头,代码极精简,但同样有递归栈开销,链表很长时不推荐。
- 无虚拟头直接拼接:先单独比较选出新的头节点,再用指针向后遍历逐个拼接。省掉了一个哨兵节点的malloc,但要单独处理头节点的边界情况,代码分支更多,反而更容易写错。
四、分割链表(面试题 02.04)
题目描述
给你一个链表的头节点head和一个特定值x,请你对链表进行分隔,使得所有小于x的节点都出现在大于或等于x的节点之前。不需要保留每个分区中各节点的初始相对位置。
提示:
- 链表中节点的数目在范围
[0, 200]内 -100 <= Node.val <= 100-200 <= x <= 200

题意分析
这道题是我踩坑最多的一道,从审题到指针操作再到边界处理,踩了个遍。核心思路很直观:一条链存小于x的节点,一条链存大于等于x的节点,遍历一遍分组,最后把两条链拼起来。
我的写法:双虚拟头+尾插分组
两个哨兵分别管理两部分链表,尾插法逐个接入节点,最后拼接并切断大链表尾部防止成环。
typedef struct ListNode ListNode;
struct ListNode* partition(struct ListNode* head, int x)
{
ListNode* dummy_small = (ListNode*)malloc(sizeof(ListNode));
ListNode* dummy_large = (ListNode*)malloc(sizeof(ListNode));
dummy_small->next = NULL;
dummy_large->next = NULL;
ListNode* cur_small = dummy_small;
ListNode* cur_large = dummy_large;
ListNode* cur = head;
while(cur != NULL)
{
if(cur->val < x)
{
cur_small->next = cur;
cur_small = cur_small->next;
}
else
{
cur_large->next = cur;
cur_large = cur_large->next;
}
cur = cur->next;
}
cur_large->next = NULL;
cur_small->next = dummy_large->next;
ListNode* res = dummy_small->next;
free(dummy_small);
free(dummy_large);
return res;
}
我踩过的坑
- 审题自加需求:一开始脑补成“小于x在左、等于x在中间、大于x在右”,自己给自己加戏,差点写成三条链表分组。实际题目只要求两部分,等于x直接归到右边即可。
- 野指针崩溃:最早的错误写法是给节点val赋值,还写了
head1=Lis1->next,但Lis1->next 从来没赋值过,全是内存垃圾值,一运行直接崩溃。正确做法是直接接入原节点:cur->next= 当前节点,然后cur后移。 - 链表成环坑:一开始漏掉了大链表尾部置NULL。原节点都带着旧的next指针,如果最后一个大节点原本指向某个小节点,拼接完成后链表会成环,判题直接超时、内存超限。
- 初始化隐患:malloc出来的哨兵节点,next是随机垃圾值,虽然代码后续会覆盖写入、侥幸能跑通,但本质属于未定义行为。严谨起见一定要手动初始化为NULL。
- 命名混乱坑:最开始变量名起成head11、head22、Lis1、Lis2,写着写着自己就搞混了哪个是哨兵、哪个是游走指针。改成见名知意的命名后,逻辑瞬间清晰了很多。
其他可行思路
- 头插法分组:遍历到小于x的节点就头插到小链表头部,大于等于的头插到大连表头部。优点是不用维护尾指针,写完直接得到头节点;缺点是会颠倒分区内节点的相对顺序,题目不要求保序时可以使用。
- 三段式分割:分成小于、等于、大于三条独立链表,最后按“小→中→大”的顺序拼接。就是我最开始脑补的“x放中间”的效果,属于拓展变种,只有题目明确要求时才需要用到。
五、刷题感悟与方法论总结
1. 思路朴素不代表效率低
最开始觉得双虚拟头尾插法思路太直白,不够“巧妙”,结果提交后击败了100%的用户。原因很现实:头插法虽然省了两个哨兵,但拼接时要额外遍历一次找尾巴,实际常数更大;尾插法一遍遍历全部搞定,实测运行反而更快。算法不是越花哨越好,常数小、边界少、不容易写错的写法,就是面试和刷题中的好写法。
2. 链表题通用避坑口诀
- 修改next前先存后继,防止断链
- 头节点不确定就上虚拟头,少处理边界
- 拆分重组链表后,尾节点一定要手动置NULL,防止成环
- malloc出来的节点,成员不会自动为NULL,手动初始化更稳妥
3. 审题永远是第一步
别像我一样自己给题目加需求,“等于x放中间”完全是脑补出来的条件。先把题意抠准、边界看清,再动手写代码,不然写半天都是无用功。
六、仓库说明
ProjectName
├─ 题目1
│ ├─ source # 源代码文件夹
│ │ ├─ func.h # 头文件
│ │ ├─ test.c
│ │ └─ blog_demo.c
│ └─ exe # 编译好的可运行程序文件夹
│ └─ main.exe # Windows可执行程序
├─ 题目2
│ ├─ source
│ │ ├─ func.h
│ │ ├─ test.c
│ │ └─ blog_demo.c
│ └─ exe
│ └─ main.exe
├─ 题目3
│ ├─ source
│ │ ├─ func.h
│ │ ├─ test.c
│ │ └─ blog_demo.c
│ └─ exe
│ └─ main.exe
└─ 题目4
├─ source
│ ├─ func.h
│ ├─ test.c
│ └─ blog_demo.c
└─ exe
└─ main.exe
Gitee 仓库:数据结构/单链表专题二(刷题复盘篇):四道经典题踩坑记录+多思路对比 · Luminous/Code_2026 - 码云 - 开源中国
仓库包含 4 道编程题目,每题独立文件夹,无多余垃圾文件。 每个题目下有两个子文件夹:
- source:源码目录
func.h:头文件,存放函数声明、结构体、宏定义test.c:程序功能测试文件blog_demo.c:博客文章配套演示源码
- exe:编译输出目录
main.exe:Windows 已编译可执行文件,双击直接运行
使用方法
- 看博客示例代码:
题目x/source/blog_demo.c - 功能测试:编译运行
test.c - 直接运行程序:exe 目录双击
main.exe - 重编译:GCC / Dev‑C++ / VS 编译源码生成新 exe
写在最后
这四道题刷下来,最大的感受是:链表题的核心永远是指针指向的管理,套路其实就那几种,多踩几次坑、多画几遍指针图,慢慢就会建立起直觉。下一篇继续刷快慢指针的进阶题型——环形链表、相交链表、倒数第k个节点,把龟兔赛跑算法彻底吃透。
-刷题复盘篇:四道经典题踩坑记录+多思路对比&spm=1001.2101.3001.5002&articleId=163514341&d=1&t=3&u=8d5e3d4205644dab9d60015825e9e24a)
336

被折叠的 条评论
为什么被折叠?



