CSP-S 提高级课程中心 / C++进阶与STL / 第4讲
CSP-S 提高级 · C++进阶与STL · s2-01

类的概念 与 简单应用

从 struct 跨到 class,学会把"数据 + 操作"打包成一个整体。用类封装分数、日期、栈、并查集,让代码从"一堆散落的数组"变成"有名字、有边界、可复用的工具"。

3 个关键机制 4 个封装案例 1 套并查集类模板 3 道实践题 12 道洛谷原题
本讲 · 第 4/52 讲类的概念与简单应用
模块 01

情景导航

从"改了一行却崩了三处"的真实困境,看类为什么能让复杂代码变得可控。

故事一行改错的连锁反应

小 C 写了一道需要"维护一个集合、不断合并、查询两个元素是否同组"的题。他用三个全局数组实现:int fa[100005];、int rnk[100005];、int sz[100005];,并写了三个全局函数 find、merge、init。

程序通过了。可当他再做一道同样需要并查集的题时,他把两份代码拼在一起,顺手把数组开大到 1000005,却忘了把 init 里的循环上界一起改。

结果:几处莫名其妙的错误答案,调试了两小时才发现是"两份代码共用同名全局数组,一份改了另一份也受影响"。

小 D 遇到同样的问题时,写的是一个类:

class DSU { int fa[N], rnk[N]; public: void init(int n); int find(int x); void merge(int a,int b); };

接着在 main 里写 DSU d1, d2; —— 两个互相独立的并查集。数据、边界、初始化全部封在类里,改 N 只需改一处。

全局变量是"公共黑板",谁都能擦;类是"私人笔记本",各自安好。

💡 类到底解决了什么问题

问题一 · 命名冲突。全局数组与函数只能有一套名字。两个并查集就要叫 fa1/fa2,代码立刻变乱。类的每个对象都有自己的一份成员变量。

问题二 · 数据与操作分离。数组是数据,函数是操作,两者靠"约定"联系。一旦有人忘了调用 init,程序不会报错,只会静默出错。类可以把初始化放进构造函数,对象一诞生就是可用状态。

问题三 · 缺少边界保护。全局数组谁都能直接改,出错了无从追查。类用 private 把内部状态藏起来,只能通过成员函数修改 —— 修改点集中、易于排查。

结论:类不是"更高级的语法",而是让复杂程序保持在可理解范围内的工具。这正是提高组从"能写出正确代码"走向"能维护复杂代码"的分水岭。

①生活类比
📱

类 = 手机的设计图

设计图定义了"有屏幕、有电池、有摄像头",并规定"按电源键开机"。它本身不是手机,而是造手机的模板。

→ class 定义
📲

对象 = 一部具体的手机

按设计图造出的每一部手机都是独立对象,各有自己的电量与相册,互不干扰。

→ 实例化对象
🔒

private = 手机内部电路

你不能直接碰主板上的焊点,只能通过屏幕与按键操作。封装让外部无法破坏内部状态。

→ 访问控制
🔋

构造/析构 = 开箱与回收

出厂时自动装好系统(构造),报废时自动回收资源(析构)。你不用手动做,但一定会发生。

→ 构造函数
②核心定义

类(class):一种用户自定义的数据类型,把数据(成员变量)与操作这些数据的函数(成员函数)打包在一起。类只是"设计图",本身不占运行时的存储。

对象(object):类的一个实例。DSU d1; 就是按 DSU 这张图造出的一个具体对象,拥有自己独立的一份成员变量。

访问控制:public(类外可访问)、private(只有本类的成员函数可访问)、protected(本类与派生类可访问)。struct 默认 public,class 默认 private —— 这是两者在语法上的唯一区别。

构造函数(constructor):函数名与类名相同、没有返回类型,在对象创建时自动调用。用于初始化成员变量,保证"对象诞生即可用"。

析构函数(destructor):名为 ~类名、无参数无返回值,在对象生命周期结束时自动调用。用于释放资源(如动态分配的内存)。

this 指针:在成员函数内部指向"调用这个函数的对象"的指针。用于区分同名的成员变量与参数名,也用于返回自身引用(链式调用)。

不变式(invariant):对象在任一时刻都应满足的条件。例如栈的 top 永远不小于 0。类的价值就在于"通过隐藏数据,保证不变式不被破坏"。

📌 本讲核心知识地图

板块核心内容对应大纲
struct 与 class语法几乎相同,默认访问权限不同【6】类的概念
访问控制public / private / protected 的作用与选择【6】类的概念
构造与析构自动初始化与清理,初始化列表【6】类的简单应用
this 指针区分同名成员与参数、链式返回 *this【6】类的简单应用
封装数据结构栈、并查集、线段树的类化写法【6】类的简单应用
竞赛实用场景多份同构结构、边界集中管理、代码复用【6】类的简单应用
③核心口诀
🔑 struct 与 class

语法几乎一样,只差默认权限:
struct 默认全公开,class 默认全私有;忘了写 public: 就等着报"不可访问"!

🔑 封装三步

数据藏 private,接口放 public,初始化交给构造函数;
对象一诞生就处于合法状态,谁也别想偷偷改坏它!

🔑 类与竞赛

一份结构要用多次 → 写成类;
多份同类结构互相独立 → 一个类,多个对象;
模板代码要复用 → 类 + 模板参数!

模块 02

知识探究

先从 struct 自然过渡到 class,掌握三个关键机制,再看四个封装案例。

1从 struct 到 class 基础

你在入门阶段已经用过 struct 打包数据。加上成员函数后,struct 就能做类的大部分事情 —— 事实上在 C++ 中 struct 就是一种 class。

① 纯数据struct Point
② 数据 + 函数加成员函数
③ 加访问控制private + public
④ 构造/析构自动管理生命周期
🧱 struct
  • 成员默认 public
  • 写竞赛代码更省事(不用打 public:)
  • 习惯上用于"纯数据打包":坐标、边、查询
  • 也可以有成员函数与构造函数
📦 class
  • 成员默认 private
  • 需要显式写 public: 才能外部访问
  • 习惯上用于"需要维护不变式"的抽象:栈、并查集
  • 强调"接口与实现分离"
C++同一个东西,用 struct 和 class 各写一遍
// ── 写法一:struct(默认 public,简洁)──
struct PointA {
    int x, y;                       // 默认就是 public
    void print() { cout << x << "," << y; }
};

// ── 写法二:class(默认 private,必须显式公开)──
class PointB {
    int x, y;                       // 默认 private!外部不可访问
public:
    void set(int a, int b) { x = a; y = b; }
    void print() { cout << x << "," << y; }
};

int main() {
    PointA a;
    a.x = 3; a.y = 4;              // ✅ 可访问
    a.print();                      // 3,4

    PointB b;
    // b.x = 3;                     // ❌ 编译错误:'x' is private
    b.set(3, 4);                  // ✅ 通过公开接口修改
    b.print();                      // 3,4
    return 0;
}
编译与运行结果
$ g++ a.cpp -O2 -Wall -std=c++17 -o a && ./a
3,4
3,4

# 若把 b.x = 3; 那行取消注释,编译报错:
error: 'int PointB::x' is private within this context
     b.x = 3;

🔍 代码逐段解析

PointA(struct) — 成员 x, y 与函数 print() 都默认 public,所以 main 里能直接 a.x = 3。

PointB(class) — 不写访问说明符时成员默认 private,b.x = 3 直接编译报错;必须显式 public: 后给出 set / print 两个接口。

main — 两种写法产出同样的结果 3,4;差别只在"数据能不能被外部直接摸到"。

易错点 — ① 以为 struct 不能有成员函数(那是 C 的规则);② class 里忘写 public:,把接口也一并藏成 private,main 里所有调用全部报错。

实用建议:竞赛中"只用来打包数据"的类型(坐标、边、事件)用 struct 更省事;"需要保护内部状态"的抽象(栈、并查集、线段树)用 class。这既是习惯,也让代码意图更清晰。
2访问控制:public / private / protected
关键字谁能访问竞赛中的用法
public任何代码都可以对外接口:push、find、merge、print
private只有本类的成员函数内部数据:fa[]、stk[]、top、sum 等
protected本类与派生类需要被继承者使用的内部成员(提高组少见)
C++访问控制的典型用法:内部数据私有,接口公开
class Counter {
private:
    long long cnt;                  // 外部不能直接改,避免被误清零
public:
    Counter() { cnt = 0; }           // 构造函数:自动初始化为 0

    void add(long long v) {
        if (v < 0) return;          // ✅ 可以在这里加入校验,保护不变式
        cnt += v;
    }
    long long get() const { return cnt; }   // 只读访问器
    void reset() { cnt = 0; }
};

int main() {
    Counter c;
    c.add(5);                       // 通过接口修改
    c.add(-100);                    // 被接口里的校验拦下,不生效
    cout << c.get() << endl;          // 5
    // c.cnt = 0;                  // ❌ 编译错误:private
    return 0;
}
运行结果
$ g++ a.cpp -O2 -Wall -std=c++17 -o a && ./a
5
动画:Counter 对象的一生(数值与上方代码一致)
1Counter c; — 构造函数自动执行,cnt = 0。还没写一行赋值代码,对象已经合法。
2c.add(5) — v = 5 ≥ 0 通过校验,cnt: 0 → 5。
3c.add(-100) — v < 0 命中 return,请求被接口拦下,cnt 仍是 5。
4c.get() — 只读访问器返回 5,与运行结果的输出一致。
5c.cnt = 0; — 想绕过接口?cnt 是 private,编译直接报错,不变式始终受保护。
私有的意义:所有修改都必须经过 add 的校验,"计数不为负"这条不变式从构造到析构一直成立。

🔍 代码逐段解析

private 段 —— long long cnt; 藏起来,外部不能直接改,避免被误清零。

构造函数 —— Counter() { cnt = 0; }:对象一诞生 cnt 就是 0,杜绝"忘记初始化"。

add() —— 先校验 if (v < 0) return; 再 cnt += v;。校验写在唯一的修改入口里,"cnt 不为负"这条规则只维护这一处。

get() —— const 只读访问器:承诺不改对象,常量对象也能调用;reset() 是唯一允许"清零"的合法通道。

易错点 —— ① get() 忘写 const,将来在 const Counter& 上调用就编不过;② 校验如果散落在调用方代码里,漏写一处不变式就被破坏——所以必须收进成员函数。

✅ private 数据 + public 接口

所有修改都经过成员函数,可以在函数里做校验(如负数不加、越界不写),保证对象始终处于"合法状态"。

出 bug 时只需检查少数几个接口,而不是全项目搜索"谁改了这个数组"。

❌ 全部 public

看起来"省事",但任何人都能绕过校验直接改数据,不变式(如 top ≥ 0)随时可能被破坏。

调试时无从下手:数组的值变了,可能是几十处代码中的任何一处干的。

竞赛务实建议:不必教条地"所有成员都 private"。如果某个成员确实是被当作公开数据使用(如线段树的 sum 数组要在调试时打印),放 public 完全可以。关键是把"需要保护的边界"藏起来。
3构造函数与析构函数 核心

构造函数解决了一个根本问题:如何保证对象一诞生就是合法的。它的名字与类名相同,无返回类型,可带参数、可重载。

C++构造函数的四种常见形态
class Vec {
public:
    double x, y;

    // ① 默认构造函数(无参数):Vec v; 时调用
    Vec() { x = 0; y = 0; }

    // ② 带参数的构造函数:Vec v(3, 4); 时调用
    Vec(double a, double b) { x = a; y = b; }

    // ③ 初始化列表(推荐写法):在进入函数体前就完成初始化
    // Vec(double a, double b) : x(a), y(b) {}

    // ④ 默认参数形式(可同时替代前两种)
    // Vec(double a = 0, double b = 0) : x(a), y(b) {}

    double len2() { return x * x + y * y; }
};

int main() {
    Vec a;              // 调用默认构造 → (0,0)
    Vec b(3, 4);        // 调用带参构造 → (3,4)
    Vec c = Vec(1, 2);    // 同样是带参构造
    Vec d[3];            // 数组:3 个元素都调用默认构造
    cout << b.len2() << endl;   // 25
    return 0;
}

🔍 代码逐段解析

默认构造 ① —— Vec() { x = 0; y = 0; }:Vec a; 时调用,a 得到 (0,0)。

带参构造 ② —— Vec(double a, double b):Vec b(3, 4) 时调用,b 得到 (3,4)。

main 中的三种写法 —— Vec c = Vec(1, 2); 走的同样是带参构造;Vec d[3]; 对 3 个元素各调用一次默认构造——这就是"对象数组需要默认构造"的原因。

len2() —— x*x + y*y,对 b(3,4) 返回 9+16=25,与输出一致。

易错点 —— ① 一旦写了带参构造,编译器就不再自动生成默认构造,Vec a; 立即编译失败;② 默认参数版 Vec(double a = 0, double b = 0) 不能与无参版 Vec() 同时写,否则调用 Vec a; 时二义。

写法效果要点
Vec() {}手写默认构造若自己写了任何构造函数,编译器就不再自动生成默认构造
Vec() = default;显式要求编译器生成默认构造保留自动生成的语义,意图清晰
Vec(int a) : x(a) {}初始化列表比在函数体里赋值更高效,且对 const 与引用成员是唯一方式
Vec(int a = 0) {}默认参数版一个函数两用,代码更短;注意别与无参版本同时写(会冲突)
~Vec() {}析构函数对象销毁时自动调用,用于释放 new 出来的内存
C++析构函数:看清"自动调用"的时机
#include <bits/stdc++.h>
using namespace std;

class Tracer {
    string name;
public:
    Tracer(string n) : name(n) { cout << "构造 " << name << endl; }
    ~Tracer()                  { cout << "析构 " << name << endl; }
};

void work() {
    Tracer t("函数内的对象");      // 进入函数时构造
    cout << "  ...干活中..." << endl;
}                                  // 离开函数作用域 → 自动析构

int main() {
    Tracer a("main 里的 a");
    work();
    {
        Tracer b("内层花括号里的 b");
    }                              // 出花括号 → b 被析构
    cout << "main 即将结束" << endl;
    return 0;
}                                  // main 结束 → a 最后析构
运行结果
构造 main 里的 a
构造 函数内的对象
  ...干活中...
析构 函数内的对象          ← 离开 work() 时自动析构
构造 内层花括号里的 b
析构 内层花括号里的 b      ← 出花括号时自动析构
main 即将结束
析构 main 里的 a           ← main 结束时最后析构

# 规律:析构顺序与构造顺序相反(后构造的先析构),像栈一样
动画:Tracer 的构造与析构时刻(与运行结果逐步对应)
1Tracer a("main 里的 a") — main 开始,先构造 a。
2work() 被调用 — 进入函数,构造 Tracer t("函数内的对象")。
3work() 返回 — t 离开函数作用域,立即析构(此刻 main 还没结束!)。
4进入内层花括号 — 构造 Tracer b("内层花括号里的 b")。
5离开花括号 — b 立即析构,a 仍活着。
6main 结束 — a 最后析构。析构顺序:t → b → a,与构造顺序 a → t → b 完全相反。
析构跟着"作用域"走,不跟着"main 什么时候结束"走——内层对象先于 main 结束被销毁。
🔍 代码逐段解析 · Tracer
1class Tracer { string name; } — 私有成员只有一个名字字符串,用于在构造/析构时"报名"。
2Tracer(string n) : name(n) — 构造函数用初始化列表给 name 赋值,再打印"构造 ××"。
3~Tracer() — 析构函数:名字同类名加 ~,无参无返回,打印"析构 ××"。
4work() 里的局部对象 t — 函数体结束(右花括号)就是它的生命终点。
5main 里的裸花括号块 — { Tracer b(...); } 也是独立作用域,出块即析构 b。
6易错点:以为所有析构都攒到 main 结束才发生(忽略了 t 和 b 的作用域);或把析构顺序写成与构造相同。
为什么竞赛里通常不写析构函数?因为竞赛代码几乎只用全局数组与 vector,很少手动 new,不需要手动释放。但你要记住这个概念:STL 容器(vector、string)正是靠析构函数在离开作用域时自动释放内存,这就是"不会内存泄漏"的原因。
4this 指针与成员函数

每个成员函数都有一个隐藏参数:指向当前对象的 this 指针。d1.find(3) 与 d2.find(3) 调用的都是同一个 find 函数,靠 this 才知道操作的是哪个对象。

C++this 的两个用途
class Point {
    int x, y;
public:
    // 用途①:参数与成员同名时,用 this-> 明确指定成员
    void set(int x, int y) {
        this->x = x;                 // 左边是成员,右边是参数
        this->y = y;
    }

    // 用途②:返回自身引用,支持链式调用
    Point& move(int dx, int dy) {
        x += dx; y += dy;
        return *this;               // 返回当前对象本身
    }

    void print() const { cout << x << "," << y << endl; }
};

int main() {
    Point p;
    p.set(1, 2);
    p.move(3, 0).move(0, 4).move(1, 1);   // 链式调用
    p.print();                        // 5,7
    return 0;
}
运行结果
$ g++ a.cpp -O2 -Wall -std=c++17 -o a && ./a
5,7     ← 1+3+0+1 = 5,2+0+4+1 = 7
动画:链式调用中 this 的流转(数值与上方代码一致)
1p.set(1, 2) — 参数 x,y 与成员同名,靠 this->x = x; 区分:左边成员、右边参数。p = (1,2)。
2p.move(3, 0) — this 指向 p,x += 3 → p = (4,2),return *this 把 p 本身交回去。
3.move(0, 4) — 接住上一步返回的 p,y += 4 → p = (4,6)。
4.move(1, 1) — 继续在同一对象上累加 → p = (5,7)。三次 move 操作的始终是同一个 p。
5p.print() — 输出 5,7,与运行结果一致(1+3+0+1=5,2+0+4+1=7)。
链式调用能串起来的关键:move 返回 Point&(对象本身的引用),而不是拷贝;若写成返回 Point(值),每次返回的都是副本,后续 move 改的不再是 p。

🔍 代码逐段解析

set() — 形参 int x, int y 把成员名"遮住"了,函数体内直接写 x = x 是参数自己赋值自己,必须 this->x = x; 才能写到成员上。

move() — 修改成员后 return *this;,返回类型 Point&(引用)是链式调用的前提。

print() — 加 const,只读接口。

易错点 — ① move 的返回类型写成 Point(值返回)时链式调用"看起来能用",但改的是副本,p 本身不会累积位移;② 忘记 this-> 时 x = x 不报错也不生效,是最隐蔽的一类 bug。

写法含义什么时候用
void f()普通成员函数,可能修改对象push、merge、set 等修改操作
void f() constconst 成员函数,承诺不修改对象get、size、print 等只读操作
static void f()静态成员函数,没有 this与具体对象无关的工具函数
Point& f()返回自身引用链式调用、连续操作
inline void f()建议内联,减少函数调用开销循环里高频调用的小函数(如 find)
性能提醒:成员函数与普通函数在运行时没有性能差别(编译后都是普通函数调用,加 this 参数而已)。写类不会让程序变慢,把它加上 inline 或定义在类内部(自动内联)反而可能更快。
5类的实用价值:为什么提高组要会封装 关键
1
🔒
边界集中

数组大小、初始化、清空都写在一处,改一个数不用满项目找

2
📦
多份独立

一个类可以实例化多个对象,天然互不干扰

3
♻️
模板复用

配合 template,一套线段树可服务多种数据类型

4
🐞
易于调试

接口数量少,bug 只可能藏在少数几个函数里

5
📖
可读性强

d.merge(a,b) 比五行动作更直观地表达意图

6
⚡
零性能损失

成员函数编译后与普通函数等价,甚至可被内联

问 1要多份独立实例?
问 2要重复初始化?
问 3要复用/背模板?
结论封装成类

🎯 竞赛中最值得封装的三种结构

并查集:需要 fa 数组 + init/find/merge 三个函数,天然适合类化。当题目需要维护多组关系(如"两个不同的图")时,类化能省下大量改名工作。

线段树 / 树状数组:它们通常要处理多组数据(多测),每个测试用例都要重置内部数组。把"重置"写进 init 或构造函数,能避免"忘记清空导致上一组数据污染下一组"这类经典错误。

矩阵运算:矩阵乘法、快速幂、单位矩阵初始化都适合封装成类;配合运算符重载(下一讲内容),代码可读性会大幅提升。

判断标准:如果同一套代码在你脑中已经有"名字"(比如"这是个并查集"),那就值得把它写成一个类。类的名字就是最好的注释。

案案例 1:分数类——用构造函数维护「不变式」 必会

从小到大我们写过无数次分数运算,最烦的不是加减,而是约分和符号。如果每次运算后都要手动约分,早晚会漏。下面这个类的思路是:把"分母为正且已约分"变成一条永远成立的性质,只要每个出口都保证它,使用的人就不用再操心。

C++Fraction:构造即约分
#include <bits/stdc++.h>
using namespace std;

class Fraction {
private:
    long long num, den;              // 分子、分母(不变式:den > 0 且已约分)

    void reduce() {                  // 私有工具:只在类内用,外面看不到
        if (den < 0) { num = -num; den = -den; }   // 统一把负号放分子
        long long g = __gcd(abs(num), den);
        num /= g; den /= g;
    }
public:
    Fraction(long long n = 0, long long d = 1) : num(n), den(d) {
        reduce();                    // 构造即约分:任何对象一出生就合法
    }
    Fraction add(const Fraction& o) const {           // 只读,不改自己
        return Fraction(num * o.den + o.num * den, den * o.den);
    }
    Fraction mul(const Fraction& o) const {
        return Fraction(num * o.num, den * o.den);
    }
    void print() const { printf("%lld/%lld\n", num, den); }
};

int main() {
    Fraction a(1, 2), b(2, 3);
    a.add(b).print();      // 1/2 + 2/3 = 7/6
    a.mul(b).print();      // 1/2 * 2/3 = 1/3
    Fraction c(6, -8);     // 自动整理成 -3/4
    c.print();
    Fraction d;            // 默认构造 = 0/1
    d.print();
    return 0;
}
运行结果
7/6
1/3
-3/4
0/1
动画:构造即约分——1/2+2/3 与 6/(-8) 的完整历程(数值与上方代码一致)
1a.add(b) — 按公式 num*o.den + o.num*den 通分:分子 1×3+2×2 = 7,分母 2×3 = 6。
2Fraction(7, 6) — 返回值触发构造函数,构造即约分:g = __gcd(7,6) = 1,7/6 已是最简,输出 7/6。
3Fraction c(6, -8) — 构造函数里 reduce() 第一步:den < 0,分子分母同时变号 → (-6, 8),负号移到分子。
4继续约分 — g = __gcd(6,8) = 2,-6/2 = -3,8/2 = 4,输出 -3/4。
5Fraction d; — 默认参数 n=0, d=1,g = __gcd(0,1) = 1,输出 0/1。
不管从哪个出口构造(add、mul、手写、默认),reduce() 都在构造函数里自动执行——调用者永远拿到"分母为正且已约分"的合法对象。

🔍 逐段解析

① 为什么 reduce() 放在 private?因为它会改变对象内部状态,如果公开,别人可能在任意时刻调用它,类就无法保证"什么时候是约分好的"。把它塞进构造函数里,调用者就再也不用想起它——这就是不变式(invariant)的威力。

② 为什么 add 用 const Fraction&?传引用避免拷贝(虽然两个 long long 拷贝不贵,但养成习惯很重要,后面换成大整数就很关键);加 const 表示"我承诺不修改参数 o",也让编译器帮你检查。

③ 为什么 add 自己的 const 也是必须的?因为 a.add(b) 中 a 只读;更重要的是,加了 const 后,add 才能被常量对象调用,也才能被其他 const 成员函数安全调用,这在写复杂结构时是硬性需求。

④ a.add(b).print() 为什么合法?add 返回一个临时的 Fraction 对象,print 是 const 成员函数,可以作用在临时对象上。注意:如果 print 忘了写 const,这行可能编不过或行为不确定。

⑤ __gcd(abs(num), den) 细节:__gcd 接受非负整数,所以对分子取了 abs;而分母在第一步已经被规范为正是,无需再取。

案案例 2:日期类——静态成员与「类外定义」 易错

日期题里总躲不开"每个月几天"这张表。每个 Date 对象都存一份 13 个 int 纯属浪费——月份天数是所有日期共用的知识,应该属于「类」而不是「对象」。这正是 static 静态成员的用武之地。

C++Date:静态表 + 闰年 + 求下一天
#include <bits/stdc++.h>
using namespace std;

class Date {
private:
    int y, m, d;
    static const int DAYS[13];       // 声明:全类共用一份,不占对象空间
public:
    Date(int y = 1, int m = 1, int d = 1) : y(y), m(m), d(d) {}

    static bool leap(int year) {     // 静态成员函数:没有 this,不依赖具体对象
        return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
    }
    int daysInMonth() const {        // 只读:看这个月的天数
        if (m == 2 && leap(y)) return 29;
        return DAYS[m];
    }
    Date nextDay() const {           // 只读:返回一份副本,不动自己
        Date t = *this;
        if (++t.d > t.daysInMonth()) {
            t.d = 1;
            if (++t.m > 12) { t.m = 1; ++t.y; }
        }
        return t;
    }
    void print() const { printf("%04d-%02d-%02d\n", y, m, d); }
};

// ★ 静态成员数组必须在类外再定义一次(C++17 前是硬性规定)
const int Date::DAYS[13] = {0,31,28,31,30,31,30,31,31,30,31,30,31};

int main() {
    Date t(2024, 2, 28);
    for (int i = 0; i < 4; i++) { t.print(); t = t.nextDay(); }
    Date u(2023, 12, 31);  u.nextDay().print();   // 2024-01-01
    Date v(1900, 2, 28);   v.nextDay().print();   // 1900-03-01(1900 非闰年)
    return 0;
}
运行结果
2024-02-28
2024-02-29
2024-03-01
2024-03-02
2024-01-01
1900-03-01
动画:nextDay 的两次跨日判断(数值与运行结果一致)
1Date t(2024, 2, 28) — 进入 nextDay() 后先 Date t = *this 复制副本,后续只改副本,原对象不动。
2++d → 29 — 与 daysInMonth() 比较:m==2 且 leap(2024) 为真 → 2 月有 29 天,29 ≤ 29 不越界,直接返回 2024-02-29。
3对 02-29 再来一次 — ++d → 30 > 29 越界,进入进位分支:d = 1。
4++m → 3 — 3 ≤ 12 不跨年,返回 2024-03-01。
5对照 2023-12-31 — ++d=32 越界进位 → d=1, m=1;++m=13 > 12 跨年 → m=1, ++y → 2024-01-01。
6对照 1900-02-28 — leap(1900):能被 100 整除但不能被 400 整除 → 平年,2 月只有 28 天 → 1900-03-01。
所有边界(闰月尾、月尾、年尾、世纪平年)都被 nextDay 的两层 if 天然覆盖——这正是把"日期规则"封装进类的价值。

把最容易出错的边界都点一遍:

测试点输入日期下一天考察什么
12024-02-282024-02-29闰年 2 月有 29 天
22024-02-292024-03-01闰年 2 月 29 → 3 月 1
32023-02-282023-03-01平年 2 月只有 28 天
42023-12-312024-01-01跨年进位
52000-02-282000-02-29整百年但被 400 整除 → 闰
61900-02-281900-03-01整百年不被 400 整除 → 平

✅ 用 static 的好处

100 万个 Date 对象,DAYS 表只有一份,节省内存;leap() 不依赖任何对象,用 Date::leap(y) 直接调用即可,语义上也更准确——"闰年判断"是日期的公共知识,不是某个日期的私有属性。

⚠️ 三个高频坑

坑 1:只在类内写了 static const int DAYS[13]; 却在类外漏掉定义,链接时报 undefined reference to Date::DAYS。
坑 2:把 nextDay 的返回值当引用:Date& nextDay() const 返回局部变量 t 的引用,函数一结束 t 就销毁了,是典型的悬空引用。
坑 3:漏写 const 导致 daysInMonth() 无法在 nextDay() const 里调用,编译直接报错。

🔍 代码逐段解析 · Date
1static const int DAYS[13]; — 静态成员声明:全类共用一份月份表,不占任何对象的空间;下标 0 空置,1~12 对应各月。
2Date(int y=1, int m=1, int d=1) : y(y), m(m), d(d) {} — 带默认值的构造函数,一份代码兼顾无参与带参两种用法。
3static bool leap(int year) — 静态成员函数没有 this,可写 Date::leap(2024) 直接调用;判断式先排"4 的倍数但不是 100 的倍数",再补"400 的倍数"。
4daysInMonth() const — 只读接口:2 月单独查闰年,其余月份查表 DAYS[m]。
5nextDay() const — 先 Date t = *this; 复制副本;++t.d 越界则 d=1、月进位;月越界则 m=1、年进位;返回副本而非引用。
6const int Date::DAYS[13] = {...} — 类外定义(不再写 static),漏掉它链接时报 undefined reference。
7易错点:① nextDay 返回类型写成 Date& → 返回局部变量引用,悬空;② leap 判断式漏掉 year % 400 == 0 分支 → 2000 年被误判为平年。
案案例 3:栈类——封装带来「用不错」的安全感 高频

用数组模拟栈时最容易犯的错是忘了维护 top 指针:入栈只写了 st[++top]=x 却忘了先判断越界,或者出栈时 top 减到了负数。封装成类以后,这些细节都被关进 push/pop 两个函数里,外部代码再也没有机会写错。

C++Stack:数组模拟栈
#include <bits/stdc++.h>
using namespace std;

class Stack {
private:
    static const int MAXN = 100005;
    int st[MAXN], tp;                 // tp 指向栈顶元素,空栈时 tp = 0
public:
    Stack() : tp(0) {}                // 构造函数:每个新栈都是空的
    void push(int x) { st[++tp] = x; }
    void pop()       { if (tp > 0) --tp; }     // 防御:空栈 pop 不出错
    int  top() const { return st[tp]; }        // 只读接口
    bool empty() const { return tp == 0; }
    int  size()  const { return tp; }
};

int main() {
    Stack s;
    s.push(5); s.push(3); s.push(8);
    printf("top=%d size=%d\n", s.top(), s.size());   // top=8 size=3
    s.pop();
    s.push(1);
    s.pop(); s.pop();
    printf("top=%d size=%d empty=%d\n", s.top(), s.size(), (int)s.empty());
    Stack t;                                          // 第二个栈,与 s 完全独立
    t.push(100);
    printf("t.top=%d s.top=%d\n", t.top(), s.top());
    return 0;
}
运行结果
top=8 size=3
top=5 size=1 empty=0
t.top=100 s.top=5

下面用动画实际走一遍这些操作,注意"栈顶"始终是最后进来的那个元素。

两个栈为什么互不干扰?因为 s 和 t 各自拥有一份 st 数组和 tp。这叫实例独立性——如果当初全写成全局变量,想同时维护两个栈就得把数组开成二维、函数再多传一个参数,代码立刻难看十倍。

🎯 想清楚再动手:为什么成员名不能叫 top?

如果成员变量写成 int top;,同时又想要一个函数 int top() const;,C++ 会直接报重定义错误——变量名和函数名共享同一个命名空间。所以这里把成员变量命名为 tp,函数命名为 top,既符合 std::stack 的习惯,又不冲突。

同理,不要给成员变量起 size、empty、count 这类名字如果你计划提供同名函数。常见做法是给私有成员统一加下划线后缀(top_)或前缀(m_top),一眼看出"这是内部数据"。

🔍 代码逐段解析 · Stack
1static const int MAXN = 100005; — 类内直接初始化的静态整型常量,不需要类外再定义(与 Date::DAYS 的区别:数组才必须类外定义)。
2int st[MAXN], tp; — 数组与栈顶指针一起藏进 private;约定 tp 指向栈顶元素,空栈时 tp = 0。
3Stack() : tp(0) {} — 构造即空栈,每个新对象天生干净,多组数据直接重建即可。
4push — 先 ++tp 再写入 st[tp] = x,保证 tp 始终指向栈顶;满了会怎样?类内没查——若题目可能超过 MAXN 就要在 push 里加防御。
5pop — if (tp > 0) --tp; 空栈 pop 也不出错,这条防御写在类里后,调用方永远不需要再判空。
6top / empty / size — 三个只读接口统一加 const;size() 直接返回 tp(元素个数),与"栈顶下标"恰好是同一个数。
7易错点:① 成员变量命名成 top 与函数 top() 重定义冲突(所以叫 tp);② 把 tp 约定成"指向栈顶的下一个位置"却沿用 st[tp] 的读法,top() 会读到垃圾值。
案案例 4:并查集类——竞赛中最经典的封装对象 提高组必写

并查集是竞赛里封装收益最高的结构:它需要 fa 数组、init、find、merge 四样东西,天然是一体的。封装之后,find 的路径压缩、merge 的按秩合并这些细节只写一次,谁都不会再写漏。

C++DSU:路径压缩 + 按秩合并
#include <bits/stdc++.h>
using namespace std;

class DSU {
private:
    vector<int> fa, sz;               // fa 父指针,sz 集合大小
public:
    DSU(int n = 0) { init(n); }      // 构造时就准备好,不怕忘初始化

    void init(int n) {
        fa.resize(n + 1); sz.assign(n + 1, 1);
        for (int i = 0; i <= n; i++) fa[i] = i;   // 每个点自成一个集合
    }
    int find(int x) {                // 路径压缩:顺便把沿途父亲都指向根
        return fa[x] == x ? x : fa[x] = find(fa[x]);
    }
    bool merge(int a, int b) {       // 返回 false 说明本来就在同一集合
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;
        if (sz[ra] < sz[rb]) swap(ra, rb);        // 小树挂到大树上
        fa[rb] = ra; sz[ra] += sz[rb];
        return true;
    }
    bool same(int a, int b) { return find(a) == find(b); }
    int  size(int x) { return sz[find(x)]; }       // 某元素所在集合的大小
};

int main() {
    int n = 6;
    DSU d(n);
    d.merge(1, 2); d.merge(2, 3); d.merge(4, 5);
    printf("same(1,3)=%d\n", (int)d.same(1, 3));   // 1
    printf("same(1,4)=%d\n", (int)d.same(1, 4));   // 0
    printf("size(2)=%d\n", d.size(2));             // 3
    d.merge(3, 4);
    printf("same(1,5)=%d size=%d\n", (int)d.same(1, 5), d.size(1));  // 1 5
    return 0;
}
运行结果
same(1,3)=1
same(1,4)=0
size(2)=3
same(1,5)=1 size=5

用格子动画看看 merge 是怎么把两个集合接起来的:同一颜色的格子代表同一个集合,第一次合并看起来还是"分块",多合并几次后颜色会迅速统一——这就是路径压缩的效果。

1
2
3
4
5
6

🔍 为什么竞赛选手几乎都写 DSU 类?

① 多组数据不再出事。题目有多个测试用例时,只要在每次读入后 d.init(n),或者直接 DSU d(n); 重建一个,绝不会出现"上一组数据残留"的经典错误。

② 扩展属性留有余地。想加"带权并查集"?只要在类里多塞一个 vector<int> w; 和几行 find 里的更新逻辑,使用方代码一行都不用改。

③ 复杂度直觉:路径压缩 + 按秩合并后,均摊复杂度约为 O(α(n)),其中 α 是反阿克曼函数——在 n < 10^9 的范围内它不超过 4,可以直接理解成"近似 O(1)"。所以并查集处理 10^6 次询问毫无压力。

④ 它的接口语义清晰:merge(a,b)、same(a,b)、size(x) 几乎就是题面语言。写题时你会明显感觉"代码在描述问题",而不是"代码在操纵数组"。

🔍 代码逐段解析 · DSU
1vector<int> fa, sz; — 动态开长度的父指针数组与集合大小数组,藏进 private,外部无法改坏。
2DSU(int n = 0) { init(n); } — 构造即初始化:fa[i] = i(各自成集合)、sz = 1;"忘 init"这类 bug 从根上消失。
3find — 一行路径压缩:fa[x] == x ? x : fa[x] = find(fa[x]); 递归找根,返回途中把沿途节点直接挂到根上。
4merge — 先找两个根;同根返回 false(本来就在一伙);否则 if (sz[ra] < sz[rb]) swap(ra, rb); 保证小树挂大树,再 fa[rb] = ra; sz[ra] += sz[rb];
5main 验证 — merge(1,2)(2,3) 后 {1,2,3},size(2)=3;merge(3,4) 把 {4,5} 并入 → same(1,5)=1,size=5,与运行结果一致。
6易错点:① init 里 fa.resize(n+1) 后忘了 for fa[i]=i(resize 不会自动设父指针);② merge 里漏 swap 直接 fa[ra]=rb 也不算错,但树高退化、复杂度不保。
模块 03

实践应用

三道完整题目:先用类管住几何数据,再用类组织排序键,最后用类扛住并查集多组询问。

题 1矩形面积统计 封装入门
📋 题目描述

给定平面上的 n 个矩形,每个矩形用左下角坐标 (x1, y1) 与右上角坐标 (x2, y2) 描述,保证 x1 < x2、y1 < y2。

请你求出:① 所有矩形的面积之和;② 其中最大的矩形面积。

⌨️ 输入格式

第一行一个整数 n。

接下来 n 行,每行四个整数 x1 y1 x2 y2,描述一个矩形。

🖨️ 输出格式

共两行:第一行为所有矩形面积之和,第二行为最大矩形的面积。

📥 输入样例
4
0 0 3 2
1 1 4 5
-2 -2 0 0
5 5 6 6
📤 输出样例
23
12

样例解释:四个矩形面积分别为 3×2=6、3×4=12、2×2=4、1×1=1,和 = 23,最大 = 12。

📏 数据范围

1 ≤ n ≤ 105,坐标满足 -104 ≤ x1 < x2 ≤ 104,-104 ≤ y1 < y2 ≤ 104。时间限制 1s,空间限制 256MB。

🧠 题目分析

这题本身简单,但正好用来体会"类帮我们挡住了什么"。

不加类会怎样?你得开四个数组 x1[]、y1[]、x2[]、y2[],然后在 main 里循环计算面积。每多一种需求(求周长、求交集、平移矩形),就要再加一段循环、再多写几个数组;数据散落各处,改一处漏一处。

加上类之后:面积怎么算写在 area() 里,只写一次;Rect 对象可像 int 一样放进 vector;哪天要加"求周长"只需在类里添一个函数,所有用到矩形的地方立刻都能用。

关于溢出:坐标范围 ±104,边长最大 2×104,面积最大 4×108,n 个相加最大 4×1013——已经超过 int 范围,所以 area() 的返回类型必须写成 long long。这是很多同学第一次栽跟头的地方:把"算面积"这件事交给类,就不会在不同地方漏写 long long。

C++题 1 参考代码
#include <bits/stdc++.h>
using namespace std;

class Rect {
private:
    long long x1, y1, x2, y2;      // 统一用 long long,杜绝溢出
public:
    Rect() : x1(0), y1(0), x2(0), y2(0) {}     // 默认构造:零矩形
    Rect(long long a, long long b, long long c, long long d)
        : x1(a), y1(b), x2(c), y2(d) {}
    void input() { scanf("%lld %lld %lld %lld", &x1, &y1, &x2, &y2); }
    long long area() const { return (x2 - x1) * (y2 - y1); }
    bool bigger(const Rect& o) const { return area() > o.area(); }
};

int main() {
    int n; scanf("%d", &n);
    vector<Rect> r(n);                      // 类对象可以放进 vector
    for (int i = 0; i < n; i++) r[i].input();
    long long sum = 0, mx = 0;
    for (int i = 0; i < n; i++) {
        sum += r[i].area();
        if (r[i].area() > mx) mx = r[i].area();
    }
    printf("%lld\n%lld\n", sum, mx);
    return 0;
}
运行结果
23
12
🔍 代码逐段解析 · 题 1(样例数值逐步核对)
1class Rect — 四个坐标成员统一 long long(面积最大 4×108、总和最大 4×1013,int 必炸)。
2两个构造函数 — 默认构造(零矩形)是 vector<Rect> r(n) 的硬需求:只写带参构造时这里编译失败。
3input() — 用 scanf("%lld ...") 读入四个成员;注意格式符与 long long 配套,写成 %d 会出错。
4area() const — (x2-x1)*(y2-y1):样例四个矩形分别 3×2=6、3×4=12、2×2=4、1×1=1。
5bigger() const — 调用 area():因为 area 加了 const,才能在 bigger 的 const 环境里被调用。
6主循环 — sum = 6+12+4+1 = 23,mx 一路取 max = 12,两行输出与样例一致。
7易错点:① sum/mx 声明成 int → 总和溢出;② mx 初值 0 依赖"面积恒正"(由题面 x1<x2、y1<y2 保证),若坐标约束放开要改成负无穷。

💭 解题思路

第一步 · 定对象。题目里反复出现"一个矩形",那就让 Rect 成为一个类。识别对象的方法很简单:题目中用同一组数据反复描述的东西,就是对象。

第二步 · 定接口。矩形需要什么操作?读入(input)、算面积(area)、比大小(bigger)。接口一旦定清楚,main 就只剩"读入 + 累加 + 输出"三件事。

第三步 · 藏细节。x1~y2 全部 private,外面只能通过 area() 拿结果,就不可能有人写出 (y2-y1)*(x2-x1) 这种顺序写反的错误。

考点:类的定义与使用、构造函数、const 成员函数、long long 溢出判断。

易错点:① area() 返回值写成 int 导致中途截断;② 忘记 const 使 area() 无法在 bigger() const 中调用;③ vector<Rect>(n) 需要默认构造函数,而只写了带参构造就会编译失败。

题 2成绩单排序 排序 + 封装
📋 题目描述

班主任拿到了一张成绩单,共 n 名学生,每名学生有姓名(不含空格)以及语文、数学、英语三科成绩。

请按总分从大到小排序输出;若总分相同,则按姓名字典序从小到大排列。

⌨️ 输入格式

第一行一个整数 n。

接下来 n 行,每行一个字符串 name 与三个整数,表示姓名与三科成绩。

🖨️ 输出格式

共 n 行,每行输出 姓名 总分,用单个空格分隔。

📥 输入样例
5
alice 90 85 88
bob 78 92 80
cathy 90 85 90
dave 60 60 60
eve 95 80 90
📤 输出样例
cathy 265
eve 265
alice 263
bob 250
dave 180

样例解释:cathy 与 eve 总分都是 265,由于 cathy < eve,所以 cathy 排在前面。

📏 数据范围

1 ≤ n ≤ 105,姓名长度不超过 20 且仅含小写字母,每科成绩 0 ≤ score ≤ 100。时间限制 1s,空间限制 256MB。

🧠 题目分析

这是一道多关键字排序题,也是"结构体 + 比较函数"最经典的用法。

A
指针数组法

开 4 个数组分别存姓名、三科、总分,再手写冒泡交换所有数组。代码冗长,交换时极易漏掉一个数组。

B
结构体数组法

把所有信息打包进一个 Stu,排序时只交换整体,不可能漏字段。这就是"数据成组"的威力。

两种排序写法对比:

写法代码适用场景
成员函数 + lambdasort(v.begin(), v.end(), [](const Stu&a, const Stu&b){ return a.isBetter(b); });需要复用比较逻辑、或类内部要用同一规则
直接重载 <sort(v.begin(), v.end());C++20 前需要写 operator<,下一讲内容

为什么推荐把比较规则写进类里?因为"怎么算更好"是学生这个概念的内部知识,不是排序算法的知识。规则写在类里,下次在别的题目里复用这个类时,排序自动就是对的;而写在 lambda 里,换一道题就得重新抄一遍比较逻辑。

C++题 2 参考代码
#include <bits/stdc++.h>
using namespace std;

class Stu {
private:
    string name;
    int a, b, c, tot;
public:
    void input() {
        cin >> name >> a >> b >> c;
        tot = a + b + c;                  // 总分在类内部算好,外面不用管
    }
    bool isBetter(const Stu& o) const {   // "排在前面"的判定规则
        if (tot != o.tot) return tot > o.tot;   // 总分高的优先
        return name < o.name;                    // 总分相同,名字小的优先
    }
    void print() const { cout << name << ' ' << tot << '\n'; }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n; cin >> n;
    vector<Stu> v(n);
    for (int i = 0; i < n; i++) v[i].input();
    sort(v.begin(), v.end(), [](const Stu& a, const Stu& b) {
        return a.isBetter(b);
    });
    for (int i = 0; i < n; i++) v[i].print();
    return 0;
}
运行结果
cathy 265
eve 265
alice 263
bob 250
dave 180
🔍 代码逐段解析 · 题 2(样例数值逐步核对)
1class Stu — 5 个字段:name + 三科 a,b,c + 预先算好的 tot,全部 private。
2input() — 读入后立刻 tot = a+b+c:排序约 1.7×106 次比较,每次省下 3 次加法。
3isBetter(const Stu& o) const — 总分不同比总分(降序用 >);相同比姓名(name < o.name 字典序升序)。两个 const 一个都不能少。
4main 开头两行 — sync_with_stdio(false); cin.tie(nullptr); 关同步,n=105 时 cin 才跑得进 1s。
5sort + lambda — lambda 把 a.isBetter(b) 包成 sort 要求的 bool(const Stu&, const Stu&) 签名。
6核对样例 — cathy 265 与 eve 265 同分,isBetter 里 "cathy" < "eve" → cathy 在前;总分序列 265, 265, 263, 250, 180 与输出一致。
7易错点:① 比较写成 tot >= o.tot 破坏严格弱序,sort 可能越界崩溃;② lambda 参数写成 Stu a, Stu b(按值)→ 每次比较拷贝两个含 string 的对象,超时主因。

💭 解题思路

第一步 · 识别字段。每个学生需要"姓名 + 三科 + 总分",共 5 个字段。总分虽是算出来的,但放进类里存一份最省事——避免每比较一次就重算三遍加法。n = 105 时排序约需 105 × log₂105 ≈ 1.7×106 次比较,每次省下两次加法,收益可观。

第二步 · 定义"更好"。isBetter 严格表达题面:"总分不同时看总分,总分相同时看姓名"。把它写成成员函数,题面规则与代码一一对应,改题只改这一处。

第三步 · 排序。sort 要求比较函数返回 bool,表示"第一个参数是否应排在第二个前面"。用 lambda 包一层,把成员的判断"翻译"成 sort 能理解的签名。

考点:结构体/类数组、多关键字排序、sort 自定义比较器、字符串比较、输入输出加速。

易错点:① 比较函数写成 a.tot >= b.tot(不满足严格弱序,sort 可能越界崩溃);② isBetter 忘了 const,lambda 参数是 const Stu& 就编不过;③ n = 105 时用 cin 未关闭同步,可能超出 1s。

题 3连通性询问 并查集
📋 题目描述

给定一张包含 n 个点、m 条边的无向图(可能存在重边,但保证无自环)。点编号为 1 ~ n。

接下来有 q 次询问,每次给出两个点 a、b,请回答这两个点是否在同一个连通块中。

⌨️ 输入格式

第一行三个整数 n m q。

接下来 m 行,每行两个整数 u v,表示一条连接 u 与 v 的无向边。

接下来 q 行,每行两个整数 a b,表示一次询问。

🖨️ 输出格式

共 q 行,若两点连通输出 Yes,否则输出 No。

📥 输入样例
6 4 3
1 2
2 3
4 5
3 1
1 3
1 4
5 6
📤 输出样例
Yes
No
No

样例解释:边构成两个连通块 {1,2,3} 与 {4,5},点 6 孤立。故 1-3 连通,1-4 与 5-6 不连通。

📏 数据范围

1 ≤ n ≤ 105,0 ≤ m ≤ 2×105,1 ≤ q ≤ 105。时间限制 1s,空间限制 256MB。

🧠 题目分析:三种方案对比

方案做法复杂度评价
暴力 DFS每次询问从 a 出发搜一遍,看能否到 bO(q × (n+m))最坏 105 × 3×105,必然超时
预处理 + 标记先 DFS 一遍给每个点标出所属连通块编号,询问时比较编号O(n+m+q)可行,但若题目变成"动态加边",就完全失效
并查集(推荐)把每条边当作一次合并,询问时比较根是否相同≈ O((m+q)·α(n))代码极短,且天然支持在线加边

为什么并查集是这道题的"天选"?题目的核心语义是"两点是否属于同一组",而并查集恰好只做两件事:合并两个组、查询两点是否同组。两者完美对应——题面怎么写,代码就怎么写。

关于复杂度的直觉:α(n) 是反阿克曼函数,增长极慢。n 取到宇宙原子数量级(约 1080)时 α(n) 也只有 4 左右。所以对本题规模,可以直接把它当成常数。总运算量约 3×105 × 4 = 1.2×106 次,1s 内绰绰有余。

C++题 3 参考代码
#include <bits/stdc++.h>
using namespace std;

class DSU {
private:
    vector<int> fa, sz;
public:
    DSU(int n = 0) { init(n); }
    void init(int n) {
        fa.resize(n + 1);
        sz.assign(n + 1, 1);
        for (int i = 0; i <= n; i++) fa[i] = i;
    }
    int find(int x) {
        return fa[x] == x ? x : fa[x] = find(fa[x]);   // 路径压缩
    }
    void merge(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return;
        if (sz[ra] < sz[rb]) swap(ra, rb);             // 按秩合并
        fa[rb] = ra; sz[ra] += sz[rb];
    }
    bool same(int a, int b) { return find(a) == find(b); }
};

int main() {
    int n, m, q;
    scanf("%d %d %d", &n, &m, &q);
    DSU d(n);
    for (int i = 0; i < m; i++) {
        int u, v; scanf("%d %d", &u, &v);
        d.merge(u, v);                                 // 加边 = 合并
    }
    for (int i = 0; i < q; i++) {
        int a, b; scanf("%d %d", &a, &b);
        puts(d.same(a, b) ? "Yes" : "No");
    }
    return 0;
}
运行结果
Yes
No
No
🔍 代码逐段解析 · 题 3(样例数值逐步核对)
1DSU d(n) — 读入 n 后立刻构造:fa[i]=i、sz=1,6 个点 6 个集合。不需要邻接表、不需要 DFS。
2读边循环 — 每条边一句 d.merge(u, v):merge(1,2) → {1,2};merge(2,3) → {1,2,3};merge(4,5) → {4,5};merge(3,1) 同根直接返回。
3此时集合 — {1,2,3}、{4,5}、{6} 孤立,与样例解释完全一致。
4询问循环 — puts(d.same(a, b) ? "Yes" : "No"):same(1,3) → 同根 Yes;same(1,4) → 不同根 No;same(5,6) → No。
5复杂度核对 — m+q ≤ 3×105 次操作 × α(n)≈4 ≈ 1.2×106,1s 内绰绰有余;暴力 DFS 是 O(q(n+m)) ≈ 3×1010,必然超时。
6易错点:① 数组开成 n 而非 n+1,读入编号 n 时越界;② m 可能为 0,边循环天然跳过,但别在循环外写"必读一条边"之类的假设。

💭 解题思路

第一步 · 认清本质。"两个点是否连通"等价于"把每条边看成一次'把两队合并成一队'的操作后,两点是否在同一队"。这是纯粹的关系维护问题,不是路径问题——所以不需要记录图的结构,只需要维护"谁和谁一组"。

第二步 · 选择结构。并查集只需 fa 数组,不需要 vector<vector<int>> 建邻接表,省下了大量的建图代码和内存。这是它能大幅简化代码的关键。

第三步 · 封装成类。注意 main 里读入边后只有一句 d.merge(u, v),读入询问后只有一句 d.same(a, b)。整个主程序的逻辑与题面文字几乎逐句对应——这就是封装带来的可读性。

第四步 · 防坑。① find 用递归实现,路径压缩后递归深度很小,安全;但如果先做若干次 merge 再统一 find,未压缩时深度可能达到 O(n),所以这里用按秩合并保证树高始终是对数级。② m 可能为 0,此时不能读边,代码中的循环天然处理;③ 重边不影响正确性,merge 遇到同根直接返回。

考点:并查集、路径压缩与按秩合并、类的封装与构造函数自动初始化、图论模型的转化。

易错点:① 忘记 init 导致每个点的父亲是随机的,全部答案错误;② find 写成 while (fa[x] != x) x = fa[x]; 而不做压缩,复杂度退化;③ 把 sa/sz 数组开小为 n 而非 n+1,访问 fa[n] 时越界。

模块 04

总结提升

把这一讲的骨架收拢成一张速查表、一个万能模板、三个拓展案例和三个值得琢磨的问题。

①核心要点与万能模板
知识板块必须记住的核心赛场用途
类与对象类是设计图,对象是按图造出的实例,各有独立成员同类结构要开多份时直接起多个对象
struct / class语法几乎相同,唯一区别是默认访问权限忘写 public: 时知道为什么报错
访问控制数据 private、接口 public把修改点集中,出错时只查少数函数
构造函数名字同类名、无返回类型、创建时自动调用对象一诞生就合法,杜绝"忘记初始化"
初始化列表ClassName(参数) : 成员(值) {}比函数体内赋值更快,const 成员必须用它
析构函数~类名(),对象销毁时自动调用释放动态内存、关闭文件
const 成员函数承诺不改对象,只读接口一律加 const避免"常量对象无法调用"的编译错误
this 指针指向当前对象,可 return *this; 做链式调用区分同名成员与参数
静态成员全类共用一份,数组需在类外再定义一次存月份表、模数等所有对象共享的常量
封装时机同一结构要用多次 / 要多份独立实例 / 需要模板复用并查集、线段树、矩阵最值得封装

万能模板:竞赛用「类化并查集」(背下来,改 int 为 long long 就能直接用在带权版本上)

C++DSU —— 通用并查集模板
class DSU {
private:
    vector<int> fa, sz;                 // sz:集合大小,用于按秩合并
public:
    DSU(int n = 0) { init(n); }         // 构造即初始化,不怕忘

    void init(int n) {
        fa.resize(n + 1);
        sz.assign(n + 1, 1);
        for (int i = 0; i <= n; i++) fa[i] = i;
    }
    int find(int x) {                    // 路径压缩
        return fa[x] == x ? x : fa[x] = find(fa[x]);
    }
    bool merge(int a, int b) {             // 返回是否真的合并了
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;
        if (sz[ra] < sz[rb]) swap(ra, rb);   // 小树挂大树
        fa[rb] = ra; sz[ra] += sz[rb];
        return true;
    }
    bool same(int a, int b) { return find(a) == find(b); }
    int size(int x) { return sz[find(x)]; }
};
使用示例
DSU d(10);          // 10 个点,全部自成一集合
d.merge(1, 2);
d.merge(2, 3);
d.same(1, 3);       // true
d.size(1);          // 3
模板改造口诀:要把普通并查集改成带权并查集,只需在做两件事——① 在类里加一个 vector<int> w;(到根的距离);② 在 find 里把 return fa[x]==x ? x : fa[x]=find(fa[x]); 拆成三句:先存下原父亲、递归找根、再累加距离。接口完全不变,使用方的代码一个字都不用改。这就是封装最强大的地方。

🔍 代码逐段解析(与案例 4 的差异点已标出)

构造 + init —— DSU(int n = 0) { init(n); } 与 init 拆开:构造时初始化一次,多测时还能手动 d.init(n) 重置同一个对象,不必重新声明。

find —— 一行路径压缩;fa[x] = find(fa[x]) 是"赋值表达式的值就是所赋的值",压缩与返回一步完成。

merge 返回 bool —— 与 q3 的 void merge 不同,这个模板返回"是否真的发生了合并",做最小生成树(Kruskal)时可直接用它统计合并次数。

same / size —— 都先 find 再比较/取值,保证拿到的是根的信息;sz[find(x)] 漏写 find 取到的是叶子位置的错误值。

易错点 —— ① fa.resize(n+1) 之后必须 for (i=0;i<=n;i++) fa[i]=i;,resize 不会替你设父指针;② Kruskkal 中误用 same(a,b) 先判断再 merge 会多做一次 find,通常直接用 merge 的返回值更简洁。

②三个拓展案例
拓 1预处理逆元表:把"预处理"封进类

组合计数题经常需要 1~n 的模逆元。与其在 main 里写三段循环,不如让一个 InvTable 对象在构造时就把表建好。

C++InvTable:构造时预处理线性逆元
class InvTable {
private:
    static const int MOD = 1000000007;
    vector<long long> inv;
public:
    InvTable(int n) {                    // 建对象 = 建表
        inv.assign(n + 1, 0);
        inv[1] = 1;
        for (int i = 2; i <= n; i++)
            inv[i] = (MOD - MOD / i) * inv[MOD % i] % MOD;
    }
    long long operator[](int i) const { return inv[i]; }   // 像数组一样用
};

// 使用:
InvTable iv(1000000);
cout << iv[7] << endl;              // 7 在模 1e9+7 下的逆元

为什么这个封装特别值?线性逆元的递推式 inv[i] = (MOD - MOD/i) * inv[MOD%i] % MOD 又长又容易抄错,而且 inv[1] = 1 这一步是必需的初始条件,忘了就全错。把它锁进构造函数之后,使用方只需 InvTable iv(n); 一行——不可能忘记处理边界 i=1。

另外注意我重载了 operator[](下一讲内容),让对象用起来和普通数组一样自然:iv[7] 而不是 iv.get(7)。封装的最高境界是"看起来不像是封装"。

拓 2静态计数:类的"全体统计"

静态成员属于类本身,可以用来统计"总共创建了多少个对象"。这在需要给每个对象分配唯一编号时特别有用。

C++Node:自动编号的静态计数器
#include <bits/stdc++.h>
using namespace std;

class Node {
private:
    int id;
    static int counter;              // 所有 Node 共享一个计数器
public:
    Node() : id(++counter) {}        // 每造一个对象,编号 +1
    int getId() const { return id; }
    static int total() { return counter; }
};

int Node::counter = 0;              // ★ 类外定义(必须有)

int main() {
    Node a, b, c;
    printf("a=%d b=%d c=%d total=%d\n", a.getId(), b.getId(), c.getId(), Node::total());
    return 0;
}
运行结果
a=1 b=2 c=3 total=3

🔍 代码逐段解析

static int counter; — 静态成员声明:计数器属于类本身,所有对象共享同一份。

int Node::counter = 0; — 类外定义(必须有):类内只是"预告",真正的存储在这一行分配。

Node() : id(++counter) {} — ++counter 先自增再取值,第一个对象拿到 1;每造一个对象编号自动 +1,不可能重号。

static int total() — 静态成员函数没有 this,只能访问 counter 这类静态成员;调用写 Node::total()。

易错点 — ① 漏掉类外定义行,链接报 undefined reference;② 静态成员函数里访问 id 这类非静态成员,直接编译错误。

注意 total() 是静态成员函数,没有 this,所以调用要写 Node::total()(也可以写 a.total(),但语义上不准确——它问的是"这个类",不是"这个对象")。同时静态成员函数里不能访问非静态成员,因为没有具体对象可指。
拓 3线段树的类化骨架

线段树有"数组 + build + 修改 + 查询"四大件,还常常要在多测里清空。用类封装后,每个测试用例 new 一个对象即可,比赛里能省掉一整块清空代码。

C++SegTree:区间求和线段树(骨架)
class SegTree {
private:
    int n;
    vector<long long> t;
    void build(int p, int l, int r, const vector<long long>& a) {
        if (l == r) { t[p] = a[l]; return; }
        int mid = (l + r) >> 1;
        build(p << 1, l, mid, a);
        build(p << 1 | 1, mid + 1, r, a);
        t[p] = t[p << 1] + t[p << 1 | 1];
    }
    void modify(int p, int l, int r, int pos, long long v) {
        if (l == r) { t[p] = v; return; }
        int mid = (l + r) >> 1;
        if (pos <= mid) modify(p << 1, l, mid, pos, v);
        else modify(p << 1 | 1, mid + 1, r, pos, v);
        t[p] = t[p << 1] + t[p << 1 | 1];
    }
    long long query(int p, int l, int r, int ql, int qr) {
        if (ql <= l && r <= qr) return t[p];
        int mid = (l + r) >> 1; long long res = 0;
        if (ql <= mid) res += query(p << 1, l, mid, ql, qr);
        if (qr > mid)  res += query(p << 1 | 1, mid + 1, r, ql, qr);
        return res;
    }
public:
    SegTree(const vector<long long>& a) {      // 构造时直接建树
        n = (int)a.size() - 1;           // 假定 a 下标从 1 开始
        t.assign(n * 4 + 4, 0);
        build(1, 1, n, a);
    }
    void setPoint(int pos, long long v) { modify(1, 1, n, pos, v); }
    long long rangeSum(int l, int r) { return query(1, 1, n, l, r); }
};

三个设计要点

① 递归函数放 private。build/modify/query 都带一个 p, l, r 参数,这是**实现细节**,外部不该看到。外面只需要 setPoint(pos, v) 和 rangeSum(l, r) 这两个符合题目语言的接口。

② 构造函数建树。使用方写 SegTree st(a); 就得到一棵树,不需要记得先调用 build。"忘调用初始化"这类 bug 从根上消失了。

③ 多测友好。下一组数据来了?直接 st = SegTree(b); 覆盖掉旧对象,内部数组自动重置。不用再手写一个 clear() 函数并惦记着调用它——这正是竞赛里最常见的失分点之一。

进阶方向:把 long long 换成模板参数 template<class T>,同一套代码就能服务 int、long long,甚至自定义的矩阵类型;再把"合并两个儿子"的操作抽成一个函数,就能一键切换成求最大值、求最小值线段树。

③三个值得琢磨的问题
思 1把一切代码都包成类,是不是更好?
点击查看思路

不是。类有成本:写起来更啰嗦、调试时多一层"跳进成员函数"的步骤、编译器优化的空间可能略小(虽然通常可忽略)。

判断标准:看这份代码会不会被"第二次"使用。

只在一个 main 里用一次、之后再也不碰的局部逻辑(比如读入 n 个数并求和),直接写在 main 里最清楚。但如果这个结构:① 需要多份独立实例(两个并查集)、② 需要重复初始化(多组测试数据)、③ 需要完整地复用(模板要背下来带进考场)——那么封装一定划算。

一句话:类是为了复用和维护,不是为了炫技。没被复用两次以上的数据结构,不要急着封装。

思 2为什么构造函数要用初始化列表,而不是在函数体里赋值?
点击查看思路

两个原因,一个性能、一个语法。

语法原因(硬性):const 成员变量和引用成员变量只能在初始化列表里初始化,在函数体里赋值会直接编译错误。此外,没有默认构造函数的成员对象(比如一个只提供了带参构造的类)也必须靠初始化列表初始化。

性能原因(软性):写在函数体里的"赋值"实际上经历了"先默认构造、再赋值"两步;初始化列表只有"直接构造"一步。对 int 来说毫不在意,但对 vector、string 这类对象,或者被构造 107 次的对象,差别就真实存在了。

结论:养成一律使用初始化列表的习惯,既不会遇到语法坑,也顺手拿到性能收益。注意初始化顺序只取决于成员声明顺序,与你写在列表里的顺序无关——所以别让列表顺序和声明顺序不一致,容易产生"明明写了却没用上"的诡异 bug。

思 3类的成员函数会不会让程序变慢?
点击查看思路

不会,甚至可能更快。

编译之后,成员函数和普通函数在机器码层面是同一种东西:成员函数只是多接收了一个隐式的 this 指针参数。调用开销、内联可能性完全相同。

而且有一个额外的好处:定义在类体内部的成员函数会被编译器自动视作 inline(内联建议)。像 find()、size()、empty() 这种短小的函数,编译器极有可能直接把它们展开成几行指令,连函数调用的开销都没有了。

真正可能影响速度的不是"用了类",而是:① 传参写成按值传递(void f(Stu s) 会完整拷贝一份对象,应写 const Stu& s);② vector<Stu> 扩容时反复拷贝大对象(用 reserve 预留空间可以缓解)。

赛场结论:不要因为"担心变慢"而放弃封装。你对复杂度的判断应该基于算法本身(是 O(n log n) 还是 O(n2)),而不是基于"代码里有没有 class"。

模块 05

学习检测

五个真实考场踩过的坑,加一张速查表和三张笔记卡。

易错1

忘写 public:,整个类变成"密室"

❌ 错误
class DSU {
    int fa[100005];
    void init(int n) { ... }
    int find(int x) { ... }
};
// main 里:
DSU d; d.init(5);
// error: 'void DSU::init(int)' is private within this context
✅ 正确
class DSU {
    int fa[100005];        // 默认 private,正好
public:
    void init(int n) { ... }
    int find(int x) { ... }
};
口诀:class 默认全私有,要用先写 public;漏了不是没定义,而是"你没权限"!
易错2

只写了带参构造函数,vector<Stu> v(n) 编不过

❌ 错误
class Stu {
public:
    Stu(int s) : score(s) {}   // 只给了带参构造 → 默认构造被"藏起来"
};
vector<Stu> v(10);
// error: no matching function for call to 'Stu::Stu()'
✅ 正确
class Stu {
public:
    Stu() : score(0) {}          // 补一个无参构造
    Stu(int s) : score(s) {}
};
或者给参数一个默认值:Stu(int s = 0),一个函数兼顾两种用法。
口诀:一旦自写构造函数,默认构造就失踪;要开数组或 vector,补个无参或给默认!
易错3

静态成员数组只声明、不定义,链接报错

❌ 错误
class Date {
    static const int DAYS[13];   // 只是声明
};
// 类外什么都没写
// 链接错误:undefined reference to 'Date::DAYS'
✅ 正确
class Date { static const int DAYS[13]; };

// ★ 类外补上定义(不要写 static)
const int Date::DAYS[13] = {0,31,28,31,30,31,30,31,31,30,31,30,31};
口诀:静态数组类内declared,类外define少不了;类外那行不写 static,其余照抄别漏掉!
易错4

只读函数忘写 const,常量对象调用不了

❌ 错误
class Point {
public:
    int getX() { return x; }      // 少了一个 const
};
const Point p;
cout << p.getX();
// error: passing 'const Point' as 'this' argument discards qualifiers
✅ 正确
class Point {
public:
    int getX() const { return x; }   // const 写在函数名后
};
规则:凡是不修改对象状态的成员函数,一律加 const。
口诀:只读函数加 const,写在参数表后面;不加就只服务"活对象",常量对象一律拒绝!
易错5

按值传参拷贝整个对象,慢还改不动原对象

❌ 错误
// 想修改传进来的对象,却按值传递:改的是副本!
void setAll(Stu s) { s.setScore(100); }   // 外面毫无变化

// 每次调用都完整拷贝一个对象(含 string),n 大了很慢
bool cmp(Stu a, Stu b) { return a.tot > b.tot; }
✅ 正确
// 要修改 → 传引用
void setAll(Stu& s) { s.setScore(100); }

// 只读 → 传 const 引用(零拷贝)
bool cmp(const Stu& a, const Stu& b) { return a.tot > b.tot; }
口诀:传参别写裸类型,加个 & 快十倍;只读再加 const,既安全又不拷贝!
📋速查表:类语法速查
语法写法说明
定义类class Name { ... };末尾分号不能漏
访问控制public: / private: / protected:带冒号,影响其后所有成员
默认构造Name() { }无参;一旦自写带参构造就需补回
带参构造Name(int a) : x(a) { }冒号后是初始化列表
默认参数Name(int a = 0)一个函数兼顾两种调用
析构函数~Name() { }无参无返回,可省
const 成员函数int get() const { }const 写在参数表后
静态成员变量static int cnt;类外还需 int Name::cnt = 0;
静态成员函数static int total() { }无 this,用 Name::total()
this 指针this->x; return *this;指向当前对象
类内定义函数void f() { ... }自动内联,推荐
类外定义函数void Name::f() { ... }要用 类名:: 限定
对象声明Name a; Name b(3);各自独立,互不影响
对象数组Name arr[100];需要默认构造函数
放进容器vector<Name> v(n);同上,需默认构造
引用传参void f(const Name& o)只读用 const 引用,零拷贝
📌知识点笔记卡
📖 必记概念
  • 类 = 数据 + 操作,是"模板"
  • 对象 = 类的实例,各有独立成员
  • struct 默认 public,class 默认 private
  • 构造函数:名同类名,无返回值,自动调用
  • 析构函数:~类名,销毁时自动调用
  • 不变式:靠 private 保护、靠构造函数建立
🧰 必背模板
  • 类骨架:class X { private: ...; public: X() {} };
  • 初始化列表:X(int a) : x(a), y(0) {}
  • 只读接口:int get() const { return x; }
  • 静态常量表:类内声明 + 类外定义
  • 并查集类:init / find / merge / same
  • 零拷贝传参:const T&
🚨 考场提醒
  • 写完类先检查有没有 public:
  • 类定义末尾的 ; 别丢
  • 要用 vector<T>(n) 就补默认构造
  • 静态数组记得类外定义一次
  • 只读函数统一加 const
  • 大对象一律传 const T&
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念辨析

说出 struct 与 class 的三点相同之处与一点不同之处。为什么说"C++ 中 struct 也是一种 class"?

查看参考答案

思路:不要背概念,回到"编译器怎么看待它们"。

相同点:① 都能包含成员变量与成员函数;② 都支持 public/private/protected 访问控制;③ 都能有构造函数、析构函数,都能继承、都能作为模板参数。

不同点:默认访问权限不同 —— struct 的成员默认 public,class 的成员默认 private;同理,struct 默认公有继承,class 默认私有继承。

为什么说"struct 也是一种 class":在 C++ 标准里,struct 与 class 是同一个关键字的不同默认值设置,编译器内部统一处理。它们唯一的差别就是那两处"默认"。

易错:误答"struct 不能有成员函数"(这是 C 中 struct 的规则,C++ 里没有限制);或误答"struct 不能有构造函数"。

A2写代码

定义 Circle 类,包含半径 r,提供 area()(面积)与 circumference()(周长)两个只读接口,并写 main 测试半径 3 的圆。

查看参考答案

思路:半径放 private,两个计算函数放 public 并加 const;π用 acos(-1) 或直接写常量。

#include <bits/stdc++.h>
using namespace std;

class Circle {
private:
    double r;
    static constexpr double PI = 3.14159265358979;
public:
    Circle(double r = 0) : r(r) {}
    double area() const { return PI * r * r; }
    double circumference() const { return 2 * PI * r; }
};

int main() {
    Circle c(3);
    printf("area=%.4f circum=%.4f\n", c.area(), c.circumference());
    return 0;
}
// area=28.2743 circum=18.8496

🔍 代码逐段解析

private: double r; — 半径藏起来,外部只能通过接口读,改半径也必须走构造函数。

static constexpr double PI — 类内直接初始化的静态常量(constexpr 整型/浮点型可以),全类共用一份。

Circle(double r = 0) : r(r) — 初始化列表 + 默认参数,兼顾 Circle c(3) 与 Circle c 两种写法。

area / circumference — πr² 与 2πr,都加 const;r=3 时 area = π×9 ≈ 28.2743,周长 = 6π ≈ 18.8496,与注释一致。

易错点 — ① 两个接口忘写 const;② PI 写成 3.14 时 area=28.26,精度不足可能 WA。

考点:类的定义、构造函数、const 成员函数、静态常量。

易错:① 两个函数忘了 const;② 把 PI 写成 3.14 精度不足导致误差超限;③ 用 int 存半径导致 r*r 溢出。

A3改错

下面代码编译报错,指出错误并给出两种修法。

class Box {
    int w, h;
public:
    Box(int a, int b) : w(a), h(b) {}
};
int main() {
    Box b(3, 4);
    cout << b.w << endl;      // ← 报错行
    return 0;
}
查看参考答案

思路:class 默认 private,w 是私有成员,外部不能直接访问。

错误:error: 'int Box::w' is private within this context。

修法一(不推荐但可行):在 int w, h; 之前加一行 public:,把 w 变成公开成员。

修法二(推荐):保持 w 私有,增加只读接口 int width() const { return w; },把 cout << b.w 改成 cout << b.width()。

考点:class 的默认访问权限、封装的意义。

易错:以为"是语法写错了",其实是访问权限问题;或者用 public: 一改了事,丢掉了封装的价值。能被外部随意读写的成员,本身就说明设计没有想清楚。

A4运行推断

下面程序的输出是什么?说明构造与析构的调用顺序。

struct T {
    char c;
    T(char x) : c(x) { printf("build %c\n", c); }
    ~T() { printf("destroy %c\n", c); }
};
int main() {
    T a('A');
    { T b('B'); }
    T c('C');
    return 0;
}
查看参考答案

思路:局部对象"先定义先构造,后定义先析构";离开 {} 作用域时,该作用域内的对象立即析构。

输出:

build A
build B
destroy B
build C
destroy C
destroy A

解释:a 先构造;进入内层作用域构造 b;离开作用域 b 立即析构;随后构造 c;main 结束前按"后进先出"的顺序:c 先析构(后定义),a 后析构。析构顺序与构造顺序完全相反。

考点:对象生命周期、作用域对析构时机的影响。

易错:以为所有析构都发生在 main 结束时(忽略了内层作用域);或把析构顺序写成与构造顺序相同。

🟡 B组 · 提高(4题)

B1设计类

设计 Matrix 类表示 n×n 整数矩阵,提供:identity(n)(生成单位矩阵)、mul(other)(矩阵乘法)。要求数据用 vector<vector<long long>> 存放,并说明这样设计在竞赛中的好处。

查看参考答案

思路:矩阵的核心操作只有"乘"和"建单位矩阵",其余都由这两个组合而来(比如快速幂)。把这两件事封进类,使用方就只需关心"乘几次"。

class Matrix {
private:
    int n;
    vector<vector<long long>> a;
public:
    Matrix(int n = 0, bool ident = false) : n(n) {
        a.assign(n, vector<long long>(n, 0));
        if (ident)
            for (int i = 0; i < n; i++) a[i][i] = 1;
    }
    long long& at(int i, int j) { return a[i][j]; }
    Matrix mul(const Matrix& o) const {
        Matrix r(n);
        for (int i = 0; i < n; i++)
            for (int k = 0; k < n; k++) {
                if (!a[i][k]) continue;
                for (int j = 0; j < n; j++)
                    r.a[i][j] += a[i][k] * o.a[k][j];
            }
        return r;
    }
};

竞赛好处:① 快速幂可以写成 Matrix res(n, true);(单位矩阵作为乘法幺元)——构造函数直接给了你一个"起点对象",不用再手写 for (i) res.a[i][i]=1;② 写"矩阵套矩阵"的题目时,只要把元素类型换成 Matrix,乘法逻辑几乎不用改;③ 每次调用 Matrix(n) 都得到全零矩阵,多测时不会残留上次的数据。

易错:① 忘记处理单位矩阵(快速幂的初值);② mul 里循环顺序写成 i,k,j 的反面导致缓存不友好(按 i,k,j 顺序且跳过 a[i][k]==0 能显著加速);③ 溢出没用 long long。

B2设计类

设计 MyDeque 类,用循环数组实现双端队列,支持 pushFront、pushBack、popFront、popBack、front、back、size。能力范围 106 次操作,1s。

查看参考答案

思路:用固定数组 + 头尾两个下标,靠取模实现"循环"。环形结构能避免 vector 头部删除的 O(n) 搬运。

class MyDeque {
private:
    static const int CAP = 2000005;
    int a[CAP], head, tail;      // [head, tail) 左闭右开
public:
    MyDeque() : head(0), tail(0) {}
    int size() const { return (tail - head + CAP) % CAP; }
    bool empty() const { return head == tail; }
    void pushBack(int x) { a[tail] = x; tail = (tail + 1) % CAP; }
    void pushFront(int x) { head = (head - 1 + CAP) % CAP; a[head] = x; }
    void popBack() { tail = (tail - 1 + CAP) % CAP; }
    void popFront() { head = (head + 1) % CAP; }
    int front() const { return a[head]; }
    int back() const { return a[(tail - 1 + CAP) % CAP]; }
};

为什么值得自己写?std::deque 内部是分块结构,常数较大;固定数组的循环队列每次操作都是常数条指令,常数更小。而且封装后 "head 和 tail 的取模维护"只写一次——这是最容易写错的地方(尤其是 popFront 时忘记 +CAP 导致负数下标)。

考点:循环队列的下标维护、类的封装、常数优化意识。

易错:① head = (head - 1) % CAP 在 C++ 里得到负数,必须 +CAP 再取模;② 容量开小了(pow2 对齐可以用位运算加速,但没必要);③ 用 size() 时忘了加 +CAP。

B3改错 + 优化

下面这段代码在 n = 105、q = 105 时超时,找出原因并优化。

class Stu {
public:
    string name; int score[3];
    int total() { return score[0] + score[1] + score[2]; }
};
bool cmp(Stu a, Stu b) { return a.total() > b.total(); }

int main() {
    ...
    sort(v.begin(), v.end(), cmp);
    ...
}
查看参考答案

思路:找出"每次比较都重复做的昂贵操作"和"不必要的拷贝"。

问题一 · 重复计算总分。total() 每次比较都要做 3 次加法并读取数组。排序约需 n log n ≈ 1.7×106 次比较,也就是约 3.4×106 次 total() 调用、上千万次加法。把总分在输入时算好,存成成员变量。

问题二 · 按值传参。bool cmp(Stu a, Stu b) 每次比较都要完整拷贝两个对象(含 string,会触发堆内存分配!)。必须改成 const Stu&。这一条往往是超时的主因。

问题三 · 缺少输入输出加速。输出 n 行时用 cin/cout 未关闭同步会明显变慢。

优化后:

class Stu {
public:
    string name; int score[3]; int tot;      // ★ 总分预先算好
    void input() {
        cin >> name >> score[0] >> score[1] >> score[2];
        tot = score[0] + score[1] + score[2];
    }
};
bool cmp(const Stu& a, const Stu& b) { return a.tot > b.tot; }   // ★ 零拷贝

考点:按值传递的拷贝成本、把重复计算前移(预处理思想)、IO 优化。

易错:只改 const 不改总分缓存(省下拷贝但仍有大量重复加法);或反过来只缓存总分却仍按值传递(string 的拷贝开销依旧致命)。两个问题要一起解决。

B4简答

为什么"多组测试数据"的题目特别适合用类封装数据结构?请举两个具体例子说明。

查看参考答案

思路:多测的核心风险是"上一组的数据污染下一组",也就是"忘记清空"。

核心回答:类的构造函数提供了唯一的、自动执行的初始化入口。每读入一组新数据,只要新建一个对象(或调用一次 init),内部状态必然回到干净状态,不可能"忘记清空"。而用全局数组时,清空是一段独立代码,忘记写、写漏几个数组、清空范围写错——都是真实发生过的失分事故。

例一 · 并查集:全局写法需要 for (i=1;i<=n;i++) fa[i]=i, sz[i]=1;,写在 main 的循环里;如果 sz 忘了重置,第二次询问的答案就会错误。类化后 DSU d(n); 直接得到干净的并查集。

例二 · 线段树 / 树状数组:它们有 t[] 数组和多层递归状态。全局写法下,t 数组必须全部清零(而不是只清前 n 个位置,因为 4n 范围内都可能被访问过);类化后 SegTree st(a); 内部 assign 一次性搞定,连"该清多长"这个问题都消失了。

附加收益:多测的题目中,某些结构可能需要同时存在多份(比如同时维护两棵线段树),此时类化几乎是唯一优雅的写法。

易错:只答"代码更整洁"这类空话。要抓住"自动初始化"这个机制层面的理由,并配上具体数组名字。

🔴 C组 · 挑战(4题)

C1综合实现

用类实现支持"高精度加法"的 BigInt:内部用 vector<int> 存各位数字(低位在前),提供默认构造、从 long long 构造、add 方法与 print 方法。要求 BigInt a = 123456789012345LL; 这样的写法能直接工作。

查看参考答案

思路:"从 long long 构造"意味着需要一个单参数构造函数(不加 explicit 才能支持 = 隐式转换)。数字拆分时注意 0 的特例。

class BigInt {
private:
    vector<int> d;                      // d[0] 是个位,低位在前
public:
    BigInt(long long v = 0) {           // 支持 BigInt a = 123LL;
        if (v == 0) d.push_back(0);    // ★ 0 的特例,别漏
        while (v > 0) { d.push_back(v % 10); v /= 10; }
    }
    BigInt add(const BigInt& o) const {
        BigInt r;
        r.d.clear();
        int carry = 0;
        size_t n = max(d.size(), o.d.size());
        for (size_t i = 0; i < n || carry; i++) {
            int s = carry;
            if (i < d.size()) s += d[i];
            if (i < o.d.size()) s += o.d[i];
            r.d.push_back(s % 10);
            carry = s / 10;
        }
        return r;
    }
    void print() const {
        for (int i = (int)d.size() - 1; i >= 0; i--) putchar('0' + d[i]);
        putchar('\n');
    }
};

要点一:BigInt(long long v = 0) 既是默认构造(BigInt r; 工作),又是转换构造(BigInt a = 123LL; 工作)。一个带默认值的构造函数能满足两种需求,这是 A 组易错 2 的正向应用。

要点二:循环条件 i < n || carry —— 即使两个数都读完了,只要还有进位就继续,否则 999+1 会算成 000 而不是 1000。

要点三:下标比较要写成 i < d.size() 而不是 i < n(两个数长度不同),并且 i 必须是无符号类型或强制转换,否则 int 与 size_t 比较会触发警告。

易错:① 忘记 v == 0 的特例,导致 BigInt(0) 变成空 vector;② 忘记处理进位;③ print 时从高位往低位输出(d 是低位在前,要倒着打印)。

C2复杂度分析

某并查集实现只有路径压缩、没有按秩合并。请构造一组操作让它的单次 find 达到 O(n)(即说明"只有路径压缩不够")。再说明为什么加上按秩合并后能保证 O(α(n))。

查看参考答案

思路:路径压缩只在被访问过的路径上生效。如果一条长链从未被访问,它就一直是最坏形态;只要刻意只去访问链的"尾巴尖",就能每次制造一条长路径。

构造方法(只路径压缩):先合并成一条链 1 ← 2 ← 3 ← … ← n(即 fa[i] = i-1,每次 merge(i, i-1) 且总是让 i 挂在 i-1 下)。注意合并过程本身不会压坏链——因为 merge 只访问两个点。此时链长 n 完好无损。

接着反复做:find(1)(把 1 到根的一段压平,但只压平了 1 所在的尾部一小段),然后 merge 一个新的孤立点 n+1 挂到 2 下面,再 find(1)……由于每次都在链的"侧面"续接新叶子,被压平的总是同一小段,链的主体始终保持很长,于是单次 find 仍可达 Θ(n)。

为什么要按秩合并:按秩合并(把小子树挂到大子树下)保证了每个节点的深度始终不超过 O(log n),所以任何时刻的 find 都不会超过 O(log n)——即使从未被压缩过。有了这个"深度上界"兜底,路径压缩才能在此基础上把均摊代价进一步压到 O(α(n))。

直觉总结:路径压缩是"事后补救",按秩合并是"事前预防"。只有预防,保证不出现长链;只有补救,无法应对持续制造长链的输入。两者结合,才能得到 O(α(n)) 这一理论最优结果。

易错:以为"路径压缩之后树高必然是常数"——这是把"均摊复杂度"错当成"单次复杂度"。均摊快不等于每次都快,而竞赛的最坏情况恰恰由单次最慢操作决定。

C3思辨

有同学说:"竞赛就是比谁代码短、谁跑得快,写类纯属浪费时间,全局变量 + 函数就够用了。" 请从至少三个角度评价这个观点。

查看参考答案

思路:不要简单说"对"或"错"。要分清什么情况下他说得对、什么情况下会翻车。

角度一 · 正确性风险(最重要)。竞赛的对手不是"代码的优雅度",而是WA 和 TLE。全局变量的最大问题是"多测忘记清空""数组名冲突改名漏改""初始化写在别处忘了调用"。这些错误在考场的压力下极难排查,而类恰恰把这些风险从"靠人记"变成"靠编译器保证"。从这个意义上说,写类是在用结构换正确率,不是浪费时间。

角度二 · 复用与调试速度。比赛只有 4~5 小时,其中相当一部分时间花在调试和复用模板上。一个封装好的 DSU 类可以直接从模板文件里粘贴过来用,不需要逐行检查"这次是不是又漏改了一个数组名"。越到后期题目越难,能背出来的模板越可靠。

角度三 · 适用边界(他有一半是对的)。对于一次性、只用一遍、逻辑简单的代码段(比如读入一个数组并求和、一道简单的模拟题),封装确实增加书写成本、降低调试直观性。这时候他的判断是合理的。

角度四 · 性能层面他可能是误解。类的成员函数不会比普通函数慢(编译后等价),定义在类内的函数还能自动内联。唯一真实的性能成本是"按值传参"和"对象数组拷贝",而这只需要养成写 const T& 的习惯就能解决。

结论:这个观点"部分成立但推不出他的结论"。正确的做法是分场景:一次性小逻辑就直写,需要复用/多份/多测的结构就封装。把它当成"代码短不短"的问题,是把工程性问题误当成风格问题了。

C4模板进阶

把上一题的 DSU 改造成模板 template<class T> class DSU,要求:① 内部使用 vector<T> fa;② 自定义"合并规则"可通过模板参数或函数对象传入。说明这样改造后能解决什么新问题。

查看参考答案

思路:模板化的意义不是"少写几行",而是让同一份逻辑服务不同的数据语义。

template<class T>
class DSU {
private:
    map<T, T> fa;                 // 用 map 支持任意可比较类型
    map<T, int> sz;
public:
    void add(const T& x) {        // 动态加入元素,不必预先知道总数
        if (!fa.count(x)) { fa[x] = x; sz[x] = 1; }
    }
    T find(const T& x) {
        if (fa[x] == x) return x;
        return fa[x] = find(fa[x]);
    }
    void merge(const T& a, const T& b) {
        T ra = find(a), rb = find(b);
        if (ra == rb) return;
        if (sz[ra] < sz[rb]) swap(ra, rb);
        fa[rb] = ra; sz[ra] += sz[rb];
    }
    int size(const T& x) { return sz[find(x)]; }
};

解决的新问题一 · 元素不再是整数下标。实际题目中常要合并"字符串命名的集合"(如并查集维护文件依赖、人名关系),或者点编号是 long long 甚至自定义坐标。模板化后 DSU<string> d; 直接可用,无需手写离散化。

解决的新问题二 · 元素的出现时机未知。提供 add(x) 后,元素可以在任意时刻"首次出现"时被登记,不必先读入全部数据、统计总数、再初始化——这在流式处理或在线算法中很关键。

关于自定义合并规则:更进阶的做法是把"合并代价的比较"抽成模板参数(如 template<class T, class Cmp>),从而支持"按权值合并""按启发式函数合并"等变体;在带权并查集中,还可以把"权值合并运算"也参数化。核心思想是:把"变化的部分"抽出来作为参数,把"不变的部分"(路径压缩、初始化、查询)留在模板里复用。

成本提醒:map 版本每次操作多一个 log 因子,在能离散化的题目中应优先用 vector 版本。模板是"扩展能力"的工具,不是"无条件更快"的工具。

易错:① map 上用 cnt[x] 会意外插入默认值,应先 count 判断或改用 find;② 递归 find 在链长时会爆栈(map 版本无法保证深度),可改写成迭代式;③ 模板类通常要整体写在 .h 或提交文件里(不能分离编译)。

📚 洛谷原题训练(12 题)

前 6 题重点练习"结构体 / 类 + 多关键字排序",后 6 题巩固字符串与模拟中的封装思路。

1
P1059
明明的随机数入门
2
P1093
奖学金普及-
3
P1104
生日普及-
4
P1051
谁拿了最多奖学金普及-
5
P1068
分数线划定普及-
6
P1781
宇宙总统普及-
7
P1012
拼数普及-
8
P1308
统计单词数普及-
9
P1042
乒乓球普及-
10
P2550
彩票摇奖入门
11
P1094
纪念品分组普及-
12
P1106
删数问题普及-