本篇题目 :138. 随机链表的复制 | 148. 排序链表 | 25. K 个一组翻转链表
统一结构:① 题目分析与解题思路 / ② 常见解法(附代码)及讲解 / ③ 知识点总结
138. 随机链表的复制

① 题目分析 & 解题思路
题意
普通链表每个节点只有 next,这题多了一个 random 指针,随机指向链表内任意节点或者 null。要求深拷贝 :全部新建节点,新链表所有指针(next、random)都只能指向新链表里面的节点,不能指向原链表节点,并且保持和原链表完全一样的结构。
深拷贝是指源对象与拷贝对象互相独立,其中任何一个对象的改动都不会对另外一个对象造成影响。
难点:遍历的时候,random 指向的节点可能还没创建出来 ,所以不能一边遍历一边直接给 random 赋值,否则拿到的是 null。
核心思路(两种主流思路)
思路 A:哈希表 (简单好写,空间 O(n)O(n)O(n))
- 第一次遍历原链表:创建所有新节点,用
HashMap<原节点, 新节点>建立映射关系; - 第二次遍历原链表:借助哈希表,给每个新节点设置
next和random;newNode.next = map.get(oldNode.next)newNode.random = map.get(oldNode.random)
- 返回新头结点
map.get(head)。
思路 B:原地拼接链表 (不用哈希表,空间 O(1)O(1)O(1),面试常考)
- 拼接:在原链表每一个旧节点后面插入它的拷贝节点;
A → A' → B → B' → C → C' - 设置
random:旧节点的random如果是R,那么拷贝节点的random就是R.next; - 拆分链表:把
A, A', B, B'拆开,原链表恢复原样,分离出纯拷贝链表。
一句话对比:哈希表把"旧→新"映射存进表;原地法把映射藏在结构里------旧节点的下一个位置就是它的副本。
② 常见解法(附代码)及讲解
节点定义(题目自带):
java
class Node {
int val;
Node next;
Node random;
public Node(int val) {
this.val = val;
this.next = null;
this.random = null;
}
}
解法 1:HashMap 哈希映射(推荐新手优先掌握)
HashMap 的设计:Map<Node, Node> map
- key = 原链表的旧节点
- value = 我们新创建出来的拷贝节点
- 作用:建立一对一映射,只要拿到旧节点,立刻找到它对应的新节点。
java
class Solution {
public Node copyRandomList(Node head) {
// 边界:原链表是空,直接返回null
if (head == null) {
return null;
}
// 哈希表:旧节点 → 拷贝节点
Map<Node, Node> map = new HashMap<>();
Node cur = head;
// == 第一轮遍历:只创建节点,不连指针 ==
while (cur != null) {
// 根据旧节点的值,新建一个拷贝节点
Node copyNode = new Node(cur.val);
// 存入映射表:旧节点对应它的拷贝
map.put(cur, copyNode);
// 指针往后走
cur = cur.next;
}
// 指针重置回表头,准备第二轮遍历
cur = head;
// == 第二轮遍历:给拷贝节点挂上 next 和 random ==
while (cur != null) {
// 通过旧节点取出它对应的拷贝节点
Node copyNode = map.get(cur);
// 1. 设置 next
// cur.next 是【原链表下一个旧节点】,map.get 拿到它对应的拷贝节点
copyNode.next = map.get(cur.next);
// 2. 设置 random
// cur.random 是【原链表 random 指向的旧节点】,map.get 拿到它对应的拷贝节点
copyNode.random = map.get(cur.random);
cur = cur.next;
}
// 返回原 head 对应的拷贝节点,也就是新链表头
return map.get(head);
}
}
分步拆解:拿示例 2 跟着走
head = [[1,1],[2,1]],原链表:
java
旧A(val=1) next → 旧B(val=2) next → null
旧A.random → 旧B
旧B.random → 旧B
第一轮循环:只造节点、填 map,不连任何指针 (cur 从旧 A 出发)
cur = 旧A→new Node(1)得到拷贝 A →map.put(旧A, 拷贝A)→cur移到旧 Bcur = 旧B→new Node(2)得到拷贝 B →map.put(旧B, 拷贝B)→cur = null,第一轮结束
此时 map 里已有全部映射(旧A→拷贝A、旧B→拷贝B)。重点 :所有拷贝节点都已存在内存里,不管 random 指向哪个都能找到。
第二轮循环:连接 next 和 random (cur 重置回旧 A)
cur = 旧A→copyNode = map.get(旧A)= 拷贝Acur.next= 旧B →map.get(旧B)= 拷贝B →拷贝A.next = 拷贝Bcur.random= 旧B →map.get(旧B)= 拷贝B →拷贝A.random = 拷贝Bcur移到旧 B
cur = 旧B→copyNode = map.get(旧B)= 拷贝Bcur.next=null→map.get(null)返回null→拷贝B.next = nullcur.random= 旧B →map.get(旧B)= 拷贝B →拷贝B.random = 拷贝Bcur = null,第二轮结束
最后返回 map.get(head) = 拷贝A,即新链表头。新链表:拷贝A(1) → 拷贝B(2) → null,且 拷贝A.random → 拷贝B、拷贝B.random → 拷贝B,完全符合深拷贝要求------所有指针都指向新节点,没有碰原链表。
三个关键细节 & 疑问解答
Q1:map.get(null) 为什么不会报错? HashMap 允许 get(null),key 不存在时直接返回 null。所以 cur.next == null 或 cur.random == null 时,map.get(null) 得到 null 正好赋给拷贝节点的指针,不用额外写 if 判断。
Q2:什么是深拷贝?和浅拷贝的区别?
- ❌ 浅拷贝:
Node copy = cur,只是拿引用,新变量和原节点是同一个对象,改一个另一个跟着变,random依旧指向原链表节点,题目不允许; - ✅ 深拷贝:
new Node(cur.val),在堆里开辟全新内存,是独立节点,和原节点互不干扰。
Q3:边界测试 case
head = null:直接return null,防止空指针;- 某个节点
random是null:map.get(null)得到null,自动处理; - 节点
random指向自己(自环):map 里能拿到自己对应的拷贝节点,正常处理。
复杂度:两轮遍历 O(n)O(n)O(n) 时间;哈希表存 n 组映射 O(n)O(n)O(n) 空间,这就是哈希表方案的代价,原地法可压到 O(1)O(1)O(1)。
解法 2:原地拼接 + 拆分(O(1)O(1)O(1) 额外空间,进阶)
java
class Solution {
public Node copyRandomList(Node head) {
// 边界:原链表为空,直接返回 null
if (head == null) {
return null;
}
Node cur = head;
// == 第一步:在原节点后面插入拷贝节点 ==
// 链表变化前:A -> B -> C -> null
// 插入后: A -> A' -> B -> B' -> C -> C' -> null
while (cur != null) {
// 根据当前旧节点的值,创建拷贝节点
Node copy = new Node(cur.val);
// 拷贝节点的 next 指向旧节点原来的下一个节点
copy.next = cur.next;
// 把拷贝节点接在旧节点的后面
cur.next = copy;
// cur 跳到下一个旧节点(跳过刚插入的拷贝节点)
cur = copy.next;
}
cur = head;
// == 第二步:给拷贝节点设置 random 指针 ==
// 规律:旧节点 cur 的 random 是 R,则拷贝节点 cur.next 的 random 就是 R.next(R 对应的拷贝)
while (cur != null) {
Node copy = cur.next; // copy 就是 cur 对应的拷贝节点
// 必须判断 random 不为空,否则 cur.random.next 空指针异常
if (cur.random != null) {
copy.random = cur.random.next;
}
// 跳到下一个旧节点
cur = copy.next;
}
// == 第三步:拆分链表,分离原链表和拷贝链表 ==
cur = head;
Node dummy = new Node(0); // 虚拟头节点,用来拼接拷贝后的新链表
Node p = dummy; // p 用来遍历、构建新链表
while (cur != null) {
Node copy = cur.next; // 取出拷贝节点
cur.next = copy.next; // 恢复原链表的 next 指针,把原链表拆开
p.next = copy; // 将拷贝节点接到新链表上
p = p.next; // 新链表指针后移
cur = cur.next; // 原链表指针后移
}
// dummy.next 就是拷贝链表的头节点
return dummy.next;
}
}
三步要点回顾
- 插入:每个旧节点后紧跟自己的副本,利用相邻关系建立"旧节点 → 拷贝节点"的隐式映射,不再需要 HashMap;
- random 赋值 :核心公式
copy.random = cur.random.next,前提是cur.random != null;因为cur.random是原链表的节点,它的下一个就是它的拷贝; - 拆分:同时维护两条链表,把新旧两条链表分开,新链表单独摘出来,原链表结构复原。
- 复杂度:时间 O(n)O(n)O(n),额外空间 O(1)O(1)O(1)(不算答案需要的 n 个节点)。
面试小贴士 :优先写哈希表版本,写完可以主动提一句"还有原地 O(1)O(1)O(1) 空间的优化方案",通常这就是下一问。
③ 知识点总结
常见出错点
- ❌ 浅拷贝 :直接
newNode = oldNode,新节点引用还是指向原链表节点,不是新建对象,直接报错; - ❌ 第一次遍历就想给
random赋值:random指向的节点还没创建,拿到null; - ❌ 哈希表
put顺序错误(还没put就先去get),或者漏判head == null,导致空指针; - ❌ 原地法拆分链表时指针搞乱,把原链表也改坏,或者链表断链;
- ❌ 边界:
head为null的时候,没有直接返回null; - ❌ 原地设置
random时,没有判断old.random != null,直接写old.random.next,空指针。
解法优缺点
- ✅ 优点(哈希表):逻辑线性、好理解、不容易写崩,面试首选写法,不容易踩空指针大坑;
- ❌ 缺点:需要额外哈希表存映射,占用 O(n)O(n)O(n) 空间;面试官后续大概率会追问"能不能不用 HashMap 做?"(即原地拼接拆分版本)。
知识点小结
- 映射思维 :同一份"旧→新"映射,可以显式存(HashMap),也可以隐式藏(
旧.next = 拷贝),两种解法只差这一层; - 指针三段式 :插入 → 改
random→ 拆分,是"原地改结构"类题目的通用骨架。
148. 排序链表

① 题目分析 & 解题思路
题目 :给定单链表头节点,将链表升序排序并返回。进阶要求:O(nlogn)O(n\log n)O(nlogn) 时间、O(1)O(1)O(1) 常数空间。
链表不支持随机访问,数组常用的快排、堆排不好直接写;归并排序天然适合链表------只需要改指针,不需要移动元素。
核心思路(自底向上归并排序,满足进阶要求):
- 先统计链表总长度
len; - 分段归并:初始段长
subLen = 1,每轮翻倍(1→2→4→8...),直到subLen ≥ len; - 每一轮:从头开始,每次切出两段长度为
subLen的链表,合并两个有序链表,再拼接回原链表; - 用一个哑节点(哨兵)方便管理头节点,避免处理大量边界。
补充:还有自顶向下递归归并 写法,代码好写,但递归栈是 O(logn)O(\log n)O(logn),不满足常数空间进阶要求。
② 常见解法(附代码)及讲解
解法一:自顶向下归并排序(简单好写,面试先写这个,空间 O(logn)O(\log n)O(logn))
原理:分治。
- 分割:快慢指针找到链表中点,断开,分成左右两个子链表;
- 递归排序左、右;
- 合并两个有序链表。
java
class Solution {
public ListNode sortList(ListNode head) {
// 递归终止:空或者只有一个节点,本身有序
if (head == null || head.next == null) return head;
// 快慢指针找中点
ListNode slow = head, fast = head.next;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next; // fast 从 head.next 开始
}
// slow 要停在中点前一个位置,mid = slow.next,再切断 slow.next
ListNode mid = slow.next; // mid 是 slow 下一个,是中点
slow.next = null; // ✅ 关键!切断左右链表,完全分开
// 递归
ListNode left = sortList(head);
ListNode right = sortList(mid);
return merge(left, right);
}
// 合并两个有序链表(LeetCode 21 原题)
private ListNode merge(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode tail = dummy;
while (l1 != null && l2 != null) {
if (l1.val < l2.val) {
tail.next = l1;
l1 = l1.next;
} else {
tail.next = l2;
l2 = l2.next;
}
tail = tail.next;
}
tail.next = (l1 != null) ? l1 : l2;
return dummy.next;
}
}
✅ 优点:代码短,逻辑直观 ❌ 缺点:递归调用栈深度 O(logn)O(\log n)O(logn),不满足进阶的 O(1)O(1)O(1) 空间。
为什么是 fast = head.next 而不是 head?
fast = head.next 时,slow 最后落在中点前一个节点 ,于是 mid = slow.next、切断 slow.next,左右链表完全分离------这个设定是专门为"归并排序分割链表"服务的。
一句话口诀:找中点用
fast = head;归并分割链表用fast = head.next。
解法二:自底向上迭代归并(满足进阶:O(nlogn)O(n\log n)O(nlogn) 时间,O(1)O(1)O(1) 空间,面试加分)
不递归,用循环模拟分段合并:
java
class Solution {
public ListNode sortList(ListNode head) {
// 边界:空链表 / 只有一个节点,本身有序直接返回
if (head == null || head.next == null) {
return head;
}
// 第一步:统计链表总节点数量,用来控制归并的分段长度
int totalLen = 0;
ListNode p = head;
while (p != null) {
totalLen++;
p = p.next;
}
// 哑节点(哨兵):统一处理头节点变化,不用单独判断谁是新头
ListNode dummy = new ListNode(0, head);
// subLen:当前一轮归并,每个子链表的长度;每次循环翻倍 1,2,4,8...
// 当 subLen >= totalLen,说明整个链表已经有序,停止循环
for (int subLen = 1; subLen < totalLen; subLen <<= 1) {
// tail:记录【已经合并完成部分的最后一个节点】,用来拼接下一轮合并结果
ListNode tail = dummy;
// cur:当前待切割、待归并的起始位置
ListNode cur = dummy.next;
// 只要还有没处理完的节点,就继续切割 + 合并
while (cur != null) {
// 1. 切出左半段,长度最多 subLen
ListNode left = cur;
// cut 函数:从 left 开始切掉 subLen 个节点,返回左段后面第一个节点(也就是右段起点)
ListNode right = cut(left, subLen);
// 2. 切出右半段,长度最多 subLen;cur 更新为【下一组待处理的起点】
cur = cut(right, subLen);
// 把合并好的有序链表,接到上一轮结果的尾部
tail.next = merge(left, right);
// tail 向后走到本轮合并结果的末尾,为下一次拼接做准备
while (tail.next != null) {
tail = tail.next;
}
}
}
// dummy.next 就是排序完成后的链表头
return dummy.next;
}
/**
* 截断链表:从 head 开始,截取最多 k 个节点,切断链表
* @param head 截断起始位置
* @param k 需要截取的节点数量
* @return 返回截断之后,后面剩下链表的起点;同时把前面这段的尾节点 next 置 null
*/
private ListNode cut(ListNode head, int k) {
// 走 k-1 步,到达这一段的最后一个节点
while (--k > 0 && head != null) {
head = head.next;
}
// 如果已经走到 null,说明后面没有节点了,直接返回 null
if (head == null) {
return null;
}
// 保存截断位置的下一个节点(后面链表的起点)
ListNode nextStart = head.next;
// 核心:切断链表,让当前截取的这一段独立,避免和后面连在一起
head.next = null;
return nextStart;
}
/**
* 合并两个有序单链表(LeetCode 21 模板)
* @param l1 有序链表1
* @param l2 有序链表2
* @return 合并后的有序链表头
*/
private ListNode merge(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode tmp = dummy;
// 双指针依次取更小的值
while (l1 != null && l2 != null) {
if (l1.val < l2.val) {
tmp.next = l1;
l1 = l1.next;
} else {
tmp.next = l2;
l2 = l2.next;
}
tmp = tmp.next;
}
// 把剩下没走完的链表直接接在尾部(剩下的本身有序)
tmp.next = (l1 != null) ? l1 : l2;
return dummy.next;
}
}
重点函数与关键点
cut(head, k):截断链表取出 k 个节点,返回后面链表起点并断开链接------它是这个写法的灵魂,强制分段,防止归并时链表串成环、无限循环;subLen <<= 1等价于subLen *= 2,位运算写法,每轮块大小翻倍;- dummy 哨兵统一处理头节点变化,不用单独判断头是谁;
- 自底向上没有递归、没有递归栈 ,满足题目进阶要求:O(nlogn)O(n\log n)O(nlogn) 时间、O(1)O(1)O(1) 额外空间(只开了有限几个指针变量);
- 内层
while (tail.next != null):必须移动 tail,不然每次合并的结果会覆盖在上一段末尾,链表错乱。
③ 知识点总结
常见出错点
- 快慢指针找中点的时候,忘记切断左右两半链表! 不切断:合并的时候链表会循环,死循环;
- 合并有序链表时指针混乱,没有保存每一段的后继节点:分段切完之后,要记住下一段的起点,不然找不到后面的链表;
- 哑节点 dummy 使用错误,更新 tail 指针的时候忘记往后走:tail 是当前合并结果的末尾,每次合并完一段,tail 要移到新末尾;
- 空链表 / 单节点链表边界漏判 :
head == null或者head.next == null直接返回; - 自顶向下递归写法 :快慢指针起点写错(
fast = head.next, slow = head,不是都从head.next开始),导致中点分割错误; - 段长翻倍循环条件写错 ,
subLen溢出。
两种写法对比
| 写法 | 时间 | 空间 | 难度 | 面试场景 |
|---|---|---|---|---|
| 自顶向下递归归并 | O(nlogn)O(n\log n)O(nlogn) | O(logn)O(\log n)O(logn) | ⭐⭐ | 快速写出,先保证 AC |
| 自底向上迭代归并 | O(nlogn)O(n\log n)O(nlogn) | O(1)O(1)O(1) | ⭐⭐⭐⭐ | 面试官追问进阶要求时写这个 |
拓展 :这道题经常和 21. 合并两个有序链表、876. 链表的中间结点 一起考,merge 函数是链表归并的核心模板,一定要背熟。
知识点小结
- 两个模板 :
merge(哑节点 + 双指针 + 剩余段接尾)来自 21 题,快慢指针找中点来自 876 题; - 必须断链 :递归版靠
slow.next = null,迭代版靠cut内置null,否则两段不独立,归并必成环。
25. K 个一组翻转链表

① 题目分析 & 解题思路
题意 :给单链表,每 k 个节点为一组进行翻转;不足 k 个的末尾分组保持原样。不能只改节点的值,必须调换节点指针 。进阶要求:O(1)O(1)O(1) 额外空间。
核心思路(迭代,满足 O(1)O(1)O(1) 空间):
- 用哑节点 dummy,统一处理头部翻转,避免单独处理头节点变化;
- 维护一个
pre指针:代表当前待翻转组的前一个节点 ,翻转完这一组后,要把pre.next指向新的组头; - 每次先从
pre出发,往后走k步,检查剩下节点够不够k个:不够直接结束;- 够 k 个:标记
start(本组起点)、end(本组终点),再记录nextGroup(下一组的起点); - 把
end.next = null,断开当前组,调用翻转单链表函数 翻转start; - 翻转后:原来的
end变成新组头,原来的start变成本组尾; - 拼接:
pre.next指向翻转后的新组头(就是原来的end,代码里写作pre.next = reverse(start)),start.next = nextGroup; - 更新
pre = start,进入下一轮循环;
- 够 k 个:标记
- 循环直到剩余节点不足 k 个。
配套辅助函数:翻转一条独立的单链表(206 题基础模板),代码见解法一的 reverse。
② 常见解法(附代码)及讲解
解法一:迭代写法(推荐,满足进阶 O(1)O(1)O(1) 空间,面试首选)
java
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
// 哑节点,放在链表最前面,统一所有分组的前驱(它的值无所谓)
// 等价于 ListNode dummy = new ListNode(-1, head);
ListNode dummy = new ListNode(-1);
dummy.next = head;
// pre:待翻转分组的前驱节点
ListNode pre = dummy;
while (true) {
// 1. 找到当前分组的末尾 end,同时判断剩余节点够不够 k 个
ListNode end = pre;
for (int i = 0; i < k; i++) {
end = end.next;
// end 走到 null,说明剩下节点不足 k 个,直接结束
if (end == null) {
return dummy.next;
}
}
// pre:上一组的尾节点;end:这一组的尾节点
// start:本组原始起点;nextGroup:下一组的起点,必须提前保存!
ListNode start = pre.next;
ListNode nextGroup = end.next;
// 关键:2. 断开当前组和下一组的连接,单独翻转当前组
end.next = null;
// 翻转本组,翻转之后 end 变成新的组头,start 变成本组尾
// pre.next 指向反转后的这一组的头结点(prev)
pre.next = reverse(start);
// 3. 拼接:翻转后的尾部(start)接上后面没处理的分组
start.next = nextGroup;
// pre 更新为当前组的尾部,作为下一组的前驱
pre = start;
}
}
/**
* 翻转一条独立的单链表(206 原题模板)
* @param head 待翻转链表头
* @return 翻转后的新头
*/
private ListNode reverse(ListNode head) {
ListNode prev = null;
ListNode cur = head;
while (cur != null) {
ListNode temp = cur.next; // 先保存原顺序的下一个结点
cur.next = prev; // 反转链表顺序,当前结点 next 指向 prev 值
prev = cur; // prev 指向 cur 值
cur = temp; // cur 指向下一个节点的值
}
return prev; // 返回反转后的第一个结点
}
}
流程示例(head = 1,2,3,4,5, k = 2)
dummy→1→2→3→4→5,pre = dummy;end向后走 2 步到 2;start = 1,nextGroup = 3;断开2→null;翻转[1,2]得到2→1;pre.next = 2;1.next = 3;pre更新到 1;- 下一轮:
end从 1 往后走 2 步到 4;start = 3,nextGroup = 5;断开4→null;翻转[3,4]得到4→3; pre.next = 4;3.next = 5;pre更新到 3;- 下一轮
end走两步,第一步到 5,第二步end = null,退出循环。
结果:dummy→2→1→4→3→5 ✅
解法二:递归写法(代码简短,空间 O(n/k)O(n/k)O(n/k),递归栈,不满足进阶)
思路:递归处理后面的分组,当前组翻转之后,把当前组的尾部接上递归返回的、后面已经处理好的链表。
java
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
// 检查剩下节点够不够 k 个
ListNode check = head;
for(int i = 0; i < k; i++){
if(check == null) return head;
check = check.next;
}
// 够 k 个,翻转当前 k 个节点
ListNode prev = null;
ListNode cur = head;
for(int i = 0; i < k; i++){
ListNode temp = cur.next;
cur.next = prev;
prev = cur;
cur = temp;
}
// head 现在是本组翻转后的尾,接上后面递归处理好的结果
// cur 是下一组的头结点
head.next = reverseKGroup(cur, k);
// prev 是本组翻转后的新头
return prev;
}
}
✅ 优点:代码很短,逻辑好理解 ❌ 缺点:递归栈深度是 O(n/k)O(n/k)O(n/k),不是 O(1)O(1)O(1) 空间,面试官问进阶就不能用这个。
③ 知识点总结
常见出错点
- ❌ 忘记提前保存下一组起点
nextGroup,翻转之后找不到后面链表,直接丢失后半段; - ❌ 翻转前没有断开本组和下一组的连接:翻转函数会把后面一组一起反转,链表错乱成环;
- ❌ 指针更新顺序搞错 :
pre必须更新为【翻转后的本组尾节点(原来的start)】,很多人继续用end当pre,分组拼接完全错位; - ❌ 检查剩余节点数量逻辑写错 :走到中途为
null还继续翻转,把不足 k 个的尾部也翻转了(题目要求尾部不足 k 保留原序); - ❌ 边界:
k = 1,不需要翻转,直接返回原链表; - ❌ 不用哑节点,处理第一组翻转后的新头非常麻烦,容易丢头节点。
两种写法对比
| 写法 | 时间 | 空间 | 特点 |
|---|---|---|---|
| 迭代 | O(n)O(n)O(n) | O(1)O(1)O(1) | 进阶要求,指针多,边界容易错,面试优先准备 |
| 递归 | O(n)O(n)O(n) | O(n/k)O(n/k)O(n/k) | 代码简洁,适合快速 AC,不能满足进阶 |
知识点小结
- 保存---断开---拼接 :先存
start/end/nextGroup,再end.next = null,最后pre.next、start.next回接,顺序错了必丢链或成环; - 难题 = 基础模板组合 :
reverse是 206 题模板;不足 k 个靠"先走 k 步探测"统一处理,避免特判末尾。
这道题是链表高频 Hard,依赖【翻转单链表】基础模板,一定要吃透指针的保存、断开、拼接三步。