左哥算法 - 链表及相关问题

234 阅读24分钟

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;
}
为什么这个方法有效?
  1. 无环情况

    • 如果链表没有环,快指针一定会先到达链表末尾(null)
    • 此时返回 false
  2. 有环情况

    • 假设环的长度是 K
    • 慢指针每次走 1 步,快指针每次走 2 步
    • 每一轮,快指针都会比慢指针多走 1 步
    • 所以快指针一定会在某个时刻追上慢指针
时间复杂度分析
  • 时间复杂度:O(N),其中 N 是链表的长度
  • 空间复杂度:O(1),只使用了两个指针
实际应用场景
  1. 检测死循环
  2. 检测链表是否正确(在某些数据结构的实现中)
  3. 环形缓冲区的检测

这个算法也被称为 "Floyd's Cycle-Finding Algorithm" 或 "龟兔赛跑算法",是一个非常经典的算法。它不仅可以用来判断链表是否有环,还可以:

  1. 找到环的起始点
  2. 计算环的长度
  3. 在其他类似需要检测循环的场景中使用

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 双指针技巧

  1. 快慢指针

    • 找中点
    • 判断环
    • 找环入口
  2. 前后指针

    • 删除倒数第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. 实用技巧总结

  1. 边界条件检查

    • 空链表
    • 单节点链表
    • 两个节点的链表
  2. 画图帮助理解

    • 在纸上画出链表
    • 标注指针变化
  3. 考虑使用虚拟头节点

    • 统一操作逻辑
    • 避免空指针
  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; // 慢指针指向中点
}
详细解释
  1. 为什么能找到中点?

    • 快指针每次走2步,慢指针每次走1步
    • 当快指针走完整个链表时,慢指针刚好走到中间位置
    • 这是因为快指针的速度是慢指针的2倍
  2. 举个例子: 假设有链表:1->2->3->4->5

    • 初始状态:slow=1, fast=1
    • 第一轮:slow=2, fast=3
    • 第二轮:slow=3, fast=5
    • 结束:slow指向中点3
  3. 两种情况:

    • 奇数个节点:慢指针正好在中间
    • 偶数个节点:慢指针在中间偏左的位置
注意事项
  1. 需要检查空链表和单节点链表
  2. 快指针移动时需要检查两步之内是否为null
  3. 如果要找到中间偏右的节点,可以调整循环条件为 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;
}
  • 使用三个指针:prevcurrnext
  • 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 为例:

  1. 找到中点3
  2. 将后半部分2->1反转成1->2
  3. 比较前半部分1->2和反转后的1->2
  4. 发现对应位置的值都相等,所以是回文
复杂度分析
  • 时间复杂度:O(N)
    • 找中点需要O(N/2)
    • 反转需要O(N/2)
    • 比较需要O(N/2)
    • 总体是O(N)
  • 空间复杂度:O(1)
    • 只使用了几个指针变量
    • 没有使用额外的数据结构

5.3 将单向链表按某值划分左边小,中间相等,右边大的形式

方法一:数组方式(就像排队分组)

想象一个班级的同学排队,要按照身高(pivot)分成三组:

  1. 矮的同学
  2. 和基准身高一样的同学
  3. 高的同学
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
两种方法比较
  1. 方法一(数组方式)

    • 优点:好理解,像排队一样
    • 缺点:需要额外空间存放所有节点
  2. 方法二(三组方式)

    • 优点:不需要额外空间
    • 缺点:需要维护六个指针,代码较复杂

在实际工作中,如果内存空间充足,可以用方法一;如果对内存要求严格,就用方法二。

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
实际应用场景
  1. 成绩分组
// 学生成绩链表:85 -> 60 -> 90 -> 70 -> 75
// pivot = 75(及格线)

partition(head, 75);
// 结果:60 -> 70 -> 75 -> 85 -> 90
// 不及格(<75):60,70
// 刚好及格(=75):75
// 优秀(>75):85,90
  1. 年龄分组
// 年龄链表:25 -> 18 -> 35 -> 40 -> 20
// pivot = 30(分界线)

partition(head, 30);
// 结果:25 -> 18 -> 20 -> 35 -> 40
// 青年(<30):25,18,20
// 中年(=30):无
// 中老年(>30):35,40
关键点
  1. pivot是外部传入的参数
  2. pivot的选择取决于具体的业务需求
  3. 同一个链表,不同的pivot值会得到不同的分组结果
  4. 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的区域
详细处理过程
  1. 第一步:处理4
[4, 2, 3, 5, 2]  43大,要放到右边
 i
 L            
             R

交换i和R位置的元素:
[2, 2, 3, 5, 4]  R左移
 i            
 L         
          R
  1. 第二步:处理2
[2, 2, 3, 5, 4]  23小,要放到左边
 i            
 L         
          R

和L位置交换(实际上是自己):
[2, 2, 3, 5, 4]  L右移,i右移
    i            
    L         
          R
  1. 第三步:处理2
[2, 2, 3, 5, 4]  23小,要放到左边
    i            
    L         
          R

和L位置交换(实际上是自己):
[2, 2, 3, 5, 4]  L右移,i右移
       i            
       L         
          R
  1. 第四步:处理3
[2, 2, 3, 5, 4]  3等于pivot,i右移
       i            
       L         
          R

[2, 2, 3, 5, 4]
          i            
       L         
          R
  1. 第五步:处理5
[2, 2, 3, 5, 4]  53大,要放到右边
          i            
       L         
          R

交换i和R位置的元素:
[2, 2, 3, 4, 5]  R左移
          i            
       L         
       R
关键点解释
  1. left指针

    • 表示小于区域的右边界
    • 遇到小于pivot的数时,left会右移
    • [0...left-1]区间都是小于pivot的数
  2. right指针

    • 表示大于区域的左边界
    • 遇到大于pivot的数时,right会左移
    • [right+1...末尾]区间都是大于pivot的数
  3. 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. 两个链表都无环
  2. 两个链表都有环
  3. 一个链表有环,一个链表无环
详细解法
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公里了,可以同步前进

这段代码的目的就是:

  1. 找出哪条路更长(n > 0)
  2. 让走长路的指针(cur1)先走几步
  3. 这样两个指针就处于距离入环点相同距离的位置
  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;
        }
        
        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. 小明(链表1)从A点进入游乐园
  2. 小红(链表2)从X点进入游乐园
  3. 他们分别从不同的门(D和Z)上了同一个旋转木马
  4. 旋转木马在不停地转圈圈

代码实现:

// 情况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

这种情况下:

  1. cur从D的下一个位置开始转圈
  2. 转了一整圈回到D
  3. 始终没有遇到Z(loop2)
  4. 说明小明和小红在两个不同的旋转木马上!

生活中的比喻:

  1. 同一个旋转木马的情况:

    • 小明从正门上了旋转木马
    • 小红从侧门上了同一个旋转木马
    • 只要转一圈,一定能看到对方的入口
  2. 不同旋转木马的情况:

    • 小明在A号旋转木马
    • 小红在B号旋转木马
    • 转多少圈都不可能相遇

关键点:

  1. 如果在同一个环上:

    • 从任意一个入口转一圈
    • 一定能遇到另一个入口
    • 返回任意入口都可以(因为都在环上)
  2. 如果在不同的环上:

    • 转一圈回到原点
    • 永远遇不到另一个入口
    • 返回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. 有环链表相交

    • 情况1:两个链表在入环前相交
    • 情况2:两个链表在环上相交
    • 情况3:两个链表不相交
  3. 一个有环一个无环

    • 这种情况是不可能相交的,直接返回null
时间复杂度
  • 判断是否有环:O(N)
  • 找到相交节点:O(N)
  • 总体时间复杂度:O(N)
  • 空间复杂度:O(1)

这个题目看起来复杂,但是只要按步骤分解:

  1. 先判断是否有环
  2. 根据有环无环的不同情况分别处理
  3. 最后整合所有情况

就能够很清晰地解决这个问题。关键是要理解各种情况下链表相交的特点,以及如何找到第一个相交点。

每个步骤详细解读下

我来详细讲解每个部分的代码,并配合具体例子说明。

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,形成环)

  1. 初始状态:slow = 1, fast = 1
  2. 第一轮:slow = 2, fast = 3
  3. 第二轮:slow = 3, fast = 5
  4. 第三轮:slow = 4, fast = 3
  5. 第四轮:slow = 5, fast = 5 (相遇点)
  6. 然后fast回到头节点1
  7. 同时移动直到相遇:最终会在节点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. 计算长度差:链表1长度为5,链表2长度为4,差值n=1
  2. 检查尾节点(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. 较长的链表(链表1)先走一步,cur1来到节点2
  2. 然后两个指针同时走,直到在节点4相遇
  3. 返回节点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. 入环点相同: 例如:
链表11->2->3->4->5->6->4
链表27->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. 环上相交: 例如:
链表11->2->3->4->5->6
                    ↑↓
                    87
链表29->10->7->8->6

从loop1开始遍历环,如果能遇到loop2,说明在环上相交。

  1. 不相交: 如果遍历完环都没找到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),只使用了常数个额外变量
关键点总结
  1. 先判断每个链表是否有环
  2. 根据有环无环的情况分类讨论
  3. 无环链表相交,关键是对齐长度差
  4. 有环链表相交,关键是判断入环点的关系
  5. 一个有环一个无环必不相交

这个问题看似复杂,但是通过分类讨论,每种情况的处理都是相对直观的。关键是要理解各种情况下链表相交的特点,以及如何找到第一个相交点。