1. 链表基础
链表节点定义
//简略版本
public class ListNode {
int val; // 节点值
ListNode next; // 指向下一个节点的指针
public ListNode(int val) {
this.val = val;
}
}
//详细版本
定义链表:
public class ListNode {
int val;
ListNode next;
public ListNode(int val){
this.val = val;
}
}
基本操作:
public class SingleLinkedList {
/**链表的头结点*/
ListNode head = null;
/**
* 链表添加结点:
* 找到链表的末尾结点,把新添加的数据作为末尾结点的后续结点
* @param data
*/
public void addNode(int data){
ListNode newNode = new ListNode(data);
if(head == null){
head = newNode;
return;
}
ListNode temp = head;
while(temp.next != null){
temp = temp.next;
}
temp.next = newNode;
}
/**
* 链表删除结点:
* 把要删除结点的前结点指向要删除结点的后结点,即直接跳过待删除结点
* @param index
* @return
*/
public boolean deleteNode(int index){
if(index<1 || index>length()){//待删除结点不存在
return false;
}
if(index == 1){//删除头结点
head = head.next;
return true;
}
ListNode preNode = head;
ListNode curNode = preNode.next;
int i = 1;
while(curNode != null){
if(i==index){//寻找到待删除结点
preNode.next = curNode.next;//待删除结点的前结点指向待删除结点的后结点
return true;
}
//当先结点和前结点同时向后移
preNode = preNode.next;
curNode = curNode.next;
i++;
}
return true;
}
/**
* 求链表的长度
* @return
*/
public int length(){
int length = 0;
ListNode curNode = head;
while(curNode != null){
length++;
curNode = curNode.next;
}
return length;
}
/**
* 打印结点
*/
public void printLink(){
ListNode curNode = head;
while(curNode !=null){
System.out.print(curNode.val+" ");
curNode = curNode.next;
}
System.out.println();
}
/**
* 查找正数第k个元素
*/
public ListNode findNode(int k){
if(k<1 || k>length()){//不合法的k
return null;
}
ListNode temp = head;
for(int i = 0; i<k-1; i++){
temp = temp.next;
}
return temp;
}
public static void main(String[]args){
SingleLinkedList myLinkedList = new SingleLinkedList();
//添加链表结点
myLinkedList.addNode(9);
myLinkedList.addNode(8);
myLinkedList.addNode(6);
myLinkedList.addNode(3);
myLinkedList.addNode(5);
//打印链表
myLinkedList.printLink();
System.out.println("链表结点个数为:" + myLinkedList.length());
myLinkedList.deleteNode(3);
myLinkedList.printLink();
}
}
常见链表类型
1. 单链表:
A -> B -> C -> null
2. 双链表:
null <- A <-> B <-> C -> null
3. 循环链表:
A -> B -> C -> A
2. 经典链表题目
2.1 反转链表
// 迭代方式
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next; // 保存下一个节点
curr.next = prev; // 反转指针
//就是往前移动 继续翻转指针
prev = curr; // 移动prev
curr = next; // 移动curr
}
return prev;
}
图解过程:
初始: 1 -> 2 -> 3 -> null
第一步: null <- 1 2 -> 3 -> null
第二步: null <- 1 <- 2 3 -> null
第三步: null <- 1 <- 2 <- 3
2.2 判断链表是否有环
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) return false;
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next; // 慢指针走一步
fast = fast.next.next; // 快指针走两步
if (slow == fast) return true; // 相遇说明有环
}
return false;
}
快慢指针原理:
无环:
1 -> 2 -> 3 -> null
快指针最终到达null
有环:
1 -> 2 -> 3 -> 4
^ |
|_________|
快慢指针最终相遇
我来用通俗易懂的方式解释判断链表是否有环的问题。
快慢指针解法(最常用)
想象一个环形跑道上有两个人在跑步:
- 一个人跑得快(快指针:一次走两步)
- 一个人跑得慢(慢指针:一次走一步)
如果跑道是直的(链表无环),快的人会先到终点。 如果跑道是环形的(链表有环),快的人总会从后面追上慢的人。
下面是代码实现:
public class ListNode {
int val;
ListNode next;
}
public boolean hasCycle(ListNode head) {
// 如果链表为空或只有一个节点,肯定没有环
if (head == null || head.next == null) {
return false;
}
// 设置快慢指针
ListNode slow = head;
ListNode fast = head;
// 快指针每次走两步,慢指针每次走一步
while (fast != null && fast.next != null) {
slow = slow.next; // 慢指针走一步
fast = fast.next.next; // 快指针走两步
// 如果快慢指针相遇,说明有环
if (slow == fast) {
return true;
}
}
// 如果快指针到达终点,说明没有环
return false;
}
为什么这个方法有效?
-
无环情况:
- 如果链表没有环,快指针一定会先到达链表末尾(null)
- 此时返回 false
-
有环情况:
- 假设环的长度是 K
- 慢指针每次走 1 步,快指针每次走 2 步
- 每一轮,快指针都会比慢指针多走 1 步
- 所以快指针一定会在某个时刻追上慢指针
时间复杂度分析
- 时间复杂度:O(N),其中 N 是链表的长度
- 空间复杂度:O(1),只使用了两个指针
实际应用场景
- 检测死循环
- 检测链表是否正确(在某些数据结构的实现中)
- 环形缓冲区的检测
这个算法也被称为 "Floyd's Cycle-Finding Algorithm" 或 "龟兔赛跑算法",是一个非常经典的算法。它不仅可以用来判断链表是否有环,还可以:
- 找到环的起始点
- 计算环的长度
- 在其他类似需要检测循环的场景中使用
2.3 找到链表中点
public ListNode findMiddle(ListNode head) {
if (head == null || head.next == null) return head;
ListNode slow = head;
ListNode fast = head;
while (fast.next != null && fast.next.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
3. 链表解题技巧
3.1 双指针技巧
-
快慢指针
- 找中点
- 判断环
- 找环入口
-
前后指针
- 删除倒数第N个节点
- 反转链表
3.2 虚拟头节点
// 删除指定值的节点
public ListNode removeElements(ListNode head, int val) {
ListNode dummy = new ListNode(0); // 虚拟头节点
dummy.next = 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;
}
4. 实用技巧总结
-
边界条件检查
- 空链表
- 单节点链表
- 两个节点的链表
-
画图帮助理解
- 在纸上画出链表
- 标注指针变化
-
考虑使用虚拟头节点
- 统一操作逻辑
- 避免空指针
-
注意指针丢失
- 保存下一个节点
- 谨慎修改指针
5. 具体问题详解
5.1 链表寻找中点问题
我来用通俗易懂的方式解释快慢指针找链表中点的方法。
快慢指针找中点的基本原理
想象一下在操场跑步的场景:
- 小明(慢指针)每次跑一步
- 小红(快指针)每次跑两步
- 当小红跑到终点时,小明正好在中点位置
代码实现
public ListNode findMiddle(ListNode head) {
// 处理特殊情况
if (head == null || head.next == null) {
return head;
}
// 定义快慢指针
ListNode slow = head;
ListNode fast = head;
// 当快指针能够继续移动时
while (fast.next != null && fast.next.next != null) {
slow = slow.next; // 慢指针每次走一步
fast = fast.next.next; // 快指针每次走两步
}
return slow; // 慢指针指向中点
}
详细解释
-
为什么能找到中点?
- 快指针每次走2步,慢指针每次走1步
- 当快指针走完整个链表时,慢指针刚好走到中间位置
- 这是因为快指针的速度是慢指针的2倍
-
举个例子: 假设有链表:1->2->3->4->5
- 初始状态:slow=1, fast=1
- 第一轮:slow=2, fast=3
- 第二轮:slow=3, fast=5
- 结束:slow指向中点3
-
两种情况:
- 奇数个节点:慢指针正好在中间
- 偶数个节点:慢指针在中间偏左的位置
注意事项
- 需要检查空链表和单节点链表
- 快指针移动时需要检查两步之内是否为null
- 如果要找到中间偏右的节点,可以调整循环条件为
while (fast != null && fast.next != null)
时间复杂度
- 时间复杂度:O(N),只需要遍历一次链表
- 空间复杂度:O(1),只使用了两个指针变量
这个方法的优点是简单高效,不需要事先知道链表长度,也不需要额外的存储空间。这就是为什么它在实际编程中被广泛使用的原因。
5.2 链表是否有回文结构
回文链表就像回文字符串一样,从前往后读和从后往前读是一样的。例如:
-
1->2->2->1 是回文链表
-
1->2->3->2->1 是回文链表
-
1->2->3->3->1 不是回文链表
解决方案
详细讲解这个判断回文链表的代码实现。
完整代码实现
public class IsPalindrome {
public static class ListNode {
int val;
ListNode next;
ListNode(int val) {
this.val = val;
}
}
public boolean isPalindrome(ListNode head) {
if (head == null || head.next == null) {
return true; // 空链表或单节点链表都是回文
}
// 1. 找中点
ListNode slow = head;
ListNode fast = head;
while (fast.next != null && fast.next.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 2. 反转后半部分
ListNode right = slow.next; // 后半部分的起始节点
slow.next = null; // 断开前后两部分
right = reverseList(right);
// 3. 比较两半是否相同
ListNode left = head;
boolean result = true;
//只比较右边大小的内容 和左边比 所以中点被排除了
while (right != null) {
if (left.val != right.val) {
result = false;
break;
}
left = left.next;
right = right.next;
}
return result;
}
private ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next; // 保存下一个节点
curr.next = prev; // 反转指针
prev = curr; // 移动prev
curr = next; // 移动curr
}
return prev;
}
}
详细解释
1. 找中点部分
ListNode slow = head;
ListNode fast = head;
while (fast.next != null && fast.next.next != null) {
slow = slow.next;
fast = fast.next.next;
}
- 使用快慢指针法找中点
slow指针每次走一步,fast指针每次走两步- 当
fast到达末尾时,slow就在中点位置 - 例如对于链表 1->2->3->2->1:
初始: 1 -> 2 -> 3 -> 2 -> 1 s,f 第一次移动后: 1 -> 2 -> 3 -> 2 -> 1 s f 第二次移动后: 1 -> 2 -> 3 -> 2 -> 1 s f
2. 反转后半部分
ListNode right = slow.next;
slow.next = null;// 断开连接,中点不参与比较
right = reverseList(right);
right指向后半部分的起始节点- 断开前后两部分的连接
- 调用
reverseList方法反转后半部分 - 反转过程详解:
原始后半部分:2 -> 1 第一步: 2 -> 1 p c 第二步: 2 <- 1 p c
3. 反转函数实现
private ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next; // 保存下一个节点
curr.next = prev; // 反转指针
prev = curr; // 移动prev
curr = next; // 移动curr
}
return prev;
}
- 使用三个指针:
prev、curr、next next保存下一个节点,防止链表断开curr.next = prev进行反转- 依次移动指针直到结束
4. 比较两半部分
这里注意 前半部分1->2->3 后半部分1->2,因为中点不参与对比,所以以短的right为准,到达终点前就结束循环了
ListNode left = head;
while (right != null) {
if (left.val != right.val) {
return false;
}
left = left.next;
right = right.next;
}
- 从头部和反转后的后半部分开始比较
- 逐个节点比较值是否相等
- 如果有不相等,则不是回文
- 全部比较完成且相等,则是回文
举例说明
以链表 1->2->3->2->1 为例:
- 找到中点3
- 将后半部分2->1反转成1->2
- 比较前半部分1->2和反转后的1->2
- 发现对应位置的值都相等,所以是回文
复杂度分析
- 时间复杂度:O(N)
- 找中点需要O(N/2)
- 反转需要O(N/2)
- 比较需要O(N/2)
- 总体是O(N)
- 空间复杂度:O(1)
- 只使用了几个指针变量
- 没有使用额外的数据结构
5.3 将单向链表按某值划分左边小,中间相等,右边大的形式
方法一:数组方式(就像排队分组)
想象一个班级的同学排队,要按照身高(pivot)分成三组:
- 矮的同学
- 和基准身高一样的同学
- 高的同学
public ListNode partition1(ListNode head, int pivot) {
if (head == null) return null;
// 第一步:数一下有多少人
int count = 0;
ListNode cur = head;
while (cur != null) {
count++;
cur = cur.next;
}
// 第二步:把所有人放进数组里
ListNode[] people = new ListNode[count];
cur = head;
for (int i = 0; i < count; i++) {
people[i] = cur;
cur = cur.next;
}
// 第三步:在数组里进行分组
int left = 0; // 小于区域的右边界
int right = count - 1; // 大于区域的左边界
int i = 0; // 当前处理的位置
while (i <= right) {
if (people[i].val < pivot) {
// 比基准小,放左边
swap(people, left++, i++);
} else if (people[i].val > pivot) {
// 比基准大,放右边
swap(people, right--, i);
} else {
// 相等,保持不动
i++;
}
}
// 第四步:重新排队(连接链表)
for (int j = 1; j < count; j++) {
people[j-1].next = people[j];
}
people[count-1].next = null;
return people[0];
}
方法二:三个小组直接分配(更省空间)
想象有三个小组,每个人来了直接分配到对应的组:
public ListNode partition2(ListNode head, int pivot) {
// 准备三个小组(每组记录头尾)
ListNode smallHead = null, smallTail = null; // 矮个子组
ListNode equalHead = null, equalTail = null; // 中等个子组
ListNode bigHead = null, bigTail = null; // 高个子组
// 遍历所有人,分配到对应的组
while (head != null) {
// 记住下一个人,因为待会要断开连接
ListNode next = head.next;
head.next = null;
// 根据身高分配到不同组
if (head.val < pivot) {
// 分配到矮个子组
if (smallHead == null) {
// 组里还没人
smallHead = head;
smallTail = head;
} else {
// 组里已经有人了,排到队尾
smallTail.next = head;
//然后新的节点 成为新的tail
smallTail = head;
}
}
else if (head.val == pivot) {
// 分配到中等个子组
if (equalHead == null) {
equalHead = head;
equalTail = head;
} else {
equalTail.next = head;
equalTail = head;
}
}
else {
// 分配到高个子组
if (bigHead == null) {
bigHead = head;
bigTail = head;
} else {
bigTail.next = head;
bigTail = head;
}
}
head = next;
}
// 最后,把三个小组连接起来
// 1. 先连接矮个子组和中等个子组
if (smallTail != null) {
// 如果有矮个子,就连到中等个子或高个子的头部
smallTail.next = equalHead != null ? equalHead : bigHead;
}
// 2. 再连接中等个子组和高个子组
if (equalTail != null) {
equalTail.next = bigHead;
}
// 3. 返回第一个非空组的头部
if (smallHead != null) return smallHead;
if (equalHead != null) return equalHead;
return bigHead;
}
举个具体例子
假设链表是:4->2->3->5->2,pivot=3
方法二的处理过程:
1. 初始状态:4->2->3->5->2
2. 处理第一个节点4:
大于组:4
中等组:空
小于组:空
3. 处理第二个节点2:
大于组:4
中等组:空
小于组:2
4. 处理第三个节点3:
大于组:4
中等组:3
小于组:2
5. 处理第四个节点5:
大于组:4->5
中等组:3
小于组:2
6. 处理第五个节点2:
大于组:4->5
中等组:3
小于组:2->2
7. 最后连接三个组:
结果:2->2->3->4->5
两种方法比较
-
方法一(数组方式)
- 优点:好理解,像排队一样
- 缺点:需要额外空间存放所有节点
-
方法二(三组方式)
- 优点:不需要额外空间
- 缺点:需要维护六个指针,代码较复杂
在实际工作中,如果内存空间充足,可以用方法一;如果对内存要求严格,就用方法二。
pivot如何确定的
pivot(基准值)是作为输入参数给定的,不需要我们去找。它可以是任意值,具体取决于业务需求。
让我举几个例子说明:
例子1
// 假设链表:4 -> 2 -> 3 -> 5 -> 2
// pivot = 3
// 调用方法:
ListNode head = new ListNode(4);
head.next = new ListNode(2);
head.next.next = new ListNode(3);
head.next.next.next = new ListNode(5);
head.next.next.next.next = new ListNode(2);
partition(head, 3); // pivot是3
// 结果:2 -> 2 -> 3 -> 4 -> 5
// 小于3的:2,2
// 等于3的:3
// 大于3的:4,5
例子2
// 同样的链表:4 -> 2 -> 3 -> 5 -> 2
// 但pivot = 4
partition(head, 4); // pivot是4
// 结果:2 -> 2 -> 3 -> 4 -> 5
// 小于4的:2,2,3
// 等于4的:4
// 大于4的:5
实际应用场景
- 成绩分组
// 学生成绩链表:85 -> 60 -> 90 -> 70 -> 75
// pivot = 75(及格线)
partition(head, 75);
// 结果:60 -> 70 -> 75 -> 85 -> 90
// 不及格(<75):60,70
// 刚好及格(=75):75
// 优秀(>75):85,90
- 年龄分组
// 年龄链表:25 -> 18 -> 35 -> 40 -> 20
// pivot = 30(分界线)
partition(head, 30);
// 结果:25 -> 18 -> 20 -> 35 -> 40
// 青年(<30):25,18,20
// 中年(=30):无
// 中老年(>30):35,40
关键点
- pivot是外部传入的参数
- pivot的选择取决于具体的业务需求
- 同一个链表,不同的pivot值会得到不同的分组结果
- pivot不一定要是链表中存在的值
代码示例
public class Main {
public static void main(String[] args) {
// 创建链表:4 -> 2 -> 3 -> 5 -> 2
ListNode head = new ListNode(4);
head.next = new ListNode(2);
head.next.next = new ListNode(3);
head.next.next.next = new ListNode(5);
head.next.next.next.next = new ListNode(2);
// 可以尝试不同的pivot值
int pivot1 = 3;
ListNode result1 = partition(head, pivot1);
// 结果:2 -> 2 -> 3 -> 4 -> 5
int pivot2 = 4;
ListNode result2 = partition(head, pivot2);
// 结果:2 -> 2 -> 3 -> 4 -> 5
int pivot3 = 2;
ListNode result3 = partition(head, pivot3);
// 结果:2 -> 2 -> 4 -> 3 -> 5
}
}
所以,pivot不需要我们去找或计算,它是一个根据业务需求指定的值,用来作为分组的标准。这就像是在给一群人按身高分组时,你需要先定一个标准身高(pivot),然后根据这个标准来划分高、中、矮三组。
方法一的划分区域
让我用更直观的方式解释这个划分区域的过程。
区域划分示意图
假设数组:[4, 2, 3, 5, 2],pivot = 3
初始状态:
[4, 2, 3, 5, 2]
i
L
R
L = left(小于区的右边界)
R = right(大于区的左边界)
i = 当前处理的位置
区域含义:
[0...left-1] : 小于pivot的区域
[left...right] : 待处理区域
[right+1...末尾] : 大于pivot的区域
详细处理过程
- 第一步:处理4
[4, 2, 3, 5, 2] 4比3大,要放到右边
i
L
R
交换i和R位置的元素:
[2, 2, 3, 5, 4] R左移
i
L
R
- 第二步:处理2
[2, 2, 3, 5, 4] 2比3小,要放到左边
i
L
R
和L位置交换(实际上是自己):
[2, 2, 3, 5, 4] L右移,i右移
i
L
R
- 第三步:处理2
[2, 2, 3, 5, 4] 2比3小,要放到左边
i
L
R
和L位置交换(实际上是自己):
[2, 2, 3, 5, 4] L右移,i右移
i
L
R
- 第四步:处理3
[2, 2, 3, 5, 4] 3等于pivot,i右移
i
L
R
[2, 2, 3, 5, 4]
i
L
R
- 第五步:处理5
[2, 2, 3, 5, 4] 5比3大,要放到右边
i
L
R
交换i和R位置的元素:
[2, 2, 3, 4, 5] R左移
i
L
R
关键点解释
-
left指针:
- 表示小于区域的右边界
- 遇到小于pivot的数时,left会右移
- [0...left-1]区间都是小于pivot的数
-
right指针:
- 表示大于区域的左边界
- 遇到大于pivot的数时,right会左移
- [right+1...末尾]区间都是大于pivot的数
-
i指针:
- 当前正在处理的位置
- 用于遍历整个数组
- 当i>right时,处理结束
处理规则
while (i <= right) {
if (arr[i] < pivot) {
// 当前数小于pivot
swap(arr, i++, left++); // 放到左边,两个指针都右移
} else if (arr[i] > pivot) {
// 当前数大于pivot
swap(arr, i, right--); // 放到右边,只移动right
} else {
// 当前数等于pivot
i++; // 保持位置不变,移动i
}
}
最终数组会变成:
- 左边是小于pivot的数
- 中间是等于pivot的数
- 右边是大于pivot的数
这种方式被称为"荷兰国旗问题"的解法,因为最终的排列像荷兰国旗的三种颜色一样分成三部分。
5.4 给定2个可能有环也可能无环的单链表,头节点head1和head2,请实现一个函数,如果2个链表相交,请返回相交的第一个节点,如果不相交,则返回null
我来详细解释这个问题的解题思路。
问题分解
这个问题可以分为三种情况:
- 两个链表都无环
- 两个链表都有环
- 一个链表有环,一个链表无环
详细解法
1️⃣ 先写一个判断链表是否有环的函数(上一题的内容)
public class ListNode {
int val;
ListNode next;
}
public ListNode getLoopNode(ListNode head) {
if (head == null || head.next == null) {
return null;
}
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
// 相遇后,快指针回到头节点,然后一次走一步
fast = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow; // 返回环的入口节点
}
}
return null; // 无环
}
2️⃣ 两个无环链表相交问题
public ListNode noLoop(ListNode head1, ListNode head2) {
if (head1 == null || head2 == null) {
return null;
}
ListNode cur1 = head1;
ListNode cur2 = head2;
int n = 0; // 记录长度差
// 计算第一个链表的长度
while (cur1.next != null) {
n++;
cur1 = cur1.next;
}
// 计算第二个链表的长度
while (cur2.next != null) {
n--;
cur2 = cur2.next;
}
// 如果最后一个节点不同,说明不相交
if (cur1 != cur2) {
return null;
}
// 让较长的链表先走差值步
cur1 = n > 0 ? head1 : head2; // 较长的链表
cur2 = cur1 == head1 ? head2 : head1; // 较短的链表
n = Math.abs(n);
//n是相差多少步 让长的先走
while (n != 0) {
cur1 = cur1.next;
n--;
}
// 同时遍历直到相遇
while (cur1 != cur2) {
cur1 = cur1.next;
cur2 = cur2.next;
}
return cur1;
}
用生活中的例子: 想象两个人要在公园相遇:
- 小明家离公园3公里
- 小红家离公园2公里
- 为了让他们同时到达,我们让小明(距离远的)先走1公里
- 这样两人就都剩2公里了,可以同步前进
这段代码的目的就是:
- 找出哪条路更长(n > 0)
- 让走长路的指针(cur1)先走几步
- 这样两个指针就处于距离入环点相同距离的位置
- 为后面同步前进找相交点做准备
3️⃣ 两个有环链表相交问题
public ListNode bothLoop(ListNode head1, ListNode loop1, ListNode head2, ListNode loop2) {
if (loop1 == loop2) {
// 情况1:在入环前相交
ListNode cur1 = head1;
ListNode cur2 = head2;
int n = 0;
// 计算到入环点的距离差
while (cur1 != loop1) {
n++;
cur1 = cur1.next;
}
while (cur2 != loop2) {
n--;
cur2 = cur2.next;
}
cur1 = n > 0 ? head1 : head2;
cur2 = cur1 == head1 ? head2 : head1;
n = Math.abs(n);
while (n != 0) {
cur1 = cur1.next;
n--;
}
while (cur1 != cur2) {
cur1 = cur1.next;
cur2 = cur2.next;
}
return cur1;
} else {
// 情况2:在环上相交
ListNode cur = loop1.next;
while (cur != loop1) {
if (cur == loop2) {
return loop1;
}
cur = cur.next;
}
return null;
}
}
让我用一个游乐园的例子来形象地解释环上相交的情况!
想象有一个圆形旋转木马:
链表1: A -> B -> C -> D(loop1)
↓
H <- E -> F
↑ ↓
X -> Y -> Z(loop2) -> G
链表2:
这就像:
- 小明(链表1)从A点进入游乐园
- 小红(链表2)从X点进入游乐园
- 他们分别从不同的门(D和Z)上了同一个旋转木马
- 旋转木马在不停地转圈圈
代码实现:
// 情况2:在环上相交
ListNode cur = loop1.next; // 从小明的入口的下一个位置开始转圈
while (cur != loop1) { // 转一圈,直到回到小明的入口
if (cur == loop2) { // 如果在转圈过程中遇到了小红的入口
return loop1; // 说明是同一个旋转木马,返回任意入口都可以
}
cur = cur.next; // 继续转圈
}
return null; // 转了一圈都没遇到小红的入口,说明是两个不同的旋转木马
让我们看具体的例子:
情况1:在同一个旋转木马上
第一步:
A -> B -> C -> [D](loop1) -> E -> F -> G -> Z(loop2) -> H
cur在E
第二步:
A -> B -> C -> D -> [E] -> F -> G -> Z(loop2) -> H
cur在F
第三步:
A -> B -> C -> D -> E -> [F] -> G -> Z(loop2) -> H
cur在G
第四步:
A -> B -> C -> D -> E -> F -> [G] -> Z(loop2) -> H
cur在Z
找到了!因为cur == loop2,说明两人在同一个旋转木马上!
情况2:在不同的旋转木马上
链表1: A -> B -> C -> D(loop1)
↓
F <- E
↑ ↓
G <- H
链表2: X -> Y -> Z(loop2)
↓
W <- V
↑ ↓
T <- U
这种情况下:
- cur从D的下一个位置开始转圈
- 转了一整圈回到D
- 始终没有遇到Z(loop2)
- 说明小明和小红在两个不同的旋转木马上!
生活中的比喻:
-
同一个旋转木马的情况:
- 小明从正门上了旋转木马
- 小红从侧门上了同一个旋转木马
- 只要转一圈,一定能看到对方的入口
-
不同旋转木马的情况:
- 小明在A号旋转木马
- 小红在B号旋转木马
- 转多少圈都不可能相遇
关键点:
-
如果在同一个环上:
- 从任意一个入口转一圈
- 一定能遇到另一个入口
- 返回任意入口都可以(因为都在环上)
-
如果在不同的环上:
- 转一圈回到原点
- 永远遇不到另一个入口
- 返回null表示不相交
这就像两个人是否在同一个旋转设施上,只要其中一个人转一圈,就能知道答案!
4️⃣ 主函数整合所有情况
public ListNode getIntersectNode(ListNode head1, ListNode head2) {
if (head1 == null || head2 == null) {
return null;
}
ListNode loop1 = getLoopNode(head1);
ListNode loop2 = getLoopNode(head2);
// 两个都无环
if (loop1 == null && loop2 == null) {
return noLoop(head1, head2);
}
// 两个都有环
if (loop1 != null && loop2 != null) {
return bothLoop(head1, loop1, head2, loop2);
}
// 一个有环一个无环
return null;
}
解题思路说明
-
无环链表相交:
- 如果两个链表相交,那么从相交点到末尾的部分是完全相同的
- 先让较长的链表走完差值步数,然后两个指针同时走,相遇点就是第一个相交点
-
有环链表相交:
- 情况1:两个链表在入环前相交
- 情况2:两个链表在环上相交
- 情况3:两个链表不相交
-
一个有环一个无环:
- 这种情况是不可能相交的,直接返回null
时间复杂度
- 判断是否有环:O(N)
- 找到相交节点:O(N)
- 总体时间复杂度:O(N)
- 空间复杂度:O(1)
这个题目看起来复杂,但是只要按步骤分解:
- 先判断是否有环
- 根据有环无环的不同情况分别处理
- 最后整合所有情况
就能够很清晰地解决这个问题。关键是要理解各种情况下链表相交的特点,以及如何找到第一个相交点。
每个步骤详细解读下
我来详细讲解每个部分的代码,并配合具体例子说明。
1. 判断链表是否有环并找到入环节点
public ListNode getLoopNode(ListNode head) {
if (head == null || head.next == null) {
return null;
}
ListNode slow = head;
ListNode fast = head;
// 第一步:找到相遇点
while (fast != null && fast.next != null) {
slow = slow.next; // 慢指针走一步
fast = fast.next.next; // 快指针走两步
if (slow == fast) { // 相遇了
// 第二步:找入环点
fast = head; // 快指针回到头部
while (slow != fast) {
slow = slow.next; // 现在都是一次走一步
fast = fast.next;
}
return slow; // 返回入环点
}
}
return null; // 无环
}
举例说明: 假设有一个链表:1->2->3->4->5->6->3(6指向3,形成环)
- 初始状态:slow = 1, fast = 1
- 第一轮:slow = 2, fast = 3
- 第二轮:slow = 3, fast = 5
- 第三轮:slow = 4, fast = 3
- 第四轮:slow = 5, fast = 5 (相遇点)
- 然后fast回到头节点1
- 同时移动直到相遇:最终会在节点3相遇(入环点)
2. 处理两个无环链表相交的情况
public ListNode noLoop(ListNode head1, ListNode head2) {
if (head1 == null || head2 == null) return null;
ListNode cur1 = head1;
ListNode cur2 = head2;
int n = 0; // 记录长度差
// 计算两个链表的长度差
while (cur1.next != null) {
n++;
cur1 = cur1.next;
}
while (cur2.next != null) {
n--;
cur2 = cur2.next;
}
// 如果尾节点不同,一定不相交
if (cur1 != cur2) {
return null;
}
举例说明: 链表1:1->2->3->4->5 链表2:7->8->4->5
- 计算长度差:链表1长度为5,链表2长度为4,差值n=1
- 检查尾节点(5)相同,继续处理
// 让较长的链表先走差值步
cur1 = n > 0 ? head1 : head2; // 较长的链表
cur2 = cur1 == head1 ? head2 : head1; // 较短的链表
n = Math.abs(n);
while (n != 0) {
cur1 = cur1.next;
n--;
}
// 同时遍历直到相遇
while (cur1 != cur2) {
cur1 = cur1.next;
cur2 = cur2.next;
}
return cur1;
}
继续上面的例子:
- 较长的链表(链表1)先走一步,cur1来到节点2
- 然后两个指针同时走,直到在节点4相遇
- 返回节点4(第一个相交点)
3. 处理两个有环链表相交的情况
public ListNode bothLoop(ListNode head1, ListNode loop1, ListNode head2, ListNode loop2) {
if (loop1 == loop2) {
// 情况1:在入环前相交
ListNode cur1 = head1;
ListNode cur2 = head2;
int n = 0;
// 计算到入环点的距离差
while (cur1 != loop1) {
n++;
cur1 = cur1.next;
}
while (cur2 != loop2) {
n--;
cur2 = cur2.next;
}
有三种情况:
- 入环点相同: 例如:
链表1:1->2->3->4->5->6->4
链表2:7->8->4->5->6->4
入环点都是4,这种情况就转化为求入环前的第一个相交节点。
} else {
// 情况2:在环上相交
ListNode cur = loop1.next;
while (cur != loop1) {
if (cur == loop2) {
return loop1;
}
cur = cur.next;
}
return null;
}
}
- 环上相交: 例如:
链表1:1->2->3->4->5->6
↑↓
8←7
链表2:9->10->7->8->6
从loop1开始遍历环,如果能遇到loop2,说明在环上相交。
- 不相交: 如果遍历完环都没找到loop2,说明两个链表不相交。
4. 主函数整合
public ListNode getIntersectNode(ListNode head1, ListNode head2) {
if (head1 == null || head2 == null) {
return null;
}
// 先找到各自的入环点
ListNode loop1 = getLoopNode(head1);
ListNode loop2 = getLoopNode(head2);
// 根据不同情况处理
if (loop1 == null && loop2 == null) {
return noLoop(head1, head2); // 两个都无环
}
if (loop1 != null && loop2 != null) {
return bothLoop(head1, loop1, head2, loop2); // 两个都有环
}
return null; // 一个有环一个无环,不可能相交
}
复杂度分析
- 时间复杂度:O(N),其中N是较长链表的长度
- 空间复杂度:O(1),只使用了常数个额外变量
关键点总结
- 先判断每个链表是否有环
- 根据有环无环的情况分类讨论
- 无环链表相交,关键是对齐长度差
- 有环链表相交,关键是判断入环点的关系
- 一个有环一个无环必不相交
这个问题看似复杂,但是通过分类讨论,每种情况的处理都是相对直观的。关键是要理解各种情况下链表相交的特点,以及如何找到第一个相交点。