前言
在算法面试中,链表相关题目占据重要地位。今天我们将深入解析两道经典的链表题目: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) 时间复杂度和常数空间复杂度的要求。常用的排序算法中,只有归并排序能满足这两个条件。
归并排序的核心思想
- 分割:将链表分成两半
- 递归:分别对两半进行排序
- 合并:将两个有序链表合并成一个
代码实现
/**
* 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),递归栈深度
- 关键技巧:分治思想,递归合并
两题的关联与对比
相似之处
- 都涉及链表合并:都需要
merge辅助函数 - 都使用分治思想:递归分割问题
- 都要求 O(n log n) 复杂度
不同之处
- 输入形式:148题是单个链表,23题是多个链表
- 处理策略:148题先分割再合并,23题直接合并
- 复杂度来源: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);
}
面试建议
- 熟练掌握链表基本操作:遍历、反转、合并
- 理解分治思想:将大问题分解为小问题
- 注意边界条件:空链表、单节点链表
- 分析时间空间复杂度:确保满足题目要求
结语
LeetCode 148 和 23 是链表算法的经典代表,掌握它们不仅能解决具体的编程问题,更重要的是理解了分治思想和归并排序在链表上的应用。这类题目的解法往往优雅且高效,体现了算法设计的美妙之处。
在实际面试中,面试官往往会从简单版本开始(如合并两个有序链表),然后逐步增加难度。因此,扎实掌握基础算法模板,灵活运用各种技巧,是解决这类问题的关键。