算法分析:K 个一组翻转链表

104 阅读5分钟

【题目】 已知链表的头节点 head ,每 k 个节点一组进行翻转,请返回修改后的链表。
k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。
不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。
示例1: 输入:head = [1,2,3,4,5], k = 2 输出:[2,1,4,3,5]
示例2: 输入:head = [1,2,3,4,5], k = 3 输出:[3,2,1,4,5]
提示: 链表中的节点数目为 n 1 <= k <= n <= 5000 0 <= Node.val <= 1000

【方法一】 模拟
思路与算法:
1)需要把链表节点按照 k 个一组分组,所以可以使用一个指针 head 依次指向每组的头节点。这个指针每次向前移动 k 步,直至链表结尾。
2)对于每个分组,我们先判断它的长度是否大于等于 k。若是,我们就翻转这部分链表,否则不需要翻转。
3)对于一个子链表,除了翻转其本身之外,还需要将子链表的头部与上一个子链表连接,以及子链表的尾部与下一个子链表连接。
因此,在翻转子链表的时候,我们不仅需要子链表头节点 head,还需要有 head 的上一个节点 pre,以便翻转完后把子链表再接回 pre。
4)新建一个节点,把它接到链表的头部,让它作为 pre 的初始值,这样 head 前面就有了一个节点,我们就可以避开链表头部的边界条件。
5)反复移动指针 head 与 pre,对 head 所指向的子链表进行翻转,直到结尾,我们就得到了答案。

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; }
}

class Solution {
    public ListNode reverseKGroup(ListNode head, int k) {
        // 创建虚拟头节点,简化边界处理
        ListNode hair = new ListNode(0);
        hair.next = head;
        ListNode pre = hair;  // pre 指向当前待反转组的前一个节点

        while (head != null) {
            ListNode tail = pre;
            // 检查剩余节点是否足够 k 个
            for (int i = 0; i < k; ++i) {
                tail = tail.next;
                if (tail == null) {
                    return hair.next;  // 不足 k 个,直接返回结果
                }
            }
            ListNode nex = tail.next;  // 保存下一组的头节点

            // 反转当前组 [head, tail]
            ListNode[] reverse = doReverse(head, tail);
            head = reverse[0];  // 反转后的新头节点
            tail = reverse[1];  // 反转后的新尾节点

            // 将反转后的子链表接回原链表
            pre.next = head;  // 前一组的尾 -> 当前组的新头
            tail.next = nex;  // 当前组的新尾 -> 下一组的头

            // 移动指针,准备处理下一组
            pre = tail;
            head = tail.next;
        }

        return hair.next;  // 返回处理后的链表头
    }

    public ListNode[] doReverse(ListNode head, ListNode tail) {
		// 初始 prev 为 tail 的下一个节点(关键!)
		// 为什么prev初始化为tail.next?反转后,原头节点head需指向tail的后继,因此prev需初始化为tail.next。
        ListNode prev = tail.next;  
        ListNode p = head;
		// 反转循环条件prev != tail的意义? 确保反转范围仅包含当前组[head, tail],避免影响下一组。
        while (prev != tail) {  // 当 prev 到达 tail 时停止(反转到 tail 为止)
            ListNode nex = p.next;  // 保存下一个节点
            p.next = prev;  // 当前节点指向前驱
            prev = p;     // 前驱后移
            p = nex;      // 当前节点后移
        }
        return new ListNode[]{tail, head};  // 返回反转后的新头和新尾
    }
}

对于上面的代码,关键点解析如下。
1)虚拟头节点 hair:
避免处理头节点反转的特殊情况,简化边界条件。
2)分组检查:
使用tail指针从pre开始移动k步,若中途tail为null,说明剩余节点不足k个,直接返回。
3)反转逻辑:
doReverse函数反转从head到tail的节点:
初始条件:prev指向tail.next(即下一组的头节点)。
循环终止:当prev移动到tail时停止,确保只反转当前组。
返回值:{tail, head},即反转后的新头和新尾。
4)子链表连接:
反转后,pre.next指向新头节点,tail.next指向下一组的头节点nex,确保链表不断裂。
5)指针更新:
pre移动到当前组的新尾节点tail,head移动到下一组的头节点tail.next。
6)复杂度分析:
时间复杂度:O(n),其中 n 为链表的长度。head 指针会在 O(⌊n/k⌋) 个节点上停留,每次停留需要进行一次 O(k) 的翻转操作。
空间复杂度:O(1),我们只需要建立常数个变量。

采用C++代码实现的方案如下:

class Solution {
public:
    // 反转从head到tail的子链表,并返回新的头和尾节点
	// 返回值:使用pair返回反转后的新头和新尾,便于连接操作。pair:存储两个节点指针,分别表示反转后的新头和新尾。
    pair<ListNode*, ListNode*> myReverse(ListNode* head, ListNode* tail) {
        ListNode* prev = tail->next;  // prev初始化为尾节点的下一个节点
        ListNode* p = head;           // 当前处理的节点
        while (prev != tail) {        // 当prev未到达tail时继续反转
            ListNode* nex = p->next;  // 保存下一个节点
            p->next = prev;           // 当前节点指向前驱
            prev = p;                 // 前驱后移
            p = nex;                  // 当前节点后移
        }
        return {tail, head};  // 返回反转后的新头和新尾(原尾变为新头,原头变为新尾)
    }

    ListNode* reverseKGroup(ListNode* head, int k) {
		// 统一处理头节点可能被反转的情况,避免边界条件判断。
        ListNode* hair = new ListNode(0);  // 创建虚拟头节点
        hair->next = head;                 // 虚拟头指向原头节点
        ListNode* pre = hair;              // pre指向当前待反转组的前一个节点

        while (head) {  // 当剩余节点存在时继续处理
            ListNode* tail = pre;
            // 检查剩余节点是否足够k个
            for (int i = 0; i < k; ++i) {
                tail = tail->next;
                if (!tail) {  // 不足k个,直接返回结果
                    return hair->next;
                }
            }
            ListNode* nex = tail->next;  // 保存下一组的头节点

            // 反转当前组,获取新的头和尾
			// tie:C++17 特性,用于解构pair,直接赋值给head和tail。
            tie(head, tail) = myReverse(head, tail);
            
            // 将反转后的子链表接回原链表
            pre->next = head;  // 前一组的尾连接到新头
            tail->next = nex;  // 新尾连接到下一组的头
            
            // 移动指针到下一组
            pre = tail;        // pre移动到当前组的新尾
            head = tail->next; // head移动到下一组的头
        }

        return hair->next;  // 返回处理后的链表头
    }
};

对上面代码进行复杂度分析如下。
时间复杂度:O (n),每个节点被遍历两次(检查长度和反转)。
空间复杂度:O (1),仅使用常数级额外空间。