1. 从数据结构说起:为什么链表操作是面试的必考项
说实话,面试刷题刷到一定阶段,你会发现链表题其实是性价比最高的一类题目。"合并两个有序链表"这道题在力扣热题100里排21位,但它背后牵出来的东西远不止一道题这么简单。我见过不少候选人,数组题写得飞起,一到链表就露怯,指针绕来绕去把自己绕晕了。原因也很简单:数组的操作是"改下标",思维模式是线性的;链表不一样,它是"改引用",你得在大脑里模拟节点之间的箭头的重新指向。
先把这个东西讲透。链表这种数据结构,本质上是把数据散落在内存的不同位置,然后通过每个节点里的next指针把它们"串"起来。所以链表的优势有两个:第一,插入和删除只需要改指针指向,不需要搬动其他元素,时间复杂度是O(1);第二,它不需要一块连续的内存空间,对内存碎片化的环境很友好。缺点也明显:你不能像数组那样O(1)随机访问,想找第k个节点必须从头一个个走过去。
"合并两个有序链表"这道题,恰好把链表的两个核心操作——"遍历"和"指针重连"——全部覆盖了。题目本身不难,但它是很多进阶链表题的基石。你在后续刷题中会遇到的"合并K个升序链表"(第23题)、"排序链表"(第148题)、"两数相加"(第2题),核心思路都能在这道题里找到影子。这也是我强烈建议链表新手把这道题当作入门必修课来练的原因。
这道题的描述很简单:给定两个升序排列的链表list1和list2,把它们合并成一个新的升序链表并返回。新链表应该通过拼接给定的两个链表的节点来组成。换句话说,你不能新建一批节点,只能把已有节点重新串起来。
就这样一道看似基础的题,里面埋了三个考点:循环终止条件的边界处理、哨兵节点的使用、对"节点复用"的理解。这三件事,正是链表操作中最容易出错的点。我下面会一个一个拆开讲。
2. 动手之前:先把题目和链表基础吃透
2.1 链表的节点定义和内存模型
力扣上的单链表节点定义是长这样的,大家应该都不陌生:
public class ListNode { int val; ListNode next; ListNode() {} ListNode(int val) { this.val = val; } ListNode(int val, ListNode next) { this.val = val; this.next = next; } }有些人觉得这只是个"模板代码",背下来就行。但如果你真是这么想的,链表题刷起来会越来越吃力。我建议你把节点理解成一个"带箭头的盒子":盒子里装两个东西,一个是值val,一个是指向另一个盒子的箭头next。多个盒子靠箭头串起来,就形成了链表。
内存模型上要特别注意:链表节点在内存中不是挨着放的。这是它和数组最大的区别。数组是连续内存,你知道了首地址,就能通过偏移量访问任意元素;链表则像是寻宝游戏,你必须拿到上一个节点手里的线索(next指针),才能找到下一个节点的位置。这也是为什么链表不少操作都是O(n)的——你必须"从头走到尾"。
面试里经常有候选人会问:合并两个链表,需不需要先遍历一遍算长度?不需要。因为这道题没有要求你按位置插入,只是要求按大小合并。你只需要同时从头节点开始,一边比较一边串,就能保证结果有序。理解这一点,后面写代码就不会做多余的动作。
2.2 关于输入的边界情况,你要心里有数
算法题里最容易丢分的不是主逻辑,而是边界。这道题的输入有两个链表,它们各自都有可能是空:
- 两个链表都为空,返回空,也是null。
- 其中一个链表为空,直接返回另一个链表。
第一种情况很顺理成章,空链表合并空链表还是空链表。第二种情况就有点意思了:如果一个链表已经遍历完了,另一个还剩一大串,根本不需要再逐个比较了,直接把剩下的整条链表"接"上去就行。
我第一次刷这道题的时候,在第二种情况上栽过跟头。我当时写了一个循环,条件是两个指针都不为null才继续,其中一个走完了就退出循环,然后我在循环外面忘了把剩余部分接上,结果测试用例挂了一半。后来养成了一个习惯:**写链表题之前,先想清楚"循环什么时候停,停完之后剩什么"这两个问题。**这两个问题想明白了,代码就不会出大错。
另外提醒一下,力扣的链表题目输入输出在调试面板里看起来是数组形态,比如输入是[1,2,4]和[1,3,4],但它内部构造的是真正的链表对象。你提交的代码里,函数参数接收的是ListNode对象,不是数组。新手第一次做题时经常在这上面犯迷糊,以为和数组操作一样按索引取值。其实你只要遵守题目的函数签名,遍历节点就可以了。
3. 迭代法:用哨兵节点把边界问题一劳永逸地解决掉
3.1 核心思路:三个指针一台戏
迭代解法是这道题最主流、最好理解的解法。整体思路就是:用两个指针cur1和cur2分别指向两条链表的当前节点,比较它们指向的值,较小的那个"串"到结果链表上,然后对应的指针往后挪一步。如此反复,直到某一条链表走完。最后把另一条链表的剩余部分整体拼接上去。
听起来很清晰对吧?但这里卡住很多人的一个问题是:**结果链表的头节点从哪来?**你当然可以单独初始化一个节点来当头部,然后再返回它。但如果你让cur1、cur2中的较小值节点直接作为结果链表的头节点,你就得先特判一下第一轮谁更小。这种特判写起来啰嗦,而且容易出bug。
更优雅的做法是引入哨兵节点:先new一个节点dummy,值随便给(一般是-1),它的作用只是提供一个起点。你把比较出来的节点一个个串到dummy后面,等串完了,返回dummy.next,就拿到了真正的头节点。
为什么要用哨兵节点?因为它能把"处理头节点"这个特殊情况和后面的普通情况统一起来。有了dummy之后,每一轮的拼接逻辑完全一样,不需要单独判断"这次拼接是不是第一个节点"。这就是链表操作里的一个经典技巧:用一个虚拟节点来消除对头节点的特殊处理。
3.2 完整代码与逐行解析
下面是我推荐的迭代写法,Java版本:
public ListNode mergeTwoLists(ListNode list1, ListNode list2) { // 哨兵节点:简化头节点处理 ListNode dummy = new ListNode(-1); ListNode cur = dummy; ListNode cur1 = list1; ListNode cur2 = list2; // 直到有一个链表被遍历完 while (cur1 != null && cur2 != null) { if (cur1.val <= cur2.val) { cur.next = cur1; cur1 = cur1.next; } else { cur.next = cur2; cur2 = cur2.next; } cur = cur.next; // 结果链表的当前指针也向后移动 } // 剩余部分直接拼接 if (cur1 != null) { cur.next = cur1; } if (cur2 != null) { cur.next = cur2; } return dummy.next; }代码不长,但每行都有讲究。我逐段说。
先看哨兵节点。dummy是一个实实在在的对象,它在堆内存里占着一席之地。你用它作为"头前节点",下一步的cur指针指向它,之后每拼接一个节点,cur就向后移动一格。注意cur移动这步很关键,漏掉它你会在某一个瞬间把后面节点的next改乱。
再看循环体的比较逻辑。if (cur1.val <= cur2.val),这里用<=还是<其实无所谓,两个链表原本各自有序,相等的节点先接哪个都不影响最终的有序性,也不影响正确性。有些资料说相等时必须选list1以"保持稳定性",这道题没有这种要求,但面试官追问的时候,你能说出"两者相等时节点顺序不影响结果,因为节点本身没有附带额外信息"这句话,也是一个加分点。
然后是循环结束后的拼接。这里就是上面说的"边界处理"的关键。当循环退出时,只可能是两种情况:cur1走完了,或者cur2走完了。不管是哪种,另一条链表剩下的节点,一定比已经拼接进去的所有节点都大(或者相等),因为两个链表各自有序。这时候直接把这堆剩余节点接到结果链表尾部,整个链表依然有序。这里其实还有一个小的优化写法:可以简化成cur.next = (cur1 != null) ? cur1 : cur2;,但我觉得初学者用两个独立的if更直观,不会因为三目运算符又绕一层。
最后返回dummy.next。这就是整条结果链表的头节点,因为dummy是我们凭空造的,不能算在结果里。很多人在这一步出错,返回了dummy本身,导致结果链表白白多了一个值为-1的头节点。力扣会直接判定答案错误。
3.3 手动推演一遍:用例子说话
光列代码还不行,我们手动走一遍流程。假设list1为1->2->4,list2为1->3->4。
初始化:dummy(-1),cur指向dummy,cur1指向1(list1头),cur2指向1(list2头)。
第1轮:cur1.val=1,cur2.val=1,条件cur1.val <= cur2.val成立,所以把cur1指向的节点串到cur.next,即dummy.next指向list1的第一个节点。cur1后移,指向2。cur后移,指向list1的节点1。
第2轮:cur1.val=2,cur2.val=1,条件不成立,把cur.next指向cur2的节点1(list2头)。cur2后移,指向3。cur后移。
第3轮:cur1.val=2,cur2.val=3,条件成立,拼接cur1的2。cur1后移,指向4。cur后移。
第4轮:cur1.val=4,cur2.val=3,条件不成立,拼接cur2的3。cur2后移,指向4。cur后移。
第5轮:cur1.val=4,cur2.val=4,条件成立,拼接cur1的4。cur1后移,变null。cur后移。
循环退出,因为cur1已经变成了null。此时cur2还指向list2的最后一个节点4。走if (cur2 != null) cur.next = cur2;,把剩余节点接上。最终链表为1->1->2->3->4->4。
推演这一遍的意义在于:你能直观看到cur始终指向结果链表的"尾巴",每次拼接就是把尾巴和下一段接上。链表拼接的本质不是"拷贝节点",而是"重新安排next指针的指向"。
3.4 迭代法的复杂度分析
时间复杂度是O(n+m),其中n是list1的长度,m是list2的长度。因为每一轮循环至少让cur1或cur2向后移动一步,两步指针一共走了n+m步,每一步做的都是常数时间的比较和指针操作。
空间复杂度是O(1)。这是链表题比较有优势的地方,你只用了几个指针变量,没有申请额外与数据规模相关的内存。你new了一个dummy节点,但它是常数空间,和n、m无关。
这个复杂度结论面试的时候能直接背出来还不够,最好能理解它的含义:只要链表题只用到有限个指针,空间复杂度基本都是O(1);如果用到递归,空间复杂度就会变成O(n)——递归栈消耗。
4. 递归法:用最小的子问题思维来理解链表操作
4.1 递归的思考视角:把大问题切成子问题
迭代法是"顺着走",递归法是"倒着想"。两者的区别本质上是你思考问题的方式不同。
递归的视角是这样:合并两个链表,其实可以描述成一句话——取两个头节点中较小的那个,然后让剩余部分的合并结果接在它后面。
换句话说,如果list1的当前节点值更小,那么合并结果的头节点就是list1的当前节点,而它的next应该指向"merge(list1.next, list2)"这个子问题的结果。
递归的最大好处是代码极短,逻辑极其清晰:
public ListNode mergeTwoLists(ListNode list1, ListNode list2) { // 基准情况 if (list1 == null) { return list2; } if (list2 == null) { return list1; } // 比较头节点,递归处理剩余部分 if (list1.val <= list2.val) { list1.next = mergeTwoLists(list1.next, list2); return list1; } else { list2.next = mergeTwoLists(list1, list2.next); return list2; } }这段代码的第一眼感受就是"干净"。但干净归干净,它有两个潜在的学习门槛:第一个是理解递归的退出条件,第二个是理解递归返回值是如何被接上的。
4.2 递归的执行细节与易错点
先看基准情况(base case)。递归一定要有明确的出口,否则就是无限递归,最终栈溢出。这里的出口就是某个链表为空。为什么为空就可以直接返回另一个链表?结合前面讲的"剩余部分直接拼接"逻辑:一个链表已经空了,另一个链表剩下的部分不需要再比较,整个接上去依然有序。
再看"嫁接"步骤。以list1.next = mergeTwoLists(list1.next, list2)这行为例。这行的意思是:我把list1的当前节点保留作为结果头节点,然后把list1的剩余部分和list2合并,合并后的完整链表作为list1.next。这个操作修改了list1节点的next指针,是递归调用在“返回之前”就完成的拼接。
这里有个巨大误区,很多人第一次学递归时会被绕进去:**递归不是把整个调用链跑完再回头处理外部,而是在每一层返回之前,已经为这一层挂好了next指针。**你只需要相信子问题会正确返回,不用在脑中展开整个调用栈。这是程序员的"信仰之跃"。
我在面试中经常看到候选人能背出这段递归代码,但被问到"如果链表长度是1000会发生什么"时答不上来。这里就涉及递归的短板:每递归一层,系统栈就要压入一帧,深度达到链表的长度。当两个链表都很长时,可能会栈溢出。很多编程语言的递归栈有默认限制,比如JVM的栈深度通常在1万左右(JVM默认栈大小和具体参数有关),Python的递归默认限制是1000。所以递归解法在概念上优雅,但在非常长的链表上存在风险。迭代法没有这个问题。
4.3 什么时候优先选递归,什么时候选迭代
一道题有两种解法,面试里你得清楚它们的取舍标准。我的建议是:
- 如果链表规模可控(几百到几千节点),递归写法完全OK,代码短,易读,不容易引入指针错误。
- 如果数据规模可能很大,或者你明确知道面试官在考察“边界处理能力”,迭代法更稳妥。
- 如果这是一道"让你展示代码功力"的面试题,你可以先给迭代法,然后主动补充"这道题也可以用递归实现,两种方式的时间和空间差异在于递归调用栈的消耗"——这样既展示了覆盖度,也展示了复杂度意识。
从这个角度来看,"合并两个有序链表"这道题的价值不只是解出来,它逼着你把迭代和递归这两种思维模式在同一道题上做对比训练。我倾向认为这是链表入门阶段最划算的一道训练题。
5. 进阶延伸:这道题如何帮你打通后续链表题
5.1 从合并两个到合并K个:思路的自然延展
力扣热题100里有一道进阶题叫"合并K个升序链表"(第23题)。如果你只会用暴力法——把K个链表两两合并——效率是O(k^2 * n),面试官大概率不满意。但如果你理解了合并两个有序链表的"比较最小值"逻辑,再去看合并K个,你会发现只需要把"两两比较"升级为"K个里选最小",这正好可以用优先队列(最小堆)来实现。
换句话说,第一道题里你写的是两个if分支的比较,K个版本里你把比较过程换成了堆的pop和push,其他结构基本不变。所以我对刷题人的建议一直是:不要为了刷题而刷题,把每道基础题的思路吃透,后面遇到它的"升级版"才能快速迁移。
5.2 哨兵节点的其他典型应用场景
哨兵节点这个技巧,在链表操作里出镜率非常高,学会这道题之后你应该有意识地去识别其他能用它的场景:
- "删除链表倒数第N个节点"(第19题):需要用双指针,而哨兵节点可以避免"删除头节点"的特殊判断。
- "两两交换链表中的节点"(第24题):用哨兵节点可以让每轮交换的逻辑完全一致,不用单独处理头两个节点的情况。
- "反转链表II"(第92题):在部分反转的场景中,哨兵节点帮你定位反转区间的前驱节点。
我在带新人刷题时经常说一句话:**链表题的本质就是"指针重连",而哨兵节点是让你重连逻辑统一化的工具。**如果你发现一段链表代码里有大量"如果是头节点怎么办"的分支判断,第一反应就应该是能不能用哨兵节点消掉它们。
5.3 关于本地调试的一个实用建议
力扣的在线编辑器可以直接运行测试,但如果你想在本地IDE里调试链表题,需要写一个简单的链表构造工具函数。我在本地做这道题的时候,写过这样一个helper方法:
// 把数组转成链表,方便调试 private static ListNode createList(int[] arr) { ListNode dummy = new ListNode(-1); ListNode cur = dummy; for (int val : arr) { cur.next = new ListNode(val); cur = cur.next; } return dummy.next; } // 把链表打印成数组形式,方便肉眼检查 private static void printList(ListNode head) { StringBuilder sb = new StringBuilder(); while (head != null) { sb.append(head.val).append(" -> "); head = head.next; } sb.append("null"); System.out.println(sb.toString()); }有了这两个方法,你在main函数里就能随心所欲地构造测试用例,打印中间结果。刷链表题的时候,我很建议每个人都准备这一套本地调试工具,它能帮你快速看清每一次指针修改产生的效果。
还有一个小习惯分享给你:在写链表题代码时,可以在关键指针变化处加几行System.out或debugger断点,逐个观察cur、cur1、cur2的指向变化。第一次刷这道题的人,往往就是靠这种逐步观察,才真正理解"拼接节点"到底是怎么发生的。
6. 常见问题与易错点速查
6.1 链表新手最容易踩的5个坑
这道题的讨论区里,常年有人发一些看起来很奇怪的问题。其实这些问题就那几类,我整理成一个表,方便你对号入座。
| 问题类型 | 错误表现 | 根因 | 解决方案 |
|---|---|---|---|
| 返回错误 | 返回了哨兵节点dummy,导致多了一个-1 | 没理解dummy是辅助节点 | 记住返回dummy.next |
| 丢失节点 | 结果链表只有半个,后面的节点丢了 | 循环结束后忘了接剩余链表 | 循环后检查cur1或cur2并拼接 |
| 死循环 | 程序超时 | cur指针没有后移 | 每次拼接后执行cur=cur.next |
| 修改原链表 | 把list1的尾部接到了list2上,导致重复 | 没区分是"复用节点"还是"新建链表" | 这道题允许复用节点,但别让两个链表交叉 |
| 空指针异常 | 在cur1.val上直接访问,没判空 | 循环条件写成了“或”而非“与” | while条件必须是cur1 != null && cur2 != null |
这五类问题几乎覆盖了这道题90%的提交错误。我自己刷题和带人刷题时,看到最多的就是"忘了接剩余链表"和"返回dummy本身"这两个。建议你把它们刻在脑子里,写代码时先自查这两点。
6.2 关于"稳定性"和相等值的处理
有些人会问:两个链表有相等节点时,是不是必须优先取list1?这道题不要求,原因前面说过了:节点本身除了val没有附带其他信息,交换相等节点的顺序不会影响结果的有效性。但"稳定排序"这个概念在更复杂的题目里会用到,比如按某种属性分组时,你想保持原先的相对顺序。如果面试官顺着这道题往"稳定性"上问,你要能说出"如果节点结构里带有一个额外的序号字段,相等值时先取list1就能保证原顺序,那稳定合并就有意义了"。
换句话说,这道题里你选择<=或者<都可以通过。但你在面试中如果能多说一句"这里相等时取哪个都不会影响结果,除非有稳定性要求",会显得你真的理解了题目的数据结构,而不是背模板。
6.3 测试用例设计:不只是跑通就完事
一道题写完,力扣的测试用例会帮你检查。但更建议你养成自己写用例的习惯。对这道题,我常用的测试用例组是:
- 空链表 + 空链表:必须返回null。
- 空链表 + 非空链表:直接返回非空链表本身。
- 两个链表长度不一致,比如[1]和[1,2,3,4,5]。
- 两个链表完全相等长度,比如[1,1,1]和[1,1,1]。
- 一个链表的值全部小于另一个链表的值,比如[1,2,3]和[4,5,6]。
- 包含负数的情况,比如[-3,-1,0]和[-2,2,3]。
这些用例覆盖了"边界条件、长度不齐、值全序关系"三类情况。经常有读者问我刷题的正确姿势是什么,我都会说:代码写完只是第一步,用例设计才是检验你是否真懂题目边界的方式。
7. 刷题方法论:从一个基础题开始建立体系
7.1 如何把一道题的价值最大化
坦白讲,合并两个有序链表这道题本身并不难,重要的不是你"做出来了",而是你从这道题里带走了什么。我个人的习惯是每道基础题都做三遍:
第一遍,先不看答案,自己憋出来,无论写得多难看都行。这一遍考验的是你有没有关于这个问题的"直觉"。
第二遍,看完最优解后,合上答案重新写,并且把复杂度分析写在注释里。这一遍是检验你有没有真正理解思路,而不是背代码。
第三遍,一周后重新做。这一遍通常是在做其他题目的间隙里顺便做,目的是检验长期记忆。
三遍之间有间隔时间,是为了对抗"熟悉度错觉"——你当场写出来,不代表你下周还能写出来。尤其是链表题,稍微绕一点就特别容易翻车,多过几遍非常有必要。
7.2 这道题在整个刷题路线中的位置
力扣热题100是很多人刷题的第一选择,我简单说一下链表相关题目在这个列表里的衔接关系:
- 基础入门:反转链表(第206题)、合并两个有序链表(第21题)。
- 中级进阶:环形链表(第141题)、两两交换链表中的节点(第24题)、删除链表倒数第N个节点(第19题)。
- 高级综合:合并K个升序链表(第23题)、排序链表(第148题)、相交链表(第160题)。
你会发现,第21题非常适合作为状态转换的起点:它简单到足以建立信心,又足够典型到能带出哨兵节点、递归、指针重连三大知识点。在刷链表题的文件夹里,这道题往往是一周目里的第三四题,目的就是把"节点之间靠next指针穿起来"这件基本事实深深地刻进肌肉记忆。
7.3 面试时如何把这道题讲出彩
如果你在面试中被问到了这道题,光把代码写出来是不够的。面试官考察的是你的表达能力和边界意识。我建议按这个顺序来讲:
- 先说你对题目的理解:两个有序链表,合并后仍然有序,节点复用,不新建节点。
- 再说你的方案:迭代法,用哨兵节点统一拼接逻辑,双指针逐个比较。
- 写代码的过程中,主动解释关键点:为什么用dummy、为什么循环条件是&&、为什么循环后要接剩余。
- 写完代码,主动说复杂度:O(n+m)时间,O(1)空间。
- 最后补充一句:这道题也可以用递归实现,不过递归会有O(n+m)的栈开销,在链表很长时需要留意栈溢出风险。
按照这个顺序讲下来,面试官不会觉得你是在背题,而会觉得你是真的理解了链表的底层操作逻辑。我在面试中更看重候选人的这种"结构化表达"能力,因为工程协作中,代码之外的沟通质量其实占了很大的比例。
8. 写在最后的实操心得
链表操作这种东西,光看不练是真的学不会。我自己早期刷链表题也走过弯路:看答案觉得"哦,就这么回事",合上答案自己写,指针还是乱飞。后来我是靠"动手画图、手动推演、本地打印调试"这三板斧才真正把链表的思维建立起来的。
这道题强烈建议你用纸笔把链表画出来,然后拿着代码一行一行地走:画两个链表,三个指针,每执行一行代码就更新一次指针的位置。这个过程虽然慢,但它能帮你建立"指针是引用、next是连接关系"的心智模型。这个模型一旦建立起来,后面遇到的链表题都会变得顺畅很多。
另外,如果你在做这道题时发现某个用例死活不对,不要急着盲目改代码,先把那个用例画出来,逐步走一遍。大多数情况下你会在走的过程里猛然意识到:原来循环退出后还有一段链表没接上,或者某个指针忘了后移。链表题的bug,九成都能靠"画图+逐步走"来定位,debugger反而有时候不如一张纸好用。
这道题我也推荐放在你的"复习清单"里,隔一两周拿出来重新写一遍,确保自己的代码肌肉还在。等你之后做到第23题合并K个升序链表、第148题排序链表时,再回头看一眼这题,你会发现自己对"指针重连"的理解已经到了完全不同的层次。