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>用于存储已经遍历过的节点引用。 - 循环遍历链表,直到
pos为null(到达链表尾部)。 - 对于每个节点,检查它是否已经存在于集合中:
- 如果存在,说明之前已经经过该节点,这正是环的起始节点(因为第一次重复遇到的节点就是环的入口),直接返回该节点。
- 如果不存在,将该节点加入集合。
- 移动指针到下一个节点,继续循环。
- 如果循环正常结束(
pos == null),说明链表中没有环,返回null。
✅ 关键点
- 哈希集合的妙用:利用集合的
O(1)平均查找时间复杂度,快速检测重复节点。 - 环的入口判定:第一个重复出现的节点一定是环的起始节点,因为从
head出发进入环后,再次访问到的第一个环上节点就是入口。 - 终止条件:既要处理无环(遇到
null),也要处理有环(遇到已访问节点)。 - 节点比较:这里比较的是节点对象引用(内存地址),而不是节点值,因此不会因为值相同而误判。
复杂度分析
- 时间复杂度:O(n) —— 最坏情况下每个节点被访问一次,集合操作平均 O(1)。
- 空间复杂度:O(n) —— 需要存储最多 n 个节点引用。
⚖️ 优缺点
- 优点:实现简单直观,通用性强,不修改链表结构。
- 缺点:空间开销大,无法处理内存受限场景,相比 Floyd 判圈算法效率较低(后者只需 O(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 指针
再次遍历混合链表,每次取一对(原节点, 副本节点)。
原节点node的random可能指向某个原节点(或null)。
因为副本节点node.next正好紧跟在原节点后面,且原节点的random指向的目标原节点target的副本正好是target.next。
所以设置:node.next.random = node.random?.next(若node.random为null,则副本的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 = right,right = temp。 - 检查交换后的左子节点(原来的右子节点)是否为
null,若不为空则入队。 - 检查交换后的右子节点(原来的左子节点)是否为
null,若不为空则入队。
- 从队首取出一个节点
- 循环结束
所有节点处理完毕后,返回根节点。原二叉树的每个节点都已交换左右子树,实现了整棵树的翻转。
✅ 关键点
- 层序遍历(广度优先):利用队列按层处理节点,确保每个节点都被访问一次。
- 原地翻转:直接修改原二叉树的节点引用,不创建新树,空间效率高。
- 空节点处理:在入队前检查子节点是否为空,避免将
null加入队列。 - 交换操作:使用临时变量或 Kotlin 的
also/let均可,本质是交换两个引用。 - 适用性:此方法对任何二叉树都有效,包括完全二叉树、退化链表等。
复杂度分析
- 时间复杂度:O(n) —— 每个节点入队一次、出队一次,交换操作 O(1)。
- 空间复杂度:O(n) —— 最坏情况下队列中同时存储约 n/2 个节点。
注:若考虑递归解法,递归栈深度 O(h) 更优,但此处为队列实现。
⚖️ 优缺点
- 优点:直观易懂,避免递归栈溢出,原地修改。
- 缺点:空间复杂度较高(最坏 O(n)),代码略冗长,依赖队列容器。
总结
本文通过三道 LeetCode Hot100 经典题目,展示了链表和二叉树操作的核心技巧:环形链表 II 利用哈希集合快速检测环入口;随机链表的复制 采用三步原地法实现 O(1) 空间的深拷贝;翻转二叉树 使用层序遍历轻松完成左右子树交换。掌握这些思路和代码,不仅能应对面试,还能提升对数据结构的理解。建议你结合 Java、Python、C++、JavaScript 或 Go 动手实现,加深记忆。
浙公网安备 33010602011771号