📌 题目链接:148. 排序链表 - 力扣(LeetCode)
🔍 难度:中等 | 🏷️ 标签:链表、归并排序、分治、双指针
⏱️ 目标时间复杂度:O(n log n)
💾 空间复杂度:O(log n)(自顶向下)或 O(1)(自底向上)
✅ 核心算法:归并排序(Merge Sort)
🔄 关键技巧:分治思想 + 快慢指针找中点 + 合并两个有序链表
💡 面试重点:如何在链表上实现 O(n log n) 时间复杂度且空间优化到常数级别?
🔥 典型考点:链表操作、递归与迭代对比、原地合并、边界处理
📊 题目分析
给你一个单链表的头节点
head,请将其按升序排列并返回排序后的链表。
🎯 输入输出示例:
-
输入:
head = [4,2,1,3]
输出:[1,2,3,4] -
输入:
head = [-1,5,3,4,0]
输出:[-1,0,3,4,5] -
输入:
head = []
输出:[]
❗️ 进阶要求:
是否能在 O(n log n) 时间复杂度和 O(1) 空间复杂度下完成?
👉 这是本题的核心挑战!普通排序算法如插入排序(O(n²))不满足;堆排序虽可达到 O(n log n),但难以在链表上高效实现;而归并排序正是最适合链表结构的高级排序方式。
🔍 核心算法及代码讲解
🌟 归并排序(Merge Sort)—— 分治法的经典应用
归并排序是一种典型的「分而治之」(Divide and Conquer)算法,其基本思想如下:
1. Divide: 将问题分解为更小的子问题
2. Conquer: 递归解决子问题
3. Combine: 将子问题的解合并成原问题的解
对于链表来说,由于没有随机访问能力(无法像数组那样用索引),我们不能直接使用下标划分,而是通过 快慢指针(Fast-Slow Pointer) 找到链表中点来拆分。
✅ 为什么选择归并排序?
| 排序算法 | 时间复杂度 | 空间复杂度 | 是否适合链表 |
|---|---|---|---|
| 插入排序 | O(n²) | O(1) | ✅ 可行但慢 |
| 快速排序 | 平均 O(n log n),最坏 O(n²) | O(log n) | ❌ 不稳定,容易栈溢出 |
| 堆排序 | O(n log n) | O(1) | ❌ 操作复杂,不适合 |
| 归并排序 | O(n log n) | O(log n) 或 O(1) | ✅ 最优选择 |
✅ 特别说明:归并排序在链表上的优势在于:
- 不需要额外数组存储元素
- 合并过程天然支持链式结构
- 可以通过“自底向上”方式避免递归栈空间消耗
🧩 解题思路(分步详解)
我们将采用两种方法来解决问题:
✅ 方法一:自顶向下归并排序(递归版)
👉 使用递归实现归并排序,逻辑清晰易懂,但空间复杂度为 O(log n)
步骤分解:
-
终止条件判断:
- 若链表为空或只有一个节点 → 直接返回
- 即:
if (head == nullptr || head->next == tail)→ 返回head
-
找到中点:
- 使用 快慢指针 技术定位中点
- 快指针每次走两步,慢指针走一步
- 当快指针到达末尾时,慢指针指向中点
-
递归拆分:
- 以中点为界,将链表分为左右两部分
- 分别对左右部分进行排序
-
合并有序链表:
- 使用「21. 合并两个有序链表」的思路合并结果
-
返回最终结果
📌 关键点:必须断开中点后继连接,防止环路!
✅ 方法二:自底向上归并排序(迭代版)
👉 通过循环控制子链表长度,逐步合并,空间复杂度可达 O(1)
步骤分解:
- 计算链表总长度
length - 设置子链表长度
subLength = 1 - 循环合并:
- 每次将链表拆成若干个长度为
subLength的子链表 - 两两合并成长度为
2 * subLength的有序链表 - 更新
subLength *= 2
- 每次将链表拆成若干个长度为
- 重复直到 subLength >= length
🌟 优点:无需递归,空间复杂度为 O(1) ⚠️ 缺点:代码稍复杂,边界处理需谨慎
📈 算法分析
| 方法 | 时间复杂度 | 空间复杂度 | 是否满足进阶要求 |
|---|---|---|---|
| 自顶向下 | O(n log n) | O(log n) | ❌ 不完全满足(非 O(1)) |
| 自底向上 | O(n log n) | O(1) | ✅ 完全满足 |
🔍 为什么自底向上能省空间? 因为它用的是 迭代代替递归,不再依赖系统调用栈,仅用几个指针变量即可完成所有操作。
💻 代码实现(完整模板 + 行注释)
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
// Definition for singly-linked list.
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
class Solution {
public:
// 主函数:入口
ListNode* sortList(ListNode* head) {
return sortList(head, nullptr); // 传入尾部哨兵
}
private:
// 递归版本:自顶向下归并排序
ListNode* sortList(ListNode* head, ListNode* tail) {
if (head == nullptr) { // 边界:空链表
return head;
}
if (head->next == tail) { // 只有一个节点
head->next = nullptr; // 断开连接,防止环
return head;
}
// 使用快慢指针找中点
ListNode* slow = head;
ListNode* fast = head;
while (fast != tail) {
slow = slow->next; // 慢指针走一步
fast = fast->next; // 快指针走两步
if (fast != tail) { // 避免越界
fast = fast->next;
}
}
ListNode* mid = slow; // 中点位置
// 递归排序左右两部分
ListNode* left = sortList(head, mid);
ListNode* right = sortList(mid, tail);
// 合并两个已排序的链表
return merge(left, right);
}
// 合并两个有序链表(经典题21)
ListNode* merge(ListNode* head1, ListNode* head2) {
ListNode* dummyHead = new ListNode(0); // 虚拟头节点
ListNode* temp = dummyHead; // 当前指针
ListNode* temp1 = head1; // 左链表指针
ListNode* temp2 = head2; // 右链表指针
// 双指针合并
while (temp1 != nullptr && temp2 != nullptr) {
if (temp1->val <= temp2->val) {
temp->next = temp1; // 选较小值
temp1 = temp1->next;
} else {
temp->next = temp2;
temp2 = temp2->next;
}
temp = temp->next; // 移动当前指针
}
// 处理剩余节点
if (temp1 != nullptr) {
temp->next = temp1;
} else if (temp2 != nullptr) {
temp->next = temp2;
}
return dummyHead->next; // 返回真实头节点
}
};
// 测试
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
// 示例测试用例
ListNode* head = new ListNode(4);
head->next = new ListNode(2);
head->next->next = new ListNode(1);
head->next->next->next = new ListNode(3);
Solution sol;
ListNode* result = sol.sortList(head);
// 输出结果
while (result != nullptr) {
cout << result->val << " ";
result = result->next;
}
cout << endl;
return 0;
}
🔄 自底向上版本(补充,供面试拓展)
class Solution {
public:
ListNode* sortList(ListNode* head) {
if (head == nullptr) return head;
// 第一步:求链表长度
int length = 0;
ListNode* node = head;
while (node != nullptr) {
length++;
node = node->next;
}
// 创建虚拟头节点,便于操作
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;
// 第二步:从长度为1开始,逐步合并
for (int subLength = 1; subLength < length; subLength <<= 1) {
ListNode* prev = dummyHead; // 上一个合并段的尾部
ListNode* curr = dummyHead->next; // 当前遍历的节点
while (curr != nullptr) {
// 第一个子链表
ListNode* head1 = curr;
for (int i = 1; i < subLength && curr->next != nullptr; i++) {
curr = curr->next;
}
// 第二个子链表
ListNode* head2 = curr->next;
curr->next = nullptr; // 断开第一个子链表
curr = head2;
// 构造第二个子链表
for (int i = 1; i < subLength && curr != nullptr && curr->next != nullptr; i++) {
curr = curr->next;
}
// 下一段的起始点
ListNode* next = nullptr;
if (curr != nullptr) {
next = curr->next;
curr->next = nullptr;
}
// 合并两个有序链表
ListNode* merged = merge(head1, head2);
prev->next = merged;
// 找到合并后链表的最后一个节点
while (prev->next != nullptr) {
prev = prev->next;
}
curr = next;
}
}
return dummyHead->next;
}
private:
ListNode* merge(ListNode* head1, ListNode* head2) {
ListNode* dummyHead = new ListNode(0);
ListNode* temp = dummyHead;
ListNode* temp1 = head1;
ListNode* temp2 = head2;
while (temp1 != nullptr && temp2 != nullptr) {
if (temp1->val <= temp2->val) {
temp->next = temp1;
temp1 = temp1->next;
} else {
temp->next = temp2;
temp2 = temp2->next;
}
temp = temp->next;
}
if (temp1 != nullptr) {
temp->next = temp1;
} else if (temp2 != nullptr) {
temp->next = temp2;
}
return dummyHead->next;
}
};
🧠 面试加分点总结
| 考点 | 说明 |
|---|---|
| 🧩 快慢指针找中点 | 是链表中点查找的标准解法,必须掌握 |
| 🔁 递归 vs 迭代 | 递归简洁但占用栈空间;迭代复杂但空间最优 |
| 🧱 合并链表 | 「21. 合并两个有序链表」是基础,必须熟练 |
| 🧼 边界处理 | 如 head == nullptr, head->next == tail |
| 🧠 分治思想 | 是许多高级算法的核心,如快速排序、归并排序、线段树等 |
| 🧪 时空复杂度分析 | 面试必问!要能说出每种方法的时间和空间代价 |
💡 建议:在面试中优先写出递归版本,再提出迭代优化方案,展示你对算法的理解深度。
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📣 下一期预告:LeetCode 热题 100 第33题 —— 46.合并 K 个升序链表(困难)
🔹 题目:给你 k 个非空链表,每个链表都已按升序排列,请将所有链表合并为一个升序链表。
🔹 核心思路:使用最小堆(优先队列)维护每个链表的头节点,每次取出最小值并加入结果链表。
🔹 考点:堆(优先队列)、链表操作、多路归并、分治思想。
🔹 难度:困难,但却是大厂常见题型,尤其在设计“流式数据合并”场景中非常实用!
💡 提示:不要暴力合并,那样会超时!优先考虑堆或分治策略!
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!
🎯 坚持每天一题,算法不再是梦!