算法通俗讲解推荐阅读
【算法--链表】83.删除排序链表中的重复元素--通俗讲解
【算法--链表】删除排序链表中的重复元素 II--通俗讲解
【算法--链表】86.分割链表--通俗讲解
【算法】92.翻转链表Ⅱ--通俗讲解
【算法--链表】109.有序链表转换二叉搜索树--通俗讲解
【算法--链表】114.二叉树展开为链表--通俗讲解
【算法--链表】116.填充每个节点的下一个右侧节点指针--通俗讲解
【算法--链表】117.填充每个节点的下一个右侧节点指针Ⅱ--通俗讲解
【算法--链表】138.随机链表的复制--通俗讲解
【算法】143.重排链表--通俗讲解
【算法--链表】146.LRU缓存--通俗讲解
【算法--链表】147.对链表进行插入排序--通俗讲解
通俗易懂讲解“排序链表”算法题目
一、题目是啥?一句话说清
给定一个链表,将其按升序排列并返回排序后的链表。
示例:
- 输入:4 → 2 → 1 → 3
- 输出:1 → 2 → 3 → 4
二、解题核心
使用归并排序算法,通过快慢指针找到链表中点,将链表分成两半,递归排序每半部分,然后合并两个有序链表。
这就像把一堆乱序的卡片分成两堆,分别排序,然后再把两堆有序的卡片合并成一堆有序的卡片。
三、关键在哪里?(3个核心点)
想理解并解决这道题,必须抓住以下三个关键点:
1. 快慢指针找中点
- 是什么:使用快慢指针技巧找到链表的中间节点,快指针每次走两步,慢指针每次走一步。
- 为什么重要:这是分治策略的基础,通过找到中点可以将链表分成两个部分,分别进行排序,这是归并排序的核心思想。
2. 递归排序
- 是什么:将链表分成两半后,递归地对每一半进行排序,直到链表长度为1或0(已经有序)。
- 为什么重要:递归使得我们可以处理任意长度的链表,将大问题分解为小问题,是分治策略的实现方式。
3. 合并有序链表
- 是什么:将两个已经排序的链表合并成一个有序链表,通过比较节点值,按顺序连接节点。
- 为什么重要:这是归并排序的最后一步,也是关键步骤,需要正确比较和连接节点,确保合并后的链表有序。
四、看图理解流程(通俗理解版本)
假设链表为:4 → 2 → 1 → 3
-
找中点并分割:
- 快慢指针:慢指针从4开始,快指针从4开始。
- 第一轮:慢指针走到2,快指针走到1。
- 第二轮:慢指针走到1,快指针走到3(快指针无法再走两步,停止)。
- 中点是节点2。将链表分成前半部分:4→2 和后半部分:1→3。
-
递归排序:
- 对前半部分4→2排序:
- 找中点:慢指针从4开始,快指针从4开始。
- 第一轮:慢指针走到2,快指针走到null(因为快指针走两步后为null)。
- 分成4和2两个单节点链表。
- 合并4和2:比较4和2,得到2→4。
- 对后半部分1→3排序:
- 找中点:慢指针从1开始,快指针从1开始。
- 第一轮:慢指针走到3,快指针走到null。
- 分成1和3两个单节点链表。
- 合并1和3:比较1和3,得到1→3。
- 对前半部分4→2排序:
-
合并两个有序链表:
- 有两个有序链表:2→4 和 1→3。
- 比较两个链表的头节点:2和1,1较小,取1。
- 比较剩余部分:2→4 和 3,2和3,2较小,取2。
- 比较剩余部分:4 和 3,3较小,取3。
- 最后取4。
- 合并结果:1→2→3→4。
五、C++ 代码实现(附详细注释)
#include <iostream>
using namespace std;
// 链表节点定义
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) {
// 基线条件:空链表或单节点链表已经有序
if (head == nullptr || head->next == nullptr) {
return head;
}
// 使用快慢指针找到链表中点
ListNode* slow = head;
ListNode* fast = head->next;
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
}
// 将链表分成两半
ListNode* mid = slow->next;
slow->next = nullptr; // 切断链表
// 递归排序两半
ListNode* left = sortList(head);
ListNode* right = sortList(mid);
// 合并两个有序链表
return merge(left, right);
}
private:
// 合并两个有序链表
ListNode* merge(ListNode* l1, ListNode* l2) {
ListNode dummy(0); // 虚拟头节点
ListNode* tail = &dummy;
while (l1 != nullptr && l2 != nullptr) {
if (l1->val < l2->val) {
tail->next = l1;
l1 = l1->next;
} else {
tail->next = l2;
l2 = l2->next;
}
tail = tail->next;
}
// 将剩余部分连接到尾部
tail->next = (l1 != nullptr) ? l1 : l2;
return dummy.next;
}
};
// 辅助函数:打印链表
void printList(ListNode* head) {
while (head != nullptr) {
cout << head->val << " ";
head = head->next;
}
cout << endl;
}
// 测试代码
int main() {
// 创建示例链表:4->2->1->3
ListNode* head = new ListNode(4);
head->next = new ListNode(2);
head->next->next = new ListNode(1);
head->next->next->next = new ListNode(3);
Solution solution;
ListNode* result = solution.sortList(head);
printList(result); // 输出:1 2 3 4
return 0;
}
六、时间空间复杂度
- 时间复杂度:O(n log n),其中n是链表长度。归并排序的时间复杂度是O(n log n),每次分割需要O(n)时间,分割深度为O(log n)。
- 空间复杂度:O(log n),主要来自递归调用栈的深度,最坏情况下递归深度为O(log n)。
七、注意事项
- 递归基线条件:当链表为空或只有一个节点时,直接返回,因为已经有序。
- 快慢指针找中点:注意快指针的起始位置,通常让快指针从head->next开始,这样可以确保当链表长度为偶数时,慢指针指向第一个中间节点。
- 链表分割:在找到中点后,必须将中点的next设置为null,以真正将链表分成两半。
- 合并有序链表:使用虚拟头节点可以简化合并操作,避免处理空链表的情况。
- 内存管理:在C++中,需要注意内存管理,但本题不需要创建新节点,只是重新连接现有节点。
- 性能考虑:归并排序是链表排序的最佳选择之一,因为它不需要随机访问,且时间复杂度较好。对于链表,归并排序通常比快速排序更适用。
算法通俗讲解推荐阅读
【算法--链表】83.删除排序链表中的重复元素--通俗讲解
【算法--链表】删除排序链表中的重复元素 II--通俗讲解
【算法--链表】86.分割链表--通俗讲解
【算法】92.翻转链表Ⅱ--通俗讲解
【算法--链表】109.有序链表转换二叉搜索树--通俗讲解
【算法--链表】114.二叉树展开为链表--通俗讲解
【算法--链表】116.填充每个节点的下一个右侧节点指针--通俗讲解
【算法--链表】117.填充每个节点的下一个右侧节点指针Ⅱ--通俗讲解
【算法--链表】138.随机链表的复制--通俗讲解
【算法】143.重排链表--通俗讲解
【算法--链表】146.LRU缓存--通俗讲解
【算法--链表】147.对链表进行插入排序--通俗讲解