LeetCode Hot100 精讲:环形链表 II、随机链表复制、翻转二叉树

LeetCode Hot100 系列是算法面试的必修课,今天我们将深入剖析三道高频考题:环形链表 II(检测环入口)随机链表的复制翻转二叉树。这三道题分别考察了链表环检测、复杂链表深拷贝以及二叉树遍历与修改的核心技巧,掌握它们能帮你轻松应对面试中的同类问题。本文将以 Java、Python、C++、JavaScript、Go 等语言为背景,提供清晰易懂的思路与代码实现。

环形链表 II:检测环的入口点

给定一个链表,如果链表中有环,要求返回环的起始节点,否则返回 null。这道题的关键在于如何高效地找到环的入口。

一句话描述思路

使用哈希集合记录已访问的节点,第一个重复出现的节点就是环的入口。

  • 用哈希集合记录所有访问过的节点,遍历链表时第一个重复遇到的节点即为环的入口;若遍历结束无重复,则无环。
//java版本
public ListNode detectCycle(ListNode head) {
    // 创建一个指针 pos,初始指向链表头节点,用于遍历链表
    ListNode pos = head;
    // 创建一个 HashSet,用于存储已经访问过的节点对象(基于节点引用)
    Set visited = new HashSet();
    // 当 pos 不为 null 时,继续遍历链表
    while (pos != null) {
        // 检查当前节点是否已经被访问过(即是否在 set 中)
        if (visited.contains(pos)) {
            // 如果已经访问过,说明当前节点就是环的入口节点(因为第一次遇到重复节点)
            return pos;
        } else {
            // 否则,将当前节点加入 visited 集合,标记为已访问
            visited.add(pos);
        }
        // 将指针移动到下一个节点,继续遍历
        pos = pos.next;
    }
    // 如果遍历完整个链表(pos 变为 null)都没有遇到重复节点,说明链表无环,返回 null
    return null;
}
// Kotlin版本
fun detectCycle(head: ListNode?): ListNode? {
    // 创建一个指针 pos,初始指向链表头节点,用于遍历链表
    var pos = head
    // 创建一个 HashSet,用于存储已经访问过的节点对象(基于节点引用)
    val visited = HashSet()
    // 当 pos 不为 null 时,继续遍历链表
    while (pos != null) {
        // 检查当前节点是否已经被访问过(即是否在 set 中)
        if (visited.contains(pos)) {
            // 如果已经访问过,说明当前节点就是环的入口节点(因为第一次遇到重复节点)
            return pos
        } else {
            // 否则,将当前节点加入 visited 集合,标记为已访问
            visited.add(pos)
        }
        // 将指针移动到下一个节点,继续遍历
        pos = pos.next
    }
    // 如果遍历完整个链表(pos 变为 null)都没有遇到重复节点,说明链表无环,返回 null
    return null
}
}

代码逻辑详解

  • 从链表头结点 head 开始,用指针 pos 指向当前节点。
  • 创建一个 HashSet<ListNode> 用于存储已经遍历过的节点引用。
  • 循环遍历链表,直到 posnull(到达链表尾部)。
  • 对于每个节点,检查它是否已经存在于集合中:
    • 如果存在,说明之前已经经过该节点,这正是环的起始节点(因为第一次重复遇到的节点就是环的入口),直接返回该节点。
    • 如果不存在,将该节点加入集合。
  • 移动指针到下一个节点,继续循环。
  • 如果循环正常结束(pos == null),说明链表中没有环,返回 null

✅ 关键点

  • 哈希集合的妙用:利用集合的 O(1) 平均查找时间复杂度,快速检测重复节点。
  • 环的入口判定:第一个重复出现的节点一定是环的起始节点,因为从 head 出发进入环后,再次访问到的第一个环上节点就是入口。
  • 终止条件:既要处理无环(遇到 null),也要处理有环(遇到已访问节点)。
  • 节点比较:这里比较的是节点对象引用(内存地址),而不是节点值,因此不会因为值相同而误判。

复杂度分析

  • 时间复杂度:O(n) —— 最坏情况下每个节点被访问一次,集合操作平均 O(1)。
  • 空间复杂度:O(n) —— 需要存储最多 n 个节点引用。

⚖️ 优缺点

  • 优点:实现简单直观,通用性强,不修改链表结构。
  • 缺点:空间开销大,无法处理内存受限场景,相比 Floyd 判圈算法效率较低(后者只需 O(1) 空间)。
[AFFILIATE_SLOT_1]

随机链表的复制

这道题要求深拷贝一个带有 random 指针的链表,每个节点除了 next 指针外,还有一个指向任意节点的 random 指针。难点在于如何高效地建立原节点与副本节点之间的 random 映射。

一句话算法流程描述

使用三步法:插入副本节点 → 设置 random 指针 → 拆分两个链表。整个过程在原链表上原地操作,无需额外哈希表。

分三步:①在原链表每个节点后插入其副本;②根据原节点的 random 指针设置副本的 random;③拆开混合链表,恢复原链表并提取新链表。

//Java版本
public Node copyRandomList(Node head) {
            // 边界条件:如果原链表为空,直接返回 null
        if (head == null) {
            return null;
        }
        // ========== 第一步:在原链表的每个节点后面插入一个自己的副本 ==========
        // 例如:原链表 A -> B -> C
        // 插入后:A -> A' -> B -> B' -> C -> C'
        for (Node node = head; node != null; node = node.next.next) {
            // 创建当前节点的新副本,值与原节点相同
            Node nodeNew = new Node(node.val);
            // 新副本的 next 指向原节点的下一个节点
            nodeNew.next = node.next;
            // 原节点的 next 指向新副本,完成插入
            node.next = nodeNew;
        }
        // ========== 第二步:设置新节点的 random 指针 ==========
        // 此时链表结构为:原节点 -> 新节点 -> 原节点 -> 新节点 ...
        // 原节点 node 的 random 指向某个原节点,那么 node.next(新节点)的 random 应该指向 node.random.next(那个原节点对应的新节点)
        for (Node node = head; node != null; node = node.next.next) {
            // 获取当前原节点对应的新节点
            Node nodeNew = node.next;
            // 如果原节点的 random 不为空,则新节点的 random 指向原节点的 random 所指节点的下一个节点(即对应的新节点)
            // 否则新节点的 random 设为 null
            nodeNew.random = (node.random != null) ? node.random.next : null;
        }
        // ========== 第三步:将原链表和新链表拆分开 ==========
        // 新链表的头节点就是原链表头节点的下一个节点
        Node headNew = head.next;
        // 遍历整个混合链表,分离出原链表和新链表
        for (Node node = head; node != null; node = node.next) {
            // nodeNew 是当前原节点对应的新节点
            Node nodeNew = node.next;
            // 恢复原节点的 next 指针:跳过新节点,指向原来的下一个原节点(即 nodeNew.next)
            node.next = node.next.next;
            // 恢复新节点的 next 指针:如果 nodeNew 后面还有新节点,则指向那个新节点,否则为 null
            nodeNew.next = (nodeNew.next != null) ? nodeNew.next.next : null;
        }
        // 返回新链表的头节点
        return headNew;
    }
}
 // Kotlin
fun copyRandomList(head: Node?): Node? {
        // 边界条件:空链表直接返回 null
        if (head == null) return null
        // ========== 第一步:插入副本节点 ==========
        // 原链表:A -> B -> C
        // 插入后:A -> A' -> B -> B' -> C -> C'
        var node: Node? = head
        while (node != null) {
            val newNode = Node(node.`val`)   // 创建副本
            newNode.next = node.next         // 副本的 next 指向原节点的下一个
            node.next = newNode              // 原节点的 next 指向副本
            node = newNode.next              // 跳两步,继续处理下一个原节点
        }
        // ========== 第二步:设置副本节点的 random ==========
        // 此时结构:原节点 -> 副本节点 -> 原节点 -> 副本节点 ...
        // 原节点 node 的 random 指向某原节点 target,
        // 则 node.next(副本节点)的 random 应指向 target?.next(对应的副本节点)
        node = head
        while (node != null) {
            val newNode = node.next           // 当前原节点对应的副本节点
            // 原节点的 random 可能为 null,需处理空安全
            newNode?.random = node.random?.next
            node = newNode?.next              // 跳两步到下一个原节点
        }
        // ========== 第三步:拆分原链表和新链表 ==========
        val newHead = head.next               // 新链表的头节点
        var cur: Node? = head
        while (cur != null) {
            val copy = cur.next               // 副本节点
            // 恢复原链表的 next:原节点指向原节点的下一个原节点(跳过副本)
            cur.next = copy?.next
            // 恢复副本链表的 next:副本节点指向下一个副本节点(如果存在)
            copy?.next = copy.next?.next
            cur = cur.next                    // 移动到下一个原节点
        }
        return newHead
    }

代码逻辑详解(分步说明)

  • 第一步:插入副本节点
    遍历原链表,在每个节点 node 后面插入一个值相同的新节点 newNode
    newNode.next = node.next 让副本指向原节点的下一个节点。
    node.next = newNode 让原节点指向副本。
    然后将指针移动到 newNode.next(即原链表的下一个原节点),继续处理。
    效果:A → A' → B → B' → C → C' → null
  • 第二步:设置副本的 random 指针
    再次遍历混合链表,每次取一对 (原节点, 副本节点)
    原节点 noderandom 可能指向某个原节点(或 null)。
    因为副本节点 node.next 正好紧跟在原节点后面,且原节点的 random 指向的目标原节点 target 的副本正好是 target.next
    所以设置:node.next.random = node.random?.next(若 node.randomnull,则副本的 random 也为 null)。
  • 第三步:拆分两个链表
    新链表的头节点已确定为 head.next
    再次遍历混合链表,恢复原链表的 next 指针,让它跳过副本节点指向下一个原节点。
    同时恢复副本链表的 next 指针,让副本节点指向下一个副本节点(若存在)。
    遍历结束后,原链表恢复原状,新链表独立存在。

✅ 关键点

  • 三步法:插入 → 设置 random → 拆分,缺一不可。
  • 空间 O(1) 原地修改:不使用额外哈希表,只在原链表上操作。
  • random 指针的映射关系:利用 原节点.random原节点.random.next 的对应关系,无需额外存储映射。
  • 指针移动步长:插入时每次跳两步,设置 random 时也跳两步,拆分时每次跳一步。
  • 空指针处理:需注意 node.random 可能为 null,Kotlin 中用 ?. 安全调用。

复杂度分析

  • 时间复杂度:O(n) —— 链表被完整遍历三次(插入、设 random、拆分)。
  • 空间复杂度:O(1)(不计输出链表)—— 只使用了常数个临时指针变量。

⚖️ 优缺点

  • 优点:空间效率极高,不依赖哈希函数,一次遍历即可建立 random 映射。
  • 缺点:实现稍复杂,修改了原链表结构(虽然最后恢复),需要三次遍历。

翻转二叉树

翻转二叉树即交换每个节点的左右子树,是二叉树操作的经典入门题。这里我们采用层序遍历(BFS)实现,避免递归可能带来的栈溢出问题。

一句话算法流程描述

使用队列进行层序遍历,对每个节点交换其左右子节点,直到所有节点处理完毕。

使用队列按层遍历二叉树,对每个出队节点交换其左右子节点,并将非空子节点入队,直到队列为空。

//Java版本
public TreeNode invertTree(TreeNode root) {
    // 基础情况:如果当前节点为空,直接返回 null
    if (root == null) {
        return null;
    }
    // 创建一个队列(使用 LinkedList 实现),用于存储待处理的节点
    // 队列遵循先进先出原则,保证按层处理节点(层序遍历)
    LinkedList queue = new LinkedList();
    // 将根节点加入队列,开始遍历
    queue.add(root);
    // 循环处理队列中的所有节点,直到队列为空
    while (!queue.isEmpty()) {
        // 从队列头部取出一个节点(当前待处理的节点)
        TreeNode tmp = queue.poll();
        // 交换该节点的左右子节点
        TreeNode left = tmp.left;   // 暂存左子节点
        tmp.left = tmp.right;       // 将右子节点赋值给左子节点
        tmp.right = left;           // 将原左子节点赋值给右子节点
        // 如果当前节点的左子节点不为空,则将其加入队列,以便后续处理其子节点
        if (tmp.left != null) {
            queue.add(tmp.left);
        }
        // 如果当前节点的右子节点不为空,则将其加入队列,以便后续处理其子节点
        if (tmp.right != null) {
            queue.add(tmp.right);
        }
    }
    // 返回翻转后的根节点(原根节点位置不变,但其左右子树已交换)
    return root;
}
//Kotlin版本
fun invertTree(root: TreeNode?): TreeNode? {
    // 边界条件:空树直接返回 null
    if (root == null) return null
    // 使用队列进行层序遍历(FIFO)
    val queue: ArrayDeque = ArrayDeque()
    queue.addLast(root)   // 根节点入队
    while (queue.isNotEmpty()) {
        // 取出队首节点
        val current = queue.removeFirst()
        // 交换当前节点的左右子节点
        val temp = current.left
        current.left = current.right
        current.right = temp
        // 如果左子节点不为空,入队以便后续处理其子节点
        current.left?.let { queue.addLast(it) }
        // 如果右子节点不为空,入队
        current.right?.let { queue.addLast(it) }
    }
    // 返回原根节点(树结构已被原地翻转)
    return root
}
}

代码逻辑详解(分步说明)

  • 处理空树
    如果 root == null,直接返回 null,无需任何操作。
  • 初始化队列
    使用双端队列(ArrayDeque)作为队列,将根节点加入队列。
  • 循环处理队列
    只要队列不为空:
    • 从队首取出一个节点 current
    • 交换 current 的左右子节点:借助临时变量 temp 存储 left,然后 left = rightright = temp
    • 检查交换后的左子节点(原来的右子节点)是否为 null,若不为空则入队。
    • 检查交换后的右子节点(原来的左子节点)是否为 null,若不为空则入队。
  • 循环结束
    所有节点处理完毕后,返回根节点。原二叉树的每个节点都已交换左右子树,实现了整棵树的翻转。

✅ 关键点

  • 层序遍历(广度优先):利用队列按层处理节点,确保每个节点都被访问一次。
  • 原地翻转:直接修改原二叉树的节点引用,不创建新树,空间效率高。
  • 空节点处理:在入队前检查子节点是否为空,避免将 null 加入队列。
  • 交换操作:使用临时变量或 Kotlin 的 also / let 均可,本质是交换两个引用。
  • 适用性:此方法对任何二叉树都有效,包括完全二叉树、退化链表等。

复杂度分析

  • 时间复杂度:O(n) —— 每个节点入队一次、出队一次,交换操作 O(1)。
  • 空间复杂度:O(n) —— 最坏情况下队列中同时存储约 n/2 个节点。

注:若考虑递归解法,递归栈深度 O(h) 更优,但此处为队列实现。

⚖️ 优缺点

  • 优点:直观易懂,避免递归栈溢出,原地修改。
  • 缺点:空间复杂度较高(最坏 O(n)),代码略冗长,依赖队列容器。
[AFFILIATE_SLOT_2]

总结

本文通过三道 LeetCode Hot100 经典题目,展示了链表和二叉树操作的核心技巧:环形链表 II 利用哈希集合快速检测环入口;随机链表的复制 采用三步原地法实现 O(1) 空间的深拷贝;翻转二叉树 使用层序遍历轻松完成左右子树交换。掌握这些思路和代码,不仅能应对面试,还能提升对数据结构的理解。建议你结合 Java、Python、C++、JavaScript 或 Go 动手实现,加深记忆。

posted on 2026-05-21 17:56  ljbguanli  阅读(32)  评论(0)    收藏  举报