LeetCode 138. 随机链表的复制:两种最优解法详解

LeetCode 中等难度题目「138. 随机链表的复制」,这道题是链表操作里的经典题型,核心难点在于「随机指针」的复制------常规链表复制只需按顺序处理 next 指针,但随机指针可指向任意节点(包括自身、null 或链表中其他节点),很容易出现"复制节点的随机指针指向原链表节点"的错误,或是陷入重复创建、指针混乱的坑。

先明确题目要求,避免踩坑:

  • 深拷贝:复制出 n 个全新节点,不能复用原链表的任何节点

  • 指针对应:新节点的 next、random 指针,必须指向复制链表中的节点(而非原链表)

  • 状态一致:原链表中 X.random → Y,复制链表中对应 x.random → y

  • 输入输出:仅传入原链表头节点,返回复制链表头节点(题目中 [val, random_index] 是输入输出的便捷表示,代码中无需处理索引,直接操作节点)

先贴出题目给出的节点定义(TypeScript 版本),后续两种解法均基于此:

typescript 复制代码
class _Node {
  val: number
  next: _Node | null
  random: _Node | null

  constructor(val?: number, next?: _Node, random?: _Node) {
    this.val = (val === undefined ? 0 : val)
    this.next = (next === undefined ? null : next)
    this.random = (random === undefined ? null : random)
  }
}

下面分享两种工业界常用的最优解法,分别是「哈希表映射法」和「原地插入拆分法」,各有优劣,大家可根据场景选择。

解法一:哈希表映射法(直观易懂,空间换时间)

核心思路

核心是用「哈希表(Map)」建立「原节点 → 复制节点」的映射关系,解决"复制节点找不到对应随机指针指向"的问题。

两步走:

  1. 第一次遍历原链表:只创建复制节点,不处理指针,将原节点作为 key、复制节点作为 value 存入 Map,此时复制节点仅初始化了 val 值。

  2. 第二次遍历原链表:根据 Map 中的映射关系,给复制节点赋值 next 和 random 指针------原节点的 next 对应复制节点的 next,原节点的 random 对应复制节点的 random(均从 Map 中取出)。

举个简单例子:原链表 A → B(A.random → B),第一次遍历存 Map(A: A', B: B');第二次遍历,A'.next = Map.get(A.next) = B',A'.random = Map.get(A.random) = B',完美对应原链表状态。

完整代码

typescript 复制代码
function copyRandomList_1(head: _Node | null): _Node | null {
  // 边界处理:空链表直接返回null
  if (!head) {
    return null
  }
  // 建立原节点到复制节点的映射
  const map = new Map()
  let curr: _Node | null = head
  // 第一步:遍历原链表,创建复制节点并存入Map
  while (curr) {
    map.set(curr, new _Node(curr.val))
    curr = curr.next
  }
  // 第二步:遍历原链表,给复制节点赋值next和random
  const dummy = new _Node() // 虚拟头节点,简化边界处理
  curr = head
  let prev = dummy // 记录复制链表的前驱节点
  while (curr) {
    const node = map.get(curr) // 取出当前原节点对应的复制节点
    prev.next = node; // 前驱节点指向当前复制节点
    // 处理random指针:原节点random存在,则复制节点random取Map中对应的值,否则为null
    if (curr.random) {
      node.random = map.get(curr.random)
    }
    // 移动指针,继续遍历
    prev = node;
    curr = curr.next;
  }
  // 虚拟头节点的next就是复制链表的头节点
  return dummy.next;
};

优劣势分析

✅ 优势:思路直观,代码简洁,容易理解和调试,无需操作原链表结构,不易出错。

❌ 劣势:使用了额外的哈希表,空间复杂度 O(n)(n 为链表长度),需要存储所有原节点和复制节点的映射关系。

适用场景:日常开发中,空间复杂度要求不高,优先追求代码可读性和开发效率的场景。

解法二:原地插入拆分法(空间最优,无额外哈希表)

核心思路

如果要求空间复杂度 O(1)(不使用额外哈希表),就需要用「原地插入+拆分」的思路------将复制节点插入到原节点的后面,利用原链表的指针关系,直接找到复制节点的 random 指向,最后再将原链表和复制链表拆分。

三步走(关键在于"插入"和"拆分"的边界处理):

  1. 原地插入复制节点:遍历原链表,在每个原节点 curr 后面插入一个复制节点 newNode(val 与 curr 一致),此时原链表变为「curr → newNode → curr.next(原next)」,不处理 random 指针。

  2. 给复制节点赋值 random:再次遍历原链表,当前原节点 curr 的复制节点是 curr.next,而 curr.random 的复制节点是 curr.random.next(因为第一步已在所有原节点后插入了复制节点),直接赋值即可。

  3. 拆分两个链表:最后遍历原链表,将原节点和复制节点拆分------原节点的 next 指向自身的下下个节点(跳过复制节点),复制节点的 next 指向自身的下下个节点(复制链表的下一个节点),最终得到独立的复制链表。

关键细节:拆分时要注意链表尾部的边界(当 next 为 null 时,复制节点的 next 也需设为 null),避免出现空指针错误。

完整代码

typescript 复制代码
function copyRandomList_2(head: _Node | null): _Node | null {
  // 边界处理:空链表直接返回null
  if (!head) {
    return null;
  }

  // 第一步:原地插入复制节点(每个原节点后面插一个复制节点)
  let curr: _Node | null = head;
  while (curr) {
    const newNode = new _Node(curr.val); // 创建复制节点
    const nextNode: _Node | null = curr.next; // 保存原节点的next
    curr.next = newNode; // 原节点指向复制节点
    newNode.next = nextNode; // 复制节点指向原节点的原next
    curr = nextNode; // 移动到下一个原节点
  }

  // 第二步:给复制节点赋值random指针
  curr = head;
  while (curr) {
    const newNode: _Node | null = curr.next; // 当前原节点对应的复制节点
    if (newNode) {
      // 原节点random存在 → 复制节点random = 原random的复制节点(原random.next)
      if (curr.random) {
        newNode.random = curr.random.next;
      } else {
        newNode.random = null; // 原random为null,复制节点也为null
      }
      curr = newNode.next; // 移动到下一个原节点(跳过复制节点)
    }
  }

  // 第三步:拆分原链表和复制链表
  curr = head;
  const copyHead = head.next; // 复制链表的头节点(原头节点的下一个)
  while (curr) {
    const newNode: _Node | null = curr.next; // 当前原节点对应的复制节点
    if (newNode) {
      const nextOldNode: _Node | null = newNode.next; // 保存下一个原节点
      curr.next = nextOldNode; // 原节点指向自身的下下个节点(拆分原链表)
      // 复制节点指向自身的下下个节点(拆分复制链表)
      if (nextOldNode) {
        newNode.next = nextOldNode.next;
      } else {
        newNode.next = null; // 链表尾部,复制节点next设为null
      }
      curr = nextOldNode; // 移动到下一个原节点
    }
  }

  return copyHead; // 返回复制链表的头节点
};

优劣势分析

✅ 优势:空间复杂度 O(1)(仅使用几个指针变量),无额外哈希表,空间最优。

❌ 劣势:思路相对复杂,需要三次遍历,且涉及原链表结构的修改,边界处理容易出错(尤其是拆分环节)。

适用场景:面试中要求优化空间复杂度,或内存资源紧张的场景(如嵌入式开发)。

两种解法对比总结

解法 时间复杂度 空间复杂度 核心优势 适用场景
哈希表映射法 O(n)(两次遍历) O(n)(哈希表存储映射) 直观易懂,调试方便 日常开发,优先可读性
原地插入拆分法 O(n)(三次遍历) O(1)(无额外空间) 空间最优 面试优化,内存紧张场景

常见踩坑点提醒

  • 踩坑1:复制节点的 random 指针直接指向原链表节点 → 违反深拷贝要求,需通过映射或原地插入找到复制节点。

  • 踩坑2:边界处理遗漏 → 空链表、链表尾部(next 为 null)、random 为 null 的情况,需单独判断。

  • 踩坑3:原地拆分时,原链表的 next 指针未恢复 → 虽然题目不要求恢复原链表,但会导致原链表结构混乱(严谨开发中需注意)。

  • 踩坑4:创建复制节点时,未初始化 next 和 random 为 null → 虽然构造函数有默认值,但建议明确赋值,避免隐式错误。

最后总结

「随机链表的复制」的核心是解决「随机指针的定位」问题,两种解法分别从"空间换时间"和"时间换空间"两个角度给出了最优解:

  1. 新手入门优先掌握「哈希表映射法」,代码简洁、思路清晰,能快速解决问题,应对日常开发场景;

  2. 面试进阶需掌握「原地插入拆分法」,理解其指针操作逻辑和边界处理,体现对链表操作的深度掌握。

相关推荐
近津薪荼1 小时前
优选算法——前缀和(4):除了自身以外数组的乘积
算法
像颗糖1 小时前
OpenSpec 和 Spec-Kit 踩了 27 个坑之后,于是我写了个 🔥SuperSpec🔥 一次性填平
前端·后端
李派森1 小时前
AI大模型之丙午马年运势模型的构建与求解
笔记·算法
俩娃妈教编程1 小时前
洛谷选题:P1055 [NOIP 2008 普及组] ISBN 号码
c++·算法
Jing_Rainbow1 小时前
【React-10/Lesson94(2026-01-04)】React 性能优化专题:useMemo & useCallback 深度解析🚀
前端·javascript·react.js
hans汉斯2 小时前
基于联邦学习的隐私保护和抗投毒攻击方法研究
网络·人工智能·算法·yolo·数据挖掘·聚类·汉斯出版社
白中白121382 小时前
Vue系列-3
前端·javascript·vue.js
沛沛老爹2 小时前
Vue3+TS实战:基于策略模式的前端动态脱敏UI组件设计与实现
前端·ui·vue3·数据安全·策略模式·动态渲染·前端脱敏
!停2 小时前
数据结构二叉树—链式结构(中)
java·数据结构·算法