链表操作的三剑客:从删除、交换到深拷贝

🔥你好我是fengxin_rou这是我的个人主页 fengxin_rou的主页

❄️欢迎查看我的专栏我的专栏

《Java后端学习》《JAVASE基础》《JUC并发》《redis》《JVM虚拟机》《MYSQL》《黑马点评》《rabbitmq》《JavaWeb+AI的talis学习系统》《苍穹外卖》

🎯 本文目标:深入剖析三道经典链表题目,掌握链表操作的核心技巧,提升算法面试通过率。

目录

引言

链表基础概念回顾

[1. 单向链表结构](#1. 单向链表结构)

[2. 链表操作的关键点](#2. 链表操作的关键点)

[3. 复杂度分析基础](#3. 复杂度分析基础)

[题目一:删除链表的倒数第N个结点(LeetCode 19)](#题目一:删除链表的倒数第N个结点(LeetCode 19))

题目描述

解题思路:双指针法

核心思想

为什么需要哑节点?

算法步骤

代码实现与解析

代码详解

复杂度分析

边界情况处理

[题目二:两两交换链表中的节点(LeetCode 24)](#题目二:两两交换链表中的节点(LeetCode 24))

题目描述

解题思路:迭代法

核心思想

指针定义

交换过程

算法步骤

代码实现与解析

代码详解

复杂度分析

递归解法对比

[题目三:随机链表的复制(LeetCode 138)](#题目三:随机链表的复制(LeetCode 138))

题目描述

解题思路:哈希表映射法

核心思想

两步构建法

为什么需要两步?

代码实现与解析

代码详解

复杂度分析

原地修改法(O(1)空间)

链表操作通用技巧总结

[1. 哑节点的使用](#1. 哑节点的使用)

[2. 双指针技巧](#2. 双指针技巧)

[3. 哈希表辅助](#3. 哈希表辅助)

[4. 递归与迭代的选择](#4. 递归与迭代的选择)

实战演练:如何系统学习链表问题

[1. 学习路径建议](#1. 学习路径建议)

[2. 推荐刷题顺序](#2. 推荐刷题顺序)

[3. 常见错误与避免](#3. 常见错误与避免)

总结与最佳实践

核心收获

最佳实践

面试技巧

常见问题解答(FAQ)

Q1:为什么删除倒数第N个节点要用哑节点?

Q2:两两交换节点时,如何保持与前面节点的连接?

Q3:随机链表复制时,为什么不能一步到位?

Q4:递归解法什么时候会栈溢出?

Q5:如何快速判断链表问题应该用什么技巧?


引言

链表作为数据结构的基础,在算法面试中占据着举足轻重的地位。与数组相比,链表具有动态大小、插入删除高效等优点,但也带来了指针操作复杂、边界情况多等挑战。本文将聚焦三道LeetCode经典链表题目:

  1. 删除链表的倒数第N个结点(LeetCode 19)
  2. 两两交换链表中的节点(LeetCode 24)
  3. 随机链表的复制(LeetCode 138)

这三道题目涵盖了链表操作的三大核心技巧:双指针定位节点交换结构复制。掌握它们不仅能解决具体问题,更能建立起解决链表问题的通用思维框架。

链表基础概念回顾

在深入具体题目之前,让我们先回顾链表的基本概念:

1. 单向链表结构

复制代码
public class ListNode {
    int val;
    ListNode next;
    ListNode() {}
    ListNode(int val) { this.val = val; }
    ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}

2. 链表操作的关键点

  • 指针操作:修改节点间的连接关系
  • 边界处理:空链表、单节点、头尾节点特殊情况
  • 哑节点技巧:简化头节点处理逻辑
  • 双指针技巧:快慢指针、前后指针

3. 复杂度分析基础

  • 时间复杂度:通常为O(n),单次遍历
  • 空间复杂度:原地操作O(1),使用辅助结构O(n)

题目一:删除链表的倒数第N个结点(LeetCode 19)

题目描述

给你一个链表,删除链表的倒数第n个结点,并且返回链表的头结点。

示例1

复制代码
输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]

示例2

复制代码
输入:head = [1], n = 1
输出:[]

解题思路:双指针法

核心思想

使用两个指针rightleft,让right先移动n步,然后两个指针同时移动,当right到达末尾时,left正好在倒数第n个节点的前一个位置。

图1:删除链表倒数第N个节点算法流程图

为什么需要哑节点?

如果没有哑节点,当删除头节点时(如head = [1], n = 1),需要特殊处理。使用哑节点可以统一处理所有情况。

算法步骤
  1. 创建哑节点dummy,指向head
  2. right指针从dummy开始移动n+1
  3. left指针从dummy开始移动
  4. rightleft同时移动,直到rightnull
  5. left.next就是要删除的节点,修改指针跳过它
  6. 返回dummy.next

代码实现与解析

复制代码
class Solution {
    public ListNode removeNthFromEnd(ListNode head, int n) {
        // 创建哑节点,简化头节点处理
        ListNode dummy = new ListNode(0, head);
        
        // right指针先移动n+1步
        ListNode right = dummy;
        for (int i = 0; i <= n; i++) {
            right = right.next;
        }
        
        // left指针从dummy开始
        ListNode left = dummy;
        
        // 同时移动,直到right到达末尾
        while (right != null) {
            left = left.next;
            right = right.next;
        }
        
        // 删除倒数第n个节点
        left.next = left.next.next;
        
        return dummy.next;
    }
}
代码详解
  1. 第5行ListNode dummy = new ListNode(0, head); 创建哑节点,值为0,next指向原头节点
  2. 第8-10行right指针移动n+1步,确保当rightnull时,left指向待删除节点的前一个位置
  3. 第13-17行 :双指针同步移动,保持间距为n+1
  4. 第20行:关键操作 - 跳过待删除节点

复杂度分析

  • 时间复杂度:O(L),其中L是链表长度,只需一次遍历
  • 空间复杂度:O(1),只使用了常数个指针变量

边界情况处理

  1. 删除头节点head = [1], n = 1,哑节点完美解决
  2. 删除尾节点head = [1,2], n = 1,正常流程
  3. n等于链表长度:删除头节点,同样适用

题目二:两两交换链表中的节点(LeetCode 24)

题目描述

给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。

示例1

复制代码
输入:head = [1,2,3,4]
输出:[2,1,4,3]

示例2

复制代码
输入:head = []
输出:[]

解题思路:迭代法

核心思想

使用三个指针node0node1node2,每次交换一对节点。关键是要保持与前一组的连接。

图2:两两交换链表节点算法流程图

指针定义
  • node0:当前交换对的前一个节点(初始为dummy)
  • node1:当前交换对的第一个节点(初始为head)
  • node2:当前交换对的第二个节点(node1.next)
  • node3:下一组的第一个节点(node2.next)
交换过程
复制代码
交换前:node0 -> node1 -> node2 -> node3
交换后:node0 -> node2 -> node1 -> node3
算法步骤
  1. 创建哑节点dummy,指向head
  2. node0 = dummynode1 = head
  3. node1node1.next都不为null时:
    • node2 = node1.next
    • node3 = node2.next
    • 执行交换:node0.next = node2node2.next = node1node1.next = node3
    • 更新指针:node0 = node1node1 = node3
  4. 返回dummy.next

代码实现与解析

复制代码
class Solution {
    public ListNode swapPairs(ListNode head) {
        // 创建哑节点
        ListNode dummy = new ListNode(0, head);
        
        // node0指向哑节点,node1指向头节点
        ListNode node0 = dummy;
        ListNode node1 = head;
        
        // 当node1和node1.next都不为空时继续交换
        while (node1 != null && node1.next != null) {
            ListNode node2 = node1.next;
            ListNode node3 = node2.next;
            
            // 执行交换:0->2, 2->1, 1->3
            node0.next = node2;
            node2.next = node1;
            node1.next = node3;
            
            // 为下一轮交换做准备
            node0 = node1;
            node1 = node3;
        }
        
        return dummy.next;
    }
}
代码详解
  1. 第5行:哑节点处理空链表和单节点情况
  2. 第11行:循环条件确保有足够的节点进行交换
  3. 第15-17行:交换操作的关键三步
  4. 第20-21行:指针更新,准备下一轮交换

复杂度分析

  • 时间复杂度:O(n),遍历整个链表
  • 空间复杂度:O(1),只使用常数个指针

递归解法对比

复制代码
class Solution {
    public ListNode swapPairs(ListNode head) {
        // 递归终止条件
        if (head == null || head.next == null) {
            return head;
        }
        
        // 保存第二个节点
        ListNode second = head.next;
        
        // 递归处理剩余部分
        head.next = swapPairs(second.next);
        
        // 交换当前两个节点
        second.next = head;
        
        return second;
    }
}

递归特点

  • 代码更简洁,逻辑清晰
  • 但空间复杂度为O(n)(递归栈)
  • 实际面试中可能要求迭代解法

题目三:随机链表的复制(LeetCode 138)

题目描述

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

构造这个链表的深拷贝。深拷贝应该正好由n个全新节点组成,其中每个新节点的值都设为其对应的原节点的值。新节点的next指针和random指针也都应指向复制链表中的新节点。

示例1

复制代码
输入:head = [[7,null],[13,0],[11,4],[10,2],[1,0]]
输出:[[7,null],[13,0],[11,4],[10,2],[1,0]]

解题思路:哈希表映射法

核心思想

使用哈希表建立原节点到新节点的映射关系,然后通过映射关系构建next和random指针。

图3:随机链表复制算法流程图

两步构建法
  1. 第一步:遍历原链表,创建所有新节点,建立映射关系
  2. 第二步:再次遍历原链表,通过映射关系设置新节点的next和random
为什么需要两步?

如果一步完成,当设置某个节点的random时,它指向的节点可能还没有被创建。

代码实现与解析

复制代码
class Solution {
    public Node copyRandomList(Node head) {
        if (head == null) return null;
        
        // 第一步:建立映射关系
        HashMap<Node, Node> map = new HashMap<>();
        Node cur = head;
        while (cur != null) {
            map.put(cur, new Node(cur.val));
            cur = cur.next;
        }
        
        // 第二步:构建next和random指针
        cur = head;
        while (cur != null) {
            map.get(cur).next = map.get(cur.next);
            map.get(cur).random = map.get(cur.random);
            cur = cur.next;
        }
        
        return map.get(head);
    }
}
代码详解
  1. 第5行:空链表直接返回
  2. 第8-12行:第一次遍历,创建所有新节点,建立映射
  3. 第15-19行:第二次遍历,通过映射设置next和random
  4. 第16行map.get(cur.next)cur.nextnull时返回null,完美处理边界
  5. 第17行 :同理,map.get(cur.random)处理random为null的情况

复杂度分析

  • 时间复杂度:O(n),两次遍历
  • 空间复杂度:O(n),哈希表存储映射关系

原地修改法(O(1)空间)

更高级的解法是在原链表中插入新节点,然后分离:

复制代码
class Solution {
    public Node copyRandomList(Node head) {
        if (head == null) return null;
        
        // 第一步:在原节点后插入新节点
        Node cur = head;
        while (cur != null) {
            Node newNode = new Node(cur.val);
            newNode.next = cur.next;
            cur.next = newNode;
            cur = newNode.next;
        }
        
        // 第二步:设置新节点的random
        cur = head;
        while (cur != null) {
            if (cur.random != null) {
                cur.next.random = cur.random.next;
            }
            cur = cur.next.next;
        }
        
        // 第三步:分离两个链表
        Node newHead = head.next;
        cur = head;
        while (cur != null) {
            Node temp = cur.next;
            cur.next = temp.next;
            if (temp.next != null) {
                temp.next = temp.next.next;
            }
            cur = cur.next;
        }
        
        return newHead;
    }
}

空间复杂度:O(1),原地操作

链表操作通用技巧总结

1. 哑节点的使用

作用:简化头节点处理,避免特殊判断

适用场景

  • 删除头节点的题目(如题目1)
  • 需要返回新头节点的题目(如题目2)

代码模式

复制代码
ListNode dummy = new ListNode(0, head);
// ... 操作 ...
return dummy.next;

2. 双指针技巧

快慢指针 :检测环、找中点

前后指针:保持固定间距(如题目1)

代码模式

复制代码
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
    slow = slow.next;
    fast = fast.next.next;
}

3. 哈希表辅助

作用:建立节点映射关系,简化复杂指针操作

适用场景

  • 需要记住节点对应关系的题目(如题目3)
  • 需要随机访问节点的题目

4. 递归与迭代的选择

特性 递归 迭代
代码简洁度 更简洁 稍复杂
空间复杂度 O(n)递归栈 O(1)
实际性能 可能栈溢出 更稳定
面试要求 通常都可 可能要求迭代

实战演练:如何系统学习链表问题

1. 学习路径建议

  1. 基础阶段:掌握链表基本操作(增删改查)
  2. 技巧阶段:学习哑节点、双指针、哈希表等技巧
  3. 题目阶段:按难度刷题,先易后难
  4. 总结阶段:归纳解题模板,形成思维框架

2. 推荐刷题顺序

  1. 简单题:设计链表、合并两个有序链表
  2. 中等题:本文三道题、环形链表、相交链表
  3. 困难题:合并K个升序链表、反转链表II

3. 常见错误与避免

  1. 指针丢失:操作前保存下一个节点
  2. 空指针异常:检查节点是否为null
  3. 循环引用:注意修改指针的顺序
  4. 边界遗漏:空链表、单节点、头尾节点

总结与最佳实践

核心收获

  1. 题目1:掌握了双指针定位倒数第k个节点的技巧
  2. 题目2:学会了节点交换的标准操作流程
  3. 题目3:理解了复杂链表复制的哈希表映射方法

最佳实践

  1. 画图辅助:复杂操作一定要画图理解
  2. 分步实现:将复杂问题拆解为简单步骤
  3. 边界检查:始终考虑空链表、单节点等特殊情况
  4. 复杂度分析:养成分析时间和空间复杂度的习惯

面试技巧

  1. 先沟通:理解题意,确认输入输出
  2. 再设计:阐述思路,分析复杂度
  3. 后编码:写出清晰、有注释的代码
  4. 最后测试:用示例和边界情况测试

常见问题解答(FAQ)

Q1:为什么删除倒数第N个节点要用哑节点?

A:因为可能删除头节点,哑节点可以统一处理所有情况,避免特殊判断。

Q2:两两交换节点时,如何保持与前面节点的连接?

A :使用node0指针始终指向当前交换对的前一个节点,每次更新node0 = node1

Q3:随机链表复制时,为什么不能一步到位?

A :因为设置random指针时,目标节点可能还未创建。两步法确保所有节点都已创建。

Q4:递归解法什么时候会栈溢出?

A:当链表很长时(如10000个节点),递归深度达到10000,可能导致栈溢出。迭代解法更安全。

Q5:如何快速判断链表问题应该用什么技巧?

A

  • 删除节点 → 考虑哑节点 + 双指针
  • 交换节点 → 考虑迭代法 + 指针操作
  • 复制结构 → 考虑哈希表映射