【优选算法篇】深入浅出链表算法:交换、重排与合并的终极策略

文章目录

    • [一、 核心原理:链表指针操作的本质](#一、 核心原理:链表指针操作的本质)
    • [二、 两两交换链表中的节点 (Medium)](#二、 两两交换链表中的节点 (Medium))
      • [2.1 题目描述](#2.1 题目描述)
      • [2.2 深度拆解:画图画图画图](#2.2 深度拆解:画图画图画图)
      • [2.3 C++ 代码实战](#2.3 C++ 代码实战)
    • [三、 重排链表 (Medium)](#三、 重排链表 (Medium))
      • [3.1 题目描述](#3.1 题目描述)
      • [3.2 深度拆解:三步分解,各个击破](#3.2 深度拆解:三步分解,各个击破)
      • [3.3 C++ 代码实战](#3.3 C++ 代码实战)
    • [四、 合并 K 个升序链表 (Hard)](#四、 合并 K 个升序链表 (Hard))
      • [4.1 题目描述](#4.1 题目描述)
      • [4.2 深度拆解:两种思路的对比](#4.2 深度拆解:两种思路的对比)
      • [4.3 C++ 代码实战(解法一:小根堆)](#4.3 C++ 代码实战(解法一:小根堆))
      • [4.4 C++ 代码实战(解法二:分治递归)](#4.4 C++ 代码实战(解法二:分治递归))
    • [五、 K 个一组翻转链表 (Hard)](#五、 K 个一组翻转链表 (Hard))
      • [5.1 题目描述](#5.1 题目描述)
      • [5.2 深度拆解:分组 + 头插法](#5.2 深度拆解:分组 + 头插法)
      • [5.3 C++ 代码实战](#5.3 C++ 代码实战)
    • [六、 总结与避坑](#六、 总结与避坑)

一、 核心原理:链表指针操作的本质

💬 底层逻辑

链表题的核心永远是指针的重定向 。不同于数组可以随机访问,链表的每一次"操作"本质上都是在修改 next 指针的指向。

  1. 哨兵节点(虚拟头节点)new ListNode(0) 是链表题的万能润滑剂。它统一了"头节点也需要被修改"的边界情况,让代码逻辑更整洁。
  2. 画图是第一生产力 :链表指针绕来绕去,脑子里很难追踪。强烈建议动手画出每一步的指针变化,再翻译成代码,否则极易出现断链或空指针。
  3. 操作顺序至关重要:在重新连接指针时,顺序一旦错误,就会丢失对后续节点的引用,导致链表断裂。每次操作前务必确认"我还拿着我需要的节点吗?"

🚀 链表题通用模板

  • 虚拟头节点ListNode* dummy = new ListNode(0); dummy->next = head;
  • 多指针协作prevcurnextnnext 各司其职,提前保存好"待会儿还要用的节点"。
  • 善用头插法:逆序一段链表时,头插法是最简洁的实现方式。

二、 两两交换链表中的节点 (Medium)

2.1 题目描述

链接24. 两两交换链表中的节点

给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。必须实际交换节点,不能只修改节点内部的值。

2.2 深度拆解:画图画图画图

画图画图画图,重要的事情说三遍~

  • 四指针协作 :每次循环需要同时掌控 prevcurnextnnext 四个指针,分别对应交换区间的前驱、第一个节点、第二个节点、后继。

  • 交换三步走

    1. prev->next = next(前驱接到第二个节点)
    2. next->next = cur(第二个节点反指第一个)
    3. cur->next = nnext(第一个节点接到后继)
  • 指针后移的顺序prev = cur 必须在 cur = nnext 之前,因为 cur 此时已经是交换后的"尾节点",是下一轮的 prev。

2.3 C++ 代码实战

cpp 复制代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* swapPairs(ListNode* head) {
        if (head == nullptr || head->next == nullptr) return head;

        // 1. 虚拟头节点,统一边界处理
        ListNode* newHead = new ListNode(0);
        newHead->next = head;

        ListNode* prev = newHead, *cur = prev->next, *next = cur->next, *nnext = next->next;
        while (cur && next) {
            // 2. 交换节点:修改三条指针
            prev->next = next;
            next->next = cur;
            cur->next = nnext;

            // 3. 后移指针,注意顺序
            prev = cur; // prev 先移到 cur(此时 cur 是交换后的尾节点)
            cur = nnext;
            if (cur) next = cur->next;
            if (next) nnext = next->next;
        }

        cur = newHead->next;
        delete newHead;
        return cur;
    }
};

三、 重排链表 (Medium)

3.1 题目描述

链接143. 重排链表

给定单链表 L(0) → L(1) → ... → L(n),将其重排为 L(0) → L(n) → L(1) → L(n-1) → L(2) → L(n-2) → ...,不能只改变节点值,必须实际交换节点。

3.2 深度拆解:三步分解,各个击破

画图画图画图,重要的事情说三遍~

这道题直接模拟很难,但拆成三个子问题后,每一步都是经典操作:

  • 第一步:找中间节点 ------ 快慢双指针。fast 每次走两步,slow 每次走一步,fast 到头时 slow 就在中间。务必画图确认 slow 的落点,奇偶长度下落点不同。
  • 第二步:逆序后半段 ------ 头插法。将 slow->next 之后的所有节点头插到一个新的虚拟头节点后,自然得到逆序链表。注意要将前后两段断开slow->next = nullptr)。
  • 第三步:合并两条链表 ------ 双指针交替拼接。cur1 遍历前半段,cur2 遍历后半段,交替接到结果链表上。

3.3 C++ 代码实战

cpp 复制代码
class Solution {
public:
    void reorderList(ListNode* head) {
        if (head == nullptr || head->next == nullptr || head->next->next == nullptr) return;

        // 1. 找到链表的中间节点 - 快慢双指针(一定要画图考虑 slow 的落点在哪里)
        ListNode* slow = head, *fast = head;
        while (fast && fast->next) {
            slow = slow->next;
            fast = fast->next->next;
        }

        // 2. 把 slow 后面的部分给逆序 - 头插法
        ListNode* head2 = new ListNode(0);
        ListNode* cur = slow->next;
        slow->next = nullptr; // 注意把两个链表给断开
        while (cur) {
            ListNode* next = cur->next;
            cur->next = head2->next;
            head2->next = cur;
            cur = next;
        }

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

            // 再放第二个链表
            if (cur2) {
                prev->next = cur2;
                prev = prev->next;
                cur2 = cur2->next;
            }
        }
        delete head2;
        delete ret;
    }
};

四、 合并 K 个升序链表 (Hard)

4.1 题目描述

链接23. 合并 K 个升序链表

给你一个链表数组,每个链表都已按升序排列,请将所有链表合并到一个升序链表中并返回。

4.2 深度拆解:两种思路的对比

  • 解法一(小根堆) :合并两个有序链表时,双指针每次选较小的节点。推广到 K K K 个链表,我们需要每次快速找到 K K K 个头节点中最小的那个,这正是小根堆 的用武之地。将所有头节点入堆,每次弹出最小节点,接到答案链表,再将该节点的 next 压入堆,时间复杂度 O ( N log ⁡ K ) O(N \log K) O(NlogK)。

  • 解法二(分治/递归) :逐一合并时,答案链表越来越长,后面每个小链表的元素都要参与大量无效比较。分治思路让长度相近的链表两两合并 ,类比归并排序,总比较次数从 O ( N K ) O(NK) O(NK) 优化到 O ( N log ⁡ K ) O(N \log K) O(NlogK),常数更小,实践中更快。

4.3 C++ 代码实战(解法一:小根堆)

cpp 复制代码
class Solution {
    struct cmp {
        bool operator()(const ListNode* l1, const ListNode* l2) {
            return l1->val > l2->val; // 小根堆
        }
    };

public:
    ListNode* mergeKLists(vector<ListNode*>& lists) {
        // 1. 创建小根堆,让所有头节点入堆
        priority_queue<ListNode*, vector<ListNode*>, cmp> heap;
        for (auto l : lists)
            if (l) heap.push(l);

        // 2. 合并 K 个有序链表
        ListNode* ret = new ListNode(0);
        ListNode* prev = ret;
        while (!heap.empty()) {
            ListNode* t = heap.top(); heap.pop();
            prev->next = t;
            prev = t;
            if (t->next) heap.push(t->next); // 弹出后将其 next 补入堆
        }

        prev = ret->next;
        delete ret;
        return prev;
    }
};

4.4 C++ 代码实战(解法二:分治递归)

cpp 复制代码
class Solution {
public:
    ListNode* mergeKLists(vector<ListNode*>& lists) {
        return merge(lists, 0, lists.size() - 1);
    }

    ListNode* merge(vector<ListNode*>& lists, int left, int right) {
        if (left > right) return nullptr;
        if (left == right) return lists[left];

        // 1. 平分数组,让长度相近的链表合并
        int mid = left + right >> 1;
        ListNode* l1 = merge(lists, left, mid);
        ListNode* l2 = merge(lists, mid + 1, right);

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

    ListNode* mergeTwoList(ListNode* l1, ListNode* l2) {
        if (l1 == nullptr) return l2;
        if (l2 == nullptr) return l1;

        ListNode head;
        ListNode* cur1 = l1, *cur2 = l2, *prev = &head;
        head.next = nullptr;
        while (cur1 && cur2) {
            if (cur1->val <= cur2->val) { prev = prev->next = cur1; cur1 = cur1->next; }
            else                        { prev = prev->next = cur2; cur2 = cur2->next; }
        }
        if (cur1) prev->next = cur1;
        if (cur2) prev->next = cur2;
        return head.next;
    }
};

五、 K 个一组翻转链表 (Hard)

5.1 题目描述

链接25. K 个一组翻转链表

给你链表的头节点 head,每 k k k 个节点一组进行翻转,返回修改后的链表。若剩余节点不足 k k k 个,则保持原有顺序。

5.2 深度拆解:分组 + 头插法

本题逻辑清晰,难度在于细节处理

  • 分组计数 :先遍历链表求总长度 n n n,然后 n ÷ k n \div k n÷k 得到需要逆序的组数,剩余节点直接接上即可,不需要特判。
  • 组内逆序(头插法) :对每组的 k k k 个节点,依次将每个节点头插到 prev 之后。头插结束后,原来的组头节点 tmp 自然成为了该组的组尾,是下一轮的 prev
  • 关键变量 tmp :在头插开始前,先用 tmp = cur 记住当前组的第一个节点(头插结束后它会成为组尾),作为下一轮循环 prev 的新值。

5.3 C++ 代码实战

cpp 复制代码
class Solution {
public:
    ListNode* reverseKGroup(ListNode* head, int k) {
        // 1. 先求出需要逆序多少组
        int n = 0;
        ListNode* cur = head;
        while (cur) { 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
        }

        // 3. 把不需要翻转的剩余部分接上
        prev->next = cur;
        cur = newHead->next;
        delete newHead;
        return cur;
    }
};

六、 总结与避坑

💬 复盘:链表的指针操作是模板化程度很高的技巧,掌握以下规律后大部分题目都能迎刃而解。

  1. 虚拟头节点是标配 :只要头节点可能被修改(交换、合并、逆序),第一步就加 dummy,省去大量边界判断。
  2. 画图再动手:指针操作在脑子里极易绕晕,每道新题务必先画出节点变化图,再翻译成代码。
  3. 提前保存待用节点 :修改 cur->next 之前,必须先用临时变量保存 cur->next,否则后继节点将永久丢失。
  4. 头插法逆序的本质 :每次将 cur 插到 prev->next 的位置,循环 k k k 次后,原来从左到右的顺序自然变为从右到左。
  5. 分治优于逐一合并 :合并 K K K 个链表时,分治比逐一合并的实际运行时间快很多,因为每条链表的节点参与比较的总次数从 O ( K ) O(K) O(K) 降到 O ( log ⁡ K ) O(\log K) O(logK)。
相关推荐
Z_Wonderful10 小时前
大文件上传-分片上传-秒传
算法·哈希算法
heimeiyingwang10 小时前
【架构实战】分布式ID生成方案:雪花算法与业务ID设计
分布式·算法·架构
RuiZN10 小时前
UE5 蓝图 FPS 01 Event Tick
c++·ue5
A charmer11 小时前
零基础学OC:变量与基本数据类型(C++开发者速通版)[特殊字符]
开发语言·c++·objective-c
SoftLipaRZC11 小时前
C语言字符完全指南:字符函数与字符串函数
c语言·开发语言·算法
墨白曦煜11 小时前
算法实战笔记:链表的底层逻辑与指针的高阶玩法(二)
笔记·算法·链表
折哥的程序人生 · 物流技术专研11 小时前
《Java 100 天进阶之路》第40篇:浮点数转成十进制问题
java·开发语言·后端·面试·求职招聘
wuweijianlove11 小时前
算法复杂度评估的实验统计方法与可视化的技术7
算法
名不经传的养虾人11 小时前
从0到1:企业级AI项目迭代日记 Vol.35|追问比演示重要——技术团队问出的五个工程缺口
人工智能·算法·机器学习·ai编程·ai工作流·企业ai