7.2 KiB
C++算法将以 链表(二)
一、 引言
1.1 问题引入:静态链表的局限性
前面我们学习了用数组来模拟链表(静态链表),比如开一个 Next[1005] 和 Prev[1005] 数组。这种方法在算法竞赛中很常见,因为它写起来快。但是它有两个缺点:
- 空间浪费或溢出:你必须提前估计好元素的最大数量(比如开 1000 万个大小的数组)。如果最终只有 10 个人排队,剩下的 999 万多个位置就白白浪费了;如果来了 1001 万个人,程序就会因为数组越界而崩溃。
- 值与下标的绑定:如果我们要存的数据本身非常大(比如学生的学号是
202300001),我们显然不能开一个大小为 2亿 的数组来把学号当作下标。
1.2 价值阐述
有没有一种办法:来一个人,我就找计算机要一个刚好的位置;走一个人,我就把这个位置还给计算机?完全按需分配,一点都不浪费?
这就是我们今天要学习的——动态内存分配与指针。基于此构建的动态链表,突破了数组容量的限制,是计算机底层系统中极为常见的数据结构。同时,我们还将学习 C++ 已经为我们造好的双向链表工具——list。
二、 动态链表的核心:指针与 new/delete
2.1 定义节点(struct Node)
在静态链表中,用数组的下标来表示位置(Next[i] = j)。
在动态链表中,直接用内存的真实地址来表示位置。存放地址的变量,叫做指针。
struct Node {
int value; // 数据域:节点存储的值
Node* prev; // 指针域:指向前驱节点的地址
Node* next; // 指针域:指向后继节点的地址
};
注意:Node* 中的星号 *,代表这是一个指针类型。prev 和 next 里存的不是一个数字,而是一个内存地址。如果一个节点没有后继(比如它是尾节点),我们让指针指向一个特殊的空地址:nullptr。
2.2 向操作系统 要 和 还 内存
-
要内存 (
new): 当一个新元素加入时,我们需要在内存中为他申请一块空间。Node* p = new Node;这句代码做了三件事:
new Node:计算机在堆区找到一块刚好能放下Node结构体的内存空间。- 返回这块内存空间的内存地址。
Node* p = ...:把这个地址保存在指针变量p中。以后我们就可以通过p找到这个新元素了。
-
访问指针指向的内容 (
->): 因为p存的是地址,我们不能用普通的.来访问数据,必须用箭头符号->(代表顺着地址走过去看)。p->value = 10; // 把这块内存里的 value 设置为 10 p->next = nullptr; // 把这块内存里的 next 设置为空 -
还内存 (
delete): 当再也不需要使用该元素时,就必须把这块空间还给操作系统,否则会导致内存泄漏。delete p; // 释放 p 指向的那块内存空间
三、 动态双向链表的实战操作
掌握了 new 和 ->,那么动态链表的操作逻辑和静态链表完全一模一样,只是写法换成了指针!
3.1 查找节点 (O(n))
在动态链表中,我们只有头节点 head 的地址,要找某个值,必须从头挨个顺着 next 指针往下问。
Node* findNode(Node* head, int val) {
Node* current = head; // 从起点出发
while (current != nullptr) { // 只要还没走到空(尽头)
if (current->value == val) {
return current; // 找到了,返回这个节点的地址
}
current = current->next; // 没找到,顺着指针去下一个节点
}
return nullptr; // 找遍了也没找到,返回空地址
}
3.2 插入节点:ins_back(x, p)
场景:找到节点 x,把新创建的节点 p 插在 x 和它的后继 z 之间。
void ins_back(Node* x, Node* p) {
Node* z = x->next; // 提前找到 x 的后继 z
// 设置新节点 p 的前后驱
p->next = z; // p 的后继连向 z
p->prev = x; // p 的前驱连向 x
// 更新原有的 x 和 z
x->next = p; // x 的后继改为 p
if (z != nullptr) { // 如果 z 存在(x 不是尾节点)
z->prev = p; // z 的前驱改为 p
}
}
3.3 删除节点:del(x)
场景:将节点 x 从链表中删除,并释放内存。
// 全局变量有 Node* head 指向开始元素的头指针; 和指向结束位置的尾指针 Node* tail;
void del(Node* x) {
Node* L = x->prev; // x 的前驱
Node* R = x->next; // x 的后继
// 处理前驱的连接
if (L != nullptr) {
L->next = R; // 前驱直接连向后继
} else {
head = R; // 如果没有前驱,说明 x 是头节点,删除后头节点变更为 R
}
// 处理后继的连接
if (R != nullptr) {
R->prev = L; // 后继直接连向前驱
} else {
tail = L; // 如果没有后继,说明 x 是尾节点,更新尾节点为 L
}
delete x; // 手动释放 x 占用的内存空间
}
四、 C++ 标准库:list
在日常开发和算法竞赛中,C++ 标准库(STL)已经为我们写好了一个方便、极其强大的双向链表工具:list。而使用前需要包含头文件:#include <list>
4.1 创建与初始化
#include <list>
using namespace std;
list<int> a; // 创建一个空的存放 int 的双向链表
list<int> l = {1, 2, 3}; // 创建并初始化为 1, 2, 3
// 获取元素数量
long long count = l.size();
4.2 特定增删操作 (O(1))
- 首尾操作:
- 在头部/尾部插入元素:
l.push_front(x);/l.push_back(x); - 删除头部/尾部元素:
l.pop_front();/l.pop_back();
- 在头部/尾部插入元素:
- 如何表示每个元素的位置?:
STL 提供了迭代器,用于表示链表中的位置。
- 指向第一个有效元素:
auto b_it = l.begin(); - 指向最后一个元素的下一个位置(没有实际元素,仅作为结束标志):
auto e_it = l.end(); - 移动:
it++(向后移),it--(向前移)。 - 访问迭代器指向的元素(解引用):
cout << *b_it;,其中b_it是指向第一个有效元素的迭代器,那么本代码会输出第一个元素的值。
- 指向第一个有效元素:
- 指定位置增删 (O(1)):
- 在迭代器
it指向的位置前插入x:l.insert(it, x); - 删除
it指向的元素:l.erase(it);
- 在迭代器
4.3 遍历链表
遍历链表更加推荐使用基于范围的 for 循环:
list<int> a = {10, 20, 30};
// val 依次代表链表 a 中的每一个元素
for (int val : a) {
cout << val << " ";
}
// 输出: 10 20 30
// 也可以使用传统的 for 循环方式
for (auto it = a.begin(); it != a.end(); it++) {
// 使用 *it 解引用来获取迭代器指向的元素值
cout << *it << " ";
}