力扣热题100实战 | 第25期:K个一组翻转链表——从两两交换到K路翻转的进阶之路

力扣热题100实战 | 第25期:K个一组翻转链表------从两两交换到K路翻转的进阶之路

两两交换链表节点只是开胃菜,K个一组翻转才是真正的硬核挑战。这道Hard题教会我们:当翻转的粒度从"2"扩展到"K",如何用虚拟头节点、区间反转和指针衔接,化解复杂度危机。

前言

你好,我是@礼拜天没时间。

上一期我们学习了"两两交换链表中的节点",掌握了通过迭代和递归处理相邻节点交换的技巧。这一期,我们来解它的终极进阶版------K个一组翻转链表(LeetCode 第25题)。

这道题在力扣上被标记为"困难",是链表操作中公认的经典难题。它的难点不在于单个技巧有多复杂,而在于多个操作的交织:分组计数、区间反转、前后衔接、边界处理,每一步都需要精确无误。

很多同学第一次接触这道题时,要么代码写得冗长混乱,要么指针丢失导致死循环。但当你真正掌握了它,你会发现自己的链表操作能力已经上升了一个台阶。

今天,我希望能带你从递归的简洁思路出发,再到迭代的极致性能,彻底吃透这道题。


一、题目:每K个一组,翻转链表

先看题目描述(LeetCode 第25题):

给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。

k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。

进阶

  • 你可以设计一个只使用常数额外空间的算法来解决此问题吗?
  • 你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。

示例 1:

复制代码
输入:head = [1,2,3,4,5], k = 2
输出:[2,1,4,3,5]
解释:每2个一组翻转,第一组1-2变成2-1,第二组3-4变成4-3,最后一组5不足2个,保持原序。

示例 2:

复制代码
输入:head = [1,2,3,4,5], k = 3
输出:[3,2,1,4,5]
解释:第一组1-2-3变成3-2-1,剩余4-5不足3个,保持原序。

示例 3:

复制代码
输入:head = [1,2,3,4,5], k = 1
输出:[1,2,3,4,5]
解释:k=1时,每个节点自己一组,相当于不翻转。

关键点解读

  1. K的范围:k 是正整数,且 ≤ 链表长度。但最后一组可能不足 k 个。

  2. 空间限制:进阶要求只能使用常数额外空间(O(1)),这意味着不能用递归(递归栈占用O(n/k)空间),也不能用栈辅助。

  3. 实际交换节点 :不能只改值,必须通过修改 next 指针实现。

  4. 与第24题的关系:当 k=2 时,就是"两两交换链表中的节点",但 k 可以是任意正整数。


二、第一反应:栈辅助法(直观但空间不达标)

当我第一次看到这道题,我的第一反应是:用栈来辅助反转。因为栈的"后进先出"特性天然适合反转操作。

核心思想

  1. 遍历链表,将节点依次压入栈中,直到栈的大小达到 k
  2. 将栈中的节点依次弹出并连接,实现反转
  3. 重复这个过程,直到链表末尾
  4. 如果最后一组不足 k 个,则保持原序(不压栈,直接连接)

代码实现

java 复制代码
public ListNode reverseKGroup(ListNode head, int k) {
    if (head == null || k == 1) return head;
    
    Deque<ListNode> stack = new LinkedList<>();
    ListNode dummy = new ListNode(0);
    ListNode cur = dummy;
    ListNode temp = head;
    
    while (temp != null) {
        int count = 0;
        // 将k个节点压入栈
        while (temp != null && count < k) {
            stack.push(temp);
            temp = temp.next;
            count++;
        }
        
        // 如果这一组正好k个,则弹出并反转
        if (count == k) {
            while (!stack.isEmpty()) {
                cur.next = stack.pop();
                cur = cur.next;
            }
            cur.next = temp; // 连接后续部分
        } else {
            // 不足k个,保持原序
            // 此时栈里可能还有元素,需要从栈底到栈顶依次连接
            // 但为了保持原序,应该直接连接第一个节点
            // 这里需要把栈中的节点按原序接回
            // 为了简化,可以直接将temp之前的节点按原序接回
            // 实际上更简单的处理是:不足k个时不压栈,直接遍历一次
            break;
        }
    }
    
    return dummy.next;
}

复杂度分析

  • 时间复杂度:O(n) ------ 每个节点入栈出栈一次
  • 空间复杂度:O(k) ------ 栈中最多存放 k 个节点

这段代码的问题在哪?

  1. 不符合进阶要求:空间复杂度 O(k),当 k 很大时(如 k=10⁴),栈空间占用过大。

  2. 代码复杂:需要处理不足 k 个时的特殊逻辑。

  3. 面试减分:面试官会追问"能否优化到 O(1) 空间"。


三、核心解法一:递归法(代码简洁,但空间不是常数)

递归解法是理解这道题的最佳起点,代码非常简洁优雅。

递归思想

  1. 先找到当前组的尾节点 :从 head 出发走 k-1 步,如果中途遇到 null,说明不足 k 个,直接返回 head(不反转)
  2. 标记下一组的头节点nextGroup = tail.next
  3. 断开当前组 :将 tail.next 设为 null,使当前组独立
  4. 反转当前组 :调用反转链表函数,得到新的头节点 newHead
  5. 递归处理下一组head.next = reverseKGroup(nextGroup, k),将反转后的当前组与后续部分连接
  6. 返回新头 :返回 newHead

图解递归过程

head = [1,2,3,4,5], k = 2 为例:

复制代码
reverseKGroup(1→2→3→4→5, 2)
├─ tail = 2 (走1步)
├─ nextGroup = 3→4→5
├─ 断开: 1→2→null
├─ 反转: 2→1 (newHead = 2)
├─ head.next = reverseKGroup(3→4→5, 2)
│  ├─ tail = 4
│  ├─ nextGroup = 5→null
│  ├─ 断开: 3→4→null
│  ├─ 反转: 4→3
│  ├─ head.next = reverseKGroup(5, 2)
│  │  └─ 不足2个,直接返回5
│  ├─ 连接: 3.next = 5 → 4→3→5
│  └─ 返回 4→3→5
├─ 连接: 1.next = 4→3→5 → 2→1→4→3→5
└─ 返回 2→1→4→3→5

代码实现

java 复制代码
class Solution {
    public ListNode reverseKGroup(ListNode head, int k) {
        // 1. 找到当前组的尾节点
        ListNode tail = head;
        for (int i = 0; i < k - 1; i++) {
            if (tail == null) return head; // 不足k个,直接返回
            tail = tail.next;
        }
        if (tail == null) return head; // 不足k个
        
        // 2. 标记下一组的头节点
        ListNode nextGroup = tail.next;
        
        // 3. 断开当前组
        tail.next = null;
        
        // 4. 反转当前组
        ListNode newHead = reverse(head);
        
        // 5. 递归处理下一组,并连接
        head.next = reverseKGroup(nextGroup, k);
        
        // 6. 返回新的头节点
        return newHead;
    }
    
    // 反转链表(迭代版)
    private ListNode reverse(ListNode head) {
        ListNode prev = null;
        ListNode curr = head;
        while (curr != null) {
            ListNode next = curr.next;
            curr.next = prev;
            prev = curr;
            curr = next;
        }
        return prev;
    }
}

代码解析

  1. 找尾节点for 循环走 k-1 步,定位到当前组的最后一个节点。如果中途遇到 null,说明不足 k 个,直接返回 head

  2. 断开连接 :将当前组的 tail.next 设为 null,这样当前组就独立出来了,便于反转。

  3. 反转当前组 :调用 reverse 函数,返回新的头节点(原来的尾节点)。

  4. 递归连接head 此时是当前组的第一个节点,反转后它变成了最后一个节点。所以 head.next 应该指向递归处理下一组的结果。

  5. 返回值 :返回反转后的新头节点 newHead

复杂度分析

  • 时间复杂度:O(n) ------ 每个节点被处理常数次
  • 空间复杂度:O(n/k) ------ 递归调用栈的深度,不是 O(1)

四、核心解法二:迭代分区反转(面试终极答案,O(1)空间)

递归解法虽然简洁,但空间复杂度不是常数。进阶要求是只能使用常数额外空间,这迫使我们必须用迭代实现 。

核心思想

  1. 使用虚拟头节点dummy 节点指向 head,统一处理头节点变化的情况
  2. 两个关键指针
    • pre:指向当前待翻转组的前一个节点
    • tail:指向当前待翻转组的最后一个节点(需要每走 k 步确定)
  3. 循环处理 :当 tail 不为空时,说明当前有 k 个节点可翻转
  4. 区间反转 :将 [pre.next, tail] 这个区间的子链表反转
  5. 重新连接 :反转后,pre.next 应该指向新的头(原来的尾),head(原来的头)变成了新的尾,需要连接到下一组
  6. 移动指针 :将 pre 移动到 head(当前组的尾),head 移动到下一组的头

区间反转的技巧

反转一个子链表,需要知道三个关键位置:

  • pre:子链表的前驱节点
  • start:子链表的第一个节点(也就是 pre.next
  • end:子链表的最后一个节点(也就是 tail

反转过程(头插法):

  1. 记录 next = end.next(下一组的头)
  2. 用标准的反转链表算法反转 startend 的部分
  3. 将反转后的子链表接回:pre.next 指向新的头(原来的 end),start.next 指向 next

图解流程

head = [1,2,3,4,5], k = 2 为例:

初始状态

复制代码
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> null
pre     head
tail

第1步:移动 tail 走 k=2 步,定位到节点2

复制代码
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> null
pre     head   tail

第2步:反转 [head, tail] 即 [1,2]

  • 反转后:2 -> 1

  • pre.next = 2

  • 1.next = next (节点3)

    dummy -> 2 -> 1 -> 3 -> 4 -> 5 -> null
    pre head

第3步:移动 pre 到 1,head 到 3

复制代码
dummy -> 2 -> 1 -> 3 -> 4 -> 5 -> null
                 pre  head

第4步:移动 tail 从 pre 出发走 k 步,定位到节点4

复制代码
dummy -> 2 -> 1 -> 3 -> 4 -> 5 -> null
                 pre  head   tail

第5步:反转 [head, tail] 即 [3,4]

  • 反转后:4 -> 3

  • pre.next = 4

  • 3.next = next (节点5)

    dummy -> 2 -> 1 -> 4 -> 3 -> 5 -> null
    pre head

第6步:移动 pre 到 3,head 到 5

复制代码
dummy -> 2 -> 1 -> 4 -> 3 -> 5 -> null
                          pre  head

第7步:移动 tail 从 pre 出发走 k 步,但 pre.next 只有 5 一个节点,不足 2 个,循环结束。

最终结果:2 -> 1 -> 4 -> 3 -> 5

代码实现

java 复制代码
class Solution {
    public ListNode reverseKGroup(ListNode head, int k) {
        if (head == null || k == 1) return head;
        
        // 1. 虚拟头节点,简化边界处理
        ListNode dummy = new ListNode(0);
        dummy.next = head;
        
        // 2. 初始化指针
        ListNode pre = dummy;
        ListNode tail = dummy;
        
        while (true) {
            // 3. 移动tail,找到当前组的最后一个节点
            for (int i = 0; i < k; i++) {
                tail = tail.next;
                if (tail == null) {
                    // 不足k个,直接返回结果
                    return dummy.next;
                }
            }
            
            // 4. 记录下一组的头节点
            ListNode nextGroup = tail.next;
            
            // 5. 反转当前组 [pre.next, tail]
            ListNode start = pre.next;
            ListNode end = tail;
            
            // 反转链表的标准操作
            reverse(start, end);
            
            // 6. 重新连接
            pre.next = end;      // pre指向新的头
            start.next = nextGroup; // 当前组的尾指向下一组的头
            
            // 7. 移动pre和tail,准备下一轮
            pre = start;
            tail = start;
        }
    }
    
    /**
     * 反转以start为头、end为尾的子链表
     * 反转后,end成为新的头,start成为新的尾
     */
    private void reverse(ListNode start, ListNode end) {
        ListNode prev = null;
        ListNode curr = start;
        ListNode stop = end.next; // 反转的终点
        
        while (curr != stop) {
            ListNode next = curr.next;
            curr.next = prev;
            prev = curr;
            curr = next;
        }
    }
}

另一种常见的迭代写法(更易理解)

java 复制代码
class Solution {
    public ListNode reverseKGroup(ListNode head, int k) {
        ListNode dummy = new ListNode(0);
        dummy.next = head;
        
        ListNode pre = dummy;
        ListNode end = dummy;
        
        while (end.next != null) {
            // 1. 移动end找到当前组的最后一个节点
            for (int i = 0; i < k && end != null; i++) {
                end = end.next;
            }
            if (end == null) break; // 不足k个
            
            // 2. 记录关键节点
            ListNode start = pre.next;
            ListNode next = end.next;
            
            // 3. 断开当前组
            end.next = null;
            
            // 4. 反转当前组
            pre.next = reverse(start);
            
            // 5. 重新连接
            start.next = next;
            
            // 6. 移动指针,准备下一轮
            pre = start;
            end = start;
        }
        return dummy.next;
    }
    
    private ListNode reverse(ListNode head) {
        ListNode prev = null;
        ListNode curr = head;
        while (curr != null) {
            ListNode next = curr.next;
            curr.next = prev;
            prev = curr;
            curr = next;
        }
        return prev;
    }
}

复杂度分析

  • 时间复杂度:O(n) ------ 每个节点被遍历常数次
  • 空间复杂度:O(1) ------ 只用了几个指针变量

五、递归 vs 迭代:巅峰对决

维度 递归法 迭代法
代码简洁度 ⭐⭐⭐⭐⭐(极简,5行核心) ⭐⭐⭐(指针操作稍复杂)
空间复杂度 O(n/k)(递归栈) O(1) ✅
符合进阶要求
可读性 需要理解递归思维 指针变化需要图解
面试推荐 思路讲解可用 ⭐⭐⭐⭐⭐(最终答案)

面试建议

优先掌握迭代法,因为只有它满足进阶要求的 O(1) 空间 。在面试中,可以先讲递归思路(展示你对问题本质的理解),再给出迭代实现(展示你的代码能力)。


六、细节剖析:面试官真正关心的问题

Q1:为什么一定要用虚拟头节点(dummy)?

答案 :因为头节点可能会变化 。当第一组反转后,原来的头节点(比如1)可能变成了组内的尾节点,新的头节点变成了原来的尾节点(比如2)。如果没有虚拟头节点,就需要单独处理这种情况。有了 dummy,我们始终可以通过 dummy.next 返回正确的头节点,统一了所有操作 。

Q2:迭代法中,为什么需要两个循环?一个找尾,一个反转?

答案 :第一个循环用于确定当前组是否有 k 个节点,同时定位尾节点。如果不足 k 个,直接结束。第二个循环(在 reverse 函数中)用于实际反转子链表。这两个步骤必须分开,因为找尾需要遍历,反转也需要遍历,但它们的逻辑不同。

Q3:反转子链表时,为什么要记录 end.next 作为停止点?

答案 :因为我们需要反转的是一个闭区间 [start, end]。反转过程中,我们希望当 curr 走到 end.next 时停止。这样,反转后的子链表的最后一个节点(原来的 start)的 next 就会自动指向 end.next,也就是下一组的头 。

Q4:如果 k=1 或 k 大于链表长度,代码怎么处理?

答案

  • k=1 :不需要翻转,直接返回原链表。可以在开头加判断 if (k == 1) return head;
  • k > 链表长度 :在找尾的过程中,tail 会变成 null,代码中判断 if (tail == null) break; 直接返回原链表。

Q5:如何验证算法的正确性?

答案:可以写一个辅助函数遍历链表,检查每个节点的值是否满足预期顺序。同时,可以计算节点总数,确保没有节点丢失。


七、面试官追问进阶版

追问1:如果要求每 k 个一组翻转,但最后一组即使不足 k 个也要翻转,怎么改?

思路:去掉"不足k个不翻转"的限制。只需将判断条件改为一直处理到链表末尾。

java 复制代码
while (true) {
    // 移动tail找尾
    for (int i = 0; i < k; i++) {
        tail = tail.next;
        if (tail == null) {
            // 最后一组不足k个,也要翻转
            // 但此时 tail 为 null,需要特殊处理
            // 可以计算剩余节点数,如果小于k,则直接返回
            // 这里为了简单,可以提前计算链表长度
        }
    }
    // ... 反转逻辑
}

追问2:如何用头插法实现分组翻转?

思路 :头插法的核心是每次将当前节点插入到 pre 之后。对于一组 k 个节点,可以依次将每个节点用头插法插入到 pre 后面,实现反转 。

java 复制代码
// 头插法反转一组
ListNode nextGroup = tail.next;
ListNode cur = pre.next;
while (cur != nextGroup) {
    ListNode temp = cur.next;
    cur.next = pre.next;
    pre.next = cur;
    cur = temp;
}

追问3:如何计算链表的长度,用于提前判断分组数?

思路 :先遍历一次链表,统计节点总数。然后根据 count / k 知道有多少组需要翻转。

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

追问4:如何实现一个通用的"反转链表前N个节点"的函数?

思路:这是本题的核心子函数。可以用递归或迭代实现。递归实现如下 :

java 复制代码
public ListNode reverseN(ListNode head, int n) {
    if (n == 1) return head;
    ListNode newHead = reverseN(head.next, n - 1);
    ListNode tail = head.next;
    head.next = tail.next;
    tail.next = head;
    return newHead;
}

八、实际开发:这道题到底有什么用?

很多读者会问:"K个一组翻转链表,实际工作中哪用得到?"

其实它的思想无处不在:

场景1:数据包重组

在网络通信中,数据包可能被分片传输,接收端需要按顺序重组。每收到 k 个分片,可能需要重新排序后提交给上层应用。

场景2:数据库页重组

在数据库系统中,磁盘页的物理存储可能需要重新排列以优化访问性能。分组反转的思想可以应用于页内记录的重新组织。

场景3:缓存淘汰策略

在某些缓存算法中,可能需要将最近访问的 k 个节点移到链表头部,这本质上是一种分组反转的变体。

场景4:文件块排序

在分布式存储系统中,文件被分成多个块,可能需要每 k 个块为一组进行重新排序,以优化读取性能。

场景5:面试压轴题

这道题常作为算法面试的压轴题,考察候选人对链表操作的掌握程度。能流畅写出迭代解法,说明链表基本功非常扎实。


九、总结:从一道题到一类题

回顾一下,我们从K个一组翻转链表学到了什么:

维度 收获
算法思维 递归(简洁)→ 迭代(O(1)空间),理解从特殊到一般的推广
代码技巧 虚拟头节点、子链表反转、指针衔接、边界处理
复杂度分析 时间 O(n)、空间 O(1) 是终极目标
面试要点 为什么要用 dummy?如何找到每组的尾?如何反转子链表?
工程关联 数据包重组、数据库页整理、缓存策略、分布式存储

力扣热题100的第二十五题,不是为了难住你,而是为了告诉你:掌握了链表的基本操作后,还需要学会组合运用。当你能熟练地在链表中切分区间、反转局部、衔接整体,你就真正掌握了链表的灵魂。

下一期预告:《删除有序数组中的重复项》------双指针的经典应用


附录:思考题

看完这篇文章,你可以试着回答:

  1. 如果要求每 k 个一组翻转,但最后一组不足 k 个时也要翻转,代码怎么改?
  2. 如何用头插法实现分组翻转?头插法和标准反转有什么区别?
  3. 你能用这道题的思路,去解 LeetCode 92(反转链表 II)吗?

欢迎在评论区留下你的思考!

相关推荐
y = xⁿ2 小时前
【从零开始学习Redis|第四篇】从底层理解缓存问题:雪崩、击穿、穿透与一致性设计
java·redis·学习·缓存
Swift社区2 小时前
LeetCode 400 第 N 位数字
算法·leetcode·职场和发展
再难也得平2 小时前
力扣239. 滑动窗口最大值(Java解法)
算法·leetcode·职场和发展
江湖有缘2 小时前
本地化JSON 处理新方案:基于 Docker的JSON Hero部署全记录
java·docker·json
摩尔曼斯克的海2 小时前
力扣面试题--双指针类
python·算法·leetcode
御坂10101号2 小时前
「2>&1」是什么意思?半个世纪的 Unix 谜题
java·数据库·bash·unix
灰色小旋风2 小时前
力扣——第7题(C++)
c++·算法·leetcode
Java基基3 小时前
Spring让Java慢了30倍,JIT、AOT等让Java比Python快13倍,比C慢17%
java·开发语言·后端·spring
future02103 小时前
Spring AOP核心机制:代理与拦截揭秘
java·开发语言·spring·面试·aop