📖 第 2.7 章:指针与内存
⏱ 预计阅读时间:90 分钟 | 难度:🟡 入门到进阶(理解 C++ 的关键一环)
📝 前置条件: 第 2.3 章(函数、数组、按引用传递)、第 2.4 章(结构体与类)
很多初学者觉得指针「玄学」「容易崩溃」「能不用就不用」。但真相是:指针是理解 C++ 如何使用内存的钥匙。一旦你建立起「变量住在内存里,每个住处都有门牌号」的图景,指针、引用、数组、动态内存、链表、树——这些后续会反复出现的概念——会突然变得清晰。
本章不要求你成为内存管理专家,而是帮你建立正确的心智模型,并掌握竞赛中真正会用到的指针技能。
前置条件
- 第 2.3 章:函数、数组、按引用传递(
&参数) - 第 2.4 章:结构体与类(指针在链表/树中大量配合结构体使用)
- 知道变量是「存储数据的容器」
🎯 学习目标
学完本章后,你将能够:
- 理解「内存」「地址」「指针」三者的关系,建立正确的心智图景
- 使用取地址运算符
&和解引用运算符* - 区分指针和引用,知道各自的适用场景
- 理解数组名与指针的关系,看懂指针算术
- 使用
new/delete进行动态内存分配,并理解什么是内存泄漏 - 用指针搭建最基础的链表和二叉树节点
- 知道竞赛中何时该用指针、何时不该用
- 理解多级指针(
int**)的概念,看懂动态二维数组和「指针的指针」 - 使用指针操作 C 风格字符串(
char*),手动实现strlen/strcpy - 了解函数指针的基本语法,看懂回调与自定义排序的底层机制
2.7.1 内存、地址与指针:先建立图景
🏨 旅馆类比
想象计算机内存是一栋有很多房间的旅馆:
- 每个房间就是一个字节(或一组字节)的存储空间
- 每个房间都有一个门牌号——这就是内存地址
- 当你写
int x = 42;,编译器给变量x分配了一个房间(比如门牌号 1000),里面放着 42 - 指针就是一张写着「门牌号」的便条。它本身不是房间里的数据,而是「某个房间在哪里」的信息
💡 核心一句话: 普通变量保存的是数据,指针变量保存的是另一个变量的地址。
取地址 & 与解引用 *
C++ 提供两个互为反操作的运算符:
| 运算符 | 名字 | 作用 | 读法 |
|---|---|---|---|
&x | 取地址 | 得到变量 x 的内存地址 | 「x 的地址」 |
*p | 解引用 | 访问指针 p 指向的那个变量 | 「p 指向的东西」 |
📄 查看代码:第一次接触指针
#include <bits/stdc++.h>
using namespace std;
int main() {
int x = 42;
int* p = &x; // p 保存 x 的地址(p「指向」x)
cout << x << "\n"; // 42 —— x 里的数据
cout << &x << "\n"; // 0x... —— x 的地址(每次运行可能不同)
cout << p << "\n"; // 0x... —— 和 &x 相同,p 保存的就是这个地址
cout << *p << "\n"; // 42 —— 解引用:顺着地址找到 x,读出 42
*p = 100; // 通过指针修改 x(顺着门牌号去改房间内容)
cout << x << "\n"; // 100 —— x 真的被改了!
return 0;
}
🤔
int* p中的*和*p中的*是一回事吗? 不是!这是初学者最大的困惑来源:
- 在声明里,
int* p的*表示「p 是一个指向 int 的指针」(类型的一部分)- 在使用里,
*p的*是解引用运算符,表示「取出 p 指向的值」 同一个符号,两种含义,靠上下文区分。
图示:指针指向变量
🎬 互动:一步步看懂 & 与 *
下面这个可视化把「取地址 → 解引用读 → 解引用写」拆成 5 步。点击「下一步」,盯着内存房间和指针箭头,亲眼看到 *p = 100 是如何穿过指针真正改到 x 的:
2.7.2 空指针与 nullptr
一个指针如果还没指向任何有效对象,应该让它指向「空」。现代 C++ 用 nullptr 表示空指针:
int* p = nullptr; // p 不指向任何东西
if (p == nullptr) {
cout << "p 是空指针,不能解引用\n";
}
// *p; // ❌ 危险!解引用空指针 → 程序崩溃(段错误)
⚠️ 不要用旧写法
NULL或0。 老代码常写int* p = NULL;,但NULL本质是0,在重载/模板场景会引起歧义。现代 C++ 一律用nullptr,类型安全且语义清晰。
🐛 头号崩溃原因: 解引用空指针或「野指针」(指向无效地址的指针)。这是
Segmentation fault(段错误)最常见的来源。永远在解引用前确认指针有效。
🧩 辨析:& 和 * 各有两副面孔
& 和 * 都是「一符多义」,靠上下文区分。初学者把它们彻底分清,指针就懂了一半:
| 符号 | 场景 | 含义 | 例子 |
|---|---|---|---|
& | 变量前(一元) | 取地址 | int* p = &x; |
& | 类型后(声明) | 引用(别名,见第 2.3 章) | int& r = x; |
& | 两数之间(二元) | 按位与(见第 2.6 章) | a & b |
* | 类型后(声明) | 「这是个指针」 | int* p; |
* | 指针前(一元) | 解引用取值 | *p = 100; |
* | 两数之间(二元) | 乘法 | a * b |
💡 一句话记忆:贴着变量/指针单独出现 → 取地址 / 解引用;夹在两个数中间 → 位与 / 乘法;跟在类型后面 → 引用 / 指针声明。
2.7.3 指针 vs 引用:到底用哪个?
第 2.3 章你已经学过引用(int& r = x;)。引用和指针都能「间接访问」另一个变量,但它们有重要区别。
📄 查看代码:引用 vs 指针对照
int x = 10, y = 20;
// ---- 引用 ----
int& r = x; // r 是 x 的别名,必须在声明时绑定
r = 100; // 直接改 x,x == 100
// r = y; // 这不是「让 r 指向 y」,而是把 y 的值赋给 x!
// 引用一旦绑定,终生不能改绑
// ---- 指针 ----
int* p = &x; // p 指向 x
*p = 100; // 改 x,x == 100
p = &y; // OK!指针可以改指向 y
*p = 200; // 现在改的是 y,y == 200
p = nullptr; // 指针可以为空
对比表
| 特性 | 引用 int& | 指针 int* |
|---|---|---|
| 是否必须初始化 | 必须(声明即绑定) | 可以先不初始化(但危险) |
| 能否改变指向 | 不能(终生绑定一个变量) | 能(随时改指向别处) |
| 能否为空 | 不能 | 能(nullptr) |
| 语法 | 直接当变量用 r | 需要解引用 *p |
| 安全性 | 更安全,不易出错 | 更灵活,但更易出错 |
⚡ 竞赛中的经验法则:
- 优先用引用。 函数传参、避免复制大对象,引用更安全、语法更干净(见第 2.3 章的
const vector<int>&)。- 需要指针的少数场景: ① 指向的目标需要变化(如遍历链表);② 需要表示「没有目标」的状态(
nullptr,如树的空子节点);③ 动态分配内存(new)。
2.7.4 指针与数组:它们是亲戚
C++ 中数组和指针关系密切。数组名在大多数场景会「退化」成指向首元素的指针。
📄 查看代码:数组名就是首元素地址
#include <bits/stdc++.h>
using namespace std;
int main() {
int arr[5] = {10, 20, 30, 40, 50};
int* p = arr; // 等价于 int* p = &arr[0]; 数组名退化为首元素指针
cout << *p << "\n"; // 10 —— arr[0]
cout << *(p+1) << "\n"; // 20 —— arr[1]
cout << *(p+2) << "\n"; // 30 —— arr[2]
// 下面四种写法完全等价:
cout << arr[2] << "\n"; // 30
cout << *(arr+2) << "\n"; // 30
cout << p[2] << "\n"; // 30
cout << *(p+2) << "\n"; // 30
return 0;
}
🤔 为什么
arr[i]等价于*(arr + i)? 因为下标运算arr[i]的本质就是「从首地址arr偏移i个元素,再解引用」。这也解释了为什么数组下标从 0 开始——下标就是「偏移量」,第一个元素偏移 0。
指针算术
指针 +1 不是地址 +1 字节,而是前进一个元素的大小:
int arr[5] = {10, 20, 30, 40, 50};
int* p = arr;
// 假设 int 占 4 字节,arr 起始地址 1000:
// p 指向 1000(arr[0])
// p+1 指向 1004(arr[1])—— 自动跳过一个 int 的 4 字节
// p+2 指向 1008(arr[2])
📄 查看代码:用指针遍历数组
int arr[5] = {10, 20, 30, 40, 50};
// 方式一:传统下标
for (int i = 0; i < 5; i++) cout << arr[i] << " ";
cout << "\n";
// 方式二:指针遍历(理解即可,竞赛中很少这么写)
for (int* p = arr; p < arr + 5; p++) {
cout << *p << " ";
}
cout << "\n";
⚡ 竞赛提示: STL 的
sort(arr, arr + n)、lower_bound(arr, arr + n, x)中的arr和arr + n正是「首元素指针」和「尾后指针」。理解了数组退化为指针,就理解了这些函数的参数为什么这么写。
2.7.5 指针作函数参数
第 2.3 章我们用引用实现了「函数修改原变量」。其实用指针也能做到——这是 C 语言的经典写法,在很多底层代码、链表/树操作中仍然常见。
📄 查看代码:用指针交换两个变量
#include <bits/stdc++.h>
using namespace std;
// 用指针:传入地址,函数内通过解引用修改原变量
void swapByPointer(int* a, int* b) {
int tmp = *a;
*a = *b;
*b = tmp;
}
// 用引用:更简洁(推荐)
void swapByRef(int& a, int& b) {
int tmp = a;
a = b;
b = tmp;
}
int main() {
int x = 1, y = 2;
swapByPointer(&x, &y); // 必须显式传地址 &x, &y
cout << x << " " << y << "\n"; // 2 1
swapByRef(x, y); // 直接传变量
cout << x << " " << y << "\n"; // 1 2
return 0;
}
💡 两者怎么选? 在 C++ 中,能用引用就用引用(语法干净、不会传空)。需要用指针的典型情况:参数本身可能是
nullptr(「可选」语义),或者你在写链表/树这类天生用指针表达的结构。
2.7.6 动态内存:new 与 delete
到目前为止,我们的数组大小要么编译时确定,要么用 vector。还有第三种方式:在运行时手动向系统申请内存,这叫动态内存分配。
栈内存 vs 堆内存
📄 查看代码:new 与 delete 基础
#include <bits/stdc++.h>
using namespace std;
int main() {
// 动态分配单个 int
int* p = new int; // 在堆上申请一个 int
*p = 42;
cout << *p << "\n"; // 42
delete p; // 用完必须释放!否则内存泄漏
// 动态分配数组(运行时才知道大小)
int n;
cin >> n;
int* arr = new int[n]; // 堆上申请 n 个 int
for (int i = 0; i < n; i++) arr[i] = i * i;
for (int i = 0; i < n; i++) cout << arr[i] << " ";
cout << "\n";
delete[] arr; // 释放数组要用 delete[](带方括号)!
return 0;
}
⚠️ 三条铁律
new配delete,new[]配delete[]。 申请数组用new[],就必须用delete[]释放,否则行为未定义。- 每个
new都要有对应的delete。 否则就是内存泄漏——申请的内存永远还不回去。 delete之后不要再用那个指针(变成「悬空指针」),可以delete后立刻p = nullptr;。
🤔 竞赛里到底用不用
new/delete? 绝大多数情况:不用! 竞赛中程序运行完就退出,操作系统会回收所有内存,所以「内存泄漏」通常无害;而且new/delete写起来啰嗦、容易出错。
- 需要动态数组?→ 用
vector(自动管理内存,见第 2.3 章)- 需要动态字符串?→ 用
string真正会手写new的,主要是链表、二叉树这类需要逐个创建节点的结构(见下一节)——而即便如此,很多选手也用数组模拟或静态节点池来避免new(见第 5.5、5.7 章)。
2.7.7 指针的杀手级应用:链表与树节点
指针真正不可替代的地方,是构建节点之间互相连接的数据结构。最经典的就是链表和树。本节先把链表讲扎实:它为什么存在、和数组有什么不同、怎样遍历、插入、删除,再过渡到树节点。
数组存储 vs 链表存储
数组和链表都能表示「一串元素」,但它们在内存里的组织方式完全不同:
- 数组:元素挨在一起,存放在一段连续内存中。
- 链表:每个元素是一个节点,节点可以分散在内存各处;节点里额外存一个指针,告诉我们「下一个节点在哪里」。
| 对比维度 | 数组 / vector | 单链表 |
|---|---|---|
| 内存布局 | 连续存储,像一排相邻房间 | 分散存储,靠指针串起来 |
访问第 i 个元素 | O(1),直接 a[i] | O(i),必须从头一路走过去 |
| 在中间插入/删除 | 通常 O(N),后面元素要整体移动 | 已知前驱节点时 O(1) 改指针即可 |
| 额外空间 | 只存数据 | 每个节点还要多存一个 next 指针 |
| 缓存友好性 | 好,连续访问很快 | 差,节点分散,访问可能跳来跳去 |
| 竞赛常用度 | 极高,绝大多数情况优先用 | 理解链式结构、面试题、数组模拟链表时重要 |
💡 一句话选择: 需要频繁随机访问 → 用数组 /
vector;需要频繁在已知位置插入、删除 → 链表更合适。但在竞赛中,真正手写指针链表并不多,更多是用数组模拟链表。
单链表节点:数据 + 指向下一个节点的指针
每个链表节点通常由两部分组成:
struct Node {
int data; // 当前节点保存的数据
Node* next; // 指向下一个节点;最后一个节点的 next 为 nullptr
};
链表只需要保存一个头指针 head,它指向第一个节点。之后顺着 next 一直走,就能访问整条链:
Node* head = new Node{1, nullptr};
head->next = new Node{2, nullptr};
head->next->next = new Node{3, nullptr};
// head -> 1 -> 2 -> 3 -> nullptr
🤔
cur->data是什么语法?cur是指针,cur->data等价于(*cur).data,意思是「先顺着指针找到节点,再取它的data成员」。->是「指针访问成员」的专用运算符,比(*cur).data好写。这个语法在链表、树里随处可见。
链表基础操作一:遍历、计数、查找
链表没有下标,不能直接跳到第 i 个元素。它的所有操作都建立在同一个动作上:从 head 出发,沿着 next 一个一个走。
📄 查看代码:遍历、求长度、查找
#include <bits/stdc++.h>
using namespace std;
struct Node {
int data;
Node* next;
};
void printList(Node* head) {
for (Node* cur = head; cur != nullptr; cur = cur->next) {
cout << cur->data << " ";
}
cout << "\n";
}
int length(Node* head) {
int cnt = 0;
for (Node* cur = head; cur != nullptr; cur = cur->next) {
cnt++;
}
return cnt;
}
Node* findValue(Node* head, int target) {
for (Node* cur = head; cur != nullptr; cur = cur->next) {
if (cur->data == target) return cur;
}
return nullptr;
}
int main() {
Node* head = new Node{1, nullptr};
head->next = new Node{2, nullptr};
head->next->next = new Node{3, nullptr};
printList(head); // 1 2 3
cout << length(head) << "\n"; // 3
cout << (findValue(head, 2) != nullptr) << "\n"; // 1
return 0;
}
⚠️ 链表遍历的核心条件:
cur != nullptr。一旦cur是空指针,就不能再写cur->data或cur->next,否则会崩溃。
链表基础操作二:插入节点
链表插入的本质是改指针。不要想成「把一排元素往后挪」,链表里没有整体搬移,只有箭头重连。
头插法:插到链表最前面
如果新节点要成为新的第一个节点,只需要让它指向旧头,再更新 head:
void pushFront(Node*& head, int x) {
Node* node = new Node{x, head}; // 新节点指向旧 head
head = node; // head 改为新节点
}
这里参数写成 Node*& head,意思是「head 指针的引用」。因为函数内部不仅要改节点内容,还要修改外部的 head 本身。
尾插法:插到链表最后面
如果只有 head,每次尾插都要从头走到尾,单次 O(N)。所以实际常维护一个 tail 指针,直接指向最后一个节点:
void pushBack(Node*& head, Node*& tail, int x) {
Node* node = new Node{x, nullptr};
if (head == nullptr) { // 空链表:新节点既是头也是尾
head = tail = node;
} else {
tail->next = node; // 旧尾巴指向新节点
tail = node; // 更新尾指针
}
}
插到某个节点后面
已知节点 pos,把新节点插到它后面,只需要两步:
void insertAfter(Node* pos, int x) {
if (pos == nullptr) return;
Node* node = new Node{x, pos->next}; // 新节点先接住 pos 原来的后继
pos->next = node; // pos 再指向新节点
}
⚠️ 顺序不能反: 必须先让新节点接住
pos->next,再改pos->next。如果先写pos->next = node,原来的后半段链表就丢了。
📄 查看代码:用头插、尾插、节点后插入构建链表
#include <bits/stdc++.h>
using namespace std;
struct Node {
int data;
Node* next;
};
void pushFront(Node*& head, int x) {
head = new Node{x, head};
}
void pushBack(Node*& head, Node*& tail, int x) {
Node* node = new Node{x, nullptr};
if (head == nullptr) {
head = tail = node;
} else {
tail->next = node;
tail = node;
}
}
void insertAfter(Node* pos, int x) {
if (pos == nullptr) return;
pos->next = new Node{x, pos->next};
}
void printList(Node* head) {
for (Node* cur = head; cur != nullptr; cur = cur->next) {
cout << cur->data << " ";
}
cout << "\n";
}
int main() {
Node* head = nullptr;
Node* tail = nullptr;
pushBack(head, tail, 1); // 1
pushBack(head, tail, 3); // 1 -> 3
insertAfter(head, 2); // 1 -> 2 -> 3
pushFront(head, 0); // 0 -> 1 -> 2 -> 3
printList(head);
return 0;
}
链表基础操作三:删除节点
单链表删除节点也靠改指针,但有一个限制:要删除某个节点,通常需要知道它的前驱节点。因为前驱节点的 next 要绕过被删除节点,指向后面的节点。
删除头节点
头节点没有前驱,所以要单独处理,并且要修改 head 本身:
void popFront(Node*& head) {
if (head == nullptr) return;
Node* old = head;
head = head->next;
delete old;
}
删除某个节点后面的节点
已知 pos,删除 pos 后面的节点:
void eraseAfter(Node* pos) {
if (pos == nullptr || pos->next == nullptr) return;
Node* victim = pos->next;
pos->next = victim->next; // 绕过 victim
delete victim; // 释放被删除节点
}
按值删除第一个出现的节点
按值删除时,我们不知道前驱是谁,所以要一边遍历一边维护 prev 和 cur:
bool eraseValue(Node*& head, int target) {
if (head == nullptr) return false;
if (head->data == target) {
popFront(head);
return true;
}
Node* prev = head;
Node* cur = head->next;
while (cur != nullptr) {
if (cur->data == target) {
prev->next = cur->next;
delete cur;
return true;
}
prev = cur;
cur = cur->next;
}
return false;
}
📄 查看代码:插入、删除、遍历的完整小例子
#include <bits/stdc++.h>
using namespace std;
struct Node {
int data;
Node* next;
};
void pushBack(Node*& head, Node*& tail, int x) {
Node* node = new Node{x, nullptr};
if (head == nullptr) {
head = tail = node;
} else {
tail->next = node;
tail = node;
}
}
void popFront(Node*& head) {
if (head == nullptr) return;
Node* old = head;
head = head->next;
delete old;
}
bool eraseValue(Node*& head, int target) {
if (head == nullptr) return false;
if (head->data == target) {
popFront(head);
return true;
}
Node* prev = head;
Node* cur = head->next;
while (cur != nullptr) {
if (cur->data == target) {
prev->next = cur->next;
delete cur;
return true;
}
prev = cur;
cur = cur->next;
}
return false;
}
void printList(Node* head) {
for (Node* cur = head; cur != nullptr; cur = cur->next) {
cout << cur->data << " ";
}
cout << "\n";
}
void clearList(Node*& head) {
while (head != nullptr) {
popFront(head);
}
}
int main() {
Node* head = nullptr;
Node* tail = nullptr;
for (int x : {1, 2, 3, 4}) pushBack(head, tail, x);
printList(head); // 1 2 3 4
eraseValue(head, 3);
printList(head); // 1 2 4
popFront(head);
printList(head); // 2 4
clearList(head);
return 0;
}
⚠️ 关于
tail的细节: 上面的删除示例为了突出核心逻辑,只维护了head。如果你的程序还保存tail,删除尾节点时必须同步更新tail,否则tail会变成悬空指针。实际写题时,要么不维护tail,要么把删除函数也设计成能更新tail。
链表基础操作复杂度
| 操作 | 单链表复杂度 | 说明 |
|---|---|---|
| 遍历整条链 | O(N) | 必须从头走到尾 |
| 查找某个值 | O(N) | 不能二分,不能随机跳转 |
访问第 i 个节点 | O(i) | 从 head 走 i 步 |
| 头部插入 / 删除 | O(1) | 改 head 即可 |
| 已知节点后插入 | O(1) | 改两条指针 |
| 已知前驱时删除后继 | O(1) | 改一条指针并 delete |
| 只知道值时删除 | O(N) | 先找前驱,再删除 |
链表最容易写错的地方
- 忘记处理空链表:
head == nullptr时不能访问head->next。 - 忘记更新
head: 删除或插入到头部时,head本身会变。 - 改指针顺序错误: 插入时先保存旧后继,再重连;反转链表时先保存
next,再改cur->next。 - 删除后继续使用旧指针:
delete victim;之后victim就不能再解引用。 - 维护
tail时不同步: 删除尾节点、清空链表时,要让tail指向新的尾或nullptr。
二叉树节点
树是链表思想的自然延伸——每个节点不止一个「下一个」,而是有左右两个孩子指针:
📄 查看代码:二叉树节点定义
struct TreeNode {
int val;
TreeNode* left; // 左孩子(没有则为 nullptr)
TreeNode* right; // 右孩子(没有则为 nullptr)
};
// 创建一个节点
TreeNode* root = new TreeNode{1, nullptr, nullptr};
root->left = new TreeNode{2, nullptr, nullptr};
root->right = new TreeNode{3, nullptr, nullptr};
// 1
// / \
// 2 3
🔗 承上启下: 链表和树的完整算法(遍历、插入、删除、BST、平衡)会在 第 5.5 章(二叉树与树算法) 详细展开。本章你要掌握的是「指针如何把节点串起来」以及「插入 / 删除本质上就是重连指针」。
🆚 指针版 vs 数组模拟版(竞赛重点)
竞赛选手往往不用 new,而是用数组下标当「指针」——这叫数组模拟(静态分配 / 节点池)。提前建立这个对照,到第 5.5/5.7 章就不会陌生:
| 维度 | 指针版 | 数组模拟版 |
|---|---|---|
| 节点定义 | struct Node{int v; Node* nxt;}; | int val[N], nxt[N]; |
| 「指向」 | 真正的指针 Node* | 数组下标(int,如 nxt[i] 存下一个下标) |
| 空指针 | nullptr | 约定一个特殊值,如 -1 或 0 |
| 新建节点 | new Node{...}(堆分配,慢) | ++cnt 取下一个空槽(无分配开销) |
| 插入 / 删除 | 改 Node* 指针 | 改 nxt[i] 下标 |
| 释放 | delete(易漏 → 泄漏) | 不用释放,整体复用数组 |
| 速度/安全 | 较慢、易写错 | 更快、更稳,竞赛主流 |
📄 查看代码:同一条链表的两种写法
// ---- 指针版 ----
struct Node { int v; Node* nxt; };
Node* head = new Node{1, nullptr};
head->nxt = new Node{2, nullptr};
// ---- 数组模拟版(竞赛常用)----
const int N = 100005;
int val[N], nxt[N], cnt = 0; // 下标 0 留作「空」,真实节点从 1 开始
int head = 0; // 0 表示空链表
int newNode(int v) { // 「new」一个节点 → 返回下标
++cnt;
val[cnt] = v;
nxt[cnt] = 0;
return cnt;
}
void insertAfter(int pos, int id) {
nxt[id] = nxt[pos];
nxt[pos] = id;
}
// 建立 1 -> 2
head = newNode(1);
insertAfter(head, newNode(2));
// 遍历:用下标代替指针,0 代替 nullptr
for (int i = head; i != 0; i = nxt[i]) cout << val[i] << " ";
📌 拓展(了解即可): 工程开发中常用智能指针
unique_ptr/shared_ptr自动管理内存(离开作用域自动delete,杜绝泄漏)。但竞赛中几乎不用——它们有额外开销,且竞赛程序结束即回收内存。知道有这回事即可,竞赛里继续用裸指针或数组模拟。
2.7.8 const 与指针:只读保护
const 和指针结合时位置不同,含义不同。这是面试和阅读源码的高频考点:
int x = 10, y = 20;
const int* p1 = &x; // 指向 const:不能通过 p1 改值,但能改 p1 指向
// *p1 = 99; ❌ 不能改值
p1 = &y; // ✓ 能改指向
int* const p2 = &x; // const 指针:能改值,但不能改指向
*p2 = 99; // ✓ 能改值
// p2 = &y; ❌ 不能改指向
const int* const p3 = &x; // 都不能改
💡 读法口诀: 从右往左读。
const int* p读作「p 是指针,指向 const int」;int* const p读作「p 是 const 指针,指向 int」。看const离谁近就管谁。
2.7.9 多级指针:指向指针的指针
指针本身也是变量,也住在内存的某个房间里。所以——指针也有地址。指向「指针」的指针,就是多级指针,最常见的是二级指针 int**。
为什么需要指向指针的指针?
三个经典场景:
- 在函数内修改指针本身(比如让一个
int*参数指向新分配的内存) - 动态二维数组(
int**指向一组int*,每个int*再指向一行) - 命令行参数
argv(main的第二个参数就是char**)
📄 查看代码:二级指针修改指针本身
#include <bits/stdc++.h>
using namespace std;
// 想让函数修改外部指针的指向?传二级指针!
void allocate(int** pp, int n) {
*pp = new int[n]; // 通过解引用二级指针,修改外部指针
}
int main() {
int* arr = nullptr;
allocate(&arr, 5); // 传 arr 的地址(int**)
for (int i = 0; i < 5; i++) arr[i] = i * i;
for (int i = 0; i < 5; i++) cout << arr[i] << " ";
cout << "\n";
delete[] arr;
return 0;
}
💡 也可以用指针的引用(
int*&)达到同样效果,语法更干净:void allocate(int*& p, int n) { p = new int[n]; }但二级指针是更底层的理解,也是阅读底层代码和旧代码时的常见写法。
动态二维数组
当行数和列数都在运行时才能确定时,可以用 int** 构建二维数组:
📄 查看代码:二级指针构建动态二维数组
#include <bits/stdc++.h>
using namespace std;
int main() {
int n = 3, m = 4;
// 分配 n 个 int*(每行的指针)
int** mat = new int*[n];
for (int i = 0; i < n; i++) {
mat[i] = new int[m]; // 每行分配 m 个 int
}
// 赋值
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
mat[i][j] = i * m + j;
// 打印
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++)
cout << setw(3) << mat[i][j];
cout << "\n";
}
// 释放:先释放每一行,再释放行指针数组
for (int i = 0; i < n; i++) delete[] mat[i];
delete[] mat;
return 0;
}
⚡ 竞赛提示: 上面这种
int**二维数组在竞赛中几乎不用——它要多次new,内存不连续(缓存不友好),释放也麻烦。竞赛中的动态二维数组一律用vector<vector<int>>(见第 2.3 章),既安全又简洁。这里展示二级指针主要是帮你建立起「指针的指针」的图景,读到底层代码或旧代码时能看得懂。
图解:二级指针的层级关系
📌 总结一句话:
int x→int* p = &x(一级指针,指向变量)→int** pp = &p(二级指针,指向指针)。每一级解引用*就沿着箭头往前走一层:*pp得到p,**pp得到x。
2.7.10 指针与C风格字符串
在 C++ 诞生之前,C 语言用以 \0(空字符)结尾的字符数组来表示字符串。这种「C风格字符串」的类型是 char* 或 const char*。虽然现代 C++ 中 std::string 是绝对主流,但理解 char* 对阅读底层代码、竞赛模板、以及深化指针理解都很有帮助。
字符串字面量
const char* s = "hello"; // s 指向字符串字面量 "hello\0"
// s[0] = 'H'; ❌ 字符串字面量存在只读区,修改是未定义行为!
⚠️ 字符串字面量
"hello"的类型是const char[6](包含末尾的\0),存储在只读内存区。所以必须用const char*来指向它。老代码可能写char* s = "hello";,这在现代 C++ 中是不推荐的(甚至可能编译不过)。
用指针遍历C风格字符串
C风格字符串以 \0 结尾,所以遍历时不需要知道长度:
📄 查看代码:指针版 strlen 和 strcpy
#include <bits/stdc++.h>
using namespace std;
// 自己实现 strlen:数到 '\0' 为止
int myStrlen(const char* s) {
int len = 0;
while (*s != '\0') { // 等价于 while (*s)
len++;
s++; // 指针前进到下一个字符
}
return len;
}
// 自己实现 strcpy:把 src 复制到 dst
void myStrcpy(char* dst, const char* src) {
while (*src != '\0') {
*dst = *src; // 逐字符复制
dst++;
src++;
}
*dst = '\0'; // 别忘了末尾的 '\0'!
}
// 更「指针味」的写法——一行搞定:
void myStrcpyV2(char* dst, const char* src) {
while ((*dst++ = *src++)); // 赋值 + 判断 + 指针前进,一体完成
}
int main() {
const char* s = "hello";
cout << myStrlen(s) << "\n"; // 5
char buf[100];
myStrcpy(buf, s);
cout << buf << "\n"; // hello
return 0;
}
💡 上面「一行搞定」的写法
while ((*dst++ = *src++));是经典 C 语言风格的指针练习。它把 赋值、指针前进、判断 压缩成一行,浓缩了指针操作的精华。如果你是初学者,建议先用分步写法理解清楚,再回来看这一行——你会发现指针没那么玄了。
C风格字符串 vs std::string
| 特性 | char* / char[] | std::string |
|---|---|---|
| 结尾标记 | \0(手动维护) | 自动管理 |
| 获取长度 | strlen(s) O(n) | s.length() O(1) |
| 拼接 | strcat(手动,危险) | s1 + s2(安全) |
| 内存管理 | 手动 | 自动 |
| 何时使用 | 阅读旧代码、底层接口、argv | 竞赛中 99% 的场景 |
⚡ 竞赛铁律:优先用
std::string。 只在main(int argc, char* argv[])的命令行参数、或调用某些 C 库函数时才会接触到char*。本节的目的不是让你回去用 C 风格字符串,而是让你看到时能读懂。
2.7.11 函数指针入门
C++ 中函数也有地址。函数指针就是指向函数的指针——可以像传递数据一样传递「要执行什么操作」。这是回调机制(callback)的基石。
基本语法
函数指针的声明语法有点特别,需要把 * 和函数名用括号括起来:
// 一个函数:输入两个 int,返回 bool
bool cmp(int a, int b) { return a > b; }
// 对应的函数指针类型:bool (*)(int, int)
bool (*fp)(int, int) = cmp; // fp 指向 cmp
// ↑ ↑ ↑
// 返回值 指针名(括号必须) 参数类型列表
// 调用函数指针:两种写法等价
cout << fp(3, 5) << "\n"; // false(3 > 5)
cout << (*fp)(3, 5) << "\n"; // false(同上,更明确)
竞赛中的典型应用:自定义排序
std::sort 的第三个参数就是一个函数指针(或函数对象):
📄 查看代码:用函数指针自定义排序
#include <bits/stdc++.h>
using namespace std;
bool cmpAbs(int a, int b) {
return abs(a) < abs(b); // 按绝对值升序
}
int main() {
vector<int> a = {-5, 3, -1, 4, -2};
sort(a.begin(), a.end(), cmpAbs); // cmpAbs 作为函数指针传入
for (int x : a) cout << x << " "; // -1 -2 3 4 -5
cout << "\n";
return 0;
}
💡 C++ 中更推荐用 lambda 表达式 或 函数对象(functor) 代替裸函数指针,因为类型更安全、编译器更容易内联优化。但函数指针是理解这些高级特性的基础——lambda 本质上是语法糖,底层仍是函数指针或函数对象。
函数指针数组
当需要在多个操作之间动态选择时,函数指针数组非常有用:
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int (*ops[3])(int, int) = {add, sub, mul}; // 函数指针数组
int op; cin >> op;
cout << ops[op](10, 5) << "\n"; // 动态选择操作
⚡ 竞赛提示: 函数指针在竞赛中不常用(往往被 lambda、
std::function或直接重载替代),但它是理解回调、事件驱动、以及qsort等 C 标准库函数的必备知识。了解即可,不必深入研究。
⚠️ 第 2.7 章常见错误
| # | 错误 | 示例 | 错在哪里 | 修复方法 |
|---|---|---|---|---|
| 1 | 解引用空指针/野指针 | int* p; *p = 5; | p 未初始化,指向随机地址 → 段错误 | 先让 p 指向有效对象,或初始化为 nullptr 并判空 |
| 2 | new[] 用 delete 释放 | int* a = new int[n]; delete a; | 数组必须用 delete[],否则未定义行为 | 改成 delete[] a; |
| 3 | 内存泄漏 | 只 new 不 delete | 申请的堆内存永不归还 | 每个 new 配一个 delete;竞赛中优先用 vector |
| 4 | 悬空指针 | delete p; cout << *p; | delete 后内存已释放,p 指向无效区域 | delete p; 后立刻 p = nullptr; |
| 5 | 混淆声明的 * 和解引用的 * | int *p = *x; | 想取地址却写成解引用 | 取地址用 &x:int* p = &x; |
| 6 | 返回局部变量的地址 | int* f(){ int t=5; return &t; } | 函数返回后 t 已销毁,地址失效 | 返回值本身,或用 new/引用传出 |
| 7 | 引用未初始化 | int& r; | 引用必须在声明时绑定 | 声明时即 int& r = x; |
本章总结
📌 核心要点
| 概念 | 要点 | 为什么重要 |
|---|---|---|
| 地址 | 每个变量在内存中都有一个门牌号 | 指针的根基 |
指针 int* p | 保存的是「另一个变量的地址」 | 间接访问、动态结构的基础 |
&x 取地址 | 得到变量 x 的地址 | 给指针赋值、按指针传参 |
*p 解引用 | 顺着地址访问指向的变量 | 读写指针指向的数据 |
nullptr | 表示「不指向任何东西」 | 表示空状态、判空保护 |
| 引用 vs 指针 | 引用安全、不可改绑;指针灵活、可空可改绑 | 竞赛优先用引用 |
| 数组退化 | 数组名 ≈ 首元素指针;arr[i] == *(arr+i) | 理解 STL 区间参数 (arr, arr+n) |
new / delete | 堆上手动分配/释放内存 | 链表/树建节点;普通数组优先用 vector |
-> 运算符 | p->data 等价 (*p).data | 链表、树操作的标配语法 |
多级指针 int** | 指向指针的指针,用于修改指针本身或动态二维数组 | 理解底层代码、argv、动态二维数组 |
C 风格字符串 char* | 以 \0 结尾的字符数组,指针可遍历和操作 | 阅读旧代码和底层接口、理解 string 底层 |
| 函数指针 | 指向函数的指针,可像数据一样传递「操作」 | 理解回调、sort 自定义比较、qsort |
❓ 常见问题
Q1:指针这么危险,竞赛里还有必要学吗?
A:有,但要分清主次。90% 的题目你用
vector、string、引用就够了,根本不用裸指针。 但当你做到链表、二叉树、图的链式前向星,或者读别人的模板代码时,不懂指针就寸步难行。本章的目标是让你「看得懂、用得对」,而不是处处手写new。
Q2:引用和指针,到底优先用哪个?
A:优先引用。 函数传参避免复制 → 用
const T&;要修改原变量 → 用T&。只有在「目标会变化」(遍历链表)、「可能没有目标」(树的空孩子nullptr)、「动态分配」(new)这三种情况下才需要指针。
Q3:*p、&x、p->m 老是搞混怎么办?
A:记住三句话:
&x是「x 住在哪」(取地址);*p是「顺着 p 去看那个房间」(解引用);p->m是「顺着 p 找到结构体,再取它的成员 m」。多写几遍链表遍历的代码,肌肉记忆就建立了。
Q4:竞赛中真的要手动 delete 吗?会不会忘了就丢分?
A:通常不会丢分。 程序结束时操作系统自动回收全部内存,所以竞赛里的内存泄漏一般无害。真正要小心的是运行中反复 new 导致超内存(MLE)——这种情况极少。结论:能用
vector/string就别手动管理内存。下面是一个会触发 MLE 的反例——在大循环里反复
new却从不delete:// ❌ 危险:每次循环都在堆上申请,永不释放 for (int i = 0; i < 10000000; i++) { int* p = new int[1000]; // 每次 4KB,累计 ~40 GB → MLE! // ... 用完 p 却忘了 delete[] p; } // ✅ 修复:要么循环外只申请一次复用,要么用完即 delete[] p;, // 最稳妥是直接用 vector<int> p(1000);(出作用域自动回收)
Q5:为什么有些选手的树/链表代码完全不用指针?
A:他们用数组模拟(也叫静态分配/节点池):用
int left[N], right[N], val[N];三个数组当作节点,用下标当「指针」。这样更快、更不容易出错、还能避免new的开销,是竞赛中的主流写法(详见第 5.5、5.7 章)。理解了指针版,再看数组版会更透彻。
Q6:多级指针、函数指针这些东西竞赛中真用得上吗?
A:直接使用不多,但理解后读代码能力大幅提升。 二级指针帮你理解
argv(命令行参数)、动态二维数组的底层;函数指针帮你理解sort的自定义比较、qsort回调。更重要的是,这些概念是后续 STL 函数对象、lambda 表达式等高级特性的基础。一句话:可以不天天写,但不能看不懂。
🔗 与后续章节的联系
- 第 5.5 章(二叉树与树算法):本章的
TreeNode结构是树遍历、BST 的起点 - 第 5.6 章(并查集)、第 5.7 章(线段树):会看到「指针版」与「数组模拟版」两种实现的取舍
- 第 5.1 章(图的基础):链式前向星本质上是用数组模拟链表来存图
- 本章建立的「内存与地址」心智模型,会让你对
vector扩容、string底层、引用传参的理解更深刻
练习题
🌡️ 热身题
热身 2.7.1 — 通过指针修改
声明 int x = 5;,定义一个指针 p 指向 x,通过 *p 把 x 改成 99,然后打印 x。
期望输出: 99
💡 题解(点击展开)
思路: 用 &x 给指针赋值,再用 *p 解引用赋值。
#include <bits/stdc++.h>
using namespace std;
int main() {
int x = 5;
int* p = &x; // p 指向 x
*p = 99; // 通过指针修改 x
cout << x << "\n"; // 99
return 0;
}
关键点:
int* p = &x;——&取地址*p = 99;——*解引用,改的是x本身
热身 2.7.2 — 指针交换
编写函数 void swp(int* a, int* b),用指针交换两个变量。在 main 中读入两个整数,交换后打印。
样例输入: 3 7 → 样例输出: 7 3
💡 题解(点击展开)
#include <bits/stdc++.h>
using namespace std;
void swp(int* a, int* b) {
int tmp = *a;
*a = *b;
*b = tmp;
}
int main() {
int x, y;
cin >> x >> y;
swp(&x, &y); // 注意要传地址
cout << x << " " << y << "\n";
return 0;
}
关键点:
- 函数参数是
int*,调用时必须传地址&x, &y - 函数内用
*a、*b访问原变量
热身 2.7.3 — 数组与指针
声明 int arr[5] = {10,20,30,40,50};,定义指针 p = arr,用 *(p+2) 打印第三个元素。
期望输出: 30
💡 题解(点击展开)
#include <bits/stdc++.h>
using namespace std;
int main() {
int arr[5] = {10, 20, 30, 40, 50};
int* p = arr; // 数组名退化为首元素指针
cout << *(p + 2) << "\n"; // 30,等价于 arr[2]
return 0;
}
关键点:
p = arr等价于p = &arr[0]*(p+2)等价于p[2]、arr[2]——指针算术按元素大小前进
🏋️ 核心练习题
题目 2.7.4 — 找最大值并返回指针
读入 N 个整数存入数组,编写函数返回指向最大元素的指针,在 main 中通过该指针打印最大值。
样例输入:
5
3 9 2 9 4
样例输出: 9
💡 题解(点击展开)
思路: 函数遍历数组,记录最大元素的地址并返回。
#include <bits/stdc++.h>
using namespace std;
int* findMax(int* arr, int n) {
int* best = arr; // 先假设第一个最大
for (int i = 1; i < n; i++) {
if (arr[i] > *best) best = &arr[i];
}
return best; // 返回指向最大元素的指针
}
int main() {
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
int* p = findMax(a.data(), n); // a.data() 拿到底层数组指针
cout << *p << "\n";
return 0;
}
关键点:
- 返回的是「指向数组内某元素」的指针,数组还活着,所以安全(不是返回局部变量地址!)
a.data()——vector提供底层连续数组的首指针best始终保存当前最大元素的地址
题目 2.7.5 — 用指针就地翻转数组 读入 N 个整数,用两个指针(一个从头、一个从尾)就地翻转数组并打印。
样例输入:
5
1 2 3 4 5
样例输出: 5 4 3 2 1
💡 题解(点击展开)
思路: 双指针,从两端向中间交换,正是第 2.3 章翻转算法的指针版。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
int* left = &a[0];
int* right = &a[n - 1];
while (left < right) { // 指针可以直接比较大小
swap(*left, *right);
left++;
right--;
}
for (int x : a) cout << x << " ";
cout << "\n";
return 0;
}
关键点:
- 同一数组内的指针可以用
<、>比较位置 left++/right--让指针按元素前进/后退- 循环条件
left < right保证在中点相遇时停止
题目 2.7.6 — 手写链表求和
不用 vector,用 struct Node{int data; Node* next;} 手动建立一条包含 N 个整数的链表,遍历求和后打印。
样例输入:
4
10 20 30 40
样例输出: 100
💡 题解(点击展开)
思路: 边读边在尾部接新节点,再从头遍历求和。
#include <bits/stdc++.h>
using namespace std;
struct Node {
int data;
Node* next;
};
int main() {
int n;
cin >> n;
Node* head = nullptr;
Node* tail = nullptr;
for (int i = 0; i < n; i++) {
int x;
cin >> x;
Node* node = new Node{x, nullptr};
if (head == nullptr) { // 第一个节点
head = tail = node;
} else {
tail->next = node; // 接到尾部
tail = node; // 更新尾指针
}
}
long long sum = 0;
for (Node* cur = head; cur != nullptr; cur = cur->next) {
sum += cur->data;
}
cout << sum << "\n";
// 释放
while (head) { Node* t = head; head = head->next; delete t; }
return 0;
}
关键点:
- 用
head和tail两个指针,尾插法 O(1) 接节点 cur = cur->next是链表遍历的标准写法- 用完逐个
delete(竞赛中可省,程序结束自动回收)
🏆 挑战题
挑战 2.7.7 — 反转链表 (经典面试 & 竞赛基础题)
建立一条链表 1→2→3→4→5,反转它使之变成 5→4→3→2→1,然后打印。要求只用指针操作,不借助数组。
期望输出: 5 4 3 2 1
💡 题解(点击展开)
思路: 三指针法——prev、cur、next。每步把 cur->next 反指向 prev,然后三个指针整体右移。
#include <bits/stdc++.h>
using namespace std;
struct Node {
int data;
Node* next;
};
int main() {
// 建立 1→2→3→4→5
Node* head = nullptr;
for (int i = 5; i >= 1; i--) { // 倒着头插,得到正序
head = new Node{i, head};
}
// 反转
Node* prev = nullptr;
Node* cur = head;
while (cur != nullptr) {
Node* next = cur->next; // 1. 先存住下一个
cur->next = prev; // 2. 当前节点反指向前一个
prev = cur; // 3. prev 前进
cur = next; // 4. cur 前进
}
head = prev; // prev 成为新的头
// 打印
for (Node* p = head; p; p = p->next) cout << p->data << " ";
cout << "\n";
return 0;
}
关键点:
- 反转链表的核心是「先用
next备份下一个,再改cur->next的指向」,顺序不能乱,否则链断了找不回来 - 三指针
prev / cur / next同步右移,O(N) 时间、O(1) 额外空间 - 这道题是理解指针操作的「试金石」,务必亲手画图走一遍
挑战 2.7.8 — 检测链表是否有环 (快慢指针经典应用)
给定一条可能成环的链表,判断它是否有环。打印 YES 或 NO。
提示:用「快慢指针」——慢指针每次走 1 步,快指针每次走 2 步。若两者相遇则有环(这叫 Floyd 判圈算法)。
💡 题解(点击展开)
思路: 快慢指针。若无环,快指针会先到达 nullptr;若有环,快指针会在环里追上慢指针。
#include <bits/stdc++.h>
using namespace std;
struct Node {
int data;
Node* next;
};
bool hasCycle(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next; // 慢:走 1 步
fast = fast->next->next; // 快:走 2 步
if (slow == fast) return true; // 相遇 → 有环
}
return false; // fast 到达末尾 → 无环
}
int main() {
// 构造 1→2→3→4,再让 4 指回 2 形成环
Node* n1 = new Node{1, nullptr};
Node* n2 = new Node{2, nullptr};
Node* n3 = new Node{3, nullptr};
Node* n4 = new Node{4, nullptr};
n1->next = n2; n2->next = n3; n3->next = n4;
n4->next = n2; // 制造环!
cout << (hasCycle(n1) ? "YES" : "NO") << "\n"; // YES
return 0;
}
关键点:
- 循环条件
fast && fast->next必须双重判空——因为fast一次走两步,要确保fast->next->next不越过空指针 - 快慢指针是处理链表环、找中点、找倒数第 K 个节点的通用利器
- 若有环,快指针每轮比慢指针多走 1 步,最终必在环内相遇——时间 O(N),空间 O(1)
挑战 2.7.9 — 奶牛排队(USACO Bronze 风格) (用链表 + 指针模拟动态插队)
Farmer John 的 N 头奶牛排成一队(编号 1..N,从队首到队尾)。接着发生 M 个事件,每个事件形如 x y:表示编号 x 的奶牛插队到编号 y 的奶牛正后方(保证 x、y 不同,且 x 当前在队中、会先被移出原位再插入)。所有事件处理完后,从队首到队尾输出整个队列。
样例输入:
4 2
2 4
1 3
(初始队列 1 2 3 4;事件1:2 插到 4 后 → 1 3 4 2;事件2:1 插到 3 后 → 3 1 4 2)
样例输出: 3 1 4 2
💡 题解(点击展开)
思路: 频繁的「移出 + 插入」正是双向链表的拿手好戏——它能 O(1) 完成任意位置的删除和插入,而数组做这件事要 O(N) 搬移。这里用数组模拟双向链表(竞赛主流写法,呼应 2.7.7 的对照表):pre[i] 和 nxt[i] 分别存编号 i 左右邻居,用哨兵 0 表示队首之前/队尾之后。
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int pre[N], nxt[N]; // pre[i]/nxt[i]:编号 i 的前驱/后继;0 为哨兵(队首端/队尾端)
void detach(int x) { // 把 x 从当前位置摘下
nxt[pre[x]] = nxt[x];
pre[nxt[x]] = pre[x];
}
void insertAfter(int y, int x) { // 把 x 插到 y 的正后方
int z = nxt[y]; // y 原来的后继
nxt[y] = x; pre[x] = y;
nxt[x] = z; pre[z] = x;
}
int main() {
int n, m;
cin >> n >> m;
// 初始队列 1 2 ... n,用哨兵 0 串起两端
pre[1] = 0; nxt[0] = 1; // 0 → 1
for (int i = 1; i < n; i++) { nxt[i] = i + 1; pre[i + 1] = i; }
nxt[n] = 0; pre[0] = n; // n → 0(回到哨兵,表示队尾)
while (m--) {
int x, y;
cin >> x >> y;
detach(x); // 先把 x 移出原位
insertAfter(y, x); // 再插到 y 后面
}
// 从哨兵后第一个节点开始,沿 nxt 输出整条队列
for (int i = nxt[0]; i != 0; i = nxt[i]) {
cout << i << (nxt[i] ? " " : "\n");
}
return 0;
}
关键点:
- 为什么用链表不用数组? 数组插队要把后面所有元素后移,单次 O(N),M 次就是 O(NM);双向链表删除+插入都是 O(1),总计 O(N+M),N、M 达 10^5 时差距巨大
- 哨兵 0 让队首/队尾不必特判:
nxt[0]是真正队首,pre[0]是真正队尾 - 这里以编号本身作下标(编号即「指针」),是数组模拟链表的典型用法——和 2.7.7 的对照表完全一致
detach必须在insertAfter之前,否则 x 的旧邻居关系还没断开
题目 2.7.10 — 用指针实现 myStrlen (指针遍历C风格字符串的经典练习)
不使用任何库函数,用纯指针操作实现 int myStrlen(const char* s),返回字符串长度(不含 \0)。在 main 中测试。
期望输出(输入 "hello"): 5
💡 题解(点击展开)
思路: 指针从首字符出发,逐字符前进,遇到 \0 停止。走过的步数就是长度。
#include <bits/stdc++.h>
using namespace std;
int myStrlen(const char* s) {
int len = 0;
while (*s != '\0') { // 等价于 while (*s)
len++;
s++; // 指针前进到下一个字符
}
return len;
}
int main() {
const char* s = "hello";
cout << myStrlen(s) << "\n"; // 5
return 0;
}
关键点:
*s解引用取当前字符;s++让指针前进(指针算术)- 循环条件
*s != '\0'——C 风格字符串以\0标记结尾 - 这个练习让你体会「指针 + 循环 = 字符串操作」的本质
题目 2.7.11 — 用指针实现 myStrcpy (指针赋值的经典练习)
不使用任何库函数,用纯指针操作实现 void myStrcpy(char* dst, const char* src),将 src 指向的字符串完整复制到 dst。在 main 中测试。
期望输出(输入 "world"): world
💡 题解(点击展开)
思路: 两个指针同步前进,逐字符赋值,最后手动补 \0。
#include <bits/stdc++.h>
using namespace std;
void myStrcpy(char* dst, const char* src) {
while (*src != '\0') {
*dst = *src; // 逐字符赋值
dst++;
src++;
}
*dst = '\0'; // 别忘了末尾的 '\0'!
}
// 精简版(理解原理后再看):
void myStrcpyV2(char* dst, const char* src) {
while ((*dst++ = *src++)); // 赋值 + 判断 + 前进,一体完成
}
int main() {
const char* src = "world";
char dst[100];
myStrcpy(dst, src);
cout << dst << "\n"; // world
return 0;
}
关键点:
const char* src——源字符串用const修饰,防止误修改- 必须手动追加
\0,否则dst不是合法的 C 字符串 - 精简版
(*dst++ = *src++)利用后置++:先赋值再指针前进,循环到赋值结果为\0时自动停止
题目 2.7.12 — 合并两个有序链表 (经典指针操作,面试高频题)
给定两条升序链表 L1 和 L2,将它们合并成一条新的升序链表并返回头指针。要求只修改指针,不新建节点(即不能用 new)。
样例输入(两条链表): 1→3→5 和 2→4→6
样例输出: 1 2 3 4 5 6
💡 题解(点击展开)
思路: 用一个「哨兵」哑节点(dummy)简化头处理,维护一个尾指针 tail。每次比较两条链表的当前头节点,把较小的摘下来接到 tail 后面,被摘的那条链表头指针前进。最后把剩余部分直接接上。
#include <bits/stdc++.h>
using namespace std;
struct Node {
int data;
Node* next;
};
Node* mergeTwoLists(Node* l1, Node* l2) {
Node dummy{0, nullptr}; // 哑节点,简化头部处理
Node* tail = &dummy; // tail 始终指向结果链表的尾
while (l1 != nullptr && l2 != nullptr) {
if (l1->data < l2->data) {
tail->next = l1; // 摘 l1 当前节点
l1 = l1->next; // l1 前进
} else {
tail->next = l2; // 摘 l2 当前节点
l2 = l2->next; // l2 前进
}
tail = tail->next; // tail 始终保持在尾部
}
// 把剩余的链表直接接上(最多一条非空)
tail->next = (l1 != nullptr) ? l1 : l2;
return dummy.next; // 跳过哑节点,返回真正的头
}
int main() {
// 构建 L1: 1→3→5
Node* l1 = new Node{1, new Node{3, new Node{5, nullptr}}};
// 构建 L2: 2→4→6
Node* l2 = new Node{2, new Node{4, new Node{6, nullptr}}};
Node* merged = mergeTwoLists(l1, l2);
for (Node* p = merged; p; p = p->next)
cout << p->data << " ";
cout << "\n"; // 1 2 3 4 5 6
return 0;
}
关键点:
- 哑节点(dummy node) 是链表题的经典技巧——避免当头节点为空时的特判
tail始终保持指向结果链表的最后一个节点,接新节点时 O(1)- 指针操作的核心:改了
tail->next后必须同步更新tail和被摘链表的头指针 - 这是合并(Merge)、链表归并排序(Merge Sort)的基础子操作
题目 2.7.13 — 找链表的中间节点 (快慢指针的又一经典应用)
给定一条单链表,用快慢指针找到它的中间节点。如果长度为偶数,返回第二个中间节点。
样例输入: 1→2→3→4→5
样例输出: 3(第 3 个节点是中间)
样例输入: 1→2→3→4
样例输出: 3(偶数长度,返回靠后的中间节点)
💡 题解(点击展开)
思路: 慢指针每次走 1 步,快指针每次走 2 步。当快指针到达末尾时,慢指针正好在中点。
#include <bits/stdc++.h>
using namespace std;
struct Node {
int data;
Node* next;
};
Node* findMiddle(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next; // 慢:1 步
fast = fast->next->next; // 快:2 步
}
return slow; // fast 到末尾时,slow 就是中点
}
int main() {
// 测试 1: 1→2→3→4→5(奇数)
Node* head1 = new Node{1, new Node{2, new Node{3,
new Node{4, new Node{5, nullptr}}}}};
cout << findMiddle(head1)->data << "\n"; // 3
// 测试 2: 1→2→3→4(偶数)
Node* head2 = new Node{1, new Node{2, new Node{3,
new Node{4, nullptr}}}};
cout << findMiddle(head2)->data << "\n"; // 3
return 0;
}
关键点:
- 快慢指针是处理链表「中点」「倒数第 K 个」「环检测」的通用利器,时间 O(N)、空间 O(1)
- 循环条件
fast && fast->next——因为快指针一次走两步,两个判空缺一不可 - 当
fast == nullptr(奇数长度)或fast->next == nullptr(偶数长度)时循环退出,slow恰好在中点 - 如果想要第一个中间节点(偶数长度时返回靠前那个),可以让快指针先走一步:
fast = head->next
🔗 USACO 真题练手
本章的指针 / 链表思想直接对应下面这些 USACO 官方真题。建议先用本章的「指针版」或「数组模拟版」实现,再去 usaco.org 提交验证:
| 真题 | 难度 | 考点 | 与本章的对应 |
|---|---|---|---|
| Reordering the Cows(USACO 2014 March Bronze P1) | 🟢 Bronze | 排列环 / 沿「指针」追踪 | 把 A[i] 当作「i 指向 A[i]」,顺着下标一路跳——正是 2.7.4「数组下标即指针」的思想,也是 Floyd 判圈在数组上的版本 |
| Cow Line(USACO 2009 Open Silver) | 🟡 Silver | 双端链表 / deque 模拟 | 两端频繁增删,正是 2.7.9「奶牛排队」的强化版;可用数组模拟双向链表,或直接用 deque(第 3.6 章) |
💡 解题提示:
- Reordering the Cows:每头牛要从位置
i走到目标位置,沿着「当前牛该去哪、那个位置原来的牛又该去哪」一路跟踪,就会走出一个个「环」。这种「顺着指向一路跳」的遍历,和链表遍历、Floyd 判圈是同一种思维。- Cow Line:左右两端都要 O(1) 增删,标准做法是双端队列。理解了本章的双向链表(
pre/nxt两个方向的「指针」),你就能明白deque内部为什么能两端 O(1)。
📌 更系统的题单:USACO Guide 按难度和专题(含 Stacks/Queues、Linked Lists)整理了官方真题,是赛前刷题的权威路线图。