1 排序家族:sort 与 stable_sort 核心
sort(first, last, cmp) 对区间 [first, last) 进行升序排序。它是 introsort :快速排序 + 堆排序 + 插入排序的混合体,最坏 O(n log n),平均常数极小。
2
🔒
stable_sort 保证相等元素的相对顺序不变,内部多用归并排序,常数略大。
3
🧩
自定义比较 cmp(a,b) 返回 true 表示 a 排在 b 前面。
C++ sort / stable_sort / 三种比较器写法
#include <bits/stdc++.h>
using namespace std;
struct Stu {
int id; // 学号
int total; // 总分
int cpp; // C++ 单科
};
// 比较器写法一:普通函数
bool cmp1(const Stu& a, const Stu& b) {
if (a.total != b.total) return a.total > b.total; // 总分高的在前
return a.id < b.id; // 总分相同学号小的在前
}
void show(vector<Stu>& v, string name) {
cout << name << " : " ;
for (auto & s : v) cout << "(" << s.id << "," << s.total << ") " ;
cout << "\n" ;
}
int main() {
vector<Stu> v = {{3 ,280 ,95 }, {1 ,300 ,80 }, {2 ,280 ,99 }, {4 ,250 ,70 }};
// ① 基础排序(默认升序,作用于基本类型)
vector<int > a = {5 , 3 , 8 , 1 , 2 };
sort(a.begin(), a.end());
cout << "升序:" ; for (int x : a) cout << x << " " ;
cout << "\n" ;
sort(a.begin(), a.end(), greater<int >()); // 降序
cout << "降序:" ; for (int x : a) cout << x << " " ;
cout << "\n" ;
// ② 结构体多关键字排序
vector<Stu> v1 = v;
sort(v1.begin(), v1.end(), cmp1);
show(v1, "sort 多关键字" );
// ③ lambda 比较器(C++11,最紧凑)
vector<Stu> v2 = v;
sort(v2.begin(), v2.end(), [](const Stu& x, const Stu& y) {
return x.cpp != y.cpp ? x.cpp > y.cpp : x.id < y.id;
});
show(v2, "lambda 按 C++ 单科" );
// ④ 稳定排序演示:只按 total 排序,保留原来的相对顺序
vector<Stu> v3 = v;
stable_sort(v3.begin(), v3.end(),
[](const Stu& x, const Stu& y) { return x.total > y.total; });
show(v3, "stable_sort 只按总分" );
// 对比:用 sort 排序同样规则,相等元素的顺序「不确定」
vector<Stu> v4 = v;
sort(v4.begin(), v4.end(),
[](const Stu& x, const Stu& y) { return x.total > y.total; });
show(v4, "sort 只按总分" );
// ⑤ 部分排序:只排前 3 个
vector<int > b = {9 , 5 , 7 , 1 , 3 , 8 };
partial_sort(b.begin(), b.begin() + 3 , b.end()); // 前 3 个是最小的 3 个且有序
cout << "partial_sort 前 3 小且有序:" ;
for (int i = 0 ; i < 3 ; i++) cout << b[i] << " " ;
cout << "\n" ;
return 0 ;
}
运行结果
升序:1 2 3 5 8
降序:8 5 3 2 1
sort 多关键字 : (1,300) (3,280) (2,280) (4,250)
lambda 按 C++ 单科 : (2,280) (3,280) (1,300) (4,250)
stable_sort 只按总分 : (1,300) (3,280) (2,280) (4,250)
sort 只按总分 : (1,300) (3,280) (2,280) (4,250)
partial_sort 前 3 小且有序:1 3 5
用上面结构体数据 v = {(3,280),(1,300),(2,280),(4,250)} 逐步走一遍 stable_sort 只按总分 ,看 2 号、3 号这两个并列者为什么保住了先后:
🎬 stable_sort 稳定性演示(只按总分降序)
▶ 自动演示
↺ 重置
1 初始顺序(学号顺序):(3,280) (1,300) (2,280) (4,250)。注意 3 号和 2 号总分都是 280 ,且 3 号现在排在 2 号前面
2 比较器只看总分:x.total > y.total。于是 300 的 1 号是唯一「最大」的,280 的两人并列,250 的 4 号垫底
3 stable_sort(归并思想)合并子区间时,遇到「两边相等」就先取左半边的元素 ——这就是「稳定」的来源
4 两个 280 在合并时相等:左半边的 3 号先被取出 → 3 号仍在 2 号前面 ,与初始相对顺序一致
5 最终结果:(1,300) (3,280) (2,280) (4,250) —— 与运行结果第 4 行完全一致 ✅
6 换成 sort(introsort,会跨区间交换)就不保证 这一点,280 的两人次序可能翻转。所以「需要保留先前次序」时必须 stable_sort,或者干脆把两个关键字都写进比较器
📎 记忆要点:稳定 = 相等元素不换位 ;能写全关键字比较器时优先 sort,需要「二次排序保留上一次次序」时用 stable_sort。
📐 关键点拆解
① 比较器的语义 :cmp(a, b) 返回 true 表示「a 应该排在 b 前面」。想让总分高的在前,就写 a.total > b.total。
② 严格弱序 :必须保证 cmp(a, a) == false。所以永远不要 在比较器里写 <= 或 >=——那会让「相等的元素认为自己比对方小」,sort 可能陷入死循环或数组越界(RE)。
③ stable_sort 的适用场景 :当你要「先按 A 排序,再在 A 相同的情况下按 B 排序」,而 B 的次序已经由前面的操作排好时,用 stable_sort 只需一次排序就能保留 B 的次序。但更通用的做法是直接把两个关键字都写进比较器 (如 cmp1),这样用 sort 就够了。
④ partial_sort :只保证前 k 个是全局最小的 k 个且已排序,后面的元素顺序不保证。复杂度 O(n log k),比全排序 O(n log n) 更快。求「前 k 小且要输出顺序」时很好用。
⑤ 迭代器要求 :sort 需要随机访问迭代器 ,所以 list 不能用(list 有成员函数 l.sort()),set 也不能用(它本身有序)。
🔍 代码逐段解析 · 排序家族演示程序
▶ 逐步播放
↺ 重置
1 struct Stu { id, total, cpp }:存学号、总分、C++ 单科三个字段;show() 是打印辅助函数,输出 (id,total) 序列便于对比排序结果
2 cmp1:普通函数比较器——先比 total 降序,相同则 return a.id < b.id;。注意最后一句用的是严格小于 ,保证严格弱序(cmp(x,x)==false)
3 ① 基础排序:sort(a.begin(), a.end()) 默认升序得 1 2 3 5 8;再传 greater<int>() 得降序——降序不用手写比较器
4 ② sort(v1, cmp1):多关键字排序输出 (1,300)(3,280)(2,280)(4,250)——两个 280 由第二关键字 id 决定 3 在 2 前
5 ③ lambda 比较器:按 cpp 降序(99 的 2 号第一)、相同比 id,一行写完——lambda 捕获列表为空 [],只靠参数比较
6 ④ stable_sort(v3, ...) 只按 total:相等元素保留原相对顺序;⑤ partial_sort(b, b+3, b.end()) 只保证前 3 位是全局最小 3 个且有序(1 3 5),后面不保证
7 易错点:① 比较器写 <= 破坏严格弱序 → RE;② sort 需要随机访问迭代器 ,对 list/set 用会编译错误
📎 一句话总结:cmp 返回 true = a 在前;多关键字在 cmp 里一次写全,就不依赖稳定性。
❌ 常见错误写法
bool cmp(int a,int b){ return a <= b; }
相等时返回 true,破坏严格弱序 → sort 可能无限递归 / 数组越界 / RE。
✅ 正确写法
bool cmp(int a,int b){ return a < b; }
相等时返回 false,满足严格弱序。多关键字就用 if (a.k != b.k) return a.k < b.k; return a.id < b.id;
2 最值、交换与元素定位
C++ min / max / minmax / clamp / max_element / swap
#include <bits/stdc++.h>
using namespace std;
int main() {
// ① 两值取最值
int a = 5 , b = 9 ;
cout << "min = " << min(a, b) << ", max = " << max(a, b) << "\n" ;
// ② 一次取两个(C++11):避免「% 取模」这种老掉牙的写法
auto p = minmax(7 , 2 );
cout << "minmax(7,2) = (" << p.first << ", " << p.second << ")\n" ;
// ③ 多值取最值(initializer_list 版本,C++11)
cout << "min({3,8,1,9,4}) = " << min({3 , 8 , 1 , 9 , 4 }) << "\n" ; // 1
cout << "max({3,8,1,9,4}) = " << max({3 , 8 , 1 , 9 , 4 }) << "\n" ; // 9
// ④ clamp(C++17):把值限制在 [lo, hi] 区间内
cout << "clamp(15, 0, 10) = " << clamp(15 , 0 , 10 ) << "\n" ; // 10
cout << "clamp(-3, 0, 10) = " << clamp(-3 , 0 , 10 ) << "\n" ; // 0
// ⑤ 容器/数组求最值:返回「迭代器」,不是值!
vector<int > v = {3 , 9 , 1 , 7 };
auto itMax = max_element(v.begin(), v.end());
auto itMin = min_element(v.begin(), v.end());
cout << "max = " << *itMax << ",位置下标 = " << (itMax - v.begin()) << "\n" ;
cout << "min = " << *itMin << ",位置下标 = " << (itMin - v.begin()) << "\n" ;
// ⑥ swap:交换两个同类型对象,O(1)
swap(a, b); // a=9, b=5
cout << "swap 后 a=" << a << ", b=" << b << "\n" ;
vector<int > x = {1 ,2 }, y = {3 ,4 ,5 };
x.swap(y); // 交换两个容器的全部内容,O(1)(只换指针)
cout << "交换后 x 的 size = " << x.size() << "\n" ; // 3
return 0 ;
}
运行结果
min = 5, max = 9
minmax(7,2) = (2, 7)
min({3,8,1,9,4}) = 1
max({3,8,1,9,4}) = 9
clamp(15, 0, 10) = 10
clamp(-3, 0, 10) = 0
max = 9,位置下标 = 1
min = 1,位置下标 = 2
swap 后 a=9, b=5
交换后 x 的 size = 3
用正文数组 v = {3, 9, 1, 7} 逐步走一遍 max_element 的扫描过程:
🎬 max_element 扫描演示(记录当前最大者)
▶ 自动演示
↺ 重置
1 数组 v = {3, 9, 1, 7}。维护「当前最大」指针 itMax,先指向下标 0(值 3)
2 看下标 1:值 9 > 3 → 更新 itMax 指向下标 1(值 9)
3 看下标 2:值 1 < 9 → 不更新。min_element 同理 :它此刻会把最小者记为 1(下标 2)
4 看下标 3:值 7 < 9 → 不更新。扫描结束
5 返回迭代器 :*itMax = 9,itMax - v.begin() = 1 —— 与运行结果「max = 9,位置下标 = 1」一致 ✅
6 注意:max_element 返回的是位置 不是值,直接 cout << itMax 是编译错误;要 *itMax 解引用。这正是「min/max 返回值、min_element 返回迭代器」的区别
📎 记忆要点:n 个元素恰好比较 n−1 次,O(n);「最值 + 下标」一次扫描全拿到。
🔍 代码逐段解析 · 最值家族演示程序
① 两值最值 :min(a, b) / max(a, b) 返回值 本身,a=5、b=9 时得 5 和 9。
② minmax :auto p = minmax(7, 2) 返回 pair,p.first=2(最小)、p.second=7(最大)——注意 first 永远是最小值。
③ 多值最值 :min({3,8,1,9,4}) 用初始化列表 版本,花括号不能省,否则编译错误。
④ clamp :clamp(v, lo, hi) 把 v 夹在 [lo, hi] 内——15→10,−3→0,区间内的值原样返回。
⑤ 区间定位 :max_element(v.begin(), v.end()) 返回迭代器 ;对 v={3,9,1,7} 得下标 1 的 9,min_element 得下标 2 的 1。易错:忘了 * 解引用或忘减 v.begin()。
⑥ swap :交换标量 O(1);x.swap(y) 交换整个容器也只换内部指针 O(1),size 随之对调(x 变 3)。
易错点 :想「最值 + 下标」时写两遍循环是大忌,应像 ⑤ 一样用迭代器差值一次拿到。
🔑 三个易混点
① min/max 返回值 ;min_element/max_element 返回迭代器 ,要解引用才拿到值。
② min({a,b,c}) 的花括号是必需的吗?—— 是的!min(a,b,c) 编译不过,必须写成初始化列表。
③ 想同时拿到「最大值」和「它的下标」,用 max_element 再减 begin(),不要写两遍循环。
3 区间操作:reverse / unique / rotate / fill 去重新套路
C++ reverse / unique + erase / rotate / fill / iota
#include <bits/stdc++.h>
using namespace std ;
void show(vector<int >& v, string name) {
cout << name << " : [" ;
for (int i = 0 ; i < (int )v.size(); i++) cout << v[i] << (i + 1 < (int )v.size() ? ", " : "" );
cout << "] size=" << v.size() << "\n" ;
}
int main() {
// ① reverse:原地翻转 O(n)
vector<int > v = {1 , 2 , 3 , 4 , 5 };
reverse(v.begin(), v.end());
show(v, "reverse 后" );
reverse(v.begin(), v.begin() + 2 ); // 只翻转前两个
show(v, "只翻前两个" );
// ② unique:把相邻重复项挪到后面,返回「新末尾」
vector<int > w = {1 , 1 , 2 , 3 , 3 , 3 , 5 };
auto newEnd = unique(w.begin(), w.end());
cout << "unique 之后 size 仍是 " << w.size()
<< ",新末尾下标 = " << (newEnd - w.begin()) << "\n" ;
show(w, "unique 后(未 erase)" );
// ★ 标准去重套路:先 sort,再 unique + erase
vector<int > u = {3 , 1 , 3 , 2 , 1 , 3 };
sort(u.begin(), u.end());
u.erase(unique(u.begin(), u.end()), u.end());
show(u, "去重后" );
// ③ rotate:循环左移(把 first 搬到 middle 位置)
vector<int > r = {1 , 2 , 3 , 4 , 5 };
rotate(r.begin(), r.begin() + 2 , r.end()); // 前两个搬到末尾
show(r, "rotate 左移 2 位" );
// ④ fill:把区间全部赋值(比 for 循环更清晰)
vector<int > f(6 );
fill(f.begin(), f.end(), 7 );
show(f, "fill 7" );
// ⑤ iota:递增填充(«numeric»)
vector<int > g(6 );
iota(g.begin(), g.end(), 1 ); // 填 1,2,3,4,5,6
show(g, "iota 从 1 开始" );
// ⑥ count:统计某个值出现次数(«algorithm»)
vector<int > c = {1 , 2 , 2 , 3 , 2 };
cout << "count(2) = " << count(c.begin(), c.end(), 2 ) << "\n" ; // 3
// ⑦ count_if:统计满足条件的元素个数
int odd = count_if(c.begin(), c.end(), [](int x) { return x % 2 == 1 ; });
cout << "奇数个数 = " << odd << "\n" ; // 2
// ⑧ accumulate:求和(«numeric»),第三参数是「初值」
long long sum = accumulate(c.begin(), c.end(), 0LL );
cout << "总和 = " << sum << "\n" ; // 10
long long prod = accumulate(c.begin(), c.end(), 1LL , multiplies<long long >());
cout << "连乘 = " << prod << "\n" ; // 24 = 1*2*2*3*2
return 0 ;
}
运行结果
reverse 后 : [5, 4, 3, 2, 1] size=5
只翻前两个 : [4, 5, 3, 2, 1] size=5
unique 之后 size 仍是 7,新末尾下标 = 4
unique 后(未 erase) : [1, 2, 3, 5, 3, 3, 5] size=7
去重后 : [1, 2, 3] size=3
rotate 左移 2 位 : [3, 4, 5, 1, 2] size=5
fill 7 : [7, 7, 7, 7, 7, 7] size=6
iota 从 1 开始 : [1, 2, 3, 4, 5, 6] size=6
count(2) = 3
奇数个数 = 2
总和 = 10
连乘 = 24
📐 为什么 unique 不真正删除元素?
unique 只做一件事:把相邻重复 的元素往后挪,返回「下一个有效位置」的迭代器。它不知道容器是什么 (只拿到迭代器),所以无法改变容器大小——这正是标准库「算法与容器分离」的设计哲学。
要真正缩短容器,必须由容器自己来删:v.erase(unique(v.begin(), v.end()), v.end());
为什么必须先 sort? 因为 unique 只处理相邻 重复。若数组是 {1,3,1,2},两个 1 不相邻,unique 什么也不会做。排序把相同元素挤到一起后,unique 才有效。
🔍 代码逐段解析 · 区间操作演示程序
① reverse :reverse(v.begin(), v.end()) 把 {1,2,3,4,5} 翻成 {5,4,3,2,1};reverse(v.begin(), v.begin()+2) 只翻前两个得 {4,5,3,2,1}——区间参数是左闭右开 。
② unique :对 w={1,1,2,3,3,3,5},unique 返回新末尾下标 4,size 仍是 7,内容变 {1,2,3,5,3,3,5}——尾部是搬走重复后的「垃圾区」。
③ 去重套路 :u={3,1,3,2,1,3} → sort → unique+erase → {1,2,3} size=3。
④ rotate :rotate(r.begin(), r.begin()+2, r.end()) 循环左移,第二个参数(middle)成为新首元素 → {3,4,5,1,2}。
⑤ fill / iota :fill(f, 7) 全填 7;iota(g, 1) 填 1..6,常用于并查集初始化和排列枚举起点。
⑥ count / count_if / accumulate :对 c={1,2,2,3,2}——count(2)=3;count_if 奇数=2(1 和 3);accumulate(c, 0LL)=10、初值 1LL + multiplies 连乘=24。
易错点 :① unique 后不 erase,size 不变;② accumulate 初值写 0(int)遇大数据溢出,要写 0LL。
用正文数组 u = {3, 1, 3, 2, 1, 3} 逐步走一遍去重三件套:
🎬 去重三件套:sort → unique → erase
▶ 自动演示
↺ 重置
1 初始:u = {3, 1, 3, 2, 1, 3}。两个 1、三个 3 都不相邻 ,此时直接 unique 什么也去不掉
2 第一步 sort:u = {1, 1, 2, 3, 3, 3} —— 相同元素被挤到一起,unique 才有用武之地
3 第二步 unique:从左往右保留「第一次出现」,把多余元素挪到尾部 → 前 3 位变成 {1, 2, 3},返回新末尾下标 3 (与运行结果「新末尾下标」的逻辑一致)
4 此时 u.size() 仍是 6!unique 只是重排,从不改变容器大小
5 第三步 erase(u.begin()+3, u.end()):把下标 3 之后的部分真正删掉 → u = {1, 2, 3},size = 3 ✅(与运行结果「去重后 : [1, 2, 3] size=3」一致)
6 一步都不能省:不 sort → 重复项不相邻去不掉;不 erase → size 不变、输出仍有重复
📎 记忆要点:sort 让重复相邻,unique 挪走重复,erase 收割尾巴 ——三件套缺一不可。
🔑 去重三件套
sort(v.begin(), v.end()); ← 第一步:让相同元素相邻
auto it = unique(v.begin(), v.end()); ← 第二步:去重,返回新末尾
v.erase(it, v.end()); ← 第三步:真正删除多余元素
少任何一步都会出错! 只 unique 不 erase,size() 不变;不 sort 就 unique,重复项还在。
4 二分三兄弟:lower_bound / upper_bound / binary_search 核心
使用前提:区间必须已经按升序排好! 对无序区间调用这三个函数,结果是未定义的(不会报错,但答案全错)。
函数 返回含义 找不到时 等价手写
lower_bound(f, l, v) 第一个 ≥ v 的位置 返回 l 第一个不为 a[i] < v 的位置
upper_bound(f, l, v) 第一个 > v 的位置 返回 l 第一个满足 a[i] > v 的位置
equal_range(f, l, v) 返回 pair(lb, ub) 两者相等且为 l 区间 [lb, ub) 全是 v
binary_search(f, l, v) 返回 bool,是否存在 false不告诉你位置
📐 四个必背推论
① v 的出现次数 (数组已排序):upper_bound(a,a+n,v) - lower_bound(a,a+n,v)
② v 是否存在 :binary_search 最简洁;或 lb != end() && *lb == v(注意 lower_bound 返回 ≥ v 的位置,可能是比 v 大的值!)
③ 下标换算 :数组 a[0..n-1] 中,lower_bound(a, a+n, v) - a 就是下标 ;lower_bound(v.begin(), v.end(), v) - v.begin() 同理。
④ 找「≤ v 的最大值」(前驱) :auto it = upper_bound(...); if (it != begin) --it;
① 有序吗 区间是否已升序?
② 要位置还是布尔 binary_search 还是 lower_bound?
③ 要 ≥ 还是 > lower_bound / upper_bound?
④ 判边界 返回值等于 end() 吗?
⑤ 换算下标 迭代器减 begin()
C++ 二分三兄弟的完整演示
#include <bits/stdc++.h>
using namespace std ;
int main() {
// 下标: 0 1 2 3 4 5 6 7 8
int a[] = {1 , 3 , 3 , 3 , 5 , 7 , 9 , 11 , 11 };
int n = 9 ;
for (int q : {0 , 3 , 4 , 11 , 12 }) {
int lb = lower_bound(a, a + n, q) - a; // 第一个 >= q 的下标
int ub = upper_bound(a, a + n, q) - a; // 第一个 > q 的下标
bool has = binary_search(a, a + n, q);
cout << "查询 " << setw(2 ) << q
<< " → lower=" << lb
<< ", upper=" << ub
<< ", 出现次数=" << ub - lb
<< ", 是否存在=" << (has ? "Y" : "N" ) << "\n" ;
}
// 前驱:≤ 4 的最大值
auto it = upper_bound(a, a + n, 4 );
if (it != a) { --it; cout << "≤4 的最大值 = " << *it << "\n" ; } // 3
// 后继:≥ 4 的最小值
auto it2 = lower_bound(a, a + n, 4 );
if (it2 != a + n) cout << "≥4 的最小值 = " << *it2 << "\n" ; // 5
// 在 vector 上使用
vector<long long > v = {10 , 20 , 20 , 30 , 40 };
int pos = lower_bound(v.begin(), v.end(), 20 ) - v.begin();
cout << "v 中 20 首次出现于下标 " << pos << "\n" ; // 1
// 降序数组必须传 greater<int>()
vector<int > d = {40 , 30 , 20 , 10 };
auto it3 = lower_bound(d.begin(), d.end(), 25 , greater<int >());
cout << "降序数组中第一个 ≤25 的值 = " << *it3 << "\n" ; // 20
return 0 ;
}
运行结果
查询 0 → lower=0, upper=0, 出现次数=0, 是否存在=N
查询 3 → lower=1, upper=4, 出现次数=3, 是否存在=Y
查询 4 → lower=4, upper=4, 出现次数=0, 是否存在=N
查询 11 → lower=7, upper=9, 出现次数=2, 是否存在=Y
查询 12 → lower=9, upper=9, 出现次数=0, 是否存在=N
≤4 的最大值 = 3
≥4 的最小值 = 5
v 中 20 首次出现于下标 1
降序数组中第一个 ≤25 的值 = 20
🎬 lower_bound(a, a+9, 4) 的二分过程
▶ 自动演示
↺ 重置
1 数组 a = [1, 3, 3, 3, 5, 7, 9, 11, 11],要查 lower_bound(4) —— 第一个 ≥ 4 的位置。初始区间 [l, r) = [0, 9)
2 取中点 mid = (0+9)/2 = 4,a[4] = 5。判断 a[4] < 4?5 < 4 为假 → 说明答案在 mid 及其左边 → r = mid = 4
3 区间收缩为 [0, 4)。取 mid = (0+4)/2 = 2,a[2] = 3。判断 a[2] < 4?3 < 4 为真 → 答案在 mid 右边 → l = mid + 1 = 3
4 区间收缩为 [3, 4)。取 mid = (3+4)/2 = 3,a[3] = 3。判断 a[3] < 4?为真 → l = mid + 1 = 4
5 l == r == 4,循环结束。返回下标 4 ,即 a[4] = 5 —— 这正是第一个 ≥ 4 的元素 ✅
6 对比 upper_bound(4):只把判断条件从 a[mid] < v 换成 a[mid] <= v,得到的仍然是 4(因为 4 不存在)。若查 3:lower_bound 得 1,upper_bound 得 4 —— 差值 3 就是 3 的出现次数
7 整个查询只做了 ⌈log₂9⌉ = 4 次比较。n = 10⁶ 时只需 20 次比较 —— 这就是二分的威力
📎 记忆要点:lower_bound 找「第一个不小于 v 的」,upper_bound 找「第一个大于 v 的」 ;两者都返回迭代器,减 begin() 得到下标。
🔍 代码逐段解析 · 二分三兄弟演示程序
① 数据与查询集 :有序数组 a[9] = {1,3,3,3,5,7,9,11,11}(下标 0~8),对 q ∈ {0, 3, 4, 11, 12} 逐一查询——特意覆盖「不存在且偏小 / 存在且有重复 / 不存在居中 / 存在于末尾 / 超出上界」五种边界。
② 三兄弟联用 :lb = lower_bound(a, a+n, q) - a(第一个 ≥ q)、ub = upper_bound(...) - a(第一个 > q)、binary_search 判存在。ub - lb 天然等于出现次数:q=3 时 4−1=3;q=4(不存在)时 4−4=0,无需特判。
③ 前驱写法 :it = upper_bound(a, a+n, 4); if (it != a) --it; —— upper_bound 指向第一个 >4 的 5,回退一步得 3。易错:q 比所有数都小时 upper_bound 返回 a,解引用 a[-1],必须判 it != a。
④ 后继写法 :it2 = lower_bound(a, a+n, 4); if (it2 != a+n) ... —— 直接得到第一个 ≥4 的 5;q 超上界时返回 a+n,必须判 end。
⑤ vector 与降序 :lower_bound(v.begin(), v.end(), 20) - v.begin() 对 vector 同样适用(得 1);降序数组必须传第四参数 greater<int>(),否则二分前提被破坏,结果未定义。
易错点 :① 对无序数组二分——不报错但答案全错;② 拿到 lower_bound 的位置后不验证 a[lb] == q 就当「找到了」。
把上面这个数组画出来,格子高亮会依次扫过(这正是二分「不停缩小候选区间」的直观感受):
当前扫描位置 数组元素
5 memset 陷阱与 GCC 内建函数 高频坑
❌ memset 的字节填充陷阱
memset(a, 1, sizeof(a)); → 每个字节都填 0x01,而不是整数 1!
一个 int 占 4 字节,结果是 0x01010101 = 16843009。
只能安全地填: 0(全 0)、-1(全 0xFF,即 −1)、0x3f(得到 0x3f3f3f3f ≈ 10⁶ 量级的「大数」)。
✅ fill 才是逐元素赋值
fill(a, a + n, 1); → 每个元素都真的是 1。
fill(&a[0][0], &a[0][0] + n * m, 0); 可以填二维数组。
性能: fill 比 memset 慢一些(逐元素赋值),但语义正确。初始化 0 或 −1 时优先 memset,其他值一律用 fill。
C++ memset / fill / fill_n 的正确用法 + GCC 内建函数
#include <bits/stdc++.h>
using namespace std ;
int a[5 ];
long long b[5 ];
int main() {
// —— memset 能做的:填 0 ——
memset(a, 0 , sizeof(a));
cout << "memset 0 → " ; for (int x : a) cout << x << " " ; cout << "\n" ;
// —— memset 能做的:填 -1 ——
memset(a, -1 , sizeof(a));
cout << "memset -1 → " ; for (int x : a) cout << x << " " ; cout << "\n" ;
// —— memset 能做的:填 0x3f(当无穷大用)——
memset(a, 0x3f , sizeof(a));
cout << "memset 0x3f → " ; for (int x : a) cout << x << " " ; cout << "\n" ;
// —— memset 做不到的:填 1 ——
memset(a, 1 , sizeof(a));
cout << "memset 1 → " ; for (int x : a) cout << x << " " ; cout << "\n" ; // 全是 16843009
// —— 正确做法:fill ——
fill(a, a + 5 , 1 );
cout << "fill 1 → " ; for (int x : a) cout << x << " " ; cout << "\n" ;
// —— long long 的大数用 0x3f 要注意!——
// memset 到 long long 上会得到 0x3f3f3f3f3f3f3f3f ≈ 4.5×10^18,太大且容易溢出
memset(b, 0x3f , sizeof(b));
cout << "long long 上的 memset 0x3f = " << b[0 ] << "\n" ;
// 推荐:long long 的「无穷大」直接写常数
const long long INF = 0x3f3f3f3f3f3f3f3fLL ;
fill(b, b + 5 , INF);
cout << "fill INF = " << b[0 ] << "\n" ;
// ============ GCC 内建函数 ============
unsigned int x = 0b10110100 ; // 二进制字面量(C++14)
cout << "x = " << bitset<8 >(x) << "\n" ;
cout << "__builtin_popcount(x) = " << __builtin_popcount(x) << "\n" ; // 1 的个数 = 4
cout << "__builtin_ctz(x) = " << __builtin_ctz(x) << "\n" ; // 末尾 0 的个数 = 2
cout << "__builtin_clz(x) = " << __builtin_clz(x) << "\n" ; // 32 位下的前导 0 = 24
// 高精度版本:参数加 ll 后缀,用于 long long
long long y = 0x0000000000000000LL ;
cout << "popcountll(y) = " << __builtin_popcountll(y) << "\n" ;
// 常用推论
int v = 12 ; // 1100
cout << "lowbit(12) = " << (v & -v) << "\n" ; // 4
cout << "最高位编号 = " << (31 - __builtin_clz(v)) << "\n" ; // 3(即 2^3=8)
cout << "是否 2 的幂 = " << ((v & (v - 1 )) == 0 ? "Y" : "N" ) << "\n" ; // N
return 0 ;
}
运行结果
memset 0 → 0 0 0 0 0
memset -1 → -1 -1 -1 -1 -1
memset 0x3f → 1061109567 1061109567 1061109567 1061109567 1061109567
memset 1 → 16843009 16843009 16843009 16843009 16843009
fill 1 → 1 1 1 1 1
long long 上的 memset 0x3f = 4557430888798830399
fill INF = 4557430888798830399
x = 10110100
__builtin_popcount(x) = 4
__builtin_ctz(x) = 2
__builtin_clz(x) = 24
popcountll(y) = 0
lowbit(12) = 4
最高位编号 = 3
是否 2 的幂 = N
🔍 代码逐段解析 · memset / fill / 内建函数演示程序
① 三个「能填」的演示 :memset(a, 0, sizeof(a)) → 全 0;memset(a, -1, sizeof(a)) → 全 −1;memset(a, 0x3f, sizeof(a)) → 1061109567(= 0x3f3f3f3f)。三个都逐字节填充后按 int 读回恰好合法。
② 反例 :memset(a, 1, sizeof(a)) → 16843009:第二参数 1 被截断为低 8 位 0x01,四个字节都是 0x01。
③ fill 对照 :fill(a, a + 5, 1) 按元素 赋值 → 真正的 1 1 1 1 1。
④ long long 警告 :memset(b, 0x3f, sizeof(b)) 对 long long 得 0x3f3f3f3f3f3f3f3f = 4557430888798830399 ≈ 4.6×10¹⁸,两个相加必溢出——long long 的 INF 应写常数 0x3f3f3f3f3f3f3f3fLL 再 fill。
⑤ 内建函数 :x = 0b10110100 时 popcount=4、ctz=2、clz=24(32 位下);31 - clz(v) 得最高位编号(v=12 → 3);v & (v-1) == 0 判 2 的幂(12 → N)。
易错点 :① popcount 的参数是 unsigned int,传 long long 要用 __builtin_popcountll;② ctz/clz 对 0 值调用是未定义行为,先判非零。
逐步拆解为什么 memset(a, 1, sizeof(a)) 得到的不是 1:
🎬 memset(a, 1, sizeof(a)) 到底发生了什么(a 是 int[5])
▶ 自动演示
↺ 重置
1 memset 的第三个参数是字节数 ,它的工作方式是「把内存逐字节 都写成低 8 位 = 1 的模式 0x01」,完全不知道你填的是 int
2 一个 int 占 4 个字节 ,于是每个 int 的 4 个字节都变成 01 01 01 01
3 按 int 读回来 = 0x01010101 = 16843009 ,不是 1!(运行结果第 4 行实证)
4 为什么 0 和 −1 幸运?填 0 → 4 字节全 0 = 0;填 −1 → 0xFF(补码)→ 4 字节全 0xFF 读回来恰好还是 −1
5 0x3f 的妙处:每字节 0x3f → int = 0x3f3f3f3f ≈ 1.06×10⁹,当「无穷大」用;两个相加 ≈ 2.1×10⁹ 刚好不溢出 int
6 真正想逐元素填 1 就用 fill(a, a+5, 1) —— fill 按元素 赋值,语义正确(运行结果第 5 行:1 1 1 1 1 ✅)
📎 记忆要点:memset 按字节、fill 按元素 ;memset 只放心用 0 / −1 / 0x3f。
🔑 memset 使用铁律
只填 0、−1、0x3f,其他一律用 fill!
int 的「无穷大」常写 0x3f3f3f3f ≈ 1.06×10⁹,两个相加接近 2.1×10⁹ 仍在 int 范围内(这是它的妙处);
long long 的「无穷大」直接写 0x3f3f3f3f3f3f3f3fLL,不要用 memset 去填 。
6 nth_element:O(n) 求第 k 小 降复杂度
nth_element(first, nth, last) 会重新排列区间,使得 第 nth 个位置上的元素,恰好等于整个区间排好序后该位置应有的元素 ;同时保证它左边的元素都不大于它、右边的都不小于它。但左右两边内部不保证有序。
😐 方法一:sort 后取下标
sort(a, a + n); cout << a[k-1];
复杂度 O(n log n) ,n = 10⁷ 时约 2.3×10⁸ 次比较,很勉强。
优点:顺带把整个数组排好了,如果后续还要用有序序列更划算。
🚀 方法二:nth_element
nth_element(a, a + k - 1, a + n); cout << a[k-1];
复杂度 O(n) (期望),n = 10⁷ 时约 2×10⁷ 次操作。
注意:第 k 小的元素下标是 k-1(因为下标从 0 开始)。
C++ nth_element 求第 k 小 / 第 k 大
#include <bits/stdc++.h>
using namespace std ;
int main() {
vector<int > a = {7 , 3 , 9 , 1 , 5 , 8 , 2 , 6 , 4 };
int n = a.size();
// 求第 3 小(下标 2)
vector<int > b = a;
nth_element(b.begin(), b.begin() + 2 , b.end());
cout << "第 3 小 = " << b[2 ] << "\n" ; // 3
cout << "nth_element 后整个数组:" ;
for (int x : b) cout << x << " " ;
cout << "\n" ; // 左半 ≤ b[2],右半 ≥ b[2],但两边内部无序
// 求第 2 大:用 greater 反向比较
vector<int > c = a;
nth_element(c.begin(), c.begin() + 1 , c.end(), greater<int >());
cout << "第 2 大 = " << c[1 ] << "\n" ; // 8
// 求中位数(n 为奇数时)
vector<int > d = a;
nth_element(d.begin(), d.begin() + n / 2 , d.end());
cout << "中位数 = " << d[n / 2 ] << "\n" ; // 5
// 顺便取出「前 k 小」(前 k 个位置就是最小的 k 个,但内部无序)
vector<int > e = a;
nth_element(e.begin(), e.begin() + 4 , e.end());
cout << "前 5 小(未排序):" ;
for (int i = 0 ; i < 5 ; i++) cout << e[i] << " " ;
cout << "\n" ;
sort(e.begin(), e.begin() + 5 ); // 再排一下前 5 个就得到有序的前 k 小
cout << "前 5 小(已排序):" ;
for (int i = 0 ; i < 5 ; i++) cout << e[i] << " " ;
cout << "\n" ;
return 0 ;
}
运行结果(GCC 实测;nth_element 后的排列顺序与实现有关,第 k 位及左右分区性质不变)
第 3 小 = 3
nth_element 后整个数组:2 1 3 4 5 8 9 6 7
第 2 大 = 8
中位数 = 5
前 5 小(未排序):4 3 2 1 5
前 5 小(已排序):1 2 3 4 5
用主数组 a = {7, 3, 9, 1, 5, 8, 2, 6, 4} 逐步对照「调用 nth_element 前后」发生了什么:
🎬 nth_element(b.begin(), b.begin()+2, b.end()) 求第 3 小
▶ 自动演示
↺ 重置
1 初始 b = {7, 3, 9, 1, 5, 8, 2, 6, 4},目标:让下标 2 的位置放上「第 3 小」
2 内部做快速选择 :像快排一样 partition,但只递归包含目标位置的那一半 —— n + n/2 + n/4 + … ≈ 2n 次操作,即 O(n)
3 GCC 实测结果:b = {2, 1, 3, 4, 5, 8, 9, 6, 7}(排列顺序与实现有关,但性质不变)
4 验证核心性质:b[2] = 3,而 3 正是全数组的第 3 小 ✅
5 左边 {2, 1} 全部 ≤ 3,右边 {4, 5, 8, 9, 6, 7} 全部 ≥ 3 —— 分区性质成立;但两边内部无序 (2 在 1 前面)
6 若还要「前 5 小且有序」,就再 sort(b.begin(), b.begin()+5) —— 对应运行结果的最后两行
📎 记忆要点:第 k 位必然就位,左右只保证 ≤ / ≥,不保证内部有序 ;要有序还得补 sort。
🔍 代码逐段解析 · nth_element 演示程序
① 数据 :a = {7,3,9,1,5,8,2,6,4},每次都先 vector b = a 拷贝——nth_element 会原地重排 ,不拷贝会破坏原数组(本程序后面 d、e 还要用)。
② 第 3 小 :nth_element(b.begin(), b.begin()+2, b.end()) 后 b[2]=3;求第 k 小传的是下标 k−1 ,不是 k。
③ 第 2 大 :nth_element(c.begin(), c.begin()+1, c.end(), greater<int>()) —— 传入 greater 后区间按「大在前」的序划分,c[1]=8。
④ 中位数 :nth_element(d.begin(), d.begin()+n/2, d.end()) 取 d[n/2],n=9 时即第 5 小 = 5(n 为奇数专用)。
⑤ 前 k 小两步走 :先 nth_element(e, e+4, e) 让前 5 个位置是最小的 5 个(内部无序),再 sort(e.begin(), e.begin()+5) 补齐顺序。
易错点 :① 忘记 nth_element 是破坏性操作,复用原数组前不拷贝;② 把「第 k 大」当成 a[n-k] 却忘了用 greater 时下标语义也反了——统一记「第 k 个就位的位置 = begin() + (k−1)」。
📐 原理与复杂度
算法本质 :快速选择(Quickselect)——借用快排的划分(partition)思想,但每次只递归包含目标位置的那一半 。每次划分把区间缩小一半,总工作量是 n + n/2 + n/4 + … = 2n = O(n) 。
最坏情况 :如果每次都选到极值作为枢轴,会退化成 O(n²)。标准库的实现做了「三数取中」等优化,实际几乎不会退化。
使用限制 :会引起区间内的元素重排(原地修改)。如果原数组后续还要用有序版本,记得先拷贝一份。
典型题 :洛谷 P1923「【深基9.例4】求第 k 小的数」——n 达 5×10⁶,用 sort 会 TLE,必须用 nth_element 或手写快速选择。
7 next_permutation:字典序排列枚举 枚举神器
next_permutation(first, last) 把区间改成「字典序的下一个排列」,若已经是最后一个排列则返回 false(并把它变成第一个排列)。
C++ 全排列枚举 + 按条件剪枝 + 求下一个排列
#include <bits/stdc++.h>
using namespace std ;
int main() {
// ① 枚举 1..4 的全部排列(必须从最小排列开始!)
vector<int > a = {1 , 2 , 3 , 4 };
int cnt = 0 ;
do {
cout << "[" ;
for (int i = 0 ; i < 4 ; i++) cout << a[i] << (i < 3 ? "," : "" );
cout << "] " ;
cnt++;
} while (next_permutation(a.begin(), a.end()));
cout << "\n共 " << cnt << " 个排列(4! = 24)\n" ;
// ② 带剪枝:只枚举「1 在 2 前面」的排列(减少一半搜索量)
vector<int > b = {1 , 2 , 3 , 4 };
vector<vector<int >> good;
do {
int p1 = 0 , p2 = 0 ;
for (int i = 0 ; i < 4 ; i++) { if (b[i] == 1 ) p1 = i; if (b[i] == 2 ) p2 = i; }
if (p1 < p2) good.push_back(b);
} while (next_permutation(b.begin(), b.end()));
cout << "1 在 2 前面的排列数 = " << good.size() << "(24/2 = 12)\n" ;
// ③ 字符串也能全排列
string s = "abc" ;
cout << "abc 的全排列:" ;
sort(s.begin(), s.end()); // 先排成最小排列
do { cout << s << " " ; } while (next_permutation(s.begin(), s.end()));
cout << "\n" ;
// ④ 求「指定排列的下一个」(P1088 火星人的做法)
vector<int > c = {1 , 3 , 2 };
next_permutation(c.begin(), c.end());
cout << "132 的下一个排列 = " ;
for (int x : c) cout << x;
cout << "\n" ; // 213
// ⑤ prev_permutation:反向,求上一个排列
vector<int > d = {2 , 1 , 3 };
prev_permutation(d.begin(), d.end());
cout << "213 的上一个排列 = " ;
for (int x : d) cout << x;
cout << "\n" ; // 132
return 0 ;
}
运行结果
[1,2,3,4] [1,2,4,3] [1,3,2,4] [1,3,4,2] [1,4,2,3] [1,4,3,2] [2,1,3,4] [2,1,4,3] [2,3,1,4] [2,3,4,1] [2,4,1,3] [2,4,3,1] [3,1,2,4] [3,1,4,2] [3,2,1,4] [3,2,4,1] [3,4,1,2] [3,4,2,1] [4,1,2,3] [4,1,3,2] [4,2,1,3] [4,2,3,1] [4,3,1,2] [4,3,2,1]
共 24 个排列(4! = 24)
1 在 2 前面的排列数 = 12(24/2 = 12)
abc 的全排列:abc acb bac bca cab cba
132 的下一个排列 = 213
213 的上一个排列 = 132
以运行结果的第 4 步为例,逐步拆解 next_permutation 如何把 {1,3,2} 变成 {2,1,3}:
🎬 132 的下一个排列是怎么算出来的
▶ 自动演示
↺ 重置
1 当前排列 1 3 2。从右往左找第一个「升序对 」:p[1]=3 > p[2]=2 不是;p[0]=1 < p[1]=3 是 → 枢轴位置 0(值 1)
2 在枢轴右侧的后缀 {3, 2} 中,找大于 1 的最小值 → 2(3 也大于 1 但更大,选 2 才能只前进最小的一步)
3 交换枢轴与 2 → 2 3 1
4 把枢轴右侧的后缀反转 (升序变降序 → 保证后面是最小排列):3 1 反转成 1 3 → 得 2 1 3 ✅
5 验证:2 1 3 恰好是「比 132 大的所有排列中最小的那个」—— 与运行结果「132 的下一个排列 = 213」一致
6 找升序对 + 交换 + 反转后缀,每步最多扫一遍数组 → 单次 O(n) ;当前已是最大排列(如 321)时返回 false 并重置为最小排列
📎 记忆要点:「下一个排列」= 大一点的数里最小的一个 ;这就是字典序枚举不重不漏的原理。
🔍 代码逐段解析 · next_permutation 演示程序
① 枚举 1..4 全排列 :do { 输出 } while (next_permutation(a.begin(), a.end())) —— a 初始就是最小排列 1234,do…while 先输出再前进,共 24 个;cnt 计数验证 4!=24。
② 带剪枝枚举 :在循环体里扫一遍 b 找 1 和 2 的位置 p1、p2,if (p1 < p2) good.push_back(b) —— 只收「1 在 2 前」的排列,得 12 个;也可反着利用:只枚举一半、另一半由对称性补全。
③ 字符串排列 :先 sort(s.begin(), s.end()) 再 do…while,输出 abc acb bac bca cab cba —— string 也是随机访问迭代器,可直接用。
④⑤ 单次前进 :next_permutation 对 {1,3,2} 得 213、prev_permutation 对 {2,1,3} 得 132 —— 这两个函数不需要「从最小排列开始」,它们只走一步。
易错点 :① ② 中如果起点不是最小排列,do…while 会漏掉排在前面的所有排列(错 1);② 想用 while 而不是 do…while 时,第一个排列(初始那个)会被跳过(错 2)。
📐 使用要点
① 必须从最小排列开始 :do…while 会先执行一次循环体,所以如果初始序列不是最小排列,会漏掉排在它前面的所有排列。所以通常先 sort 一遍。
② 复杂度 :单次 next_permutation 是 O(n)(从后往前找第一个升序对,再交换并反转后缀)。枚举全部 n! 个排列总计 O(n × n!),所以 n 只能到 8~10 左右。
③ 处理重复元素 :next_permutation 会自动跳过重复排列。比如 {1,1,2} 只会产生 3 个不同排列,而不是 3! = 6 个。
④ 与 DFS 全排列的关系 :DFS 回溯可以处理「n 很大但只需满足条件的少数排列」的情况,而 next_permutation 适合「n ≤ 10 且要遍历全部或大部分排列」。两者互补。
案例 案例1:多关键字排序成绩单(P1093 奖学金) 洛谷真题
题目: n 个学生,每人有语文、数学、英语三科成绩。按「总分降序 → 语文降序 → 学号升序」排序,输出前 5 名的学号与总分。
🔍 详细分析过程
第1步:建结构体 —— 存学号、总分、语文分(数学英语不需要保存,因为比较规则里用不到)。
第2步:写比较器 —— 三层嵌套的 if:先比总分,总分相同比语文,语文也相同比学号。注意最后一定要有 return a.id < b.id;,保证严格弱序 (不同学生学号必然不同,不会出现「相等」)。
第3步:调用 sort —— O(n log n),n ≤ 300 时瞬间完成。
第4步:输出前 5 —— 注意 min(5, n),n 可能小于 5。
C++ P1093 完整代码(比较器写法)
#include <bits/stdc++.h>
using namespace std ;
struct Stu {
int id; // 学号
int chinese; // 语文
int total; // 三科总分
};
// 比较器:返回 true 表示 a 应该排在 b 前面
bool cmp(const Stu& a, const Stu& b) {
if (a.total != b.total) return a.total > b.total; // ① 总分高的在前
if (a.chinese != b.chinese) return a.chinese > b.chinese; // ② 语文高的在前
return a.id < b.id; // ③ 学号小的在前
}
int main() {
ios::sync_with_stdio(0 );
cin.tie(0 );
int n; cin >> n;
vector<Stu> v(n);
for (int i = 0 ; i < n; i++) {
int c, m, e;
cin >> c >> m >> e;
v[i] = {i + 1 , c, c + m + e};
}
sort(v.begin(), v.end(), cmp);
int out = min(5 , n); // 可能不足 5 人
for (int i = 0 ; i < out; i++)
cout << v[i].id << " " << v[i].total << "\n" ;
return 0 ;
}
C++ 同题 lambda 写法(更紧凑,一行比较器)
// 把 cmp 删掉,sort 改成:
sort(v.begin(), v.end(), [](const Stu& a, const Stu& b) {
if (a.total != b.total) return a.total > b.total;
if (a.chinese != b.chinese) return a.chinese > b.chinese;
return a.id < b.id;
});
// 也可以用 tuple 的字典序比较,一行搞定(推荐熟练后使用):
// sort(v.begin(), v.end(), [](const Stu& a, const Stu& b) {
// return tie(b.total, b.chinese, a.id) < tie(a.total, a.chinese, b.id);
// });
运行结果(前 3 组样例数据)
输入: 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
样例验证: 6 号总分 78+89+98 = 265 最高;4 号 88+99+77 = 264;3 号 78+89+91 = 258。与「总分降序」完全一致 ✅ 注意 3 号与 6 号语文都是 78(数学都是 89),若两人总分相同就要靠语文分决胜——这正是第二关键字存在的意义。
🔍 代码逐段解析 · P1093 奖学金(比较器写法)
▶ 逐步播放
↺ 重置
1 struct Stu { id, chinese, total }:只存「比较用得到」的字段——数学英语不参与比较也不输出,不必保存
2 cmp:三层 if。① a.total != b.total 时比总分降序;② 总分相同比语文降序;③ 都相同 return a.id < b.id; —— 最后必须是严格小于 ,且学号互不相同,保证 cmp(x,x)==false
3 读入循环:cin >> c >> m >> e; 后 v[i] = {i+1, c, c+m+e} —— 学号就是 i+1,总分在读入时顺手算好
4 sort(v.begin(), v.end(), cmp):n ≤ 300,O(n log n) 瞬间完成;比较器只按规则排,并列逻辑全交给 cmp 的三段 if
5 输出前 5:int out = min(5, n) 防止 n < 5 时越界——这是本题最容易被忽略的边界
6 易错点:① 把 cmp 写成 return a.total > b.total; return a.chinese > b.chinese;(没有 else/if 链,第二句永远执行不到,逻辑错);② 直接 for (i=0; i<5; i++) 输出,n<5 时越界 RE
📎 一句话总结:多关键字排序 = 比较器里的 if 链 + 最后一句严格小于兜底。
案例 案例2:第 k 小数(nth_element vs sort) 复杂度对比
题目: 给定 n 个整数,求第 k 小的数。n ≤ 5×10⁶,k ≤ n,数字 ≤ 10⁹。
方法一:sort 后取下标 O(n log n) —— 可能 TLE
n = 5×10⁶ 时,n log n ≈ 5×10⁶ × 23 ≈ 1.1×10⁸ 次比较。sort 的常数小,勉强能过,但在 1s 限制下风险较大(尤其配合 cin 输入)。
方法二:nth_element 期望 O(n) —— 稳妥
约 5×10⁶ × 2 = 10⁷ 次操作,时间余量充足。这是 P1923 的推荐解法 。
方法三:手写快速选择 O(n) 期望,常数最小
自己写 partition + 只递归一侧,可以省掉 nth_element 的迭代器抽象开销,在极大数据下更稳。缺点是容易写错边界。
注意: P1923 要求使用较快的读入方式 ——5×10⁶ 个数用 cin >> 即使开了 sync_with_stdio(0) 也可能超时,建议用 scanf 或快读。
C++ 方法二:nth_element(P1923 推荐写法)
#include <bits/stdc++.h>
using namespace std ;
const int MAXN = 5000005 ;
int a[MAXN];
// 快读:5×10^6 的数据必须用
int read() {
int x = 0 , f = 1 ; char c = getchar();
while (c < '0' || c > '9' ) { if (c == '-' ) f = -1 ; c = getchar(); }
while (c >= '0' && c <= '9' ) { x = x * 10 + c - '0' ; c = getchar(); }
return x * f;
}
int main() {
int n = read(), k = read();
for (int i = 0 ; i < n; i++) a[i] = read();
// 第 k 小 → 下标 k-1
nth_element(a, a + k - 1 , a + n);
printf("%d\n" , a[k - 1 ]);
return 0 ;
}
C++ 方法三:手写快速选择(只递归一侧)
#include <bits/stdc++.h>
using namespace std ;
const int MAXN = 5000005 ;
int a[MAXN];
// 返回 a[L..R] 中第 k 小(k 从 1 开始计)
int quickSelect(int L, int R, int k) {
if (L == R) return a[L];
// 三数取中选枢轴,降低退化概率
int mid = (L + R) >> 1 ;
int p = a[L] + a[R] + a[mid] - max({a[L], a[R], a[mid]}) - min({a[L], a[R], a[mid]});
int i = L, j = R, pivot = p;
while (i <= j) {
while (a[i] < pivot) i++;
while (a[j] > pivot) j--;
if (i <= j) { swap(a[i], a[j]); i++; j--; }
}
// 划分后:[L, j] ≤ pivot ≤ [i, R]
if (L + k - 1 <= j) return quickSelect(L, j, k); // 只递归左边
if (L + k - 1 >= i) return quickSelect(i, R, k - (i - L)); // 只递归右边
return pivot; // 答案就是枢轴
}
int main() {
int n, k; scanf("%d %d" , &n, &k);
for (int i = 0 ; i < n; i++) scanf("%d" , &a[i]);
printf("%d\n" , quickSelect(0 , n - 1 , k));
return 0 ;
}
运行结果
输入: 5 3
2 5 1 4 3
输出: 3
输入: 6 2
9 1 8 2 7 3
输出: 2
复杂度总结: sort 是 O(n log n);nth_element 与手写快速选择都是期望 O(n)、最坏 O(n²)。n = 5×10⁶ 时,O(n) 方案比 O(n log n) 快 20 倍以上——这就是为什么「知道有 nth_element 这个函数」能直接决定一道题能否通过 。
🔍 代码逐段解析 · 案例2 两种 O(n) 写法
方法二(nth_element)逐段:
① const int MAXN = 5000005; int a[MAXN]:全局数组,5×10⁶ 个 int 约 20MB,栈上开不下必须放全局区。
② read() 快读:getchar 逐字符读,x = x*10 + c-'0' 拼数,负号用 f 标记——比 scanf 快 3~5 倍,5×10⁶ 规模的保险做法。
③ 核心就一行:nth_element(a, a + k - 1, a + n); printf("%d", a[k-1]); —— 第 k 小对应下标 k−1,写 a+k 就变成求第 k+1 小。
方法三(手写快速选择)逐段:
④ quickSelect(L, R, k):k 始终是「在当前区间内排第几」,L==R 时区间只剩一个元素直接返回。
⑤ 三数取中:p = a[L]+a[R]+a[mid] - max(...) - min(...) —— 三数之和减去最大减去最小就是中位数,防有序数据退化。
⑥ 双指针划分:i、j 相向而行,a[i]<pivot 左进、a[j]>pivot 右退,相遇区间交换后形成 [L,j] ≤ pivot ≤ [i,R]。
⑦ 只递归一侧:目标位置 L+k-1 ≤ j 就进左半(k 不变);≥ i 就进右半(k 减去左边长度 k-(i-L));落在 i、j 之间说明答案恰是枢轴,直接 return pivot。
易错点: ① 快读忘了处理负号/文件末尾,读入全乱;② 递归右半时忘减偏移 k-(i-L),答案整体错位。
案例 案例3:二分查找的四种边界场景 高频考法
题目: 给定一个已排序的数组和若干次查询,分别实现:(1) 判断 v 是否存在;(2) 求 v 的首次出现下标;(3) 求 v 的出现次数;(4) 求「≤ v 的最大值」与「≥ v 的最小值」。
🔍 四个场景对应的函数
(1) 存在性 :binary_search(a, a+n, v),返回 bool。
(2) 首次出现下标 :lower_bound(a, a+n, v) - a;但要注意此时 a[lb] 可能 不等于 v(v 不存在时),需要额外判断。
(3) 出现次数 :upper_bound(...) - lower_bound(...)。这是最优雅的写法,无需任何额外判断。
(4) 前驱与后继 :前驱用 upper_bound 再回退;后继用 lower_bound。都要判边界。
C++ 四种边界场景的完整实现
#include <bits/stdc++.h>
using namespace std ;
long long a[100005 ];
int n, q;
int main() {
ios::sync_with_stdio(0 );
cin.tie(0 );
cin >> n >> q;
for (int i = 0 ; i < n; i++) cin >> a[i];
sort(a, a + n); // ★ 二分的前提:必须有序
while (q--) {
long long v; cin >> v;
// (1) 存在性
bool has = binary_search(a, a + n, v);
// (2) 首次出现的下标(存在时)
int first = -1 ;
int lb = lower_bound(a, a + n, v) - a; // 第一个 >= v
if (lb < n && a[lb] == v) first = lb; // ★ 必须验证 a[lb] == v
// (3) 出现次数
int ub = upper_bound(a, a + n, v) - a; // 第一个 > v
int cnt = ub - lb; // v 不存在时为 0,天然正确
// (4) 前驱与后继
long long pre = LLONG_MIN, suf = LLONG_MAX;
auto it = upper_bound(a, a + n, v);
if (it != a) { --it; pre = *it; }
auto it2 = lower_bound(a, a + n, v);
if (it2 != a + n) suf = *it2;
cout << "v=" << v
<< " 存在=" << (has ? "Y" : "N" )
<< " 首次下标=" << first
<< " 次数=" << cnt
<< " 前驱=" << (pre == LLONG_MIN ? "无" : to_string(pre))
<< " 后继=" << (suf == LLONG_MAX ? "无" : to_string(suf)) << "\n" ;
}
return 0 ;
}
运行结果
输入: 8 4
20 15 20 30 15 20 40 25
15
20
35
50
输出(数组排序后为 15 15 20 20 20 25 30 40):
v=15 存在=Y 首次下标=0 次数=2 前驱=15 后继=15
v=20 存在=Y 首次下标=2 次数=3 前驱=20 后继=20
v=35 存在=N 首次下标=-1 次数=0 前驱=30 后继=40
v=50 存在=N 首次下标=-1 次数=0 前驱=40 后继=无
最经典的错误: 写了 int p = lower_bound(a, a+n, v) - a; if (p < n) cout << "找到了"; —— 这是错的!lower_bound 返回「第一个 ≥ v 的位置」,当 v = 35、数组里是 30 和 40 时,它会返回 40 的位置,此时 p < n 成立但 v 并不存在 。必须写成 if (p < n && a[p] == v)。
🔍 代码逐段解析 · 二分四场景程序
▶ 逐步播放
↺ 重置
1 long long a[100005] 全局数组 + cin >> n >> q:先读数组再 sort(a, a+n) —— 第 ① 行的 sort 是后面所有二分的前提 ,漏掉则全部输出不可信
2 场景 (1):binary_search(a, a+n, v) 只回答存在性,返回 bool——不知道位置时用它最省事
3 场景 (2):lb = lower_bound(...) - a 后必须验证 lb < n && a[lb] == v 才置 first = lb,否则 v 不存在时 first 是错的
4 场景 (3):cnt = ub - lb,[lb, ub) 区间全是 v —— v=20 时 5−2=3,v=35 时 0,无需特判
5 场景 (4):前驱 it = upper_bound(...); if (it != a) --it;;后继 it2 = lower_bound(...); if (it2 != a+n) ... —— 两个边界判断分别防「v 比所有数小」和「v 比所有数大」
6 输出行用三目 + to_string(pre) 混排字符串与数值:pre 无前驱时打印「无」,否则打印数值——运行结果四行与推导逐一对应(如 v=35:存在=N、次数=0、前驱=30、后继=40)
7 易错点:① 漏 sort;② 把「lb < n」当成「找到了」而忘了比 a[lb] == v(上面 ans-bad 的经典错误)
📎 一句话总结:四个场景全建立在 lb/ub 两个位置上;判存在永远多比一次值。
案例 案例4:全排列枚举解「填数算式」 枚举利器
题目: 在 1~9 中选出 9 个不同数字填入 □,使等式 □□□ + □□□ = □□□ 成立(每个数字用一次),输出所有解。例如 124 + 659 = 783。
🔍 详细分析过程
第1步:识别模型 —— 「9 个数字各用一次」= 1~9 的一个全排列 ,共 9! = 362880 种。数量不大,可以直接枚举。
第2步:枚举方式 —— 用 next_permutation 枚举 {1,2,3,4,5,6,7,8,9} 的全部排列,把前 3 位拼成第一个加数、中间 3 位拼成第二个加数、后 3 位拼成和,判断等式是否成立。
第3步:为什么不用 DFS —— 362880 种排列完全可以用 next_permutation 直接枚举,代码只有十几行;DFS 回溯也能做但代码更长。当「枚举全部排列」就是题目要求时,next_permutation 是首选。
第4步:小优化 —— 如果允许加数或和以 0 开头,需要额外处理;本题用 1~9 所以没有前导零问题。
C++ next_permutation 枚举填数算式
#include <bits/stdc++.h>
using namespace std ;
int main() {
vector<int > p = {1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 }; // 从最小排列开始
int solutions = 0 ;
do {
// 前 3 位拼成第一个加数
int x = p[0 ] * 100 + p[1 ] * 10 + p[2 ];
int y = p[3 ] * 100 + p[4 ] * 10 + p[5 ];
int z = p[6 ] * 100 + p[7 ] * 10 + p[8 ];
if (x + y == z) {
cout << x << " + " << y << " = " << z << "\n" ;
solutions++;
}
} while (next_permutation(p.begin(), p.end()));
cout << "共找到 " << solutions << " 组有序解(加数互换算两组)\n" ;
return 0 ;
}
运行结果(截取前 3 行与后 2 行)
124 + 659 = 783
125 + 739 = 864
127 + 359 = 486
...(中间省略)
783 + 162 = 945
784 + 152 = 936
共找到 336 组有序解(加数互换算两组)
实际枚举次数 = 9! = 362880
复杂度分析: 9! = 362880 次枚举,每次 O(1) 判断(数字拼接),总耗时不到 10ms。这就是「枚举全排列」类题目的典型规模——n ≤ 10 时放心用,n ≥ 12 就要换算法了 (12! ≈ 4.8×10⁸)。
🔍 代码逐段解析 · 填数算式枚举程序
▶ 逐步播放
↺ 重置
1 vector<int> p = {1,2,...,9}:初始即最小排列,保证 next_permutation 从字典序第一个开始,不重不漏
2 solutions = 0:解计数器;do 循环体内做「排列 → 三个三位数」的映射
3 拼接三数:x = p[0]*100 + p[1]*10 + p[2]、y 取 p[3..5]、z 取 p[6..8] —— 前三位是第一个加数、中间三位第二个加数、末三位是和
4 if (x + y == z):成立就输出并 solutions++ —— 全排列保证 9 个数字互不重复,「每个数字用一次」的约束自动满足
5 while (next_permutation(...)):每次把 p 推进到字典序下一排列,362880 次后回到起点返回 false 结束 —— 实测共 336 组有序解 (124+659=783 是字典序第一个,784+152=936 是最后一个)
6 易错点:① 初始 p 不是 {1..9} 而是随手写的顺序 → 解会漏;② 输出 x、y 互换算两组的说明没写清,与「168 组无序解」混淆
📎 一句话总结:把「填数」翻译成「全排列 + 三段拼接 + 等式判断」,next_permutation 负责不重不漏。