1. 链表基础与LeetCode203题解析
1.1 链表数据结构详解
链表作为基础数据结构,由一系列节点(Node)组成,每个节点包含两个部分:数据域(val)和指针域(next)。与数组不同,链表在内存中不是连续存储的,而是通过指针将零散的内存块串联起来。
Java中的典型链表节点定义如下:
java复制class ListNode {
int val; // 数据域:存储节点的值
ListNode next; // 指针域:指向下一个节点
ListNode() {} // 无参构造
ListNode(int val) {
this.val = val;
} // 带值构造
ListNode(int val, ListNode next) {
this.val = val;
this.next = next;
} // 全参构造
}
在实际应用中,我们最常用的是带值构造方法。例如构建链表1→2→3可以这样实现:
java复制// 链式构造法(推荐)
ListNode head = new ListNode(1,
new ListNode(2,
new ListNode(3)));
// 分步构造法
ListNode node3 = new ListNode(3);
ListNode node2 = new ListNode(2, node3);
ListNode node1 = new ListNode(1, node2);
关键技巧:链表题目中90%的bug都源于指针操作错误。务必在纸上画出节点间的指向关系,特别是进行插入、删除操作时。
1.2 LeetCode203题:移除链表元素
题目要求删除链表中所有值为指定值的节点。以输入1→2→6→3→4→5→6,val=6为例,应返回1→2→3→4→5。
1.2.1 直接处理法
java复制public ListNode removeElements(ListNode head, int val) {
// 处理头节点连续匹配的情况
while(head != null && head.val == val) {
head = head.next;
}
if(head == null) return null;
ListNode curr = head;
while(curr.next != null) {
if(curr.next.val == val) {
curr.next = curr.next.next; // 跳过目标节点
} else {
curr = curr.next; // 正常移动指针
}
}
return head;
}
实现要点:
- 先处理头节点可能连续匹配的情况
- 使用
curr.next进行判断而非curr,便于执行删除操作 - 时间复杂度O(n),空间复杂度O(1)
1.2.2 虚拟头节点法(推荐)
java复制public ListNode removeElements(ListNode head, int val) {
ListNode dummy = new ListNode(-1, head); // 创建虚拟头节点
ListNode curr = dummy;
while(curr.next != null) {
if(curr.next.val == val) {
curr.next = curr.next.next;
} else {
curr = curr.next;
}
}
return dummy.next; // 返回真实头节点
}
优势对比:
| 方法 | 优点 | 缺点 |
|---|---|---|
| 直接处理 | 无需额外空间 | 需要特殊处理头节点 |
| 虚拟节点 | 统一处理逻辑 | 多使用O(1)空间 |
实战经验:虚拟头节点法能简化边界条件处理,建议作为链表问题的首选模式。虽然多用一个节点空间,但在现代编程中这个开销完全可以忽略。
2. LeetCode707题:设计链表实现
2.1 完整链表类设计
实现一个包含以下操作的链表类:
get(index):获取指定位置节点的值addAtHead(val):头部插入addAtTail(val):尾部插入addAtIndex(index, val):指定位置插入deleteAtIndex(index):删除指定位置节点
java复制class MyLinkedList {
private class Node {
int val;
Node next;
Node(int val) { this.val = val; }
}
private int size;
private Node head;
public MyLinkedList() {
size = 0;
head = null;
}
public int get(int index) {
if(index < 0 || index >= size) return -1;
Node curr = head;
for(int i=0; i<index; i++) curr = curr.next;
return curr.val;
}
public void addAtHead(int val) {
Node newNode = new Node(val);
newNode.next = head;
head = newNode;
size++;
}
public void addAtTail(int val) {
if(size == 0) {
addAtHead(val);
return;
}
Node curr = head;
while(curr.next != null) curr = curr.next;
curr.next = new Node(val);
size++;
}
public void addAtIndex(int index, int val) {
if(index < 0 || index > size) return;
if(index == 0) {
addAtHead(val);
return;
}
if(index == size) {
addAtTail(val);
return;
}
Node prev = head;
for(int i=0; i<index-1; i++) prev = prev.next;
Node newNode = new Node(val);
newNode.next = prev.next;
prev.next = newNode;
size++;
}
public void deleteAtIndex(int index) {
if(index < 0 || index >= size) return;
if(index == 0) {
head = head.next;
} else {
Node prev = head;
for(int i=0; i<index-1; i++) prev = prev.next;
prev.next = prev.next.next;
}
size--;
}
}
2.2 关键实现细节解析
2.2.1 边界条件处理
-
索引有效性检查:
get()和deleteAtIndex():index < 0 || index >= sizeaddAtIndex():index < 0 || index > size(允许在末尾添加)
-
空链表处理:
addAtTail()在size=0时直接调用addAtHead()- 所有操作前检查size避免NPE
2.2.2 指针移动技巧
java复制// 获取index-1位置的节点(插入/删除的前驱节点)
Node prev = head;
for(int i=0; i<index-1; i++) prev = prev.next;
易错点:循环终止条件是
i < index-1而非i < index,这是链表操作中最常见的off-by-one错误。
2.2.3 时间复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| get | O(n) | 需要遍历到指定位置 |
| addAtHead | O(1) | 直接修改头指针 |
| addAtTail | O(n) | 需要遍历到末尾 |
| addAtIndex | O(n) | 平均需要遍历n/2次 |
| deleteAtIndex | O(n) | 同addAtIndex |
3. 链表操作进阶技巧
3.1 双指针法的应用
双指针是解决链表问题的利器,常见场景包括:
- 快慢指针找中点:
java复制ListNode slow = head, fast = head;
while(fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// slow即为中点
- 判断环形链表:
java复制while(fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if(slow == fast) return true; // 相遇说明有环
}
3.2 递归处理链表
虽然递归会使用O(n)栈空间,但某些问题用递归会更直观:
java复制// 递归反转链表
public ListNode reverseList(ListNode head) {
if(head == null || head.next == null) return head;
ListNode newHead = reverseList(head.next);
head.next.next = head;
head.next = null;
return newHead;
}
3.3 调试与测试技巧
- 可视化链表:
java复制public static String printList(ListNode head) {
StringBuilder sb = new StringBuilder();
while(head != null) {
sb.append(head.val).append("->");
head = head.next;
}
sb.append("NULL");
return sb.toString();
}
- 常见测试用例:
- 空链表
- 单节点链表
- 头/尾节点操作
- 连续重复值
- 超界索引访问
4. 链表问题实战心得
-
指针操作四字诀:
- 增:
newNode.next = prev.next; prev.next = newNode; - 删:
prev.next = prev.next.next; - 改:
curr.val = newVal; - 查:
while(curr != null) { ... curr = curr.next; }
- 增:
-
虚拟头节点的三大优势:
- 统一头节点和其他节点的处理逻辑
- 避免空指针异常
- 简化代码逻辑(减少if-else分支)
-
效率优化方向:
- 对于频繁的尾部操作,可以维护tail指针
- 在已知长度的情况下,可以从尾部倒着遍历
- 对于大型链表,考虑使用跳表等变体结构
-
面试常见考察点:
- 边界条件处理能力
- 指针操作的准确性
- 时间/空间复杂度分析
- 代码的可读性和健壮性
在实际刷题过程中,建议先从基础操作开始,逐步过渡到复杂问题。每道题至少用两种方法实现(如迭代和递归),并比较它们的优劣。链表问题的核心在于指针操作,多画图、多调试是快速提升的不二法门。