📖 第 2.7 章 ⏱️ 约 90 分钟 🎯 入门 → 进阶

📖 第 2.7 章:指针与内存

⏱ 预计阅读时间:90 分钟 | 难度:🟡 入门到进阶(理解 C++ 的关键一环)

📝 前置条件: 第 2.3 章(函数、数组、按引用传递)、第 2.4 章(结构体与类)

很多初学者觉得指针「玄学」「容易崩溃」「能不用就不用」。但真相是:指针是理解 C++ 如何使用内存的钥匙。一旦你建立起「变量住在内存里,每个住处都有门牌号」的图景,指针、引用、数组、动态内存、链表、树——这些后续会反复出现的概念——会突然变得清晰。

本章不要求你成为内存管理专家,而是帮你建立正确的心智模型,并掌握竞赛中真正会用到的指针技能。


前置条件

  • 第 2.3 章:函数、数组、按引用传递(& 参数)
  • 第 2.4 章:结构体与类(指针在链表/树中大量配合结构体使用)
  • 知道变量是「存储数据的容器」

🎯 学习目标

学完本章后,你将能够:

  1. 理解「内存」「地址」「指针」三者的关系,建立正确的心智图景
  2. 使用取地址运算符 & 和解引用运算符 *
  3. 区分指针引用,知道各自的适用场景
  4. 理解数组名与指针的关系,看懂指针算术
  5. 使用 new / delete 进行动态内存分配,并理解什么是内存泄漏
  6. 用指针搭建最基础的链表二叉树节点
  7. 知道竞赛中何时该用指针、何时不该用
  8. 理解多级指针(int**)的概念,看懂动态二维数组和「指针的指针」
  9. 使用指针操作 C 风格字符串(char*),手动实现 strlen / strcpy
  10. 了解函数指针的基本语法,看懂回调与自定义排序的底层机制

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;   // ❌ 危险!解引用空指针 → 程序崩溃(段错误)

⚠️ 不要用旧写法 NULL0 老代码常写 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) 中的 arrarr + 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 动态内存:newdelete

到目前为止,我们的数组大小要么编译时确定,要么用 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;
}

⚠️ 三条铁律

  1. newdeletenew[]delete[] 申请数组用 new[],就必须用 delete[] 释放,否则行为未定义。
  2. 每个 new 都要有对应的 delete 否则就是内存泄漏——申请的内存永远还不回去。
  3. 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->datacur->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;              // 释放被删除节点
}

按值删除第一个出现的节点

按值删除时,我们不知道前驱是谁,所以要一边遍历一边维护 prevcur

按值删除时维护 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)headi
头部插入 / 删除O(1)head 即可
已知节点后插入O(1)改两条指针
已知前驱时删除后继O(1)改一条指针并 delete
只知道值时删除O(N)先找前驱,再删除

链表最容易写错的地方

  1. 忘记处理空链表: head == nullptr 时不能访问 head->next
  2. 忘记更新 head 删除或插入到头部时,head 本身会变。
  3. 改指针顺序错误: 插入时先保存旧后继,再重连;反转链表时先保存 next,再改 cur->next
  4. 删除后继续使用旧指针: delete victim; 之后 victim 就不能再解引用。
  5. 维护 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约定一个特殊值,如 -10
新建节点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**

为什么需要指向指针的指针?

三个经典场景:

  1. 在函数内修改指针本身(比如让一个 int* 参数指向新分配的内存)
  2. 动态二维数组int** 指向一组 int*,每个 int* 再指向一行)
  3. 命令行参数 argvmain 的第二个参数就是 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 xint* 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 并判空
2new[]delete 释放int* a = new int[n]; delete a;数组必须用 delete[],否则未定义行为改成 delete[] a;
3内存泄漏newdelete申请的堆内存永不归还每个 new 配一个 delete;竞赛中优先用 vector
4悬空指针delete p; cout << *p;delete 后内存已释放,p 指向无效区域delete p; 后立刻 p = nullptr;
5混淆声明的 * 和解引用的 *int *p = *x;想取地址却写成解引用取地址用 &xint* 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% 的题目你用 vectorstring、引用就够了,根本不用裸指针。 但当你做到链表、二叉树、图的链式前向星,或者读别人的模板代码时,不懂指针就寸步难行。本章的目标是让你「看得懂、用得对」,而不是处处手写 new

Q2:引用和指针,到底优先用哪个?

A:优先引用。 函数传参避免复制 → 用 const T&;要修改原变量 → 用 T&。只有在「目标会变化」(遍历链表)、「可能没有目标」(树的空孩子 nullptr)、「动态分配」(new)这三种情况下才需要指针。

Q3:*p&xp->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,通过 *px 改成 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;
}

关键点:

  • headtail 两个指针,尾插法 O(1) 接节点
  • cur = cur->next 是链表遍历的标准写法
  • 用完逐个 delete(竞赛中可省,程序结束自动回收)

🏆 挑战题


挑战 2.7.7 — 反转链表 (经典面试 & 竞赛基础题)

建立一条链表 1→2→3→4→5反转它使之变成 5→4→3→2→1,然后打印。要求只用指针操作,不借助数组。

期望输出: 5 4 3 2 1

💡 题解(点击展开)

思路: 三指针法——prevcurnext。每步把 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 — 检测链表是否有环 (快慢指针经典应用)

给定一条可能成环的链表,判断它是否有环。打印 YESNO

提示:用「快慢指针」——慢指针每次走 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 — 合并两个有序链表 (经典指针操作,面试高频题)

给定两条升序链表 L1L2,将它们合并成一条新的升序链表并返回头指针。要求只修改指针,不新建节点(即不能用 new)。

样例输入(两条链表): 1→3→52→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)整理了官方真题,是赛前刷题的权威路线图。