本文概览:本文以LeetCode经典题目"K 个一组翻转链表"为例,从普通反转链表入手,重点讲解如何按组截取、如何保证前后链表不断开,以及如何用 NPC 三指针法完成局部链表反转


一、题目

K个一组翻转链表题目

二、题目分析

给定链表的头节点 head,每 k 个节点一组进行翻转,返回修改后的链表。如果节点总数不是 k 的整数倍,最后剩余不足 k 个节点保持原有顺序

目标:每次翻转链表中的一小段,并保证整条链表不断开

核心难点:这题不是单纯反转整条链表,而是反转链表中的某一段。每反转一组之后,还要把这一组重新接回前后链表中

主要难点有两个:

  1. 如何反转当前这 k 个节点?
  2. 部分反转后,如何保证前面的链表和后面的链表不丢失?

思路概览

Java实现代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public ListNode reverseKGroup(ListNode head, int k) {
if (head == null || k <= 1) {
return head;
}

// 创建哑节点
ListNode dummy = new ListNode(0, head);

// 上一组的尾节点(初始为dummy)
ListNode prevGroupEnd = dummy;

while (true) {
// 检查是否有足够的k个节点
ListNode groupEnd = prevGroupEnd;
for (int i = 0; i < k; i++) {
groupEnd = groupEnd.next;
if (groupEnd == null) {
return dummy.next;
}
}
// 记录下一组的头节点(当前组的尾节点的下一个节点)
ListNode nextGroupHead = groupEnd.next;
// 记录当前组的头节点
ListNode curGroupHead = prevGroupEnd.next;
// 反转链表之后返回新的头节点,将上一组的尾节点指向新的头节点
prevGroupEnd.next = reverse(curGroupHead, nextGroupHead);
// 将上一组的尾节点更新为当前组的头节点(当前组反转后的尾节点)
prevGroupEnd = curGroupHead;
}
}

private ListNode reverse(ListNode curGroupHead, ListNode groupEnd) {
// 记录当前反转后的组的最左节点
ListNode prev = groupEnd;
// 记录当前组准备要反转的节点
ListNode curr = curGroupHead;

// 准备反转的节点不等于组的尾节点,就说明还有节点没插完
while (curr != groupEnd) {
ListNode nxt = curr.next; // ① 提前存住下一个要处理的节点
curr.next = prev; // ② 头插:当前节点挂到前面
prev = curr; // ③ 更新最左节点:当前节点成为新的最左节点
curr = nxt; // ④ 更新当前节点:处理下一个节点
}
return prev; // 最左节点就是新的头节点:返回新的头节点
}

思路简要说明

  1. 按组处理:每次先检查后面是否还有 k 个节点,不足 k 个就直接结束

  2. 记录前后连接点prevGroupEnd 记录上一组的尾节点,nextGroupHead 记录下一组的头节点,防止链表断开

  3. 局部反转:对 [curGroupHead, nextGroupHead) 这段链表进行反转,反转后返回新的头节点

  4. 接回链表:上一组尾节点连接到当前组反转后的新头节点,当前组原头节点变成新尾节点,作为下一轮的 prevGroupEnd

三、思路详解

从普通反转链表入手

前面做过反转链表以后,这题的核心并不陌生。普通反转链表就是把整条链表从:

1
1 → 2 → 3 → 4

变成:

1
4 → 3 → 2 → 1

而这道题只是把"整条链表反转"变成了"每 k 个节点反转一次":

1
2
3
原链表:1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
k = 3
结果: 3 → 2 → 1 → 6 → 5 → 4 → 7 → 8

也就是说,我们每次只反转一小段链表,不足 k 个节点的部分保持不变

这题真正难在哪里?

思路本身不难:每次找到 k 个节点,然后反转它们

真正难的是代码实现,因为局部反转会带来两个问题:

问题一:当前组反转后,前面的链表怎么接回来?

比如:

1
dummy → 1 → 2 → 3 → 4 → 5

如果要反转 1 → 2 → 3,反转后应该变成:

1
dummy → 3 → 2 → 1 → 4 → 5

这意味着上一组的尾节点 dummy 必须指向当前组反转后的新头节点 3

所以我们需要 prevGroupEnd 记录上一组的尾节点

问题二:当前组反转后,后面的链表不能丢失

反转 1 → 2 → 3 时,后面的 4 → 5 必须保留下来,并且反转后要接到 1 后面:

1
3 → 2 → 1 → 4 → 5

所以我们需要提前记录下一组的头节点 nextGroupHead,也就是当前组尾节点的下一个节点

每一组需要记录哪些指针?

每次处理一组时,我们需要几个关键指针:

  • prevGroupEnd:上一组的尾节点,用来连接当前组反转后的新头节点
  • groupEnd:当前组的尾节点,用来判断是否有 k 个节点
  • nextGroupHead:下一组的头节点,也就是当前组反转时的终止位置
  • curGroupHead:当前组的头节点,反转后会变成当前组的尾节点

图示如下:

1
2
3
4
5
6
7
8
prevGroupEnd

dummy → 1 → 2 → 3 → 4 → 5 → 6
↑ ↑ ↑
curGroupHead groupEnd nextGroupHead

当前要反转的范围是:[curGroupHead, nextGroupHead)
也就是:1 → 2 → 3

为什么反转范围写成 [curGroupHead, nextGroupHead)

因为 nextGroupHead 是下一组的头节点,它不能被反转,只是作为当前组反转的终止边界

如何检查是否还有 k 个节点?

每一轮开始时,先让 groupEndprevGroupEnd 出发向后走 k 步:

1
2
3
4
5
6
7
ListNode groupEnd = prevGroupEnd;
for (int i = 0; i < k; i++) {
groupEnd = groupEnd.next;
if (groupEnd == null) {
return dummy.next;
}
}

如果走不到 k 步,说明剩余节点不足 k 个,直接返回结果。因为题目要求不足 k 个节点保持原顺序

NPC 三指针反转法

这里的 reverse 方法使用的是 NPC 三指针法:

  • nxt:next,提前保存当前节点的下一个节点,防止断链
  • prev:pre,已经反转部分的头节点
  • curr:cur,当前准备反转的节点

代码如下:

1
2
3
4
5
6
7
8
9
10
11
private ListNode reverse(ListNode curGroupHead, ListNode groupEnd) {
ListNode prev = groupEnd;
ListNode curr = curGroupHead;
while (curr != groupEnd) {
ListNode nxt = curr.next;
curr.next = prev;
prev = curr;
curr = nxt;
}
return prev;
}

注意这里的 prev 初始值不是 null,而是 groupEnd(也就是下一组的头节点)

为什么?因为我们不是反转整条链表,而是反转某一段链表。当前组反转后,原来的头节点会变成当前组的尾节点,它的 next 应该指向下一组的头节点

所以一开始让:

1
ListNode prev = groupEnd;

这样反转过程中,当前组的尾部会天然接到下一组的头节点,不会断链

图示理解局部反转过程

以当前组 1 → 2 → 3,下一组头节点为 4 为例:

1
2
3
4
5
6
反转前:
prevGroupEnd → 1 → 2 → 3 → 4 → 5
↑ ↑
curGroupHead groupEnd参数(这里实际是nextGroupHead=4)

reverse(1, 4)

初始化:

1
2
3
4
5
6
7
8
9
10
prev = 4
curr = 1

4 → 5

prev

1 → 2 → 3 → 4 → 5

curr

第1轮:处理节点 1

1
2
3
4
nxt = curr.next;   // nxt = 2
curr.next = prev; // 1 → 4
prev = curr; // prev = 1
curr = nxt; // curr = 2

结果:

1
2
3
4
5
6
7
1 → 4 → 5

prev

2 → 3 → 4 → 5

curr

此时节点 1 已经变成反转后这组的尾节点,并且自然接上了下一组的头节点 4

第2轮:处理节点 2

1
2
3
4
nxt = curr.next;   // nxt = 3
curr.next = prev; // 2 → 1
prev = curr; // prev = 2
curr = nxt; // curr = 3

结果:

1
2
3
4
5
6
7
2 → 1 → 4 → 5

prev

3 → 4 → 5

curr

第3轮:处理节点 3

1
2
3
4
nxt = curr.next;   // nxt = 4
curr.next = prev; // 3 → 2
prev = curr; // prev = 3
curr = nxt; // curr = 4

结果:

1
2
3
4
5
3 → 2 → 1 → 4 → 5

prev

curr = 4,循环结束

此时 prev 就是当前组反转后的新头节点 3,返回 prev

反转后如何接回上一组?

reverse(curGroupHead, nextGroupHead) 返回的是当前组反转后的新头节点

所以:

1
prevGroupEnd.next = reverse(curGroupHead, nextGroupHead);

例如当前组 1 → 2 → 3 反转后变成 3 → 2 → 1 → 4,返回的就是 3,于是:

1
dummy → 3 → 2 → 1 → 4 → 5

反转完成后,原来的当前组头节点 curGroupHead 已经变成了当前组的尾节点,所以要更新:

1
prevGroupEnd = curGroupHead;

这样下一轮反转时,prevGroupEnd 就指向上一组的尾节点了

完整例子

1 → 2 → 3 → 4 → 5 → 6 → 7 → 8k = 3 为例

第一组:1,2,3

1
2
3
反转前:dummy → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
反转后:dummy → 3 → 2 → 1 → 4 → 5 → 6 → 7 → 8
prevGroupEnd 更新为 1

第二组:4,5,6

1
2
3
反转前:dummy → 3 → 2 → 1 → 4 → 5 → 6 → 7 → 8
反转后:dummy → 3 → 2 → 1 → 6 → 5 → 4 → 7 → 8
prevGroupEnd 更新为 4

剩余:7,8 不足 3 个

1
2
不足 k 个,保持不变
最终结果:3 → 2 → 1 → 6 → 5 → 4 → 7 → 8
  • 时间复杂度:O(n),每个节点只被处理一次
  • 空间复杂度:O(1),只使用常数个指针变量