链表 阶段算法总结

目录

一、先把链表想清楚

[1. 链表是什么](#1. 链表是什么)

[2. 为什么链表不能像数组一样按下标访问](#2. 为什么链表不能像数组一样按下标访问)

[3. LeetCode 中的 ListNode](#3. LeetCode 中的 ListNode)

二、链表题的五个常见动作

[1. dummy:虚拟头结点](#1. dummy:虚拟头结点)

[2. 快慢指针](#2. 快慢指针)

[3. 断开与连接](#3. 断开与连接)

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

[5. 尾插、头插与反转](#5. 尾插、头插与反转)

三、链表固定模板

[模板 1:创建虚拟头结点](#模板 1:创建虚拟头结点)

[模板 2:遍历链表](#模板 2:遍历链表)

[模板 3:迭代反转整条链表](#模板 3:迭代反转整条链表)

[模板 4:合并两个升序链表](#模板 4:合并两个升序链表)

[模板 5:每 k 个节点分组反转](#模板 5:每 k 个节点分组反转)

[四、2. 两数相加(medium)](#四、2. 两数相加(medium))

[五、24. 两两交换链表中的节点(medium)](#五、24. 两两交换链表中的节点(medium))

[六、143. 重排链表(medium)](#六、143. 重排链表(medium))​​​​​​​

[七、23. 合并 K 个升序链表(hard)](#七、23. 合并 K 个升序链表(hard))

[八、25. K 个一组翻转链表(hard)](#八、25. K 个一组翻转链表(hard))

九、五题共同规律

[1. 五题都在做"节点重连",不是数组下标操作](#1. 五题都在做“节点重连”,不是数组下标操作)

[2. dummy 让开头统一](#2. dummy 让开头统一)

[3. 反转前先保存 next](#3. 反转前先保存 next)

[4. "断开"与"连接"是成对出现的](#4. “断开”与“连接”是成对出现的)

[5. 有序性决定选择策略](#5. 有序性决定选择策略)

[6. 递归和迭代的取舍](#6. 递归和迭代的取舍)

[十、Java 常用 API 与语句](#十、Java 常用 API 与语句)​​​​​​​

十一、复杂度对照

十二、看到题目后的判断顺序

十三、最后的阶段性速记

这是一份围绕五道代表题整理的学习阶段总结:2. 两数相加、24. 两两交换链表中的节点、143. 重排链表、23. 合并 K 个升序链表、25. K 个一组翻转链表。全文只写 Java,代码采用 LeetCode 的 Solution 提交形式。

代码依据现有 Java 代码页中的结构、标准方法名和主要变量整理。需要特别说明:原材料中的代码页存在分页、字体编码和换行问题,文本层提取并非可靠的逐字稿;因此下面保留了可辨认的标准方法名、变量名和整体写法,但对排版断裂处做了必要的 Java 格式化与少量注释补全,不声称代码逐字提取


一、先把链表想清楚

1. 链表是什么

链表是一串由节点连接起来的数据结构。以最常见的单链表为例,每个节点包含两部分:

java 复制代码
class ListNode {
    int val;          // 当前节点保存的数值
    ListNode next;    // 指向下一个节点的引用
}

如果节点依次保存 1、2、3,结构可以画成:

复制代码
head
 ↓
[1 | next] -> [2 | next] -> [3 | null]

这里的箭头不是数值,而是"引用关系":第一个节点的 next 指向第二个节点,最后一个节点的 nextnull,表示后面没有节点。

2. 为什么链表不能像数组一样按下标访问

数组的元素通常连续存放,知道起始地址后,可以通过"起始地址 + 下标 × 元素大小"直接跳到 nums[i]。所以数组按下标读取通常是 O(1)

链表的节点不要求连续存放。head 只知道第一个节点;想找第 4 个节点,必须依次读取:

复制代码
head -> 第 1 个 -> 第 2 个 -> 第 3 个 -> 第 4 个

因此:

  • 找链表中的第 i 个节点通常需要 O(i),整体可记为 O(n)
  • 链表不能写 head[3],也不能写 head.get(3) 来访问节点。
  • 访问下一个节点只能写 node.next
  • 链表在已知插入位置时,改变几个 next 就能完成插入或删除,不需要像数组那样整体搬移元素。

"链表题"真正要处理的通常不是节点值,而是节点之间的 next 关系。题目若说不能修改节点内部的值,就意味着必须通过改指针完成顺序变化。

3. LeetCode 中的 ListNode

平台一般已经提供 ListNode,提交时不需要再次定义。为了让代码能够独立阅读,下面先列出常见结构:

java 复制代码
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;
    }
}

在下面的题解代码中,假定平台已经提供这个结构;如果在自己的 Java 文件中运行,可以把它放在 Solution 外面。this.val = val 的意思是:把构造方法收到的参数 val 保存到当前对象的成员变量 val 中。


二、链表题的五个常见动作

1. dummy:虚拟头结点

dummynewHeadret 都经常被用作一个新建的辅助节点。它不属于真正的答案,只是放在真正头结点之前:

复制代码
dummy -> head -> ...

它的中文含义是虚拟头结点 ,不是"假的答案节点"。虚拟头结点的 next 才是最终链表头。

为什么有用?如果没有它,删除头节点、交换第一对节点、合并时第一次插入都可能需要单独写特殊分支;有了它,所有节点都可以按"把节点接到某个节点后面"的统一逻辑处理。

常用固定写法:

java 复制代码
ListNode dummy = new ListNode(0);
ListNode prev = dummy;
// 处理过程中不断修改 prev.next
return dummy.next;

dummy 最后的值 0 通常没有意义,不会被返回;返回的是 dummy.next

2. 快慢指针

快慢指针不只用于数组,也常用于链表中点:

java 复制代码
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
    slow = slow.next;
    fast = fast.next.next;
}
  • slow 每轮走一步。
  • fast 每轮走两步。
  • fast 到末尾时,slow 大约在中间。

中点题一定要用小规模链表手算,确认 slow 落在哪个节点。不同的循环初始位置和条件,可能让中点落在中间两个节点中的不同一个。

3. 断开与连接

链表分段时,不能只把某个指针移到后面就算完成;还要修改原链表中的 next

java 复制代码
ListNode cur = slow.next;
slow.next = null;

slow.next = null 把前半段和后半段真正断开。连接时则是:

java 复制代码
prev.next = cur;

链表操作的核心可以概括成:先保存将要失去的路线,再改 next,最后把各段重新接好

4. 递归与迭代

  • 迭代 :使用 whilefor 和若干指针逐步处理。链表题更容易画图跟踪,额外栈空间通常为 O(1)
  • 递归 :把大问题拆成小问题,函数返回小问题的链表结果,再连接起来。代码可能短,但每次递归调用都占用调用栈,深度为 O(log k)O(n),要留意边界。

本板块中,23 题展示了堆和递归/分治两种方向;其余题以迭代为主。

5. 尾插、头插与反转

尾插:把新节点接到答案尾部。

java 复制代码
prev.next = node;
prev = prev.next;

头插:把当前节点插到一段链表的最前面。

java 复制代码
ListNode next = cur.next;  // 先保存后继
cur.next = head2.next;     // 当前节点指向原来的第一个节点
head2.next = cur;          // 辅助头指向当前节点
cur = next;                // 继续处理原来的后继

不断头插会把顺序反过来。反转时最容易丢失后继,所以通常第一句就是保存 next


三、链表固定模板

下面这些模板不是要机械背诵,而是帮助把题目中的动作翻译成指针操作。

模板 1:创建虚拟头结点

java 复制代码
ListNode newHead = new ListNode(0);
ListNode prev = newHead;

newHead 是辅助起点,prev 是当前答案尾部。最后返回 newHead.next

模板 2:遍历链表

java 复制代码
ListNode cur = head;
while (cur != null) {
    // 使用 cur.val
    cur = cur.next;
}

cur 从头节点开始,每次沿 next 向后走一个节点。若写成 while (cur.next != null),循环体内就不会处理最后一个节点,必须确认这是否符合题意。

模板 3:迭代反转整条链表

java 复制代码
ListNode prev = null;
ListNode cur = head;
while (cur != null) {
    ListNode next = cur.next;
    cur.next = prev;
    prev = cur;
    cur = next;
}
return prev;

每一轮完成四件事:保存后继、反转当前箭头、推进 prev、推进 cur。循环结束时 prev 是新头。

模板 4:合并两个升序链表

java 复制代码
ListNode head = new ListNode(0);
ListNode prev = head;
while (cur1 != null && cur2 != null) {
    if (cur1.val <= cur2.val) {
        prev.next = cur1;
        prev = cur1;
        cur1 = cur1.next;
    } else {
        prev.next = cur2;
        prev = cur2;
        cur2 = cur2.next;
    }
}
if (cur1 != null) prev.next = cur1;
if (cur2 != null) prev.next = cur2;
return head.next;

两个链表都升序时,当前尚未合并的最小节点一定在 cur1cur2 中,比较这两个节点即可。一个链表耗尽后,另一个链表剩余部分已经有序,可以整体接上。

模板 5:每 k 个节点分组反转

java 复制代码
int n = 0;
ListNode cur = head;
while (cur != null) {
    cur = cur.next;
    n++;
}
n /= k;

ListNode newHead = new ListNode(0);
ListNode prev = newHead;
cur = head;
for (int i = 0; i < n; i++) {
    ListNode tmp = cur;             // 本组反转前的头,反转后成为本组尾
    for (int j = 0; j < k; j++) {
        ListNode next = cur.next;
        cur.next = prev.next;
        prev.next = cur;
        cur = next;
    }
    prev = tmp;
}
prev.next = cur;                    // 剩余不足 k 个的部分保持原样
return newHead.next;

这是本板块 Java 代码中保留的"先数完整组,再重复头插"的写法。理解它的关键是:prev 指向已处理部分的尾;每组开始时的 tmp 在本组反转完成后变成尾巴,下一组要接在它后面。


四、2. 两数相加(medium)

1. 一句话​​​​​​​题目描述

两个链表把两个非负整数按个位在前的逆序形式保存,需要逐位相加并返回同样逆序存储的和。

  • 官方题目链接:2. 两数相加
  • 链表代码中的标准方法名:addTwoNumbers
  • 例子:[2,4,3] 表示 342[5,6,4] 表示 465,结果是 [7,0,8],表示 807

2. 核心难点

  1. 链表是逆序存储,正好允许从头节点开始模拟小学竖式加法。
  2. 两条链表可能长度不同,较短链表走完后仍要继续处理较长链表。
  3. 每一位相加可能产生进位,最后还可能多出一个最高位节点。
  4. 结果链表从第一位开始不断生成,使用虚拟头结点可以避免第一次插入的特殊处理。

3. 算法思路:从链表结构出发

设:

复制代码
l1: 2 -> 4 -> 3       表示 342
l2: 5 -> 6 -> 4       表示 465

从头开始处理:

  1. 2 + 5 + 0 = 7,结果节点为 7,新的进位为 0
  2. 4 + 6 + 0 = 10,结果节点保存个位 0,进位为 1
  3. 3 + 4 + 1 = 8,结果节点为 8,进位回到 0
  4. 两个链表和进位都处理完,返回 7 -> 0 -> 8

每次让 t 表示"当前这一位相加后、还没有拆开的总和"。

复制代码
当前位 = t % 10
下一轮进位 = t / 10

循环条件必须包括 t != 0,这样 9 + 1 之类在链表末尾产生的最高位不会被漏掉。

4. 节点与指针含义

  • l1l2:传入的两条链表头节点。
  • cur1:当前正在读取的第一条链表节点。
  • cur2:当前正在读取的第二条链表节点。
  • newHead:结果链表的虚拟头结点。
  • prev:结果链表当前尾指针,下一次新节点接在 prev.next
  • t:当前位的加法总和,也承担进位记录。
  • t % 10:当前结果位。
  • t /= 10:把当前总和转换成下一轮要带的进位。

5. 简洁 Java 代码

平台已提供 ListNode 结构时,提交下面的 Solution 即可:

java 复制代码
class Solution {
    public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
        ListNode cur1 = l1, cur2 = l2;
        ListNode newHead = new ListNode(0); // 创建一个虚拟头结点,方便记录结果
        ListNode prev = newHead;            // 尾插操作的尾指针
        int t = 0;                          // 记录进位

        while (cur1 != null || cur2 != null || t != 0) {
            // 先加上第一个链表
            if (cur1 != null) {
                t += cur1.val;
                cur1 = cur1.next;
            }
            // 加上第二个链表
            if (cur2 != null) {
                t += cur2.val;
                cur2 = cur2.next;
            }

            prev.next = new ListNode(t % 10);
            prev = prev.next;
            t /= 10;
        }
        return newHead.next;
    }
}

6. 关键语句逐行解释

复制代码
ListNode cur1 = l1, cur2 = l2;

不直接移动参数 l1l2,而是用 cur1cur2 作为游标。这样变量含义更清楚:参数代表链表入口,cur 代表当前处理位置。

复制代码
ListNode newHead = new ListNode(0);
ListNode prev = newHead;

先造一个虚拟头。第一次生成结果节点时,不必判断结果链表是否为空;以后每次都执行同一套尾插逻辑。

复制代码
while (cur1 != null || cur2 != null || t != 0)

只要第一条未结束、第二条未结束、或者还有进位,就必须继续循环。三个条件是"或",不是"且"。

复制代码
t += cur1.val;
cur1 = cur1.next;

先读取当前节点值,再沿 next 移向下一位。不能先移动再读取,否则会跳过当前位。

复制代码
prev.next = new ListNode(t % 10);

new ListNode(...) 创建结果节点。% 10 只保留个位,例如 10 % 10 = 0

复制代码
prev = prev.next;
t /= 10;

尾指针前进到刚生成的节点;整数除法 /= 10 丢掉当前个位,留下进位,例如 10 / 10 = 1

复制代码
return newHead.next;

虚拟头不属于答案,真正的第一位在 newHead.next

7. Java 函数、变量与语法中文含义

名称 中文含义
addTwoNumbers add two numbers,两个数相加
ListNode 链表节点类型
l1l2 list 1list 2,两条输入链表
cur1cur2 current 1/current 2,当前节点
newHead 新的头结点,这里是虚拟头
prev previous,前一个节点;这里也充当结果尾指针
t 临时加法总和,同时保存进位
val 节点保存的值
next 下一个节点的引用
new 创建对象
% 取余,t % 10 取得个位
/= 除后赋值,t /= 10 等于 t = t / 10

8. 时间和空间复杂度

设两条链表长度分别为 mn

  • 时间复杂度:O(max(m, n))
  • 结果节点空间:O(max(m, n)),因为需要生成答案链表。
  • 除返回结果所需节点外的额外指针空间:O(1)

9. 手算示例

示例 A:产生进位并多出最高位

复制代码
l1 = [9,9,9]
l2 = [1]
轮次 cur1 cur2 t 相加后 t 新节点 t % 10 下一进位 t / 10
1 9 1 0 10 0 1
2 9 1 10 0 1
3 9 1 10 0 1
4 1 1 1 0

返回 [0,0,0,1],表示 1000

示例 B:长度不相同

复制代码
l1 = [2,4,3],l2 = [5,6]

依次是 2+5=74+6=103+1=4,结果为 [7,0,4]

10. 易错点

  1. 把逆序链表 [2,4,3] 当成 243,实际它表示 342
  2. 循环条件写成 cur1 != null && cur2 != null,会漏掉较长链表和最后进位。
  3. 加完当前节点后忘记 cur1 = cur1.nextcur2 = cur2.next,造成死循环。
  4. 只把 t % 10 放入结果,却忘记 t /= 10,进位不会正确传到下一位。
  5. 最后返回 newHead,把虚拟节点的 0 也返回了;正确是 newHead.next
  6. 题目要求实际操作节点时,本题是新建结果节点,不要误以为可以只修改输入节点的值来代替逐位结果。

11. 下次遇到类似题的判断依据

看到"链表按位存储数字""个位在前""逐位相加"时,可以直接联想到:

  1. 两个游标分别走两条链表。
  2. 用一个整数保存进位。
  3. 循环条件包含"任一链表未结束或仍有进位"。
  4. 结果从低位到高位生成,天然适合尾插。
  5. 结果需要不断追加时,优先考虑虚拟头结点。

五、24. 两两交换链表中的节点(medium)

1. 一句话题目描述

把链表中相邻的两个节点交换位置,按 1、22、1,再处理下一对;不能修改节点值,只能修改节点连接关系。

2. 核心难点

一对节点不是孤立的,交换时还要保持前一段和后一段连接。交换:

复制代码
prev -> cur -> next -> nnext

之后应变成:

复制代码
prev -> next -> cur -> nnext

如果没有先保存 nnext,改动 next.nextcur.next 后,后面的路线可能丢失。还要注意交换完成后,下一轮的 prev 应该落在刚交换完的第二个节点 cur 上。

3. 算法思路:从链表结构出发

  1. 空链表或只有一个节点时,没有完整的一对可交换,直接返回 head
  2. 建立 newHead -> head,这样第一对节点也有统一的前驱。
  3. cur 指向一对节点的第一个,next 指向第二个,nnext 指向第二对的第一个。
  4. 按三句连接代码完成交换:
    • prev.next = next
    • next.next = cur
    • cur.next = nnext
  5. 更新指针,继续处理下一对。
  6. 若最后剩一个节点,循环条件不满足,它自然保持原位置。

4. 节点与指针含义

  • newHead:虚拟头结点,保证第一对节点也有 prev
  • prev:当前待交换一对节点之前的节点。
  • cur:当前一对中的第一个节点。
  • next:当前一对中的第二个节点;交换后成为这一对的头。
  • nnext:当前第二个节点后面的节点,用于接回未处理部分。

这里变量名 next 容易和成员变量 cur.next 混淆:next 是一个局部变量,表示当前第二个节点;cur.next 是节点 cur 的后继引用。

5. 简洁 Java 代码

复制代码
class Solution {
    public ListNode swapPairs(ListNode head) {
        if (head == null || head.next == null) return head;

        ListNode newHead = new ListNode(0);
        newHead.next = head;
        ListNode prev = newHead;
        ListNode cur = prev.next;
        ListNode next = cur.next;
        ListNode nnext = next.next;

        while (cur != null && next != null) {
            // 交换节点
            prev.next = next;
            next.next = cur;
            cur.next = nnext;

            // 修改指针
            prev = cur; // 注意顺序
            cur = nnext;
            if (cur != null) next = cur.next;
            if (next != null) nnext = next.next;
        }
        return newHead.next;
    }
}

6. 关键语句逐行解释

复制代码
if (head == null || head.next == null) return head;

没有节点或只有一个节点,不能组成相邻的一对。提前返回也避免后面读取 next.next 时空指针。

复制代码
newHead.next = head;

把虚拟头接到原头前面。此时初始关系为 newHead -> 1 -> 2

复制代码
ListNode prev = newHead, cur = prev.next, next = cur.next, nnext = next.next;

一次把局部窗口中的四个位置保存下来:前驱、第一节点、第二节点、后继。这样下面改箭头时不会找不到后续节点。

复制代码
prev.next = next;
next.next = cur;
cur.next = nnext;

这是交换的核心。三句执行后,四个节点的连接从 prev -> cur -> next -> nnext 变成 prev -> next -> cur -> nnext

复制代码
prev = cur;

交换后,cur 已经是这对节点的后一个;它正好是下一对节点的前驱。注释中的"注意顺序"很重要,不能先把 prev 改成 next

复制代码
cur = nnext;

把游标移到尚未处理部分的第一个节点。

复制代码
if (cur != null) next = cur.next;
if (next != null) nnext = next.next;

只有确认引用非空后才访问 .next,防止最后一对之后发生空指针异常。

7. Java 函数、变量与语法中文含义

名称 中文含义
swapPairs swap pairs,交换成对节点
head 链表头节点
newHead 新头、虚拟头
prev previous,当前对之前的节点
cur current,当前第一个节点
next 当前第二个节点;英文是下一个
nnext next next,第二个节点后面的节点
head.next 头节点指向的下一个节点
== null 判断引用为空
&& 并且;两边都为真才为真
`

8. 时间和空间复杂度

  • 每个节点最多被访问和改指针常数次。
  • 时间复杂度:O(n)
  • 额外空间复杂度:O(1),只用了若干引用;虚拟头是一个常数个辅助节点。

9. 手算示例

初始:

复制代码
newHead -> 1 -> 2 -> 3 -> 4 -> null
           cur  next  nnext

第一轮:

复制代码
prev.next = next   : newHead -> 2
next.next = cur    : 2 -> 1
cur.next = nnext   : 1 -> 3

得到:

复制代码
newHead -> 2 -> 1 -> 3 -> 4

更新:prev = 1cur = 3next = 4nnext = null

第二轮得到:

复制代码
newHead -> 2 -> 1 -> 4 -> 3 -> null

最后返回 newHead.next,即节点 2

10. 易错点

  1. 只交换 cur.valnext.val。虽然输出可能看似正确,但题目要求交换节点,且明确不能改值。
  2. 先执行 cur.next = next 等错误连接,形成环或丢失后继。
  3. 忘记虚拟头,第一对交换后没有办法更新真正的头节点。
  4. prev = next,导致下一轮前驱位置错误;交换后前驱应是 cur
  5. 最后只有一个节点时仍访问 next.next,导致空指针。
  6. 更新 cur 后没有重新计算 nextnnext

11. 下次遇到类似题的判断依据

看到"相邻节点成对交换""只能改指针"时,先画出:

复制代码
前驱 -> 第一个 -> 第二个 -> 后继

然后固定思考三根新箭头:

复制代码
前驱 -> 第二个 -> 第一个 -> 后继

只要每次先保存后继,并用虚拟头统一处理开头,题目就从"交换整条链表"变成"重复处理一个四节点窗口"。


六、143. 重排链表(medium)

1. 一句话题目描述

L0 -> L1 -> ... -> Ln-1 -> Ln 重排为 L0 -> Ln -> L1 -> Ln-1 -> L2 -> Ln-2 -> ...,实际改变节点连接而不是只修改节点值。

  • 官方题目链接:143. 重排链表
  • 标准方法名:reorderList
  • 方法返回类型是 void:它直接在原链表上重排,调用结束后 head 所在链表就是答案。

2. 核心难点

这道题把三个经典链表动作串在一起:

  1. 找中间节点:快慢指针。
  2. 反转后半段:头插法。
  3. 交替合并前半段和反转后的后半段:双指针尾插。

最容易出错的地方是中点之后必须断开。如果不执行 slow.next = null,后半段和前半段仍连在一起,合并时可能出现环或重复走节点。

3. 算法思路:三段操作

假设:

复制代码
1 -> 2 -> 3 -> 4 -> 5

第一步:找中间节点

复制代码
slow 最终在 3 附近

代码中从 slow = headfast = head 开始,循环结束后 slow 落在中间位置。取 slow.next 作为后半段开头。

第二步:断开并反转后半段

先保存:

复制代码
ListNode cur = slow.next;
slow.next = null;

前半段为:

复制代码
1 -> 2 -> 3 -> null

后半段为:

复制代码
4 -> 5 -> null

头插反转后得到:

复制代码
5 -> 4 -> null

第三步:交替合并

分别用 cur1cur2 指向两段:

复制代码
cur1: 1 -> 2 -> 3
cur2: 5 -> 4

依次接入 1、5、2、4、3,得到目标结果。

4. 节点与指针含义

  • head:原链表头,也是重排后第一个节点。
  • slow:慢指针,寻找中点。
  • fast:快指针,每轮前进两步。
  • head2:反转后半段使用的虚拟头结点。
  • cur:反转阶段当前待处理节点。
  • next:提前保存的后继,防止头插改指针后失去路线。
  • cur1:第一段当前节点。
  • cur2:反转后第二段当前节点。
  • ret:合并阶段虚拟头结点。
  • prev:合并结果的尾指针。

5. 简洁 Java 代码

下面保留了代码中的 head2cur1cur2retprev 等写法。ret 只是合并辅助起点;由于合并节点本来就是 head 中的节点,最终不需要从 ret 返回新头。

复制代码
class Solution {
    public void reorderList(ListNode head) {
        // 处理边界情况
        if (head == null || head.next == null || head.next.next == null) return;

        // 1. 找链表的中间节点 - 快慢双指针
        ListNode slow = head, fast = head;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }

        // 2. 把 slow 后面的部分逆序 - 头插法
        ListNode head2 = new ListNode(0);
        ListNode cur = slow.next;
        slow.next = null; // 把两个链表分离
        while (cur != null) {
            ListNode next = cur.next;
            cur.next = head2.next;
            head2.next = cur;
            cur = next;
        }

        // 3. 合并两个链表 - 双指针
        ListNode cur1 = head, cur2 = head2.next;
        ListNode ret = new ListNode(0);
        ListNode prev = ret;
        while (cur1 != null) {
            // 先放第一个链表
            prev.next = cur1;
            prev = cur1;
            cur1 = cur1.next;

            // 再合并第二个链表
            if (cur2 != null) {
                prev.next = cur2;
                prev = cur2;
                cur2 = cur2.next;
            }
        }
    }
}

6. 关键语句逐行解释

复制代码
if (head == null || head.next == null || head.next.next == null) return;

长度为 0、1、2 时,重排后顺序不需要复杂处理或结果与原结构等价。提前返回让后面安全地读取中点和后半段。

复制代码
while (fast != null && fast.next != null)

只有快指针和它的后继都存在时,才能执行 fast.next.next。每轮 slow 走一步,fast 走两步。

复制代码
ListNode cur = slow.next;
slow.next = null;

先把后半段入口保存到 cur,再断开前半段尾节点的 next。这两句顺序不能颠倒,否则后半段入口会丢失。

复制代码
ListNode next = cur.next;

头插前先保存后继。接下来 cur.next 会被改写,若不保存,原来的后半段路线就无法继续遍历。

复制代码
cur.next = head2.next;
head2.next = cur;

这两句把 cur 插入到 head2 后面。连续处理 4、5 时,先插入 4,再把 5 插到 4 前面,所以结果是 5 -> 4

复制代码
prev.next = cur1;
prev = cur1;
cur1 = cur1.next;

把第一段当前节点接到答案尾部,然后保存它为新的尾,再把第一段游标推进。

复制代码
if (cur2 != null) { ... }

第一段可能比第二段多一个节点,所以第二段的接入要单独判断;第一段耗尽时,目标顺序已经完成。

7. Java 函数、变量与语法中文含义

名称 中文含义
reorderList reorder list,重新排列链表
slow 慢指针
fast 快指针
head2 第二段链表的虚拟头
cur current,反转阶段当前节点
next 当前节点后继
cur1cur2 两段链表的当前节点
ret return/result 的常见缩写,合并辅助头
prev 合并结果尾指针
void 方法不返回对象,直接修改原链表

8. 时间和空间复杂度

  • 找中点、反转、合并都只线性扫描,时间复杂度:O(n)
  • 额外指针空间:O(1)
  • 代码中创建了常数个虚拟节点,仍属于 O(1) 额外空间。
  • 没有使用数组或 ArrayList 保存所有节点,因此不会因为存储节点副本而增加 O(n) 空间。

9. 手算示例

偶数个节点:[1,2,3,4]

找中点后可以分成:

复制代码
第一段:1 -> 2
第二段:3 -> 4

反转第二段:

复制代码
4 -> 3

交替合并:

复制代码
1 -> 4 -> 2 -> 3

奇数个节点:[1,2,3,4,5]

代码的 slow 会落在中间节点附近,取 slow.next 后,前半段包含中间节点:

复制代码
第一段:1 -> 2 -> 3
第二段:4 -> 5

反转并交替合并:

复制代码
1 -> 2 -> 3    与    5 -> 4
=> 1 -> 5 -> 2 -> 4 -> 3

10. 易错点

  1. 找中点后不写 slow.next = null,两段没有真正分开。
  2. 反转时没有先保存 next,导致后半段节点丢失。
  3. 误修改节点值,而不是改变 next 关系。
  4. 合并时只接第一段,忘记交替接第二段。
  5. 忘记判断 cur2 != null,在奇数长度链表中访问空指针。
  6. head2 本身当成答案节点;真正的后半段头是 head2.next
  7. 快慢指针循环条件写错,导致 fast.next.next 空指针或中点位置不符合后续分段。

11. 下次遇到类似题的判断依据

看到"头尾交替""前一个、最后一个、前两个、倒数第二个"时,链表不能按下标随机取末尾,优先拆成:

  1. 快慢指针找中间。
  2. 反转后半段,让原来的尾部变成可顺序遍历的头部。
  3. 两段交替合并。

这类"重排顺序"题常常不是一个新算法,而是找中点 + 反转 + 合并三个固定模块的组合。


七、23. 合并 K 个升序链表(hard)

1. 一句话题目描述

给定一个链表数组 lists,每条链表都已按升序排列,把所有链表合并成一条仍然升序的链表。

  • 官方题目链接:23. 合并 K 个升序链表
  • 标准方法名:mergeKLists
  • 代码中还使用了辅助方法:mergemergeTwoList
  • 例子:[1,4,5][1,3,4][2,6] 合并为 [1,1,2,3,4,4,5,6]

2. 核心难点

合并两条升序链表时,只需比较两个当前头;合并 K 条时,每次要从 K 个当前候选头中找到最小值。

如果每次线性扫描所有 K 条链表,选择最小头的成本是 O(K)。使用小根堆可以把"找当前最小值"降为 O(log K)。另一种思路是分治,把链表两两合并,避免一个答案越来越长后反复和很多链表比较。

本题 Java 代码中明确给出了两种思路:

  1. 小根堆:PriorityQueue<ListNode>
  2. 递归/分治:merge 平分数组,mergeTwoList 合并两条链表。

3. 方法一:小根堆算法思路

第一步:把所有非空链表头放入堆

对于:

复制代码
1 -> 4 -> 5
1 -> 3 -> 4
2 -> 6

初始堆中只有 1、1、2 三个节点,而不是把所有节点都放进去。

第二步:取出堆顶最小节点

堆顶是当前所有候选节点中值最小的节点。把它接到结果尾部。

第三步:补入被取出节点的后继

假设取出第一条链表的 1,它的下一个节点是 4。这时第一条链表新的候选头变成 4,把 4 放入堆。

每条链表在任意时刻最多贡献一个候选头,因此堆大小最多为 K

第四步:重复到堆为空

堆为空说明所有链表节点都已经接入答案。

4. 小根堆 Java 代码

复制代码
import java.util.PriorityQueue;

class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        PriorityQueue<ListNode> heap =
                new PriorityQueue<>((v1, v2) -> v1.val - v2.val);

        // 将所有头结点加入到小根堆中
        for (ListNode l : lists) {
            if (l != null) heap.offer(l);
        }

        // 合并
        ListNode ret = new ListNode(0);
        ListNode prev = ret;
        while (!heap.isEmpty()) {
            ListNode t = heap.poll();
            prev.next = t;
            prev = t;
            if (t.next != null) heap.offer(t.next);
        }
        return ret.next;
    }
}

关键语句解释

复制代码
PriorityQueue<ListNode> heap =
        new PriorityQueue<>((v1, v2) -> v1.val - v2.val);

PriorityQueue 是 Java 的优先队列。默认最小元素优先;这里通过比较器告诉它:节点值小的优先,因此它充当小根堆。

比较器中的 (v1, v2) -> v1.val - v2.val 是 Lambda 写法:

  • 小于 0v1 排在 v2 前。
  • 等于 0:优先级相同。
  • 大于 0v2 排在前。

在数值可能接近 Integer.MAX_VALUEInteger.MIN_VALUE 的自定义场景中,v1.val - v2.val 可能整数溢出,更稳妥的写法是 Integer.compare(v1.val, v2.val);本题给定范围下原写法可正常工作。为了保留代码中的变量和写法,上面仍展示原有比较器形式。

复制代码
for (ListNode l : lists) {
    if (l != null) heap.offer(l);
}

增强 for 依次取出链表数组中的每条链表;空链表没有候选节点,不能把 null 放入 PriorityQueue,所以要判断 l != null

复制代码
ListNode t = heap.poll();

poll() 取出并删除当前最小节点。peek() 只能查看而不删除,本题要向后推进,所以必须使用 poll()

复制代码
prev.next = t;
prev = t;

将堆顶节点接到结果尾部,并让尾指针前进。

复制代码
if (t.next != null) heap.offer(t.next);

被取出的节点来自某条有序链表,它的后继是该链表新的候选头。把它放回堆,下一次就能继续参与全局最小值竞争。

小根堆中的节点与变量含义

  • lists:链表数组。
  • l:数组遍历到的某条链表头。
  • heap:小根堆/优先队列。
  • ret:结果虚拟头结点。
  • prev:结果尾指针。
  • t:本轮从堆中取出的最小节点。
  • offer:向优先队列加入元素。
  • poll:取出并删除队首元素。
  • isEmpty:判断队列是否为空。
  • Lambda 比较器:定义节点的优先级顺序。

小根堆复杂度

设所有链表节点总数为 N,链表数量为 K

  • 初始放入头节点:O(K log K),更准确地说只对非空链表执行。
  • 每个节点出堆、最多一个后继入堆:O(N log K)
  • 总时间复杂度:O(N log K)
  • 堆空间:O(K)
  • 结果使用原节点重新连接,除堆外没有为每个节点新建副本。

5. 方法二:递归/分治

为什么分治更均衡

若把链表依次合并:

复制代码
先合并第 1 条,再与第 2 条合并,再与第 3 条合并......

中间结果会越来越长,后面的链表需要和越来越长的答案比较。

分治则先让相近规模的链表两两合并,再合并合并结果:

复制代码
(0,1)、(2,3)、(4,5)、(6,7)
-> 两两结果继续两两合并
-> 最后合并成一个

递归树高度约为 log K,每一层总共处理全部 N 个节点,因此时间为 O(N log K)

分治步骤

  1. 如果 left > right,当前没有链表,返回 null
  2. 如果 left == right,当前区间只有一条链表,不需要合并,直接返回 lists[left]
  3. mid = (left + right) / 2,平分数组。
  4. 递归合并左半区和右半区。
  5. 调用 mergeTwoList 合并得到的两条升序链表。

分治 Java 代码

复制代码
class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        return merge(lists, 0, lists.length - 1);
    }

    public ListNode merge(ListNode[] lists, int left, int right) {
        if (left > right) return null;
        if (left == right) return lists[left];

        // 1. 平分数组
        int mid = (left + right) / 2;
        // [left, mid] [mid + 1, right]

        // 2. 递归处理左右两部分
        ListNode l1 = merge(lists, left, mid);
        ListNode l2 = merge(lists, mid + 1, right);

        // 3. 合并两个有序链表
        return mergeTwoList(l1, l2);
    }

    public ListNode mergeTwoList(ListNode l1, ListNode l2) {
        if (l1 == null) return l2;
        if (l2 == null) return l1;

        // 合并两个有序链表
        ListNode head = new ListNode(0);
        ListNode cur1 = l1, cur2 = l2, prev = head;

        while (cur1 != null && cur2 != null) {
            if (cur1.val <= cur2.val) {
                prev.next = cur1;
                prev = cur1;
                cur1 = cur1.next;
            } else {
                prev.next = cur2;
                prev = cur2;
                cur2 = cur2.next;
            }
        }
        if (cur1 != null) prev.next = cur1;
        if (cur2 != null) prev.next = cur2;
        return head.next;
    }
}

分治代码关键语句解释

复制代码
return merge(lists, 0, lists.length - 1);

从链表数组的完整下标区间开始递归。若 lists.length == 0,右端点是 -1,会触发 left > right,安全返回 null

复制代码
if (left == right) return lists[left];

递归出口:一条已经有序的链表不需要自己和自己合并。

复制代码
ListNode l1 = merge(lists, left, mid);
ListNode l2 = merge(lists, mid + 1, right);

先得到两半各自合并好的结果,再用"两条升序链表合并"这个已知子问题继续处理。

复制代码
if (l1 == null) return l2;
if (l2 == null) return l1;

有一边为空时,另一边本来已经升序,直接返回即可。

复制代码
if (cur1.val <= cur2.val)

相等时选择 cur1 不影响正确性,且与代码中的写法保持一致。

复制代码
if (cur1 != null) prev.next = cur1;
if (cur2 != null) prev.next = cur2;

循环结束时最多有一条链表未耗尽,把它整体接上;它内部已升序,不需要逐节点再比较。

分治变量与 API 中文含义

名称 中文含义
mergeKLists 合并 K 条链表
merge 递归合并数组下标区间
mergeTwoList 合并两条链表;保留代码中的单数 List 命名
leftright 当前递归区间左右下标
mid 中间下标
l1l2 两个递归结果链表
head 两链表合并时的虚拟头
cur1cur2 两条链表当前节点
PriorityQueue 优先队列,常用作堆
offer 入队/入堆
poll 出队/取出队首
Integer.compare 更安全地比较两个整数

分治复杂度

  • 每一层递归总共处理 N 个节点。
  • 递归层数约为 log K
  • 时间复杂度:O(N log K)
  • 递归调用栈空间:O(log K)
  • 合并本身只使用常数个指针;返回的链表节点沿用原节点。

6. 手算示例:小根堆

输入:

复制代码
lists = [
  1 -> 4 -> 5,
  1 -> 3 -> 4,
  2 -> 6
]

堆和结果变化:

操作 堆中候选值 结果
初始 1, 1, 2
1,补入 4 1, 2, 4 1
1,补入 3 2, 3, 4 1,1
2,补入 6 3, 4, 6 1,1,2
3,补入 4 4, 4, 6 1,1,2,3
依次取 4、4、5、6 逐渐为空 1,1,2,3,4,4,5,6

7. 易错点

  1. 把所有节点都放进堆,虽然可能得到结果,但没有利用"每条链表只需一个当前候选头"的结构。
  2. lists 中有空链表,却直接 heap.offer(l)PriorityQueue 通常不允许 null
  3. poll() 后忘记把 t.next 放回堆,导致某条链表后面的节点丢失。
  4. 合并两条链表时忘记接剩余部分。
  5. 分治递归没有处理 left > right,空数组时可能越界。
  6. left + right / 2 当成 (left + right) / 2;括号能明确表达意图。
  7. 递归函数名与题目方法名混乱;代码中保留的是 mergeTwoList,不要在调用处写成另一个未定义名称。
  8. 误以为"hard"一定要新建所有节点;本题可以直接复用原节点的 next

8. 下次遇到类似题的判断依据

看到"多个已经有序的链表合并"时:

  1. 先确认每条链表是否已经有序;有序才可以只比较当前头。
  2. K 较小或需要逐次取全局最小值时,想到小根堆。
  3. 想把多个链表变成两两合并时,想到分治。
  4. 题目要求总节点数为 N 时,堆和分治都常见为 O(N log K)
  5. 合并两个链表的模板是基础,23 题只是把"两条合并"扩展成"K 条管理"。

八、25. K 个一组翻转链表(hard)

1. 一句话题目描述

从链表头开始,每 k 个节点作为一组进行翻转;最后不足 k 个的节点保持原有顺序,并且只能修改节点连接而不能改节点值。

  • 官方题目链接:25. K 个一组翻转链表
  • 标准方法名:reverseKGroup
  • 例子:[1,2,3,4,5]k=2 变成 [2,1,4,3,5]k=3 变成 [3,2,1,4,5]

2. 核心难点

  1. 最后一组不足 k 个时不能翻转。
  2. 每组翻转后,要把它接到前一组后面,再接回后续部分。
  3. 头插过程中 prev 的含义会变化,必须理解"已处理部分尾指针"。
  4. 题目要求实际交换节点,不能把节点值放入数组再倒序写回。

3. 算法思路:先数完整组,再逐组头插

设链表长度为 n

  1. 先用 cur 遍历整条链表,数出 n
  2. n /= k 得到完整的、需要翻转的组数。
  3. 创建 newHead,让 prev 指向已处理部分的尾。
  4. 每组开始时,cur 是本组第一个节点,把它保存为 tmp
  5. 重复 k 次头插:把当前节点插到 prev 后面,形成反转顺序。
  6. 本组处理完成后,原来的第一节点 tmp 已经变成本组尾,把 prev = tmp,为下一组做准备。
  7. 循环结束时,cur 指向不足 k 个的剩余部分,执行 prev.next = cur 原样接回。

例如:

复制代码
1 -> 2 -> 3 -> 4 -> 5,k = 2

完整组数 5 / 2 = 2。前两组翻转为 2 -> 14 -> 3,剩余 5 原样接上。

4. 节点与指针含义

  • head:输入链表头。
  • n:链表总节点数,随后变成完整组数。
  • cur:当前还没有被本组头插处理的节点。
  • newHead:整个结果的虚拟头结点。
  • prev:已处理部分的尾节点;本组翻转时也是头插位置的前一个节点。
  • tmp:本组反转前的第一个节点;翻转后它是本组尾。
  • i:第几组。
  • j:本组内第几个节点。
  • next:头插前保存的后继节点。

5. 简洁 Java 代码

复制代码
class Solution {
    public ListNode reverseKGroup(ListNode head, int k) {
        // 1. 先求出需要逆序多少组
        int n = 0;
        ListNode cur = head;
        while (cur != null) {
            cur = cur.next;
            n++;
        }
        n /= k;

        // 2. 重复 n 次:长度为 k 的链表的逆序
        ListNode newHead = new ListNode(0);
        ListNode prev = newHead;
        cur = head;
        for (int i = 0; i < n; i++) {
            ListNode tmp = cur;
            for (int j = 0; j < k; j++) {
                // 头插的逻辑
                ListNode next = cur.next;
                cur.next = prev.next;
                prev.next = cur;
                cur = next;
            }
            prev = tmp;
        }

        // 把后面不需要逆序的部分连接上
        prev.next = cur;
        return newHead.next;
    }
}

6. 关键语句逐行解释

复制代码
while (cur != null) {
    cur = cur.next;
    n++;
}

先沿 next 走完整条链表,得到总长度 n。这一步不改变链表结构。

复制代码
n /= k;

整数除法只保留完整组数。例如 5 / 2 = 2,说明只能翻转两组,最后一个节点不能动。

复制代码
ListNode newHead = new ListNode(0);
ListNode prev = newHead;
cur = head;

prev 一开始在虚拟头。cur 指向第一组的第一个节点。

复制代码
ListNode tmp = cur;

记录本组原来的头。因为整组反转后,原头会成为本组尾;下一组要接在它后面,所以不能丢掉它。

复制代码
ListNode next = cur.next;

本组头插前保存还没处理的节点。没有这句,下面改写 cur.next 后就无法继续走原链表。

复制代码
cur.next = prev.next;
prev.next = cur;

假设已经处理过的部分是:

复制代码
prev -> A

执行后,当前 cur 插到 prev 后面:

复制代码
prev -> cur -> A

对本组节点依次头插,顺序自然反转。

复制代码
cur = next;

继续处理原链表中下一个尚未插入本组的节点。

复制代码
prev = tmp;

本组反转后,tmp 是本组尾。将 prev 放到这里,下一组的第一个节点就会插到本组后面。

复制代码
prev.next = cur;

所有完整组处理完后,cur 可能是 null,也可能指向不足 k 个的剩余节点。直接连接即可保持剩余部分原顺序。

7. Java 函数、变量与语法中文含义

名称 中文含义
reverseKGroup reverse in K-group,按 K 个一组反转
head 输入头节点
n 节点数,之后表示完整组数
newHead 虚拟头结点
prev 已处理部分的前驱/尾指针
cur current,当前待处理节点
tmp temporary,临时保存本组原头
next 当前节点原来的后继
i 外层组编号
j 内层组内编号
for 按次数重复执行
n /= k n = n / k,保留完整组数

8. 时间和空间复杂度

  • 第一次数长度:O(n)
  • 第二次逐节点头插:每个节点最多处理一次,也是 O(n)
  • 总时间复杂度:O(n)
  • 额外空间复杂度:O(1),没有使用数组、集合或递归调用栈。
  • 节点本身没有复制,都是通过改 next 复用原节点。

9. 手算示例

k = 21 -> 2 -> 3 -> 4 -> 5

总长度 n = 5,完整组数 n / k = 2

第一组 1、2

初始:

复制代码
newHead -> 1 -> 2 -> 3 -> 4 -> 5
prev = newHead, cur = 1

头插 1

复制代码
newHead -> 1

头插 2prev 后:

复制代码
newHead -> 2 -> 1 -> 3 -> 4 -> 5

此时 tmp = 1,所以组尾是 1,令 prev = 1

第二组 3、4

34 依次头插到 1 后面,得到:

复制代码
newHead -> 2 -> 1 -> 4 -> 3 -> 5

此时 cur = 5,不足 k 个,执行 prev.next = cur,结果不改变剩余节点。

k = 31 -> 2 -> 3 -> 4 -> 5

完整组数为 1。第一组反转得到:

复制代码
3 -> 2 -> 1 -> 4 -> 5

剩余 4 -> 5 保持原顺序。

10. 易错点

  1. 每次只向后看 k 个节点,发现不够时才翻转;若边界连接写错,容易破坏原链表。当前代码先统计完整组数,逻辑更直观。
  2. 忘记 n /= k,把最后不足 k 个的部分也反转。
  3. 头插前没有保存 next,后面的节点会丢失。
  4. 每组结束后令 prev = cur;此时 cur 已经指向下一组,正确的本组尾应是 tmp
  5. 最后忘记 prev.next = cur,剩余节点会从结果中断开。
  6. 只交换节点值而不改 next,不符合题意。
  7. k 是正整数,题目保证 k >= 1;自定义调用若允许 k == 0n / k 会除零异常。

11. 下次遇到类似题的判断依据

看到"每 k 个一组翻转""不足一组保持原序"时,先把链表分成完整组和尾部残组:

  1. 需要 O(1) 空间时,优先用指针原地反转。
  2. 先确认一组是否完整,再做组内反转;本代码用总长度除以 k 统一解决。
  3. 记录组的原头,因为它反转后会变成组尾。
  4. 组内反转完成后,连接"前一组尾 -> 新组头 -> 下一组起点"。
  5. 最后一段不足 k 个时直接接回,不做任何指针翻转。

九、五题共同规律

1. 五题都在做"节点重连",不是数组下标操作

链表节点没有直接下标访问。题目中的"交换、重排、合并、翻转",都要落实为:

复制代码
某个节点.next = 另一个节点;

写代码前画箭头,先明确每个节点改动前后的后继。

2. dummy 让开头统一

  • 2 题:结果从第一位开始尾插。
  • 24 题:第一对节点也需要一个前驱。
  • 23 题:合并结果需要一个稳定的起点。
  • 25 题:第一组反转后需要接回虚拟头。
  • 143 题:代码中 head2ret 也承担辅助头作用。

常见返回规律:

复制代码
return dummy.next;

3. 反转前先保存 next

只要代码要改写 cur.next,几乎都应该先问:原来的后继还需要吗?若需要,先写:

复制代码
ListNode next = cur.next;

这是 143 和 25 的共同核心,也是普通整链表反转的固定习惯。

4. "断开"与"连接"是成对出现的

  • 143:slow.next = null 断开两段,合并时重新设置多个 prev.next
  • 24:先把一对节点从旧顺序中重新接出,再接回 nnext
  • 25:组内头插后,prev.next = cur 接回未翻转尾段。
  • 23:选出节点后把它接到结果尾,并把它的后继放回候选结构。

5. 有序性决定选择策略

  • 两条链表已升序:只比较 cur1.valcur2.val
  • 多条链表已升序:堆维护所有当前头,或分治成多次两链表合并。
  • 没有升序保证时,不能直接使用"两头比较就够"的合并逻辑。

6. 递归和迭代的取舍

  • 需要稳定的 O(1) 额外空间,且指针过程容易画出来:优先迭代。
  • 多个同类子问题可以平分,代码用递归表达更清楚:23 题的分治适合递归。
  • 递归并不等于更快;复杂度还要看每层处理多少节点。

十、Java 常用 API 与语句

1. new ListNode(value)

复制代码
ListNode node = new ListNode(5);

创建一个节点,节点的 val5next 默认是 null

复制代码
prev.next = new ListNode(t % 10);

创建结果节点,并把它接到 prev 后面。

2. node.next

复制代码
cur = cur.next;

沿链表向后走一步。

复制代码
cur.next = prev;

把当前节点的后继改成 prev,是反转箭头的核心。

访问之前必须确认 cur != null,否则 cur.next 会抛出 NullPointerException

3. while

复制代码
while (cur != null) {
    cur = cur.next;
}

适合"不知道要走多少步、走到链表结束"的遍历。

条件中的 &&|| 必须根据题意选择:

  • cur1 != null && cur2 != null:两者都还有节点时处理,常见于两链表比较合并。
  • cur1 != null || cur2 != null:任意一者还有节点就处理,常见于两数相加。

4. for

复制代码
for (int i = 0; i < n; i++) {
    for (int j = 0; j < k; j++) {
        // 处理一组中的 k 个节点
    }
}

外层表示组数,内层表示组内节点数。25 题中 ij 的含义就是这个层次关系。

5. PriorityQueue

复制代码
PriorityQueue<ListNode> heap =
        new PriorityQueue<>((v1, v2) -> v1.val - v2.val);

PriorityQueue 是优先队列,常用于实现堆。配合比较器后,poll() 每次取出值最小的节点。

常见方法:

  • offer(x):把 x 加入队列。
  • poll():取出并删除优先级最高的元素。
  • peek():只查看队首,不删除。
  • isEmpty():判断是否为空。

6. ArrayList 和数组:本板块是否需要

这五道题的核心 Java 实现都不需要 ArrayList

  • 2 题直接生成结果节点。
  • 24 题直接交换指针。
  • 143 题直接分段、反转、合并。
  • 23 题用 ListNode[] 保存输入链表,堆或递归管理链表头。
  • 25 题直接原地分组反转。

如果用 ArrayList<ListNode> 暂存所有节点,也能完成某些重排题,但会额外使用 O(n) 空间,失去链表原地操作的练习价值。ListNode[] lists 是"链表头组成的数组",而不是把所有节点拍平成数组。

7. 递归调用

复制代码
return merge(lists, 0, lists.length - 1);

递归函数要明确:

  1. 参数表示什么区间。
  2. 什么时候停止。
  3. 返回值代表什么结果。
  4. 两个子结果如何合并。

23 题中 left == right 是单链表区间的递归出口,left > right 是空区间的递归出口。


十一、复杂度对照

设单条链表节点数为 n,多链表总节点数为 N,链表数量为 K

题目 时间复杂度 额外空间 主要原因
2. 两数相加 O(max(m,n)) O(1)(不计结果节点) 两个游标逐位相加
24. 两两交换 O(n) O(1) 每个节点只处理常数次
143. 重排链表 O(n) O(1) 找中点、反转、合并各一遍
23. K 个升序链表:小根堆 O(N log K) O(K) 每个节点进出堆
23. K 个升序链表:分治 O(N log K) O(log K) 分治层数约 log K
25. K 组翻转 O(n) O(1) 数长度一次、处理节点一次

关于空间的表述要区分:

  • 返回一个新结果链表所需的节点不通常算作"额外工作空间"。
  • 23 题堆中的 K 个候选节点是额外管理结构,需要 O(K)
  • 递归分治的调用栈是额外空间 O(log K)
  • 143 和 25 虽然创建了常数个 new ListNode(0),仍是 O(1)

十二、看到题目后的判断顺序

面对一条链表题,可以按下面顺序快速判断:

  1. 题目要求改值还是改节点? 若说不能改值,就必须画 next 的变化。
  2. 是否需要处理头节点? 若第一步可能改变头,创建 dummy/newHead
  3. 是遍历、反转、交换、合并还是重排? 先归类,再套相应固定模板。
  4. 是否需要后继节点? 只要会改 cur.next,先保存 ListNode next = cur.next
  5. 是否需要断开链表? 分段时明确写出哪一个 next 要置为 null
  6. 循环条件处理谁? 判断是 cur != null、两个都非空,还是任意一个非空;最后一个节点是否会被处理。
  7. 是否有完整分组? k 组题先确认最后是否足够 k 个,不足时保持原样。
  8. 是否有序? 两条有序链表用双指针;多条有序链表考虑 PriorityQueue 或分治。
  9. 返回哪个节点? 有虚拟头时通常返回 dummy.next,不是 dummy
  10. 用最小例子画一遍。 至少测试空链表、单节点、两节点、奇数长度、偶数长度、不同长度、最后不足一组和产生进位。

十三、最后的阶段性速记

复制代码
链表不能按下标直接访问,只能沿 next 走。
需要统一处理开头:dummy -> head。
要改 cur.next:先保存 next。
要反转:保存后继,反箭头,推进 prev/cur。
要找中点:slow 一步,fast 两步。
要重排:中点 + 断开 + 反转 + 交替合并。
要合并有序链表:比较当前头,较小者接到尾部。
要合并 K 条:小根堆维护 K 个当前头,或分治两两合并。
要 K 个一组翻转:先确认完整组,组内头插,剩余原样接回。
最后常返回 dummy.next。

五道题表面不同:一题模拟加法、一题交换、一题重排、一题多路合并、一题分组翻转;但它们共同训练的是同一件事:看清每个节点当前指向谁,保存将要失去的后继,再用最少的 next 修改把链表接成目标结构。

相关推荐
坐吃山猪40 分钟前
【多线程】CompletableFuture使用
java·开发语言·多线程
浔溺1 小时前
al+大数据每日学习笔记31
大数据·笔记·学习
a187927218311 小时前
【算法】回溯算法(三):三记重锤与 N 皇后——记忆化、状态设计与三层漏斗
算法·leetcode·go·剪枝·回溯·n皇后·算法讲解
摇滚侠1 小时前
《SpringBoot 3:入门与应用实战》第 9 章 跨域问题 阅读笔记 27
spring boot·笔记·okhttp
山岚的运维笔记1 小时前
mysql 专业笔记 -- 第 8 章:使用变量
运维·数据库·笔记·后端·学习·mysql·dba
m0_587383001 小时前
点餐预约核销系统的架构脉络
java·架构·系统架构·需求分析
代码方舟1 小时前
O2O二手车交易平台B端车商管理后台:基于天远车信盟出险构建自动化车况评估网关
java·运维·人工智能·自动化
爱学堂IT分享1 小时前
图灵Java互联网架构师六期
java·开发语言
坐吃山猪2 小时前
【多线程】Semaphore使用
java·数据库·oracle·多线程