Java版LeetCode热题100之「排序链表」详解
本文约9200字,全面深入剖析 LeetCode 第148题《排序链表》。涵盖题目解析、两种归并排序解法(自顶向下 & 自底向上)、复杂度分析、面试高频问答、实际开发应用场景、相关题目推荐等,助你彻底掌握链表排序的核心技巧。
一、原题回顾
题目描述:
给你链表的头结点head,请将其按升序排列并返回排序后的链表。
示例 1:
输入:head = [4,2,1,3] 输出:[1,2,3,4]示例 2:
输入:head = [-1,5,3,4,0] 输出:[-1,0,3,4,5]示例 3:
输入:head = [] 输出:[]提示:
- 链表中节点的数目在范围
[0, 5 * 10⁴]内 -10⁵ <= Node.val <= 10⁵
进阶要求:
你可以在O(n log n) 时间复杂度和常数级空间复杂度下,对链表进行排序吗?
📌关键提示:时间复杂度 O(n log n) 的排序算法包括归并排序、堆排序和快速排序,其中最适合链表的是归并排序。
二、原题分析
这道题要求对单链表进行高效排序。与数组不同,链表不支持随机访问,因此许多基于索引的排序算法(如堆排序)难以直接应用。
核心挑战:
- 时间效率:需达到 O(n log n),排除插入排序(O(n²));
- 空间限制:进阶要求 O(1) 空间,排除递归栈开销大的方法;
- 链表特性:
- 只能顺序访问
- 节点通过指针连接
- 合并操作比交换更高效
为什么选择归并排序?
| 排序算法 | 数组适用性 | 链表适用性 | 原因 |
|---|---|---|---|
| 快速排序 | ✅ 高效 | ❌ 不理想 | 需随机访问选 pivot |
| 堆排序 | ✅ | ❌ | 需要完全二叉树结构 |
| 归并排序 | ✅ | ✅ 最佳 | 天然适合顺序访问,合并操作高效 |
✅归并排序优势:
- 稳定(相等元素相对位置不变)
- 时间复杂度稳定 O(n log n)
- 合并两个有序链表是 O(1) 空间的经典操作
三、答案构思
方法一:自顶向下归并排序(递归)
- 思想:分治法
- 找中点,将链表分为两半
- 递归排序左右两半
- 合并两个有序链表
- 优点:代码简洁,逻辑清晰
- 缺点:递归栈空间 O(log n),不满足进阶要求
方法二:自底向上归并排序(迭代)
- 思想:从小到大逐步合并
- 先将链表视为 n 个长度为 1 的有序子链表
- 两两合并,得到 ⌈n/2⌉ 个长度为 2 的有序子链表
- 继续合并,直到整个链表有序
- 优点:O(1) 空间,满足进阶要求
- 缺点:代码稍复杂,需仔细处理指针
📌两种方法时间复杂度均为 O(n log n),但空间复杂度不同。
四、完整答案(Java 实现)
方法一:自顶向下归并排序(递归)
/** * Definition for singly-linked list. * 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; } * } */classSolution{publicListNodesortList(ListNodehead){returnsortList(head,null);}/** * 对 [head, tail) 区间的链表进行排序 */privateListNodesortList(ListNodehead,ListNodetail){// 空链表或单节点,直接返回if(head==null||head.next==tail){if(head!=null){head.next=null;// 断开与后续节点的连接}returnhead;}// 使用快慢指针找中点ListNodeslow=head,fast=head;while(fast!=tail){slow=slow.next;fast=fast.next;if(fast!=tail){fast=fast.next;}}ListNodemid=slow;// 递归排序左右两半ListNodeleft=sortList(head,mid);ListNoderight=sortList(mid,tail);// 合并两个有序链表returnmerge(left,right);}/** * 合并两个有序链表 */privateListNodemerge(ListNodel1,ListNodel2){ListNodedummy=newListNode(0);ListNodecurrent=dummy;while(l1!=null&&l2!=null){if(l1.val<=l2.val){current.next=l1;l1=l1.next;}else{current.next=l2;l2=l2.next;}current=current.next;}// 连接剩余部分current.next=(l1!=null)?l1:l2;returndummy.next;}}方法二:自底向上归并排序(迭代,推荐!)
classSolution{publicListNodesortList(ListNodehead){if(head==null){returnhead;}// 计算链表长度intlength=0;ListNodenode=head;while(node!=null){length++;node=node.next;}// 创建虚拟头节点ListNodedummy=newListNode(0,head);// subLength 表示当前合并的子链表长度for(intsubLength=1;subLength<length;subLength<<=1){ListNodeprev=dummy;// 指向已排序部分的尾部ListNodecurr=dummy.next;// 当前处理位置while(curr!=null){// 获取第一个子链表 head1ListNodehead1=curr;for(inti=1;i<subLength&&curr.next!=null;i++){curr=curr.next;}// 获取第二个子链表 head2ListNodehead2=curr.next;curr.next=null;// 断开第一个子链表curr=head2;// 移动到第二个子链表的末尾if(curr!=null){for(inti=1;i<subLength&&curr.next!=null;i++){curr=curr.next;}ListNodenext=curr.next;// 保存下一组的开始curr.next=null;// 断开第二个子链表curr=next;}// 合并两个子链表ListNodemerged=merge(head1,head2);prev.next=merged;// 移动 prev 到合并后链表的末尾while(prev.next!=null){prev=prev.next;}}}returndummy.next;}privateListNodemerge(ListNodel1,ListNodel2){ListNodedummy=newListNode(0);ListNodecurrent=dummy;while(l1!=null&&l2!=null){if(l1.val<=l2.val){current.next=l1;l1=l1.next;}else{current.next=l2;l2=l2.next;}current=current.next;}current.next=(l1!=null)?l1:l2;returndummy.next;}}✅方法二满足进阶要求:O(n log n) 时间 + O(1) 空间!
五、代码分析
方法一:自顶向下(递归)
1. 区间表示[head, tail)
- 使用左闭右开区间,便于处理边界
tail作为哨兵,避免额外的 null 检查
2. 快慢指针找中点
while(fast!=tail){slow=slow.next;fast=fast.next;if(fast!=tail){fast=fast.next;}}fast每次走 2 步,slow走 1 步- 当
fast到达tail时,slow恰好在中点
3. 断开连接
if(head!=null){head.next=null;}- 确保每个子链表独立,避免合并时出现环
方法二:自底向上(迭代,重点!)
1. 外层循环:控制子链表长度
for(intsubLength=1;subLength<length;subLength<<=1)subLength从 1 开始,每次翻倍(1→2→4→8…)
2. 内层循环:处理每一对子链表
- 步骤1:提取第一个子链表
head1for(inti=1;i<subLength&&curr.next!=null;i++){curr=curr.next;} - 步骤2:断开并提取第二个子链表
head2ListNodehead2=curr.next;curr.next=null;// 关键:断开连接 - 步骤3:移动到下一组的开始
- 步骤4:合并并重新连接
3. 虚拟头节点的作用
- 统一处理头节点变更问题
prev始终指向已排序部分的尾部,便于连接新合并的链表
⚠️关键细节:每次提取子链表后必须断开连接,否则合并时会包含多余节点!
六、时间复杂度和空间复杂度分析
| 方法 | 时间复杂度 | 空间复杂度 | 是否满足进阶要求 |
|---|---|---|---|
| 自顶向下 | O(n log n) | O(log n) | ❌(空间不满足) |
| 自底向上 | O(n log n) | O(1) | ✅ |
时间复杂度详解:
- 分治深度:log n 层
- 每层工作量:O(n)(所有合并操作总和)
- 总计:O(n log n)
空间复杂度详解:
- 自顶向下:递归调用栈深度为 log n → O(log n)
- 自底向上:仅使用常数个指针变量 → O(1)
💡工程建议:优先使用自底向上方法,尤其在内存受限场景。
七、常见问题解答(FAQ)
Q1:为什么不用快速排序?
答:
快速排序需要随机访问来选择 pivot,而链表只能顺序访问。
虽然可以用快慢指针找中点作为 pivot,但最坏情况时间复杂度仍为 O(n²),且 partition 操作在链表上效率低下。
Q2:自底向上方法中,为什么要计算链表长度?
答:
需要知道何时停止(当subLength >= length时,整个链表已有序)。
虽然可以不用长度(通过检测是否只剩一个子链表),但计算长度使逻辑更清晰。
Q3:合并函数能否优化?
答:可以,但收益有限。例如:
- 提前判断一个链表为空
- 减少临时变量
但核心逻辑不变,且现代 JVM 会优化这些细节。
Q4:如果链表有重复元素,排序是否稳定?
答:是稳定的!
归并排序是稳定排序,在合并时l1.val <= l2.val保证了相等元素的相对顺序不变。
八、优化思路
1. 提前终止优化
在自底向上方法中,如果某轮合并后发现整个链表已有序,可提前退出。但检测有序性需要额外 O(n) 时间,通常不值得。
2. 减少指针操作
可复用部分指针,但会降低代码可读性,不推荐。
3. 工程化增强
- 输入校验:虽然题目保证范围,但生产代码应检查
head有效性 - 日志记录:调试时打印每轮合并状态
- 单元测试:覆盖空链表、单节点、已排序、逆序等 case
// 示例测试用例@TestvoidtestSortList(){ListNodehead=newListNode(4,newListNode(2,newListNode(1,newListNode(3))));ListNodesorted=solution.sortList(head);// 验证 sorted 为 [1,2,3,4]}九、数据结构与算法基础知识点回顾
1. 归并排序原理
- 分治思想:分解 → 解决 → 合并
- 稳定性:相等元素相对位置不变
- 适用场景:外部排序、链表排序
2. 快慢指针技巧
- 找中点:快指针走 2 步,慢指针走 1 步
- 检测环:Floyd 判圈算法
- 找倒数第 k 个:快指针先走 k 步
3. 虚拟头节点(Dummy Node)
- 作用:简化头节点处理
- 适用场景:任何可能改变头节点的链表操作
4. 空间复杂度意识
- O(1) 空间:意味着不能使用递归、栈、额外数组
- 面试重点:进阶要求常考察空间优化能力
十、面试官提问环节(模拟)
❓ 问题1:你的自底向上解法中,为什么内层循环要断开子链表?
回答:
合并函数假设输入的两个链表都是独立的(以 null 结尾)。
如果不断开,head1会包含head2的内容,导致合并结果错误。
例如:[1,2,3,4]分为[1,2]和[3,4],若不断开,head1实际是[1,2,3,4]。
❓ 问题2:如果要求降序排列,如何修改?
回答:
只需修改合并函数中的比较条件:
将l1.val <= l2.val改为l1.val >= l2.val。
其他逻辑完全不变。
❓ 问题3:这个算法能处理环形链表吗?
回答:
不能,且题目假设链表无环。
若存在环,计算长度会无限循环,快慢指针也会陷入死循环。
实际工程中,应先用 Floyd 算法检测环,再处理。
❓ 问题4:为什么归并排序比其他 O(n log n) 算法更适合链表?
回答:
三个原因:
- 顺序访问友好:归并排序只需要顺序遍历,不需要随机访问;
- 合并高效:合并两个有序链表只需调整指针,O(1) 空间;
- 稳定可靠:时间复杂度稳定 O(n log n),不像快排有 O(n²) 最坏情况。
十一、这道算法题在实际开发中的应用
虽然“排序链表”看似理论化,但其思想在实际系统中非常有用:
1.外部排序(External Sorting)
- 当数据量超过内存时,将数据分块排序后归并
- 归并排序是外部排序的标准算法
2.数据库查询优化
- SQL 的
ORDER BY在某些情况下使用归并排序 - 特别适合已部分排序的数据(如索引扫描结果)
3.日志系统中的事件排序
- 分布式系统中,各节点产生的日志需要全局排序
- 归并排序天然适合合并多个有序日志流
4.文件系统中的目录项排序
- 目录项通常以链表形式存储
- 显示时需要按名称排序,归并排序效率高
💡核心价值:掌握分治思想和链表操作技巧,这是系统编程的基础。
十二、相关题目推荐
掌握本题后,可挑战以下 LeetCode 题目:
| 题号 | 题目 | 关联点 |
|---|---|---|
| 21. 合并两个有序链表 | 基础 | 本题子问题 |
| 23. 合并K个升序链表 | 扩展 | 多路归并 |
| 147. 对链表进行插入排序 | 对比 | O(n²) 解法 |
| 876. 链表的中间结点 | 技巧 | 快慢指针 |
| 25. K 个一组翻转链表 | 链表操作 | 指针技巧 |
| 143. 重排链表 | 综合 | 找中点+反转+合并 |
建议按顺序练习,逐步构建链表操作知识体系。
十三、总结与延伸
✅ 本题核心收获
- 归并排序的优势:稳定、高效、适合链表;
- 两种实现方式:自顶向下(简洁)vs 自底向上(高效);
- 快慢指针技巧:找中点的标准方法;
- 空间复杂度意识:O(1) 空间解法的实现要点。
🔮 延伸思考
- 多线程归并:能否并行处理不同子链表?(理论上可以,但链表非随机访问,实际收益有限)
- 缓存友好性:数组归并排序有更好的缓存局部性,链表在这方面处于劣势
- 函数式解法:在支持不可变数据结构的语言中,归并排序天然适合
🌟 最后建议
- 手写代码:在白板上写出自底向上的主循环,确保指针操作无误;
- 讲清思路:面试时先说“我打算用归并排序”,再解释两种实现的权衡;
- 主动测试:提出测试空链表、单节点、已排序、逆序等 case,展现全面性。
“排序链表,归并为王;分而治之,合则有序。”
掌握本题,你就拥有了处理复杂链表操作的利器。继续前行,算法之路越走越宽!