算法 --链表

链表(Linked List)是数据结构中最基础、最高频,也是最考验指针操作功底的题型。它不像数组那样在内存中连续存储,而是通过指针将零散的内存块串联起来。

链表的本质与优缺点

1. 物理结构:

链表由一个个"节点(Node)"组成,每个节点包含两部分:

  • 数据域(val):存储数据。

  • 指针域(next) :存储下一个节点的内存地址。

    在 C++ 中通常定义为:

    cpp 复制代码
    struct ListNode {
        int val;
        ListNode *next;
        ListNode() : val(0), next(nullptr) {}
        ListNode(int x) : val(x), next(nullptr) {}
    };

    2. 与数组(Array)的核心对比:

  • 优点 :增删极快。只要知道前驱节点,插入或删除节点只需要改变指针指向,时间复杂度 O(1)。且不需要连续内存,内存利用率高。

  • 缺点 :查询极慢。不能像数组那样通过下标 O(1) 随机访问。要访问第 K 个节点,必须从头遍历,时间复杂度 O(K)。

  • 算法题的启示 :正因为链表"查慢增删快",链表题的考点几乎全部集中在**"如何在不破坏链表结构的前提下,高效地遍历和重新连接指针"**。

题目一:两数相加

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* addTwoNumbers(ListNode* l1, ListNode* l2) {
        ListNode* cur1 = l1;
        ListNode* cur2 = l2;
        ListNode* newhead = new ListNode(0);
        ListNode* prev = newhead;
        int t = 0;
        while (cur1 || cur2 || t) {
            if (cur1 != nullptr) {
                t += cur1->val;
                cur1 = cur1->next;
            }
            if (cur2 != nullptr) {
                t += cur2->val;
                cur2 = cur2->next;
            }

            prev->next = new ListNode(t % 10);
            prev = prev->next;
            t /= 10;
        }
        prev = newhead->next;
        delete newhead;

        return prev;
    }
};

算法思想:模拟竖式加法 + 遍历链表

这道题本质上是小学数学的加法:从最低位开始相加,逢十进一。因为链表是逆序存储的(个位在头节点),正好符合我们从低位到高位的计算顺序。我们只需遍历两个链表,逐位相加并处理进位即可。

逐行代码逻辑

  1. ListNode* cur1 = l1; ListNode* cur2 = l2;:分别指向两个链表的头部,用于遍历。

  2. ListNode* newhead = new ListNode(0);:创建一个虚拟头节点,值为0。

  3. ListNode* prev = newhead;:prev 作为尾指针,始终指向新链表的最后一个节点。

  4. int t = 0;:t 代表当前位的和以及进位。

  5. while(cur1 || cur2 || t):循环条件很关键,只要两个链表还有节点,或者进位 t 不为0,就继续循环。

  6. t += cur1->val;:累加 l1 当前节点的值。

  7. t += cur2->val;:累加 l2 当前节点的值。

  8. prev->next = new ListNode(t % 10);:取 t 的个位数,创建新节点,并接到结果链表末尾。

  9. prev = prev->next;:尾指针后移。

  10. t /= 10;:t 除以10,得到进位,用于下一轮循环。

  11. prev = newhead->next; delete newhead; return prev;:获取真正的头节点,释放虚拟头节点,返回结果。

题目二:两两交换链表中的节点

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;
        ListNode* cur1 = head;
        ListNode* cur2 = head->next;
        ListNode* newHead = new ListNode(0);
        newHead->next = head;

        ListNode* prev = newHead;

        while(cur1&&cur2)
        {
            prev->next=cur2;
            cur1->next=cur2->next;
            cur2->next=cur1;

            prev=cur1;
            cur1=cur1->next;
            if(cur1) 
            {
                cur2 = cur1->next; 
            } else 
            {
                cur2 = nullptr;
            }
        }
        
        ListNode* res = newHead->next;
        delete newHead;
        return res;
    }
};

算法思想:指针交换 + 虚拟头节点

我们需要遍历链表,每次处理两个相邻节点。核心操作是改变三个指针的指向:上一组的尾节点、当前组的第一个节点、当前组的第二个节点。由于头节点也可能被交换,使用虚拟头节点可以统一处理。

逐行代码逻辑

  1. if(head == nullptr || head->next == nullptr) return head;:处理边界情况。空链表或只有一个节点,无需交换,直接返回。

  2. ListNode* cur1 = head; ListNode* cur2 = head->next;:初始化当前要处理的两个节点。

  3. ListNode* newHead = new ListNode(0); newHead->next = head;:创建虚拟头节点,并指向原头节点。

  4. while(cur2):循环条件。当 cur2 存在时,说明至少有两个节点可以交换。

  5. cur1->next = cur2->next;:第1步 ,将 cur1 指向 cur2 的下一个节点(即让 cur1 指向下一组的开头)。

  6. cur2->next = cur1;:第2步 ,将 cur2 指向 cur1,完成交换。

  7. cur1 = cur1->next; cur2 = cur1->next;:第3步,更新指针,准备处理下一组。

题目三:重排链表

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:
    void reorderList(ListNode* head) 
    {
        if (head == nullptr || head->next == nullptr ||
            head->next->next == nullptr)
            return;
    }

    ListNode *slow = head, *fast = head;
    while(fast && fast->next) 
    {
        slow = slow->next;
        fast = fast->next->next;
    }

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

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

算法思想:快慢指针找中点 + 头插法反转后半段 + 合并两个链表

这是一道综合性很强的题目,分为三个清晰的步骤:

  1. 用快慢指针找到链表中点。

  2. 将中点后的后半部分链表用头插法进行反转。

  3. 将前半部分与反转后的后半部分交替合并。

逐行代码逻辑

  1. if (head == nullptr || head->next == nullptr || head->next->next == nullptr) return;:处理边界。节点数少于3时,重排后顺序不变。

  2. ListNode *slow = head, *fast = head;:初始化快慢指针。

  3. while (fast && fast->next):快指针每次走两步,慢指针每次走一步。循环结束时,slow 位于中间节点。

  4. ListNode* head2 = new ListNode(0);:为反转后半段创建虚拟头节点。

  5. ListNode* cur = slow->next;:cur 指向后半段的第一个节点。

  6. slow->next = nullptr;:关键步骤!断开前后两半,使前一半成为独立链表。

  7. while(cur) { ... }:头插法反转链表 。next = cur->next; (保存下一个);cur->next = head2->next; (指向新头部);head2->next = cur; (新头接管);cur = next; (后移)。

  8. ListNode* ret = new ListNode(0); ListNode* prev = ret;:创建最终结果链表的虚拟头节点和尾指针。

  9. ListNode* cur1 = head, *cur2 = head2->next;:分别指向前半段和反转后的后半段头部。

  10. while(cur1):循环合并。因为前半段长度一定大于等于后半段,所以以 cur1 为主循环条件。

  11. prev->next = cur1; cur1 = cur1->next; prev = prev->next;:先放第一个链表的节点。

  12. if(cur2) { prev->next = cur2; cur2 = cur2->next; prev = prev->next; }:再放第二个链表的节点(如果有的话)。

  13. 结尾:释放虚拟头节点 delete ret;,返回 ret->next。

题目四:合并K个升序链表

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 {
    struct cmp {
        bool operator()(const ListNode* l1, const ListNode* l2) {
            return l1->val > l2->val;
        }
    };

public:
    ListNode* mergeKLists(vector<ListNode*>& lists) {
        priority_queue<ListNode*, vector<ListNode*>, cmp> heap;

        for (auto l : lists) {
            if (l)
                heap.push(l);
        }

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

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

算法思想:优先队列(最小堆)

面对 K 个有序链表,每次只需要从 K 个链表的"当前头部"中选出最小的那一个,接到结果链表上。这个过程正好可以用"最小堆"来实现,堆顶永远是当前的最小值。

逐行代码逻辑

  1. struct cmp { bool operator()(const ListNode* l1, const ListNode* l2) { return l1->val > l2->val; } };:自定义比较器。在优先队列中,返回 true 表示 l1 优先级低于 l2。这里用 > 使得值小的优先级高,从而构造小根堆。

  2. priority_queue<ListNode*, vector<ListNode*>, cmp> heap;:声明一个存放 ListNode* 的小根堆。

  3. for(auto l : lists) { if(l) heap.push(l); }:将所有链表的头节点放入堆中。注意 :必须判断 if(l),因为 lists 中可能包含空链表。

  4. ListNode* ret = new ListNode(0); ListNode* prev = ret;:创建虚拟头节点和尾指针。

  5. while(!heap.empty()):只要堆非空,就继续合并。

  6. ListNode* t = heap.top(); heap.pop();:取出堆顶节点(全局最小值),并从堆中移除。

  7. prev->next = t; prev = t;:将取出的节点连接到结果链表末尾。

  8. if(t->next) heap.push(t->next);:关键步骤!如果取出的节点后面还有节点,必须将它的下一个节点入堆,参与后续的比较。

  9. 结尾:释放虚拟头节点 delete ret;,返回 ret->next。

题目五:K个一组反转链表

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* reverseKGroup(ListNode* head, int k) 
    {
        // 1. 计算链表的总长度
int n = 0;
ListNode* cur = head;
while(cur)
{
    cur = cur->next;
    n++;
}
n /= k; // 计算出一共需要反转多少组

// 2. 初始化指针
ListNode* newHead = new ListNode(0); // 总虚拟头节点,用于统一处理头部
ListNode* prev = newHead;            // 核心指针 始终指向上一组反转后的尾节点
cur = head;                          // 重新指向原链表头部,准备开始反转

// 3. 外层循环:固定循环 n 次(n 是总组数)
for(int i = 0; i < n; i++)
{
    ListNode* tmp = cur; // 【极其关键】记录这一组反转前的第一个节点
    
    // 4. 内层循环:对每一组进行长度为 k 的头插法反转
    for(int j = 0; j < k; j++)
    {
        ListNode* next = cur->next; // 保命步:先存下一个节点,防断链
        cur->next = prev->next;     // 断旧连新:当前节点指向 prev 后面的节点
        prev->next = cur;           // 新头接管:prev 接管当前节点
        cur = next;                 // 指针推进:cur 后移
    }
    
    // 5. 一组反转完毕后的收尾工作
    prev = tmp; // 精髓所在 将 prev 更新为这一组原来的头节点(即现在的尾节点)
}

// 6. 拼接不需要反转的剩余部分
prev->next = cur; 

// 7. 返回最终结果并释放内存
cur = newHead->next;
delete newHead;
return cur;
    }
};

算法思想:分组头插法 + 宏观指针拼接

这道题的核心难点在于:如何把反转好的局部链表,重新天衣无缝地拼回原链表中?

  1. 先遍历一遍 ,算出链表总长度 n。

  2. 计算需要反转的组数 :n / k。这就意味着剩下的 n % k 个节点完全不需要处理,直接留在原地即可。外层 for 循环次数固定,不需要在循环内部去判断"剩余节点够不够 K 个"。

  3. 外层循环控制组数,内层循环执行"头插法反转" 。每次反转 K 个节点,并且利用一个 prev 指针记录上一组的尾部,用于承接下一组。

这五道题几乎覆盖了链表操作的所有核心场景:遍历、增删、反转、合并、分组。

核心重难点(算法思想层面)

1. 虚拟头节点------ 链表的"万能钥匙"

在这五道题中,几乎每一题都用到了虚拟头节点(两数相加、两两交换、重排链表、合并K个、K个一组翻转)。

  • 作用:将"头节点"的特殊情况普通化。如果没有它,你需要在每个操作前判断"我是不是在操作第一个节点",代码会变得极其臃肿。

  • 重难点 :虚拟头节点不仅用于"构建结果链表",还用于"作为前置节点辅助翻转"(如K个一组中的 prev)。

  • 口诀:只要涉及头节点可能改变的链表操作,先建 虚拟头节点。

2. 指针的"保存与推进" ------ 链表的"命脉"

链表不像数组可以通过下标随机访问,一旦指针断开,后续节点就永远丢失了。

  • 重难点 :在改变任何一个节点的 next 指向之前,必须先保存它的原始后继节点。

  • 实战体现:

    • 反转链表中的 next = cur->next;(先存后断)。

    • K个一组翻转中的 tmp = cur;(保存本组开头,反转后会变成尾部)。

    • 合并链表中的 cur1 = cur1->next;(推进指针)。

3. 快慢指针 ------ 找中点/判环的神器

  • 重难点 :slow 走一步,fast 走两步。循环条件 while(fast && fast->next) 决定了 slow 最终停在哪里。

  • 实战体现 :在"重排链表"中,正是利用快慢指针找到中点,才得以将链表一分为二。要注意区分节点总数的奇偶性对 slow 位置的影响。

深度易错点(代码实现层面)

1. 空指针解引用

这是链表题最致命的错误,通常发生在以下三个场景:

  • 遍历时越界 :while(cur->next) 没判断 cur 是否为空。正确写法:while(cur && cur->next)。

  • 累加值时越界 :如"两数相加"中 t += cur1->val;,如果 cur1 已经是 nullptr,直接崩溃。必须在前面加 if(cur1)。

  • 推进指针时越界 :如"两两交换"中,如果 cur1 已经变成 nullptr,再去 cur2 = cur1->next; 就会崩溃。先更新 cur2,再更新 cur1 是安全的顺序。

2. 指针丢失与断链(逻辑错误)

  • 反转时的断链 :执行 cur->next = prev; 之前,如果没有保存 cur->next,后面的链表就全丢了。

  • 合并时的断链 :合并两个链表时,如果先移动了 cur1,再想用 cur1 去接后面的节点,就会发现 cur1 已经指向别处了。必须先用 prev->next 把节点接上,再移动 cur1。

3. 循环终止条件写错(死循环或漏节点)

  • 合并K个(优先队列版) :while(!heap.empty()) 没问题,但循环内部如果忘记 if(t->next) heap.push(t->next);,那么取出的节点后面的所有节点都会丢失。

  • 重排链表 :合并时如果用 while(cur1 && cur2),奇数个节点时,前半段最后一个节点会被遗漏。以较长的链表作为循环条件 (while(cur1))才是安全的。

  • K个一组翻转 :如果内层循环写成 while(cur) 而不是 for(int j = 0; j < k; j++),那么当剩余节点不足 K 个时,也会被错误地翻转。

4. 内存泄漏

  • 在 C++ 中,new 出来的虚拟头节点,在返回结果前必须 delete。

  • 如"重排链表"中的 delete ret;,"两数相加"中的 delete newhead;。

  • 进阶技巧 :在"合并K个(分治版)"中,使用栈上局部变量 ListNode head; 替代 new ListNode(0),既不需要 delete,效率也更高。

5. 虚拟头节点忘记断开原连接

  • 在"重排链表"中,找到中点后,必须执行 slow->next = nullptr;。如果不切断,前半段的尾节点仍然指向后半段,合并时就会形成环,导致程序死循环。

思维框架

面对任何链表题:

  1. 画图:不要空想,在纸上画出指针的初始状态和目标状态。

  2. 设虚拟头节点 :除非明确知道头节点不会改变(如纯粹的遍历),否则一律先建 dummy。

  3. 定循环条件 :明确循环结束的边界是 nullptr 还是某个特定节点,注意 && 的短路特性。

  4. 先存后改 :在修改任何 next 指针前,先确认自己已经保存了所有必要的后继节点。

  5. 收尾检查:

    • 最后返回的是不是 dummy->next?

    • 有没有节点被遗漏(尤其是尾部)?

    • 有没有动态内存没有释放?

相关推荐
圣保罗的大教堂2 小时前
leetcode 2333. 最小差值平方和 中等
leetcode
aqiu1111112 小时前
【C++算法打怪专栏】C++ `unordered_set` 常用初始化与插入方法总结
开发语言·c++·算法
Terra.K2 小时前
高精度阶乘求和模板深度解析
开发语言·c++·算法
Peregrine93 小时前
C++ - > 类和对象
java·c++·算法
Gigavision4 小时前
DEAP数据集 2026最新Transformer算法 DEAP 数据集 下载
深度学习·算法·transformer
夜之眷属4 小时前
PriorityQueue:for-each 遍历和 poll 取出,顺序一样吗?
java·服务器·数据结构·后端·算法
思维新观察5 小时前
三星 Galaxy Tab S12 系列:大屏 AI 平板,兼顾轻薄与生产力
数据结构·人工智能·电脑
地平线开发者5 小时前
不是线程越多越快:用 hrt_model_exec 在 征程6M 上给 YOLOv5x 建立性能基线
算法
姜鱼问生6 小时前
fail2ban 实战:从默认配置到生产可用
后端·算法·架构