LeetCode Hot 100 · 链表篇
链表是 Hot 100 里题量最大的一类(14 题),也是面试手撕频率最高的一类——它不考什么高深数据结构,考的全是指针基本功。整类题目翻来覆去只有三个核心套路,吃透它们,14 题全部是套模板:
- dummy 虚拟头节点:凡是可能改动头节点(删除、插入、交换、翻转),先
new ListNode(0)挂上 dummy,边界处理直接清零,返回dummy.next。 - 快慢指针:
fast走两步、slow走一步,用于找中点、判环、找环入口、找倒数第 N 个。 - 三指针原地反转:
pre/cur/tmp三个指针,cur.next = pre逐步倒转,是 206、234、25 的基石。
下面是本分类所有题目通用的节点定义:
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; }
}
(138 随机链表复制、146 LRU 缓存因题而异,节点定义在对应题内单独给出。)
160. 相交链表
题意
给定两个单链表的头节点 headA、headB,找到并返回它们相交的起始节点;不相交则返回 null。要求不能破坏原链表结构。
输入:listA = [4,1,8,4,5], listB = [5,6,1,8,4,5]
输出:值为 8 的那个节点
题目本质是双指针路径补偿:让两个指针分别遍历两条链表,走完本链表后切换到另一条,这样两者走过的总路程相同,若相交必然在交点相遇。可以类比两人约会——甲走完自己的路后走乙的路,乙走完后走甲的路,两人总路程一样,若有公共路段必然同时到达公共起点。
难点与易错点
- 不相交也要返回
null:双指针写法靠「同时走到 null」自然退出,千万不能在循环里只判断「相等」,否则不相交时会无限循环。 - 切换时机是
p == null而不是p.next == null:必须让指针真正走到链尾的null再切换,才能保证两指针总路程严格相等(各走a + b)。若在倒数第二个节点就切换,总路程不对,交点会错过。 - 不能用「反转后比较」的思路:题目要求保持原结构,而且反转其中一条会让另一条也被牵连(若相交),得不偿失。
解法一:哈希表标记
最直观的思路:先遍历链表 A,把走过的每个节点塞进 HashSet;再遍历链表 B,第一个在集合里出现过的节点就是交点。
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
// 记录链表 A 的所有节点
Set<ListNode> seen = new HashSet<>();
ListNode p = headA;
while (p != null) {
seen.add(p);
p = p.next;
}
// 遍历 B,第一个出现过的节点即交点
p = headB;
while (p != null) {
if (seen.contains(p)) {
return p;
}
p = p.next;
}
return null; // 无交点
}
}
复杂度:时间 O(m + n),两链表各遍历一遍;空间 O(m),哈希表存 A 的全部节点。
瓶颈:空间 O(m),面试官通常追问「能不能 O(1) 空间」。
解法二:双指针路径补偿(最优)
两个指针 p1、p2 同时从各自头节点出发,每步各走一格;谁先走到 null,就切到另一条链表的头继续走。因为 p1 走的总路程是 A独有 + 公共 + B独有,p2 是 B独有 + 公共 + A独有,两者相等,所以要么在交点相遇,要么同时走到 null。
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
ListNode p1 = headA;
ListNode p2 = headB;
// 若相交:p1 走 a + c + b,p2 走 b + c + a,在交点相遇
// 若不相交:两者同时到达 null(a + b == b + a),循环自然结束
while (p1 != p2) {
// 走完本链表后,切换到另一条链表的头
p1 = (p1 == null) ? headB : p1.next;
p2 = (p2 == null) ? headA : p2.next;
}
return p1; // 交点或 null
}
}
复杂度推导:设 A 独有段长 a、B 独有段长 b、公共段长 c。相交时 p1 走 a + c + b 步、p2 走 b + c + a 步,两者步数相同,所以步数上界为 (a + b + c),即两链表总长度 → O(m + n);不相交时各走 a + b 步后同时到 null,同样 O(m + n)。空间只用了两个指针 → O(1)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 哈希表标记 | O(m+n) | O(m) | 快速给出可行解,写起来最不容易错 |
| 双指针路径补偿 | O(m+n) | O(1) | 面试要求的最终答案,思路巧妙 |
这题约束下(可能很长、且要求 O(1) 空间)双指针是唯一既快又省空间的解,且代码量极短,面试首选。
CodeTop 变体
- 判断是否相交(只返回布尔):字节、腾讯常作为预热问。双指针写法把返回
p1改为p1 != null即可。 - 两个可能带环的链表判断相交(进阶,小红书/快手考过):先对两条链表分别做 142 的环检测;根据「都有环 / 一个环一个无环 / 都无环」三种情况分治,属于 142 与 160 的组合题。
- 求交点距离两个头节点的距离:本质就是上面的
a、b,双指针相遇时统计步数即可。
206. 反转链表
题意
反转一个单链表,返回反转后的头节点。
输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]
题目本质是三指针原地反转:用 pre、cur、tmp 三个指针,每次把 cur.next 指回 pre,逐步把链表倒转。现实类比是「排队反转」——告诉每个人转身指向身后的人,从头依次操作,队尾最终成为新队首。
难点与易错点
- 必须先暂存
tmp = cur.next:把cur.next改指pre的瞬间,原 next 就断了,不暂存的话后面cur就无法前进。 pre初始必须为null:否则反转后原头节点的next会指向一个野指针,无法形成「末尾指向 null」。- 返回的是
pre而不是cur:循环结束时cur已经越界到null,pre才指向新头。 - 递归写法忘写
head.next = null:递归回溯时不把head.next置空,会导致原头节点成环,死循环/内存泄漏。
解法一:递归
递归地反转 head.next 之后的子链表,再把 head 挂到子链表末尾。
class Solution {
public ListNode reverseList(ListNode head) {
// 递归基:空链表或单节点
if (head == null || head.next == null) {
return head;
}
// 先递归反转后面的链表,newHead 是反转后的头
ListNode newHead = reverseList(head.next);
// 把 head 接到子链表末尾:head.next 是子链表的尾
head.next.next = head;
// 关键:断开 head 原来的 next,否则成环
head.next = null;
return newHead;
}
}
复杂度:时间 O(n),每个节点递归处理一次;空间 O(n),递归调用栈深度为 n。
瓶颈:递归栈占 O(n) 空间,链表极长时会栈溢出,且不是面试官想要的「O(1) 空间」。
解法二:三指针迭代(最优)
class Solution {
public ListNode reverseList(ListNode head) {
ListNode pre = null; // 前驱,初始 null(反转后成为新链表末尾)
ListNode cur = head; // 当前处理节点
while (cur != null) {
ListNode tmp = cur.next; // 1. 暂存下一节点,防止断链
cur.next = pre; // 2. 当前节点指向前驱(反转)
pre = cur; // 3. 前驱后移
cur = tmp; // 4. 当前节点后移
}
return pre; // pre 最终指向新链表的头
}
}
> **💭 思考**:反转链表的三指针为什么先 `tmp = cur.next`,顺序不能乱?一步步想——递归空间 O(n) 不满足 O(1),改成迭代后要原地反转,就得在遍历中把每条边的方向倒过来。核心矛盾是:把 `cur.next` 改指 `pre` 的瞬间,原来的 `next` 就断了,`cur` 无法继续前进,所以**必须先用 `tmp` 暂存**。于是形成固定四步:暂存 → 反转 → 前驱后移 → 当前后移。这个「pre/cur/tmp 三指针」是 234、25 等一大类题的基石,背住这四步就够。
复杂度推导:循环变量 cur 从 head 一直走到 null,恰好遍历 n 个节点,每轮只做 4 步常量指针操作 → O(n);全程只用了 pre、cur、tmp 三个指针,与链表长度无关 → O(1)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 递归 | O(n) | O(n) | 思路简洁,用于理解「链表递归」心智模型 |
| 三指针迭代 | O(n) | O(1) | 面试标准答案,也是 25 题 K 个一组翻转的组件 |
在「要求 O(1) 空间 + 手撕」的约束下,迭代版是唯一选择,且它会被 92、25 反复复用,必须熟练到能默写。
CodeTop 变体
- 反转链表 II(92,反转区间 [left, right]):字节、腾讯高频。套路是「穿针引线」:先定位 left 的前驱
pre,反转[left, right]子区间,再重新接回,本质上就是本模板套一个定位步骤。 - K 个一组翻转(25):见本篇第 25 题,就是 206 的「分段复用」。
- 反转链表的前 N 个节点:快手的追问,是 92 的子问题,同样用 pre + 计数控制反转长度。
234. 回文链表
题意
判断一个单链表是否为回文链表(正读反读一样)。
输入:head = [1,2,2,1]
输出:true
输入:head = [1,2]
输出:false
题目本质是快慢指针找中点 + 反转后半段 + 双指针对比:找到中间,反转后半段,然后从头和后半段头同时向后对比。现实类比是「检查回文词」——找到单词中间位置,把后半部分倒着写,再逐字母和前半段对比。
难点与易错点
- 奇数长度时中点归属:
slow停下时,奇数长度落在正中间节点,偶数长度落在后半段第一个节点。判断循环用p2 != null作为终止条件(后半段更短),就能天然跳过奇数长度的中间节点,无需特判。 - 反转后半段会破坏原链表:这题允许修改链表(LeetCode 判题只看返回值),但若面试官要求不改结构,需要提前记录并恢复。
- 别用「遍历转字符串再 reverse」糊弄:那本质是 O(n) 空间,面试官要的是 O(1) 空间的原地解。
解法一:转数组 + 双指针
把链表值逐个放进数组,再用双指针从两端向中间比较,直观且不易错。
class Solution {
public boolean isPalindrome(ListNode head) {
List<Integer> vals = new ArrayList<>();
ListNode p = head;
while (p != null) {
vals.add(p.val);
p = p.next;
}
int l = 0, r = vals.size() - 1;
while (l < r) {
if (!vals.get(l).equals(vals.get(r))) {
return false;
}
l++;
r--;
}
return true;
}
}
复杂度:时间 O(n),遍历一遍 + 双指针扫一遍;空间 O(n),数组存全部值。
瓶颈:O(n) 额外空间,面试官一般会要求压到 O(1)。
解法二:快慢指针 + 反转后半段(最优)
class Solution {
public boolean isPalindrome(ListNode head) {
ListNode mid = findMiddle(head); // 快慢指针找中点
ListNode head2 = reverseList(mid); // 反转后半段
// 双指针从两端向中间对比
ListNode p1 = head, p2 = head2;
while (p2 != null) { // 后半段更短,用 p2 判终止
if (p1.val != p2.val) {
return false;
}
p1 = p1.next;
p2 = p2.next;
}
return true;
}
// 快慢指针找链表中间(偶数长度时返回后半段第一个节点)
private ListNode findMiddle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
// 三指针迭代反转(复用 206 模板)
private ListNode reverseList(ListNode head) {
ListNode pre = null, cur = head;
while (cur != null) {
ListNode tmp = cur.next;
cur.next = pre;
pre = cur;
cur = tmp;
}
return pre;
}
}
复杂度推导:findMiddle 中 fast 走 n 步、slow 走 n/2 步 → O(n);reverseList 反转后半段 n/2 个节点 → O(n/2);对比阶段 p2 走 n/2 步 → O(n/2)。三步相加仍是线性 → O(n)。空间只用 slow/fast/pre/cur/p1/p2 等常量个指针 → O(1)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 数组 + 双指针 | O(n) | O(n) | 快速通过、思路直观 |
| 快慢指针 + 反转后半段 | O(n) | O(1) | 面试标准解,考验 206 + 快慢指针组合 |
在「空间要 O(1)」的约束下只能选反转后半段,且它把「找中点」和「反转」两个高频模板组合在一起,是面试官最爱看的「综合应用」。
CodeTop 变体
- 要求 O(1) 空间且不修改链表(进阶):腾讯追问过。做法是反转后半段、对比完再把后半段反转回来恢复原结构。
- 判断回文数的链表版:给的是逆序存储的数字链表,可先转成数字再判回文(结合第 2 题)。
- 同套路延伸:重排链表(143,把后半段反转后与前半段交替合并),是本题「找中点 + 反转」的直接复用,字节高频。
141. 环形链表
题意
判断链表中是否有环。
输入:head = [3,2,0,-4], pos = 1(尾部指向第 2 个节点)
输出:true
题目本质是快慢指针追及:若有环,快指针(每步 2 格)必然追上慢指针(每步 1 格);无环则快指针先到 null。现实类比是「操场跑圈」——两人一快一慢在跑道跑,跑道是环形时快的必然追上慢的,是直道时快的先到终点。
难点与易错点
- 快慢必须同起点、先走后判:
fast、slow都从head出发,先各自移动,再判断fast == slow。若一开始就判相等,会在起点误判「有环」。 - 循环条件是
fast != null && fast.next != null:fast每步跨两格,两个条件缺一不可,否则fast.next.next会空指针异常。 - 为什么每步 1 格 vs 2 格必然相遇:两指针相对速度是 1,进入环后距离最多为环长,快指针每轮追近 1 格,因此必然在环长步内追上(这是面试常问的证明)。
解法一:哈希表
遍历节点,把访问过的节点存进 HashSet,遇到重复节点即有环。
public class Solution {
public boolean hasCycle(ListNode head) {
Set<ListNode> seen = new HashSet<>();
ListNode p = head;
while (p != null) {
if (seen.contains(p)) {
return true; // 再次遇到同一节点,说明有环
}
seen.add(p);
p = p.next;
}
return false;
}
}
复杂度:时间 O(n),最多遍历 n 个节点;空间 O(n),哈希表存所有节点。
瓶颈:O(n) 空间,有环时无法提前结束,且不是 O(1) 的最优解。
解法二:快慢指针(最优)
public class Solution {
public boolean hasCycle(ListNode head) {
ListNode fast = head;
ListNode slow = head;
// fast 每次走两步,slow 每次走一步;有环则 fast 必然追上 slow
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
if (fast == slow) {
return true; // 快慢相遇,确认有环
}
}
return false; // fast 到达末尾,无环
}
}
复杂度推导:无环时,fast 走到 null 共走 n 步 → O(n)。有环时,slow 入环前最多走 n 步;入环后 fast 与 slow 距离不超过环长 L,相对速度 1,最多再走 L 步相遇;L ≤ n,所以总步数仍 ≤ 2n → O(n)。空间只用两个指针 → O(1)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 哈希表 | O(n) | O(n) | 直观,但浪费空间 |
| 快慢指针(Floyd) | O(n) | O(1) | 面试标准解,且是 142 的前置 |
这题约束下快慢指针是唯一 O(1) 空间的解,而且它直接通向 142(找环入口),务必记住「相对速度 1」这个相遇证明。
CodeTop 变体
- 找环的入口节点(142):见下一篇,就是本题的数学升级版,几乎所有大厂都把它当 141 的追问。
- 求环的长度:相遇后固定一个指针,另一个继续走直到再次相遇,统计步数即环长,字节考过。
- 判断并返回「是否有重复的访问」同时不修改链表:本质就是本题;若允许修改(把访问过的 next 断开),可以做到 O(1) 空间但会破坏结构,面试要主动说明取舍。
142. 环形链表 II
题意
返回链表开始入环的第一个节点;无环返回 null。不允许修改链表。
输入:head = [3,2,0,-4], pos = 1
输出:值为 2 的那个节点(索引 1)
题目本质是快慢指针 + 数学推导:设头到入环点距离为 a,入环点到相遇点距离为 b,相遇时 fast 走了 2(a+b) = a+b + n*环长,可推出 a = n*环长 - b,即从头出发和从相遇点出发的两个指针同速走 a 步后,会在入环点相遇。现实类比是「绳子打结」——先用一快一慢两只虫确认有结,再分别从头和结点处同速爬,相遇处就是打结位置。
难点与易错点
- 两阶段指针不能混用:阶段一用
fast/slow找相遇点,阶段二要重新取index1 = 相遇点、index2 = head同速走。直接在原 fast/slow 上继续操作会算错。 - 无环时要在阶段一正确返回
null:阶段二只能放在「已经确认fast == slow」之后,别在循环外无脑执行。 - 相遇点不一定是入环点:很多新手误以为快慢第一次相遇处就是环入口——错,相遇点在环内,入口要用数学结论再找一遍。
- 数学推导要能口述:面试常让解释「为什么从头走 a 步 + 从相遇点走 a 步会在入口相遇」,不能只背代码。
解法一:哈希表
和 141 哈希表版几乎一样,只是返回「第一个重复出现的节点」。
public class Solution {
public ListNode detectCycle(ListNode head) {
Set<ListNode> seen = new HashSet<>();
ListNode p = head;
while (p != null) {
if (seen.contains(p)) {
return p; // 第一次重复访问的节点就是入环点
}
seen.add(p);
p = p.next;
}
return null;
}
}
复杂度:时间 O(n);空间 O(n)。
瓶颈:O(n) 空间,且没有体现「Floyd 判环」的数学价值。
解法二:快慢指针 + 数学推导(最优)
public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode fast = head, slow = head;
// 阶段一:快慢指针找相遇点
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
if (fast == slow) {
// 阶段二:一个从头、一个从相遇点,同速前进,在入环点相遇
// 数学推导:设头到入环点距离 a,入环点到相遇点距离 b,
// fast 路程 = a + b + n*环长 = 2*(a + b) → a = n*环长 - b
// 故 index2 从头走 a 步、index1 从相遇点走 a 步,必在入环点相遇
ListNode index1 = fast; // 从相遇点出发
ListNode index2 = head; // 从头出发
while (index1 != index2) {
index1 = index1.next;
index2 = index2.next;
}
return index1; // 入环点
}
}
return null; // 无环
}
}
> **💭 思考**:为什么「判环」用快慢指针,「找环入口」还要再来一个阶段?一步步想——快慢指针相遇只能证明有环,相遇点在环内、不一定是入口。要定位入口,需要从「相遇」这个信息里反推:设头到入口为 a、入口到相遇点为 b,快指针路程是慢指针 2 倍,可得 `a = n×环长 - b`,即「从头走 a 步」和「从相遇点走 a 步」会同时到达入口。于是第二阶段:一个指针回到头、一个留在相遇点,同速前进,再次相遇处就是入口。这解释了为什么 142 比 141 多一段「同速再走」,本质是把判环结论转成位置信息。
复杂度推导:阶段一无环时 fast 走 n 步到 null → O(n);有环时 slow 入环前 ≤ n 步、入环后 ≤ L 步追上 → O(n)。阶段二 index2 从 head 走 a 步(a < n)→ O(n)。两阶段相加仍线性 → O(n)。空间只用 fast/slow/index1/index2 常量个指针 → O(1)。
数学推导补充(口述要点):设头到入口 a,入口到相遇点 b,相遇点到入口 c。相遇时 slow 走了 a + b,fast 走了 a + b + n(b + c)(多绕 n 圈)。因为 fast 速度是 2 倍,2(a+b) = a + b + n(b+c) → a + b = n(b+c) → a = n(b+c) - b = (n-1)(b+c) + c。所以从头走 a 步和从相遇点走 a 步,都等价于「绕 (n-1) 圈再多走 c」,两者在入口相遇。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 哈希表 | O(n) | O(n) | 快速可行,但没亮点 |
| 快慢指针 + 数学 | O(n) | O(1) | 面试标准解,考的是数学推导能力 |
这题约束下快慢指针 O(1) 空间是唯一选择,且数学推导是面试官区分「背题」和「理解」的关键,务必能完整口述。
CodeTop 变体
- 求环的长度:相遇后一个指针停住,另一个继续走,再次相遇所走过的步数就是环长,阿里、字节都追问过。
- 求链表中「非环部分」与「环部分」的长度:
a和环长可分别通过阶段二步数、相遇后计数得到。 - 两个带环链表是否相交:结合 160,先分别找入口,再分「环入口相同 / 不同但在同一环」两种子情况讨论,是 160 + 142 的综合难题,快手、小红书考过。
21. 合并两个有序链表
题意
把两个升序链表合并成一个升序链表,由原节点拼接而成。
输入:list1 = [1,2,4], list2 = [1,3,4]
输出:[1,1,2,3,4,4]
题目本质是双指针归并:类似归并排序的 merge 步骤,每次从两个链表头部取较小值拼到结果链表,一条遍历完就把另一条剩余部分整体接上。现实类比是「两队排好序的人合并成一队」——每次比较两队队首,较矮的先进新队,某队空后把另一队全加到末尾。
难点与易错点
- 头节点难处理,用 dummy:结果链表的头是「两个头里较小的那个」,直接写要特判。用
dummy虚拟头 +p指针统一追加,最后返回dummy.next,这是本题的灵魂。 - 相等时的取值约定:
<=时优先取list1,可保证稳定性(相等时保持原相对顺序),虽不影响结果值,但能体现功底。 - 别忘记把剩余部分整体接上:循环结束后
p.next = (p1 != null) ? p1 : p2一步兜底,漏写会导致结果少一段。
解法一:递归
递归比较两个头,取较小者,其 next 指向「剩余两链表的合并结果」。
class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
if (list1 == null) return list2; // 一条为空,直接返回另一条
if (list2 == null) return list1;
if (list1.val <= list2.val) {
list1.next = mergeTwoLists(list1.next, list2);
return list1;
} else {
list2.next = mergeTwoLists(list1, list2.next);
return list2;
}
}
}
复杂度:时间 O(m + n),每次递归处理掉一个节点;空间 O(m + n),递归栈深度。
瓶颈:递归栈 O(m+n) 空间,链表长时可能栈溢出。
解法二:迭代 + dummy(最优)
class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
ListNode dummy = new ListNode(-1); // 虚拟头,简化结果链表头部处理
ListNode p = dummy; // p 始终指向已合并链表的最后一个节点
ListNode p1 = list1, p2 = list2;
while (p1 != null && p2 != null) {
// 每次选较小节点拼到 p 后面
if (p1.val <= p2.val) {
p.next = p1;
p1 = p1.next;
} else {
p.next = p2;
p2 = p2.next;
}
p = p.next;
}
// 剩余节点已有序,整体接上
p.next = (p1 != null) ? p1 : p2;
return dummy.next;
}
}
复杂度推导:循环每轮让 p1 或 p2 前进一步,两个指针合计移动 m + n 次,每轮 O(1) → O(m + n);只用了 dummy、p、p1、p2 常量个指针 → O(1)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 递归 | O(m+n) | O(m+n) | 代码简洁,理解递归合并 |
| 迭代 + dummy | O(m+n) | O(1) | 面试标准解,且是 148、23 的组件 |
在「要求 O(1) 空间」约束下选迭代版;且 mergeTwoLists 会被 148(排序链表)、23(合并 K 个)直接复用,要练到盲写。
CodeTop 变体
- 合并 K 个升序链表(23):见本篇第 23 题,两两合并就是它的退化版,字节、腾讯高频。
- 求两个有序链表的交集 / 差集:快手考过,双指针思路相同,只是「相等才加入」而非「较小者加入」。
- 合并两个有序数组(88):数组版双指针,从尾部开始归并避免搬移,是链表版思想的平移。
2. 两数相加
题意
两个非空链表各表示一个非负整数,按逆序存储(每节点存一位数字),把两数相加,以同样的链表形式返回和。
输入:l1 = [2,4,3], l2 = [5,6,4] (即 342 + 465)
输出:[7,0,8] (即 807)
题目本质是模拟手工加法:从低位到高位逐位相加,处理进位 carry,用 dummy 构建结果链表,最后检查最高位进位。现实类比是「小学竖式加法」——从个位到高位逐位相加,满十进一,一位一位地构建结果。
难点与易错点
- 循环终止条件要包含
carry != 0:while (l1 != null || l2 != null || carry != 0),漏掉carry会在99 + 1这类最高位进位时丢掉结果最后的「1」。 - 某条链表走完要按 0 处理:一条短一条长时,短的视作补 0,不能直接结束循环。
- 不能转成 int / long 相加:数字可能极长(几百位),
long也会溢出,这是本题最大的坑。面试官会先抛出「能不能转数字」来试探。 - 结果也是逆序:新建节点值取
sum % 10,进位sum / 10,两者顺序别写反。
解法一:转大数相加(直观但非最优)
把两个链表读成字符串,用 BigInteger 相加,再拆回链表。它正确、直观,能躲过 int 溢出,但空间 O(n)、且绕开了「进位」的考点,面试不推荐。
import java.math.BigInteger;
class Solution {
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
// 读逆序数字为字符串
StringBuilder s1 = new StringBuilder();
for (ListNode p = l1; p != null; p = p.next) s1.insert(0, p.val);
StringBuilder s2 = new StringBuilder();
for (ListNode p = l2; p != null; p = p.next) s2.insert(0, p.val);
// 大数相加(避免 long 溢出)
BigInteger sum = new BigInteger(s1.toString()).add(new BigInteger(s2.toString()));
// 拆回逆序链表
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
String str = sum.toString();
for (int i = str.length() - 1; i >= 0; i--) {
p.next = new ListNode(str.charAt(i) - '0');
p = p.next;
}
return dummy.next;
}
}
复杂度:时间 O(m + n);空间 O(m + n)。
瓶颈:O(n) 额外空间,且没体现逐位进位的精髓,面试官要的是下面的模拟解。
解法二:逐位模拟加法(最优)
class Solution {
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
int carry = 0; // 进位
// 同时遍历两条链表(某条为 null 时视其值为 0),并处理最后的进位
while (l1 != null || l2 != null || carry != 0) {
int val1 = (l1 != null) ? l1.val : 0;
int val2 = (l2 != null) ? l2.val : 0;
int sum = val1 + val2 + carry;
carry = sum / 10; // 更新进位
p.next = new ListNode(sum % 10); // 当前位数字
p = p.next;
if (l1 != null) l1 = l1.next;
if (l2 != null) l2 = l2.next;
}
return dummy.next;
}
}
复杂度推导:设两链表长度 m、n(m ≥ n)。循环迭代次数 = max(m, n) 次「同时遍历」+ 最多 1 次「纯 carry」收尾,每轮 O(1) → O(max(m, n))。空间上,结果链表长度最多 max(m, n) + 1(最高位进位多一个节点),这是输出本身 → 若不计输出则额外 O(1),计输出则 O(max(m, n))。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 转 BigInteger | O(m+n) | O(m+n) | 快速绕开溢出,但偏离考点 |
| 逐位模拟加法 | O(max(m,n)) | O(max(m,n)) | 面试标准解,考进位处理 |
这题约束下「逐位模拟」是唯一能同时保证正确性(不溢出)与 O(1) 额外空间(除输出外)的写法,进位细节是面试扣分重灾区。
CodeTop 变体
- 两数相加 II(445,正序存储):输入改为正序(最高位在头),无法直接从头加,标准做法是栈(先进后出还原逆序)或先反转链表再套本题,字节高频。
- 字符串相加(415):把链表换成字符串,逐位模拟 + 进位,逻辑一模一样,腾讯考过。
- 链表表示的数 + 1 / 二进制相加:同套路,只把进位进制换成 2 或改变操作数即可。
19. 删除链表的倒数第 N 个结点
题意
删除链表的倒数第 n 个节点,返回新链表头。
输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]
题目本质是双指针保持固定间距:fast 先走 n + 1 步,再和 slow 同步前进,fast 到 null 时 slow 恰在待删节点的前驱。现实类比是「两人排队保持固定距离」——后面的人与前面的人保持固定距离,前面的人走到终点时,后面的人恰好在需要的位置。
难点与易错点
- fast 必须先走
n + 1步而非n步:因为要 slow 停在待删节点前驱(删除需要前驱节点),多走一步制造「间隔 n 个节点 + 前驱」的错位。 - 删除头节点是经典边界:
n == 链表长度时要删的就是 head。用dummy.next = head且 fast/slow 都从 dummy 出发,就能统一处理,无需特判。 - 循环次数写成
i <= n:对应「先走 n+1 步」,for (int i = 0; i <= n; i++),写i < n会导致 slow 停在待删节点本身而非前驱。
解法一:两次遍历(先求长度)
先统计链表总长 len,则倒数第 n 个就是正数第 len - n + 1 个,再从头走到它的前驱删除。
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(-1);
dummy.next = head;
// 第一次遍历:求长度
int len = 0;
for (ListNode p = head; p != null; p = p.next) {
len++;
}
// 第二次遍历:定位到待删节点的前驱
ListNode p = dummy;
for (int i = 0; i < len - n; i++) {
p = p.next;
}
p.next = p.next.next; // 删除
return dummy.next;
}
}
复杂度:时间 O(n),两遍遍历;空间 O(1)。
瓶颈:要遍历两遍,面试官常追问「能不能一遍」。
解法二:双指针一遍扫描(最优)
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(-1);
dummy.next = head; // 处理删除头节点的边界
ListNode fast = dummy;
ListNode slow = dummy;
// fast 先走 n+1 步,使 fast、slow 间隔 n 个节点,slow 落在待删节点前驱
for (int i = 0; i <= n; i++) {
fast = fast.next;
}
// 同步前进:fast 到 null 时 slow 恰在待删节点的前驱
while (fast != null) {
slow = slow.next;
fast = fast.next;
}
slow.next = slow.next.next; // 跳过待删节点
return dummy.next;
}
}
复杂度推导:第一阶段 fast 走 n + 1 步;第二阶段 fast 继续从 n+1 位置走到 null,共走 len + 1 - (n+1) = len - n 步,slow 同步走 len - n 步。两步合计 (n+1) + (len-n) = len + 1 次前进,每步 O(1) → O(n);只用了 dummy/fast/slow 常量指针 → O(1)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 两次遍历 | O(n) | O(1) | 直观,先求长度再定位 |
| 双指针一遍 | O(n) | O(1) | 面试标准解,双指针固定间距的典范 |
在「要求一遍扫描」的约束下选双指针;「快指针先走固定步数制造间距」这个模板在找倒数第 k 个、判环等多题通用。
CodeTop 变体
- 返回倒数第 k 个节点的值(不删除):剑指 Offer 原题,双指针同套路,去掉 dummy 和删除即可,字节高频。
- 删除链表的中间节点(2095):用快慢指针找中点前驱,本质是「固定间距」思想的变体,腾讯考过。
- 一次遍历求链表中点:快慢指针是「间距」模板的另一个表现形式,可与本题对照记忆。
24. 两两交换链表中的节点
题意
两两交换相邻节点,返回新头。只能交换节点(不能改值)。
输入:head = [1,2,3,4]
输出:[2,1,4,3]
题目本质是模拟指针交换:每次处理一对节点,保存三个关键临时变量后按顺序重连,cur 移动到已交换对的末尾以处理下一对。现实类比是「两人换座位」——记录座位旁下一对人,让两人换完,下一对同样操作。
难点与易错点
- 交换前必须保存
tmp = second.next:重连时first的 next 要指回原来的second.next,不保存就断链。 cur要移到first(交换后的对尾):交换后顺序是cur → second → first,下一对的前驱是first,写成cur = first而不是cur = second。- 循环条件
cur.next != null && cur.next.next != null:要同时保证一对两个节点都存在;只剩单节点时直接结束(保持原样)。 - 不能改值:题目明确禁止「交换节点的值」,只用
val交换会被判错(面试也会被点出)。
解法一:递归
递归交换「前两个节点 + 剩余子链表的交换结果」。
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),每层处理一对;空间 O(n),递归栈深度 n/2。
瓶颈:递归栈 O(n),且链表长时栈溢出。
解法二:迭代 + dummy(最优)
class Solution {
public ListNode swapPairs(ListNode head) {
ListNode dummy = new ListNode(-1);
dummy.next = head;
ListNode cur = dummy;
// 每次处理 cur 后面的一对节点
while (cur.next != null && cur.next.next != null) {
ListNode first = cur.next; // 待交换的第一个节点
ListNode second = cur.next.next; // 待交换的第二个节点
ListNode tmp = second.next; // 保存后续节点,防止断链
// 重连:cur → second → first → tmp
cur.next = second;
second.next = first;
first.next = tmp;
// cur 移到已交换对的末尾(first),准备处理下一对
cur = first;
}
return dummy.next;
}
}
复杂度推导:每轮交换一对(2 个节点),共 n/2 轮,每轮 O(1) 指针操作 → 总 O(n);只用 dummy/cur/first/second/tmp 常量个指针 → O(1)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 递归 | O(n) | O(n) | 思路清晰,代码短 |
| 迭代 + dummy | O(n) | O(1) | 面试标准解,且是 25 题 k=2 的特例 |
这题是 25(K 个一组翻转)当 k=2 时的特例,迭代写法里的「保存三个临时变量 + 顺序重连」是 25 的基础,务必吃透。
CodeTop 变体
- K 个一组翻转链表(25):把「两两交换」推广到「每 k 个翻转」,见下一篇,字节、腾讯必考。
- 奇偶链表(328):把奇数位、偶数位节点重排,是「两两分组操作」的同族思路,美团考过。
- 交换后返回并验证不可改值:面试会强调「只能换指针」,可主动说明用了
tmp保存指针而非val交换。
25. K 个一组翻转链表
题意
每 k 个节点一组翻转,剩余不足 k 个则保持原有顺序,返回修改后的链表。
输入:head = [1,2,3,4,5], k = 2
输出:[2,1,4,3,5]
输入:head = [1,2,3,4,5], k = 3
输出:[3,2,1,4,5]
题目本质是分组反转 + 拼接:统计节点总数,每次对 k 个节点做标准反转,再把反转后的组正确拼接到前一组末尾,循环直到剩余不足 k。现实类比是「按组反向排队」——把整队按 k 个一组分组,每组内部倒序站,不足一组的原序,各组串联成新队伍。
难点与易错点
- 组内反转前先确认「剩余 ≥ k」:不足 k 必须原样返回,这是本题和 24、206 最大的区别,也是最高频的扣分点。
- 组与组之间的拼接是重灾区:反转后
pre是新组头、原来的p.next变成新组尾,需要「新尾接下一组头 + 前驱接新组头」两步拼接,顺序不能乱。 p要移到「上一组的尾」而不是「新组头」:拼接后p = groupTail,否则下一组会接错位置。k = 1或空链表要能正确短路:n < k时直接返回原头,别做多余操作。
解法一:递归
先判断剩余是否够 k 个;够则反转前 k 个,剩余递归处理;不够则直接返回。
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
// 检查剩余节点是否够 k 个,不够则原样返回
ListNode p = head;
for (int i = 0; i < k; i++) {
if (p == null) return head;
p = p.next;
}
// 反转前 k 个节点(复用 206 模板)
ListNode pre = null, cur = head;
for (int i = 0; i < k; i++) {
ListNode nxt = cur.next;
cur.next = pre;
pre = cur;
cur = nxt;
}
// 原 head 现在是组尾,接上后续递归反转的结果
head.next = reverseKGroup(cur, k);
return pre; // 新组头
}
}
复杂度:时间 O(n),每个节点被访问常数次;空间 O(n/k),递归栈深度约 n/k。
瓶颈:递归栈 O(n/k),且拼接思路藏在递归里,迭代版更能体现「手撕」功底。
解法二:迭代分组(最优)
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
// 统计链表长度
int n = 0;
for (ListNode cur = head; cur != null; cur = cur.next) {
n++;
}
ListNode dummy = new ListNode(0, head);
ListNode p = dummy; // p 始终指向当前待处理组的前驱节点
// 每次处理 k 个节点
for (; n >= k; n -= k) {
ListNode pre = null;
ListNode cur = p.next; // 当前组的第一个节点
// 标准链表翻转:翻转 k 个节点
for (int i = 0; i < k; i++) {
ListNode nxt = cur.next;
cur.next = pre;
pre = cur;
cur = nxt;
}
// 翻转后:pre 是新组头,p.next(原头)是新组尾
ListNode groupTail = p.next; // 原头成为翻转后的组尾
p.next.next = cur; // 新组尾连接下一组的头
p.next = pre; // p 的下一个改为新组头(pre)
p = groupTail; // p 移到当前组末尾,准备下一组
}
return dummy.next;
}
}
复杂度推导:① 统计长度遍历 n 个节点 → O(n)。② 分组翻转:共 n/k 组,每组内 k 次循环反转,合计 (n/k) * k = n 次操作 → O(n)。③ 拼接:每组 O(1),共 n/k 组 → O(n/k)。三段相加仍是线性 → O(n)。空间只用 dummy/p/pre/cur/nxt/groupTail 常量个指针 → O(1)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 递归 | O(n) | O(n/k) | 代码简洁,逻辑清晰 |
| 迭代分组 | O(n) | O(1) | 面试标准解,拼接细节最见功底 |
在「要求 O(1) 空间 + 手撕拼接」约束下选迭代版。它是链表题的「集大成者」——dummy + 三指针反转 + 分组拼接全用上了,字节、腾讯几乎必考。
CodeTop 变体
- 递归版与迭代版都要会:字节面试常让先写递归再改迭代,两种写法是同一套反转模板的两面。
- 要求不足 k 也反转(变体):把
n < k直接 return 改成「不足 k 也反转」,腾讯追问过,只需去掉长度判断。 - 按 k 个一组反转,并统计反转次数 / 返回组头数组:快手考过,本质是本题的遍历顺序变体。
138. 随机链表的复制
题意
给定链表,每个节点含 val、next、random(可指向任意节点或 null),构造并返回它的深拷贝。
输入:head = [[7,null],[13,0],[11,4],[10,2],[1,0]]
输出:与输入结构完全一致的新链表(每个节点都是新对象)
本题节点定义(含 random 指针):
class Node {
int val;
Node next;
Node random;
Node(int val) { this.val = val; }
}
题目本质是原地拼接复制节点绕过哈希表:在每个原节点后插入它的复制节点,利用这种结构处理 random(cur.random.next 就是 cur.random 的复制节点),最后拆分两条链表。现实类比是「档案复制」——在每份档案旁放一份复印件,先复印原件,再按原件的引用关系找到复印件的引用,最后把原件和复印件分开归档。
难点与易错点
- random 不能直接复制指向:
copy.random = original.random会把新节点的 random 指到旧链表上,必须映射到「random 对应的新节点」。 - 三次遍历各有分工:① 插入复制节点 ② 设置 random ③ 拆分。三步顺序不能省也不能乱,尤其拆分时要同时恢复原链表,否则原链表被破坏。
cur.random可能为 null:设置 random 时if (cur.random != null)必须先判空,否则空指针异常。- 拆分时注意尾节点 next:最后要保证复制链表末尾指向 null,别把原链表节点残留进去。
解法一:哈希表映射
第一遍遍历创建所有新节点并存入 HashMap<旧节点, 新节点>;第二遍根据映射设置 next 和 random。
class Solution {
public Node copyRandomList(Node head) {
if (head == null) return null;
// 第一遍:为每个旧节点创建新节点,建立映射
Map<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);
}
}
复杂度:时间 O(n),两遍遍历;空间 O(n),哈希表存 n 个映射。
瓶颈:O(n) 额外空间。面试官常追问「能不能 O(1) 空间」,引出下面的原地拼接。
解法二:原地拼接(最优,O(1) 额外空间)
class Solution {
public Node copyRandomList(Node head) {
if (head == null) return null;
// 第一步:在每个原节点后插入其复制节点
Node cur = head;
while (cur != null) {
Node copy = new Node(cur.val);
copy.next = cur.next;
cur.next = copy;
cur = copy.next; // 跳到下一个原节点
}
// 第二步:设置复制节点的 random
// cur.next 是复制节点,cur.random.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 copy = cur.next; // 当前原节点的复制节点
cur.next = copy.next; // 恢复原链表
copy.next = (copy.next != null) ? copy.next.next : null; // 复制链表指向下一个复制节点
cur = cur.next; // 移动到下一个原节点
}
return newHead;
}
}
复杂度推导:三步各遍历一遍链表,每遍 n 个节点,每节点 O(1) → 3n → O(n)。空间上只用了 cur/copy/newHead 常量个指针,不额外开哈希表 → O(1)(新链表本身是输出,不计入额外空间)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 哈希表映射 | O(n) | O(n) | 直观、不易错,两遍遍历 |
| 原地拼接 | O(n) | O(1) | 面试标准解,考「复用 next 当映射」的巧思 |
在「要求 O(1) 空间」约束下选原地拼接。这个「把复制节点塞进原链表的 next 里当隐式映射」的技巧非常巧,字节、腾讯爱考,且能自然过渡到「图的深拷贝」。
CodeTop 变体
- 克隆图(133):深拷贝一个无向图,节点间是邻接表,用 HashMap + DFS/BFS,是本题「哈希表映射深拷贝」的图版,字节高频。
- 要求恢复原链表(拆分后原链表必须原样):本题已要求,面试会重点检查拆分这步是否真的还原了原链表。
- 只拷贝 random 不拷贝 next 的反向变体:快手追问过,本质仍是「先建映射再连线」的两步法。
148. 排序链表
题意
给链表按升序排序并返回。
输入:head = [4,2,1,3]
输出:[1,2,3,4]
题目本质是链表版归并排序:找到中间节点把链表一分为二,递归排序左右两段,再 merge 合并。现实类比是「图书馆整理书架」——把书架从中间分开,左右各请人整理好,你再合并两组有序书目。
难点与易错点
- 必须用归并,不能用快排/插入的 O(n²) 退化:链表不支持随机访问,直接快排最坏 O(n²);要求「O(n log n) 时间 + O(1) 空间」,唯一选择是归并(且要理解为什么链表归并比数组更优雅——它不需要额外数组,只改指针)。
- 找中点必须断开:
pre.next = null把左段尾切断,否则递归会无限分割同一段导致栈溢出。 findMiddle要返回右半段的头,并让slow停在右段第一个:用pre记录slow前驱,偶数长度时slow落在右段头,这样才能稳定分治。- 递归栈 O(log n) 是空间主要来源:若要严格 O(1) 空间,得改自底向上归并(见变体)。
解法一:转数组排序
把节点值取出排序,再写回链表——O(n log n) 时间但 O(n) 空间,且不满足「链表排序」的考察意图。
class Solution {
public ListNode sortList(ListNode head) {
List<Integer> vals = new ArrayList<>();
ListNode p = head;
while (p != null) {
vals.add(p.val);
p = p.next;
}
Collections.sort(vals); // 快排 O(n log n)
p = head;
for (int v : vals) {
p.val = v;
p = p.next;
}
return head;
}
}
复杂度:时间 O(n log n)(sort);空间 O(n)(数组)。
瓶颈:O(n) 额外空间,且这本质是「数组排序」,绕开了链表归并的考点。
解法二:归并排序(最优,自顶向下)
class Solution {
public ListNode sortList(ListNode head) {
if (head == null || head.next == null) {
return head; // 递归基:空或单节点已有序
}
// 找中点并断开左右两段
ListNode mid = findMiddle(head);
ListNode left = sortList(head); // 左段
ListNode right = sortList(mid); // 右段
return mergeSorted(left, right);
}
// 快慢指针找中点,并把左段尾指向 null 断开
private ListNode findMiddle(ListNode head) {
ListNode pre = head, slow = head, fast = head;
while (fast != null && fast.next != null) {
pre = slow;
slow = slow.next;
fast = fast.next.next;
}
pre.next = null; // 断开左段
return slow; // slow 是右半段头
}
// 归并两个有序链表(复用 21 题模板)
private ListNode mergeSorted(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
p.next = l1;
l1 = l1.next;
} else {
p.next = l2;
l2 = l2.next;
}
p = p.next;
}
p.next = (l1 != null) ? l1 : l2;
return dummy.next;
}
}
复杂度推导:归并排序每次把区间对半分,递归深度为 log₂n 层;每一层对所有节点做一次 mergeSorted,单层合计 O(n);总时间 = 层数 × 单层 = log n × n → O(n log n)。空间:递归调用栈深度 log n,每层 O(1) → O(log n)(归并过程只改指针,不需要像数组归并那样开临时数组,这正是链表归并的优越性)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 转数组排序 | O(n log n) | O(n) | 快速通过,但偏离考点 |
| 归并(自顶向下) | O(n log n) | O(log n) | 面试标准解 |
| 归并(自底向上) | O(n log n) | O(1) | 严格 O(1) 空间的进阶解 |
这题约束「O(n log n) 时间、O(1) 空间(最严格)」下,链表归并是唯一出路:数组归并需要 O(n) 临时数组,而链表归并改指针即可,天然省空间。
CodeTop 变体
- 要求 O(1) 空间的自底向上归并:不用递归,从「步长 = 1」开始两两合并,步长逐次翻倍直到覆盖全表,字节追问高频。
- 插入排序链表(147):数据近似有序时插入排序更优,考的是「如何在链表上做插入」,与归并形成对照。
- 合并 K 个升序链表(23):把「两路归并」推广到「K 路归并」,是本题
mergeSorted的规模化,腾讯高频。
23. 合并 K 个升序链表
题意
给定一个链表数组,每个链表已升序,把所有链表合并成一个升序链表。
输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
题目本质是小顶堆优先队列多路归并:把所有链表头节点放入小顶堆,每次取出最小节点拼接到结果,该节点有后继就把后继入堆。现实类比是「K 路归并」——K 组有序播放列表,每次从各组当前队首选最小歌曲播放,播完把下一首加入候选池(优先队列)。
难点与易错点
- 空数组 / 全空链表的边界:
lists.length == 0要返回 null;只把head != null的节点入堆,别把空头塞进去。 - 比较器必须按节点值排序:
PriorityQueue默认不可直接比较ListNode,要传(a, b) -> a.val - b.val;用 lambda 比实现 Comparable 简洁。 - 取出节点后要把它后继入堆:
if (smallest.next != null) minHeap.offer(smallest.next),漏掉会让每条链表只贡献一个节点。 - 理解「为什么堆里最多 k 个元素」:因为每次 poll 一个、至多 offer 一个,堆大小恒 ≤ k,这是 O(k) 空间和 O(log k) 每次操作的关键。
解法一:两两顺序合并
用 21 题的 mergeTwoLists 逐个合并:res = merge(res, lists[i])。
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) return null;
ListNode res = null;
for (ListNode list : lists) {
res = mergeTwoLists(res, list); // 复用 21 题
}
return res;
}
private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) { p.next = l1; l1 = l1.next; }
else { p.next = l2; l2 = l2.next; }
p = p.next;
}
p.next = (l1 != null) ? l1 : l2;
return dummy.next;
}
}
复杂度:第 i 次合并时结果长度约 i * (n/k),总时间 ∑ i·(n/k) ≈ O(n·k);空间 O(1)。
瓶颈:O(n·k) 时间——前几轮反复扫描前面已合并的大链表,k 大时明显慢。
解法二:小顶堆优先队列(最优)
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists.length == 0) return null;
// 小顶堆:按节点值升序排列
PriorityQueue<ListNode> minHeap = new PriorityQueue<>(
lists.length, (a, b) -> a.val - b.val
);
// 将所有非 null 的链表头节点加入小顶堆
for (ListNode head : lists) {
if (head != null) {
minHeap.offer(head);
}
}
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
while (!minHeap.isEmpty()) {
ListNode smallest = minHeap.poll(); // 取出当前最小节点
p.next = smallest;
p = p.next;
// 若该节点还有后继,将后继入堆继续参与比较
if (smallest.next != null) {
minHeap.offer(smallest.next);
}
}
return dummy.next;
}
}
> **💭 思考**:合并 K 个有序链表为什么用「小顶堆」,而不是两两顺序合并?一步步想——两两顺序合并的瓶颈是「每次找当前最小值都要在所有 k 个链表头里扫一遍」,第 i 次合并时反复扫描前面已合并的大链表,总时间 O(n·k)。而「每次从 k 个头里取最小」正是优先队列的典型场景:堆里始终只放 k 个候选头,取出最小后把它的后继入堆,每次取最小从 O(k) 降到 O(log k),整体 O(n log k)。看到「多路归并、反复取最小」,就该想到小顶堆。
复杂度推导:设总节点数 n = 所有链表长度之和。每个节点入堆一次、出堆一次,共 2n 次堆操作;堆中元素数量始终 ≤ k(poll 一个最多 offer 一个),每次堆操作 O(log k);总时间 = 2n × O(log k) = O(n log k)。空间上堆里最多同时存 k 个节点 → O(k)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 两两顺序合并 | O(n·k) | O(1) | k 很小时简单可行 |
| 分治归并 | O(n log k) | O(log k) | k 大且不想用堆时 |
| 小顶堆优先队列 | O(n log k) | O(k) | 面试标准解,最通用 |
这题约束下(k 可能很大)优先队列把「每次找最小值」从 O(k) 降到 O(log k),是 k 路归并的经典答案;分治归并(两两配对合并)也是 O(n log k),可作补充。
CodeTop 变体
- 用分治(两两配对)实现 O(n log k):每次把 k 个链表两两合并,k 减半,共 log k 轮,每轮 O(n),腾讯、美团追问过。
- 不借助堆,k 路归并求前 m 小:快手考过,用「每路一个指针 + 反复扫描取最小」或堆,本质同源。
- 合并 K 个有序数组(而非链表):字节考过,把「节点入堆」换成「(值, 数组下标, 元素下标) 三元组入堆」,逻辑一致。
146. LRU 缓存
题意
设计并实现 LRU(最近最少使用)缓存:get(key) O(1) 返回值,不存在返回 -1;put(key, value) O(1) 插入/更新,容量满时逐出最久未使用的 key。
输入:
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出:
[null, null, null, 1, null, -1, null, -1, 3, 4]
题目本质是哈希表 + 双向链表:哈希表提供 O(1) key 查询,双向链表维护访问顺序(最近访问移到头部,最久未使用在尾部,满时淘汰尾部)。现实类比是「图书馆最受欢迎书架」——书架容量有限,记录每本书最近借出顺序,借出就把它移到最前面,架子满了就把最后面(最久没人借)的书撤走。
说明:LeetCode 上本题类名固定为
LRUCache(构造器签名LRUCache(int capacity)),其余 13 题类名统一为Solution。这里沿用官方签名。
难点与易错点
- 必须用「哈希表 + 双向链表」而不是单向链表:单链表删除中间节点需要遍历找前驱,做不到 O(1);双向链表 + 哈希表定位才能 O(1) 摘除、O(1) 插入头部。
- 哨兵节点形成循环链表,避免判空:
dummy.pre恒为最久未使用、dummy.next恒为最近使用,头尾插入/删除无需判 null,这是写得又短又稳的关键。 - put 已存在的 key 要「更新值 + 移到头部」:只更新值不移动顺序会导致淘汰顺序错误。
- 淘汰时 map 和链表要同步删:
map.remove(lru.key)和removeNode(lru)缺一不可,否则 map 里残留脏数据,get 会返回已删除节点。 getNode里先判存在再摘除:containsKey为 false 直接返回 null,别对不存在的 key 做链表操作。
解法一:LinkedHashMap(Java 内置,直观)
Java 的 LinkedHashMap 在构造时指定 accessOrder=true 即可按访问顺序维护,removeEldestEntry 重写实现容量淘汰。
import java.util.LinkedHashMap;
import java.util.Map;
class LRUCache extends LinkedHashMap<Integer, Integer> {
private final int capacity;
public LRUCache(int capacity) {
// accessOrder = true:按访问顺序(最近访问移到末尾)
super(capacity, 0.75f, true);
this.capacity = capacity;
}
public int get(int key) {
return super.getOrDefault(key, -1);
}
public void put(int key, int value) {
super.put(key, value);
}
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity; // 超过容量淘汰最老元素
}
}
复杂度:get/put 均 O(1)(LinkedHashMap 内部就是哈希表 + 双向链表)。空间 O(capacity)。
瓶颈:这是「调库」,面试官不会满意——要的是手写哈希表 + 双向链表证明你懂底层。
解法二:哈希表 + 双向链表(手撕,最优)
class LRUCache {
private final int capacity;
// 哨兵节点:dummy.next 是最近使用,dummy.pre 是最久未使用
private final Node dummy = new Node(0, 0);
private final Map<Integer, Node> map = new HashMap<>();
// 双向链表节点
private class Node {
int key, value;
Node pre, next;
Node(int key, int value) {
this.key = key;
this.value = value;
}
}
public LRUCache(int capacity) {
this.capacity = capacity;
// 初始化循环双向链表(哨兵节点自环)
dummy.pre = dummy;
dummy.next = dummy;
}
public int get(int key) {
Node node = getNode(key);
return node == null ? -1 : node.value;
}
public void put(int key, int value) {
Node node = getNode(key);
if (node != null) {
node.value = value; // 更新值(getNode 中已移到头部)
return;
}
// 新节点:插入到链表头部
node = new Node(key, value);
map.put(key, node);
insertToFront(node);
// 超过容量:逐出最久未使用节点(dummy.pre)
if (map.size() > capacity) {
Node lru = dummy.pre;
map.remove(lru.key);
removeNode(lru);
}
}
// 从 map 获取节点,并移到链表头部(标记为最近使用)
private Node getNode(int key) {
if (!map.containsKey(key)) return null;
Node node = map.get(key);
removeNode(node); // 从当前位置摘除
insertToFront(node); // 插到链表头部
return node;
}
// 从双向链表中摘除节点
private void removeNode(Node node) {
node.pre.next = node.next;
node.next.pre = node.pre;
}
// 将节点插入到链表头部(dummy.next 之前)
private void insertToFront(Node node) {
node.pre = dummy;
node.next = dummy.next;
node.pre.next = node;
node.next.pre = node;
}
}
> **💭 思考**:为什么 LRU 必须「哈希表 + 双向链表」,缺一个都不行?一步步想——get/put 都要 O(1),key 的定位靠哈希表 O(1) 查找;但「淘汰最久未使用」需要维护一个访问顺序,且插入/删除要 O(1)。单向链表删除中间节点要先遍历找前驱,是 O(n),做不到;只有双向链表能 O(1) 摘除任意节点,配合哈希表 O(1) 定位,才能同时满足「O(1) 查询 + O(1) 移动」。哈希表管「在哪」、双向链表管「顺序」,两者组合是这题唯一解。
复杂度推导:get = HashMap 定位 O(1) + 双向链表摘除 O(1) + 插入头部 O(1) = O(1);put 的插入、更新、淘汰各步都是 O(1) 的 map 操作 + 链表操作 = O(1)。空间:map + 链表最多存 capacity 个节点 → O(capacity)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| LinkedHashMap | O(1) | O(capacity) | 快速实现,说明思路 |
| 哈希表 + 双向链表 | O(1) | O(capacity) | 面试标准手撕,考底层实现 |
这题是「哈希表提供 O(1) 定位 + 双向链表提供 O(1) 移动」组合拳的教科书案例,字节、腾讯、阿里、美团几乎逢面必考,必须能独立盲写并解释每个字段。
CodeTop 变体
- LFU 缓存(460,最不经常使用):淘汰策略从「最近」换成「频率」,需要「哈希表 + 有序集合/频率桶」,是 LRU 的升级,字节、快手高频。
- 要求 O(1) 且线程安全的并发缓存:美团追问过,把 HashMap 换
ConcurrentHashMap+ 锁,或讨论 Guava Cache 的分段锁设计。 - 手写带过期时间的缓存(TTL):腾讯考过,在 Node 里加时间戳,get 时惰性过期,逻辑在 LRU 之上叠加。
以上就是 Hot 100 链表篇的全部 14 题。整类题目可以浓缩成一句话:dummy 管边界、快慢指针管定位、三指针管反转,剩下的合并、相加、复制、排序、缓存,都是这三个模板的组合与放大。手撕前把 206(反转)、141/142(快慢指针)、21(归并)、146(哈希 + 双链)这五道练到盲写,其余九题自然水到渠成。
章末提问
链表篇的追问多集中在「为什么选这个解法 / 复杂度怎么推 / 边界怎么抠」这类思考型问题。下面是最常考的 5 个角度(结论先行,因为跟着原因):
-
「为什么链表题这么多都挂一个 dummy 节点?不挂行不行?」 回答思路:结论是挂 dummy 能用统一逻辑消除「改头节点」的边界特判,因为删除、插入、交换、翻转都可能改动头节点,不挂就得对 head 单独写 if,挂了之后一律返回
dummy.next,代码更短且更不容易错。 -
「反转链表递归和迭代你都会,面试时到底选哪个?」 回答思路:结论是默认迭代三指针,因为它是 O(1) 空间、不会栈溢出,而且会被 92、25 反复复用;递归只在对方明确要求或想展示「链表递归心智模型」时才写,且要主动补一句「递归栈是 O(n) 空间」。
-
「快慢指针的复杂度你怎么推到 O(n)?」 回答思路:结论是无环时 fast 走 n 步到 null;有环时 slow 入环前最多走 n 步、入环后 fast 与 slow 距离不超过环长 L、相对速度是 1,最多再走 L 步就追上,因为 L ≤ n,所以总步数仍 ≤ 2n,即 O(n)。
-
「21、23、148 这些合并、排序题,为什么迭代 + dummy 比递归更被认可?」 回答思路:结论是递归栈会占 O(n) 或 O(log n) 空间,而迭代 + dummy 只用常数个指针、空间 O(1),因为合并类题目的本质是顺序扫描 + 拼接,用循环就能表达,并不需要递归的回溯能力。
-
「LRU 为什么必须是哈希表 + 双向链表,用单向链表差在哪?」 回答思路:结论是单链表删除中间节点要 O(n) 找前驱、做不到 O(1),因为 LRU 要求 get 和 put 都 O(1),只有双向链表能 O(1) 摘除节点、哈希表能 O(1) 定位节点,两者组合才能同时满足。