①核心要点与万能模板
| 知识板块 | 必须记住的核心 | 赛场用途 |
| 类与对象 | 类是设计图,对象是按图造出的实例,各有独立成员 | 同类结构要开多份时直接起多个对象 |
| 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;
}
🔍 代码逐段解析
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"。