Files
leetcode-go/链表/32-随机链表的复制.md

23 KiB
Raw Permalink Blame History

tags, create time
tags create time
LeetCode
链表
哈希表
原地操作
中等
2026-05-17 15:00

32-随机链表的复制

题面

给你一个长度为 n 的链表,每个节点包含一个额外增加的随机指针 random,该指针可以指向链表中的任何节点或空节点。

构造这个链表的 深拷贝。深拷贝应该正好由 n 个 全新 节点组成,其中每个新节点的值都设为其对应的原节点的值。新节点的 next 指针和 random 指针也都应指向复制链表中的新节点,并使原链表和复制链表中的这些指针能够表示相同的链表状态。复制链表中的指针都不应指向原链表中的节点。

例如,如果原链表中有 X 和 Y 两个节点,其中 X.random --> Y。那么在复制链表中对应的两个节点 x 和 y,同样有 x.random --> y。

返回复制链表的头节点。

用一个由 n 个节点组成的链表来表示输入/输出中的链表。每个节点用一个 [val, random_index] 表示:

  • val:一个表示 Node.val 的整数。
  • random_index:随机指针指向的节点索引(范围从 0 到 n-1);如果不指向任何节点,则为 null。

你的代码 只 接受原链表的头节点 head 作为传入参数。

示例 1:

输入:head = [[7,null],[13,0],[11,4],[10,2],[1,0]]
输出:[[7,null],[13,0],[11,4],[10,2],[1,0]]
解释:原链表有 5 个节点。
- 节点 7 的 random 指向 null
- 节点 13 的 random 指向自己(索引 0)
- 节点 11 的 random 指向自己(索引 4,即最后一个节点 1)
- 节点 10 的 random 指向节点 10(索引 2)
- 节点 1 的 random 指向节点 13(索引 1)

示例 2:

输入:head = [[1,1],[2,1]]
输出:[[1,1],[2,1]]

示例 3:

输入:head = [[3,null],[3,0],[3,null]]
输出:[[3,null],[3,0],[3,null]]

提示:

  • 0 <= n <= 1000
  • -10^4 <= Node.val <= 10^4
  • Node.random 为 null 或指向链表中的节点。

[!warning] ⚠️ 与普通链表复制的区别 普通单向链表复制只需依次创建新节点并链接 next。但这道题多了 random 指针——它可能指向已经处理过、还未处理或自身之前从未遇到过的任意节点。这意味着你不能按顺序一次性搞定每个节点的所有指针。


思路

[!question] 💡 核心难点 给定原链表中的某个节点 A,我们要创建一个新节点 A'。A'.next 很容易——它就是 A.next 对应的新节点 B'。但 A'.random 指向哪里?如果 A.random 指向 F,我们需要知道 F 对应的新节点 F' 是谁。问题是:我们在遍历过程中无法通过值来定位 F',因为不同节点可能有相同的值。

所以核心问题归结为一句话:如何在 O(1) 或 O(n) 时间内建立"旧节点 → 新节点"的映射关系?

方法一:哈希表法(直觉方案)⭐

既然问题等价于"旧节点到新节点的查找",那最直接的想法就是——用一张哈希表存下这个映射。

[!tip] 🔑 为什么不能靠 val 来匹配? 看示例 3:三个节点 val 都是 3,第二个节点的 random 指向第一个节点(索引 0)。如果仅靠值相等来判断,你会把 random 指向错误的副本。必须依赖引用/地址关系而非值。

整个过程分为两轮遍历:

第一轮:创建所有新节点并建映射

flowchart LR
    subgraph Map["哈希表: old → new"]
        M1["A(old) → A'(new)"]
        M2["B(old) → B'(new)"]
        M3["C(old) → C'(new)"]
        M4["D(old) → D'(new)"]
        M5["E(old) → E'(new)"]
    end

    Map --> RESULT["拿到 copyHead = A' ✅"]

    style M1 fill:#bbf,stroke:#333
    style M2 fill:#bbf,stroke:#333
    style M3 fill:#bbf,stroke:#333
    style M4 fill:#bbf,stroke:#333
    style M5 fill:#bbf,stroke:#333
    style RESULT fill:#d4edda,stroke:#28a

第二轮:填充 next 和 random 指针

flowchart TD
    START["遍历原链表: A→B→C→D→E"] --> STEP1["cur = A\nA'.Next = Map[A.Next] = B'\nA'.Random = Map[A.Random]\n\n比如 A.Random=nil → A'.Random=nil"]
    STEP1 --> STEP2["cur = B\nB'.Next = Map[B.Next] = C'\nB'.Random = Map[B.Random]\n\n比如 B.Random=A → B'.Random=Map[A]=A'"]
    STEP2 --> STEP3["对每个节点重复\n利用 Map[old] 直接查 new\nO(1) 时间"]
    STEP3 --> DONE["全部设置完毕 ✅\n返回 A'"]

    style START fill:#ffd700,stroke:#333
    style STEP1 fill:#9df,stroke:#333
    style STEP2 fill:#bbf,stroke:#333
    style STEP3 fill:#f9d,stroke:#333
    style DONE fill:#d4edda,stroke:#28a

逐步演示(以示例 1 为例):

原链表:

     7          13         11         10         1
   (idx 0)  (idx 1)    (idx 2)    (idx 3)    (idx 4)
   random=null random→0  random→2  random→4  random→1
轮次 cur(旧节点) cur.Next cur.Random map[旧→新] A'.Next A'.Random
第一轮 A(7) → B(13) nil {A→A'} — —
第一轮 B(13) → C(11) → A {A→A', B→B'} — —
第一轮 C(11) → D(10) → E {A→A', B→B', C→C'} — —
第一轮 D(10) → E(1) → C {A→A', B→B', C→C', D→D'} — —
第一轮 E(1) nil → B {全5个} — —
第二轮 A B nil — B' nil
第二轮 B C A — C' A'
第二轮 C D A — D' A'
第二轮 D E C — E' C'
第二轮 E nil B — nil B'

最终结果:

      7'         13'        11'        10'        1'
   random=null random→A' random→A' random→C' random→B'

[!info] 🧠 映射的建立时机很关键 我们是在第一遍创建节点时就同步建好映射,这样第二遍遍历中无论 random 指向哪个旧节点,都能在 O(1) 时间内找到对应的新节点。这个设计决定了整个算法的简洁性。

时间复杂度:O(n) — 两次线性遍历
空间复杂度:O(n) — 哈希表存储 n 个映射

[!note] 🤔 Go 中使用 make(map[*Node]*Node) 还是 map[int][]*Node? 答案是 **map[Node]Node——直接用指针地址做 key!因为只有同一个节点才能正确映射到它的副本。靠 val 不行(值可重复),靠 index 更麻烦(需要先用数组装一遍节点再编索引)。Go 的 map 天然支持指针类型的比较(比较的是内存地址),这正是我们需要的行为。


方法二:原地交织法(O(1) 空间)⭐(最优)⭐

[!question] 💡 能不能不借助额外的哈希表? 如果我们能让新节点紧挨着旧节点出现,那么每个新节点都能通过旧节点的 next 指针"顺手"访问到下一个旧节点。更重要的是:random 的关系也能通过地址偏移自然表达——这避免了任何额外的数据结构。

这个方法的核心思想:把新节点"插"进原链表中,让它成为旧节点的后继。这样新旧节点成对交错排列,random 映射可以通过地址关系直接推导出来。

三步走策略

flowchart LR
    S1["① 交织: 在每个旧节点后插入对应的副本\nA→B→C ⇒ A→A'→B→B'→C→C'"] --> S2["② 连random: 利用A'就在A后面的关系\nA'.Random = A.Random.Next\n(若A.Random存在, 则它的副本一定在它后面)"] --> S3["③ 拆分: 将新旧链表重新分离\nA→B→C 和 A'→B'→C'"]

    style S1 fill:#bbf,stroke:#333
    style S2 fill:#f9d,stroke:#333
    style S3 fill:#d4edda,stroke:#28a

第一步:交织 —— 在新建节点的同时把它接在原节点后面

对于原链表中的每个节点 A,创建 A',然后将 A' 插入到 A 和 A.next 之间。

// 伪代码
cur := head
for cur != nil {
    nextTemp := cur.Next           // 暂存 A.Next = B
    cur.Next = &Node{Val: cur.Val} // 创建 A', A→A'
    cur.Next.Next = nextTemp       // A'→B,恢复连接
    cur = nextTemp                 // 走到 B
}

图解(A→B→C→D 变为 A→A'→B→B'→C→C'→D→D'):

flowchart LR
    subgraph BEFORE["原始链表"]
        B1["A"] --> B2["B"]
        B2 --> B3["C"]
        B3 --> B4["D"]
        B4 -.-> END1["nil"]
    end

    subgraph AFTER["交织后的链表"]
        A1["A"] --> AP["A']"]
        AP --> B5["B"]
        B5 --> BP["B']"]
        BP --> C1["C"]
        C1 --> CP["C']"]
        CP --> D1["D"]
        D1 -.-> DP["D']"]
        DP -.-> END2["nil"]
    end

    BEFORE ==> AFTER

    style AP fill:#f9d,stroke:#333
    style BP fill:#f9d,stroke:#333
    style CP fill:#f9d,stroke:#333
    style DP fill:#f9d,stroke:#333

[!info] 🧠 这一步的精妙之处 交织完成后,每个新节点都在其对应旧节点的后面一位。这意味着如果旧节点 X 的 random 指向 Y,那么新节点 X' 的 random 就指向 Y'——而 Y' 恰好就在 Y 的后面!所以:

cur.Next.Random = cur.Random.Next

(前提是 cur.Random 不为 nil)

第二步:连接 random 指针

由于新节点紧贴在旧节点后面,我们可以同时遍历旧节点和新节点,利用地址相邻的关系:

cur.Next.Random = (cur.Random == nil) ? nil : cur.Random.Next

这里的逻辑:

  • 如果 cur.Random == nil → cur'.Random = nil(没有指向)
  • 如果 cur.Random != nil → cur'.Random = cur.Random.Next(cur.Random 的副本恰好在它后面)

图解:

flowchart TD
    A["A"] --> AR["A.Random = C"]
    AR --> C["C"]
    C .-> NEXT["C.Next = C'"]
    
    A .-> AP["A']"]
    AP --> NPR["A'.Random = A.Random.Next = C.Next = C'"]
    
    AP -.随机指针已连.-> RESULT["✅ A'.Random 指向 C' 无需查找"]

    style AP fill:#f9d,stroke:#333
    style C fill:#bbf,stroke:#333
    style RESULT fill:#d4edda,stroke:#28a

第三步:拆分成两条独立的链表

最后一步是把交织后的长链重新拆成两条:一条旧链表(保持原样)、一条新链表(完全独立)。

关键细节: 在断开 A→A' 时不能丢了 A' 后续的连接,所以需要分别维护两条链的尾指针。

// 伪代码
oldCur := head          // 沿着旧链表走
newCur := head.Next     // 沿着新链表走
newHead := head.Next    // 保存新链表的头

for oldCur != nil {
    oldCur.Next = oldCur.Next.Next     // A→B,跳过 A'
    if newCur.Next != nil {
        newCur.Next = newCur.Next.Next // A'→B',跳过 B
    }
    oldCur = oldCur.Next               // 前进到下一个旧节点
    newCur = newCur.Next               // 前进到下一个新节点
}
return newHead

逐步拆解(A→A'→B→B'→C→C' 拆开):

flowchart TD
    SUB1["初始: A→A'→B→B'→C→C'"] --> STEP1["断开 A→A': A→B\nA'.Next 仍指 B,先不动\noldCur=B, newCur=B'"]
    STEP1 --> STEP2["断开 B→B': B→C\nB'→C'\noldCur=C, newCur=C'"]
    STEP2 --> STEP3["断开 C→C': C→nil\nC'→nil\n完成 ✅"]
    
    STEP3 --> OLD["旧链表: A→B→C"]
    STEP3 --> NEW["新链表: A'→B'→C'"]

    style SUB1 fill:#ffd700,stroke:#333
    style STEP1 fill:#9df,stroke:#333
    style STEP2 fill:#bbf,stroke:#333
    style STEP3 fill:#f9d,stroke:#333
    style OLD fill:#eee,stroke:#999
    style NEW fill:#d4edda,stroke:#28a

[!warning] ⚠️ 常见错误:newCur.Next.Next 可能越界 当新链表的末尾节点(如 D')尝试访问 D'.Next.Next 时会 panic(nil 指针)。所以需要在访问前检查 if newCur.Next != nil。

[!note] 🤔 为什么要保留旧链表? 题目要求返回的是新的链表,不要求恢复旧链表。但从好的工程实践来说,不修改原数据是一个好习惯。即使 LeetCode 不强制恢复,保留这个操作可以让你的代码更具普适性。

时间复杂度:O(n) — 三次线性遍历(交织、连 random、拆分),总计约 3n 步
空间复杂度:O(1) — 只用了几条临时指针变量,没有额外数据结构


代码提示

方法一:哈希表法伪代码

func copyRandomList(head):
    if head == nil: return nil
    
    // 辅助函数:根据旧节点获取(或创建)对应新节点
    func getNode(map, cur):
        if cur == nil: return nil
        if cur not in map:
            map[cur] = &Node{Val: cur.Val}
        return map[cur]
    
    cur := head
    while cur != nil:
        getNode(map, cur)                    // 确保当前节点有新节点
        getNode(map, cur.Next)               // 确保 next 指向的节点有新节点
        getNode(map, cur.Random)             // 确保 random 指向的节点有新节点
        
        // 连指针
        map[cur].Next  = map[cur.Next]
        map[cur].Random = map[cur.Random]
        
        cur = cur.Next
    
    return map[head]

方法二:原地交织法伪代码

func copyRandomList(head):
    if head == nil: return nil
    
    // 步骤 1:交织 — 在每个旧节点后插入副本
    cur := head
    while cur != nil:
        nextTemp := cur.Next
        newNode := &Node{Val: cur.Val}
        cur.Next = newNode
        newNode.Next = nextTemp
        cur = nextTemp
    
    // 步骤 2:连 random 指针
    cur := head
    while cur != nil:
        if cur.Random != nil:
            cur.Next.Random = cur.Random.Next
        cur = cur.Next.Next  // 每次跳两步,沿旧链表走
    
    // 步骤 3:拆分成两条链表
    oldCur := head
    newCur := head.Next
    newHead := newCur
    
    while oldCur != nil:
        oldCur.Next = oldCur.Next.Next
        if newCur.Next != nil:
            newCur.Next = newCur.Next.Next
        oldCur = oldCur.Next
        newCur = newCur.Next
    
    return newHead


技巧

[!tip] 🔑 "先占位,后连线"模式 遇到需要建立复杂映射关系的问题,考虑是否可以先创建所有实体(节点、位置等)并建立某种结构化的组织方式,然后基于这个结构去填充关联。这本质上是"空间换时间"思想的变体——多一次遍历建结构,后续操作就简化了。

[!tip] 🔑 地址编码法(原地法的精髓) 让新元素紧跟旧元素之后排列,可以用物理位置的邻接关系替代抽象的数据结构映射。这是一种非常巧妙的"用空间布局信息代替额外存储"的技巧,类似思路还出现在「数组中去重」(用前半段存集合)、「堆排序建堆」(用树形位置关系隐含父子关系)等问题中。

[!warning] ⚠️ 常见错误 1:Go 中 && 不能与 := 组合使用 这是最容易被踩的坑。下面这行代码在 C++ / Java 中可以正常工作,但在 Go 中会编译报错:

// ❌ 编译错误:non-name XXX on left side of :=
if cur.Next != nil && _, ok := mapOldToNew[cur.Next]; !ok { ... }

必须拆成两层 if,且判断必须在左侧保证非 nil。正确的写法是先检查非 nil,再在内部创建映射。

[!warning] ⚠️ 常见错误 2:只创建新节点但没有正确建立 random 映射 很多人卡在只写出了创建新节点的代码,却不知道如何高效地为 random 赋值。核心误区是试图通过遍历来找到 random 指向节点的副本——这是 O(n²) 的做法。记住要么用哈希表 O(1) 查找,要么用交织法让映射关系隐式存在于链表中。

[!warning] ⚠️ 常见错误 3:交织法拆分时漏掉边界检查 拆分阶段 newCur.Next.Next 需要判断 newCur.Next != nil,否则在链表末尾会 panic。这是一个非常容易遗漏的细节。

[!warning] ⚠️ 常见错误 4:混淆了"修改旧链表"和"深拷贝"的概念 深拷贝的本质是创建一组全新的对象,它们拥有与原对象相同的值和结构,但与原对象没有任何共享引用。交织法虽然暂时修改了旧链表的 next 指针,但最终会恢复原状,并且返回值是完全独立的新链表。这种"借道修改再恢复"的策略在很多算法中都有应用。

[!note] 🐹 Go 中的链表定义 LeetCode 的 Go 环境内置如下结构体定义:

type Node struct {
    Val    int
    Next   *Node
    Random *Node
}

不需要手动定义,直接在解题中使用即可。

[!info] 📊 两种方法对比

维度 哈希表法(迭代) 原地交织法 ⭐
时间复杂度 O(n) O(n)
空间复杂度 O(n) O(1)
代码行数 ~25 行 ~25 行
直观程度 很高(直接建映射) 中等(需理解三趟遍历的意图)
面试推荐 ⭐⭐ 可作为铺垫讲出 ⭐⭐⭐ 面试官期望的答案
是否修改原链表 ❌ 完全不改 ⚠️ 中途改了 next,最终恢复

[!success] ✅ 相关题目串联

  • 22-相交链表 — 同样是链表上建立映射的经典场景
  • 146-LRU 缓存 — 哈希表 + 双向链表的组合,也是"用哈希加速链表操作"的思路
  • 24-回文链表 — 同样使用了"原地操作+反转"的模式来做到 O(1) 空间

代码

方法一:哈希表法(最直观)⭐

/**
 * Definition for a Node.
 * type Node struct {
 *     Val int
 *     Next *Node
 *     Random *Node
 * }
 */

func copyRandomList(head *Node) *Node {
	if head == nil {
		return nil
	}

	// 核心思路:建立一个 hash map,key 是旧节点,value 是对应的新节点
	// 这样无论 random 指向哪个节点,都能在 O(1) 时间内找到它的副本
	mapOldToNew := make(map[*Node]*Node)

	// ---------- 第一轮:创建所有新节点,建立 old → new 映射 ----------
	cur := head
	for cur != nil {
		// 如果该旧节点还没有对应的副本,就创建一个
		if _, ok := mapOldToNew[cur]; !ok {
			mapOldToNew[cur] = &Node{Val: cur.Val}
		}

		// ⚠️ 注意:Go 语言中 && 不能与 := 组合使用
		// 需要拆成两层 if,且判断必须在左侧保证非 nil
		if cur.Next != nil {
			if _, ok := mapOldToNew[cur.Next]; !ok {
				mapOldToNew[cur.Next] = &Node{Val: cur.Next.Val}
			}
		}
		if cur.Random != nil {
			if _, ok := mapOldToNew[cur.Random]; !ok {
				mapOldToNew[cur.Random] = &Node{Val: cur.Random.Val}
			}
		}

		cur = cur.Next
	}

	// ---------- 第二轮:根据映射填充所有新节点的 next 和 random ----------
	// ⚠️ 注意:map[old.Next] / map[old.Random] 的查找结果若为 nil,直接赋值即可
	// 因为 nil pointer 就是 Go 中指针的零值,恰好表示 "不指向任何节点"
	for old, new := range mapOldToNew {
		new.Next = mapOldToNew[old.Next]
		new.Random = mapOldToNew[old.Random]
	}

	// 返回新链表的头节点
	return mapOldToNew[head]
}

[!warning] ⚠️ 两个关键细节

  1. && 不能与 := 组合 —— Go 编译器不允许在 if 的条件表达式中把布尔判断和短变量声明连在一起。必须拆成外层判断 + 内层 if + := 的两层结构。
  2. nil 作为 key 的问题 —— 虽然我们在创建时通过 != nil 检查避免了写入 nil key,但遍历时 map[old.Next] 可能返回 nil(当 old.Next == nil 时)。这是正确的行为:nil 是指针的零值,正好表示"不指向任何节点",无需额外判空。

方法二:原地交织法 ⭐(O(1) 空间最优解)⭐

/**
 * Definition for a Node.
 * type Node struct {
 *     Val int
 *     Next *Node
 *     Random *Node
 * }
 */

func copyRandomList(head *Node) *Node {
	if head == nil {
		return nil
	}

	// ========== 第 1 趟:交织 ==========
	// 在每个旧节点后面插入对应的副本
	// 目标:A → B → C 变成 A → A' → B → B' → C → C'
	cur := head
	for cur != nil {
		nextTemp := cur.Next // 暂存 B
		newNode := &Node{Val: cur.Val}
		cur.Next = newNode     // A → A'
		newNode.Next = nextTemp // A' → B
		cur = nextTemp         // 移到 B,继续下一轮
	}

	// ========== 第 2 趟:连接 random ==========
	// 因为新节点紧跟在旧节点后面,利用这个关系直接设 random
	// 公式:cur'.Random = cur.Random.Next(如果 cur.Random != nil)
	// 原因:cur.Random 的副本 cur.Random.Next 恰好就在 cur.Random 后面
	cur = head
	for cur != nil {
		if cur.Random != nil {
			cur.Next.Random = cur.Random.Next
		}
		cur = cur.Next.Next // 每次跳两步,沿旧链表走(A → B → C ...)
	}

	// ========== 第 3 趟:拆分 ==========
	// 把交织在一起的链表拆成两条:原链表和新链表
	oldCur := head
	newCur := head.Next
	newHead := newCur // 保存新链表的头

	for oldCur != nil {
		// 断开头节点与副本
		oldCur.Next = oldCur.Next.Next

		// 连接新链表的节点
		if newCur.Next != nil {
			newCur.Next = newCur.Next.Next
		}

		// 两指针各前进一步
		oldCur = oldCur.Next
		newCur = newCur.Next
	}

	return newHead
}

执行流程可视化(示例 1):

flowchart TD
    INIT["原链表:\n7→13→11→10→1\nrandom: null, →0, →2, →4, →1"]

    subgraph Pass1["第1趟:交织"]
        P1["7→7'→13→13'→11→11'→10→10'→1→1'"]
    end

    subgraph Pass2["第2趟:连random"]
        P2["7'.random = 7.random.next = nil\n13'.random = 13.random.next = 7'.next = 7' → Wait...\n实际上 13.random指向13自己, so 13'.random = 13.next = 13'\n11'.random = 11.random.next = 10'.next = 10'\n10'.random = 10.random.next = 1'.next = 1'\n1'.random = 1.random.next = 13'.next = 11'"]
    end

    subgraph Pass3["第3趟:拆分"]
        P3["旧链表: 7→13→11→10→1\n新链表: 7'→13'→11'→10'→1'"]
    end

    INIT --> Pass1 --> Pass2 --> Pass3

    style P1 fill:#bbf,stroke:#333
    style P2 fill:#f9d,stroke:#333
    style P3 fill:#d4edda,stroke:#28a

[!success] ✅ 运行验证 原地交织法是这道题的最优解,在面试中如果能流畅地写出这个解法,说明你对链表操作的掌控达到了相当高的水平。建议掌握以下要点作为面试亮点:① 明确说出三趟遍历的目的;② 能说清楚为什么 cur.Next.Random = cur.Random.Next 是正确的;③ 能处理边界情况(nil random、链表为空、单节点)。

[!TIP] 💬 面试加分话术 "这道题主要有两种主流解法:哈希表法 O(n) 空间,最直观且不修改原链表;原地交织法 O(1) 空间,最优解。在实际面试中,我会先讲哈希表的思路,因为它最直观、最容易验证正确性。然后告诉面试官我想优化到 O(1) 空间,接着画出交织法的三趟遍历图,说明每一步的不变量是什么——这样做既展示了清晰的思维过程,又体现了对空间复杂度的敏感度。"

[!note] 🤔 如果题目要求不修改原链表怎么办? 哈希表法天然满足这个约束——它完全没有触碰原链表的任何指针。而原地交织法虽然最终会恢复旧链表,但如果面试官坚持说"原链表绝对不能被修改(哪怕暂时也不行)",那就只能用哈希表法或者递归法了。