文章目录
1. 题目描述
给你两个单链表的头节点 headA
和 headB
,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null
。
图示两个链表在节点 c1
开始相交:
链表 A: a1 → a2 ↘
c1 → c2 → c3
链表 B: b1 → b2 → b3 ↗
题目数据保证整个链式结构中不存在环。
注意:函数返回结果后,链表必须保持其原始结构。
示例 1:
输入:intersectVal = 8, listA = [4,1,8,4,5], listB = [5,6,1,8,4,5], skipA = 2, skipB = 3
输出:Intersected at '8'
解释:相交节点的值为 8 (注意,如果两个链表相交则不能为 0)。
从各自的表头开始算起,链表 A 为 [4,1,8,4,5],链表 B 为 [5,6,1,8,4,5]。
在 A 中,相交节点前有 2 个节点;在 B 中,相交节点前有 3 个节点。
示例 2:
输入:intersectVal = 2, listA = [1,9,1,2,4], listB = [3,2,4], skipA = 3, skipB = 1
输出:Intersected at '2'
解释:相交节点的值为 2 (注意,如果两个链表相交则不能为 0)。
从各自的表头开始算起,链表 A 为 [1,9,1,2,4],链表 B 为 [3,2,4]。
在 A 中,相交节点前有 3 个节点;在 B 中,相交节点前有 1 个节点。
示例 3:
输入:intersectVal = 0, listA = [2,6,4], listB = [1,5], skipA = 3, skipB = 2
输出:null
解释:从各自的表头开始算起,链表 A 为 [2,6,4],链表 B 为 [1,5]。
由于这两个链表不相交,所以 intersectVal 必须为 0,而 skipA 和 skipB 可以是任意值。
这两个链表不相交,因此返回 null。
提示:
- listA 中节点数目为 m
- listB 中节点数目为 n
- 1 <= m, n <= 3 * 10^4
- 1 <= Node.val <= 10^5
- 0 <= skipA <= m
- 0 <= skipB <= n
- 如果 listA 和 listB 没有交点,intersectVal 为 0
- 如果 listA 和 listB 有交点,intersectVal == listA[skipA] == listB[skipB]
进阶: 你能否设计一个时间复杂度 O(m + n)、仅用 O(1) 内存的解决方案?
2. 理解题目
这道题要求我们找到两个单链表的相交节点。关键理解点:
- 相交的定义:两个链表在某个节点开始共享相同的节点(不仅仅是值相同,而是同一个节点对象)
- 相交后的结构:一旦两个链表相交,从相交点开始,后续的所有节点都是共享的
- 返回要求:返回相交的起始节点,如果不相交则返回 null
- 结构保持:算法执行后,原链表结构不能被破坏
2.1 链表节点定义
// 链表节点的定义
public class ListNode {
int val;
ListNode next;
ListNode(int x) {
val = x;
}
}
2.2 相交情况分析
两个链表可能的情况:
- 不相交:两个链表完全独立,没有共同节点
- 相交:两个链表在某个节点开始共享后续所有节点
相交的特点:
- 相交节点之后的所有节点都是共享的
- 两个链表的尾节点必须是同一个节点
- 相交点可能在链表的任何位置
3. 解法一:哈希表法
3.1 思路
最直观的解法是使用哈希表:
- 遍历链表A,将所有节点存入哈希表
- 遍历链表B,检查每个节点是否在哈希表中
- 第一个在哈希表中找到的节点就是相交节点
3.2 Java代码实现
import java.util.HashSet;
import java.util.Set;
class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
// 处理边界情况
if (headA == null || headB == null) {
return null;
}
// 使用HashSet存储链表A的所有节点
Set<ListNode> nodesInA = new HashSet<>();
// 遍历链表A,将所有节点加入HashSet
ListNode current = headA;
while (current != null) {
nodesInA.add(current);
current = current.next;
}
// 遍历链表B,检查每个节点是否在HashSet中
current = headB;
while (current != null) {
// 如果当前节点在HashSet中,说明找到了相交节点
if (nodesInA.contains(current)) {
return current;
}
current = current.next;
}
// 没有找到相交节点
return null;
}
}
3.3 代码详解
让我们详细分析每一步:
// 处理边界情况
if (headA == null || headB == null) {
return null;
}
- 如果任一链表为空,不可能有相交节点,直接返回null
// 使用HashSet存储链表A的所有节点
Set<ListNode> nodesInA = new HashSet<>();
// 遍历链表A,将所有节点加入HashSet
ListNode current = headA;
while (current != null) {
nodesInA.add(current);
current = current.next;
}
- 创建HashSet存储链表A的所有节点
- 遍历链表A,将每个节点(不是节点的值,而是节点对象本身)加入HashSet
- 这样我们就记录了链表A中的所有节点
// 遍历链表B,检查每个节点是否在HashSet中
current = headB;
while (current != null) {
// 如果当前节点在HashSet中,说明找到了相交节点
if (nodesInA.contains(current)) {
return current;
}
current = current.next;
}
- 遍历链表B的每个节点
- 检查当前节点是否在HashSet中
- 如果找到,说明这个节点既在链表A中又在链表B中,即为相交节点
- 返回第一个找到的相交节点
3.4 复杂度分析
- 时间复杂度: O(m + n),其中m和n分别是两个链表的长度。需要遍历两个链表各一次。
- 空间复杂度: O(m),需要存储链表A的所有节点。
3.5 适用场景
哈希表法是最直观的解法,容易理解和实现,适合:
- 对空间复杂度要求不严格的场景
- 需要快速实现的情况
- 作为其他复杂解法的对比基准
4. 解法二:双指针法(推荐)
4.1 思路
双指针法是最优雅的解法,核心思想是:
- 使用两个指针分别从两个链表的头部开始遍历
- 当指针到达链表末尾时,跳转到另一个链表的头部继续遍历
- 如果两个链表相交,两个指针最终会在相交节点相遇
- 如果不相交,两个指针最终都会变为null
关键洞察:
- 设链表A长度为m,链表B长度为n
- 设相交前A的长度为a,相交前B的长度为b,相交部分长度为c
- 则:m = a + c,n = b + c
- 指针pA走过的路径:a + c + b
- 指针pB走过的路径:b + c + a
- 两个指针走过的总长度相同:a + c + b = b + c + a
4.2 Java代码实现
class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
// 处理边界情况
if (headA == null || headB == null) {
return null;
}
// 初始化两个指针
ListNode pA = headA;
ListNode pB = headB;
// 当两个指针不相等时继续遍历
while (pA != pB) {
// 如果pA到达链表A的末尾,跳转到链表B的头部
// 否则移动到下一个节点
pA = (pA == null) ? headB : pA.next;
// 如果pB到达链表B的末尾,跳转到链表A的头部
// 否则移动到下一个节点
pB = (pB == null) ? headA : pB.next;
}
// 返回相交节点(如果不相交,pA和pB都为null)
return pA;
}
}
4.3 代码详解
让我们详细分析这个巧妙的算法:
// 处理边界情况
if (headA == null || headB == null) {
return null;
}
- 如果任一链表为空,不可能相交
// 初始化两个指针
ListNode pA = headA;
ListNode pB = headB;
- pA从链表A的头部开始
- pB从链表B的头部开始
// 当两个指针不相等时继续遍历
while (pA != pB) {
// 如果pA到达链表A的末尾,跳转到链表B的头部
// 否则移动到下一个节点
pA = (pA == null) ? headB : pA.next;
// 如果pB到达链表B的末尾,跳转到链表A的头部
// 否则移动到下一个节点
pB = (pB == null) ? headA : pB.next;
}
这是算法的核心部分:
- 当pA到达链表A末尾(pA.next为null,pA变为null)时,让pA跳转到链表B的头部
- 当pB到达链表B末尾时,让pB跳转到链表A的头部
- 这样确保两个指针走过的总路径长度相同
// 返回相交节点(如果不相交,pA和pB都为null)
return pA;
- 如果相交,pA和pB会在相交节点相遇
- 如果不相交,pA和pB最终都会变为null,此时pA == pB == null
4.4 算法执行过程示例
让我们通过示例1来跟踪算法执行:
链表A: [4,1,8,4,5],链表B: [5,6,1,8,4,5],相交于节点8
初始状态:
pA -> 4 (链表A)
pB -> 5 (链表B)
第1步:pA != pB
pA -> 1, pB -> 6
第2步:pA != pB
pA -> 8, pB -> 1
第3步:pA != pB
pA -> 4, pB -> 8
第4步:pA != pB
pA -> 5, pB -> 4
第5步:pA != pB
pA -> null (到达A末尾), pB -> 5
第6步:pA != pB
pA -> 5 (跳转到B头部), pB -> null (到达B末尾)
第7步:pA != pB
pA -> 6, pB -> 4 (跳转到A头部)
第8步:pA != pB
pA -> 1, pB -> 1
第9步:pA != pB
pA -> 8, pB -> 8
第10步:pA == pB (都指向相交节点8)
返回节点8
4.5 复杂度分析
- 时间复杂度: O(m + n),每个指针最多遍历两个链表一次。
- 空间复杂度: O(1),只使用了两个指针变量。
4.6 为什么这个算法有效?
数学证明:
- 设链表A长度为m,链表B长度为n
- 如果相交,设相交前A的独有部分长度为a,B的独有部分长度为b,共同部分长度为c
- 则:m = a + c,n = b + c
- pA的路径:a + c + b = m + b
- pB的路径:b + c + a = n + a
- 当pA走了m+b步,pB走了n+a步时:
- pA位置:从B头部走了b步,到达相交点
- pB位置:从A头部走了a步,到达相交点
- 此时pA == pB,算法结束
如果不相交:
- pA路径:m + n步后到达null
- pB路径:n + m步后到达null
- 此时pA == pB == null,算法结束
5. 解法三:长度差法
5.1 思路
另一种直观的解法是先计算两个链表的长度差:
- 分别计算两个链表的长度
- 让较长链表的指针先走长度差步
- 然后两个指针同时移动,直到相遇或到达末尾
5.2 Java代码实现
class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
// 处理边界情况
if (headA == null || headB == null) {
return null;
}
// 计算两个链表的长度
int lenA = getLength(headA);
int lenB = getLength(headB);
// 初始化两个指针
ListNode pA = headA;
ListNode pB = headB;
// 让较长链表的指针先走长度差步
if (lenA > lenB) {
// 链表A更长,pA先走 lenA - lenB 步
for (int i = 0; i < lenA - lenB; i++) {
pA = pA.next;
}
} else {
// 链表B更长,pB先走 lenB - lenA 步
for (int i = 0; i < lenB - lenA; i++) {
pB = pB.next;
}
}
// 两个指针同时移动,直到相遇或到达末尾
while (pA != null && pB != null) {
if (pA == pB) {
return pA; // 找到相交节点
}
pA = pA.next;
pB = pB.next;
}
// 没有相交节点
return null;
}
// 辅助方法:计算链表长度
private int getLength(ListNode head) {
int length = 0;
ListNode current = head;
while (current != null) {
length++;
current = current.next;
}
return length;
}
}
5.3 代码详解
// 计算两个链表的长度
int lenA = getLength(headA);
int lenB = getLength(headB);
- 使用辅助方法计算两个链表的长度
// 让较长链表的指针先走长度差步
if (lenA > lenB) {
// 链表A更长,pA先走 lenA - lenB 步
for (int i = 0; i < lenA - lenB; i++) {
pA = pA.next;
}
} else {
// 链表B更长,pB先走 lenB - lenA 步
for (int i = 0; i < lenB - lenA; i++) {
pB = pB.next;
}
}
- 计算长度差,让较长链表的指针先移动
- 这样确保两个指针到链表末尾的距离相同
// 两个指针同时移动,直到相遇或到达末尾
while (pA != null && pB != null) {
if (pA == pB) {
return pA; // 找到相交节点
}
pA = pA.next;
pB = pB.next;
}
- 两个指针同步移动
- 如果相交,它们会在相交节点相遇
- 如果不相交,它们会同时到达末尾(null)
5.4 复杂度分析
- 时间复杂度: O(m + n),需要遍历两个链表计算长度,然后再遍历一次找相交点。
- 空间复杂度: O(1),只使用了常数个变量。
6. 解法四:栈法
6.1 思路
使用栈的特性(后进先出)来解决:
- 将两个链表的所有节点分别压入两个栈
- 同时从两个栈顶弹出节点进行比较
- 最后一个相同的节点就是相交节点
6.2 Java代码实现
import java.util.Stack;
class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
// 处理边界情况
if (headA == null || headB == null) {
return null;
}
// 创建两个栈
Stack<ListNode> stackA = new Stack<>();
Stack<ListNode> stackB = new Stack<>();
// 将链表A的所有节点压入栈A
ListNode current = headA;
while (current != null) {
stackA.push(current);
current = current.next;
}
// 将链表B的所有节点压入栈B
current = headB;
while (current != null) {
stackB.push(current);
current = current.next;
}
// 从栈顶开始比较,找到最后一个相同的节点
ListNode intersection = null;
while (!stackA.isEmpty() && !stackB.isEmpty()) {
ListNode nodeA = stackA.pop();
ListNode nodeB = stackB.pop();
if (nodeA == nodeB) {
intersection = nodeA; // 更新相交节点
} else {
break; // 找到第一个不同的节点,停止
}
}
return intersection;
}
}
6.3 代码详解
// 将链表A的所有节点压入栈A
ListNode current = headA;
while (current != null) {
stackA.push(current);
current = current.next;
}
- 遍历链表A,将所有节点压入栈A
- 栈顶是链表A的最后一个节点
// 从栈顶开始比较,找到最后一个相同的节点
ListNode intersection = null;
while (!stackA.isEmpty() && !stackB.isEmpty()) {
ListNode nodeA = stackA.pop();
ListNode nodeB = stackB.pop();
if (nodeA == nodeB) {
intersection = nodeA; // 更新相交节点
} else {
break; // 找到第一个不同的节点,停止
}
}
- 从两个栈顶同时弹出节点进行比较
- 如果相同,更新相交节点
- 如果不同,说明已经超过了相交部分,停止比较
6.4 复杂度分析
- 时间复杂度: O(m + n),需要遍历两个链表各一次。
- 空间复杂度: O(m + n),需要两个栈存储所有节点。
7. 详细步骤分析与示例跟踪
让我们通过具体示例详细跟踪双指针法的执行过程。
7.1 示例1:有相交的情况
输入:
- 链表A: [4,1,8,4,5]
- 链表B: [5,6,1,8,4,5]
- 相交于值为8的节点
执行过程:
初始状态:
链表A: 4 -> 1 -> 8 -> 4 -> 5 -> null
链表B: 5 -> 6 -> 1 -> 8 -> 4 -> 5 -> null
pA指向4, pB指向5
步骤1: pA=4, pB=5 (不相等)
pA移动到1, pB移动到6
步骤2: pA=1, pB=6 (不相等)
pA移动到8, pB移动到1
步骤3: pA=8, pB=1 (不相等)
pA移动到4, pB移动到8
步骤4: pA=4, pB=8 (不相等)
pA移动到5, pB移动到4
步骤5: pA=5, pB=4 (不相等)
pA移动到null, pB移动到5
步骤6: pA=null, pB=5 (不相等)
pA跳转到链表B头部(5), pB移动到null
步骤7: pA=5, pB=null (不相等)
pA移动到6, pB跳转到链表A头部(4)
步骤8: pA=6, pB=4 (不相等)
pA移动到1, pB移动到1
步骤9: pA=1, pB=1 (不相等,但值相同,注意这里比较的是节点对象)
pA移动到8, pB移动到8
步骤10: pA=8, pB=8 (相等!找到相交节点)
返回节点8
7.2 示例2:无相交的情况
输入:
- 链表A: [2,6,4]
- 链表B: [1,5]
- 无相交
执行过程:
初始状态:
链表A: 2 -> 6 -> 4 -> null
链表B: 1 -> 5 -> null
pA指向2, pB指向1
步骤1: pA=2, pB=1 (不相等)
pA移动到6, pB移动到5
步骤2: pA=6, pB=5 (不相等)
pA移动到4, pB移动到null
步骤3: pA=4, pB=null (不相等)
pA移动到null, pB跳转到链表A头部(2)
步骤4: pA=null, pB=2 (不相等)
pA跳转到链表B头部(1), pB移动到6
步骤5: pA=1, pB=6 (不相等)
pA移动到5, pB移动到4
步骤6: pA=5, pB=4 (不相等)
pA移动到null, pB移动到null
步骤7: pA=null, pB=null (相等!)
返回null(无相交)
8. 常见错误与优化
8.1 常见错误
-
比较节点值而不是节点对象:
// 错误:比较节点的值 if (pA.val == pB.val) { return pA; } // 正确:比较节点对象 if (pA == pB) { return pA; }
-
忘记处理边界情况:
// 错误:没有检查空链表 public ListNode getIntersectionNode(ListNode headA, ListNode headB) { ListNode pA = headA; // ... } // 正确:先检查边界情况 public ListNode getIntersectionNode(ListNode headA, ListNode headB) { if (headA == null || headB == null) { return null; } // ... }
-
双指针法中的跳转逻辑错误:
// 错误:跳转条件不正确 pA = (pA.next == null) ? headB : pA.next; // 正确:当pA为null时才跳转 pA = (pA == null) ? headB : pA.next;
-
栈法中忘记更新相交节点:
// 错误:找到第一个相同节点就返回 if (nodeA == nodeB) { return nodeA; } // 正确:继续比较,找到最早的相交节点 if (nodeA == nodeB) { intersection = nodeA; } else { break; }
8.2 性能优化
-
提前终止优化:
// 优化:如果两个链表的尾节点不同,肯定不相交 public ListNode getIntersectionNode(ListNode headA, ListNode headB) { if (headA == null || headB == null) { return null; } // 找到两个链表的尾节点 ListNode tailA = headA; while (tailA.next != null) { tailA = tailA.next; } ListNode tailB = headB; while (tailB.next != null) { tailB = tailB.next; } // 如果尾节点不同,肯定不相交 if (tailA != tailB) { return null; } // 继续使用双指针法 // ... }
-
减少重复计算:
// 在长度差法中,可以在计算长度的同时记录尾节点 private int getLengthAndTail(ListNode head, ListNode[] tail) { int length = 0; ListNode current = head; while (current != null) { length++; if (current.next == null) { tail[0] = current; // 记录尾节点 } current = current.next; } return length; }
9. 扩展题目与应用
9.1 相关题目
-
LeetCode 141. 环形链表:
判断链表中是否有环,也可以使用双指针法(快慢指针) -
LeetCode 142. 环形链表 II:
找到环形链表中环的起始节点 -
LeetCode 19. 删除链表的倒数第N个节点:
使用双指针法,一个指针先走N步
9.2 实际应用场景
-
内存管理:
- 检测对象引用的共享
- 垃圾回收中的引用分析
-
数据结构设计:
- 实现共享数据结构
- 优化内存使用
-
图算法:
- 检测图中的公共路径
- 寻找最短公共祖先
-
版本控制系统:
- Git中的分支合并点检测
- 代码历史的公共提交点
10. 完整的 Java 解决方案
以下是推荐的双指针法完整实现,包含详细注释:
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* }
* }
*/
public class Solution {
/**
* 找到两个单链表的相交节点
* 使用双指针法,时间复杂度O(m+n),空间复杂度O(1)
*
* @param headA 链表A的头节点
* @param headB 链表B的头节点
* @return 相交节点,如果不相交返回null
*/
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
// 边界情况:如果任一链表为空,不可能相交
if (headA == null || headB == null) {
return null;
}
// 初始化两个指针,分别指向两个链表的头部
ListNode pA = headA;
ListNode pB = headB;
// 核心思想:让两个指针走过相同的路径长度
// pA路径:链表A + 链表B
// pB路径:链表B + 链表A
// 如果相交,两个指针会在相交点相遇
// 如果不相交,两个指针最终都会变为null
while (pA != pB) {
// 当pA到达链表A末尾时,跳转到链表B头部
// 否则移动到下一个节点
pA = (pA == null) ? headB : pA.next;
// 当pB到达链表B末尾时,跳转到链表A头部
// 否则移动到下一个节点
pB = (pB == null) ? headA : pB.next;
}
// 返回相交节点(如果不相交,pA和pB都为null)
return pA;
}
}
10.1 测试用例
public class TestIntersection {
public static void main(String[] args) {
Solution solution = new Solution();
// 测试用例1:有相交的情况
// 创建链表A: 4 -> 1 -> 8 -> 4 -> 5
ListNode a1 = new ListNode(4);
ListNode a2 = new ListNode(1);
ListNode c1 = new ListNode(8); // 相交节点
ListNode c2 = new ListNode(4);
ListNode c3 = new ListNode(5);
a1.next = a2;
a2.next = c1;
c1.next = c2;
c2.next = c3;
// 创建链表B: 5 -> 6 -> 1 -> 8 -> 4 -> 5
ListNode b1 = new ListNode(5);
ListNode b2 = new ListNode(6);
ListNode b3 = new ListNode(1);
b1.next = b2;
b2.next = b3;
b3.next = c1; // 指向相交节点
ListNode result1 = solution.getIntersectionNode(a1, b1);
System.out.println("测试用例1结果: " + (result1 != null ? result1.val : "null"));
// 期望输出: 8
// 测试用例2:无相交的情况
ListNode d1 = new ListNode(2);
ListNode d2 = new ListNode(6);
ListNode d3 = new ListNode(4);
ListNode e1 = new ListNode(1);
ListNode e2 = new ListNode(5);
d1.next = d2;
d2.next = d3;
e1.next = e2;
ListNode result2 = solution.getIntersectionNode(d1, e1);
System.out.println("测试用例2结果: " + (result2 != null ? result2.val : "null"));
// 期望输出: null
}
}
11. 算法比较与选择
11.1 各解法对比
解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
---|---|---|---|---|
哈希表法 | O(m+n) | O(m) | 直观易懂,实现简单 | 需要额外空间 |
双指针法 | O(m+n) | O(1) | 空间最优,代码简洁 | 理解稍难 |
长度差法 | O(m+n) | O(1) | 逻辑清晰,易于理解 | 代码稍长 |
栈法 | O(m+n) | O(m+n) | 思路直观 | 空间消耗大 |
11.2 推荐选择
-
面试推荐:双指针法
- 时间和空间复杂度都是最优的
- 代码简洁优雅
- 体现了算法思维
-
学习理解:长度差法
- 逻辑最直观
- 容易理解和记忆
- 适合初学者
-
快速实现:哈希表法
- 最容易想到和实现
- 不容易出错
- 适合时间紧张的情况
12. 总结与技巧
12.1 解题要点
- 理解相交的定义:是节点对象相同,不是节点值相同
- 掌握双指针技巧:让两个指针走过相同的路径长度
- 处理边界情况:空链表、单节点链表等
- 选择合适的解法:根据时间和空间要求选择最优解法
12.2 学习收获
通过学习相交链表问题,你可以掌握:
- 链表操作的基本技巧
- 双指针法的巧妙应用
- 空间和时间复杂度的权衡
- 多种解法的对比分析
12.3 面试技巧
如果在面试中遇到此类问题:
- 先确认题目要求(是节点相交还是值相交)
- 分析不同解法的优缺点
- 选择最优解法并解释原理
- 考虑边界情况和错误处理
- 分析时间和空间复杂度
12.4 记忆技巧
双指针法的核心思想可以这样记忆:
- “你走过我走过的路,我走过你走过的路”
- “如果有缘分(相交),我们终会相遇”
- “如果没有缘分(不相交),我们都会走到尽头(null)”