hot100 排序链表(148)

本题采用自顶向下的归并排序算法(又称"二分滑窗切分与归并重组法")解决单链表的升序排列问题。其核心本质是利用分治策略,通过快慢指针在同向拓扑结构中步进以锁定链表中点,执行物理截断将其逐级裂变为单节点子问题,再通过双指针有序归并实现链路的重新编排。当前提供的源码实现了在时间复杂度 O(N log N) 和系统递归栈空间复杂度 O(log N) 条件下的对数阶局部最优检索,最终走向是完全重写各节点指针的指向并返回全局升序的头节点。

一、 问题本质与数据模型

对于由 ListNode 构成的无序单链表,由于其物理内存分布的不连续性与单向指针域 next 的不可逆性,无法像数组结构一样在 O(1) 时间内进行随机物理寻址。因此,诸如堆排序、快速排序等依赖高频随机交换位置的算法在链表结构上会引入极高的指针维护开销。

归并排序(Merge Sort)天然适合链表这一拓扑结构。在重组阶段,链表的归并操作仅需要改变现有节点的指针指向,即可在 O(1) 的额外堆内存损耗下完成有序拼接。

算法引入了以下核心数据控制流模型:

  1. "快慢指针双倍步进截断模型" :设置 fastslowpre 三个指针,通过快指针移动速度为慢指针两倍的物理规律,当快指针触底时,慢指针精准卡位在后半段的起始点。通过将前驱节点 pre.next 置为 null,实现原链表的完全物理解耦。

  2. "分治递归收敛模型":将长链表持续一分为二,直至分裂出的子链表仅包含单个节点(或为空),此时局部自然满足升序条件,构成分治的基准退出边界(Base Case)。

二、 算法演进对比

在解决链表全排序问题时,自顶向下归并法在指针开销与时间收敛上表现出确定的线性对数级特征:

解法名称 时间复杂度 空间复杂度 核心原理 物理瓶颈 / 缺陷
外部数组中转排序法 O(N log N) O(N) 将链表节点值全部复制到数组中,利用快速排序后重新物理构建一条新链表 产生了与链表规模呈正比的外部存储空间消耗,破坏了原地修改的原则
自顶向下归并法(当前解法) O(N log N) O(log N) 通过快慢指针执行中点物理分割,深度递归后进行原地双指针归并缝合 系统的递归调用栈深度与链表长度的对数呈正比,无法实现绝对意义上的 O(1) 额外空间
自底向上迭代归并法 O(N log N) O(1) 利用循环引入固定的步长(1, 2, 4...),在链表内部逐段执行局部归并与桥接 控制变量极其繁琐,需要引入大量复杂的指针域边界缝合逻辑,代码可读性低

三、 核心分支控制逻辑与决策证明

当前源码的控制流由一个递归主函数 sortList、一个切分函数 mid 以及一个二路归并函数 merge 组合而成,其内部决策及物理证明如下:

1. 递归终止条件边界:if (head == null || head.next == null)

  • 执行 :直接返回当前 head

  • 数学证明:当节点数为 0 或 1 时,链表在拓扑逻辑上不具备产生逆序的可能性。

  • 结论:当前子区间已收敛为最小物理单元,无需进一步切分,直接向上层触发归并流程。

2. 快慢指针中点切分:mid(ListNode head)

  • 执行fast = fast.next.next; slow = slow.next; 并在循环后执行 pre.next = null;

  • 数学证明 :设链表总节点数为 N。由于 fast 每次步进 2 个节点,slow 每次步进 1 个节点,当 fast 遍历完 N 个节点触底时,slow 精准移动了 N/2 步,即停留于中间节点。

  • 结论pre 作为 slow 的前驱,执行 pre.next = null 彻底斩断了前半段与后半段的物理关联,将输入链表均分为两条独立的子链表,其头节点分别为 headslow

3. 双指针原地有序归并:merge(ListNode p, ListNode q)

  • 执行 :利用 dummy 节点作为控制哨兵,通过 while (p != null && q != null) 执行线性扫描,较小者挂载到 cur.next

  • 结论:该结构保证了两个已排序的子链表能够以 O(1) 的额外堆内存损耗,重组为一条满足全局升序的新链表。

四、 算法执行状态机步进示例

以输入无序链表 head = [4, 2, 1, 3] 为例,自顶向下切分及自底向上归并的整体状态演进过程如下表所示:

步骤阶段 作用的局部拓扑数据 执行的控制动作 产生的中间断开状态 / 归并状态 拓扑物理剩余状态说明
初始调用 4, 2, 1, 3 启动主函数 sortList 进入第一层分治 链表未发生拆解
第一级切分 4, 2, 1, 3 调用 mid 函数锁定中点 断裂为两段:head1=[4, 2], head2=[1, 3] 成功切分为左右两个子链表区域
第二级左切分 head1=[4, 2] 对左段递归调用 mid 断裂为两段:[4][2] 满足终止条件,退化为基础单元
第一级左归并 [4][2] 对左侧两个单节点执行 merge 归并重组为升序链表:[2, 4] 左侧子区间局部升序完成
第二级右切分 head2=[1, 3] 对右段递归调用 mid 断裂为两段:[1][3] 满足终止条件,退化为基础单元
第一级右归并 [1][3] 对右侧两个单节点执行 merge 归并重组为升序链表:[1, 3] 右侧子区间局部升序完成
最终全局归并 [2, 4][1, 3] 触发最外层 merge(head1, head2) 通过双指针重定向,输出 [1, 2, 3, 4] 全链表有序化,指针重组完毕

五、 源码实现

复制代码
/**
 * 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; }
 * }
 */
class Solution {
    public ListNode sortList(ListNode head) {
        // 基准出口边界:若链表为空或仅剩单一节点,无需排序,直接终止递归
        if (head == null || head.next == null) {
            return head;
        }
        
        // 步骤 1:寻找当前链表的中点,并将链表从中间物理断开,返回后半段的头节点
        ListNode head2 = mid(head);
        
        // 步骤 2:对前半段链表执行递归归并排序
        ListNode head1 = sortList(head);
        // 对后半段链表执行递归归并排序
        head2 = sortList(head2);
        
        // 步骤 3:将两段已经实现局部升序的链表融合成一个全局有序的链表
        return merge(head1, head2);
    }

    private ListNode mid(ListNode head) {
        // 初始化快、慢指针以及用于记录慢指针前驱的 pre 指针
        ListNode fast = head;
        ListNode slow = head;
        ListNode pre = head;
        
        // 快慢指针双倍速推进控制网
        while (fast != null && fast.next != null) {
            pre = slow;          // 记录 slow 指针移动前的前驱位置
            fast = fast.next.next; // 快指针单次步进 2 个节点
            slow = slow.next;      // 慢指针单次步进 1 个节点
        }
        
        // 核心切分动作:斩断前半段尾部与后半段头部的指针链接
        pre.next = null;
        
        // 返回后半段链表的头节点
        return slow;
    }

    private ListNode merge(ListNode p, ListNode q) {
        // 创建虚拟哑节点 dummy,用作合并过程中的固定边界哨兵
        ListNode dummy = new ListNode();
        // 初始化工作控制指针 cur 指向哑节点
        ListNode cur = dummy;
        
        // 双指针线性扫描比对
        while (p != null && q != null) {
            if (p.val < q.val) {
                cur.next = p;
                p = p.next;
            } else {
                cur.next = q;
                q = q.next;
            }
            // 新链表控制指针同步后移
            cur = cur.next;
        }
        
        // 边界残留处理:将尚未消耗完毕的非空剩余链表段直接整段挂载
        cur.next = (p != null) ? p : q;
        
        // 返回剥离哨兵后的真实有序链表头节点
        return dummy.next;
    }
}

六、 复杂度分析

1. 时间复杂度:O(N log N)

  • 分析

    • 切分阶段 :每一层递归都需要调用一次 mid 函数,其耗时与当前子链表的长度呈线性关系。对于长度为 N 的链表,二分切分的总层数为 log N。

    • 归并阶段 :同层所有子链表执行 merge 操作时的基本比较次数之和恒等于 N。

  • 结论:整个归并排序模型的计算工作量可以表达为每一层线性扫描耗时与对数阶层数的乘积,整体时间损耗恒定为 O(N log N)。

2. 空间复杂度:O(log N)

  • 分析:从堆内存(Heap)的角度来看,算法完全是在原节点上修改指针域指向,没有开辟任何与输入规模成正比的全新临时节点。然而,由于该解法采用了自顶向下的递归架构,在程序执行期间,操作系统需要开辟隐式的系统递归调用栈来保存当前的局部变量状态。

  • 结论:二分递归树的最大深度为 log N,因此系统栈空间的物理消耗恒定为对数阶 O(log N)。

相关推荐
码完就睡1 小时前
数据结构——串的模式匹配算法(BF算法、KMP算法)
数据结构·算法
林泽毅1 小时前
Qwen3.5 DPO 模型对齐实战(pytrio训练版)
人工智能·python·算法
wear工程师1 小时前
synchronized 锁升级:多数人还在背 JDK8 的答案,偏向锁早在 JDK18 就没了
java·面试
荣码1 小时前
LangGraph多步工作流:Agent不够用的时候,我踩了4个坑
java·python
imuliuliang1 小时前
算法中的随机化思想及其复杂度收益评估的技术7
算法
是小李呀1 小时前
Java集合扩容机制解析:ArrayList与HashMap底层原理及性能差异
java
SimonKing1 小时前
FreeLLMAPI:24 家厂商、128 个模型、1 个 Key智能切换所有可用的模型
java·后端·程序员
阿pin1 小时前
Java随笔-JVM内存模型
java·开发语言·jvm·内存模型