ACM 一个月学习计划 · DAY 12

Day 12 · 排序:sort 与它的朋友们

排序是竞赛里最常用的工具之一。今天把 sort、自定义 cmp、去重、冒泡与逆序对一次讲透

今日目标:熟练使用 sort 及其自定义比较函数,掌握去重与逆序对
预计用时:约 3 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1059 / P1012 / P1781 / P1116
← Day 11 · 结构体:把相关的数据打包

⏱今日时间安排

0 - 25 minsort 的三种基本用法 + 左闭右开的坑(第 1 节)
25 - 55 min自定义 cmp 与「严格弱序」这条铁律(第 2 节)
55 - 80 min去重三连 sort → unique → erase(第 3 节)
80 - 105 min冒泡排序与逆序对(第 4 节)——P1116 的本体
105 - 200 min做 4 道题,每题限时 25 分钟
200 - 220 min对照本文题解 + 做自测
💡 和昨天内容的衔接

昨天你用 sort 和 cmp 排了学生(结构体)。今天要把它彻底讲清楚: sort 到底怎么用、cmp 有什么必须遵守的规矩、去重怎么和排序配合。

另外今天还会打开「排序的黑盒子」——学一遍冒泡排序。 你可能会问:既然有 sort,为什么还要学又慢又笨的冒泡? 因为 P1116 车厢重组要的不是「排好序」,而是「交换了多少次」。 而「冒泡排序的交换次数」正好等于一个非常重要的量——逆序对个数。

1std::sort 的三种基本用法

先说结论:一句话搞定排序

#include <algorithm>      // ★ sort 住在这个头文件里,别忘了

sort(a, a + n);                          // ① 数组升序
sort(a, a + n, greater<int>());          // ② 数组降序
sort(a, a + n, cmp);                     // ③ 按你自己的规则排

如果用 vector:

sort(v.begin(), v.end());                // vector 升序
sort(v.begin(), v.end(), greater<int>()); // vector 降序
🎯 为什么用 sort 而不是自己写

标准库里的 sort 是经过极限优化的混合排序(快排 + 堆排 + 插入排序), 复杂度 O(n log n)。你自己手写的快排几乎不可能比它快,而且容易写错。

n = 10⁶ 的时候,O(n log n) 大约是 2×10⁷ 次操作,跑起来只要零点几秒。 而 O(n²) 是 10¹² 次,要跑好几分钟——这就是为什么「会排序」是硬门槛。

范围为什么写成 sort(a, a + n)

因为 C++ 标准库的区间约定是左闭右开 [first, last): 包含 first 指的元素,不包含 last 指的元素。

数据存放方式正确的 sort 写法说明
a[0] ~ a[n-1]sort(a, a + n)右端点 = 起始 + n
a[1] ~ a[n]sort(a + 1, a + n + 1)右端点还要再 +1
vector v 全部sort(v.begin(), v.end())end() 指向末尾的「下一个」
⚠️ 最经典的 off-by-one

数据存在 a[1] ~ a[n],却写成 sort(a + 1, a + n)—— 最后一个元素根本没参与排序。

这种错误特别阴险:样例可能照样过,因为样例小、最后一个恰好不用动;到大测试点才突然 WA。

校验方法:sort 的右端点写成 a + 起始下标 + 元素个数,永远错不了。

greater<int>() 是什么东西

它是标准库给你的一个「现成的比较规则」,意思是「大的排前面」。 加一对括号是因为它是个临时对象,要传一个实例给 sort。

sort(a, a + n);                  // 升序:1 2 3 4 5
sort(a, a + n, greater<int>());  // 降序:5 4 3 2 1
💡 忘了 () 会怎样

写 greater<int> 而不加括号,编译器会报一大串「模板参数推导失败」之类的错。 记住口诀:greater 后面一定要带一对小括号。

同理,排序 long long 或 double 时要用 greater<long long>()、 greater<double>()——尖括号里写元素的类型。

结构体/字符串不能直接 sort

sort 必须知道「谁该排在前面」。对 int、double、 string(字典序)这些类型,C++ 内置了默认规则; 但两个自定义结构体谁大谁小,编译器不知道,必须你写 cmp——这就是第 2 节。

2自定义比较函数 cmp

cmp 的唯一规则

🎯 一句话记牢

cmp(x, y) 返回 true,意思就是「x 应该排在 y 前面」。

所以:

· return x < y; → 小的在前 → 升序
· return x > y; → 大的在前 → 降序

不要背「大于还是小于」,每次写的时候问自己:「我希望谁排在前面?」

多关键字:一层一层往下筛

bool cmp(const Student &x, const Student &y){
    if(x.total != y.total) return x.total > y.total;         // ① 总分降序
    if(x.chinese != y.chinese) return x.chinese > y.chinese;  // ② 语文降序
    return x.id < y.id;                                      // ③ 学号升序
}

每一行的含义是「这一项不一样,就由这一项说了算」,一样就往下走。 这就是昨天 P1093 的写法,今天要补充的是:为什么第一行必须用 != 而不能用 >。

如果写成 if(x.total > y.total) return true;, 那么两人总分相等时不会返回,会直接落到第 ③ 行, 于是学号小的会被判为排在前面,哪怕总分更低——名次直接错乱。

铁律:cmp 必须满足「严格弱序」

听起来很学术,其实只有两句人话:

规则人话例子
cmp(x, x) 必须是 false自己不能排在「自己」前面❌ x <= y 会让 cmp(x,x) 为 true
不能出现「循环谁在前面」a 在 b 前、b 在 c 前 ⇒ a 必在 c 前我们写的多关键字 cmp 天然满足
🚨 绝对不能写 <= 或 >=
bool cmp(int x, int y){
    return x <= y;      // ❌ 大错!相等时也返回 true
}

标准规定 sort 的 cmp 必须是严格小于(strict)。你写 <= 就破坏了约定, 行为变成未定义——排序库内部会做出错误的分区假设, 可能越界访问内存直接崩溃(RE),也可能排出来的顺序是错的。

我实际的测试结果(老实说):在本机 MinGW g++ 6.3.0 / -O2 下, 我用 <= 的 cmp 排 20 万个随机数,程序没有崩、结果也恰好是升序的。 但这不代表这样写是安全的——它是未定义行为,换个编译器、换个标准库、换份数据就可能崩或排错。 计划书里写「会 RE」是一种风险警告,而不是保证。

结论:永远写严格的 < 或 >,一个等号都不要多。

💡 cmp 的参数记得用 const T &

传引用避免复制整个结构体,加 const 表示「只读不改」。 数据量大时这个细节能省下可观的时间。昨天的 const Student &x 就是这个道理。

3去重三连:sort → unique → erase

思路

要把一堆数「去重 + 排序」,标准做法只有三步:

sort(a, a + n);                              // ① 先排序,让相同的数挨在一起
int m = unique(a, a + n) - a;                // ② 去掉相邻的重复项,m 是剩余个数
// 如果用的是 vector,还要第三步:
v.erase(unique(v.begin(), v.end()), v.end()); // ③ 真正删掉多余的尾巴
🎯 unique 到底干了什么

它不是「删除」,而是「搬家」:把不重复的元素依次挪到数组前面,重复的丢到后面去。 返回值是新的「逻辑末尾」的指针,所以 unique(...) - a 就是去重后的元素个数。

对数组来说,后面的垃圾数据你不去管就行了(用 m 当新的长度)。 对 vector 来说,size() 没变,所以要 erase 把尾巴真删掉。

⚠️ unique 只能去掉「相邻」的重复

unique 只比较相邻两个元素。如果数组是 3 1 3 1, 它什么都删不掉(3 和 1 不相邻)。

所以顺序绝对不能反:必须先 sort,再 unique。排完序相同的数一定挨在一起,unique 才能一网打尽。

另一条路:桶去重(Day 6 的老朋友)

如果题目里的数值范围很小(比如 1 ~ 1000),用标记桶更简单,连排序都省了:

bool vis[1005] = {false};
for(int i = 0; i < n; i++){ int x; cin >> x; vis[x] = true; }

// 从小到大扫一遍桶,有旗子的就是出现过的数,顺便天然有序
for(int v = 1; v <= 1000; v++)
    if(vis[v]) cout << v << " ";
方法复杂度适用场景
sort + uniqueO(n log n)值域很大也能用(通用首选)
标记桶O(n + 值域)值域小的时候更简单更快

今天的 P1059 两种写法都行,我会把两种代码都给你,而且都实测过。

4冒泡排序与逆序对(P1116 的本体)

冒泡排序:最朴素的排序

核心想法就一句话:从左到右把相邻两个数比一比,前面的大就交换,一轮下来最大的那个就「冒」到了最右边。

拿 4 3 2 1 走一遍第一轮:

比较操作数组变成
4 和 34 > 3,交换3 4 2 1
4 和 24 > 2,交换3 2 4 1
4 和 14 > 1,交换3 2 1 4
第一轮结束最大的 4 冒到最右,共交换 3 次3 2 1 | 4

然后对前面 3 2 1 重复,再对 2 1 重复……

标准模板

for(int i = 0; i < n - 1; i++){          // 一共做 n-1 轮
    for(int j = 0; j < n - 1 - i; j++){  // 后面 i 个已经排好,不用再管
        if(a[j] > a[j+1]){               // 相邻逆序
            swap(a[j], a[j+1]);
        }
    }
}
排序算法复杂度特点
冒泡排序O(n²)慢,但能顺便数出「交换次数」
std::sortO(n log n)快,竞赛首选,但数不出交换次数

n ≤ 1000 的时候 O(n²) 是 10⁶ 次操作,完全没问题,所以今天的 P1116 可以直接用冒泡。

逆序对:今天最重要的概念

🎯 定义

在一串数里,如果前面一个数比后面一个数大,这两数就构成一对逆序对。

例子:4 3 2 1 的逆序对有:
(4,3) (4,2) (4,1) (3,2) (3,1) (2,1) —— 正好 6 对。

⚠️ 核心结论:冒泡的交换次数 = 逆序对个数

为什么?看冒泡的内层循环:每发现一对「相邻且前大后小」,就交换一次。 而每一次交换,恰好消掉一对相邻逆序,并且让整个数组的逆序对总数减少 1; 没有交换的时候,说明已经没有相邻逆序了,而此时数组必定已经有序(逆序对为 0)。

所以:数组有序 ⟺ 逆序对为 0 ⟺ 不再有交换, 交换次数从头到尾正好把逆序对一对一对清空,因此两者相等。

我实测验证过:写了两个程序,一个用冒泡数交换次数,一个用归并排序数逆序对, 对 12 组随机数据(n 从 10 到 65)跑出来结果完全一致,比如 n=65 那组两个都是 1043,n=58 那组都是 692。

所以 P1116 车厢重组在问什么

题目里的「桥旋转 180 度交换相邻两节车厢」,就是冒泡里的 swap(a[j], a[j+1])。 问「最少多少步」——因为每次只能交换相邻车厢,这个「最少步数」正好就是逆序对个数。

💡 想进阶的话:归并排序数逆序对

n ≤ 1000 用冒泡就够。但如果 n 变成 10⁵,O(n²) 就挂了,这时要用归并排序在 O(n log n) 内数出逆序对: 归并的时候,如果右半段的 a[j] 小于左半段的 a[i], 那么左半段剩下的 mid - i + 1 个数全都比 a[j] 大,一次就贡献这么多逆序对。

这段代码我也写了并编译跑过,附在 P1116 的题解里,当作课外加餐。

5四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译并跑过官方样例,输出是你看到的真实结果。

P1059 [NOIP 2006 普及组] 明明的随机数 普及- · 排序 + 去重

题意:明明生成了 N 个 1 到 1000 之间的随机整数,其中重复的只保留一个。 要求先输出去重后有多少个不同的数(M),再把这些数从小到大排好序输出。

⚠️ 数据范围 / 关键约束

N ≤ 100,每个数在 1 ~ 1000 之间(题面原文是「生成了 N 个 1 到 1000 之间的随机整数 (N ≤ 100)」, 在说明里只标注了「NOIP 2006 普及组 第一题」,没有额外的分档数据表)。

数值范围这么小,说明桶做法完全可行——这正是本题最省事的解法。

思路

题目要求的是「去重 + 排序」两件事,而这两件事有天然的联系:排完序之后,相同的数一定挨在一起, 于是「去重」就退化成了「删掉相邻的重复项」。

两种写法:

  1. 通用写法:sort 排序 → unique 去重相邻重复项 → 得到新的长度 m。
  2. 桶写法:开一个 vis[1005],读到哪个数就给哪个桶插旗; 最后从 1 扫到 1000,有旗子的数字天然就是升序且不重复的。
样例输入
10
20 40 32 67 40 20 89 300 400 15
样例输出
8
15 20 32 40 67 89 300 400

10 个数里 20 和 40 各出现了两次,去重后剩 8 个。

完整代码(写法一:sort + unique)

#include <iostream>
#include <algorithm>
using namespace std;

int a[105];

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; i++) cin >> a[i];

    sort(a, a + n);                         // ① 排序

    // ② 去重:unique 把不重复的元素挪到前面,返回「新区间末尾」的指针
    int m = unique(a, a + n) - a;

    cout << m << endl;                      // ③ 先输出去重后的个数
    for(int i = 0; i < m; i++){
        if(i) cout << " ";                  // 数字之间一个空格,末尾不多打
        cout << a[i];
    }
    cout << endl;
    return 0;
}

完整代码(写法二:标记桶)

#include <iostream>
using namespace std;

int a[105];
bool vis[1005];     // 桶:vis[v] 标记数字 v 出现过

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; i++){
        cin >> a[i];
        vis[a[i]] = true;       // 出现过的数,在它自己的桶里插一面旗
    }

    int m = 0;
    for(int v = 1; v <= 1000; v++){
        if(vis[v]) m++;         // 从小到大扫桶,顺便数出有多少个不同的数
    }

    cout << m << endl;
    int printed = 0;
    for(int v = 1; v <= 1000; v++){
        if(vis[v]){
            if(printed) cout << " ";
            cout << v;
            printed++;
        }
    }
    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 → 两种写法都输出:

8
15 20 32 40 67 89 300 400

与样例输出完全一致。

自造边界测试「5 个数全是 7」→ 两种写法都输出 1 / 7 正确

易错点

  • 先 unique 再 sort:unique 只删相邻重复项,没排序时重复的数不挨着,一个也删不掉。顺序不能反。
  • 忘了输出 M:题目要输出两行,第一行是不同数的个数,很容易只输出排序结果。
  • 输出末尾多打空格:洛谷一般能容忍行末空格,但用 if(i) cout << " "; 的写法更规范。
  • 桶写法里桶的大小要开到 1000 以上(数值上限 1000,所以 vis[1005]), 开成 vis[1000] 在读入 1000 时会越界。
P1012 [NOIP 1998 提高组] 拼数 普及/提高- · 自定义比较(经典易错)

题意:给出 n 个正整数,把它们首尾相接拼成一排,组成一个最大的整数,输出这个整数。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 20,1 ≤ aᵢ ≤ 10⁹。

注意两件事:① 每个数最大 10 亿(10 位数),n 最多 20 个, 拼起来最长有 200 位左右——远超 long long 的范围,必须用字符串处理。 ② n 很小,说明这题考的不是算法效率,而是比较规则想不想得对。

思路:这题的关键是「两个数谁该排前面」

先说一个错误但非常诱人的想法:把数字当字符串,按字典序从大到小排,然后拼起来。

它对样例居然是对的!但它是错的。看这个反例:

输入字典序降序排(❌ 错)正确答案(✅)
3 31"31" > "3" → 拼成 313331 > 313 → 拼成 331
9 91"91" > "9" → 拼成 919991 > 919 → 拼成 991

(上面两组我都真的编译运行验证过:我的正确程序输出 331 和 991; 专门写了一个「字典序降序」的错误版本,输出的正是 313 和 919。)

🎯 正确的比较规则:拼接比较

不要单独看 a 和 b,而要看「a 拼 b」和「b 拼 a」哪个更大:

如果 a + b > b + a,那么 a 应该排在 b 前面

回到 {3, 31}:"3"+"31" = "331","31"+"3" = "313", 因为 "331" > "313",所以 3 排在 31 前面,拼出来是 331。✅

再看 {9, 91}:"9"+"91" = "991","91"+"9" = "919", 991 > 919,所以 9 排在 91 前面,得到 991。✅

为什么这样比是对的

三个理由,说服你自己:

  1. a+b 和 b+a 的长度一定相同(都是「a 的长度 + b 的长度」)。 长度相同的字符串比大小,就是纯粹的数值比大小,不用考虑位数问题。这一点非常关键—— 如果长度不同,「位数多的更大」和「字典序更大」就不一定一致了。
  2. 它回答的正是题目要问的问题。题目要总长度最大,而在「a 在前」和「b 在前」之间二选一时, 局部最优就是选拼接串更大的那种排法。当这个「两两比较」规则被 sort 全局应用之后,得到的整体拼接串就是最大的。
  3. 它满足严格弱序:当 a+b == b+a 时(比如 a=b,或者 {"12","1212"} 这种), cmp 两边都返回 false,视为「等价」。所以它可以安全地交给 sort。
💡 数据保证 aᵢ ≥ 1,所以不用管前导零

题面给了 1 ≤ aᵢ,也就是说输入里不会出现 0。 如果真的有 0(比如 0 1),拼接结果可能是 "01" 这种不合法的整数形式, 那时才需要特判。这题不用操心。

样例输入 1
3
13 312 343
样例输出 1
34331213
样例输入 2
4
7 13 4 246
样例输出 2
7424613

样例 1:343 > 312 > 13(两两拼接比较后),拼成 34331213。
样例 2:顺序是 7、4、246、13 → 拼成 7424613。

完整代码

#include <iostream>
#include <string>
#include <algorithm>
using namespace std;

string s[25];

// ★ 核心比较规则:如果 a 接在 b 前面比 b 接在 a 前面更大,a 就排在前面
bool cmp(const string &a, const string &b){
    return a + b > b + a;
}

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; i++) cin >> s[i];

    sort(s, s + n, cmp);

    for(int i = 0; i < n; i++) cout << s[i];
    cout << endl;
    return 0;
}

读入直接读成 string,不做任何转换——因为最后要的就是拼接出来的字符串, 中间用不用 int 根本没意义,而且 int 也存不下 10 位数拼起来的结果。

✅ 实测结果

官方样例 1 3 / 13 312 343 → 输出 34331213 完全一致

官方样例 2 4 / 7 13 4 246 → 输出 7424613 完全一致

关键反例 2 / 3 31 → 输出 331 正确(朴素字典序降序会输出 313,错)

关键反例 2 / 9 91 → 输出 991 正确(朴素字典序降序会输出 919,错)

⚠️ 一个非常重要的实测发现:我专门写了一个「按字典序降序」的错误版本, 拿它去跑两个官方样例,竟然都输出正确(34331213、7424613 全对); 只有在我自造的反例 {3,31}、{9,91} 上才露出马脚(输出 313、919)。

结论:样例全过 ≠ 代码正确。P1012 是「样例骗人」的典型案例,比较规则必须从原理上想清楚。

易错点

  • 用 int / long long 去拼:20 个数各 10 位,拼起来最多 200 位, 什么整数类型都存不下,必须用 string。
  • cmp 写成 a > b(字典序降序):样例能过,隐藏数据必挂,见上面的反例。
  • cmp 写成 a + b >= b + a:多了个等号,破坏严格弱序,属于未定义行为, 可能崩也可能排错。必须用严格的 >。
  • cmp 里反复做字符串拼接在数据量大时会有开销,但本题 n ≤ 20,完全无所谓。
  • 输出是一个整数(没有空格、没有换行分组),别在中间加空格——加了就变成另一个东西了。
P1781 宇宙总统 普及- · 大数比较(长度 + 字典序)

题意:n 个候选人,每人有一个票数,票数可能非常大(最多 100 位)。 输出票数最多的那个人的号数(第几行输入的),再输出他的票数。保证没有并列。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 20;票数可能大到 100 位数字;保证不存在票数相同的候选人。

100 位是什么概念?long long 最多存 19 位。所以只能把票数当字符串读进来。

思路:大数比大小,两步走

🎯 大数比较口诀:先比位数,位数相同再比字典序

第一步:谁长谁大。一个数的位数多,它一定更大 (因为两个数都没有前导零:5 位数最小是 10000,4 位数最大是 9999,10000 > 9999 恒成立)。

第二步:一样长就按字典序比。位数相同时,字符串的字典序比较等价于数值比较—— 因为逐位比较就是「从最高位开始比」,第一位谁大谁就大。而 string 的 > 正是逐字符比较 ASCII 码,数字字符 '0'~'9' 的 ASCII 也是递增的,所以完全对得上。

实现上用「打擂台」比排序更简单,毕竟 n 只有 20:

  1. 读第一个候选人,把他当擂主(记住他的字符串和号数)。
  2. 后面每个人来挑战:先比长度,长度相同再比字典序,赢了就换擂主。
  3. 最后输出擂主的号数和票数。
💡 号数别丢

又是昨天那个道理:要输出「第几个人」,就必须在扫描时把号数一直带在身边。 这里用一个 bestId 变量跟着擂主字符串一起更新就行了。

样例输入
5
98765
12365
87954
1022356
985678
样例输出
4
1022356

5 个票数里,1022356 是唯一一个 7 位数,其他都是 5 位或 6 位数,所以位数最多的一定最大 → 第 4 个人当选。这题样例本身就体现了「先比长度」的重要性:985678(6 位)比 98765(5 位)大, 但比 1022356(7 位)小。

完整代码

#include <iostream>
#include <string>
using namespace std;

int main(){
    int n;
    cin >> n;

    string best = "";       // 目前票数最多的那个数
    int bestId = 0;         // 它的号数

    for(int i = 1; i <= n; i++){
        string s;
        cin >> s;

        bool better = false;
        if(bestId == 0){
            better = true;                                  // 第一个候选人,直接当擂主
        }else if(s.size() != best.size()){
            better = (s.size() > best.size());              // ① 位数多的数一定更大
        }else{
            better = (s > best);                            // ② 位数相同,用字典序比
        }

        if(better){
            best = s;
            bestId = i;
        }
    }

    cout << bestId << endl;
    cout << best << endl;
    return 0;
}
✅ 实测结果

官方样例 → 输出 4 / 1022356 完全一致

自造大数据测试(3 个候选人:一个 99 位数 999…9、一个 100 位数 999…98、一个 12345) → 输出 2 / 那个 100 位数 正确

这个测试说明:100 位数字用 string 存完全没问题, 位数多的那个(100 位)确实更大,程序选出了第 2 个候选人。

「先比长度」这一步到底有多必要?我专门写了个只比字典序的错误版本做了对照:

  • 错误版本跑官方样例 → 输出 1 / 98765 是错的(正确答案是 4 / 1022356)。 因为 '9' 的 ASCII 码比 '1' 大,字典序认为 98765 > 1022356。
  • 换一组 2 / 9 / 10:正确版输出 2 / 10; 错误版输出 1 / 9。字典序认为 "9" > "10",但数值上 9 < 10。

结论:大数比大小,「先比长度」绝对不能省。(这题比 P1012 友好——错误做法连样例都过不了, 而不是像 P1012 那样样例全过、隐藏数据才挂。)

易错点

  • 用 long long 读票数:100 位数字直接溢出,读进来就是垃圾值。必须用 string。
  • 只按字典序比、忘了先比长度:"9" 会被判为大于 "10",答案就错了。
  • 把号数搞成 0-based:输出要求的是「第一个候选人到第 n 个候选人」,第一行是 1 不是 0。
  • 并列怎么办:题面保证不存在票数相同的候选人,所以不用处理。 如果真遇到,用严格大于(better 只在严格更大时为 true)就会保留靠前的那位。
P1116 车厢重组 普及- · 逆序对 / 冒泡交换次数

题意:一条桥最多容纳两节车厢,转 180 度就能交换相邻两节车厢。 要用这个操作把车厢按编号从小到大排好,问最少要转多少次。

⚠️ 数据范围 / 关键约束

车厢总数 N ≤ 1000,N 个数互不相同。

还有一个藏在输入格式里的坑:题面特意注明 「实际上数据中并不都在同一行,有可能分行输入」。

所以如果你用 getline 按行读、或者假设「第二行正好有 N 个数」,就会翻车。 用 cin >> a[i] 逐个读则完全免疫——因为 cin 会 自动跳过空格和换行,根本不在乎数据是怎么分行的。这也是我一直推荐用 cin >> 读数字的原因之一。

思路:把「旋转」翻译成「相邻交换」

题目绕了一大圈,本质就是一句:你有一个操作,可以交换数组里相邻的两个元素,问最少几次能让数组升序。

而「最少次数」这个问题的答案非常干净:

🎯 结论:最少步数 = 逆序对个数

下界:每次相邻交换最多只能消掉 1 个逆序对,所以步数 ≥ 逆序对个数。

达成:冒泡排序每次发现相邻逆序就交换,恰好每次都消掉 1 个,最终把逆序对清零,步数 = 逆序对个数。

两边一夹:最少步数 = 逆序对个数 = 冒泡排序的交换次数。

所以这题的代码骨架就是「冒泡排序 + 一个计数器」。N ≤ 1000 时 O(N²) 是 10⁶ 次操作,轻松通过。

样例输入
4
4 3 2 1
样例输出
6

4 3 2 1 的逆序对:(4,3)(4,2)(4,1)(3,2)(3,1)(2,1),共 6 对 → 需要 6 次旋转。也可以手数:第一轮 3 次交换、第二轮 2 次、第三轮 1 次,3+2+1 = 6。

完整代码(冒泡,最简单)

#include <iostream>
#include <algorithm>
using namespace std;

int a[1005];

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; i++) cin >> a[i];   // cin 自动跳过换行,分行输入也不怕

    int cnt = 0;
    // 冒泡排序:每一轮把当前最大的车厢「冒」到最右边
    for(int i = 0; i < n - 1; i++){
        for(int j = 0; j < n - 1 - i; j++){
            if(a[j] > a[j+1]){          // 相邻两节逆序,必须旋转一次
                swap(a[j], a[j+1]);
                cnt++;                  // 每交换一次记一步
            }
        }
    }

    cout << cnt << endl;
    return 0;
}

进阶版:归并排序数逆序对(O(N log N))

如果 N 变成 10⁵,冒泡的 O(N²) 就超时了。这时用归并排序,在合并两段的时候顺手把逆序对统计出来:

#include <cstdio>
#include <iostream>
using namespace std;

int a[1005], tmp[1005];
long long cnt = 0;          // 逆序对个数

// 归并排序:顺便统计跨越左右两半的逆序对
void mergeSort(int l, int r){
    if(l >= r) return;
    int mid = (l + r) / 2;
    mergeSort(l, mid);
    mergeSort(mid + 1, r);

    int i = l, j = mid + 1, k = l;
    while(i <= mid && j <= r){
        if(a[i] <= a[j]){
            tmp[k++] = a[i++];
        }else{
            tmp[k++] = a[j++];
            cnt += (mid - i + 1);   // 左半段剩下的 i..mid 全都比 a[j] 大
        }
    }
    while(i <= mid) tmp[k++] = a[i++];
    while(j <= r)   tmp[k++] = a[j++];
    for(int t = l; t <= r; t++) a[t] = tmp[t];
}

int main(){
    int n;
    scanf("%d", &n);
    for(int i = 0; i < n; i++) scanf("%d", &a[i]);
    mergeSort(0, n - 1);
    cout << cnt << endl;
    return 0;
}
💡 这版代码的 key 就一行

合并时如果右半段的 a[j] 比左半段的 a[i] 小, 说明左半段从 i 到 mid 这 mid - i + 1 个数全都不小于 a[j], 而且它们在原数组里都排在 a[j] 前面——一次就贡献了这么多逆序对。

今天的题用不上,但它是后面「CDQ 分治」「树状数组求逆序对」的思想起点,值得先混个脸熟。

✅ 实测结果

官方样例 4 / 4 3 2 1:冒泡版输出 6,归并版也输出 6 与样例完全一致

边界测试:已有序输入 3 / 1 2 3 → 输出 0(不用旋转) 正确
N = 1(1 / 1)→ 输出 0 正确

最大规模 N = 1000 完全降序(逆序对最多的情况)→ 输出 499500, 与理论值 1000×999÷2 = 499500 完全吻合,运行毫秒级完成

交叉验证:用 12 组随机数据(n 从 10 到 65)分别跑冒泡版和归并版, 12 组结果全部相同(例如 n = 65 那组两版都是 1043,n = 58 那组都是 692,n = 12 那组都是 20), 实测支持了「冒泡交换次数 = 逆序对个数」这条结论。

易错点

  • 用 getline 读数据:题面明说数据可能分行,分行读会把一行当成一整份,直接错。 用 cin >> 或 scanf 逐个读数字最稳。
  • 把 1005 写成 1000:N 最大 1000,数组至少要 a[1001],开 a[1005] 更保险。
  • 以为要「排序后输出」:题目只要输出次数,不要输出排好序的车厢序列。
  • 内层循环边界写成 j < n - 1 而不是 j < n - 1 - i: 多跑一些不影响正确性(只是慢一点),但内层访问到 a[n] 就越界了,必须带 - i。
  • 以为「最少」是随便走走看运气:这题答案是确定的(逆序对数),不需要搜索。

✗今日常见错误汇总

错误写法后果正确写法
sort(a+1, a+n, cmp) 左闭右开,最后一个元素没参与排序,样例可能还过 sort(a+1, a+n+1, cmp)
sort(a, a+n, greater<int>) 少了括号,编译报模板推导失败 greater<int>()
bool cmp(int x,int y){return x<=y;} 破坏严格弱序 → 未定义行为,可能越界崩溃或排错 return x < y;
if(x.total > y.total) return true;
return x.id < y.id;
第一关键字相等时,会被后面的条件错误接管 if(x.total != y.total)
  return x.total > y.total;
去掉重时先 unique 再 sort unique 只删相邻重复,没排序时一个都删不掉 先 sort,再 unique
vector 只用 unique 不 erase size() 没变,尾巴上还留着垃圾数据 v.erase(unique(v.begin(),v.end()), v.end())
P1012 用 a > b 当 cmp 两个样例全过但隐藏数据必挂({3,31} 会输出 313) return a + b > b + a;
P1781 用 long long 读票数 100 位数字溢出,读到垃圾值 string s; cin >> s; 先比长度再比字典序
P1116 用 getline 读车厢 数据可能跨行,按行读全乱 cin >> a[i],自动跳过空白符
变量 int 存逆序对数 n 大时 n²/2 可能超 int(n=1000 时 50 万没超,但习惯要养好) long long cnt;

✓自测:你真的会了吗

第 1 题:数据存在 a[0] ~ a[n-1],要降序排,sort 怎么写?

答:sort(a, a + n, greater<int>());

三个要点:① 需要 #include <algorithm>; ② 右端点是 a + n(左闭右开,不含 a[n]); ③ greater<int> 后面的括号不能丢。

第 2 题:下面这个 cmp 有什么问题?
bool cmp(int x, int y){
    return x <= y;
}

答:用了 <=,不满足严格弱序(cmp(x,x) 返回了 true,等于说「自己排在自己前面」)。

sort 要求的是严格小于。写 <= 属于未定义行为:可能让排序库越界读写直接崩溃, 也可能悄悄排出错误的顺序。正确写法是 return x < y;。

★ 补充一句真话:我在本机 MinGW g++ 6.3.0 / -O2 上实测过这个错误 cmp, 排 20 万随机数时并没有崩、结果也恰好是对的。 但这不代表安全——未定义行为的意思是「标准不保证任何结果」,换个环境就可能出事。 别拿编译器的宽容当运气。

第 3 题:3 31 3 三个数拼起来最大是多少?

用 a + b > b + a 的规则两两比一遍:

· 3 和 31:"331" > "313" → 3 在前
· 3 和 3:"33" == "33" → 等价,谁先都一样
· 31 和 3:"313" < "331" → 3 在前

答:排序结果是 3 3 31,拼起来是 3313。

验证一下确实是最大:3313(3-3-31)、3313(3-31-3 也是 3313)…… 而 3133 更小。✅

第 4 题:数组 5 4 3 2 1 的逆序对有几个?冒泡要交换几次?

答:逆序对 = 任取两个数都是「前大后小」= C(5,2) = 10 个。 冒泡交换次数也是 10 次(两者永远相等)。

手数验证:4 + 3 + 2 + 1 + 0 = 10。✅
一般结论:完全降序的 n 个数,逆序对是 n(n-1)/2 个—— 这就是为什么 1000 个降序车厢的答案是 1000×999÷2 = 499500。

第 5 题:为什么去重前一定要先排序?

答:因为 unique 只比较相邻的两个元素,把相邻的重复项合并掉。 如果数组是 2 5 2,两个 2 不相邻,unique 一个都删不掉。

排完序之后(2 2 5),相同的数一定挨在一起,unique 才能一次清干净。

★ 一句话:sort 负责把重复的凑到一起,unique 负责把它们合并。分工明确,顺序不能换。

▤速查卡(截图存手机)

【sort 三件套】  #include <algorithm>
  sort(a, a + n);                          // 升序(左闭右开!)
  sort(a, a + n, greater<int>());          // 降序,括号别丢
  sort(a, a + n, cmp);                     // 自定义规则
  sort(v.begin(), v.end());                // vector
  // 数据在 a[1]~a[n] → sort(a+1, a+n+1, cmp)

【cmp 的铁律】
  cmp(x, y) 返回 true  =  x 排在 y 前面
  只能写严格 < 或 >,绝不能写 <= / >=
  多关键字:if(x.k1 != y.k1) return x.k1 > y.k1;
            if(x.k2 != y.k2) return x.k2 > y.k2;
            return x.k3 < y.k3;
  参数用 const T &x

【去重三连】
  sort(a, a + n);
  int m = unique(a, a + n) - a;            // 数组:m 就是新长度
  v.erase(unique(v.begin(), v.end()), v.end());   // vector 要 erase

【桶去重(值域小时更好用)】
  bool vis[1005] = {false};
  vis[x] = true;
  for(int v = 1; v <= 1000; v++) if(vis[v]) cout << v << " ";

【冒泡排序 + 数交换次数】
  int cnt = 0;
  for(int i = 0; i < n - 1; i++)
      for(int j = 0; j < n - 1 - i; j++)
          if(a[j] > a[j+1]){ swap(a[j], a[j+1]); cnt++; }
  // cnt = 逆序对个数 = 最少相邻交换次数

【大数比较(字符串)】
  先比长度:s.size() != t.size() → 长的更大
  再比字典序:s > t

【拼接最大数(P1012)】
  bool cmp(const string &a, const string &b){
      return a + b > b + a;     // ★ 不是 a > b!
  }

【输入防坑】
  cin >> x  自动跳过空格和换行 → 数据分行也无所谓
  不要用 getline 读数字序列

→明天预告 · Day 13

贪心入门:局部最优通向全局最优

今天学的排序,明天马上要派上大用场——「排序 + 贪心」是竞赛里最常见的组合拳: P1223 排队接水(按接水时间从小到大排)、P1094 纪念品分组(排序 + 双指针)、 P4995 跳跳、P3817 小 A 的糖果,全都建立在你今天掌握的排序能力之上。

另外,今天没搞懂的 P1012 拼接比较,明天还会以别的形式出现,记得复习一下 cmp 的心法。