LeetCode 148 & 23 题解:链表排序与合并的艺术

64 阅读5分钟

前言

在算法面试中,链表相关题目占据重要地位。今天我们将深入解析两道经典的链表题目:LeetCode 148 - 排序链表LeetCode 23 - 合并K个升序链表。这两道题不仅考察链表基本操作,还涉及高级算法思想,是检验链表掌握程度的绝佳题目。

LeetCode 148: 排序链表

题目描述

给你链表的头结点 head,请将其按升序排列并返回排序后的链表。

示例:

  • 输入:[4,2,1,3] → 输出:[1,2,3,4]
  • 输入:[-1,5,3,4,0] → 输出:[-1,0,3,4,5]

解题思路

这道题的关键在于O(n log n) 时间复杂度常数空间复杂度的要求。常用的排序算法中,只有归并排序能满足这两个条件。

归并排序的核心思想

  1. 分割:将链表分成两半
  2. 递归:分别对两半进行排序
  3. 合并:将两个有序链表合并成一个

代码实现

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
var sortList = function(head) {
    // 递归终止条件
    if (!head || !head.next) return head;
    
    // 使用快慢指针找到中点
    let slow = head;
    let fast = head;
    let prev = null; // 用于断开链表
    
    while (fast && fast.next) {
        prev = slow;
        slow = slow.next;
        fast = fast.next.next;
    }
    
    // 断开链表,分成两部分
    prev.next = null;
    
    // 递归排序两部分
    let left = sortList(head);
    let right = sortList(slow);
    
    // 合并两个有序链表
    return merge(left, right);
};

// 合并两个有序链表的辅助函数
function merge(list1, list2) {
    let dummy = new ListNode(0);
    let current = dummy;
    
    // 比较两个链表的节点值,较小的接入结果链表
    while (list1 && list2) {
        if (list1.val <= list2.val) {
            current.next = list1;
            list1 = list1.next;
        } else {
            current.next = list2;
            list2 = list2.next;
        }
        current = current.next;
    }
    
    // 处理剩余节点
    current.next = list1 || list2;
    
    return dummy.next;
}

算法分析

  • 时间复杂度:O(n log n),符合要求
  • 空间复杂度:O(log n),递归栈的深度
  • 关键技巧:快慢指针找中点,递归分割,合并有序链表

LeetCode 23: 合并K个升序链表

题目描述

给你一个链表数组,每个链表都已经按升序排列,请你将所有链表合并到一个升序链表中,返回合并后的链表。

示例:

  • 输入:lists = [[1,4,5],[1,3,4],[2,6]] → 输出:[1,1,2,3,4,4,5,6]

解题思路

这道题有多种解法,我们介绍两种最优解:

方法一:分治法(推荐)

var mergeKLists = function(lists) {
    if (!lists || lists.length === 0) return null;
    if (lists.length === 1) return lists[0];
    
    // 分治合并
    return mergeRange(lists, 0, lists.length - 1);
};

function mergeRange(lists, start, end) {
    if (start === end) return lists[start];
    
    let mid = Math.floor((start + end) / 2);
    let left = mergeRange(lists, start, mid);
    let right = mergeRange(lists, mid + 1, end);
    
    return merge(left, right); // 使用上面定义的merge函数
}

方法二:优先队列(最小堆)

// JavaScript 没有内置堆,需要自己实现或使用第三方库
var mergeKLists = function(lists) {
    if (!lists || lists.length === 0) return null;
    
    // 使用最小堆存储所有链表的头节点
    let heap = [];
    
    // 将所有非空链表的头节点加入堆
    for (let i = 0; i < lists.length; i++) {
        if (lists[i]) {
            heap.push(lists[i]);
            heap.sort((a, b) => a.val - b.val); // 简单排序模拟堆
        }
    }
    
    let dummy = new ListNode(0);
    let current = dummy;
    
    while (heap.length > 0) {
        // 取出最小值节点
        let minNode = heap.shift();
        current.next = minNode;
        current = current.next;
        
        // 如果该节点还有下一个节点,加入堆
        if (minNode.next) {
            heap.push(minNode.next);
            heap.sort((a, b) => a.val - b.val);
        }
    }
    
    return dummy.next;
};

完整的分治法实现

var mergeKLists = function(lists) {
    if (!lists || lists.length === 0) return null;
    
    return mergeLists(lists, 0, lists.length - 1);
};

function mergeLists(lists, left, right) {
    if (left === right) return lists[left];
    if (left > right) return null;
    
    let mid = Math.floor((left + right) / 2);
    let l1 = mergeLists(lists, left, mid);
    let l2 = mergeLists(lists, mid + 1, right);
    
    return merge(l1, l2);
}

function merge(l1, l2) {
    let dummy = new ListNode(0);
    let current = dummy;
    
    while (l1 && l2) {
        if (l1.val <= l2.val) {
            current.next = l1;
            l1 = l1.next;
        } else {
            current.next = l2;
            l2 = l2.next;
        }
        current = current.next;
    }
    
    current.next = l1 || l2;
    return dummy.next;
}

算法分析

  • 时间复杂度:O(N log k),其中 N 是所有节点总数,k 是链表数量
  • 空间复杂度:O(log k),递归栈深度
  • 关键技巧:分治思想,递归合并

两题的关联与对比

相似之处

  1. 都涉及链表合并:都需要 merge 辅助函数
  2. 都使用分治思想:递归分割问题
  3. 都要求 O(n log n) 复杂度

不同之处

  1. 输入形式:148题是单个链表,23题是多个链表
  2. 处理策略:148题先分割再合并,23题直接合并
  3. 复杂度来源:148题复杂度来自排序,23题来自多个链表

关键知识点总结

1. 快慢指针找中点

// 标准模板
let slow = head;
let fast = head;
let prev = null;

while (fast && fast.next) {
    prev = slow;
    slow = slow.next;
    fast = fast.next.next;
}
prev.next = null; // 断开链表

2. 合并两个有序链表

// 标准模板
function merge(list1, list2) {
    let dummy = new ListNode(0);
    let current = dummy;
    
    while (list1 && list2) {
        if (list1.val <= list2.val) {
            current.next = list1;
            list1 = list1.next;
        } else {
            current.next = list2;
            list2 = list2.next;
        }
        current = current.next;
    }
    
    current.next = list1 || list2;
    return dummy.next;
}

3. 分治递归模板

function divideAndConquer(arr, start, end) {
    if (start === end) return baseCase(arr[start]);
    if (start > end) return emptyCase();
    
    let mid = Math.floor((start + end) / 2);
    let left = divideAndConquer(arr, start, mid);
    let right = divideAndConquer(arr, mid + 1, end);
    
    return combine(left, right);
}

面试建议

  1. 熟练掌握链表基本操作:遍历、反转、合并
  2. 理解分治思想:将大问题分解为小问题
  3. 注意边界条件:空链表、单节点链表
  4. 分析时间空间复杂度:确保满足题目要求

结语

LeetCode 148 和 23 是链表算法的经典代表,掌握它们不仅能解决具体的编程问题,更重要的是理解了分治思想归并排序在链表上的应用。这类题目的解法往往优雅且高效,体现了算法设计的美妙之处。

在实际面试中,面试官往往会从简单版本开始(如合并两个有序链表),然后逐步增加难度。因此,扎实掌握基础算法模板,灵活运用各种技巧,是解决这类问题的关键。