1. 链表算法基础与解题框架
链表作为数据结构中的经典类型,在算法面试中出现的频率极高。与数组不同,链表通过指针连接各个节点,这种非连续存储的特性带来了独特的操作方式和解题思路。掌握链表问题的核心在于理解指针操作和递归思维,下面我将从基础开始,逐步拆解LeetCode中常见的链表问题解法。
1.1 链表数据结构特点
链表由节点(Node)组成,每个节点包含两个部分:数据域(val)和指针域(next)。单链表的基本结构如下:
java复制class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
链表操作有几个关键特性需要特别注意:
- 头节点处理:很多操作需要特殊处理头节点,使用虚拟头节点(dummy node)可以简化逻辑
- 指针丢失:在修改next指针前,必须提前保存后续节点的引用
- 边界条件:空链表、单节点链表、操作头节点/尾节点等情况需要单独考虑
1.2 链表问题通用解题框架
解决链表问题时,我通常会遵循以下思考框架:
-
确定解法模式:判断问题适合迭代还是递归解法。迭代通常需要维护多个指针,递归则需要明确定义递归函数的作用和终止条件。
-
处理边界条件:考虑空链表、单节点链表、操作头尾节点等特殊情况。
-
指针操作规划:在纸上画出指针变化前后的链表状态,确保不会出现指针丢失或循环引用。
-
复杂度分析:明确时间复杂度和空间复杂度要求,选择最优解法。
-
测试验证:用简单测试用例验证代码,特别注意边界情况。
2. 合并两个有序链表
合并两个有序链表是链表操作的基础问题,它展示了如何同时遍历两个链表并进行指针操作。这个问题有两种经典解法:递归和迭代。
2.1 递归解法详解
递归解法的核心思想是:比较两个链表当前节点的值,选择较小的节点作为合并后的当前节点,然后递归处理剩余部分。
java复制public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
// 递归终止条件:任一链表为空,直接返回另一个
if (l1 == null) return l2;
if (l2 == null) return l1;
// 选择较小节点作为当前节点,并递归合并剩余部分
if (l1.val <= l2.val) {
l1.next = mergeTwoLists(l1.next, l2);
return l1;
} else {
l2.next = mergeTwoLists(l1, l2.next);
return l2;
}
}
注意事项:
- 递归终止条件必须放在最前面,防止空指针异常
- 每次递归调用都会消耗栈空间,链表过长可能导致栈溢出
- 时间复杂度O(n+m),空间复杂度O(n+m)(递归栈深度)
2.2 迭代解法详解
迭代解法使用双指针遍历两个链表,通过比较节点值决定连接顺序,并使用虚拟头节点简化操作。
java复制public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(-1); // 虚拟头节点
ListNode curr = dummy; // 当前指针
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
curr.next = l1;
l1 = l1.next;
} else {
curr.next = l2;
l2 = l2.next;
}
curr = curr.next; // 移动当前指针
}
// 连接剩余部分
curr.next = (l1 != null) ? l1 : l2;
return dummy.next; // 返回真实头节点
}
常见错误:
- 忘记移动当前指针(curr = curr.next)
- 没有正确处理其中一个链表提前遍历完的情况
- 不使用虚拟头节点导致头节点处理复杂
提示:在实际面试中,迭代解法通常更受青睐,因为它避免了递归的栈溢出风险,且空间复杂度更低(O(1))。
3. 两数相加问题
两数相加问题要求我们用链表表示的两个数字相加,返回结果链表。这个问题考察了链表遍历和进位处理的技巧。
3.1 算法思路与实现
java复制public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0); // 虚拟头节点
ListNode curr = dummy;
int carry = 0; // 进位
while (l1 != null || l2 != null) {
// 获取当前位的值,链表为空则为0
int x = (l1 != null) ? l1.val : 0;
int y = (l2 != null) ? l2.val : 0;
// 计算和与进位
int sum = x + y + carry;
carry = sum / 10;
curr.next = new ListNode(sum % 10);
// 移动指针
curr = curr.next;
if (l1 != null) l1 = l1.next;
if (l2 != null) l2 = l2.next;
}
// 处理最后的进位
if (carry > 0) {
curr.next = new ListNode(carry);
}
return dummy.next;
}
关键点解析:
- 使用虚拟头节点简化链表构建
- 循环条件为
l1 != null || l2 != null,确保较长的链表能完全处理 - 每次计算都要考虑前一位的进位
- 循环结束后仍需检查是否有剩余进位
3.2 边界条件与测试用例
测试时应考虑以下情况:
- 两个链表长度不同:如 123 + 45678
- 有连续进位:如 999 + 1
- 最后一位有进位:如 5 + 5
- 一个链表为空:如 123 + null
4. 删除链表倒数第N个节点
这个问题考察如何定位链表中的特定位置节点,并展示了双指针技巧的典型应用。
4.1 两次遍历法
java复制public ListNode removeNthFromEnd(ListNode head, int n) {
// 第一次遍历:计算链表长度
int len = 0;
ListNode curr = head;
while (curr != null) {
len++;
curr = curr.next;
}
// 处理删除头节点的情况
if (len == n) {
return head.next;
}
// 第二次遍历:找到目标节点的前驱
curr = head;
for (int i = 0; i < len - n - 1; i++) {
curr = curr.next;
}
// 删除目标节点
curr.next = curr.next.next;
return head;
}
4.2 双指针法(一次遍历)
更高效的方法是使用快慢指针,让快指针先走n步,然后快慢指针同步移动。
java复制public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode fast = dummy;
ListNode slow = dummy;
// 快指针先走n步
for (int i = 0; i < n; i++) {
fast = fast.next;
}
// 同步移动直到快指针到达末尾
while (fast.next != null) {
fast = fast.next;
slow = slow.next;
}
// 删除目标节点
slow.next = slow.next.next;
return dummy.next;
}
注意事项:
- 使用虚拟头节点统一处理删除头节点的情况
- 快指针先走n步后,需要检查是否已经超出链表长度
- 循环终止条件是fast.next != null,这样slow会停在目标节点的前驱
5. 两两交换链表节点
这个问题要求我们交换相邻的两个节点,考察了指针操作的精确控制能力。
5.1 迭代解法
java复制public ListNode swapPairs(ListNode head) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode curr = dummy;
while (curr.next != null && curr.next.next != null) {
// 保存要交换的两个节点
ListNode first = curr.next;
ListNode second = curr.next.next;
// 执行交换
first.next = second.next;
curr.next = second;
second.next = first;
// 移动curr指针
curr = first;
}
return dummy.next;
}
5.2 递归解法
java复制public ListNode swapPairs(ListNode head) {
// 递归终止条件
if (head == null || head.next == null) {
return head;
}
// 新的头节点是原第二个节点
ListNode newHead = head.next;
// 递归处理剩余部分,并连接到第一个节点后面
head.next = swapPairs(newHead.next);
// 将原第一个节点接到新头节点后面
newHead.next = head;
return newHead;
}
常见错误:
- 指针操作顺序错误导致链表断裂
- 忘记更新curr指针的位置
- 递归终止条件不完整
6. K个一组翻转链表
这是链表问题中的难题,综合运用了反转链表和分组处理技巧。
6.1 算法实现
java复制public ListNode reverseKGroup(ListNode head, int k) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode pre = dummy; // 上一组的尾节点
ListNode end = dummy; // 当前组的尾节点
while (end.next != null) {
// 定位当前组的尾节点
for (int i = 0; i < k && end != null; i++) {
end = end.next;
}
if (end == null) break; // 不足k个,不反转
// 记录下一组的头节点
ListNode nextGroup = end.next;
// 断开当前组与下一组的连接
end.next = null;
// 记录当前组的头节点
ListNode groupStart = pre.next;
// 反转当前组
pre.next = reverse(groupStart);
// 连接反转后的当前组与下一组
groupStart.next = nextGroup;
// 更新pre和end指针
pre = groupStart;
end = pre;
}
return dummy.next;
}
// 反转链表辅助函数
private ListNode reverse(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
}
6.2 关键点分析
- 分组定位:使用end指针找到每组k个节点的边界
- 断开连接:反转前需要断开当前组与下一组的连接
- 反转操作:使用标准的链表反转算法
- 重新连接:反转后需要将当前组的尾节点连接到下一组的头节点
- 指针更新:移动pre和end指针到新的位置,准备处理下一组
注意事项:
- 需要处理最后一组不足k个节点的情况
- 反转前后必须正确处理组与组之间的连接
- 使用虚拟头节点简化边界条件处理
7. 链表问题常见错误与调试技巧
在实际解决链表问题时,经常会遇到各种错误。以下是我总结的一些常见问题和解决方法:
7.1 空指针异常
这是最常见的错误,通常发生在:
- 访问null节点的next或val属性
- 没有正确处理空链表的情况
- 指针移动时没有检查是否为null
解决方法:
- 在访问节点属性前总是检查是否为null
- 使用虚拟头节点避免头节点特殊处理
- 仔细检查循环条件和终止条件
7.2 指针丢失
在修改指针时,如果不注意顺序,可能会导致后续节点无法访问。
示例:
java复制// 错误的指针操作
curr.next = prev;
prev = curr;
curr = curr.next; // 这里curr.next已经被修改为prev了
正确做法:
java复制ListNode next = curr.next; // 先保存下一个节点
curr.next = prev;
prev = curr;
curr = next;
7.3 循环引用
在反转链表等操作中,如果不小心创建了循环引用,会导致无限循环或栈溢出。
调试技巧:
- 打印链表内容,检查是否有循环
- 使用小规模测试用例手动跟踪指针变化
- 在纸上画出每一步操作后的链表状态
8. 链表问题的扩展与变种
掌握了基本链表操作后,可以尝试解决一些更有挑战性的变种问题:
8.1 环形链表检测
使用快慢指针检测链表中是否存在环:
java复制public boolean hasCycle(ListNode head) {
if (head == null) return false;
ListNode slow = head;
ListNode fast = head.next;
while (fast != null && fast.next != null) {
if (slow == fast) return true;
slow = slow.next;
fast = fast.next.next;
}
return false;
}
8.2 寻找链表中间节点
同样使用快慢指针技巧:
java复制public ListNode middleNode(ListNode head) {
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
8.3 复杂链表的复制
带有随机指针的链表复制问题:
java复制public Node copyRandomList(Node head) {
if (head == null) return null;
// 第一步:复制每个节点,插入到原节点后面
Node curr = head;
while (curr != null) {
Node copy = new Node(curr.val);
copy.next = curr.next;
curr.next = copy;
curr = copy.next;
}
// 第二步:设置复制节点的random指针
curr = head;
while (curr != null) {
if (curr.random != null) {
curr.next.random = curr.random.next;
}
curr = curr.next.next;
}
// 第三步:分离两个链表
curr = head;
Node newHead = head.next;
while (curr != null) {
Node temp = curr.next;
curr.next = temp.next;
if (temp.next != null) {
temp.next = temp.next.next;
}
curr = curr.next;
}
return newHead;
}
链表问题的核心在于理解指针操作和递归思维。通过反复练习这些经典问题,可以培养对链表结构的直觉和操作技巧。在实际面试中,建议先从简单的方法开始,逐步优化,并始终注意边界条件的处理。