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

成员函数 与 运算符重载

让自定义类型像 int 一样自然地参与运算:能写成 c = a + b、能直接 a < b 交给 sort 和 set、能一句 cout << x 打印出对象。这是把"能用的类"升级成"顺手好用的类"的关键一步。

6 个核心知识点 4 个完整案例 1 套运算符模板 3 道实践题 12 道洛谷原题
本讲 · 第 5/52 讲成员函数与运算符重载
模块 01

情景导航

从"函数名越写越长"和"set 报了一屏错"两个真实场景,看运算符重载到底解决了什么问题。

故事一堆 mul 函数引发的思考

小 C 上一讲刚学会写类,很快就用上了。为了做一道"矩阵快速幂"的题,他写了一个 Matrix 类,把矩阵乘法实现成成员函数。

逻辑完全正确,但写出来是这样的:

r = Matrix::mul(r, a); a = Matrix::mul(a, a);

而他心里想的数学表达式明明只有两行:

r = r * a; a = a * a;

接着他做了一道"维护不重复元素集合"的题。他写了 Point 结构,还专门写了一个比较函数 bool cmp(Point a, Point b),用 sort(v.begin(), v.end(), cmp) 一切正常。

可是当他写下 set<Point> s; 时,编译器毫不客气地吐了一屏错误:

error: no match for 'operator<' (operand types are 'const Point' and 'const Point')

他觉得很委屈:我明明写了比较函数啊,为什么 set 不认?

小 D 看完说了一句话:你写的 cmp 是你自己的函数,set 不知道它的存在。set 只认 operator<。接着小 D 在 Point 里加了四行:

friend bool operator<(const Point& a, const Point& b) { return a.x != b.x ? a.x < b.x : a.y < b.y; }

加完之后,sort 不用给比较函数了,set 能用了,priority_queue 也能用了。小 C 这才明白:运算符重载不是"炫技的语法糖",而是让自己的类型能和标准库说上话的唯一通道。

标准库只认运算符,不认你的函数名。

💡 运算符重载到底解决了什么

问题一 · 表达不自然。数学里写 a + b、a * b、a < b,代码里却要写 add(a,b)、mul(a,b)、cmp(a,b)。符号是通用的,函数名是私有的——重载让自定义类型获得与其他类型一致的"表达方式"。

问题二 · 无法接入标准库。这是最关键的一点。标准库的大量组件是按运算符约定编写的:sort 默认用 <、set/map 用 < 维护有序性、priority_queue 用 < 比较优先级、cout 用 << 输出、cin 用 >> 读入。你的类型想被它们接受,就必须提供对应的运算符。

问题三 · 代码重复与不一致。如果每次排序都要传一个比较函数,那"什么算更小"这个规则就散落在各处;某处改了、某处忘了改,就会出现"同一个集合,两处排序结果不同"的诡异现象。把规则写成 operator<,全项目只有一份定义。

结论:运算符重载是"让自定义类型融入 C++ 生态"的手段。它不改变运算符本身的行为,不改变优先级,也不改变结合性——只是告诉编译器:当操作数是你自己的类型时,这个符号该做什么。

①生活类比
📐

「+」的多重身份

同一个加号,在数字上是求和,在向量上是分量相加,在矩阵上是矩阵加法,在字符串上是拼接。符号不变,含义随"操作对象"而定 —— 这正是运算符重载的思路。

→ operator+
⚖️

「<」是排序的裁判

sort、set 都只问一句话:"a 是不是比 b 小?"至于谁大谁小,完全由你定义。你定的规则叫 operator<,它们就照着执行。

→ operator<
🖨️

「<<」是传送带

cout << x 本质是把 x 送到输出流这条传送带上。重载 << 就是教传送带"怎么念出你的类型"。

→ operator<<
🧰

成员 vs 全局

成员函数像"随身工具",得先有对象(a.add(b));全局/友元函数像"公共工具箱",两个对象平等地传进去(operator+(a, b))。各有各的用武之地。

→ 成员形式 / 友元形式
②核心定义

成员函数(member function):定义在类内部的函数,通过对象调用(a.f(b))。编译后会多接收一个隐式的 this 指针参数,因此可以直接访问本对象的私有成员。

const 成员函数:在参数表之后加 const 的成员函数,承诺不修改对象状态。常量对象、const 引用只能调用 const 成员函数。规则:只读操作一律加 const。

运算符重载(operator overloading):为自定义类型重新定义运算符的行为。语法上就是一个名字特殊的函数:operator+、operator<、operator<<。可以写成成员形式,也可以写成非成员(通常配 friend)形式。

友元函数(friend):在类内用 friend 声明、但定义在类外的非成员函数,被授予访问该类私有成员的权限。它不属于类,不通过对象调用,没有 this 指针。

严格弱序(strict weak ordering):operator< 必须满足的三条性质:非自反(a < a 恒为假)、反对称(a < b 与 b < a 不能同时为真)、可传递(a < b 且 b < c 推出 a < c)。违反它,sort 可能越界崩溃,set 会丢元素。

返回值约定:+、-、*、/ 返回新对象(值),因为结果是一个新值;+=、-=、*= 返回自身引用(*this),以支持连续使用;<、== 返回 bool;<<、>> 返回流引用(ostream& / istream&),以支持 cout << a << b << endl 这样的链式写法。

📌 本讲核心知识地图

板块核心内容对应大纲
成员函数通过对象调用、隐式 this、可直接访问私有成员【6】成员函数
const 成员函数只读承诺、常量对象调用、const 重载【6】成员函数
运算符重载规则不能改优先级/结合性/操作数个数;至少一个自定义类型【6】运算符重载
成员形式a.operator+(b);= [] () -> 必须成员【6】运算符重载
全局/友元形式左右操作数对称;<<、>> 首选此形式【6】运算符重载
比较运算符严格弱序;支撑 sort / set / map / priority_queue【6】运算符重载
流运算符重载 << 与 >>,返回流引用【6】运算符重载
③核心口诀
🔑 运算符重载三步

一看操作数个数定参数(二元两个、一元一个);
二看能否修改自己定 const;
三看结果是不是新值定返回值(算术返回值、复合赋值返引用)。

🔑 形式选择

= [] () -> 和复合赋值 → 必须/优先成员形式;
算术与比较运算符 → 两种都行,成员形式更常见;
<< 和 >> → 只能用全局(友元)形式!

🔑 比较运算的黄金法则

"不同字段用 != 早返回,相同字段再用 < 比到底":
a.x != b.x ? a.x < b.x : a.y < b.y
永远不要写 >= 或 <=,否则破坏严格弱序,sort 会崩!

模块 02

知识探究

先把成员函数讲透,再讲运算符重载的三条硬规则、两种书写形式,最后落到比较与输入输出。

1成员函数与普通函数:到底差在哪 基础

同一个功能,写成成员函数和写成普通函数,机器层面的差别其实只有"多一个隐式的 this 参数"。真正的差别在于它能访问谁的数据。

① 普通函数(类外工具)

int dist(int x1, int y1, int x2, int y2) {
    return abs(x1-x2) + abs(y1-y2);
}

所有数据都要作为参数传进来。参数一多就难用,而且传什么由调用者决定,函数本身无法保证"这两个点是一对"。

② 成员函数(对象自己的技能)

struct Point {
    int x, y;
    int distTo(const Point& o) const {
        return abs(x-o.x) + abs(y-o.y);
    }
};

自己的一半数据天然就在手里(x、y),只需传另一个点。调用写成 a.distTo(b),语义一目了然。

动画:a.distTo(b) 一次调用的内部过程(数据取自上方代码)
1a.distTo(b) — 设 a = (3,4)、b = (1,2)。编译器把调用翻译成 Point::distTo(&a, b):多出来的第一个参数就是 this。
2函数体内 — 裸写的 x、y 其实是 this->x、this->y,即 a 的坐标 (3,4)。
3abs(x - o.x) — |3 − 1| = 2;abs(y - o.y) — |4 − 2| = 2。
4返回 2 + 2 = 4 — a 到 b 的曼哈顿距离。整个过程 b 只是普通参数,a 的数据"不用传就在手里"。
5普通函数版 — dist(3, 4, 1, 2) 四个参数全靠调用者传,传错顺序编译器帮不上忙;成员函数版少两个参数,还保证了"坐标成对"。
成员函数与普通函数在机器层面几乎一样(多一个 this 参数),真正的差别是"数据从哪来"。

下面这张表说清了"什么时候该用哪种":

对比项成员函数非成员(全局)函数
调用方式a.f(b)f(a, b)
能否访问私有成员可以不可以(除非声明为 friend)
参数表不含自己(隐式 this)要把所有操作数都写出来
左右对称性天然不对称(a.f(b) 里 a 特殊)左右完全对称
能否支持 2 + a不能(2 不是对象)可以(靠隐式类型转换)
典型用途修改自身(push)、只读查询(size())需要左右对称、需要和内置类型混算
竞赛取舍:日常写类时,修改自身状态的操作一律写成成员函数(push、merge、setPoint),只读查询也写成成员函数并加 const(size、getX)。只有当运算需要"左右对称"或者要支持"内置类型在左边"(2 * v)时,才考虑写成全局/友元形式。
2const 成员函数:一个容易被忽略但要命的细节 高频报错

上一讲已经提过 const,但在运算符重载的语境下,它会变成一个绕不过去的坑。先搞清楚编译器为什么要管这件事。

① 对象是只读的
② 调用成员函数
③ 没有 const → 拒绝
④ 加了 const → 放行
C++编译器到底在检查什么
class Point {
private:
    int x = 0, y = 0;              // 类内默认初始化,让 const Point p; 合法
public:
    int getX()       { return x; }         // ❌ 少写 const
    int getY() const { return y; }         // ✅ 正确
};

void f(const Point& p) {
    cout << p.getY();     // ✅ 可以,getY 是 const 成员函数
    cout << p.getX();     // ❌ error: passing 'const Point' as 'this' argument discards qualifiers
}

int main() {
    const Point p;
    p.getY();             // ✅
    p.getX();             // ❌ 同样报错
}

🔍 为什么在运算符重载里这个坑特别致命?

原因一 · 比较运算符的参数必然是 const 引用。你会写 bool operator<(const Point& a, const Point& b)。如果 operator< 内部调用了一个没标 const 的成员函数(比如 a.total()),编译器立刻报错。

原因二 · 标准库传递的一定是常量引用。set、map、sort 内部持有的元素都是不可修改的(否则会破坏有序结构),它们调用比较时,对象一定是 const 的。所以你的 operator< 必须能接受 const 对象——这就要求它内部调用的所有成员函数都是 const 的。

原因三 · 报错信息极难看懂。错误信息里是 discards qualifiers、no matching function for call to 'Point::total() const',新手常常完全看不懂在说什么。记住这个症状:只要看到 const 出现在错误信息里,第一反应就是"某个成员函数忘了加 const"。

一条救命规则:写成员函数时,先无脑加 const,等编译器报错说"不能修改"时再删掉。因为"要修改"是少数情况(push、merge、set),"不修改"才是大多数。先写 const 能让你少踩很多坑。

C++const 重载:同名函数,两个版本
class Array {
private:
    vector<int> a;
public:
    int&       operator[](int i)       { return a[i]; }   // 可写版本
    const int& operator[](int i) const { return a[i]; }   // 只读版本
};

Array arr;
arr[0] = 5;          // 调用可写版本,可以赋值

const Array& carr = arr;
cout << carr[0];     // 调用只读版本
carr[0] = 6;       // ❌ 编译错误:const 版本返回 const 引用,不能赋值
这两个版本不是重复劳动。编译器会根据"调用它的对象是不是 const"自动挑一个:非 const 对象走可写版本,const 对象走只读版本。这样既让 const 对象能读,又让普通对象能写,两全其美。标准库的 vector、string 都用了这个技巧。
3运算符重载的核心规则 必会

运算符重载听起来玄,其实只有一句话:写一个名字叫 operator符号 的函数。剩下的都是围绕这句话的几条硬性规则。

C++运算符重载的本质:它就是函数
// 你写的:
Complex c = a + b;

// 编译器理解成(成员形式):
Complex c = a.operator+(b);

// 或者(全局/友元形式):
Complex c = operator+(a, b);

// 也就是说,这三种写法完全等价:
cout << a + b << endl;
cout << a.operator+(b) << endl;
cout << operator+(a, b) << endl;

四条必须记住的硬规则:

动画:a + b 的三种等价展开(编译器视角)
1你写下 Complex c = a + b; — 对自定义类型,+ 不再是内置加法,编译器要找一个名为 operator+ 的函数。
2先找成员形式 — a.operator+(b):左操作数 a 充当 this,右操作数 b 是唯一参数。
3再找全局形式 — operator+(a, b):两个操作数都作参数,左右对称。
4两种都找不到 — 才报 no match for 'operator+';只重载了其一,另一个方向就可能写不出来。
5结论 — 运算符重载的本质就是函数调用,参数个数、返回类型全由你写的函数决定;这也是"看懂报错信息"的钥匙。
规则说明后果
① 不能发明新运算符只能重载 C++ 已有的运算符;不能造 ** 或 <=>(C++20 之前)写 operator** 直接语法错误
② 不能改变优先级和结合性a + b * c 永远先算乘法;a - b - c 永远左结合2 + v * 3 不会变成 (2+v)*3
③ 不能改变操作数个数+ 永远是二元,! 永远是一元,?: 永远三元不能给 + 定义三个参数
④ 至少一个操作数是自定义类型不允许重载"int + int"或"double * double"保护内置类型的语义不被篡改

能重载和不能重载的运算符清单:

✅ 竞赛中真正会用到的(按重要度排序)

< —— 最重要,sort / set / map / priority_queue 全靠它
== —— unique / find / 判等
+ - * / —— 向量、矩阵、分数、复数、大数
+= -= *= —— 复合赋值,循环累加时常用
[] —— 让对象像数组一样用
() —— 函数对象,可作 sort 的比较器参数
<< —— 让 cout 能打印你的对象(调试神器)
> —— 小根堆、降序排列时需要

❌ 不能重载的(记住结论就行)

. —— 成员访问
:: —— 作用域解析
?: —— 三目运算符
sizeof —— 求大小
.* —— 成员指针访问
typeid —— 类型信息

原因:这些运算符要么与语言的核心机制绑定(改了会破坏语法本身),要么必须在编译期确定结果,无法交给运行期的函数决定。竞赛中完全不需要关心它们。

🎯 返回值该写什么?这是最容易写错的地方

运算符返回类型为什么
+ - * /T(值)运算结果是一个新的值,不是原来的对象。写 return T(...);
+= -= *=T&(引用)修改自己并返回自己,这样 a += b += c 才能工作。写 return *this;
< > == !=bool只回答"是/否"
[]T&(可写)/ const T&(只读)要让 v[i] = x 合法就必须返回引用
<< >>ostream& / istream&必须返回流本身,才能 cout << a << b
=T&(引用)支持 a = b = c;同时也是标准库要求

为什么算术运算符要返回值而不是引用?因为 a + b 的结果是一个临时的中间量,不在任何地方存放。如果返回引用,就只能返回一个局部变量的引用——局部变量在函数结束时就被销毁了,那是个悬空引用,行为未定义。这是初学者最常犯的严重错误之一。

4成员形式还是全局(友元)形式:怎么选 关键决策

同一个运算符,两种形式都能实现,但参数个数不同、能做的事也不同。选错形式,有些表达式就写不出来。

❌ 只用成员形式:写不出 2 * v

struct Vec {
    double x, y;
    Vec operator*(double k) const {
        return {x*k, y*k};
    }
};

Vec v{1, 2};
Vec a = v * 2.0;   // ✅ OK
Vec b = 2.0 * v;   // ❌ 编译错误!
// 因为 2.0 是 double,它没有 operator* 成员函数;
// 编译器不会反过来去调 v 的成员函数。

✅ 全局形式:两边都能写

struct Vec {
    double x, y;
};

Vec operator*(const Vec& v, double k) {
    return {v.x*k, v.y*k};
}
Vec operator*(double k, const Vec& v) {
    return {v.x*k, v.y*k};
}

Vec v{1, 2};
Vec a = v * 2.0;   // ✅
Vec b = 2.0 * v;   // ✅ 也能写了

选择清单(建议直接背下来):

问 1要修改自己?
问 2左边是流?
问 3左边可能是内置类型?
默认成员形式
运算符推荐形式参数个数原因
= [] () ->必须成员1([])语言规定,写成全局形式直接编译错误
+= -= *= /=成员1要修改自己,语义上"自己的动作"
+ - * /成员 或 全局成员 1 / 全局 2需要支持内置类型在左边时用全局
< > == !=成员 或 全局成员 1 / 全局 2两种都行,竞赛中全局(友元)更常见
<<只能全局(友元)2左操作数是 ostream,你无法给它加成员函数
>>只能全局(友元)2同上,左操作数是 istream
++ --(前置)成员0++a 返回引用
++ --(后置)成员1(int 占位)a++ 返回旧值的副本
为什么 << 只能用全局形式?因为 cout << x 展开后是 operator<<(cout, x)。左操作数是 ostream 对象,那是标准库的类,你不可能往里面加成员函数。所以编译器只能去找一个"以 ostream 为第一个参数、以你的类型为第二个参数"的全局函数——这就是为什么 operator<< 必须写成非成员形式。

关于 friend:它到底解决了什么?如果 operator<< 需要访问私有成员(比如打印一个封装了内部数组的类),写成普通全局函数就没有权限。此时在类内用 friend 声明一下,就能既保持"非成员"的形态,又拿到"访问私有成员"的权限。

C++友元的正确用法:声明在类内,定义在类外
class Complex {
private:
    double re, im;
public:
    Complex(double r = 0, double i = 0) : re(r), im(i) {}

    // 类内声明为友元(不是成员函数!)
    friend ostream& operator<<(ostream& os, const Complex& c);
};

// 类外定义:注意不要写 friend,也不要写 Complex::
ostream& operator<<(ostream& os, const Complex& c) {
    os << c.re << (c.im >= 0 ? "+" : "") << c.im << "i";
    return os;                     // ★ 必须返回流本身
}

// 使用:
Complex c(3, -4);
cout << "c = " << c << endl;       // c = 3-4i

三个容易忽视的细节

① 友元声明可以写在类内任何位置(public 或 private 都行),因为友元不是成员,访问权限对它没有意义。习惯上放在类的最上方或最下方。

② 定义时不要写 friend,也不要写 Complex::。friend 只在声明时用;Complex:: 是成员函数的限定符,友元不是成员。这两个错误都会导致"链接找不到函数"。

③ 友元关系是单向且不传递的。Date 声明 Time 是朋友,不等于 Time 也把 Date 当朋友;Date 朋友的 Time 也不自动成为 Date 的朋友。在竞赛里这个特性几乎用不到,知道即可。

5重载 <:让 sort、set、map、堆都认识你的类型 提高组核心

如果这一讲只学一件事,那就是它。operator< 是自定义类型进入标准库世界的通行证。下面这张图说明谁在依赖它。

1
🔢
sort / stable_sort

不传比较器时,默认用 operator< 升序排列

2
🌲
set / multiset

用 operator< 维护红黑树的有序性,只认它,不认 cmp

3
🗺️
map / multimap

键的排序同样依赖 operator<;键必须可比较

4
⛰️
priority_queue

默认大根堆,靠 operator< 判断优先级

5
⬆️
lower_bound / upper_bound

在已排序区间里二分查找,仍依赖 <

6
🔗
max / min / nth_element

都用 < 决定"谁更大"

C++重载 < 的两种写法(推荐第一种)
// ── 写法 A:友元(全局)形式,推荐 ────────────────
struct Stu {
    string name;
    int tot;
    // 总分高的排前面;总分相同时名字字典序小的排前面
    friend bool operator<(const Stu& a, const Stu& b) {
        if (a.tot != b.tot) return a.tot > b.tot;
        return a.name < b.name;
    }
};

// ── 写法 B:成员形式,也可以 ──────────────────────
struct Stu2 {
    string name; int tot;
    bool operator<(const Stu2& o) const {
        if (tot != o.tot) return tot > o.tot;
        return name < o.name;
    }
};

// ── 有了它,三种容器全部可用 ──────────────────────
vector<Stu> v;
sort(v.begin(), v.end());        // ✅ 不需要第三个参数了

set<Stu> s;                      // ✅ 终于能用了

priority_queue<Stu> pq;          // ✅ 也能用了

⚠️ 严格弱序:写错就崩,而且是"莫名其妙地崩"

operator< 必须满足三条性质,这个要求叫严格弱序(strict weak ordering):

① 非自反:a < a 必须为 假。→ 所以绝对不能写 a.tot <= b.tot!

② 反对称:a < b 和 b < a 不能同时为真。

③ 可传递:a < b 且 b < c ⇒ a < c。

违反之后会发生什么?这不是"结果不太对"那么温和,而是未定义行为(UB):

• sort 的内部实现是快排 + 插入排序的混合。当比较结果自相矛盾时,它的下标可能越界读写,表现为随机 WA、随机 RE,甚至在不同机器上表现不同。
• set 是红黑树,比较矛盾会让它"找不到明明存在的元素",或者把重复元素插进去两次。
• priority_queue 会陷入一种"永远选不出发送者"的困境,可能死循环。

三条救命规则:

规则一:只用 <,不用其他比较符。需要"大于"时写成 b < a(注意参数顺序),而不是写 a > b。这样天然满足非自反。

规则二:多关键字用 != 早返回。标准写法是 if (a.tot != b.tot) return a.tot > b.tot; return a.name < b.name;——第一关键字不同时才比较第一关键字,相同才比较下一关键字,这样保证传递性。

规则三:比较器必须是无副作用的纯函数。不要在 operator< 里做计数器 ++cnt、不要读全局变量、不要修改任何东西。因为标准库调用比较的次数和顺序是不确定的,有副作用必然出错。

① 写下 operator<
② 反复成对比较
③ 出现矛盾
④ 用严格弱序修复
一个快速自检方法:把 a 和 b 换成同一个对象,看你的比较函数返回什么。如果返回 true,就一定违反非自反——这就是 <= 和 >= 的典型症状。写完后在心里过一遍这个检查,能消灭一大半的排序玄学 bug。
6重载 << 和 >>:让调试变得轻松 实用技巧

调试时最痛苦的事,莫过于 cout << p 报错、只能写 p.print()。重载 << 只要十行代码,却能让你在整场比赛中受益。

C++完整的流运算符重载(含输入)
#include <bits/stdc++.h>
using namespace std;

struct P {
    int x, y;

    // ── 输出:教 cout 怎么打印一个 P ──
    friend ostream& operator<<(ostream& os, const P& p) {
        os << "(" << p.x << "," << p.y << ")";
        return os;                    // ★ 返回流,支持链式
    }

    // ── 输入:教 cin 怎么读一个 P ──
    friend istream& operator>>(istream& is, P& p) {
        is >> p.x >> p.y;
        return is;                    // ★ 返回流,支持链式
    }
};

int main() {
    P a, b;
    cin >> a >> b;                      // 一行读入两个点
    cout << "a = " << a << ", b = " << b << endl;

    vector<P> v{a, b};
    for (const P& p : v) cout << p << " ";   // 输出整个容器
    cout << endl;
    return 0;
}
运行结果(输入 3 4 5 6)
a = (3,4), b = (5,6)
(3,4) (5,6)

🔍 代码逐段解析

operator<< — 友元全局形式,第二参数 const P&(只读);打印 (x,y) 后 return os;。设 x=3, y=4,输出 (3,4)。

operator>> — 第二参数是 P& p(没有 const,因为要往里写);is >> p.x >> p.y 读入两个整数,return is; 支持 cin >> a >> b。

main 的输入输出 — cin >> a >> b 输入 3 4 5 6:a=(3,4)、b=(5,6),与运行结果一致。

vector 遍历 — for (const P& p : v) cout << p << " ":重载 << 后整个容器一行打印,输出 (3,4) (5,6)。

易错点 — ① 输入重载的参数误写 const P&(无法写入,编译错误);② 两个函数都忘 return 流(返回类型是 ostream&/<istream&> 却不返回 → UB)。

动画:cout << "a = " << a << endl 的左结合展开(数值与运行结果一致)
1编译器按左结合加括号:((cout << "a = ") << a) << endl; — 最内层先算。
2cout << "a = " — 内置版本,打印 a = 并返回 cout 自己的引用。
3(cout) << a — 调用你重载的 operator<<(os, P):打印 (3,4),函数末尾 return os; 又交回流。
4(cout) << endl — 继续在同一个 cout 上换行。三步全靠"每一步都返回流本身"才串得起来。
5若 operator<< 返回 void — 第 3 步之后 (void) << endl 没有意义,编译错误。返回流引用是链式的硬性要求。

🔍 为什么必须返回流引用?

因为 cout << "a = " << a << endl 是左结合的,编译器把它理解成:

((cout << "a = ") << a) << endl;

也就是说,cout << "a = " 必须先返回一个还能继续 << 的东西,这里就是 cout 自己。所以函数签名必须是 ostream& 而不是 void。

同理,cin >> a >> b 要求 operator>> 返回 istream&。这是"链式调用"的通用模式:每一步都返回同一个可以继续操作的载体。

第二个细节:输入重载的第二个参数不能加 const。因为要往里面写入数据。所以是 istream& is, P& p —— 注意没有 const P&,这是输出重载和输入重载的关键区别。

第三个技巧:如果只想用于调试、不想污染提交的代码,可以用条件编译包起来:

#ifdef DEBUG
    friend ostream& operator<<(ostream& os, const P& p) { return os << "(" << p.x << "," << p.y << ")"; }
#endif

// 调试时:g++ -DDEBUG a.cpp -o a     → 可用 cout << p
// 提交时:g++ a.cpp -o a             → 不编译这个函数

另外注意:operator<< 声明为 friend 是为了能访问 private 成员。如果 x、y 本来就是 public(比如 struct),friend 可以省略,写成普通全局函数即可。

竞赛收益:重载 << 后,你可以直接把对象丢进 cout,甚至直接打印整个容器:for (auto& x : v) cout << x << endl;。在紧张调试时,"少写一个 .print()"累积下来能省下可观的时间。
案案例 1:二维向量——从"函数名"到"数学符号" 入门范本

计算几何题里到处都是向量加减、数乘、点积、叉积。用普通函数写,代码里满是 add(a,b)、sub(a,b)、mul(a,k);重载之后,代码看起来就是数学公式。

C++Vec:完整的二维向量
#include <bits/stdc++.h>
using namespace std;

struct Vec {
    double x, y;
    Vec(double x = 0, double y = 0) : x(x), y(y) {}

    // ── 算术运算符:返回新对象(值) ──
    Vec operator+(const Vec& o) const { return Vec(x + o.x, y + o.y); }
    Vec operator-(const Vec& o) const { return Vec(x - o.x, y - o.y); }
    Vec operator*(double k)        const { return Vec(x * k, y * k); }

    // ── 几何运算(不需要运算符,用函数名更清楚) ──
    double dot(const Vec& o)   const { return x * o.x + y * o.y; }   // 点积
    double cross(const Vec& o) const { return x * o.y - y * o.x; }   // 叉积
    double len2()              const { return x * x + y * y; }       // 长度平方

    // ── 比较运算符 ──
    bool operator==(const Vec& o) const { return x == o.x && y == o.y; }
    bool operator<(const Vec& o) const {                       // 严格弱序
        if (x != o.x) return x < o.x;
        return y < o.y;
    }

    // ── 全局形式:让「标量 * 向量」两个方向都能写 ──
    friend Vec operator*(double k, const Vec& v) { return v * k; }

    // ── 输出:调试时直接 cout << v ──
    friend ostream& operator<<(ostream& os, const Vec& v) {
        return os << "(" << v.x << "," << v.y << ")";
    }
};

int main() {
    Vec a(3, 4), b(1, 2);
    cout << "a     = " << a << endl;
    cout << "b     = " << b << endl;
    cout << "a+b   = " << a + b << endl;
    cout << "a-b   = " << a - b << endl;
    cout << "a*2   = " << a * 2 << endl;      // 成员形式
    cout << "2*a   = " << 2 * a << endl;      // 全局形式,缺了就编不过
    cout << "a·b   = " << a.dot(b) << endl;
    cout << "a×b   = " << a.cross(b) << endl;
    cout << "|a|   = " << sqrt(a.len2()) << endl;
    cout << "a==b? " << (a == b) << endl;

    vector<Vec> v{Vec(5, 1), Vec(1, 9), Vec(5, 0), Vec(0, 7)};
    sort(v.begin(), v.end());          // 只认 operator<,不用传比较器
    cout << "排序后:";
    for (const Vec& p : v) cout << p << " ";
    cout << endl;
    return 0;
}
运行结果
a     = (3,4)
b     = (1,2)
a+b   = (4,6)
a-b   = (2,2)
a*2   = (6,8)
2*a   = (6,8)
a·b   = 11
a×b   = 2
|a|   = 5
a==b? 0
排序后:(0,7) (1,9) (5,0) (5,1)
动画:a=(3,4) 与 b=(1,2) 的全部运算手算(与运行结果逐步核对)
1a + b — 分量相加:(3+1, 4+2) = (4,6);a - b — 分量相减:(3−1, 4−2) = (2,2)。
2a * 2 — 成员数乘:(3×2, 4×2) = (6,8);2 * a — 全局友元版,内部 return v * k,同为 (6,8)。
3a.dot(b) — 3×1 + 4×2 = 3+8 = 11;a.cross(b) — 3×2 − 4×1 = 6−4 = 2。
4|a| — len2 = 9+16 = 25,开方得 5;a == b — x、y 都不同 → 0。
5排序 — operator< 先比 x 再比 y:(5,1) vs (1,9):x 5>1 → (1,9) 更小;(5,0) vs (5,1):x 相同比 y,0<1 → (5,0) 在前。最终 (0,7) (1,9) (5,0) (5,1)。
每一步都与"运行结果"逐行对应——写完运算符重载,手算一遍是最快的自检方式。

🔍 逐段解析

① 为什么算术运算符的参数是 const Vec&?传引用避免拷贝(几何题里对象可能更大),加 const 表示"我不修改对方"。这几乎是一条无条件的习惯:只要函数不改参数,就写 const T&。

② 为什么 operator+ 返回 Vec 而不是 Vec&?因为 a + b 的结果是一个全新的临时值,它不属于 a 也不属于 b。返回引用只能指向一个已存在的对象,而这里根本没有这样的对象。强行返回局部变量的引用就是悬空引用。

③ 为什么 a*2 和 2*a 都能跑,靠的却是两个不同的函数?a*2 走的是成员函数 a.operator*(2);2*a 走的是全局函数 operator*(2, a)。如果没有那个全局版本,编译器会试图找 double::operator*(内置类型没有这个成员函数),然后放弃并报错。

④ 为什么点积不用 operator*?因为 operator* 已经被"数乘"占用了——a * b 究竟是点积还是叉积?含义不唯一时,用明确的函数名反而更好。这也提示一条设计原则:运算符重载要让代码更清楚,而不是更含糊。

⑤ 为什么 a == b 需要括号?因为 << 的优先级高于 ==。若写 cout << a == b,编译器会先算 cout << a,再拿结果和 b 比较,语义完全错了。输出比较结果时,比较表达式一律加括号。

⑥ 排序结果为什么是按 x 再按 y?因为 operator< 里先比 x,相同才比 y,这就是标准的多关键字字典序。sort 默认用 operator<,所以连第三个参数都不用写。

案案例 2:大数比较与排序——解释"set 为什么不认 cmp" 核心原理

这个故事来自本讲开头。我们要把超长整数(可能长达 105 位)排序并去重。因为位数太多,只能用 string 存。而 string 的默认比较是字典序,"9" > "100" —— 这显然是错的数值序。

C++BigNum:让 string 按"数值大小"比较
#include <bits/stdc++.h>
using namespace std;

struct BigNum {
    string s;                       // 无前导零

    void trim() {                   // 去掉前导零(保留最后一位,保证 "0" 不变成空串)
        int i = 0;
        while (i + 1 < (int)s.size() && s[i] == '0') i++;
        s = s.substr(i);
    }

    // ★ 核心:数值序 = 先比位数,位数相同再比字典序
    bool operator<(const BigNum& o) const {
        if (s.size() != o.s.size()) return s.size() < o.s.size();
        return s < o.s;
    }
    bool operator==(const BigNum& o) const { return s == o.s; }

    friend ostream& operator<<(ostream& os, const BigNum& b) { return os << b.s; }
};

// ── 错误示范:只写了普通比较函数 ──
bool cmp(const BigNum& a, const BigNum& b) { return a < b; }

int main() {
    int n; cin >> n;
    vector<BigNum> v(n);
    for (BigNum& b : v) { cin >> b.s; b.trim(); }

    // ① sort 用普通比较函数:可以
    sort(v.begin(), v.end(), cmp);
    cout << "排序:";
    for (const BigNum& b : v) cout << b << " ";
    cout << endl;

    // ② set 不接受 cmp,只能靠 operator<
    set<BigNum> st;
    for (const BigNum& b : v) st.insert(b);
    cout << "去重后 " << st.size() << " 个:";
    for (const BigNum& b : st) cout << b << " ";
    cout << endl;

    // ③ map 的键同理,必须有 operator<
    map<BigNum, int> mp;
    for (const BigNum& b : v) mp[b]++;
    cout << "出现次数最多的大数位数:" << mp.rbegin()->first.s.size() << endl;
    return 0;
}
运行结果(输入见下)
6
0100
9
008
1000
9
0
排序:0 8 9 9 100 1000
去重后 5 个:0 8 9 100 1000
出现次数最多的大数位数:4
动画:"9" 与 "100" 的两种比较 + trim 的作用(数值与样例一致)
1string 默认(字典序) — "9" > "100":首字符 '9' > '1' 就直接分出胜负。数值上显然错误,这正是本案要修的问题。
2BigNum 数值序 — 先比位数:"9".size()=1 < "100".size()=3 → 9 < 100,第一步就得出正确结论。
3位数相同时 — 例如 "100" vs "099"?先 trim!"099" 去前导零变 "99"(i 停在最后一位前,至少留一位),然后同位数比字典序:"100" vs "99" 位数不同了……保持 trim 后再比,顺序永远正确。
4trim("0100") — s[0]='0' 且还有后位 → i 前进;到 '1' 停止,substr 得 "100"。输入 6 个数依次变为 100、9、8、1000、9、0。
5排序结果 — 按位数(1,1,1,1,3,4)再字典序:0 8 9 9 100 1000,与输出一致;set 去重后 9 只留一份,共 5 个。
6mp.rbegin()->first — 最大键是 1000,s.size() = 4,输出"出现次数最多的大数位数:4"(注意这里取的是最大键的位数,与正文代码行为一致)。
先比位数、再比字典序——大数比较的两步套路,前提是"无前导零",这就是 trim 必须先跑的原因。

✅ 有 operator< 时能做什么

sort(v.begin(), v.end()) —— 不传比较器
set<BigNum> —— 有序去重
map<BigNum, int> —— 大数当键
priority_queue<BigNum> —— 大顶堆
lower_bound(v.begin(), v.end(), x) —— 二分查找
max_element / min_element —— 找最值

❌ 只有 cmp 时做不到的

set<BigNum> st; → 编译错误:no match for 'operator<'
map<BigNum, int> → 编译错误,同上
sort(v.begin(), v.end()) → 会按 string 默认字典序排,"9" 排在 "1000" 后面,结果错误

原因:set、map 的模板参数里没有"比较器"这一项(其实可以传,但要写成 set<T, Cmp>,很啰嗦)。它们默认只认 operator<。cmp 是你自己的函数,标准库不可能猜到你叫什么名字。

🔍 三个关键细节

① trim() 为什么必须保留至少一位?如果写成 while (s[0] == '0') s.erase(0, 1);,那输入 "0" 会变成空字符串,之后所有比较都出错。条件里的 i + 1 < s.size() 就是在保证"至少留一位"。这是字符串处理里极经典的边界问题。

② 为什么"先比位数"就够了?因为已经去掉了前导零,所以位数多的数一定更大(10 位数 > 9 位数)。位数相同时,string 的字典序比较恰好等价于数值比较——因为每位字符 '0'~'9' 的 ASCII 码本身就是有序的。这个"两步比较"是大数比较的标准套路,务必记住。

③ mp.rbegin()->first 是什么?反向迭代器的第一个元素,也就是"最大的键"。这里顺手演示了:有了 operator<,连 rbegin() 的"最大"都变得有意义。

④ 成员形式的 operator< 内部可以直接访问 o.s 吗?可以。同一个类的不同对象之间可以互相访问私有成员——访问控制是按"类"而不是按"对象"划分的。这是很多同学的认知盲点。

🔍 代码逐段解析 · BigNum
1string s — 唯一成员,无前导零约定由 trim 维护。
2trim() — while (i + 1 < (int)s.size() && s[i] == '0') i++; 条件里的 i+1 < size() 保证至少留一位,"0" 不会被删成空串。
3operator< — 第一关键字位数 s.size() != o.s.size() 早返回;位数相同直接 s < o.s(字典序 = 数值序,因为无前导零且 '0'~'9' 的 ASCII 有序)。
4operator== — 与 < 一致:完全相等 ⟺ 互不小于。
5main — 读入后立刻 b.trim();sort 传 cmp(可行),set/map 只靠 operator<(自动去重),mp[b]++ 统计出现次数。
6易错点 — ① 忘调 trim,输入 0100 按 3 位参与比较结果全错;② operator< 漏比位数,直接字典序比较,"9" 会排在 "1000" 后面。
💡 一句话记住

sort 可以收你的比较函数,但 set/map 只认 operator<。
想让自己的类型进标准库,就老老实实重载 <。

案案例 3:优先队列的两个致命注意点 高频踩坑

优先队列(priority_queue)是贪心题的常客,但它的比较器用法和 sort 恰好相反,而且对 const 有额外要求。这两点放倒过无数人。

C++Task:按截止时间最早优先的小根堆
#include <bits/stdc++.h>
using namespace std;

struct Task {
    int d, id;                      // d = 截止时间
    friend ostream& operator<<(ostream& os, const Task& t) { return os << t.id; }
};

// ── 注意 1:priority_queue 是【大根堆】 ──
// 它会把 cmp(a,b) 返回 false 的一方放到堆顶。
// 想让 d 小的在堆顶,就要让「d 小的」在 cmp 里显得「更大」
// 即:返回 a.d > b.d 表示 a 比 b 小 → 堆顶是 d 最小的
//
// ── 注意 2:operator() 必须加 const!──
// priority_queue 内部持有的是 const 的比较器对象,
// 漏掉 const 会报一屏「discards qualifiers」的错
struct CmpTask {
    bool operator()(const Task& a, const Task& b) const {     // ★ const 不能漏
        if (a.d != b.d) return a.d > b.d;
        return a.id > b.id;
    }
};

int main() {
    int n; cin >> n;
    priority_queue<Task, vector<Task>, CmpTask> pq;
    for (int i = 0; i < n; i++) {
        int d, id; cin >> d >> id;
        pq.push({d, id});
    }
    cout << "处理顺序:";
    while (!pq.empty()) {
        cout << pq.top() << " ";
        pq.pop();
    }
    cout << endl;
    return 0;
}
运行结果(输入见下)
5
30 1
10 2
20 3
10 4
50 5
处理顺序:2 4 3 1 5
动画:5 个任务入堆、出堆的全过程(数值与运行结果一致)
1依次 push — (30,1)、(10,2)、(20,3)、(10,4)、(50,5)。CmpTask 用 a.d > b.d:d 小的"更大",堆顶始终是 d 最小的任务。
2第一次 top() — d=10 的有两个:(10,2) 与 (10,4)。d 相同时比 a.id > b.id:id 小的优先 → 堆顶 (10,2),弹出打印 2。
3第二次 top() — 剩下 (10,4) 的 d 仍最小,弹出 4。
4接着弹出 — d=20 → 3;d=30 → 1;d=50 → 5。
5总顺序 2 4 3 1 5 — 与运行结果一致。若把 CmpTask 方向写成 a.d < b.d(照抄 sort),堆顶就变成 d 最大的 (50,5),顺序完全颠倒。
验证比较器方向最笨也最可靠的办法:塞两个元素,看 top() 是不是你要的。
🔍 代码逐段解析 · Task + CmpTask
1struct Task — 两个成员 d(截止时间)、id;重载 << 只打印 id,方便输出处理顺序。
2struct CmpTask — 函数对象:重载 operator(),参数表后必须写 const(priority_queue 内部的比较器对象不可修改)。
3比较逻辑 — if (a.d != b.d) return a.d > b.d; 主关键字:d 小的算"更大";return a.id > b.id; 次关键字:同 d 时 id 小的优先,让顺序完全确定。
4priority_queue<Task, vector<Task>, CmpTask> — 三个模板参数:元素类型、底层容器、比较器类型(注意传的是类型不是对象)。
5pq.push({d, id}) — 聚合初始化临时 Task 入堆;循环 while (!pq.empty()) 依次 top + pop。
6易错点 — ① operator() 漏 const → 一屏 discards qualifiers 报错;② 方向照抄 sort → 得到大根堆,顺序反了且不易发现。

⚠️ 注意点一:比较器的方向与 sort 相反

场景cmp(a, b) 返回 true 的含义谁在前 / 谁在顶
sort(v.begin(), v.end(), cmp)"a 应该排在 b 前面"返回 true 的元素靠前
priority_queue<T, vector<T>, cmp>"a 的优先级 低于 b"(a 更"小")返回 false 的一方在堆顶

后果有多严重?把同一份 cmp 同时交给 sort 和 priority_queue,会得到完全相反的顺序。更要命的是:两个结果单独看都"像是能跑通",只有在对比答案时才会发现谁反了。

记忆口诀:sort 的 cmp 是"谁在前",堆的 cmp 是"谁更小"。想不清楚时就用最笨的办法——写好后手动推演两三个元素,看堆顶是不是你要的。

⚠️ 注意点二:operator() 必须加 const

因为 priority_queue 内部保存的比较器对象是不可修改的,调用它时会走 const 重载路径。漏写 const 的报错信息长得离谱,通常有几十行模板展开,最后一行才是 passing 'const CmpTask' as 'this' argument discards qualifiers。看到这个症状,直接去给 operator() 加 const。

💡 补充:三种写出小根堆的方式

// 方式 A:内置类型 + greater(最简洁,只适用于 int/long long 等)
priority_queue<int, vector<int>, greater<int>> pq;

// 方式 B:自定义结构体 + 函数对象(推荐,最通用)
struct Cmp { bool operator()(const T& a, const T& b) const { return a.val > b.val; } };
priority_queue<T, vector<T>, Cmp> pq;

// 方式 C:函数指针(要写 decltype,很啰嗦,不推荐)
auto cmp = [](const T& a, const T& b) { return a.val > b.val; };
priority_queue<T, vector<T>, decltype(cmp)> pq(cmp);

方式 C 的两个坑:① 必须写 decltype(cmp) 而不能写 bool(*)(...),因为 lambda 的类型是编译器生成的匿名类型;② 构造时必须把 cmp 传进去,否则默认构造的比较器无法调用 lambda(lambda 没有默认构造函数)。所以竞赛里要么用方式 A,要么用方式 B,尽量别碰方式 C。

案案例 4:手写复数类——运算符全家桶 综合练习

这道案例把本讲所有知识点串起来:成员形式与全局形式并存、一元与二元运算符、返回值的两种约定、const 的完整使用、以及 << 重载。

C++Complex:复数运算完整实现
#include <bits/stdc++.h>
using namespace std;

class Complex {
private:
    double re, im;
public:
    Complex(double r = 0, double i = 0) : re(r), im(i) {}
    double real() const { return re; }
    double imag() const { return im; }
    double mod2() const { return re * re + im * im; }

    // ── 二元算术:返回新对象(值) ──
    Complex operator+(const Complex& o) const { return Complex(re + o.re, im + o.im); }
    Complex operator-(const Complex& o) const { return Complex(re - o.re, im - o.im); }
    Complex operator*(const Complex& o) const {
        return Complex(re * o.re - im * o.im, re * o.im + im * o.re);
    }

    // ── 一元负号:参数个数为 0 ──
    Complex operator-() const { return Complex(-re, -im); }

    // ── 复合赋值:返回自己的引用,支持连续运算 ──
    Complex& operator+=(const Complex& o) { re += o.re; im += o.im; return *this; }
    Complex& operator*=(const Complex& o) { *this = *this * o; return *this; }

    // ── 比较:== 与 != 成对出现 ──
    bool operator==(const Complex& o) const { return re == o.re && im == o.im; }
    bool operator!=(const Complex& o) const { return !(*this == o); }

    // ── 全局形式:支持「标量 * 复数」 ──
    friend Complex operator*(double k, const Complex& c) {
        return Complex(k * c.re, k * c.im);
    }

    // ── 输出:教 cout 怎么念一个复数 ──
    friend ostream& operator<<(ostream& os, const Complex& c) {
        os << c.re;
        if (c.im >= 0) os << "+";
        return os << c.im << "i";
    }
};

int main() {
    Complex a(3, -4), b(1, 2);
    cout << "a        = " << a << endl;
    cout << "b        = " << b << endl;
    cout << "a+b      = " << a + b << endl;
    cout << "a-b      = " << a - b << endl;
    cout << "a*b      = " << a * b << endl;
    cout << "-a       = " << -a << endl;
    cout << "2.5*a    = " << 2.5 * a << endl;
    Complex c = a;
    c += b; c *= b;
    cout << "(a+b)*b  = " << c << endl;
    cout << "a==b? " << (a == b) << " a!=b? " << (a != b) << endl;
    cout << "|a|^2    = " << a.mod2() << endl;
    return 0;
}
运行结果
a        = 3-4i
b        = 1+2i
a+b      = 4-2i
a-b      = 2-6i
a*b      = 11+2i
-a       = -3+4i
2.5*a    = 7.5-10i
(a+b)*b  = 8+6i
a==b? 0 a!=b? 1
|a|^2    = 25
动画:(3−4i)×(1+2i) 与复合赋值的完整手算(与运行结果一致)
1a * b — 实部 re·o.re − im·o.im = 3×1 − (−4)×2 = 3+8 = 11;虚部 re·o.im + im·o.re = 3×2 + (−4)×1 = 2 → 11+2i。
2-a — 一元负号:成员形式零参数,返回 (−3, +4) → -3+4i。
32.5 * a — 全局友元版数乘:(2.5×3, 2.5×(−4)) = 7.5−10i。
4c += b — c 从 a=(3,−4) 变为 (4, −2);c *= b — 内部 *this = *this * o:实部 4×1−(−2)×2 = 8,虚部 4×2+(−2)×1 = 6 → 8+6i。
5operator<< — 先打实部,im >= 0 补 "+",再打虚部和 i:a 输出 3-4i(im=−4 不加号),b 输出 1+2i。
6mod2() — 3²+(−4)² = 9+16 = 25;a==b 实虚都不同 → 0,a!=b 复用 !(*this==o) → 1。
六个设计要点(参数个数、返回引用、复用逻辑、==成对、输出格式、默认参数)都能在这几步手算里找到对应。

🔍 六个设计要点

① 一元与二元的参数个数。operator+ 是二元的,成员形式只写一个参数(另一个是自己);operator-() 是一元的,成员形式不写参数。编译器靠"参数个数"区分 a - b(二元)和 -a(一元),所以两者可以同名共存。

② 复合赋值返回引用。operator+= 写成 Complex& 且 return *this;。这样 (c += b) *= b 才能工作,也符合内置类型的行为。如果返回 Complex(值),每次 += 都会多一次拷贝。

③ *this = *this * o 的含义。*this * o 调用的是 operator*(返回新值),再赋给 *this。这是复用逻辑的标准写法:让 *= 依赖 *,而不是把乘法公式再抄一遍。公式改了只需改一处。

④ == 和 != 要成对实现,且 != 复用 ==。写成 !(*this == o) 保证两者永远一致。反过来写(== 复用 !=)也可以,但一定要有一个是"原始"实现。

⑤ 输出格式的细节。if (c.im >= 0) os << "+"; 让正虚部也带加号,输出 1+2i 而不是 12i(因为 1 和 2 会连在一起)。这是"给对象设计输出格式"时必须考虑的边界,重载 << 只是手段,格式好不好用才是目的。

⑥ 为什么构造函数用默认参数?Complex(double r = 0, double i = 0) 一个函数同时提供:默认构造(Complex c;)、单参构造(Complex c(5); 得到 5+0i)、双参构造。这样 vector<Complex> v(n) 也能正常工作——正是上一讲"易错 2"的正向应用。

模块 03

实践应用

三道完整题目:分数运算练 + 与 <,坐标排序练 < 与 ==,矩阵乘法练 * 与所有权重细节。

题 1分数排序与求和 运算符基础
📋 题目描述

给定 n 个分数,每个分数以 分子/分母 的形式给出(分母不为 0,分子可能为负)。

请你完成两件事:

① 把这 n 个分数按从小到大排序后输出;
② 输出它们的和,结果必须是最简分数形式。

⌨️ 输入格式

第一行一个整数 n。

接下来 n 行,每行一个形如 a/b 的分数。

🖨️ 输出格式

第一行输出排序后的 n 个分数,以单个空格分隔,每个分数为最简形式。

第二行输出它们的和,同样为最简形式。

📥 输入样例
4
1/2
2/3
1/6
3/4
📤 输出样例
1/6 1/2 2/3 3/4
25/12

样例解释:四个分数从小到大依次为 1/6≈0.167、1/2=0.5、2/3≈0.667、3/4=0.75。和为 6/12+8/12+2/12+9/12=25/12。

📏 数据范围

1 ≤ n ≤ 105,-106 ≤ a ≤ 106,1 ≤ b ≤ 106。保证输出结果的分母不超过 1018。时间限制 1s,空间限制 256MB。

🧠 题目分析

核心难点:分数之间怎么比大小?浮点数不行——1/3 和 2/6 明明相等,double 却可能给出不等的结果。正确做法是交叉相乘:a/b < c/d ⟺ a×d < c×b(在 b, d > 0 的前提下)。

溢出的陷阱:a, b 都在 106 量级,交叉相乘最大 1012 —— int 会溢出,long long 安全。求和时每次 num*den2 + num2*den,量级约 106×1018 会溢出 long long,所以必须每次都约分,把分子分母控制在合理范围内。

A
转成 double 比较

代码短,但会有精度问题;n 大时相等判断全错。不要用。

B
交叉相乘比较

把比较规则写进 operator<,用 long long 保证不溢出。本题正解。

关于"最简形式":这件事应该交给构造函数去做。只要每个分数对象一诞生就是最简的,那么无论排序还是相加,结果都自动是最简的。这就是上一讲说的"用构造函数维护不变式"在运算符场景下的直接收益。

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

struct Frac {
    long long n, d;                 // 不变式:d > 0 且已约分

    Frac(long long n = 0, long long d = 1) : n(n), d(d) { simplify(); }

    void simplify() {               // 约分 + 规范符号
        if (d < 0) { n = -n; d = -d; }
        long long g = __gcd(abs(n), d);
        if (g) { n /= g; d /= g; }
    }

    Frac operator+(const Frac& o) const {           // 返回新对象
        return Frac(n * o.d + o.n * d, d * o.d);    // 构造即约分
    }

    bool operator<(const Frac& o) const {           // 交叉相乘比较
        return n * o.d < o.n * d;
    }

    friend ostream& operator<<(ostream& os, const Frac& f) {
        return os << f.n << "/" << f.d;
    }
};

int main() {
    int N; scanf("%d", &N);
    vector<Frac> v(N);
    for (int i = 0; i < N; i++) {
        long long a, b;
        scanf("%lld/%lld", &a, &b);
        v[i] = Frac(a, b);
    }

    sort(v.begin(), v.end());                  // 只认 operator<
    for (int i = 0; i < N; i++) {
        if (i) putchar(' ');
        cout << v[i];
    }
    putchar('\n');

    Frac sum(0, 1);
    for (int i = 0; i < N; i++) sum = sum + v[i];   // 每步都自动约分
    cout << sum << endl;
    return 0;
}
运行结果
1/6 1/2 2/3 3/4
25/12
🔍 代码逐段解析 · 题 1(样例数值逐步核对)
1struct Frac — 成员 n, d 用 long long(交叉相乘最大 1012,int 必溢出)。
2simplify() — 先把负号统一到分子(d<0 则同变号),再 __gcd(abs(n), d) 约分;if (g) 防 n=0 时除零(其实 __gcd(0,d)=d,约成 0/1,是对的)。
3operator+ — 通分相加 n*o.d + o.n*d,返回 Frac(...) 触发构造即约分:1/2+2/3 = (3+4)/6 = 7/6。
4operator< — 交叉相乘 n*o.d < o.n*d:比较 1/6 与 1/2 → 1×2=2 < 1×6=6 → 1/6 更小 ✓。d>0 的不变式保证不等号方向不翻。
5main — sort 只传首尾(默认用 operator<);逐个 cout << v[i](重载的 << 打印 n "/" d)。
6求和 — sum 从 0/1 开始逐步加:6/12+8/12+2/12+9/12 每步构造即约分,最终 25/12,与样例一致。
7易错点 — ① 交叉相乘忘 long long;② 求和过程中不约分,分母按 106 幂次增长会溢出;③ operator< 用浮点除法比较 → 精度错。

💭 解题思路

第一步 · 把"分数"建模成一个对象。分子分母必须始终成对出现、且满足"分母为正、已约分"的不变式。这正是类的用武之地——把不变式的维护写进构造函数,外部就不可能破坏它。

第二步 · 用运算符表达"比较"和"相加"。把 < 重载成"交叉相乘",sort(v.begin(), v.end()) 一句话完成排序;把 + 重载成"通分相加",求和写成 sum = sum + v[i],读起来就是数学。

第三步 · 用 << 简化输出。否则输出部分得写 cout << v[i].n << "/" << v[i].d,一旦输出格式要改(比如加括号)就要改多处。

考点:构造函数维护不变式、+ 与 < 的重载、<< 输出、long long 溢出判断、__gcd 的使用。

易错点:① operator< 写成浮点除法比较,精度出错;② 交叉相乘用 int 溢出(106×106=1012 已经远超 int 上限);③ simplify 里 __gcd 遇到 n=0 会返回 d,此时 d 会被约成 1,结果 0/1,这是正确行为;但如果 d 也可能为 0,就会出现除零——本题保证 b ≥ 1;④ 求和时忘记"每步都约分",导致中途溢出(例如两个大分母相乘后再次相乘)。

题 2按距离排序并去重 < 与 == 的配合
📋 题目描述

给定平面上 n 个整数坐标点。请把所有不同的点按到原点的曼哈顿距离从小到大排序输出;距离相同时,按 x 坐标从小到大;x 也相同时按 y 从小到大。

重复出现的点只输出一次。

⌨️ 输入格式

第一行一个整数 n。

接下来 n 行,每行两个整数 x y。

🖨️ 输出格式

按规则排序并去重后,每行输出一个点 x y。

📥 输入样例
5
3 4
0 5
4 3
0 0
0 5
📤 输出样例
0 0
0 5
3 4
4 3

样例解释:五个点中 (0,5) 出现了两次,去重后剩四个。曼哈顿距离分别为 0、5、7、7;其中 (3,4) 与 (4,3) 距离同为 7,按 x 从小到大排,(3,4) 在前、(4,3) 在后;而 (0,5) 距离 5 小于 7,排在两者之前。

📏 数据范围

1 ≤ n ≤ 2×105,-109 ≤ x, y ≤ 109。时间限制 1s,空间限制 256MB。

🧠 题目分析

这道题同时用到 operator< 和 operator==,是检验"你把两个运算符都写对了吗"的好题目。

排序规则怎么写?三个关键字依次比较,标准写法是层层早返回:

bool operator<(const P& o) const {
    if (dist() != o.dist()) return dist() < o.dist();   // 第一关键字
    if (x != o.x)           return x < o.x;             // 第二关键字
    return y < o.y;                                         // 第三关键字
}

去重怎么做?两种路线:

路线写法复杂度评价
sort + uniquesort(v.begin(), v.end()); v.erase(unique(v.begin(), v.end()), v.end());O(n log n)需要 < 和 == 都正确
set 去重set<P> s(v.begin(), v.end());O(n log n)只需要 <,自动排序去重

选哪个?推荐 set 路线——它只需要 operator<,少写一个函数,少一个出错点,而且天然保持有序。但要注意:set 的每个节点有额外的指针开销,n = 2×105 时内存仍然绰绰有余(约 10MB 左右)。

如果用 sort + unique,operator== 必须与 operator< 保持一致。什么叫一致?a == b 为真 ⟺ a < b 和 b < a 都为假。如果两者矛盾(比如 == 说两点不同,但 < 说它们既不小于也不大于),unique 就会漏掉重复元素。这是"逻辑一致性"要求,编译器不会帮你检查。

距离为什么用曼哈顿距离?因为 |x|+|y| 全是整数运算,不涉及开方和浮点误差。如果题目要求欧氏距离,也应该比较距离的平方(x²+y²),同样避免浮点数。这是计算几何题的重要习惯:能不用浮点就不用浮点。

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

struct P {
    long long x, y;

    long long dist() const { return llabs(x) + llabs(y); }   // 曼哈顿距离

    bool operator<(const P& o) const {
        long long da = dist(), db = o.dist();
        if (da != db) return da < db;      // 第一关键字:距离
        if (x != o.x) return x < o.x;      // 第二关键字:x
        return y < o.y;                    // 第三关键字:y
    }

    friend ostream& operator<<(ostream& os, const P& p) {
        return os << p.x << " " << p.y;
    }
};

int main() {
    int n; scanf("%d", &n);
    set<P> s;                             // 自动排序 + 自动去重,只靠 operator<
    for (int i = 0; i < n; i++) {
        long long x, y;
        scanf("%lld %lld", &x, &y);
        s.insert({x, y});
    }
    for (const P& p : s) cout << p << '\n';
    return 0;
}
运行结果
0 0
0 5
3 4
4 3
🔍 代码逐段解析 · 题 2(样例数值逐步核对)
1long long dist() — llabs(x) + llabs(y):坐标到 109,|x|+|y| 最大 2×109 超出 int,必须 long long。
2operator< — 三关键字层层早返回:先比 da/db(距离),再比 x,最后比 y。任何一步不相等就返回,保证严格弱序。
3样例核对 — (0,0) d=0;(0,5) d=5;(3,4) d=7;(4,3) d=7。d 相同时比 x:3 < 4,所以 (3,4) 在 (4,3) 前。
4set<P> s — 插入 5 次,(0,5) 重复被拒;set 内部按 operator< 有序存放。
5s.insert({x, y}) — P 无构造函数,聚合初始化临时对象;for (const P& p : s) 按序输出 4 行,与样例一致。
6易错点 — ① dist() 用 int 存 → 2×109 溢出;② 比较写成 <= 破坏非自反性,set 丢元素;③ 若给 P 加了构造函数,insert({x,y}) 要改成 P(x,y)。

💭 解题思路

第一步 · 让距离成为对象的一部分。把 dist() 写成成员函数,而不是在外面到处算。这样比较规则可以写在类内部,任何地方使用这个点集,排序都是对的。

第二步 · 用一个 operator< 表达全部三个关键字。"层层早返回"的写法保证传递性和严格弱序;只要写法正确,sort 和 set 都能直接接受。

第三步 · 用 set 一次性完成排序 + 去重。这比手写 sort + unique 少一个函数、少一处逻辑,而且天然满足"输出时已有序"。当题目同时要求"排序"和"去重"时,set 往往是最省事的选择。

第四步 · 检查溢出。坐标到 109,|x| + |y| 最大 2×109 —— 超过了 int 的 2.1×109 边缘线,必须用 long long。abs() 只支持 int,处理 long long 要用 llabs() 或 std::abs。

考点:多关键字 operator<、用 set 排序去重、long long 与 llabs、避免浮点数。

易错点:① 用 sqrt() 算欧氏距离导致精度问题;② dist() 用 int 存导致溢出;③ 多关键字比较写成了 <=,破坏非自反性,set 会丢失元素或插入重复值;④ set::insert({x, y}) 依赖聚合初始化,如果 P 定义了构造函数就要改成 P(x, y)。

题 3矩阵乘法 综合应用
📋 题目描述

给定两个 n × n 的整数矩阵 A 和 B,求它们的乘积 C = A × B。

其中 C[i][j] = Σk=1..n A[i][k] × B[k][j]。

⌨️ 输入格式

第一行一个整数 n。

接下来 n 行,每行 n 个整数,表示矩阵 A。

再接下来 n 行,每行 n 个整数,表示矩阵 B。

🖨️ 输出格式

共 n 行,每行 n 个整数,表示乘积矩阵 C。

📥 输入样例
2
1 2
3 4
5 6
7 8
📤 输出样例
19 22
43 50

样例解释:C[1][1]=1×5+2×7=19,C[1][2]=1×6+2×8=22,C[2][1]=3×5+4×7=43,C[2][2]=3×6+4×8=50。

📏 数据范围

1 ≤ n ≤ 200,矩阵元素 |A[i][j]|, |B[i][j]| ≤ 104。时间限制 1s,空间限制 256MB。

🧠 题目分析

为什么矩阵特别适合封装成类?因为矩阵乘法的三重循环写起来容易错(循环顺序、下标顺序),而一旦封装,所有调用点都复用同一份正确实现。

复杂度与常数分析:n = 200 时三重循环是 2003 = 8×106 次乘法,1s 内非常宽裕。但如果题目要求算 Ak(矩阵快速幂),就是 log k 次乘法,这时循环顺序的常数优化就变得重要了。

A
朴素写法 i-j-k

for i, for j, for k: c[i][j] += a[i][k]*b[k][j]。内层循环里 b[k][j] 的访问跨行跳跃,缓存不友好。

B
优化写法 i-k-j

for i, for k, for j: c[i][j] += a[i][k]*b[k][j]。a[i][k] 在内层不变,c[i][j] 与 b[k][j] 都是连续访问,缓存友好,常数小得多。

再加上一句 if (!a[i][k]) continue;——稀疏矩阵可以跳过大量无效乘法。这就是"封装成类"的额外好处:优化只需要做一次,所有使用处自动受益。

溢出判断:元素最大 104,单次乘积 108,累加 n=200 次得到 2×1010 —— 超过 int,必须用 long long。如果是矩阵快速幂(k 次乘方),数值会爆炸式增长,通常题目会要求取模。

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

struct Matrix {
    int n;
    vector<vector<long long>> a;

    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;   // 单位矩阵
    }

    Matrix operator*(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;                // 跳过 0,稀疏时更快
                long long aik = a[i][k];               // 提取到寄存器
                for (int j = 0; j < n; j++)
                    r.a[i][j] += aik * o.a[k][j];      // 连续访问,缓存友好
            }
        return r;
    }

    friend ostream& operator<<(ostream& os, const Matrix& m) {
        for (int i = 0; i < m.n; i++) {
            for (int j = 0; j < m.n; j++) {
                if (j) os << ' ';
                os << m.a[i][j];
            }
            if (i + 1 < m.n) os << '\n';
        }
        return os;
    }

    bool operator==(const Matrix& o) const {          // 判等:逐元素比较
        if (n != o.n) return false;
        for (int i = 0; i < n; i++)
            for (int j = 0; j < n; j++)
                if (a[i][j] != o.a[i][j]) return false;
        return true;
    }
};

int main() {
    int n; scanf("%d", &n);
    Matrix A(n), B(n);
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++) scanf("%lld", &A.a[i][j]);
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++) scanf("%lld", &B.a[i][j]);

    Matrix C = A * B;             // 一行搞定
    cout << C << endl;

    Matrix I(n, true);            // 单位矩阵:乘法幺元
    cout << "A*I 与 A 相同?" << (A * I == A ? "是" : "否") << endl;
    return 0;
}
运行结果
19 22
43 50
A*I 与 A 相同?是
🔍 代码逐段解析 · 题 3(样例数值逐步核对)
1Matrix(n, ident) — assign 全零;ident=true 时对角线置 1(单位矩阵)。默认构造让 Matrix r(n) 与 Matrix A(n) 都合法。
2operator* — 循环顺序 i-k-j:a[i][k] 在内层固定,r.a[i][j] 与 o.a[k][j] 都按行连续访问,缓存友好。
3样例核算 C[0][0] — A 第 0 行 (1,2) × B 第 0 列 (5,7):1×5 + 2×7 = 19;C[0][1] = 1×6 + 2×8 = 22;C[1][0] = 3×5+4×7 = 43;C[1][1] = 50。
4long long aik = a[i][k]; — 提出循环不变量;if (!a[i][k]) continue; 跳过零元素,稀疏时大幅加速。
5operator<< — if (j) os << ' ' 行首不加空格;if (i+1 < m.n) os << '\n' 最后一行不换行(交给 endl),避免格式错误。
6main — A*I==A 一行需要 operator==,正文留作练习;返回值核对:operator* 返回 Matrix 值(局部 r 不能返引用)。
7易错点 — ① 循环写成 i-j-k 常数大 2~3 倍;② 元素用 int → 累加 2×1010 溢出;③ operator* 忘 const → const Matrix& 无法相乘。

上面代码里的 A * I == A 用到 operator==,结构与 < 一致(先比尺寸再逐元素比较);小练习:再补一个 operator!=,并让 Matrix 支持 sort(想一想按什么关键字比较有意义?)。

💭 解题思路

第一步 · 决定内部存储。用 vector<vector<long long>> 而不是定长数组,好处是矩阵尺寸在运行时确定,而且每个 Matrix 对象拥有自己独立的内存,不会出现"两个矩阵共用同一个数组"的错误。

第二步 · 用构造函数提供"零矩阵"和"单位矩阵"。Matrix(n) 得到全零;Matrix(n, true) 得到单位矩阵。单位矩阵是矩阵乘法的幺元(类比数字里的 1),矩阵快速幂的初始值正是它。把它做进构造函数,就再也不会忘记初始化。

第三步 · 重载 * 实现乘法。注意循环顺序写成 i-k-j 并跳过零元素,这是常数优化的标准技巧。关键点:operator* 必须返回新对象,因为有 Matrix r(n) 这个局部变量——返回引用会立刻悬空。

第四步 · 重载 << 处理格式化输出。注意行末不输出多余空格的技巧:if (j) os << ' ';,以及最后一行不输出换行。虽然多几行判断,但避免了"格式错误"这种最冤的失分。

考点:运算符返回值的约定(值 vs 引用)、构造函数提供多种初始化方式、循环顺序的常数优化、long long 溢出、格式化输出。

易错点:① operator* 返回 Matrix& 导致悬空引用;② 循环写成 i-j-k,常数大 2~3 倍;③ 用 int 存矩阵元素导致累加溢出;④ Matrix(int n = 0, bool ident = false) 如果不给 n 默认值,那 vector<Matrix> v(k) 会编译失败(需要默认构造);⑤ 忘记在 operator* 的声明后加 const,导致 const Matrix& 无法相乘。

模块 04

总结提升

把这一讲的规则收成一张表、一个万能模板,再看三个拓展与三个灵魂发问。

①核心要点与万能模板
知识板块必须记住的核心赛场用途
成员函数隐式 this,可访问私有成员;修改自身用成员形式push / merge / setPoint
const 成员函数只读操作一律加 const;常量对象只能调它避免 discards qualifiers
const 重载[] 同时提供可写与只读两个版本让 const 对象也能读
运算符重载本质a + b 就是 operator+(a,b)看懂报错信息
四条硬规则不发明新符号、不改优先级/结合性/操作数个数、至少一个自定义类型避免无效尝试
形式选择= [] () -> 必须成员;<< >> 必须全局写之前先想清楚
返回值约定算术返回值;复合赋值返回 *this;比较返回 bool;流返回流引用避免悬空引用
严格弱序只用 <、多关键字 != 早返回、纯函数无副作用避免 sort 随机 RE
比较运算符的用途sort / set / map / priority_queue / lower_bound自定义类型进标准库
堆与 sort 方向相反sort:true 表示"排前面";堆:true 表示"优先级更低"贪心题的必备直觉
函数对象operator() 必须 const,否则堆报错priority_queue 的比较器
流运算符返回 ostream& / istream&;输入参数不能 const调试时直接打印对象

万能模板:运算符重载速查骨架(把 T 换成你的类型,按需删减)

C++完整骨架:一次写对全部常用运算符
#include <bits/stdc++.h>
using namespace std;

struct T {
    int x, y;                                   // 数据成员

    // ── 1. 构造函数:给默认值,兼顾默认构造 ──
    T(int x = 0, int y = 0) : x(x), y(y) {}

    // ── 2. 算术运算符:返回【值】,参数 const 引用 ──
    T operator+(const T& o) const { return T(x + o.x, y + o.y); }
    T operator-(const T& o) const { return T(x - o.x, y - o.y); }
    T operator*(int k)        const { return T(x * k, y * k); }

    // ── 3. 复合赋值:返回【引用】,写 return *this ──
    T& operator+=(const T& o) { x += o.x; y += o.y; return *this; }
    T& operator-=(const T& o) { x -= o.x; y -= o.y; return *this; }

    // ── 4. 一元负号:成员形式不写参数 ──
    T operator-() const { return T(-x, -y); }

    // ── 5. 判等:!= 复用 == ──
    bool operator==(const T& o) const { return x == o.x && y == o.y; }
    bool operator!=(const T& o) const { return !(*this == o); }

    // ── 6. 排序:【严格弱序】,多关键字 != 早返回 ──
    bool operator<(const T& o) const {
        if (x != o.x) return x < o.x;
        return y < o.y;
    }

    // ── 7. 下标(可选):两个版本 ──
    int&       operator[](int i)       { return i == 0 ? x : y; }
    const int& operator[](int i) const { return i == 0 ? x : y; }

    // ── 8. 函数对象(可选):operator() 必须 const ──
    bool operator()(const T& a, const T& b) const { return a.x > b.x; }

    // ── 9. 全局形式:左右对称 + 流运算符 ──
    friend T operator*(int k, const T& t) { return T(t.x * k, t.y * k); }

    friend ostream& operator<<(ostream& os, const T& t) {
        return os << "(" << t.x << "," << t.y << ")";
    }
    friend istream& operator>>(istream& is, T& t) {      // ★ 注意没有 const
        return is >> t.x >> t.y;
    }
};
使用示例
T a(1,2), b(3,4);
cout << a + b << endl;      // (4,6)
cout << a * 3 << endl;      // (3,6)
cout << 3 * a << endl;      // (3,6)
cout << (a == b) << endl;   // 0
vector<T> v{T(3,1), T(1,2)};
sort(v.begin(), v.end());   // 不需要比较器

🔍 代码逐段解析(9 个部分按骨架编号)

1 构造函数 — T(int x=0, int y=0) 默认参数兼顾默认构造与带参构造,vector<T> v(n) 不报错。

2 算术运算符 — 全部 const 成员、参数 const T&、返回 T 值(新对象)。

3 复合赋值 — 返回 T& 且 return *this;,支持 a += b += c 链式且零拷贝。

4 一元负号 — 成员形式不写参数,靠参数个数与二元 operator- 区分。

5 判等 — != 写成 !(*this == o) 复用 ==,两者永远一致。

6 排序 — 多关键字 != 早返回,只出现 < 一个方向,天然满足严格弱序。

7 下标 — 可写版返回 int&、只读版返回 const int&,编译器按对象是否 const 自动选择。

8 函数对象 — operator() 加 const,才能交给 priority_queue。

9 全局/友元 — k * t 方向的数乘 + 流运算符;operator>> 的 T& 没有 const(要写入)。

易错点 — ① 套模板时忘记"按需删减",留下用不到的 [](返回 int& 对你的类型可能语义错误);② 友元函数定义在类内是可以的(内联),但别忘了它不是成员。

怎么用这个模板?把 T 换成你的结构名、x,y 换成真实成员,然后按需删掉用不到的运算符——没用到却重载了,只会增加出错概率,没有任何收益。实际竞赛中最常用的只有四个:<(排序去重)、+(累加)、==(判等)、<<(调试打印)。
②三个拓展案例
拓 1函数对象:把"比较规则"变成一个可以传递的对象

上一讲我们说比较规则应该写进类里。但有时同一份数据需要多种排序方式(比如学生既能按总分排、也能按姓名排)。这时就可以写多个"函数对象"。

C++函数对象(仿函数):重载 operator()
struct Stu { string name; int tot; };

// ── 三个"比较规则"对象 ──
struct ByTotal {                  // 按总分降序
    bool operator()(const Stu& a, const Stu& b) const { return a.tot > b.tot; }
};
struct ByName {                   // 按姓名字典序
    bool operator()(const Stu& a, const Stu& b) const { return a.name < b.name; }
};
struct ByTotalAsc {               // 按总分升序,可直接给小根堆用
    bool operator()(const Stu& a, const Stu& b) const { return a.tot > b.tot; }
};

// ── 使用:同一个容器,三种排序 ──
vector<Stu> v = {...};
sort(v.begin(), v.end(), ByTotal{});
sort(v.begin(), v.end(), ByName{});

priority_queue<Stu, vector<Stu>, ByTotalAsc> pq;     // 总分最小的在堆顶

函数对象 vs 普通比较函数,好在哪?

① 可以作为类型参数。priority_queue<T, vector<T>, ByTotalAsc> 需要的是一个类型,不是函数指针。普通函数做不到这一点(虽然可以用 decltype,但那很啰嗦)。

② 可以携带"状态"。函数对象可以有成员变量。比如写一个"按第 k 个字段排序"的比较器:struct ByKth { int k; bool operator()(...) const {...} };,使用时 ByKth{2} 就按第 2 个字段排。普通函数要实现同样效果只能靠全局变量,而全局变量会破坏"纯函数"的要求。

③ 名字即文档。sort(v.begin(), v.end(), ByTotal{}) 一眼就能看出"按总分排序",比一个匿名的 lambda 更清楚。当同一份数据有多种排序需求时,给每种规则起个名字,是很好的习惯。

什么时候还是用 lambda?只在一处使用、逻辑很短的时候。lambda 的优势是就地写、省去定义类;函数对象的优势是能复用、能命名、能当模板参数。按这个标准选就行。

拓 2重载 []:让对象用起来像数组

带权并查集需要维护"每个点到根的距离"。如果每次都写 d.w(x) 会觉得别扭,重载 [] 就能写成 d[x]。

C++二维坐标表:重载 operator[]
class Grid {
private:
    int n, m;
    vector<int> data;
public:
    Grid(int n, int m) : n(n), m(m), data(n * m, 0) {}

    // ── 只读版本:const 对象也能用 ──
    const int& operator[](int i) const { return data[i]; }

    // ── 可写版本:支持 g[i] = x ──
    int& operator[](int i) { return data[i]; }

    int at(int i, int j) const { return data[i * m + j]; }
    void set(int i, int j, int v) { data[i * m + j] = v; }
    void fill(int v) { for (int& e : data) e = v; }
};

// 使用:
Grid g(3, 4);
g[5] = 7;                        // 调用可写版本
const Grid& cg = g;
cout << cg[5];                 // 调用只读版本,输出 7
为什么 C++ 不能直接重载 [i][j]?因为语言规定 operator[] 只能接收一个参数。想支持 g[i][j],就得让 operator[] 返回一个"代表第 i 行的对象",再由那个对象重载一次 []。这个技巧叫"代理类",实现起来比较麻烦——竞赛中直接用 at(i,j) 这样的函数更省事。知道有这么回事就行。
拓 3矩阵快速幂:重载运算符后代码有多短

上一讲我们写过矩阵类。现在加上运算符重载,看看矩阵快速幂的代码能简洁到什么程度。

C++有了 operator* 之后,快速幂就是数学公式
const long long MOD = 1000000007;

struct Mat {
    int n;
    vector<vector<long long>> a;
    Mat(int n = 0, bool I = false) : n(n) {
        a.assign(n, vector<long long>(n, 0));
        if (I) for (int i = 0; i < n; i++) a[i][i] = 1;
    }
    Mat operator*(const Mat& o) const {
        Mat 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] = (r.a[i][j] + a[i][k] * o.a[k][j]) % MOD;
            }
        return r;
    }
    Mat& operator*=(const Mat& o) { *this = *this * o; return *this; }
};

// ── 矩阵快速幂:与整数快速幂写法一模一样 ──
Mat qpow(Mat base, long long k) {
    Mat res(base.n, true);          // 单位矩阵 = 乘法里的 1
    while (k) {
        if (k & 1) res *= base;      // 原来是 res = mul(res, base)
        base *= base;                // 原来是 base = mul(base, base)
        k >>= 1;
    }
    return res;
}

// ── 对比整数快速幂 ──
long long qpow(long long base, long long k, long long mod) {
    long long res = 1;
    while (k) {
        if (k & 1) res = res * base % mod;
        base = base * base % mod;
        k >>= 1;
    }
    return res;
}

重载带来的真实收益:两个 qpow 函数结构完全一致——只有"1 换成单位矩阵""* 换成带取模的矩阵乘"这两处不同。

为什么这很重要?因为快速幂的思路(二进制拆分 + 倍增)是可以跨类型复用的算法结构。整数能用、矩阵能用、多项式能用、甚至自定义的"半群上运算"都能用。运算符重载让"算法结构"与"具体数据类型"解耦——写矩阵快速幂时,你只需要确认"单位元是什么""乘法怎么写",剩下的倍增框架照抄即可。

注意 *= 的实现:*this = *this * o; 复用了 operator*,而不是把三重循环再抄一遍。公式要改时只改一处,这是"复合赋值依赖算术运算符"的标准模式(上一讲案例 4 的复数类也用了同样的手法)。

一个小提醒:Mat res(base.n, true) 中 true 表示"生成单位矩阵"。如果不小心传了 false 或者忘传,res 就是全零矩阵,任何数乘以它都是零——最后结果全 0,却看不出哪里错了。这种"静默错误"恰恰说明:用构造函数提供"带语义的初始化",比事后手动填写更可靠。

🔍 代码逐段解析

Mat 构造函数 — Mat(int n=0, bool I=false):全零或单位矩阵一步到位;快速幂的初值 Mat res(base.n, true) 就是乘法幺元。

operator* — i-k-j 顺序 + 跳过 a[i][k]==0;每次乘加后 % MOD,两个 < MOD(约 109)的数相乘约 1018,long long 恰好装得下。

operator*= — *this = *this * o; 复用乘法后返回 *this;res *= base、base *= base 就这样省掉所有临时变量管理。

qpow — 标准二进制拆分:if (k & 1) res *= base; 把 base 的低次幂累进 res,k >>= 1 右移,log k 次循环。

整数版对比 — 两个 qpow 逐行同构:唯一差别是"1 ↔ 单位矩阵"与"res * base % mod ↔ 带取模的矩阵乘"。

易错点 — ① 忘传 true,res 全零、答案全 0;② 忘记每步取模,两三次乘法后溢出;③ base 按值传参是对的(要在函数内被 *= 修改),写成 const 引用反而编译错误。

③三个值得琢磨的问题
思 1为什么 C++ 不允许重载 .、::、?: 和 sizeof?
点击查看思路

先说结论:因为这些运算符要么参与"编译期就必须确定"的事情,要么是语言语法骨架本身的一部分,一旦允许重载,整个语言就没法解析了。

. 和 :: 不能重载:它们的作用是在编译期定位一个名字——a.b 要求编译器在编译阶段就知道 b 是不是 a 的成员、偏移量是多少。如果允许重载,那么 a.b 的含义就依赖于运行期才知道的信息,编译器根本无法解析后续代码。

?: 不能重载:三目运算符的求值规则是只求值其中一个分支(短路求值)。如果改成函数调用,两个分支都会被求值——这既改变了语义,也无法实现"只算一边"。语言设计者选择保护这个语义。

sizeof 不能重载:它的结果必须在编译期确定(用于分配数组、模板参数等)。而重载运算符是运行期行为,这两者根本矛盾。

反过来想,允许重载的运算符都有一个共同特征:它们的结果可以在运行期算出来,且不改变语法结构。比如 + 只是"把两个值算成一个值"——至于怎么算,交给运行期函数完全没问题。

对竞赛的意义:知道这些限制,就不会浪费时间尝试"重载 . 来简化成员访问"这类不可能实现的想法。见到报错说 operator. cannot be overloaded,直接放弃这条路。

思 2为什么算术运算符返回"值",而复合赋值返回"引用"?反过来会怎样?
点击查看思路

先说为什么现在这样设计。

算术运算符返回"值":因为 a + b 的结果是一个全新的临时对象——它既不是 a 也不是 b,而是函数内部构造出来的。如果返回引用,那就只能返回某个已存在对象的引用。唯一"存在"的候选是函数内的局部变量,而它在函数返回时就销毁了,引用立刻失效。所以只能返回值。

复合赋值返回"引用":因为 a += b 的语义是"修改 a,并且这个表达式的值就是 a 本身"。返回 *this 完美对应这个语义,而且不产生任何拷贝。

如果反过来会怎样?

情形一 · 算术运算符返回引用:悬空引用,未定义行为。程序可能"看起来能用",直到某次优化或某个编译器版本变化后突然崩溃。这类 bug 最难查,因为崩溃点往往离出错点很远。

情形二 · 复合赋值返回值:不会崩溃,但有两个代价。① 每次 a += b 都要额外拷贝一个对象,在矩阵、大整数这类重运算中开销明显;② 写成 a += b += c 时,内层的 b += c 返回一个临时副本,再赋给 a——虽然结果碰巧正确,但如果 b 之后还要用,语义就容易让人困惑。

能不能两个都返回值?技术上可以(很多人图省事这么写),但它破坏了"与内置类型行为一致"的原则——内置的 int 里 (a += b) 返回的就是 a 的引用(你可以写 (a += b) = 5 并真的改变 a)。自己的类型尽量和内置类型行为一致,代码才不会有"意外感"。

一句记忆:造新值的返值,改自己的返引用;流要返流,比较返 bool。

思 3什么时候"不该"重载运算符?
点击查看思路

运算符重载是有代价的:它会隐藏函数名,让读者必须知道"这个符号在你的类型上是什么含义"。所以当含义不唯一、或与直觉不符时,用普通函数名更好。

反例一 · 含义歧义(本讲案例 1 就遇到过):向量有"点积"和"数乘"两种乘法。如果重载 a * b,读者无法判断它是点积(返回标量)还是叉积(返回向量)。此时应当用 dot()、cross() 这样的名字,让意图一目了然。

反例二 · 滥用 + 做非加法的操作:比如把"合并两个并查集集合"写成 a + b,把"求最短路"写成 dist[a] * dist[b]。这类"符号与语义脱节"的写法,读者每看一次都要在脑中翻译一次,长期看是负收益。

反例三 · 重载了却没人用:本讲模板里为什么强调"按需删减"?因为每一个多写的运算符都是一个潜在的出错点——它可能违反严格弱序、可能忘记 const、可能返回错误的类型。没被使用的代码不是中性的,它是有害的。

反例四 · 破坏直觉的运算符:重载 ! 表示"反转"没问题(标准库的 ! 本来就是取反),但重载 ! 表示"求逆元"或"四舍五入"就很糟糕。判断标准:一个不懂你类的人看到这个表达式,能不能猜对含义。

那什么时候该重载?三个明确信号:

信号一:标准库需要它(< 给 sort/set、== 给 unique/find、<< 给 cout)。这不是"该不该",而是"必须"。

信号二:语义与内置类型高度一致(复数相加、矩阵相乘、分数比较、大整数比较)。读者不需要额外学习成本。

信号三:表达式会被反复书写,重载能显著减少重复代码(比如矩阵快速幂里的 *=)。

一句总结:运算符是"公共语义"的载体,不是"个性表达"的工具。当你的类型在语义上真的"就是"那种运算时,才去重载它。

模块 05

学习检测

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

易错1

比较运算符用了 <= 或 >=,sort 随机 RE

❌ 错误
bool operator<(const Stu& o) const {
    return tot <= o.tot;     // ❌ 非自反性被破坏!
}
// 表现:本地小数据正常,大数据随机 RE 或 WA;
//       换一个编译器/换一台评测机,结果又变了
✅ 正确
bool operator<(const Stu& o) const {
    return tot < o.tot;      // ✅ 严格小于
}
// 多关键字:
bool operator<(const Stu& o) const {
    if (tot != o.tot) return tot < o.tot;
    return name < o.name;
}
口诀:<= 会破坏自反 —— a < a 变成真,sort 的下标就会乱窜;比较只写 <,要"大于"就把参数换过来。
易错2

忘记 const 成员函数,比较运算符内部调用失败

❌ 错误
struct Stu {
    string name; int tot;
    int total() { return tot; }          // ❌ 没有 const
    friend bool operator<(const Stu& a, const Stu& b) {
        return a.total() < b.total();       // ❌ 报错
    }
};
// error: passing 'const Stu' as 'this' argument discards qualifiers
✅ 正确
struct Stu {
    string name; int tot;
    int total() const { return tot; }     // ✅ 加上 const
};
规则:只读操作一律加 const;先无脑加,报错说"要修改"时再删。
口诀:看到报错里有 const 和 discards qualifiers,第一反应就是某个成员函数忘了加 const。
易错3

算术运算符返回了引用,悬空引用

❌ 错误
struct Vec {
    double x, y;
    Vec& operator+(const Vec& o) const {
        Vec r(x + o.x, y + o.y);
        return r;              // ❌ 返回局部变量的引用
    }
};
// 表现:可能"看起来能用",也可能输出垃圾值/崩溃
// 换个优化等级 (-O0 → -O2) 结果就变了 —— 典型 UB
✅ 正确
struct Vec {
    double x, y;
    Vec operator+(const Vec& o) const {
        return Vec(x + o.x, y + o.y);   // ✅ 返回【值】
    }
};
口诀:算术运算符返【值】—— 因为结果是新造出来的临时对象,根本没有可引用的实体;返回引用 = 悬空引用。
易错4

priority_queue 的比较器方向写反 / operator() 忘写 const

❌ 错误
// 错误 1:照抄了 sort 的方向
struct Cmp { bool operator()(const T& a, const T& b) const { return a.v < b.v; } };
priority_queue<T, vector<T>, Cmp> pq;
// 结果:拿到的是【大根堆】,与预期相反

// 错误 2:漏写 const
struct Cmp2 { bool operator()(const T& a, const T& b) { return a.v > b.v; } };
// error: passing 'const Cmp2' as 'this' argument discards qualifiers
✅ 正确
// 小根堆:让"值大的"被视为优先级更低 → 返回 a.v > b.v
struct Cmp {
    bool operator()(const T& a, const T& b) const {
        return a.v > b.v;      // ✅ const 不能漏
    }
};
记忆:sort 的 cmp 是"谁在前",堆的 cmp 是"谁更小"。写完后手动推演两个元素,看堆顶对不对。
口诀:堆的比较器,方向与 sort 相反;operator() 必须 const;拿不准就用两个元素手动验证堆顶!
易错5

指望 cmp 让 set 工作 / 忘记 operator<

❌ 错误
struct P { int x, y; };

bool cmp(const P& a, const P& b) { return a.x < b.x; }

set<P> s;              // ❌ 编译错误
// error: no match for 'operator<' (operand types are 'const P' and 'const P')
// 一屏几十行模板展开,找不到重点
✅ 正确
struct P {
    int x, y;
    bool operator<(const P& o) const {
        if (x != o.x) return x < o.x;
        return y < o.y;
    }
};
set<P> s;              // ✅ 可以了
// 连带 sort / map / priority_queue 全部可用
口诀:sort 收你的 cmp,set 只认 operator<;想让类型进标准库,就重载 < 一个顶五个!
📋速查表:运算符重载速查
运算符形式参数个数返回类型典型写法
+ - * /成员1T(值)T operator+(const T& o) const
+= -= *= /=成员1T&T& operator+=(const T& o)
- (一元)成员0TT operator-() const
++ (前置)成员0T&T& operator++()
++ (后置)成员1(int)TT operator++(int)
== !=成员/全局1 / 2boolbool operator==(const T& o) const
< > <= >=成员/全局1 / 2boolbool operator<(const T& o) const
[]必须成员1T& / const T&int& operator[](int i)
()必须成员任意任意bool operator()(const T& a, const T& b) const
=必须成员1T&T& operator=(const T& o)
<<只能全局2ostream&friend ostream& operator<<(ostream& os, const T& t)
>>只能全局2istream&friend istream& operator>>(istream& is, T& t)
->必须成员0指针T* operator->()
三个"必须/只能"要死记:= [] () -> 必须是成员函数;<< >> 只能是全局(友元)函数。其余运算符两种形式都可以。
📌知识点笔记卡
📖 必记概念
  • 运算符重载 = 名为 operator符号 的函数
  • 不能发明符号、不改优先级/结合性/操作数个数
  • 至少一个操作数为自定义类型
  • 严格弱序:非自反、反对称、可传递
  • friend 声明在类内、定义在类外
  • 函数对象的 operator() 必须 const
🧰 必背模板
  • 算术:T operator+(const T& o) const { return T(...); }
  • 复合赋值:T& operator+=(const T& o) { ...; return *this; }
  • 比较:if (a.x != b.x) return a.x < b.x; return a.y < b.y;
  • 输出:friend ostream& operator<<(ostream& os, const T& t) { return os << ...; }
  • 堆比较器:struct Cmp { bool operator()(const T& a, const T& b) const {...} };
  • 小根堆:priority_queue<T, vector<T>, Cmp>
🚨 考场提醒
  • 比较只用 <,绝不用 <=
  • 所有只读成员函数加 const
  • 算术返回值,复合赋值返引用
  • 堆的比较器方向与 sort 相反
  • set/map 只认 operator<
  • 没用的运算符别重载
课后练习

分层练习作业

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

🟢 A组 · 基础(4题)

A1概念辨析

① 成员函数与普通函数在"能访问什么"上的本质区别是什么?
② 哪些运算符必须写成成员函数?哪些只能写成全局函数?

查看参考答案

思路:第 ① 问的答案围绕 this 指针;第 ② 问答"语言规定了什么"。

① 本质区别:成员函数隐式接收一个 this 指针,指向调用它的对象,因此可以直接访问该对象的 private/protected 成员。普通函数没有 this,要访问私有成员必须把参数传进去,且必须被声明为 friend。

注意:编译后成员函数就是"多一个 this 参数的普通函数",运行时没有任何额外开销。

② 必须成员:=、[]、()、->。原因是这些运算符的语义与"当前对象"强绑定(比如 a = b 需要修改 a;a[i] 的 a 必须是对象),语言直接规定它们不能是静态成员或非成员函数。

② 只能全局:<<、>>。因为 cout << x 展开为 operator<<(cout, x),左操作数是标准库的 ostream 对象,我们无法往它的类里添加成员函数。所以只能写成"以 ostream 为第一参数、以自定义类型为第二参数"的全局函数(需要访问私有成员时加 friend)。

易错:① 误答"成员函数比普通函数快"(不会,编译后等价);② 误答 << 也能写成成员函数(写出来永远用不上,因为 cout 不是你的类型);③ 混淆"必须成员"和"建议成员"——复合赋值只是建议成员,不是强制。

A2写代码

给定下面的分数类,请补上 operator+=,并说明为什么返回值类型必须写 Frac& 而不是 Frac。

struct Frac {
    long long n, d;
    Frac(long long n = 0, long long d = 1) : n(n), d(d) {}
    // 请在这里补上 operator+=
};
查看参考答案

思路:复合赋值的语义是"修改自己,并把这个表达式的结果定义为修改后的自己"。

Frac& operator+=(const Frac& o) {
    n = n * o.d + o.n * d;
    d = d * o.d;
    // 保持不变式:约分 + 规范符号
    if (d < 0) { n = -n; d = -d; }
    long long g = __gcd(abs(n), d);
    if (g) { n /= g; d /= g; }
    return *this;      // ★ 返回自己
}

为什么必须是 Frac&:因为 a += b 的结果在语义上就是 a 本身(内置的 int 里你甚至可以写 (a += b) = 5 并真的改变 a)。返回 *this 正好表达这一点,而且不产生任何拷贝。

如果返回 Frac(值)会怎样?不会崩溃,但有两个代价:① 每次 += 都额外拷贝一个对象;② (a += b) = c 这种写法会变成"修改一个临时副本",与内置类型行为不一致,容易让人困惑。而如果是大对象(矩阵、大整数),这个拷贝开销就相当可观了。

易错:① 忘记 return *this;(返回类型是 Frac& 却什么都不返回 → UB);② 写成 return *this 但函数没有 &(多一次拷贝,不算错但没必要);③ 忘记约分,导致后续运算数值爆炸而溢出。

A3改错

下面的比较函数在大数据上会导致 sort 随机崩溃(RE),小数据却看不出问题。指出错误并说明原因。

struct Node {
    int val;
    bool operator<(const Node& o) const {
        return val <= o.val;
    }
};
查看参考答案

思路:检查这个比较函数是否满足严格弱序的三条性质。

错误:val <= o.val 违反非自反性。当 a、b 是值相同的两个元素(或同一个对象)时,a < b 返回 真,而正确行为应该是假。

为什么小数据没事、大数据崩:sort 对少量元素会退化成"插入排序",比较结果矛盾时通常只是顺序不太对,还能跑完;元素一多,主循环走快排的分区逻辑,当比较结果自相矛盾时,两个扫描指针可能互相穿过、下标越界,导致非法内存访问——这就是随机 RE 的来源。

为什么"随机":因为它取决于具体数据和具体实现。同一份代码换个编译器版本、换个优化等级、换个数据分布,表现都可能不同。这正是未定义行为(UB)的典型特征。

正确写法:return val < o.val;

考点:严格弱序、sort 的未定义行为表现。

易错:① 以为"<= 只是让相等元素换个顺序而已",实际上会崩;② 以为必须加个 if 判断相等再返回 false(不需要,直接用 < 天然满足);③ 只改这个函数却不检查其它比较函数(比如 operator> 里用了 >=)。

A4运行推断

下面两个 cout 语句输出分别是什么?为什么?

struct Num {
    int v;
    Num(int v = 0) : v(v) {}
    Num operator-(const Num& o) const { printf("binary "); return Num(v - o.v); }
    Num operator-() const             { printf("unary ");  return Num(-v); }
};

int main() {
    Num a(5), b(3);
    cout << (a - b).v << endl;
    cout << (-a).v << endl;
    return 0;
}
查看参考答案

思路:编译器靠"运算符后面跟了几个操作数"来区分一元和二元版本。

输出:

binary 2
unary -5

解释:(a - b) 有两个操作数,调用二元的 a.operator-(b)(成员形式一个参数),打印 binary,结果 5-3=2。(-a) 只有一个操作数,调用一元的 a.operator-()(成员形式零个参数),打印 unary,结果 -5。

关键点:两个函数同名共存、互不冲突,因为参数个数不同,属于函数重载。编译器在解析 a - b 时看到两个操作数,就去找"需要一个参数的 operator-";遇到 -a 只有一个操作数,就找"零个参数的 operator-"。

易错:① 以为一元负号应该写成全局函数(可以,但成员形式更简洁);② 把 -a 的成员实现写成带一个参数(那就变成"减号"了,-a 会找不到函数);③ 忘了写括号 (-a).v,写成 -a.v 会先算成员访问再取负(对 int 来说结果碰巧一样,但语义不同)。

🟡 B组 · 提高(4题)

B1设计题

已有 Vec 类实现了成员形式的 operator*(double k)。
① 为什么 2.0 * v 编不过?
② 请补上缺失的代码,并说明为什么加了这个函数 v * 2.0 仍然能工作。

查看参考答案

思路:从"编译器怎么解析运算符表达式"入手。

① 为什么编不过:2.0 * v 中,编译器看到左操作数是 double,会先尝试找 double 的成员函数 operator*。内置类型 double 没有这样的成员函数。编译器不会反过来去调用 v 的成员函数——因为成员运算符要求"左操作数是本类对象"。

② 补上的代码:

// 在类内写:
friend Vec operator*(double k, const Vec& v) { return v * k; }

// 或者类外写(x、y 是 public 时):
Vec operator*(double k, const Vec& v) { return Vec(v.x * k, v.y * k); }

为什么 v * 2.0 仍然能工作:因为两个函数参数个数相同(都是 2)但参数类型不同(顺序相反),属于合法的函数重载。v * 2.0 参数类型是 (Vec, double),匹配成员版本 v.operator*(2.0);2.0 * v 参数类型是 (double, Vec),匹配全局版本。编译器按"实参类型"精确挑选,不会冲突。

实现细节:全局版本内部直接 return v * k; —— 复用了成员版本的逻辑,而不是把乘法公式再抄一遍。这样一旦乘法规则要改(比如改成返回 Vec<long long>),只需改一处。

考点:运算符左结合、成员形式与全局形式的配合、函数重载的匹配规则、逻辑复用。

易错:① 以为"成员函数会自动处理左操作数是内置类型的情况";② 全局版本里又写一遍完整的乘法逻辑(重复代码);③ 全局版本写成 return v.operator*(k); 也可以,但更啰嗦。

B2设计题

写一个"按第 k 个字段升序排序"的函数对象,使得同一份数据可以用 ByKth{1}、ByKth{2} 分别按不同字段排序。每行有 3 个整数。

查看参考答案

思路:函数对象相比普通函数的优势之一就是可以携带状态。把 k 作为成员变量存起来。

struct Row { int a[3]; };

struct ByKth {
    int k;                                     // ★ 携带的状态
    ByKth(int k) : k(k) {}
    bool operator()(const Row& x, const Row& y) const {
        return x.a[k] < y.a[k];                 // 只比较第 k 个字段
    }
};

// 使用:
vector<Row> v = {...};
sort(v.begin(), v.end(), ByKth{0});     // 按第 0 个字段排
sort(v.begin(), v.end(), ByKth{1});     // 按第 1 个字段排
sort(v.begin(), v.end(), ByKth{2});     // 按第 2 个字段排

为什么必须用函数对象,不能用普通函数?普通函数无法携带 k。想实现同样效果,只能把 k 存成全局变量:int g_k; bool cmp(const Row&a, const Row&b){ return a.a[g_k] < b.a[g_k]; }。但全局变量有两个严重问题:

① 破坏"纯函数"要求。比较器的结果依赖于外部状态,一旦有别的代码修改了 g_k,排序中途的结果就会变化 —— 这是未定义行为级别的隐患。

② 无法并发/嵌套。如果同时要用两种排序(比如先按 k=1 分组,组内再按 k=2 排),全局变量根本不够用。

这两个问题正是"函数对象携带状态"要解决的。ByKth{1} 和 ByKth{2} 是两个完全独立的比较器对象,各自持有自己的 k,互不干扰。

考点:函数对象、operator() 的 const、比较器必须无副作用。

易错:① operator() 忘写 const(给 sort 用一般没事,但给 priority_queue 用会报错,养成加 const 的习惯);② 把 k 写成引用或指针导致悬空;③ 忘记给 ByKth 写构造函数,只能用 ByKth{k} 的聚合初始化(C++11 起可以,但如果成员有默认值就不行,稳妥起见写构造函数)。

B3改错

下面的代码想实现"按 d 值最小的任务优先"的小根堆,但编译报错且逻辑也不对。找出两处问题并修正。

struct Task { int d, id; };

struct Cmp {
    bool operator()(const Task& a, const Task& b) {
        return a.d < b.d;
    }
};

priority_queue<Task, vector<Task>, Cmp> pq;
查看参考答案

思路:分别检查"编译能否通过"和"语义是否正确"。

问题一 · operator() 缺少 const(编译错误)。priority_queue 内部保存的比较器是不可修改的,调用时走 const 路径。报错信息通常是几十行模板展开,最后一行是 passing 'const Cmp' as 'this' argument discards qualifiers。

问题二 · 比较方向反了(语义错误)。priority_queue 是大根堆:它把 cmp(a,b) 返回 false 的一方放到堆顶。写 a.d < b.d 意味着"d 小的更小",于是 堆顶是 d 最大的任务,恰好与预期相反。

修正后:

struct Cmp {
    bool operator()(const Task& a, const Task& b) const {    // ★ 加 const
        return a.d > b.d;                                        // ★ 方向反过来
    }
};

怎么记住方向?把它读成"a 的优先级比 b 低吗?"。小根堆要"d 小的在顶",那么"d 大的"就是优先级低的,所以 a.d > b.d 表示"a 的 d 更大 ⇒ a 优先级更低 ⇒ 返回 true"。或者用最笨但最可靠的办法:塞进两个元素,看 top() 是哪个。

第三个隐形问题(值得指出):如果 d 相同的任务很多,a.d > b.d 会让这些元素变得"不可区分",堆的行为虽然合法(满足严格弱序,因为它们互不比对方小)但结果不确定。最好补上第二关键字:if (a.d != b.d) return a.d > b.d; return a.id > b.id;,让顺序完全确定。

易错:只改方向不加 const(仍然编不过);或只加 const 不改方向(能跑但答案错,更危险,因为不容易发现)。

B4简答

为什么 sort 可以接受你写的比较函数 cmp,而 set 不行?set 真的完全无法使用自定义比较规则吗?

查看参考答案

思路:从"标准库如何获取比较规则"这一机制层面回答。

为什么 sort 可以:因为在 C++ 中函数可以作为参数传递。sort 的签名里有第三个参数,它是一个"比较器",类型由模板参数推导。你传 cmp(函数指针)或 Cmp{}(函数对象)或 lambda,它都收得下 —— 规则是"运行时/编译时通过参数送进去的"。

为什么 set 不行:set 需要在编译期就确定元素的比较方式,因为它的红黑树结构、节点布局都与之相关。如果比较规则在运行期才传入,编译器无法生成对应的类型。所以 set 的做法是:从"元素类型"本身去找比较方式,也就是去找 operator<。它不会、也无法去猜你写了哪个名字的比较函数。

但 set 并非完全不能自定义:set 其实有第二个模板参数 Compare:

// 默认:set<T> 等价于 set<T, less<T>>,less 内部用 operator<
// 你可以显式传一个比较器类型:
struct ByX { bool operator()(const P& a, const P& b) const { return a.x < b.x; } };
set<P, ByX> s;                        // ✅ 这样就不需要 operator< 了

// 或降序:
set<int, greater<int>> s2;              // 从大到小

那为什么还推荐写 operator<?因为 set<P, ByX> 这种写法有两个现实缺点:① 啰嗦——每次声明都要带上 ByX,容易漏写;② 容易不一致——同一个 P 在这个 set 里按 x 排、在另一个里按 y 排,稍不留神就把元素插进了"以为不会重复"的集合里。

结论:默认的比较规则(operator<)应该写进类里,让它成为这个类型的稳定语义;特殊的一次性排序需求(某个 set 想按别的字段排)才用第二个模板参数。这正是"默认 + 例外"的合理分工。

易错:① 认为"set 完全不能自定义比较"(可以,靠第二个模板参数);② 反过来认为"既然能自定义,就不需要 operator<"(默认规则写进类里更省事、更一致);③ 混淆 set<P, ByX> 中 ByX 是类型而 sort 的第三个参数是对象。

🔴 C组 · 挑战(4题)

C1综合实现

为矩阵类实现 operator*、operator*=、operator== 和 operator<<,并用它实现矩阵快速幂求 Ak mod 1e9+7。要求 *= 内部复用 *。

查看参考答案

思路:四个运算符的返回值约定完全不同,正好把本讲的核心规则全部练一遍。

const long long MOD = 1000000007;

struct Mat {
    int n;
    vector<vector<long long>> a;

    Mat(int n = 0, bool I = false) : n(n) {
        a.assign(n, vector<long long>(n, 0));
        if (I) for (int i = 0; i < n; i++) a[i][i] = 1;
    }

    // ── 1. 乘法:返回【值】 ──
    Mat operator*(const Mat& o) const {
        Mat r(n);
        for (int i = 0; i < n; i++)
            for (int k = 0; k < n; k++) {
                if (!a[i][k]) continue;
                long long aik = a[i][k];
                for (int j = 0; j < n; j++)
                    r.a[i][j] = (r.a[i][j] + aik * o.a[k][j]) % MOD;
            }
        return r;
    }

    // ── 2. 复合赋值:返回【引用】,复用 operator* ──
    Mat& operator*=(const Mat& o) {
        *this = *this * o;
        return *this;
    }

    // ── 3. 判等:返回 bool ──
    bool operator==(const Mat& o) const {
        if (n != o.n) return false;
        for (int i = 0; i < n; i++)
            for (int j = 0; j < n; j++)
                if (a[i][j] != o.a[i][j]) return false;
        return true;
    }
    bool operator!=(const Mat& o) const { return !(*this == o); }

    // ── 4. 输出:返回流引用 ──
    friend ostream& operator<<(ostream& os, const Mat& m) {
        for (int i = 0; i < m.n; i++) {
            for (int j = 0; j < m.n; j++) {
                if (j) os << ' ';
                os << m.a[i][j];
            }
            if (i + 1 < m.n) os << '\n';
        }
        return os;
    }
};

// ── 矩阵快速幂:与整数快速幂结构完全一致 ──
Mat qpow(Mat base, long long k) {
    Mat res(base.n, true);            // 单位矩阵 = 1
    while (k) {
        if (k & 1) res *= base;
        base *= base;
        k >>= 1;
    }
    return res;
}

int main() {
    int n; long long k;
    scanf("%d %lld", &n, &k);
    Mat A(n);
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++) scanf("%lld", &A.a[i][j]);
    cout << qpow(A, k) << endl;
    return 0;
}

四个返回值约定回顾:

运算符返回类型原因
*Mat(值)结果是新构造的临时对象,返回引用会悬空
*=Mat&修改自己并返回自己,支持链式且零拷贝
== !=bool只回答是否相等
<<ostream&支持 cout << x << endl 的左结合链式调用

*= 为什么必须复用 *?*this = *this * o; 一行同时完成了"调用乘法"和"赋值回自己"。好处是:如果哪天乘法逻辑要改(比如改成 long long 溢出保护、或改成模另一个数),只需要改 operator* 一处,*= 自动跟着变。如果把三重循环在 *= 里再抄一遍,就会出现"两处逻辑不一致"的隐患。

易错:① operator* 返回 Mat&(悬空引用);② operator* 忘写 const,导致 const Mat& 无法参与乘法(qpow 里传参时会报错);③ operator== 忘记先比较 n,导致访问越界;④ operator<< 里最后一行多输出一个换行(格式错误);⑤ Mat(int n = 0, bool I = false) 如果不给 n 默认值,vector<Mat> v(k) 会编译失败。

🔍 代码逐段解析

operator* — 与题 3 同款 i-k-j + 跳零 + 提取 aik,区别只在多一个 % MOD;返回 Mat 值。

operator*= — 两行:*this = *this * o; 复用乘法,return *this; 返回引用。

operator== — 先比 n != o.n 早返回(防越界),再双重循环逐格比较,遇不等立即 false。

operator<< — 友元全局;if (j) 控制行内空格、if (i+1 < m.n) 控制换行,返回 os。

qpow + main — 单位矩阵做初值,scanf("%lld", &k) 读指数;cout << qpow(A, k) 直接输出结果矩阵。

C2深度理解

为什么 operator<< 的 ostream& 参数不能按值传(写成 ostream os)?如果把返回类型从 ostream& 改成 void,会发生什么?

查看参考答案

思路:从"ostream 能不能被拷贝"和"链式调用需要什么"两个角度分析。

问题一 · 按值传参 ostream os:直接编译错误。C++ 标准库的流类(ostream、istream、ifstream 等)都显式删除了拷贝构造函数(basic_ios(const basic_ios&) = delete;)。原因很实在:流的本质是"对某个外部设备(屏幕、文件、管道)的连接",如果允许拷贝,就会有两个对象同时操作同一个设备,谁负责关闭、谁负责刷新缓冲区都说不清。

所以写成 ostream os 会报 use of deleted function 'basic_ostream(const basic_ostream&)'。必须用引用 ostream& 或 const ostream&。(而 << 会修改流的状态 —— 比如更新写入位置、设置错误标志,所以只能用非 const 引用 ostream&。)

问题二 · 返回类型改成 void:能编译通过(因为 << 重载返回什么类型是自由的),但会破坏链式调用。

// 返回 void 时:
cout << "x = " << x << endl;
// 编译器展开成:((cout << "x = ") << x) << endl;
//                ↑ 这一步返回 void
// 于是变成 (void << x) —— 编译错误

// 返回 ostream& 时:
cout << "x = " << x << endl;
// ((cout << "x = ") << x) << endl;
//   ↑ 返回 cout 的引用,可以继续 <<

所以 return os; 这一行不是"可选的礼貌",而是链式输出的硬性要求。同理,operator>> 必须返回 istream&,否则 cin >> a >> b 也写不出来。

一个更广泛的结论:"返回 自身类型的引用"是所有链式调用模式的基础。本讲里的 operator+= 返回 *this 也是同一个道理 —— 没有它就写不出 a += b += c。这是 C++ 中一个重要设计模式的统一体现。

易错:① 以为"返回值就是图个方便"(其实是语法必需);② 把参数写成 const ostream&(编译能过但无法写入流内容,会报 no match for operator<<);③ 只给 << 加 const 却忘了 >> 的第二个参数必须是非 const 的 T&(因为要写入)。

C3综合实现

实现一个支持 <、==、+、<< 的高精度非负整数类 BigUint,说明:① 为什么 + 的实现不能修改 this;② 各操作的时间复杂度。

查看参考答案

思路:内部用 vector<int> 低位在前存储。< 先比位数再比字典序(因为无前导零),+ 模拟竖式加法。

struct BigUint {
    vector<int> d;                          // d[0] 是个位

    BigUint(long long v = 0) {
        if (v == 0) d.push_back(0);
        while (v) { d.push_back(v % 10); v /= 10; }
    }
    void trim() {                                // 去前导零(至少留一位)
        while (d.size() > 1 && d.back() == 0) d.pop_back();
    }

    // ── 比较:返回 bool,且不修改任何一方 ──
    bool operator==(const BigUint& o) const { return d == o.d; }
    bool operator!=(const BigUint& o) const { return !(*this == o); }
    bool operator<(const BigUint& o) const {
        if (d.size() != o.d.size()) return d.size() < o.d.size();
        for (int i = (int)d.size() - 1; i >= 0; i--)
            if (d[i] != o.d[i]) return d[i] < o.d[i];
        return false;                            // ★ 完全相等 → 返回 false
    }

    // ── 加法:返回【值】,const 保证不改自己、不改参数 ──
    BigUint operator+(const BigUint& o) const {
        BigUint 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;
        }
        r.trim();
        return r;
    }

    // ── 输出:从高位往低位打印 ──
    friend ostream& operator<<(ostream& os, const BigUint& b) {
        for (int i = (int)b.d.size() - 1; i >= 0; i--)
            os << b.d[i];
        return os;
    }
};

int main() {
    BigUint a(999999999999999999LL), b(1);
    cout << a + b << endl;      // 1000000000000000000
    cout << (a < b) << " " << (a == b) << endl;   // 0 0
    return 0;
}

① 为什么 + 不能修改 this:a + b 的语义是"计算出一个新值",a 和 b 都应保持不变。如果 operator+ 修改了 this,那么 c = a + b; 之后 a 就变了 —— 这既违反数学直觉,也会在 cout << (a+b) << a 这种表达式里输出错误结果。

在代码层面的保证就是那个 const:它让编译器帮你检查"确实没有修改 this"。同时参数写成 const BigUint& o 保证不改 b。写下 const 就等于把这个约定交给编译器强制执行。

② 各操作的时间复杂度(设 L₁、L₂ 为两个数的位数,L = max(L₁,L₂)):

操作复杂度说明
operator+O(L)从低位到高位扫一遍,每位一次加法 + 进位
operator<O(L)先比位数 O(1);位数相同时最坏扫完整个数组
operator==O(L)vector 的 == 逐位比较,相同则扫完
operator<<O(L)逐位输出
sort 排序 n 个数O(n log n · L)每次比较最坏 O(L),共 O(n log n) 次比较

一个实用推论:如果要把大量大整数排序,总代价是 O(n log n · L)。当 L 很大(比如 105 位)时,这个开销会非常可观——这也是为什么"用 operator< 做比较"的实现在大数场景下要谨慎评估。

易错:① operator< 在完全相等时忘记 return false(会走到末尾返回不确定值,破坏严格弱序);② + 的循环条件漏掉 || carry,99...+1 会算错;③ 忘记 d.push_back(0) 处理 0 的特例,导致 BigUint(0) 是空 vector,d.size() 为 0 时比较出错;④ 成员函数里 `operator<` 忘记 const,导致 const BigUint& 无法比较(sort 直接编不过)。

🔍 代码逐段解析

构造函数 — BigUint(long long v=0):v==0 特例 push 一位 0;否则逐位 push_back(v % 10)(低位在前)。

trim() — while (d.size() > 1 && d.back() == 0) d.pop_back(); 从高位删零,size() > 1 保证至少留一位。

operator< — 先比位数(无前导零前提下位数即大小);位数相同从最高位往低位扫,出现不等立即返回;全等 return false 保证非自反。

operator+ — size_t i 循环条件 i < n || carry:999999999999999999 + 1 时两数读完 carry 仍为 1,循环继续补出第 19 位,得 1018。

main 核对 — a+b 输出 1000000000000000000;(a<b)=0(位数 18 vs 1)、(a==b)=0,与注释一致。

C4深度思辨

下面这段代码中 operator< 与 operator== 不一致,导致 unique 去重失败。请指出矛盾在哪、为什么 unique 会失效,并给出正确的实现,说明"一致性"的判定标准。

struct P {
    int x, y;
    bool operator<(const P& o) const {
        return x * x + y * y < o.x * o.x + o.y * o.y;   // 只比距离
    }
    bool operator==(const P& o) const {
        return x == o.x && y == o.y;                      // 比坐标
    }
};
查看参考答案

思路:先想清楚 unique 的实现依赖什么,再检验两个运算符是否"一致"。

矛盾在哪:取 a = (1,0)、b = (0,1)。两者距离都是 1,所以 a < b 为假、b < a 也为假 —— 按 < 的语义,它们"等价"(既不小于也不大于)。但 a == b 返回 假(坐标不同)。

由此产生两个具体后果:

后果一 · unique 去重失败(或者说行为不符预期)。unique 的实现是"用 == 判断相邻元素是否相同,把不同的往前搬"。它先用 < 排过序,但去重判等时用的是 ==。(1,0) 和 (0,1) 被排到一起(因为 < 认为它们等价),unique 却因为 == 说它们不同而保留两个。结果是:你以为"距离相同的点会去重",实际一个都没去掉。

后果二 · 如果是 set<P>,问题更严重。set 只用 < 判等(两个元素等价 ⟺ 互不小于)。所以 set 会认为 (1,0) 和 (0,1) 是同一个元素,第二个插入被拒绝而丢弃。于是同一个类,在 vector + unique 里保留了两个,在 set 里只剩一个 —— 两套语义打架,代码行为完全不可预期。

一致性的判定标准(记住这一条):

a == b 为真 ⟺ a < b 为假 且 b < a 为假

换句话说:== 必须与 < 定义的"等价类"完全一致。判等标准就是"既不小于也不大于"。

正确实现(两种方案):

// 方案 A:推荐 —— 让 == 从属于 < 的等价关系,并保留距离作为排序关键字
struct P {
    int x, y;
    long long d2() const { return (long long)x * x + (long long)y * y; }

    bool operator<(const P& o) const {
        if (d2() != o.d2()) return d2() < o.d2();
        if (x != o.x) return x < o.x;      // ★ 加第二、第三关键字
        return y < o.y;
    }
    bool operator==(const P& o) const { return x == o.x && y == o.y; }
};

方案 A 的关键:给 operator< 补全所有能区分两个对象的字段(这里加上 x、y)。这样"两个对象等价"就严格等价于"坐标完全相同",与 == 完全一致。这也是 unique、set、sort 三者行为统一的保证。

// 方案 B:如果确实想"只按距离去重",就不要用 == 去重
// 改用 set(只认 <),并明确接受"同距离只保留一个"的语义
set<P> s(pts.begin(), pts.end());   // 语义清晰:按 < 的等价类去重

方案 B 的要点:当"去重标准"与"对象相等"确实不同时,必须明确选择用哪一套语义,并且全程只用一套。要么统一用 <(配 set),要么统一用 ==(配 unique),绝不能让两个运算符各说各话。

其他两个隐患(顺带指出):① x * x + y * y 用 int 计算,坐标到 105 时 x*x 就溢出 —— 必须转 long long,这也是"一致性"之外的独立 bug;② d2() 被调用了两次(d2() != o.d2() 和 return d2() < o.d2()),每次比较都重算 4 次乘法,n = 2×105 时约 1.7×106 次比较就是近千万次乘法,可以把结果缓存成成员变量。

易错:① 只看到"排序结果看着还挺对"就以为没问题(vector + unique 和 set 会给出不同答案);② 认为 == 和 < 是"两件独立的事"(它们必须描述同一个等价关系);③ 修复时只改 == 让它迁就 <(更好的是反过来让 < 补全关键字,因为 < 承担了排序与去重的双重职责)。

📚 洛谷原题训练(12 题)

本组题目的共同点是"必须给自定义类型定义排序或判等规则"。建议每道题都先自己写一个结构体 / 类并重载 <,再用 sort 或 set 解决,体会"规则写进类里"的便利。

1
P1177
【模板】快速排序普及-
2
P3378
【模板】堆普及-
3
P1097
统计数字普及-
4
P4305
[JLOI2011] 不重复数字普及-
5
P3370
【模板】字符串哈希普及-
6
P3879
[TJOI2010] 阅读理解普及-
7
P5266
【深基17.例6】学籍管理入门
8
P1918
保龄球普及-
9
P1102
A-B 数对普及-
10
P2249
【深基13.例1】查找普及-
11
P3955
图书管理员普及-
12
P1631
序列合并普及/提高-