三十二、环形链表(II) _ 检测环的入口点
1.一句话描述思路
- 用哈希集合记录所有访问过的节点,遍历链表时第一个重复遇到的节点即为环的入口;若遍历结束无重复,则无环。
java
//java版本
public ListNode detectCycle(ListNode head) {
// 创建一个指针 pos,初始指向链表头节点,用于遍历链表
ListNode pos = head;
// 创建一个 HashSet,用于存储已经访问过的节点对象(基于节点引用)
Set<ListNode> visited = new HashSet<ListNode>();
// 当 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
// Kotlin版本
fun detectCycle(head: ListNode?): ListNode? {
// 创建一个指针 pos,初始指向链表头节点,用于遍历链表
var pos = head
// 创建一个 HashSet,用于存储已经访问过的节点对象(基于节点引用)
val visited = HashSet<ListNode>()
// 当 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
}
}
2.代码逻辑详解
- 从链表头结点
head开始,用指针pos指向当前节点。 - 创建一个
HashSet<ListNode>用于存储已经遍历过的节点引用。 - 循环遍历链表,直到
pos为null(表示到达链表尾部)。 - 对于每个节点,检查它是否已经存在于集合中:
- 如果存在,说明之前已经经过该节点,这正是环的起始节点(因为第一次重复遇到的节点就是环的入口),直接返回该节点。
- 如果不存在,将该节点加入集合。
- 移动指针到下一个节点,继续循环。
- 如果循环正常结束(
pos == null),说明链表中没有环,返回null。
3.关键点
-
哈希集合的妙用 :利用集合的
O(1)平均查找时间复杂度,快速检测重复节点。 -
环的入口判定 :第一个重复出现的节点一定是环的起始节点,因为从
head出发进入环后,再次访问到的第一个环上节点就是入口。 -
终止条件 :既要处理无环(遇到
null),也要处理有环(遇到已访问节点)。 -
节点比较 :这里比较的是 节点对象引用(内存地址),而不是节点值,因此不会因为值相同而误判。
4.复杂度分析
- 时间复杂度:O(n)
最坏情况下,每个节点被访问一次,集合的插入和查找操作平均为 O(1),因此总时间复杂度为 O(n),其中 n 为链表节点数。 - 空间复杂度:O(n)
需要使用一个哈希集合来存储最多 n 个节点的引用,因此额外空间与链表长度成正比。
5.优缺点
优点:
- 实现简单直观,容易理解和编写。
- 通用性强,不要求修改链表结构或节点值,适用于只读场景。
- 能准确返回环的入口节点,而不仅仅是判断是否有环。
缺点:
- 空间开销大,需要 O(n) 的额外存储空间,当链表很长时内存占用较高。
- 无法处理无法使用哈希的场景(如内存极度受限的环境)。
- 相比双指针(Floyd 判圈算法)效率较低,后者只需要 O(1) 额外空间。
三十三、随机链表的复制
1.一句话算法流程描述
分三步:①在原链表每个节点后插入其副本;②根据原节点的 random 指针设置副本的 random;③拆开混合链表,恢复原链表并提取新链表。
java
//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;
}
}
java
// 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
}
2.代码逻辑详解(分步说明)
第一步:插入副本节点
-
遍历原链表,在每个节点
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指针,让副本节点指向下一个副本节点(若存在)。 -
遍历结束后,原链表恢复原状,新链表独立存在。
3.关键点
-
三步法:插入 → 设置 random → 拆分,缺一不可。
-
空间 O(1) 原地修改:不使用额外哈希表,只在原链表上操作。
-
random 指针的映射关系 :利用
原节点.random与原节点.random.next的对应关系,无需额外存储映射。 -
指针移动步长:插入时每次跳两步,设置 random 时也跳两步,拆分时每次跳一步。
-
空指针处理 :需注意
node.random可能为null,Kotlin 中用?.安全调用。
4.复杂度分析
-
时间复杂度:O(n)
链表被完整遍历三次(插入、设 random、拆分),每次遍历都是线性时间。
-
空间复杂度:O(1) (不计输出链表)
除了返回的新链表节点外,只使用了常数个临时指针变量。新链表节点本身是题目要求的输出,不算额外空间。
5.优缺点
优点
-
空间效率极高:O(1) 额外空间,优于哈希表法(O(n))。
-
不依赖哈希函数,适用于所有环境。
-
一次遍历即可建立 random 映射,逻辑清晰。
缺点
-
实现稍复杂:需要谨慎处理指针移动和空值,容易出错。
-
修改了原链表结构(虽然最后恢复了),在多线程或只读场景下不适用。
-
需要三次遍历,常数时间略高于哈希表法(但渐进复杂度相同)。
三十四、翻转二叉树
1.一句话算法流程描述
使用队列按层遍历二叉树,对每个出队节点交换其左右子节点,并将非空子节点入队,直到队列为空。
java
//Java版本
public TreeNode invertTree(TreeNode root) {
// 基础情况:如果当前节点为空,直接返回 null
if (root == null) {
return null;
}
// 创建一个队列(使用 LinkedList 实现),用于存储待处理的节点
// 队列遵循先进先出原则,保证按层处理节点(层序遍历)
LinkedList<TreeNode> queue = new LinkedList<TreeNode>();
// 将根节点加入队列,开始遍历
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;
}
java
//Kotlin版本
fun invertTree(root: TreeNode?): TreeNode? {
// 边界条件:空树直接返回 null
if (root == null) return null
// 使用队列进行层序遍历(FIFO)
val queue: ArrayDeque<TreeNode> = 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
}
}
2.代码逻辑详解(分步说明)
-
处理空树
如果
root == null,直接返回null,无需任何操作。 -
初始化队列
使用双端队列(
ArrayDeque)作为队列,将根节点加入队列。 -
循环处理队列
只要队列不为空:
-
从队首取出一个节点
current。 -
交换
current的左右子节点:借助临时变量temp存储left,然后left = right,right = temp。 -
检查交换后的左子节点(原来的右子节点)是否为
null,若不为空则入队。 -
检查交换后的右子节点(原来的左子节点)是否为
null,若不为空则入队。
-
-
循环结束
所有节点处理完毕后,返回根节点。原二叉树的每个节点都已交换左右子树,实现了整棵树的翻转。
3.关键点
-
层序遍历(广度优先):利用队列按层处理节点,确保每个节点都被访问一次。
-
原地翻转:直接修改原二叉树的节点引用,不创建新树,空间效率高。
-
空节点处理 :在入队前检查子节点是否为空,避免将
null加入队列。 -
交换操作 :使用临时变量或 Kotlin 的
also/let均可,本质是交换两个引用。 -
适用性:此方法对任何二叉树都有效,包括完全二叉树、退化链表等。
4.复杂度分析
-
时间复杂度:O(n)
每个节点恰好入队一次、出队一次,交换操作是 O(1),总操作次数与节点数 n 成正比。
-
空间复杂度:O(n)
最坏情况下(满二叉树),队列中最多同时存储约 n/2 个节点(最后一层),因此额外空间为 O(n)。
注:若考虑递归解法,递归栈深度 O(h) 更优,但此处为队列实现。
5.优缺点
优点
-
直观易懂:层序遍历思想简单,交换左右子树的逻辑清晰。
-
避免递归栈溢出:对于深度极大的树(如链状树),不会像递归解法那样可能导致栈溢出。
-
原地修改:不占用额外树节点空间,仅使用队列存储指针。
缺点
-
空间复杂度较高:最坏情况下需要 O(n) 的队列空间,而递归解法(DFS)只需要 O(h) 的栈空间(h 为树高)。对于满二叉树,两者都是 O(n),但常数因子队列稍大。
-
代码略冗长:相比递归解法(几行代码),队列实现需要显式管理队列。
-
需要额外数据结构:依赖队列容器,在某些受限环境中可能不可用。