排序链表 图解题解
链表没有下标,快排靠不住——归并排序只需顺着走和合并,天生适合链表。
整理一叠扑克牌,快排靠随机翻到中间那张;链表只能从头顺着走,没有下标,快排直接废了。归并排序不用随机访问——只需顺着走、再把两条有序链一节节合并,这两件事链表都很擅长。找中点时慢指针从头、快指针从第二个节点出发,fast 每次走两步、slow 走一步,fast 到尾时 slow 正好停在前半段末尾,从那里切断;两半各自递归排序,再合并。
这道题到底在问什么
- 输入
- 4→2→7→1→5→3
- 输出
- 1→2→3→4→5→7
最优解:为什么这么做
一句话答案:LeetCode 148 排序链表要求 O(n log n) 时间,标准解是归并排序:快慢指针找中点并断链,左右两半递归排好,再按「比头取小」合并两条有序链。链表版合并只改指针、不需要辅助数组,时间 O(n log n),递归栈 O(log n),自底向上迭代还能做到真正 O(1) 空间。
为什么链表排序首选归并排序
在 O(n log n) 的几大排序里,堆排序要按下标随机访问,链表做不到;快速排序依赖来回交换元素,链表上实现别扭且最坏会退化到 O(n²)。归并排序的两个核心动作——从中间劈开、把两条有序序列合并——恰好都是链表擅长的顺序操作。更妙的是,数组版归并需要 O(n) 辅助数组存放合并结果,链表版只需把现成节点重新接线,一个新节点都不用建,归并排序在链表上反而比在数组上更省。
快慢指针找中点,fast 为什么从 head.next 出发
找中点用快慢指针:slow 每次走一步、fast 每次走两步,fast 到尾时 slow 停在中间附近。细节在起点:fast 从 head.next 出发而不是 head,这样偶数长度时 slow 停在左半的末尾,劈出来的左半不长于右半、且两半都非空。若 fast 从 head 出发,长度为二的链会被劈成「整条加空链」,子问题规模没有变小,递归永远到不了底。
劈开之后为什么必须把链断掉
找到中点后要做两件事:记下右半的头 mid,并把左半末尾的 next 置空,也就是代码里的 slow.next = None。断链这一步最容易漏——不置空的话,左半在物理上仍然连着右半,对左半递归时快慢指针会一路跑进右半,子问题没有缩小,结果就是死循环或者栈溢出。断干净之后,左右两条子链才是彼此独立的更小问题,递归才能收敛到单节点这个天然有序的终点。
合并两条有序链为什么是对的
合并阶段用一个哨兵节点起头,反复比较两条链的头节点,把较小者摘下来接到结果尾部,某一条走完后把另一条整段接上。正确性来自两条链各自有序:两个头当中较小的那个,必然小于等于双方剩余的所有节点,把它放到结果的下一个位置不可能出错。值相等时优先取左半的节点(比较用小于等于),能让相同元素保持原有先后,也就是稳定排序。
复杂度怎么数,空间还能再省吗
每层递归把链劈成两半,共约 log n 层;每一层的全部合并加起来恰好扫 n 个节点,总时间 O(n log n)。合并不新建节点,额外开销只剩递归调用栈的 O(log n)。想做到严格 O(1) 空间,可以改成自底向上的迭代写法:先按子段长度一两两合并,再按二、四逐层翻倍,用循环替代递归,逻辑同构但不吃栈空间——这是本题最常见的面试追问。
▶ 动画逐步走查(共 51 步)——想跟着动画一帧帧对照就展开
- 3记住「拆半 → 递归 → 合并」这条主线,下面逐帧演示。
- 4快慢指针起步:slow 指头 4,fast 指 2(从 head.next 起)。slow 每步走 1 个、fast 每步走 2 个,fast 到尾时 slow 正好停在中点。
- 5slow 前移到 2,fast 前移两步到 1。fast 还没到尾,继续。
- 6slow 前移到 7,fast 前移两步到 3。fast 还没到尾,继续。
- 7在中点后断开:左半 4→2→7、右半 1→5→3。接下来对左右两半各自递归排序。
- 8递归左半 [4,2,7]:再对半 → [4,2] 与 [7]。[7] 只剩一个节点,天然有序(标绿)。
- 9[4,2] 再对半 → [4] 与 [2],都到单节点。递归到底:每段只剩一个,全部天然有序。
- 10递归右半 [1,5,3]:对半 → [1] 与 [5,3]。[1] 单节点有序。
- 11[5,3] 再对半 → [5] 与 [3],都到单节点。现在 6 个单节点 [4][2][7][1][5][3] 全部有序,开始一层层合并回去。
- 12归并 4 与 2:两个指针 p、q 各指两段头,比头取小,接到结果尾。
- 13比两段头:左 4 vs 右 2。右 2 更小,取它。
- 14把 2 接到结果尾(绿)。从右段取走的节点标灰,该段指针前移。
- 15右段已空,把左段剩下的整段直接接上,当前接 4。
- 16把 4 接到结果尾(绿)。从左段取走的节点标灰,该段指针前移。
- 174 与 2 合成有序段 2→4。
- 18归并 2→4 与 7:两个指针 p、q 各指两段头,比头取小,接到结果尾。
- 19比两段头:左 2 vs 右 7。左 2 更小(相等优先取左·稳定),取它。
- 20把 2 接到结果尾(绿)。从左段取走的节点标灰,该段指针前移。
- 21比两段头:左 4 vs 右 7。左 4 更小(相等优先取左·稳定),取它。
- 22把 4 接到结果尾(绿)。从左段取走的节点标灰,该段指针前移。
- 23左段已空,把右段剩下的整段直接接上,当前接 7。
- 24把 7 接到结果尾(绿)。从右段取走的节点标灰,该段指针前移。
- 252→4 与 7 合成有序段 2→4→7。
- 26归并 5 与 3:两个指针 p、q 各指两段头,比头取小,接到结果尾。
- 27比两段头:左 5 vs 右 3。右 3 更小,取它。
- 28把 3 接到结果尾(绿)。从右段取走的节点标灰,该段指针前移。
- 29右段已空,把左段剩下的整段直接接上,当前接 5。
- 30把 5 接到结果尾(绿)。从左段取走的节点标灰,该段指针前移。
- 315 与 3 合成有序段 3→5。
- 32归并 1 与 3→5:两个指针 p、q 各指两段头,比头取小,接到结果尾。
- 33比两段头:左 1 vs 右 3。左 1 更小(相等优先取左·稳定),取它。
- 34把 1 接到结果尾(绿)。从左段取走的节点标灰,该段指针前移。
- 35左段已空,把右段剩下的整段直接接上,当前接 3。
- 36把 3 接到结果尾(绿)。从右段取走的节点标灰,该段指针前移。
- 37左段已空,把右段剩下的整段直接接上,当前接 5。
- 38把 5 接到结果尾(绿)。从右段取走的节点标灰,该段指针前移。
- 391 与 3→5 合成有序段 1→3→5。
- 40归并 2→4→7 与 1→3→5:两个指针 p、q 各指两段头,比头取小,接到结果尾。
- 41比两段头:左 2 vs 右 1。右 1 更小,取它。
- 42把 1 接到结果尾(绿)。从右段取走的节点标灰,该段指针前移。
- 43比两段头:左 2 vs 右 3。左 2 更小(相等优先取左·稳定),取它。
- 44把 2 接到结果尾(绿)。从左段取走的节点标灰,该段指针前移。
- 45比两段头:左 4 vs 右 3。右 3 更小,取它。
- 46把 3 接到结果尾(绿)。从右段取走的节点标灰,该段指针前移。
- 47比两段头:左 4 vs 右 5。左 4 更小(相等优先取左·稳定),取它。
- 48把 4 接到结果尾(绿)。从左段取走的节点标灰,该段指针前移。
- 49比两段头:左 7 vs 右 5。右 5 更小,取它。
- 50把 5 接到结果尾(绿)。从右段取走的节点标灰,该段指针前移。
- 51右段已空,把左段剩下的整段直接接上,当前接 7。
- 52把 7 接到结果尾(绿)。从左段取走的节点标灰,该段指针前移。
- 532→4→7 与 1→3→5 合成有序段 1→2→3→4→5→7。
⚠️ 容易写错的地方
✗ 错:断链时忘了 slow.next = None
✓ 对:找到中点后必须断开
不断开左半还连着右半,递归会死循环 / 无限链
✗ 错:fast 从 head 起导致偶数链中点偏右
✓ 对:fast 从 head.next 起
保证左半 ≤ 右半、两半至少各 1 个,递归能收敛
✗ 错:合并相等时随意取
✓ 对:相等优先取 left(<=)
保持稳定排序,避免重复元素乱序
完整代码(Python / C++ / Java)
Python
def sortList(head):
if not head or not head.next: return head
slow, fast = head, head.next # 快慢指针找中点
while fast and fast.next:
slow, fast = slow.next, fast.next.next
mid = slow.next; slow.next = None # 从中点断开
left, right = sortList(head), sortList(mid)
dummy = tail = ListNode() # 合并两条有序链
while left and right:
if left.val <= right.val:
tail.next, left = left, left.next
else:
tail.next, right = right, right.next
tail = tail.next
tail.next = left or right
return dummy.nextC++
ListNode* sortList(ListNode* head){
if(!head || !head->next) return head;
ListNode *slow=head, *fast=head->next;
while(fast && fast->next){ slow=slow->next; fast=fast->next->next; }
ListNode* mid=slow->next; slow->next=nullptr;
ListNode *l=sortList(head), *r=sortList(mid);
ListNode dummy, *tail=&dummy;
while(l && r){
if(l->val <= r->val){ tail->next=l; l=l->next; }
else { tail->next=r; r=r->next; }
tail=tail->next;
}
tail->next = l ? l : r;
return dummy.next;
}Java
public ListNode sortList(ListNode head){
if(head == null || head.next == null) return head;
ListNode slow = head, fast = head.next;
while(fast != null && fast.next != null){
slow = slow.next; fast = fast.next.next;
}
ListNode mid = slow.next; slow.next = null;
ListNode left = sortList(head), right = sortList(mid);
ListNode dummy = new ListNode(0), tail = dummy;
while(left != null && right != null){
if(left.val <= right.val){ tail.next = left; left = left.next; }
else { tail.next = right; right = right.next; }
tail = tail.next;
}
tail.next = (left != null) ? left : right;
return dummy.next;
}复杂度
时间
O(n log n)
log n 层递归,每层合并扫 n 个节点
空间
O(1)*
原地接线不新建节点;*递归栈 O(log n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 排序链表 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
如何把空间降到真正 O(1)(去掉递归栈)?+
改自底向上迭代归并:从子段长 1、2、4…逐层倍增,每层把相邻两段两两合并,用循环而非递归,省掉 O(log n) 栈。
快慢指针为什么 fast 从 head.next 起?+
让偶数长度时 slow 停在左中点,保证拆出的左半不超过右半、两半都非空,递归才能严格变小并收敛。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 排序链表 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。