148. 排序链表

LeetCode 原题链接 题目

题意

给定链表的头节点 head,请将链表按升序排列,并返回排序后的链表。要求时间复杂度为 O(nlogn),并尽量使用常数级额外空间。

实现思路

链表不能像数组一样随机访问,无法高效地通过下标定位元素,因此更适合使用归并排序。归并排序只需要顺序遍历链表,且合并两个有序链表时可以直接调整节点指针:

  • 使用快慢指针找到链表中点,将链表拆成左右两段。
  • 分别递归排序左右两段链表。
  • 将两个有序链表合并成一个有序链表。
  • 递归终止条件是链表为空,或者链表只有一个节点。

例如链表 4 -> 2 -> 1 -> 3 的拆分过程如下:

4 -> 2 -> 1 -> 3
       /       \
   4 -> 2     1 -> 3
   /   \      /   \
  4     2    1     3

叶子链表天然有序,之后逐层合并即可得到 1 -> 2 -> 3 -> 4

选择 fast = head.next 的原因是:拆分时需要让 slow 停在左半部分的尾节点,而不是仅仅找到一个“中间节点”。这样执行 slow.next = null 后,左右链表的长度都会变小,递归才能结束。

为什么不使用数组排序

可以先遍历链表把所有值放入数组,再调用数组排序,但这种做法需要 O(n) 的额外空间,也没有利用链表本身适合顺序合并的特点。直接对链表做归并排序可以复用原节点,避免重新创建业务节点。

代码实现

// sortList 函数接收链表头节点 head,返回排序后的链表头节点
var sortList = function (head) {
  // 如果链表为空,或者链表只有一个节点,说明已经有序,直接返回
  if (head === null || head.next === null) {
    // 返回当前链表头节点,作为递归的终止结果
    return head;
  }

  // slow 是慢指针,用来寻找链表中点,初始指向头节点
  let slow = head;
  // fast 是快指针,每次走两步;从 head.next 开始可以让 slow 停在左半部分的尾节点
  let fast = head.next;

  // 当 fast 还能继续向后走时,说明 slow 还没有到达中间位置
  while (fast !== null && fast.next !== null) {
    // slow 每次只走一步
    slow = slow.next;
    // fast 每次走两步
    fast = fast.next.next;
  }

  // slow.next 是右半部分链表的头节点,需要先保存下来
  const rightHead = slow.next;
  // 将 slow.next 置为 null,把原链表断开成左右两个独立链表
  slow.next = null;

  // 递归排序左半部分链表,head 仍然是左半部分的头节点
  const left = sortList(head);
  // 递归排序右半部分链表,rightHead 是右半部分的头节点
  const right = sortList(rightHead);

  // 将两个已经有序的链表合并,并返回合并后的头节点
  return merge(left, right);
};

// merge 函数接收两个升序链表 list1 和 list2,返回合并后的升序链表
function merge(list1, list2) {
  // 创建虚拟头节点,方便统一处理结果链表的第一个节点
  const dummy = new ListNode(0);
  // cur 指向结果链表的尾节点,后续新接入的节点都挂在 cur.next 上
  let cur = dummy;

  // 当两个链表都还有节点时,持续比较两个链表当前节点的值
  while (list1 !== null && list2 !== null) {
    // 如果 list1 当前节点的值更小或相等,就把 list1 当前节点接到结果链表后面
    if (list1.val <= list2.val) {
      // cur.next 指向 list1 当前节点,完成节点拼接
      cur.next = list1;
      // list1 指针后移,准备比较 list1 的下一个节点
      list1 = list1.next;
    } else {
      // 否则说明 list2 当前节点更小,就把 list2 当前节点接到结果链表后面
      // cur.next 指向 list2 当前节点,完成节点拼接
      cur.next = list2;
      // list2 指针后移,准备比较 list2 的下一个节点
      list2 = list2.next;
    }

    // cur 后移到结果链表的最新尾节点
    cur = cur.next;
  }

  // 循环结束后,至少有一个链表已经为空;另一个链表剩余部分本身已经有序,直接接上即可
  cur.next = list1 === null ? list2 : list1;

  // dummy 是虚拟头节点,真正的排序结果从 dummy.next 开始
  return dummy.next;
}

复杂度分析

  • 时间复杂度:O(nlogn)。链表会被拆分为 logn 层,每一层合并时总共遍历 n 个节点。
  • 空间复杂度:O(logn),来自递归调用栈;合并过程只使用了常数个指针,并没有新建业务节点。
  • 稳定性:合并时使用 list1.val <= list2.val,相同值优先取左链表节点,因此排序是稳定的。

如果需要严格的 O(1) 额外空间,可以改用自底向上的迭代归并排序:从长度为 1 的有序子链表开始,两两合并,再将子链表长度扩大为 2、4、8...。本题常见的递归写法更直观,面试中应根据题目是否强调常数级空间来选择实现。

关键点

  • 找中点时让 fasthead.next 开始,可以保证 slow 停在左半部分的尾节点,方便执行 slow.next = null 切断链表。
  • 这里不是单纯为了找到“中间节点”,而是为了找到“左半部分的尾节点”。如果 fasthead 开始,两个节点的链表中 slow 会停在最后一个节点,导致 rightHead = slow.nextnull,链表没有被真正拆开,递归会一直处理同一段链表。
  • 例如链表 4 -> 2,让 fast = head.next 时,循环不会执行,slow 停在 4,可以拆成 42 两段;这能保证递归规模持续变小。
  • 合并时直接复用原链表节点,不需要创建新的业务节点,只需要一个虚拟头节点辅助拼接。
  • 必须先保存 rightHead = slow.next,再断开 slow.next,否则右半部分会丢失。