单链表专题(二)-刷题复盘篇:四道经典题踩坑记录+多思路对比

写在前面

承接上篇的三道基石题,最近继续刷了四道链表与数组的高频题,从数组原地合并到链表反转、归并、分割,核心都围绕双指针思想展开,但每道题都踩了实打实的坑。这篇把我的最终写法、踩过的真实坑点、以及其他可行思路一起整理出来,既是个人复盘,也做思路拓展。

上篇回顾:单链表专题(应用篇):三道经典题吃透删除、反转与快慢指针

本文章代码仓库位置(具体说明和使用方法在模块六仓库说明)

数据结构/单链表专题二(刷题复盘篇):四道经典题踩坑记录+多思路对比 · Luminous/Code_2026 - 码云 - 开源中国


一、合并两个有序数组(LeetCode 88)

题目描述

给你两个按非递减顺序排列的整数数组nums1nums2,另有两个整数mn,分别表示nums1nums2中的元素数目。请你合并nums2nums1中,使合并后的数组同样按非递减顺序排列。

注意: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--;
    }
}

我踩过的坑

  1. 思维惯性坑:一开始直接套链表归并的“从前向后双指针”,写了两行才反应过来数组是连续内存,向前写会覆盖未读取的有效数据,这也是数组和链表归并最核心的区别。
  2. 多余操作误区:循环结束后还补了一段处理l1剩余的循环,写完才反应过来:nums1剩下的元素本来就在数组前部,本身就在正确位置,完全不需要额外移动。
  3. 边界忽略:一开始没特意考虑m=0的场景,也就是nums1全是占位0、所有元素都要从 nums2迁入;幸好第二个while循环天然覆盖了这个情况,没有出问题。

其他可行思路

  1. 暴力排序法:把nums2的元素全部复制到nums1后半段的空位,直接对整个nums1做一次排序。思路零门槛,完全不用设计指针逻辑,缺点是时间复杂度高,属于“想不出最优解时的保底写法”。
  2. 辅助数组法:新开一个长度为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;
}

我踩过的坑

  1. 经典断链坑:最开始写的时候手快,先改了pcur->next再去取下一个节点,直接把后半段链表弄丢了,遍历当场中断。链表操作铁则:改next之前,一定先存好后继节点。
  2. 返回值搞反:第一次写完下意识返回了pcur,结果直接返回空。循环结束时pcur已经走到了NULL,store才停在原链表最后一个节点,也就是反转后的新头。
  3. 认知误区:一开始以为反转要新建节点、拷贝数值,后来才明白只需要改指针指向,节点本身完全可以复用,空间开销直接从O(n)降到O(1)。

其他可行思路

  1. 递归反转法:核心思想是“先递归反转后面的子链表,再把当前节点接到子链表的尾部”。代码写出来非常精简,不需要手动管理多个指针,但理解门槛更高,链表过长时还会有递归栈溢出的风险。
  2. 新链表头插法:遍历原链表的每个节点,每次都把当前节点插到新链表的最头部,遍历完成后天然就是反转后的链表。和迭代法本质原理一致,只是表述和操作视角不同。

三、合并两个有序链表(LeetCode 21)

题目描述

将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

提示:

  • 两个链表的节点数目范围是 [0, 50]
  • -100 <= Node.val <= 100
  • l1 和 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;
}

我踩过的坑

  1. 致命低级坑:第一次写完返回了cur->next而不是dummy->next。cur到最后已经走到了链表尾部,cur->next只是后半段剩余节点,前面的所有节点直接全部丢失,这个bug排查了很久。记住:虚拟头的next才是新链表真正的头。
  2. 内存困惑:纠结过malloc的哨兵要不要free、free后要不要把指针置NULL。刷题场景下不 free也能AC,但工程代码必须配对释放;至于free后置NULL,因为函数马上就要返回、局部变量立刻销毁,这里置空属于多余操作,没有实际意义。
  3. 走弯路:最开始想新建节点拷贝val来拼接,后来发现完全可以直接复用原链表节点,只改 next指针,不用额外申请业务节点。

其他可行思路

  1. 递归合并法:每次比较两个链表的头节点,把值更小的作为结果头,它的next指向剩下两个链表的递归合并结果。不用虚拟头,代码极精简,但同样有递归栈开销,链表很长时不推荐。
  2. 无虚拟头直接拼接:先单独比较选出新的头节点,再用指针向后遍历逐个拼接。省掉了一个哨兵节点的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;
}

我踩过的坑

  1. 审题自加需求:一开始脑补成“小于x在左、等于x在中间、大于x在右”,自己给自己加戏,差点写成三条链表分组。实际题目只要求两部分,等于x直接归到右边即可。
  2. 野指针崩溃:最早的错误写法是给节点val赋值,还写了head1=Lis1->next,但Lis1->next 从来没赋值过,全是内存垃圾值,一运行直接崩溃。正确做法是直接接入原节点:cur->next= 当前节点,然后cur后移。
  3. 链表成环坑:一开始漏掉了大链表尾部置NULL。原节点都带着旧的next指针,如果最后一个大节点原本指向某个小节点,拼接完成后链表会成环,判题直接超时、内存超限。
  4. 初始化隐患:malloc出来的哨兵节点,next是随机垃圾值,虽然代码后续会覆盖写入、侥幸能跑通,但本质属于未定义行为。严谨起见一定要手动初始化为NULL。
  5. 命名混乱坑:最开始变量名起成head11、head22、Lis1、Lis2,写着写着自己就搞混了哪个是哨兵、哪个是游走指针。改成见名知意的命名后,逻辑瞬间清晰了很多。

其他可行思路

  1. 头插法分组:遍历到小于x的节点就头插到小链表头部,大于等于的头插到大连表头部。优点是不用维护尾指针,写完直接得到头节点;缺点是会颠倒分区内节点的相对顺序,题目不要求保序时可以使用。
  2. 三段式分割:分成小于、等于、大于三条独立链表,最后按“小→中→大”的顺序拼接。就是我最开始脑补的“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个节点,把龟兔赛跑算法彻底吃透。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值