【数据结构与算法基础】04. 线性表与链表详解(C++ 实战)
【数据结构与算法基础】04. 线性表与链表详解(C++ 实战)
- 掌握线性表的两种实现:**顺序表(数组/顺序存储)与链表(指针/链式存储)**的核心差异与适用场景。
- 熟练实现单链表的增删改查、双向链表与循环链表的指针操作。
- 熟练应用链表技巧:翻转、快慢指针找中点、判环、每 k 个一组翻转。
- 能够基于业务语义在随机访问多与增删频繁之间做出合理选型。
(关注不迷路哈!!!)
文章目录
- 【数据结构与算法基础】04. 线性表与链表详解(C++ 实战)
-
- 一、数据结构核心概念与方法论
-
- 1.1 数据处理的本质
- 1.2 方法论(三步法)
- 1.3 复杂度权衡
- 1.4 数据结构的引入
- 二、线性表概述与链表类型
-
- 2.1 线性表的定义
- 2.2 链表的类型
- 三、线性表对于数据的增删查处理(以单向链表为例)
-
- 3.1 增加操作
- 3.2 删除操作
- 3.3 查找操作
- 3.4 复杂度总结
- 四、线性表案例
-
- 4.1 链表的翻转
- 4.2 查找链表中间位置的结点数值
- 4.3 判断链表是否有环
- 五、链表高频算法题精要与模板
-
- 5.1 单链表翻转(迭代,返回新头结点)
- 5.2 快慢指针找中点(奇数长度场景)
- 5.3 判断链表是否有环(Floyd 判圈)
- 5.4 每 k 个节点一组翻转(打印/返回新头结点)
- 六、 C++ 工程案例实践
-
- 6.1 案例代码
- 6.2 输出结果
- 6.3 代码说明
- 6.3 代码功能
一、数据结构核心概念与方法论
1.1 数据处理的本质
数据处理的本质可抽象为 3 个基本操作:增、删、查。
- 增:向数据结构中写入新数据,可细分为“在末尾增”和“在中间增”。
- 删:移除数据,可细分为“在末尾删”和“在中间删”。
- 查:按条件定位数据,可细分为“按位置/索引查找”和“按数值/特征查找”。
- “改”的本质可分解为先查后删再增,因此仍归约到上述三类操作。
1.2 方法论(三步法)
- 明确代码对数据先后进行了哪些操作。
- 找出其中对时间复杂度影响最大的“热点操作”。
- 选择能将该热点操作降至或接近 O(1) 的数据结构。
1.3 复杂度权衡
-
空间换时间是常用策略。
-
某些结构虽占用 O(n) 空间,但可将关键操作从 O(n) 降至 O(1),从而显著降低整体时间复杂度。
1.4 数据结构的引入
当有了一定数量的数据时,需要考虑以什么样的方式对这些数据进行组织,这就是数据结构。
- 如同幼儿园小朋友组织运动会站队有多种方式(站成一横排、站成方阵、围成大圆圈等)
- 计算机处理大量数据时也需要考虑数据的组织方式,实际开发中经过验证且高效的常用数据结构有限,掌握这些就能成为合格软件工程师。
二、线性表概述与链表类型
2.1 线性表的定义
-
线性表定义:线性表是由 n 个数据元素构成的有限序列,是最基础、最常用的数据结构之一。通常也叫作线性链表或者链表。
-
典型应用:排队/先到先得(可用队列建模)、日志/消息顺序处理、多项式/稀疏数据等。
-
在链表中存储的数据元素也叫作结点,每个结点(结点结构)包含:
-
数据域:存放具体数据值;
-
指针域:指向下一个结点(单链表)。
-
-
链表的头指针
- 在链表的最前面,通常会有个头指针用来指向第一个结点。
-
链表的尾结点
- 链表的最后一个结点,由于在它之后没有下一个结点,因此它的指针是个空指针(nullptr)。
2.2 链表的类型
类型 | 遍历方向 | 典型优点 | 典型缺点 |
---|---|---|---|
单向链表 | 单向 | 实现简单、空间开销小 | 无法反向遍历;插入/删除需前驱 |
双向链表 | 双向 | 可双向遍历;删除任意结点更便捷 | 每结点多一个指针,空间开销更大 |
循环链表 | 单向循环 | 无明确尾;循环遍历方便 | 不慎易造成无限循环 |
双向循环链表 | 双向循环 | 两端与环上操作都便捷 | 实现与维护复杂度更高 |
选型要点(速记):
- 只需单向、尾部频繁插入且不需回溯 → 单向链表。
- 需要前驱或双向遍历 → 双向链表。
- 需要循环语义(如约瑟夫、轮询) → 循环/双向循环链表。
这些种类的链表都是以 单向链表为基础 进行的变种
三、线性表对于数据的增删查处理(以单向链表为例)
3.1 增加操作
-
操作描述:在链表中某个结点之后插入一个新结点。例如在一个存储了 10 个同学考试成绩的链表中,在红色结点之后插入一个忘记存储的成绩。
-
操作方法:把待插入结点的指针指向原指针的目标,把原来的指针指向待插入的结点。
-
时间复杂度:O(1),但实际新增数据时通常会伴随查找动作,整体复杂度可能为 O(n)。
3.2 删除操作
- 操作描述:删除链表中误操作放进的某个成绩样本结点。
- 操作方法:如果待删除的结点为 b,把指向 b 的指针 (p.next),指向 b 的指针指向的结点(p.next.next)。
- 时间复杂度:O(1),但查找要删除的结点可能需要 O(n),整体复杂度可能为 O(n)。
3.3 查找操作
- 按位置序号查找
- 操作描述:类似于数组中的 index,例如在一个按学号存储了 10 个同学考试成绩的链表中,查找学号等于 5 的同学的成绩。
- 操作方法:从头开始,一个一个地遍历去查找,先找到学号为 1 的同学,再经过他跳转到学号为 2 的同学,直到找到学号为 5 的同学。
- 时间复杂度:O(n)。
- 按具体成绩查找
- 操作描述:在一个存储了 10 个同学考试成绩的链表中,查找是否有人得分为 95 分。
- 操作方法:判断第一个结点的值是否等于 95,如果不是,则通过指针去判断下一个结点的值是否等于 95,以此类推,直到把所有结点都访问完。
- 时间复杂度:O(n)。
3.4 复杂度总结
链表在新增、删除数据理论上可在 O(1) 的时间复杂度内完成,但实际中新增或删除数据往往伴随查找动作,整体复杂度常为 O(n)。查找不管按位置还是按数值条件,都需遍历全部数据,时间复杂度为 O(n)。
- 线性表真正的价值在于其按顺序存储数据,当数据元素个数不确定且需要经常进行数据的新增和删除时,链表比较合适;
- 若数据元素大小确定且删除插入操作不多,数组可能更适合。
数据结构 | 按索引查找 | 末尾增/删 | 中间增/删 | 典型适用场景 |
---|---|---|---|---|
数组 Array | O(1) | 末尾O(1)(均摊) 开头/中间O(n) | 开头/中间O(n) | 随机访问 固定大小 尾部频繁写入 |
链表 Linked List | O(n) | 尾插O(1)(有尾指针) 头插O(1) | 任意位置O(n) | 频繁在头尾插入/删除 无需随机访问 |
四、线性表案例
4.1 链表的翻转
- 问题描述:给定一个链表,输出翻转后的链表,例如输入 1 -> 2 -> 3 -> 4 -> 5,输出 5 -> 4 -> 3 -> 2 -> 1。
- 难点分析:数组翻转容易,因为数组在连续空间存储,可通过索引查找元素并交换完成翻转;而单向链表指针结构使数据通路有去无回,修改指针会导致后面数据失联。
- 解决方法:构造三个指针 prev、curr 和 next,对当前结点、以及它之前和之后的结点进行缓存,再完成翻转动作。
4.2 查找链表中间位置的结点数值
- 问题描述:给定一个奇数个元素的链表,查找出这个链表中间位置的结点的数值。
- 解决方法
- 暴力方法:先通过一次遍历去计算链表的长度,知道链表中间位置是第几个,接着再通过一次遍历去查找这个位置的数值。
- 巧妙方法(快慢指针):利用快慢指针进行处理,快指针每次循环向后跳转两次,而慢指针每次向后跳转一次。当快指针到达末尾时,慢指针指向中点。
4.3 判断链表是否有环
- 问题描述:判断一个链表是否有环,例如图中所示的有环链表。
- 解决方法:使用快慢指针方法。假设链表有环,快指针每次走两格,慢指针每次走一格,快指针每次循环会多走一步,所以如果链表存在环,快指针和慢指针一定会在环内相遇(fast == slow);反之,则最终会完成循环,二者从未相遇。
五、链表高频算法题精要与模板
5.1 单链表翻转(迭代,返回新头结点)
- 三指针:prev = nullptr, curr = head, next = nullptr
- 循环体:保存 next;反转指针;移动 prev/curr。
- 时间复杂度:O(n);空间复杂度:O(1)。
struct ListNode {int val;ListNode *next;ListNode(int x) : val(x), next(nullptr) {}
};ListNode* reverseList(ListNode* head)
{ListNode *prev = nullptr, *curr = head;while (curr){ListNode* next = curr->next;curr->next = prev;prev = curr;curr = next;}return prev;
}
5.2 快慢指针找中点(奇数长度场景)
- fast 每次走 2 步,slow 每次走 1 步;fast 到末尾时,slow 指向中点。
- 时间复杂度:O(n);空间复杂度:O(1)。
ListNode* findMiddle(ListNode* head)
{if (!head) return nullptr;ListNode *slow = head, *fast = head;while (fast->next && fast->next->next){slow = slow->next;fast = fast->next->next;}return slow; // 奇数:正中;偶数:上中
}
5.3 判断链表是否有环(Floyd 判圈)
- 快慢指针同启;若存在环,必相遇(fast == slow);否则快指针先到链表尾。
- 时间复杂度:O(n);空间复杂度:O(1)。
bool hasCycle(ListNode *head)
{if (!head || !head->next) return false;ListNode *slow = head, *fast = head;while (fast && fast->next){slow = slow->next;fast = fast->next->next;if (slow == fast) return true;}return false;
}
5.4 每 k 个节点一组翻转(打印/返回新头结点)
- 思路:外部循环按组推进,组内复用“链表翻转”;组间用前驱指针串联结果。
- 边界:k 合法性、链表长度不是 k 的整数倍时的处理策略(按题意决定最后一组是否翻转)。
ListNode* reverseKGroup(ListNode* head, int k)
{if (k <= 1) return head;ListNode dummy(0);dummy.next = head;ListNode *prev = &dummy;while (ListNode* grpHead = prev->next){ListNode *tail = grpHead;int step = 1;while (tail && step < k){tail = tail->next;++step;}if (!tail) break; // 不足 k 个,不再翻转ListNode *nextGrp = tail->next;tail->next = nullptr; // 断开当前组prev->next