← 目录 第 12 / 60 节 全部 60 节

12 · 结构体与多关键字排序

用 struct 把多字段对象打包成可整体搬动的类型,用 cmp 把“总分 → 语文 → 学号”写成严格弱序,并搞清 sort 的左闭右开、greater、stable_sort 与 lambda 的分工。
⭐ 已学范围普及-预计 60 分钟structsortcmp 严格弱序
本节出现的新函数 / 新语法(先看懂再用)

前 11 节里,一个数组只装一种东西:全是成绩,或全是坐标。可真实题目里,一个学生同时有学号、语文、数学、英语、总分,而且要求按总分排、总分相同看语文、还相同看学号。裸数组排不动这种题——学号一个数组、总分一个数组,一排序就对不上号。结构体把多个字段打包成一个整体,排序时整包一起搬,字段之间的对应关系才不会散。

例题 · 奖学金洛谷 P1093普及-去洛谷做这题 ↗

输入第一行一个整数 n,表示学生人数(n 不超过 300,且至少有 5 人)。

接下来 n 行,每行三个整数(0~100),依次是语文、数学、英语成绩。学生按输入顺序编号为 1 到 n。

按总分从高到低排序;总分相同按语文从高到低;语文也相同按学号从小到大。

输出前 5 名,每行两个整数:学号、总分。

输入: 6 90 67 80 87 66 91 78 89 91 88 99 77 67 89 64 78 89 98 输出: 6 265 4 264 3 258 2 244 1 237

排序规则有三层。字段要一起搬、比较要按多个键——这是 struct 加 cmp 的典型场景。先看 6 张语法卡。

新函数拆解 · struct:把多个字段打包成一个类型
struct 名字 { 类型 字段1; 类型 字段2; }; // ← 这个分号不能漏
组成含义 / 格式要求
struct声明一个新类型。字段就是普通变量声明,可以是 int、double、string,甚至数组。
名字习惯首字母大写:Stu、Node。用法和 int 一样:Stu a;、Stu s[305];。
结尾 ;必须有。漏了会让后面几十行一起报 expected ';',错误行号离真正的坑很远。
最小可运行示例
示例
struct Stu {
    int id;
    int sum;
};                             // 分号不能漏

int main() {
    Stu a;                     // 像 int 一样定义变量
    a.id = 1;  a.sum = 265;
    Stu s[3];                  // s[0]、s[1]、s[2] 各是一个学生
    s[1].sum = 244;
    cout << a.id << " " << s[1].sum << "\n";
}
注意

示例省略 #include <bits/stdc++.h> 与 using namespace std;,每张卡片都照此补。

局部结构体变量不会自动初始化:Stu a; 之后 a.sum 是随机值,读之前必须先写。

新函数拆解 · sort:区间左闭右开
sort(首指针, 尾后指针); // 默认升序 sort(首指针, 尾后指针, 比较器); // 自定义顺序
组成含义 / 格式要求
首指针第一个元素的地址:a。
尾后指针最后一个元素下一个位置的地址。排 a[0..n−1] 就写 a + n。
比较器省略时按 < 升序;结构体数组必须自己传。
复杂度O(n log n)。n = 10⁶ 约 2×10⁷ 次比较,1 秒内跑完。
最小可运行示例
示例
int main() {
    int a[6] = {5, 2, 8, 1, 9, 3};
    sort(a, a + 6);                  // 排 a[0..5] → 1 2 3 5 8 9
    sort(a, a + 3);                  // 只排前 3 个
    sort(a, a + 6, greater<int>());  // 降序 → 9 8 5 3 2 1

    int b[6] = {5, 2, 8, 1, 9, 3};
    sort(b + 1, b + 6);              // 1 下标:排 b[1..5],b[0] 不动
}
注意

sort(a, a + n - 1) 只排了 n−1 个,最后一个不参与——最常见的差一位。

1 下标数组写成 sort(a + 1, a + n + 1),末尾是 + n + 1,别抄成 + n。

新函数拆解 · cmp:自定义比较器与严格弱序
bool cmp(const T &a, const T &b) { return a.键 < b.键; // 返回 true ⇒ a 排在 b 前面 }
组成含义 / 格式要求
返回值bool,只回答“a 该不该排在 b 前面”,不是返回差值,也不是返回“a 更小”。
const T &按引用传,避免每个元素被整包复制;const 保证比较时不改数据。
运算符只写严格不等 < 或 >,绝不能写 <=、>=。
严格弱序cmp(a,a) 必须为 false;cmp(a,b) 与 cmp(b,a) 不能同时为 true;必须可传递。
最小可运行示例
示例
bool cmpBig(int a, int b) { return a > b; }   // 降序:大的在前
bool bad(int a, int b) { return a >= b; }     // cmp(a,a) 竟返回 true

int main() {
    int a[5] = {3, 1, 4, 1, 5};
    sort(a, a + 5, cmpBig);                   // 5 4 3 1 1
    sort(a, a + 5, bad);                      // 可能直接 RE
}
注意

std::sort 的分区循环靠这三条假设决定指针往哪走。用 >= 时“等于 pivot 也算小”的指针停不下来,会一路越过数组末尾写内存,症状是 RE 而不是编译错误。

新函数拆解 · greater:标准库自带的降序比较器
sort(a, a + n, greater<类型>());
组成含义 / 格式要求
<类型>尖括号里写元素类型,必须和数组元素类型一致。
末尾 ()这对圆括号是“构造一个对象”。漏了就语法错误:greater<int> 只是类型,类型不能当值传。
等价greater<int>() 等价于 [](int x, int y) { return x > y; }。
最小可运行示例
示例
int main() {
    int a[5] = {3, 1, 4, 1, 5};
    sort(a, a + 5, greater<int>());       // 5 4 3 1 1
}
注意

同族的 less<int>() 就是 <,也正是 sort 省略比较器时的默认行为。

新函数拆解 · stable_sort:稳定排序
stable_sort(首, 尾后, 比较器); // 认为“相等”的元素保持原有先后
组成含义 / 格式要求
稳定比较器认为等价的两个元素,排序后仍按原来的顺序排列;sort 不保证这一点。
用法多关键字:从最次要的键排到最重要的键,每趟都用 stable_sort。
代价多花 O(n) 内存,常数略大;n ≤ 10⁵ 完全够用。
最小可运行示例
示例
// 假设已有例题里的 Stu s[] 与 n:目标 总分降序 → 语文降序 → 学号升序
sort(s, s + n, [](const Stu &a, const Stu &b) { return a.id < b.id; });
stable_sort(s, s + n, [](const Stu &a, const Stu &b) { return a.ch > b.ch; });
stable_sort(s, s + n, [](const Stu &a, const Stu &b) { return a.sum > b.sum; });
注意

顺序必须从次要到主要。反过来先排主键,后面几趟会把主键重新打乱。

第一趟按唯一键(如学号)排,用 sort 也不会错,因为根本没有并列。

新函数拆解 · lambda:就地写一个匿名比较器
sort(a, a + n, [](const T &x, const T &y) { return 条件; });
组成含义 / 格式要求
[]捕获列表:空表示不用外部变量;[&] 表示按引用用外部变量。
(参数)和普通函数形参一样,写 const T & 最省。
位置就地写在 sort 第三个参数处,不必在全局另起一个函数;整个 lambda 后用 ) 和 ; 收尾。
最小可运行示例
示例
struct Stu { int id, sum; };

int main() {
    Stu s[3] = {{1, 240}, {2, 265}, {3, 240}};
    sort(s, s + 3, [](const Stu &x, const Stu &y) {
        if (x.sum != y.sum) return x.sum > y.sum;   // 总分降序
        return x.id < y.id;                         // 学号升序兜底
    });
}
注意

lambda 里不能修改未被捕获的外部变量,编译会直接报错。

只在排序里用一次的比较器,用 lambda 比在全局起名字更清楚。

例题怎么想:三个优先级,写成三层 if

排序规则有三层:总分 → 语文 → 学号。写成三层 if,第一个不同的关键字就定胜负,后面的不看。

最容易忽略的是最后一层 return a.id < b.id;。它不是“顺便加一句”,而是把平局彻底消掉的兜底:学号唯一,任意两人都有确定先后。少了它,总分语文都相同的人会互相“不小于”,严格弱序被破坏。

完整代码
#include <bits/stdc++.h>
using namespace std;

struct Stu {
    int id;            // 原始下标:输入顺序就是学号,排序后靠它认人
    int ch, ma, en;    // 语文、数学、英语
    int sum;           // 总分:输入时就算好,别在 cmp 里反复加
};

// 返回 true 表示 a 应排在 b 前面
bool cmp(const Stu &a, const Stu &b) {
    if (a.sum != b.sum) return a.sum > b.sum;   // 第一关键字:总分降序
    if (a.ch  != b.ch ) return a.ch  > b.ch;    // 第二关键字:语文降序
    return a.id < b.id;                         // 第三关键字:学号升序
}

int main() {
    int n;
    cin >> n;
    Stu s[305];
    for (int i = 0; i < n; ++i) {
        s[i].id = i + 1;                        // 先记下原始下标,排完序就找不回来了
        cin >> s[i].ch >> s[i].ma >> s[i].en;
        s[i].sum = s[i].ch + s[i].ma + s[i].en;
    }

    sort(s, s + n, cmp);                        // 左闭右开:覆盖 s[0] ~ s[n-1]

    for (int i = 0; i < 5 && i < n; ++i)
        cout << s[i].id << " " << s[i].sum << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
struct Stu { int id; int ch, ma, en; int sum; };5 个字段打成一个包。排序搬动的是整个 Stu,学号和总分的对应关系不会散。
bool cmp(const Stu &a, const Stu &b)const 引用:n = 300 时省下数百次结构体拷贝,也防止比较过程改坏数据。
if (a.sum != b.sum) return a.sum > b.sum;第一层:总分不同就按总分降序定胜负,后两行不执行。先判“不同”再比较,不能反。
if (a.ch != b.ch) return a.ch > b.ch;走到这里说明总分相同,改用语文继续分。
return a.id < b.id;最后一层必须给出“不平局”的判据。学号唯一,正好当兜底。
s[i].id = i + 1;在排序之前把原始下标存进结构体。排完序 s[k] 是第 k 名,只有 id 能告诉你他是谁。
s[i].sum = ...;总分在输入时算好。放进 cmp 里每次现算,一次排序要多做 O(n log n) 次加法。
sort(s, s + n, cmp);左闭右开 [s+0, s+n),正好覆盖 s[0]~s[n−1]。写成 s+n−1 会漏掉最后一个。
同一个排序逻辑的两种载体

A 与 C 结果完全一样。逻辑复杂、想单独验算就用独立 cmp;关键字多到写不清优先级就逐层 stable_sort,每层只关心一个键。lambda 只是 A 的就地缩写,见上一张卡片。

两种写法
// A:独立 cmp 函数(本页用法)—— 定义见上面例题
sort(s, s + n, cmp);

// C:逐层 stable_sort —— 从最次要的键排到最重要的键
sort(s, s + n, [](const Stu &a, const Stu &b) { return a.id < b.id; });          // 最次要
stable_sort(s, s + n, [](const Stu &a, const Stu &b) { return a.ch > b.ch; });   // 次要
stable_sort(s, s + n, [](const Stu &a, const Stu &b) { return a.sum > b.sum; }); // 主要

C 的代价是排序 k 遍、常数乘 k;好处是每层表达式只有一两个字符,几乎不可能写错优先级。

这一节最容易犯的 5 个错

cmp 里用 >= 或 <=。cmp(a,a) 返回 true,严格弱序被破坏,洛谷上的典型症状是 RE(Segmentation fault,常挂在中段测试点),而不是提示你写错了。改成 > 立刻通过。

漏写结构体定义结尾的 };,编译器在下一个函数处报 expected ';' before ...,报错行号和真正的坑差几十行。

sort(s, s + n - 1, cmp) 漏排最后一个元素:最大(或最小)的那个人永远上不去,表现为“答案总是差一名”的 WA。

greater<int> 少写末尾的圆括号。sort(a, a+n, greater<int>) 编译错误:类型不是表达式。

只写了总分一个关键字。测试点里专门有“总分相同、语文不同”的构造,单关键字版能过大部分点,剩下的 WA 很不明显——因为前几名常常恰好没并列。

自己动手复现

不看本页,把 P1093 敲一遍并提交。

再做三组破坏性实验:① 把 cmp 里两个 > 全改成 <,看输出是否整体倒过来;② 把最后一层改成 return false;,构造三个总分都是 240 的学生,看输出顺序是否还受规则约束(它可能恰好没变——这正是未定义行为的可怕之处);③ 故意写成 return a.sum >= b.sum;,多交几次观察是否偶发 RE。

改一改:把 cmp 换成等价的 lambda;再换成方案 C 的三趟 stable_sort,确认两种写法的输出逐字节相同。

数学补给 · 全序、偏序与严格弱序

排序器要的数学条件很明确:给定任意两个元素,它得能判断“谁先”。

全序:任意两个元素都可比且无并列。按 总分+语文+学号 排完,任意两人次序唯一——这才是全序,结果确定、可复现。偏序只保证可比的那部分有次序,允许并列。

严格弱序是 std::sort 的全部要求,翻成 cmp 就是三条公理:

写 >= 违反的是第一条。sort 分区里有一句“只要还小就继续前进”,而“等于 pivot 也算小”会让它永远停不下来,指针冲出数组末尾——所以症状是 RE 而不是 WA。

字典序:把 k 个关键字看成 k 元组 (k₁, k₂, …, k_k),从第一个分量开始比,遇到第一个不同分量就定胜负。本节的 cmp 就是字典序的手写展开;sort 排 pair、string 用的也是它。它一定是严格弱序,因为元组比较本身是全序;唯一例外是元组完全相等,此时 cmp 返回 false,正好满足非自反——这就是最后一层要留一个唯一键的原因。

稳定排序与基数排序:对 k 个关键字,从最次要的键开始做 k 趟稳定排序,结果等价于一次多关键字排序。归纳证明:第 i 趟结束时序列按“前 i 个次要键”有序;第 i+1 趟稳定,相等元素保留上一趟次序,次要键信息被完整带过去。这就是 LSD 基数排序的正确性来源。

举一反三 · 想清楚这些再往下走
  1. 两个人总分、语文都相同时,cmp 最后写 a.id < b.id; 与写 return false; 有什么区别?后者破坏了哪条公理?
  2. 只按总分排序、不加学号兜底时,两个 250 分的同学谁在前?这个答案由什么决定?为什么不能指望它?
  3. 字段有 6 个关键字时,你写 6 层 if 还是逐层 stable_sort?两种写法各要多少次比较、几趟排序?
  4. 把 struct 换成 pair<int,int>,两关键字排序怎么写?三关键字呢?什么时候该退回 struct?
  5. “结构体里存原始下标”这件事还能用在哪?(输出原名次、离散化、排序后映射回原数组、按原顺序输出答案)
  6. 若题目要求“总分相同的人按输入顺序输出”,用 sort+下标 与 stable_sort 哪个更短?哪个更不容易写错?
自测:能全部回答再进下一节
挑战题 · 生日
洛谷 P1104普及-去洛谷做这题 ↗

有 n 个人,每人给出姓名和出生年、月、日,请按年龄从大到小输出他们的姓名。

如果两个人的生日完全相同,则后输入的那个人排在前面。

输入第一行一个整数 n(n ≤ 100),接下来 n 行,每行一个字符串和三个整数:姓名、年、月、日。输出 n 行,每行一个姓名。

引导性提示(先只看这里)

年龄大就是出生早。先把“年龄从大到小”翻译成“日期从小到大”,比较方向就不会写反。

年、月、日是分层比较:年份不同时,月和日根本不用看。

“生日相同的后输入排前面”说明光有日期不够——把输入顺序也变成一个能比较的数字存起来。

想看思路了?点开完整思路

结构体存 name、y、m、d、idx,idx 按输入顺序编号。

cmp:y 小的在前;y 相同比 m;m 相同比 d;三者全相同则 idx 大的在前。

idx 唯一,所以任意两人都有确定次序,cmp 永远不会对两个不同元素同时返回 false,严格弱序成立,sort 安全。

按排序后的顺序逐个输出 name。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

struct Per {
    string name;
    int y, m, d, idx;       // idx:输入顺序,用来打破生日相同的平局
};

bool cmp(const Per &a, const Per &b) {
    if (a.y != b.y) return a.y < b.y;    // 出生早 = 年龄大
    if (a.m != b.m) return a.m < b.m;
    if (a.d != b.d) return a.d < b.d;
    return a.idx > b.idx;                // 生日相同:后输入(idx 大)的排前面
}

int main() {
    int n;
    cin >> n;
    Per p[105];
    for (int i = 0; i < n; ++i) {
        cin >> p[i].name >> p[i].y >> p[i].m >> p[i].d;
        p[i].idx = i;                    // 先记下原始顺序,排完序就找不回来了
    }

    sort(p, p + n, cmp);                 // 左闭右开

    for (int i = 0; i < n; ++i) cout << p[i].name << "\n";
    return 0;
}
关键片段拆解
片段作用
struct Per { string name; int y, m, d, idx; };姓名和日期放在一起,排序时整包搬,姓名不会和日期错位。
if (a.y != b.y) return a.y < b.y;年份小 = 出生早 = 年龄大,所以是 <。顺着“年龄大”写成 > 就整体反了。
if (a.m != b.m) return a.m < b.m;同年才比月;月小 = 同一年里出生更早。
return a.idx > b.idx;唯一键兜底:生日完全相同时,后输入的 idx 更大,要排前面,所以用 >。
sort(p, p + n, cmp);覆盖 p[0..n−1]。cmp 对任意两个不同元素都有确定结论,不会出现“互相都不小于”。
这题最容易错的地方
  • 年份比较写成 a.y > b.y(顺着“年龄大”的方向想),输出整个反了。
  • 忘了 idx 这一层,收尾写 return false;。生日相同的两人次序不受控,且严格弱序被破坏,可能 RE。
  • 用 scanf("%s", s.name) 读 string——string 不是字符数组,会崩。要么 cin >>,要么把字段改成 char name[25]。
  • 结构体数组开成 Per p[100] 而 n 正好 100,越界写坏别的变量,症状时好时坏。
延伸阅读 / 参考