百日算法修炼 · Day 13

百日算法修炼 · Day 13

主题:链表中点、回文链表、链表分区、随机指针复制、链表相交

语言:Java | 建议用时:120~160 分钟


阅读方式

本文仍然分为两个区域:

  • 上半篇是练习区:只包含题目、示例、思考问题和可折叠提示;
  • 下半篇是答案区:包含优化后的代码、过程图解、复杂度、关键不变式、易错点与验证思路。

建议先独立完成练习,再点击每道题下面的"查看参考答案"。

Day 13 的五道题看起来各不相同,但其实都在训练同一件事:

不依赖数组下标,只靠指针移动、局部改链和结构性质解决问题。

今天可以归成三条主线:

  1. 快慢指针:中点、回文、入环点;
  2. 链表重组:反转、三路分区、复制随机指针;
  3. 结构分类讨论:无环相交、有环相交、共享环但入环点不同。

今日任务地图

text 复制代码
Day 13 学习路线

开始
  ↓
链表上中点 / 下中点
  ↓
回文链表
  ↓
按 pivot 三路稳定分区
  ↓
复制带 random 指针的链表
  ↓
两个链表的第一个相交节点
  ↓
统一理解:快慢指针 + 断链重连 + 分类讨论
  ↓
对数器验证与复盘
题号 主题 核心能力
1 链表上中点 / 下中点 快慢指针的起点与停止条件
2 回文链表 找中点、反转、比较、恢复
3 链表三路分区 六指针维护三个稳定区域
4 复制随机指针链表 原地穿插复制与拆链恢复
5 链表相交 无环 / 有环分类讨论与环入口

第一部分:独立练习区

今天每道题都先问自己两个问题:

① 当前每个指针代表什么?

② 我修改 next 之前,有没有把后续节点保存下来?

练习 1:链表的上中点与下中点

给定一个无环单链表 head

请分别实现:

java 复制代码
public static Node upperMiddle(Node head)
public static Node lowerMiddle(Node head)

定义如下:

  • 链表长度为奇数时,两者都返回真正的中点;
  • 链表长度为偶数时:
    • upperMiddle 返回中间两个节点中的前一个;
    • lowerMiddle 返回中间两个节点中的后一个。

例如:

text 复制代码
奇数长度:

1 → 2 → 3 → 4 → 5

upperMiddle = 3
lowerMiddle = 3
text 复制代码
偶数长度:

1 → 2 → 3 → 4

upperMiddle = 2
lowerMiddle = 3

思考问题

  1. 为什么快指针每次走两步,慢指针每次走一步,可以定位中间位置?
  2. 上中点和下中点的核心差异,到底是"起点"不同还是"循环停止条件"不同?
  3. 长度为 2 时,两种写法分别应该返回哪个节点?
  4. fast.next != null && fast.next.next != null 会得到哪一种中点?
  5. fast != null && fast.next != null 又会得到哪一种中点?
  6. 对数器比较两个节点时,为什么更应该比较节点引用是否相同,而不是比较节点值?

提示 1:先手推长度 2、3、4、5

快慢指针题不要先背模板。把短链表全部手推一遍,观察 slow 最终停在哪里,比记条件可靠得多。
提示 2:两个版本可以都从 head 出发

如果 fast 和 slow 都从 head 开始,那么仅通过 while 的停止条件,就可以分别得到上中点和下中点。

写完后再看:[查看练习 1 参考答案](#查看练习 1 参考答案)


练习 2:判断链表是否为回文链表

给定一个无环单链表 head,判断它的节点值是否从前往后与从后往前完全一致。

本文沿用本组练习的约定:

text 复制代码
head == null → false

例如:

text 复制代码
1 → 2 → 3 → 2 → 1
返回 true
text 复制代码
1 → 2 → 2 → 1
返回 true
text 复制代码
1 → 2 → 3
返回 false

请至少思考两种方案:

text 复制代码
方案 A:栈
方案 B:O(1) 额外空间,原地反转后半段

思考问题

  1. 如果使用栈,有没有必要把整个链表都压栈?
  2. 如何只用 O(N / 2) 个栈元素完成比较?
  3. 如果追求 O(1) 额外空间,应该反转哪一半?
  4. 偶数长度和奇数长度在"第二半段起点"上有什么不同?
  5. 比较过程中一旦发现不相等,能否立刻 return false
  6. 如果算法临时修改了原链表,为什么返回之前必须恢复?
  7. 怎样写代码,才能保证"无论比较成功还是失败,链表都一定被恢复"?

提示 1:先找到上中点

如果 slow 停在上中点,那么 slow.next 正好可以作为后半段的起点。反转 slow.next 后,只需要让一个指针从 head 出发,另一个从反转后的后半段出发。
提示 2:不要在比较失败时直接返回

先把结果记到 boolean 变量中,然后统一执行"恢复链表",最后再返回结果。这样不会因为提前 return 把链表留在被破坏的状态。

写完后再看:[查看练习 2 参考答案](#查看练习 2 参考答案)


练习 3:链表按指定值分成小于区、等于区、大于区

给定一个无环单链表 head 和整数 pivot

请把链表重新组织成:

text 复制代码
小于 pivot 的节点
        ↓
等于 pivot 的节点
        ↓
大于 pivot 的节点

要求:

  1. 不创建新的数据节点;
  2. 额外空间 O(1)
  3. 最好保持每个区域内部原有的相对顺序,也就是做成稳定分区

例如:

text 复制代码
原链表:

7 → 3 → 5 → 2 → 5 → 8 → 1
pivot = 5

期望:

text 复制代码
3 → 2 → 1 → 5 → 5 → 7 → 8
└─ 小于区 ─┘  └等于区┘  └大于区┘

注意:

text 复制代码
3、2、1 的相对顺序没有改变
两个 5 的相对顺序没有改变
7、8 的相对顺序没有改变

思考问题

  1. 为什么这道题需要维护三个"小链表"?
  2. 每个小链表为什么都需要 head + tail 两个指针?
  3. 把当前节点接到某个区域之前,为什么建议先执行 cur.next = null
  4. 如果"小于区"不存在,最终头节点应该从哪里选?
  5. 如果"等于区"不存在,如何直接连接"小于区"和"大于区"?
  6. 如果"大于区"不存在,代码能否仍然安全工作?
  7. 为什么"数组 + 荷兰国旗"虽然也能做分区,却通常不稳定?

提示 1:六个指针

维护 lessHead、lessTail、equalHead、equalTail、greaterHead、greaterTail。遍历原链表时,每个节点只会被追加到其中一个区域。
提示 2:先保存 next,再断链

先写 Node next = cur.next,再写 cur.next = null。否则一旦重连 cur.next,就可能丢失原链表后续位置,甚至留下旧指针形成错误连接。

写完后再看:[查看练习 3 参考答案](#查看练习 3 参考答案)


练习 4:复制带 random 指针的链表

定义节点:

java 复制代码
public static class RandNode {
    int value;
    RandNode next;
    RandNode rand;

    public RandNode(int value) {
        this.value = value;
    }
}

其中:

  • next 指向下一个节点;
  • rand 可以指向链表中的任意节点,也可以为 null

请完成一个深拷贝

java 复制代码
public static RandNode copyRandomList(RandNode head)

要求复制后的链表满足:

  1. 节点值相同;
  2. next 结构相同;
  3. rand 指向关系相同;
  4. 新链表中的节点不能和原链表共享节点对象;
  5. 最优方案做到 O(N) 时间、O(1) 额外辅助空间。

示例:

text 复制代码
原链表 next:

A → B → C → D → null

random:

A ─────→ C
B → null
C ─────→ A
D ─────→ C

复制后应该得到:

text 复制代码
A' → B' → C' → D' → null

A' ─────→ C'
B' → null
C' ─────→ A'
D' ─────→ C'

而不是:

text 复制代码
A' ─────→ C   ← 错误:指回原链表

思考问题

  1. 使用 HashMap<原节点, 新节点> 时,为什么很容易完成这道题?
  2. 不使用哈希表时,怎样让每个原节点"马上找到自己的复制节点"?
  3. 如果把复制节点插到原节点后面,会形成什么结构?
  4. 此时 original.rand.next 为什么恰好就是 copy.rand
  5. 最后一阶段拆链时,为什么既要恢复原链表,又要串好复制链表?
  6. 单节点链表会暴露拆链代码中的哪些边界问题?
  7. 验证深拷贝时,只比较 rand.value 是否相等为什么不够?

提示 1:把映射关系藏进链表结构

把每个复制节点插到对应原节点后面:A → A' → B → B' → C → C'。这样 original.next 就是它的 copy,不再需要 HashMap 保存映射。
提示 2:三轮遍历

第一轮穿插复制节点;第二轮设置 random;第三轮把两条链表拆开,并恢复原链表。

写完后再看:[查看练习 4 参考答案](#查看练习 4 参考答案)


练习 5:找到两个链表的第一个相交节点

给定两个单链表 head1head2

链表可能:

  • 无环;
  • 有环。

请返回它们的相交节点;如果不相交,返回 null

这里的"相交"指:

两个指针指向同一个节点对象,而不是节点值相同。

先看无环情况

text 复制代码
head1
  1 → 2 → 3
          \
           7 → 8 → 9
          /
     4 → 5
head2

第一个相交节点是 7

再看有环情况

两个有环链表可能:

text 复制代码
情况 A:入环点相同
情况 B:入环点不同,但共享同一个环
情况 C:两个独立的环

思考问题

  1. 一个有环链表和一个无环链表,是否可能相交?
  2. 为什么"相交"必须比较节点引用,而不能只比较 value
  3. 无环链表如何通过"长度差"让两个指针对齐?
  4. 怎样用 Floyd 快慢指针找到入环节点?
  5. 两个链表的入环节点相同,就能直接返回入环节点吗?
  6. 如果它们在入环之前已经相交,真正的第一个相交节点在哪里?
  7. 两个入环节点不同,如何判断它们是否仍然共享同一个环?
  8. 如果共享同一个环但入环点不同,"第一个相交节点"是否仍有唯一答案?

提示 1:先把题目分成三大类

无环 + 无环;一个有环一个无环;有环 + 有环。不要试图用一个 while 循环直接把所有情况揉在一起。
提示 2:相同入环点还要继续处理前缀

loop1 == loop2 只能说明两个链表最终从同一个位置进入环,但它们可能更早就在入环前相交,因此还要对两个"入环前前缀"做一次长度对齐。

写完后再看:[查看练习 5 参考答案](#查看练习 5 参考答案)


第二部分:参考答案与图解

到这里再对照答案。建议先比较"指针含义"和"边界",最后再比较具体代码。

答案 1:链表的上中点与下中点

[返回练习 1](#返回练习 1)

问题回顾

这道题真正考的不是"快指针走两步"。

真正要掌握的是:

同样是快慢指针,只要停止条件不同,slow 最终就会落在不同的中点位置。

先把短链表全部手推一遍

长度为 1
text 复制代码
1
↑
上中点 = 下中点 = 1
长度为 2
text 复制代码
1 → 2
↑   ↑
上   下
长度为 3
text 复制代码
1 → 2 → 3
    ↑
  上 = 下
长度为 4
text 复制代码
1 → 2 → 3 → 4
    ↑   ↑
    上  下
长度为 5
text 复制代码
1 → 2 → 3 → 4 → 5
        ↑
      上 = 下

这五种情况一旦手推清楚,代码就不需要死记。


上中点:让 fast 再走两步之前必须"有两步可走"

核心条件:

java 复制代码
while (fast.next != null && fast.next.next != null)

流程:

text 复制代码
upperMiddle

slow = head
fast = head
    ↓
fast 后面至少还有两个节点?
    ┌──────┴──────┐
    │             │
   是            否
    │             │
slow 走 1 步     返回 slow
fast 走 2 步
    │
    └────→ 继续判断

对于偶数长度:

text 复制代码
1 → 2 → 3 → 4

初始:
S/F
 ↓
 1 → 2 → 3 → 4

走一次:
    S       F
    ↓       ↓
1 → 2 → 3 → 4

fast 后面只剩 1 步,
不能再完成"两步",停止。

答案 = 2

所以得到上中点。


下中点:只要 fast 还能完成一步,就让 slow 再走一步

核心条件:

java 复制代码
while (fast != null && fast.next != null)

对于长度 4

text 复制代码
初始:
S/F
 ↓
1 → 2 → 3 → 4

第 1 轮:
    S       F
    ↓       ↓
1 → 2 → 3 → 4

第 2 轮:
        S
        ↓
1 → 2 → 3 → 4
                F = null

答案 = 3

完整代码

java 复制代码
/**
 * 返回链表上中点:
 * - 奇数长度:返回真正中点
 * - 偶数长度:返回中间两个节点中的前一个
 */
public static Node upperMiddle(Node head) {
    if (head == null || head.next == null) {
        return head;
    }

    Node slow = head;
    Node fast = head;

    // fast 必须还能完整向后走两步,slow 才继续走一步。
    while (fast.next != null && fast.next.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }

    return slow;
}

/**
 * 返回链表下中点:
 * - 奇数长度:返回真正中点
 * - 偶数长度:返回中间两个节点中的后一个
 */
public static Node lowerMiddle(Node head) {
    if (head == null || head.next == null) {
        return head;
    }

    Node slow = head;
    Node fast = head;

    // 只要 fast 还能走一步,就让 slow 再向前移动。
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }

    return slow;
}

对数器:用数组下标直接取答案

对数器不追求最优,只追求简单可信。

java 复制代码
import java.util.ArrayList;
import java.util.List;

public static Node upperMiddleByArray(Node head) {
    if (head == null) {
        return null;
    }

    List<Node> nodes = new ArrayList<>();

    for (Node cur = head; cur != null; cur = cur.next) {
        nodes.add(cur);
    }

    return nodes.get((nodes.size() - 1) / 2);
}

public static Node lowerMiddleByArray(Node head) {
    if (head == null) {
        return null;
    }

    List<Node> nodes = new ArrayList<>();

    for (Node cur = head; cur != null; cur = cur.next) {
        nodes.add(cur);
    }

    return nodes.get(nodes.size() / 2);
}

验证时建议这样比较:

java 复制代码
if (upperMiddle(head) != upperMiddleByArray(head)) {
    throw new AssertionError("upperMiddle failed");
}

if (lowerMiddle(head) != lowerMiddleByArray(head)) {
    throw new AssertionError("lowerMiddle failed");
}

而不是:

java 复制代码
upperMiddle(head).equals(...)

原因有两个:

  1. head == null 时可能直接空指针;
  2. 这道题要验证的是是不是同一个节点对象 ,用 == 更准确。

两个版本放在一起记

目标 循环条件 偶数长度返回
上中点 fast.next != null && fast.next.next != null 前一个
下中点 fast != null && fast.next != null 后一个

复杂度

指标 快慢指针
时间复杂度 O(N)
额外空间 O(1)
是否修改链表

易错点

  • 把"下中点"的注释写成"偶数返回上中点";
  • 不手推长度 2,导致循环边界错一位;
  • 对可能为 null 的返回值直接调用 .equals()
  • 用节点值判断是不是同一个中点,忽略链表中可能有重复值;
  • 只记代码,不理解循环停止时 fast 还剩几步。

答案 2:判断链表是否为回文链表

[返回练习 2](#返回练习 2)

问题回顾

如果允许额外空间,最直观的办法是:

text 复制代码
链表 → 数组 / 栈 → 从两端比较

但更值得训练的是:

text 复制代码
找中点
  ↓
反转后半段
  ↓
从两端同时比较
  ↓
恢复后半段

这样可以做到:

text 复制代码
时间 O(N)
额外空间 O(1)

手推:奇数长度

text 复制代码
原链表:

1 → 2 → 3 → 2 → 1
        ↑
      上中点

slow.next 开始反转:

text 复制代码
前半段:

1 → 2 → 3

后半段原来:
2 → 1

反转后:
1 → 2

现在两个指针:

text 复制代码
p1
↓
1 → 2 → 3 → ...

p2
↓
1 → 2

只需要比较到 p2 == null

text 复制代码
1 == 1
2 == 2

回文

中间节点 3 不需要参与比较。


手推:偶数长度

text 复制代码
1 → 2 → 2 → 1
    ↑
  上中点

反转 slow.next

text 复制代码
后半段:

2 → 1
变成
1 → 2

比较:

text 复制代码
左侧:1 → 2
右侧:1 → 2

完全一致

所以同一套代码可以同时处理奇数和偶数。


最重要的一步:恢复链表

算法执行期间,链表会暂时变成:

text 复制代码
原来:

1 → 2 → 3 → 2 → 1

中间过程:

text 复制代码
1 → 2 → 3 → 2
            ↑
            1

如果直接返回,调用者拿到的链表结构就已经被修改。

正确流程应该是:

text 复制代码
比较结束
   ↓
reverse(secondHalfHead)
   ↓
firstHalfEnd.next = 恢复后的后半段
   ↓
再返回 true / false

尤其要注意:

发现不匹配时,也不能在恢复之前直接 return false


O(1) 额外空间的优化版本

java 复制代码
/**
 * 判断无环单链表是否为回文。
 *
 * 约定:
 * head == null 时返回 false。
 *
 * 核心步骤:
 * 1. 找上中点;
 * 2. 反转后半段;
 * 3. 比较前半段和反转后的后半段;
 * 4. 无论结果如何,都恢复原链表。
 */
public static boolean isPalindrome(Node head) {
    if (head == null) {
        return false;
    }

    if (head.next == null) {
        return true;
    }

    Node firstHalfEnd = upperMiddle(head);

    // secondHalfHead 保存"反转后的后半段头节点"。
    Node secondHalfHead = reverse(firstHalfEnd.next);

    Node p1 = head;
    Node p2 = secondHalfHead;
    boolean palindrome = true;

    // 后半段长度始终 <= 前半段长度,
    // 因此只要 p2 还没结束,就继续比较。
    while (p2 != null) {
        if (p1.value != p2.value) {
            palindrome = false;
            break;
        }

        p1 = p1.next;
        p2 = p2.next;
    }

    // 非常重要:
    // 即使上面发现了不匹配,也必须先恢复链表再返回。
    firstHalfEnd.next = reverse(secondHalfHead);

    return palindrome;
}

/**
 * 返回上中点。
 */
private static Node upperMiddle(Node head) {
    Node slow = head;
    Node fast = head;

    while (fast.next != null && fast.next.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }

    return slow;
}

/**
 * 反转单链表,返回新的头节点。
 */
private static Node reverse(Node head) {
    Node prev = null;
    Node cur = head;

    while (cur != null) {
        // 修改 cur.next 之前,先保存后继。
        Node next = cur.next;

        cur.next = prev;
        prev = cur;
        cur = next;
    }

    return prev;
}

为什么不需要提前统计链表长度?

如果已经通过上中点找到:

text 复制代码
firstHalfEnd

那么:

text 复制代码
firstHalfEnd.next

就是需要反转的后半段起点。

比较时又只需要:

java 复制代码
while (p2 != null)

因此不必额外先做一次 count(head)

虽然总体复杂度仍然是 O(N),但少了一次完整遍历,逻辑也更集中。


栈版本:只压一半

如果暂时不想改链表,可以使用半栈版本。

java 复制代码
import java.util.ArrayDeque;
import java.util.Deque;

/**
 * 使用栈判断回文,只保存前半段。
 *
 * 额外空间:O(N / 2) = O(N)
 */
public static boolean isPalindromeByStack(Node head) {
    if (head == null) {
        return false;
    }

    Deque<Integer> stack = new ArrayDeque<>();

    Node slow = head;
    Node fast = head;

    // slow 每次走一步,fast 每次走两步。
    // 同时把 slow 经过的"前半段"压栈。
    while (fast != null && fast.next != null) {
        stack.push(slow.value);
        slow = slow.next;
        fast = fast.next.next;
    }

    // fast != null 说明链表长度为奇数,
    // 此时 slow 正好位于中点,跳过它。
    if (fast != null) {
        slow = slow.next;
    }

    while (slow != null) {
        if (stack.pop() != slow.value) {
            return false;
        }
        slow = slow.next;
    }

    return true;
}

整体流程图

text 复制代码
回文链表 O(1) 空间方案

head == null ?
   ┌──────┴──────┐
   │             │
  是            否
   │             │
返回 false     找上中点
                 ↓
            反转后半段
                 ↓
       p1 从 head 开始
       p2 从反转头开始
                 ↓
            逐节点比较
                 ↓
         是否出现不相等?
           ┌────┴────┐
           │         │
          是        否
           │         │
      记录 false   保持 true
           │         │
           └────┬────┘
                ↓
           恢复后半段
                ↓
             返回结果

复杂度

方案 时间 额外空间 是否临时修改链表
整链表入栈 O(N) O(N)
半链表入栈 O(N) O(N)
反转后半段 O(N) O(1) 是,但最终恢复

易错点

  • 比较失败时直接 return false,忘记恢复链表;
  • 反转后半段后,丢失其头节点,导致无法恢复;
  • 奇数长度没有跳过中点;
  • 反转时先修改 cur.next,却没有保存原来的 next
  • 为了区分奇偶先额外统计长度,使逻辑变复杂;
  • 栈版本把中点也错误地拿去和后半段错位比较。

答案 3:链表按 pivot 三路稳定分区

[返回练习 3](#返回练习 3)

问题回顾

目标不是把节点"排序"。

只需要满足:

text 复制代码
所有 < pivot
    在
所有 == pivot
    之前

所有 == pivot
    在
所有 > pivot
    之前

如果还希望稳定,就要保证每个区域内部:

text 复制代码
节点原来的先后顺序不改变

六指针模型

维护三条临时链:

text 复制代码
小于区:
lessHead → ... → lessTail

等于区:
equalHead → ... → equalTail

大于区:
greaterHead → ... → greaterTail

一共六个指针:

text 复制代码
lessHead
lessTail

equalHead
equalTail

greaterHead
greaterTail

每扫描一个节点,只做一件事:

text 复制代码
把它追加到自己所属区域的 tail 后面

为什么一定要先断开当前节点?

假设原链表:

text 复制代码
7 → 3 → 8 → ...

处理 7 时,它被放进"大于区"。

但如果此时不处理旧的 next

text 复制代码
greaterHead = 7

7.next 仍然指向 3

后面 3 又会被放进"小于区"。

于是三个临时区域之间仍然残留原链表连接。

最稳妥的写法:

java 复制代码
Node next = cur.next;
cur.next = null;

先把当前节点变成一个独立节点,再追加到对应区域。

text 复制代码
原来:

cur → next → ...

保存 next 后:

cur     next → ...
 ↓
null

这样后续重连完全由我们控制。


手推示例

text 复制代码
原链表:

7 → 3 → 5 → 2 → 5 → 8 → 1
pivot = 5

依次扫描后:

text 复制代码
less:
3 → 2 → 1

equal:
5 → 5

greater:
7 → 8

最后只需要拼接:

text 复制代码
3 → 2 → 1
        │
        └────→ 5 → 5
                    │
                    └────→ 7 → 8

得到:

text 复制代码
3 → 2 → 1 → 5 → 5 → 7 → 8

分区流程图

text 复制代码
遍历当前节点 cur
       ↓
先保存 next = cur.next
       ↓
cur.next = null
       ↓
比较 cur.value 和 pivot
   ┌────────┼────────┐
   │        │        │
  小于     等于     大于
   │        │        │
追加到     追加到    追加到
less      equal     greater
   │        │        │
   └────────┼────────┘
            ↓
          cur = next
            ↓
         继续遍历
            ↓
       三条小链全部完成
            ↓
       less → equal → greater
            ↓
          返回头节点

优化后的完整代码

java 复制代码
/**
 * 按 pivot 对链表做稳定三路分区:
 *
 * [小于 pivot] -> [等于 pivot] -> [大于 pivot]
 *
 * 不创建新的数据节点,额外空间 O(1)。
 */
public static Node partition(Node head, int pivot) {
    if (head == null) {
        return null;
    }

    Node lessHead = null;
    Node lessTail = null;

    Node equalHead = null;
    Node equalTail = null;

    Node greaterHead = null;
    Node greaterTail = null;

    Node cur = head;

    while (cur != null) {
        // 改 cur.next 前必须保存后继。
        Node next = cur.next;

        // 把当前节点彻底从原链表中摘出来,
        // 避免旧 next 干扰三个分区的结构。
        cur.next = null;

        if (cur.value < pivot) {
            if (lessHead == null) {
                lessHead = cur;
                lessTail = cur;
            } else {
                lessTail.next = cur;
                lessTail = cur;
            }
        } else if (cur.value == pivot) {
            if (equalHead == null) {
                equalHead = cur;
                equalTail = cur;
            } else {
                equalTail.next = cur;
                equalTail = cur;
            }
        } else {
            if (greaterHead == null) {
                greaterHead = cur;
                greaterTail = cur;
            } else {
                greaterTail.next = cur;
                greaterTail = cur;
            }
        }

        cur = next;
    }

    // 连接"小于区"与后面的第一个非空区域。
    if (lessTail != null) {
        lessTail.next = (equalHead != null) ? equalHead : greaterHead;
    }

    // 连接"等于区"与"大于区"。
    if (equalTail != null) {
        equalTail.next = greaterHead;
    }

    // 返回三个区域中第一个非空区域的头节点。
    if (lessHead != null) {
        return lessHead;
    }

    if (equalHead != null) {
        return equalHead;
    }

    return greaterHead;
}

为什么这个版本比大量分类拼接更稳?

最终连接阶段只处理两条边:

text 复制代码
lessTail  → 后面的第一个区域
equalTail → greaterHead

而不是把:

text 复制代码
less 是否为空
equal 是否为空
greater 是否为空

组合成很多分支。

尤其是:

text 复制代码
全部节点都 == pivot

此时:

text 复制代码
greaterHead == null
greaterTail == null

代码不能去执行:

java 复制代码
greaterTail.next = null;

否则会直接空指针。

采用"遍历时每个节点都先断链"的方式后:

text 复制代码
每个区域尾节点天然已经 next == null

最终不再需要额外强行给尾节点置空。


稳定性为什么成立?

对于每个区域,我们只做:

text 复制代码
尾插

例如小于区原本依次遇到:

text 复制代码
3
2
1

构建过程:

text 复制代码
3

3 → 2

3 → 2 → 1

从来没有把后来的节点插到前面,所以相对顺序保持不变。


和数组"荷兰国旗"方案对比

另一种思路:

text 复制代码
链表节点全部放进数组
       ↓
对数组做三路 partition
       ↓
按数组顺序重新连链

它的优点是容易借用熟悉的荷兰国旗模板。

但:

  • 额外空间是 O(N)
  • 交换数组元素会改变同一区域节点的相对顺序;
  • 因此通常不是稳定分区。
方案 时间 空间 稳定
六指针三链 O(N) O(1)
数组 + 荷兰国旗 O(N) O(N) 通常否

易错点

  • 不保存 next 就修改 cur.next
  • 节点追加到新区域时不先断开旧连接;
  • 某个区域为空时仍然访问它的 tail.next
  • 只保证"值分区正确",却没有意识到题目还可以做到稳定;
  • 对数器只检查三个区域顺序,却没有检查节点有没有丢失或重复。

答案 4:复制带 random 指针的链表

[返回练习 4](#返回练习 4)

问题回顾

难点不在 next

普通链表的 next 复制很简单:

text 复制代码
A → B → C

可以直接创建:

text 复制代码
A' → B' → C'

真正困难的是:

text 复制代码
A.rand = C

复制后必须变成:

text 复制代码
A'.rand = C'

不能是:

text 复制代码
A'.rand = C

所以核心问题是:

拿到一个原节点时,如何快速找到它对应的复制节点?


方案一:HashMap 显式保存映射

最容易想到:

text 复制代码
A → A'
B → B'
C → C'
D → D'

放入:

java 复制代码
Map<RandNode, RandNode>

这样:

text 复制代码
copy.next = map.get(original.next)
copy.rand = map.get(original.rand)

都能 O(1) 找到。


HashMap 基准版本

java 复制代码
import java.util.HashMap;
import java.util.Map;

/**
 * HashMap 版本。
 *
 * 时间 O(N)
 * 额外空间 O(N)
 */
public static RandNode copyRandomListByMap(RandNode head) {
    if (head == null) {
        return null;
    }

    Map<RandNode, RandNode> map = new HashMap<>();

    // 第一遍:只创建所有复制节点,建立 原节点 -> 新节点 映射。
    for (RandNode cur = head; cur != null; cur = cur.next) {
        map.put(cur, new RandNode(cur.value));
    }

    // 第二遍:同时设置 next 和 rand。
    for (RandNode cur = head; cur != null; cur = cur.next) {
        RandNode copy = map.get(cur);

        copy.next = map.get(cur.next);
        copy.rand = map.get(cur.rand);
    }

    return map.get(head);
}

这里非常重要:

java 复制代码
copy.next = map.get(cur.next);

和:

java 复制代码
copy.rand = map.get(cur.rand);

缺一不可。

只复制 rand、不复制 next,得到的并不是一条完整链表。


方案二:把映射关系"藏"进原链表

如果不想用 HashMap,可以把:

text 复制代码
original → copy

直接编码到 next 结构里。

第一轮插入复制节点。

原来:

text 复制代码
A → B → C → D

变成:

text 复制代码
A → A' → B → B' → C → C' → D → D'

于是对于任意原节点:

text 复制代码
original.next == copy

映射关系已经天然存在。


第二轮:设置 random

假设:

text 复制代码
A.rand = C

穿插后:

text 复制代码
A → A' → B → B' → C → C'
│                   ↑
└───────────────────┘

因为:

text 复制代码
A.rand = C
C.next = C'

所以:

text 复制代码
A'.rand = A.rand.next
        = C.next
        = C'

也就是:

java 复制代码
copy.rand = cur.rand == null ? null : cur.rand.next;

这就是整道题最漂亮的一步。


第三轮:拆成两条链

当前:

text 复制代码
A → A' → B → B' → C → C' → D → D'

需要同时得到:

text 复制代码
原链表:
A → B → C → D

和:

text 复制代码
复制链表:
A' → B' → C' → D'

每次处理:

text 复制代码
cur  = 原节点
copy = cur.next

nextOriginal = copy.next

然后:

text 复制代码
cur.next  = nextOriginal

copy.next = nextOriginal == null
            ? null
            : nextOriginal.next

三阶段总流程

text 复制代码
阶段 1:穿插复制

A → B → C
↓
A → A' → B → B' → C → C'

              ↓

阶段 2:设置 random

copy.rand
=
original.rand == null
? null
: original.rand.next

              ↓

阶段 3:拆链

A → B → C
A' → B' → C'

O(1) 额外空间优化版本

java 复制代码
/**
 * 深拷贝带 random 指针的链表。
 *
 * 时间复杂度:O(N)
 * 额外辅助空间:O(1)
 *
 * 注意:
 * 算法会临时修改原链表,但最终会完整恢复。
 */
public static RandNode copyRandomList(RandNode head) {
    if (head == null) {
        return null;
    }

    // ------------------------------------------------------------
    // 第 1 阶段:
    // 把每个复制节点插在原节点后面。
    //
    // A -> B -> C
    //
    // 变成:
    //
    // A -> A' -> B -> B' -> C -> C'
    // ------------------------------------------------------------
    RandNode cur = head;

    while (cur != null) {
        RandNode nextOriginal = cur.next;

        RandNode copy = new RandNode(cur.value);
        cur.next = copy;
        copy.next = nextOriginal;

        cur = nextOriginal;
    }

    // ------------------------------------------------------------
    // 第 2 阶段:
    // 设置每个复制节点的 rand。
    //
    // 若 cur.rand == X,
    // 那么 X.next 就是 X 的复制节点 X'。
    // ------------------------------------------------------------
    cur = head;

    while (cur != null) {
        RandNode copy = cur.next;

        copy.rand = (cur.rand == null)
                ? null
                : cur.rand.next;

        // 跳过复制节点,到下一个原节点。
        cur = copy.next;
    }

    // ------------------------------------------------------------
    // 第 3 阶段:
    // 拆开原链表与复制链表。
    // 同时保证原链表被完整恢复。
    // ------------------------------------------------------------
    RandNode copyHead = head.next;
    cur = head;

    while (cur != null) {
        RandNode copy = cur.next;
        RandNode nextOriginal = copy.next;

        // 恢复原链表:
        // original -> nextOriginal
        cur.next = nextOriginal;

        // 连接复制链表:
        // copy -> nextCopy
        copy.next = (nextOriginal == null)
                ? null
                : nextOriginal.next;

        cur = nextOriginal;
    }

    return copyHead;
}

为什么单节点链表特别适合测拆链?

只有一个节点:

text 复制代码
A

第一阶段:

text 复制代码
A → A'

第三阶段必须恢复成:

text 复制代码
A → null

同时复制链表:

text 复制代码
A' → null

如果拆链循环只是"处理到复制节点还有 next 为止",就很容易漏掉最后一个原节点的恢复。

所以单节点用例非常重要。


一个更可靠的深拷贝验证标准

只比较:

text 复制代码
p1.rand.value == p2.rand.value

是不够的。

因为下面这种错误也可能通过:

text 复制代码
原节点 C.value = 7
复制节点 C'.value = 7

A'.rand 错误地指向原节点 C

仅比较值:

text 复制代码
7 == 7

看不出问题。

真正应该验证:

  1. 两条 next 链长度相同;
  2. 对应位置节点值相同;
  3. 对应节点不是同一个对象;
  4. 每个 rand 指向的是相同"位置"的节点;
  5. 复制链任何节点都不属于原链表。

可以把两个链表分别按 next 放进数组,然后比较 rand 的下标映射。


复杂度

方案 时间 额外空间 是否临时修改原链表
HashMap O(N) O(N)
穿插复制 O(N) O(1) 是,但最终恢复

易错点

  • HashMap 版本只设置 rand,忘记设置复制节点的 next
  • 拆链时没有恢复最后一个原节点;
  • 第二阶段错误写成 copy.rand = cur.rand,导致新链表指回原链表;
  • cur.rand == null 时直接访问 cur.rand.next
  • 验证时只比较值,没有验证对象独立性;
  • judge 遍历结束后没有确认两个链表是否同时结束。

答案 5:找到两个链表的相交节点

[返回练习 5](#返回练习 5)

问题回顾

这是今天分类讨论最重的一题。

第一步不要写代码。

先画分类树:

text 复制代码
两个链表
   ↓
分别找入环点 loop1 / loop2
   ↓
┌───────────────────────────────┐
│                               │
两个都无环                    至少一个有环
│                               │
无环相交                    ┌────┴────┐
                            │         │
                       一有一无     两个都有环
                            │         │
                          不相交      ↓
                                比较 loop1 / loop2

继续展开:

text 复制代码
两个都有环
    ↓
loop1 == loop2 ?
 ┌──────┴──────┐
 │             │
是            否
 │             │
在入环前      从 loop1 绕环一圈
寻找第一个     看能否遇到 loop2
相交节点        │
          ┌─────┴─────┐
          │           │
        遇到         没遇到
          │           │
       共享同一环     两个独立环
          │           │
       返回环上      返回 null
       一个共享节点

为什么"一有环、一无环"一定不相交?

假设无环链表和有环链表共享了某个节点:

text 复制代码
无环链表
   \
    X → ... → 环
   /
有环链表

一旦进入同一个节点 X,后面的 next 路径就完全一样。

如果有环链表之后会进入环,那么"无环链表"也必然跟着进入环。

这和"无环"矛盾。

所以:

text 复制代码
一个有环 + 一个无环
=
一定不相交

第一块:Floyd 找入环节点

先判断有没有环:

text 复制代码
slow 每次 1 步
fast 每次 2 步

如果有环,它们最终会在环内相遇。

text 复制代码
head
 ↓
1 → 2 → 3 → 4 → 5
        ↑       ↓
        8 ← 7 ← 6

相遇后:

text 复制代码
一个指针回到 head
另一个留在相遇点

两个都改成每次走 1 步

再次相遇的位置就是入环点。

流程:

text 复制代码
快慢指针第一次相遇
        ↓
fast = head
        ↓
fast、slow 都每次走一步
        ↓
第二次相遇
        ↓
返回入环节点

代码:

java 复制代码
/**
 * 使用 Floyd 算法返回入环节点。
 *
 * 无环时返回 null。
 */
private static Node getLoopEntry(Node head) {
    if (head == null
            || head.next == null
            || head.next.next == null) {
        return null;
    }

    Node slow = head.next;
    Node fast = head.next.next;

    // 第一阶段:判断是否有环,并在环内相遇。
    while (slow != fast) {
        if (fast == null || fast.next == null) {
            return null;
        }

        slow = slow.next;
        fast = fast.next.next;
    }

    // 第二阶段:
    // fast 回到头节点,随后两者都一次一步。
    fast = head;

    while (slow != fast) {
        slow = slow.next;
        fast = fast.next;
    }

    return slow;
}

第二块:两个无环链表

例如:

text 复制代码
head1:
1 → 2 → 3 → 7 → 8 → 9

head2:
      4 → 5 → 7 → 8 → 9

长度:

text 复制代码
len1 = 6
len2 = 5

长链先走:

text 复制代码
6 - 5 = 1 步

然后两个指针同步移动。

text 复制代码
长链先对齐:

1 → 2 → 3 → 7 → 8 → 9
    ↑
   p1

    4 → 5 → 7 → 8 → 9
    ↑
   p2

之后:

text 复制代码
p1、p2 一起向后

第一次:

text 复制代码
p1 == p2

的位置就是第一个相交节点。


第三块:两个有环,且入环点相同

这是非常容易写错的一种情况。

例如:

text 复制代码
head1:
1 → 2
     \
      7 → 8 → 9
     /        ↑ ↓
3 → 4         11 ← 10
head2

假设:

text 复制代码
loop1 == loop2 == 9

但两个链表其实早在:

text 复制代码
7

就已经相交。

所以:

text 复制代码
loop1 == loop2

时不能直接:

java 复制代码
return loop1;

正确做法:

loop1 当成两个前缀共同的"终点",只比较进入环之前的部分。

也就是:

text 复制代码
head1 ... loop1
head2 ... loop2

先计算各自到入环点之前的长度,再对齐。

如果入环前有交点,就会提前相遇;

如果没有,就会同时到达同一个入环点。


第四块:两个有环,但入环点不同

例如:

text 复制代码
head1
  ↓
  1 → 2 → A → B → C
          ↑       ↓
          E ← D ←─┘
              ↑
             head2 经过自己的前缀后从 D 入环

可能:

text 复制代码
loop1 = A
loop2 = D

虽然入环点不同,但:

text 复制代码
A、B、C、D、E

属于同一个环。

判断方法:

text 复制代码
从 loop1.next 出发
绕环一整圈

如果途中遇到:

text 复制代码
loop2

说明共享同一个环。

否则是两个独立环。


关于"第一个相交节点"的一个细节

如果:

text 复制代码
loop1 != loop2

但二者共享同一个环,那么从 head1 的视角:

text 复制代码
loop1

是它第一次进入共享结构的位置。

head2 的视角:

text 复制代码
loop2

又是它第一次进入共享结构的位置。

此时不存在一个同时对两条链都唯一的"最早节点"。

因此常见实现会:

text 复制代码
返回 loop1 或 loop2 中任意一个

它们都确实是共享节点。


O(1) 额外空间完整版本

java 复制代码
/**
 * 返回两个单链表的相交节点。
 *
 * 链表可以无环,也可以有环。
 *
 * 说明:
 * - 无环时,返回唯一的第一个相交节点;
 * - 有环且入环点相同时,返回真正的第一个相交节点;
 * - 有环且入环点不同但共享同一环时,
 *   "第一个"没有全局唯一含义,这里返回 loop1。
 */
public static Node getIntersectNode(Node head1, Node head2) {
    if (head1 == null || head2 == null) {
        return null;
    }

    Node loop1 = getLoopEntry(head1);
    Node loop2 = getLoopEntry(head2);

    // 两个都无环。
    if (loop1 == null && loop2 == null) {
        return intersectNoLoop(head1, head2);
    }

    // 一个有环、一个无环:不可能相交。
    if ((loop1 == null) != (loop2 == null)) {
        return null;
    }

    // 两个都有环。
    return intersectBothLoop(head1, loop1, head2, loop2);
}

/**
 * 处理两个无环链表。
 */
private static Node intersectNoLoop(Node head1, Node head2) {
    int len1 = lengthUntil(head1, null);
    int len2 = lengthUntil(head2, null);

    Node p1 = head1;
    Node p2 = head2;

    // 让更长的链表先走长度差。
    if (len1 > len2) {
        p1 = advance(p1, len1 - len2);
    } else {
        p2 = advance(p2, len2 - len1);
    }

    // 对齐后同步移动。
    while (p1 != p2) {
        p1 = p1.next;
        p2 = p2.next;
    }

    // 相交时这里是第一个公共节点;
    // 不相交时两个指针最终都会变成 null。
    return p1;
}

/**
 * 处理两个有环链表。
 */
private static Node intersectBothLoop(
        Node head1,
        Node loop1,
        Node head2,
        Node loop2) {

    // ------------------------------------------------------------
    // 情况 1:
    // 两个链表从同一个节点进入环。
    //
    // 真正的第一个交点可能出现在入环之前,
    // 所以要先处理两个"环前前缀"。
    // ------------------------------------------------------------
    if (loop1 == loop2) {
        int len1 = lengthUntil(head1, loop1);
        int len2 = lengthUntil(head2, loop2);

        Node p1 = head1;
        Node p2 = head2;

        if (len1 > len2) {
            p1 = advance(p1, len1 - len2);
        } else {
            p2 = advance(p2, len2 - len1);
        }

        // 如果入环前已经相交,会提前遇到;
        // 否则最终会同时到达 loop1。
        while (p1 != p2) {
            p1 = p1.next;
            p2 = p2.next;
        }

        return p1;
    }

    // ------------------------------------------------------------
    // 情况 2:
    // 入环点不同。
    //
    // 从 loop1 绕一圈,看能不能遇到 loop2。
    // ------------------------------------------------------------
    Node cur = loop1.next;

    while (cur != loop1) {
        if (cur == loop2) {
            // 共享同一个环。
            // 此时"第一个公共节点"没有全局唯一含义,
            // 返回 loop1 作为一个合法公共节点。
            return loop1;
        }

        cur = cur.next;
    }

    // 绕一圈也没遇到 loop2:
    // 两个链表属于不同的环。
    return null;
}

/**
 * 统计从 head 出发,到 stopExclusive 之前一共有多少个节点。
 *
 * stopExclusive == null 时:
 * 用于无环链表,统计完整长度。
 *
 * stopExclusive != null 时:
 * 用于统计进入环之前的前缀长度。
 */
private static int lengthUntil(Node head, Node stopExclusive) {
    int length = 0;
    Node cur = head;

    while (cur != stopExclusive) {
        length++;
        cur = cur.next;
    }

    return length;
}

/**
 * 让指针向前走 steps 步。
 */
private static Node advance(Node node, int steps) {
    while (steps-- > 0) {
        node = node.next;
    }

    return node;
}

/**
 * Floyd:寻找入环节点。
 */
private static Node getLoopEntry(Node head) {
    if (head == null
            || head.next == null
            || head.next.next == null) {
        return null;
    }

    Node slow = head.next;
    Node fast = head.next.next;

    while (slow != fast) {
        if (fast == null || fast.next == null) {
            return null;
        }

        slow = slow.next;
        fast = fast.next.next;
    }

    fast = head;

    while (slow != fast) {
        slow = slow.next;
        fast = fast.next;
    }

    return slow;
}

HashSet 基准版本也要注意"第二条链自己的环"

用集合做对数器是很自然的:

text 复制代码
先收集 head1 能到达的所有节点
再从 head2 开始向后找

但是:

text 复制代码
head2 自己也可能有环

如果它和 head1 完全不相交,而代码没有记录 head2 已访问节点,就会无限循环。

更稳妥:

java 复制代码
import java.util.HashSet;
import java.util.Set;

/**
 * 使用集合实现的基准版本。
 *
 * 时间 O(N)
 * 额外空间 O(N)
 */
public static Node getIntersectNodeBySet(Node head1, Node head2) {
    if (head1 == null || head2 == null) {
        return null;
    }

    Set<Node> nodesOfFirst = new HashSet<>();

    Node cur = head1;

    // nodesOfFirst.add(cur) 返回 false,
    // 说明遇到了第一条链自己的环。
    while (cur != null && nodesOfFirst.add(cur)) {
        cur = cur.next;
    }

    Set<Node> visitedSecond = new HashSet<>();
    cur = head2;

    while (cur != null && visitedSecond.add(cur)) {
        if (nodesOfFirst.contains(cur)) {
            return cur;
        }

        cur = cur.next;
    }

    return null;
}

复杂度

设两个链表总共涉及的不同节点数量为 N

方案 时间 额外空间
Floyd + 分类讨论 O(N) O(1)
HashSet 基准 O(N) O(N)

易错点

  • loop1 == loop2 时直接返回入环点,漏掉环前更早交点;
  • 一个有环一个无环时还继续做复杂判断;
  • 两个不同入环点时,不绕环检查是否共享同一环;
  • HashSet 版本只防第一条链死循环,却没有防第二条链自己的环;
  • value 判断相交,而不是用节点引用;
  • 没有先明确"不同入环点但共享环时,第一个交点并不全局唯一"。

第三部分:五道题统一理解

今天五道题表面上分别是:

text 复制代码
中点
回文
分区
复制
相交

但真正反复出现的能力只有四类。


1. 快慢指针:用速度差表达结构信息

出现位置:

text 复制代码
链表中点
回文链表找中点
Floyd 判环
Floyd 找入环点

核心不是:

text 复制代码
fast += 2
slow += 1

而是:

把"链表长度 / 环结构"这种看不见的全局信息,转换成两个指针的相对位置。


2. 改 next 之前先保存后继

出现位置:

text 复制代码
反转链表
链表三路分区
随机指针链表穿插与拆分

统一模板:

java 复制代码
Node next = cur.next;

// 从这里开始才允许修改 cur.next。
cur.next = ...;

cur = next;

一句话:

链表没有数组下标帮你找回后面的位置,断链前不保存 next,就可能永远找不回去。


3. 临时修改结构,就要考虑恢复

出现位置:

text 复制代码
回文链表
随机指针复制

它们都为了省空间,暂时利用了原链表结构。

text 复制代码
回文:
反转后半段
→ 比较
→ 再反转回来

随机指针复制:
原节点和复制节点穿插
→ 建 random
→ 拆开并恢复

因此写代码时要主动问:

text 复制代码
是否存在某条提前 return 路径,
会让我跳过恢复步骤?

4. 链表复杂题先分类,不要直接写 while

最典型是相交问题。

text 复制代码
先判环
  ↓
再分类
  ↓
每一类单独证明
  ↓
最后编码

如果不先分类,代码往往会变成:

text 复制代码
一个巨大 while
+
大量 if
+
很难证明

五题对比表

题目 核心结构 时间 额外空间 会修改链表吗
上 / 下中点 快慢指针 O(N) O(1)
回文链表 中点 + 反转 O(N) O(1) 临时修改并恢复
三路分区 三条临时链 O(N) O(1) 是,重排节点
random 深拷贝 穿插映射 O(N) O(1) 临时修改并恢复
链表相交 Floyd + 分类 O(N) O(1)

第四部分:今天最重要的指针不变式

1. 上中点

循环开始时:

text 复制代码
slow 走过的距离
约等于
fast 走过距离的一半

停止条件负责决定:

text 复制代码
偶数长度时 slow 落在前中点还是后中点

2. 回文链表

比较阶段始终保持:

text 复制代码
p1 指向前半段当前待比较位置
p2 指向"反转后的后半段"当前待比较位置

并且:

text 复制代码
secondHalfHead

必须一直保存,供最终恢复使用。


3. 三路分区

扫描任意时刻:

text 复制代码
已经处理过的节点
全部且仅属于
less / equal / greater 三条链中的一条

每条链内部保持原相对顺序。

未处理节点仍然可以通过:

text 复制代码
next

继续访问。


4. random 复制

第一阶段结束后:

text 复制代码
每一个原节点 original
满足:

original.next
=
它对应的复制节点 copy

这条性质就是替代 HashMap 的关键。


5. 链表相交

分类以后,每个子问题只维护一种简单事实。

无环:

text 复制代码
对齐后,
两个指针距离各自尾部一样远

相同入环点:

text 复制代码
对齐后,
两个指针距离共同 loop 的步数一样

不同入环点:

text 复制代码
只需要判断 loop2 是否属于 loop1 所在的环

第五部分:从原始实现里最值得修正的几个点

这一部分不是为了"挑错",而是把最容易在真实面试或工程代码里出问题的边界提炼出来。


1. 中点对数器:节点可能为 null

不要写:

java 复制代码
upperMiddle(head).equals(expected)

更稳:

java 复制代码
upperMiddle(head) != expected

这里比较的是节点身份,而且天然支持:

text 复制代码
null == null

2. 回文:失败路径也必须恢复

危险结构:

java 复制代码
if (p1.value != p2.value) {
    return false;
}

如果前面已经反转了后半段,这个 return 会跳过恢复。

更稳:

java 复制代码
boolean ans = true;

if (...) {
    ans = false;
    break;
}

// 统一恢复

return ans;

3. 三路分区:不要假设某个区域一定存在

例如:

text 复制代码
5 → 5 → 5
pivot = 5

此时:

text 复制代码
lessTail == null
greaterTail == null

任何无条件:

java 复制代码
greaterTail.next = null;

都会失败。

最简单的办法是:

text 复制代码
每个节点进入区域前先 cur.next = null

这样尾部不需要额外收尾。


4. random 复制:不仅要复制 rand,也要复制 next

HashMap 版本第二遍必须同时做:

java 复制代码
copy.next = map.get(cur.next);
copy.rand = map.get(cur.rand);

只做第二句,复制出来的节点之间并没有形成完整 next 链。


5. random 拆链:最后一个原节点也要恢复

拆链应该以:

java 复制代码
while (cur != null)

为主循环。

而不是依赖:

text 复制代码
copy.next 是否为空

否则尾节点很容易遗漏。


6. 相交:相同入环点不代表"第一个交点就是入环点"

text 复制代码
head1 ─────┐
           ├→ X → ... → loop
head2 ─────┘

如果:

text 复制代码
X

在环外,那么它显然比 loop 更早。

所以:

text 复制代码
loop1 == loop2

以后仍然要处理环前前缀。


第六部分:统一对数器与验证思路

链表题的对数器有一个特别重要的原则:

不要只验证"值看起来对不对",还要验证节点身份、结构是否丢失、是否意外形成环、原链表是否被恢复。


1. 中点:数组保存节点引用

text 复制代码
链表节点依次放进 List
        ↓
用下标直接算标准答案
        ↓
和快慢指针返回值做引用比较

上中点:

java 复制代码
nodes.get((size - 1) / 2)

下中点:

java 复制代码
nodes.get(size / 2)

2. 回文:比较结果之外,还要验证原链表没被改坏

测试前先记录:

text 复制代码
节点引用顺序
+
每个 next 指向

调用 isPalindrome(head) 后,再确认:

text 复制代码
所有节点仍然按原顺序连接

这样才能发现:

text 复制代码
"结果算对了,但链表没恢复"

这种非常隐蔽的问题。


3. 三路分区:不要只判断分区性质

只检查:

text 复制代码
先 < pivot
再 == pivot
再 > pivot

还不够。

错误实现可能:

text 复制代码
丢了一个节点
重复接了一个节点
同一区域顺序被改了

更可靠的验证:

text 复制代码
先按原链表生成期望节点顺序:

expectedLess
expectedEqual
expectedGreater

拼成 expected

然后检查输出链表中的
"节点引用顺序"
是否和 expected 完全一致

这样同时验证:

  • 分区正确;
  • 稳定;
  • 没丢节点;
  • 没重复节点。

4. random 深拷贝:验证"位置映射"

把原链表和复制链表分别保存为:

text 复制代码
originalNodes[]
copyNodes[]

建立:

text 复制代码
节点 -> 下标

对于每个位置 i

text 复制代码
originalNodes[i].value
==
copyNodes[i].value

并且:

text 复制代码
originalNodes[i]
!=
copyNodes[i]

如果:

text 复制代码
originalNodes[i].rand

指向原数组下标 j

那么必须保证:

text 复制代码
copyNodes[i].rand
==
copyNodes[j]

这比只比较 rand.value 强得多。


5. 相交:随机生成不如"结构化构造"

相交题尤其适合手工构造典型拓扑。

至少测试:

text 复制代码
1. 两个无环,不相交
2. 两个无环,在头部相交
3. 两个无环,在中间相交
4. 一个有环,一个无环
5. 两个有环,相同入环点,环前不相交
6. 两个有环,相同入环点,环前已相交
7. 两个有环,不同入环点,但共享同一环
8. 两个有环,完全独立

这些用例比纯随机生成更容易覆盖真正危险的分支。


第七部分:容易混淆的几个点

1. "快慢指针"不是一个固定模板

下面都叫快慢指针:

text 复制代码
上中点
下中点
判环
找入环点

但初始化和停止条件不一样。

应该记:

text 复制代码
我想让 slow 最终停在哪里?

而不是记:

text 复制代码
slow = head.next
fast = head.next.next

这样的孤立代码片段。


2. 链表节点相等,通常问的是引用相等

text 复制代码
Node A(value = 7)
Node B(value = 7)

它们的值相同,但:

java 复制代码
A != B

对于:

text 复制代码
中点
相交
random 指针目标

节点身份往往比值更重要。


3. O(1) 额外空间经常意味着"借用原结构"

今天两个典型例子:

text 复制代码
回文:
借用后半段 next,临时反转

random 复制:
借用 next,穿插保存映射关系

所以空间省下来了,代价是:

text 复制代码
实现必须更谨慎地恢复结构

4. 分区正确不等于稳定

以下两种输出都满足值分区:

text 复制代码
3 → 2 → 1 → 5 → 5 → 7 → 8
text 复制代码
1 → 3 → 2 → 5 → 5 → 8 → 7

但如果原小于区顺序是:

text 复制代码
3 → 2 → 1

第二种就不稳定。

六指针尾插法天然稳定。


5. 有环相交问题的"第一个"需要先明确语义

当:

text 复制代码
loop1 == loop2

第一交点唯一,可以真正求"最早"。

当:

text 复制代码
loop1 != loop2

但共享同一个环时:

text 复制代码
从 head1 看,loop1 更早
从 head2 看,loop2 更早

因此没有一个同时对两条链都唯一最早的公共节点。

这类题要先说清约定,再写返回逻辑。


今日自测

1. 为什么上中点和下中点只改 while 条件就能得到不同答案?

查看答案

因为两个版本都让 fast 每次走两步、slow 每次走一步,真正决定 slow 最后多走不走那一步的是循环停止条件。偶数长度时,这最后一步正好决定 slow 落在前中点还是后中点。

2. 为什么链表中点的对数器应该比较节点引用?

查看答案

链表可能有多个 value 相同的节点。题目要求返回的是特定位置的节点对象,因此应验证返回引用是否就是同一个节点,而不是只比较 value。

3. 回文链表为什么建议反转 slow.next,而不是包含上中点一起反转?

查看答案

让上中点保留在前半段后,slow.next 正好对应需要与链表开头比较的后半段。奇数长度时天然跳过中间节点,偶数长度时后半段长度正好是 N/2,比较逻辑统一。

4. 为什么发现回文不匹配后不能立即 return?

查看答案

因为 O(1) 空间方案已经临时反转了原链表后半段。立即返回会跳过恢复逻辑,导致调用结束后原链表被破坏。应该记录结果、统一恢复,再返回。

5. 三路分区为什么每个区域都要 head 和 tail?

查看答案

head 用于最终找到整个区域的起点,tail 用于 O(1) 时间把新节点追加到区域末尾。只有 head 没有 tail,就需要每次重新走到链尾,整体可能退化为 O(N²)。

6. 为什么三路分区中先 cur.next = null 很重要?

查看答案

它把当前节点和原链表旧结构彻底分离,避免旧 next 跨区域残留。配合提前保存 next,可以确保既不会丢失后续节点,也不会留下错误连接或意外环。

7. 穿插复制为什么可以替代 HashMap?

查看答案

因为第一轮结束后,每个原节点的 next 都恰好指向自己的复制节点。于是"原节点到复制节点"的映射被编码在链表本身,不再需要额外哈希表。

8. copy.rand = cur.rand.next 为什么成立?

查看答案

若 cur.rand 指向某个原节点 X,那么经过穿插复制后 X.next 就是 X 的复制节点 X'。因此 cur 对应的 copy 应该把 rand 指向 cur.rand.next。

9. 一个有环链表和一个无环链表为什么不可能相交?

查看答案

一旦两个链表进入同一个节点,之后的 next 路径就完全相同。如果其中一条之后会进入环,另一条也必然进入同一个环,因此它不可能仍然是无环链表。

10. 两个链表入环点相同,为什么不能直接返回入环点?

查看答案

它们可能在进入环之前就已经相交。正确做法是把共同入环点视为前缀终点,对两个环前部分做长度对齐,先寻找更早的交点;只有环前不相交时,答案才是入环点。

11. 两个不同入环点怎样判断是否共享一个环?

查看答案

从 loop1.next 出发沿环走一整圈。如果途中遇到 loop2,说明两个入口属于同一个环;如果回到 loop1 都没有遇到 loop2,则是两个独立环。

12. 为什么 random 深拷贝验证不能只比较 random 指向节点的值?

查看答案

因为复制节点可能错误地指向原链表中一个"值相同"的节点,仅比较 value 会误判通过。深拷贝必须验证指向的是复制链中对应位置的新节点,并且新旧节点不共享对象。


加餐挑战

  1. 写出"奇数返回中点,偶数返回上中点前一个"的快慢指针版本;
  2. 写出"奇数返回中点前一个,偶数返回上中点前一个"的版本;
  3. 在回文链表代码中统计实际比较了多少对节点;
  4. 写一个不稳定的链表 partition,再构造数据证明它为什么不稳定;
  5. 把三路分区扩展成"按多个 pivot 划分多个区间";
  6. 给 random 链表增加一个 prev 指针,思考深拷贝时需要怎么修改;
  7. 写一个严格验证 random 深拷贝结构的 judge 方法;
  8. 不调用任何现成 Tools.hasCycle,完整写出 Floyd 判环与找入口;
  9. 构造两个"入环点相同,但环前已经相交"的链表,验证不能直接返回 loop;
  10. 构造两个"入环点不同,但共享同一个环"的链表,并解释为什么不存在全局唯一的第一个公共节点。

挑战 1 / 2 提示

继续用短链表手推法。先写出长度 1~6 时你希望 slow 停在哪,再倒推 slow、fast 的初始位置和 while 条件。
挑战 7 提示

把两条 next 链分别放进 List,并建立 Node 到下标的 Map。验证 copy.rand 时,不比较 value,而比较它在复制链中的目标下标是否和原链的 rand 目标下标一致。


Day 13 复盘清单

  • 我能不背模板,手推长度 2~5 的链表得到上中点和下中点
  • 我知道链表节点位置类问题通常应该比较引用,而不是只比较 value
  • 我能写出 O(N) 时间、O(1) 空间的上中点和下中点
  • 我能用半栈方法判断回文链表
  • 我能用"找中点 + 反转后半段 + 比较 + 恢复"判断回文
  • 我知道回文比较失败时为什么不能在恢复前直接 return
  • 我能独立写出单链表反转,并始终先保存 next
  • 我能用六个指针完成稳定三路分区
  • 我知道为什么分区时最好先把当前节点断链
  • 我能正确处理 less / equal / greater 任意区域为空的情况
  • 我知道数组荷兰国旗方案通常为什么不稳定
  • 我能用 HashMap 完成 random 链表深拷贝
  • 我能解释"穿插复制"为什么等价于保存 original → copy 映射
  • 我能写出 random 链表的三阶段 O(1) 空间复制方案
  • 我知道拆链时必须同时恢复原链表和连接复制链表
  • 我知道深拷贝验证不能只比较节点值
  • 我能用 Floyd 找到链表的入环点
  • 我知道一个有环链表和一个无环链表一定不可能相交
  • 我能用长度差解决两个无环链表的第一个交点
  • 我知道相同入环点时仍然要检查环前是否已经相交
  • 我能判断两个不同入环点是否属于同一个环
  • 我理解"不同入环点但共享同一环"时,第一个公共节点不具有全局唯一性
  • 我会用结构化测试覆盖链表算法的关键拓扑,而不是只依赖随机数据

一句话总结: Day 13 真正修炼的是"指针语义"。快慢指针负责从结构里提取位置,断链重连负责在 O(1) 空间里重组链表,分类讨论负责把复杂拓扑拆成可以证明的小问题;写链表代码时,只要始终知道每个指针代表什么、改 next 前保存了什么、临时修改最终如何恢复,绝大多数边界错误都会明显减少。

相关推荐
如此这般英俊1 小时前
手搓Claude Code-第十三章 background_tasks
前端·人工智能·chrome·python·算法·语言模型·自然语言处理
Fa_Mian_Tuan1 小时前
图论基础 | 邻接多重表原理剖析 + 完整可运行C语言代码(插边/删边/释放)
c语言·数据结构·笔记·算法·图论
学习星球1 小时前
【LeetCode算法题精讲】二分查找精讲
java·数据结构·算法·leetcode·职场和发展·图搜索
邵奈一1 小时前
Ornith 1.5 开源即王炸:会自我进化的大模型 Mac 本地部署实测
算法
民乐团扒谱机1 小时前
【微实验】线性代数之——奇异值分解 SVD:穿透数据迷雾的“X光机”,附 MATLAB 全流程仿真
线性代数·算法·matlab
arin8761 小时前
【图论】网络流
算法
_Narcissus_2 小时前
B+树的概念和操作笔记(含完整代码实现)
c语言·数据结构·数据库·c++·笔记·b树·算法
aichitang20242 小时前
快乐泛函每一天!内积空间
c++·python·数学·算法·机器学习·ai·泛函分析
茜茜数模3 小时前
2026年天府杯大学生数学建模A题全套资源
人工智能·算法·机器学习