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

算法模板库 常用函数 全解析

把 «algorithm» 与 «numeric» 里的二十多个常用函数一次吃透——从此排序、去重、二分、求第 k 小、枚举全排列都只用一行。

20+ 个常用函数 O(n) nth_element 求第 k 小 4 个递进案例 3 处交互动画 12 道洛谷原题
本讲 · 第 9/52 讲算法模板库常用函数
模块 01

情景导航

用一次模考成绩单的处理流程,串起本讲要学的全部函数。

故事一次模考的成绩单

模考结束,集训队有 10 万名学员的成绩需要处理。教练给助教列了一张清单:

清单第 1 条:「按总分从高到低排序,总分相同按学号从小到大。」——助教老老实实手写了一个冒泡排序加两层判断,写了 40 行。

清单第 2 条:「把所有人的分数去重后输出有哪些不同的分数。」——助教开了个桶数组,写了一个双重循环,又写了 15 行。

清单第 3 条:「找出哪几个人的分数大于等于某个分数线。」——助教又写了个二分查找,结果因为边界写错(while(l < r) 还是 <=?r = mid 还是 mid-1?)调试了半小时。

清单第 4 条:「找出这次考试的第 3000 名是多少分。」——助教又写了一遍排序。

清单第 5 条:「把这 10 万人分成 5 个人一批,输出所有可能的分批方案。」——助教彻底放弃了。

教练看了一眼代码,淡淡地说:「这些需求,标准库全都有现成的函数,而且比你自己写的更快、更不容易错。你需要的只是知道它们叫什么、在哪个头文件、怎么传参数。」

会用标准库,是从「能写代码」到「能在考场上按时写完」的关键一步。

💡 为什么必须熟练使用标准库函数

①速度:sort 是精心优化的快排 + 堆排 + 插排混合算法(introsort),平均常数远小于绝大多数手写排序;nth_element 更是把「求第 k 小」从 O(n log n) 降到 O(n)。

②正确性:二分查找的边界问题是竞赛中最常见的「调试两小时、代码二十行」陷阱,标准库的 lower_bound 已经替你处理好了所有边界。

③可读性:a.erase(unique(a.begin(), a.end()), a.end()) 一行就能表达「去重」,比手写双重循环清晰得多。

本讲的学习方法:不要背函数签名,而是记住「我要做什么 → 该用哪个函数 → 返回什么」这条链路。下面我们会用一张决策流程图帮你建立这个映射。

①生活类比
🗂️

整理书架 = sort

按书名、按厚度、按颜色…给一个规则就能排好。

→ sort(begin, end, cmp)
🔎

查词典 = lower_bound

不必一页页翻,直接翻到中间比较,再决定往前还是往后。

→ 二分查找 O(log n)
🚮

倒掉重复印刷 = unique

先把相同页相邻排好,再把多余的那叠整批扔掉。

→ unique + erase
🎰

排队轮换 = next_permutation

像洗牌一样,每次给出「下一组排列」,直到所有组合走完。

→ 字典序下一排列
②核心定义

«algorithm» 头文件:提供 sort / stable_sort / nth_element / lower_bound / upper_bound / binary_search / unique / reverse / fill / min / max / swap / next_permutation 等算法函数,全部以迭代器区间 [first, last) 作为操作对象。

«numeric» 头文件:提供 accumulate(累加)、iota(递增填充)、gcd(最大公约数,C++17)等数值算法。

半开区间 [first, last):所有标准算法都遵循「左闭右开」约定——包含 first,不包含 last。这是理解 lower_bound 返回值与 unique 用法的关键。

严格弱序(Strict Weak Ordering):比较器必须满足「自己和自己比较返回 false」。写成 <= 会破坏这个性质,导致 sort 段错误或结果错乱。

GCC 内建函数:__builtin_popcount(统计 1 的个数)、__builtin_ctz(末尾 0 的个数)、__builtin_clz(前导 0 的个数)等,编译成单条 CPU 指令,比手写循环快得多。

📌 本讲核心知识地图

板块核心内容对应大纲
排序家族sort / stable_sort / 自定义比较 / 多关键字算法模板库常用函数
最值与交换min / max / minmax / clamp / max_element / swap算法模板库常用函数
区间操作reverse / unique + erase / rotate / fill算法模板库常用函数
二分三兄弟lower_bound / upper_bound / binary_search / equal_range算法模板库常用函数
数值与统计accumulate / count / count_if / __builtin_popcount算法模板库常用函数
选择性算法nth_element 求第 k 小(O(n))算法模板库常用函数
排列枚举next_permutation / prev_permutation算法模板库常用函数
数组初始化memset 的字节填充陷阱 vs fill算法模板库常用函数
③核心口诀
🔑 记忆主线

排序 sort 稳 stable,去重 先排序再 unique;
二分只认有序区,lower 找 ≥,upper 找 >;
第 k 小用 nth_element,排列枚举 next_permutation。

🔑 三个必防的坑

① memset 只能填 0 / −1 / 0x3f,填 1 会得到 16843009!
② sort 的比较器永远用严格小于,写成 <= 会 RE。
③ unique 不真正删除元素,它只把重复项挪到末尾,必须配合 erase 或手动截断长度。

模块 02

知识探究

按「排序 → 最值 → 区间 → 二分 → 统计 → 选择 → 排列」的顺序,把常用函数逐个拆解。

1排序家族:sort 与 stable_sort 核心

sort(first, last, cmp) 对区间 [first, last) 进行升序排序。它是 introsort:快速排序 + 堆排序 + 插入排序的混合体,最坏 O(n log n),平均常数极小。

1
⚡
sort

不保证稳定,最快,竞赛首选。

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 号垫底
3stable_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 也不能用(它本身有序)。

🔍 代码逐段解析 · 排序家族演示程序
1struct Stu { id, total, cpp }:存学号、总分、C++ 单科三个字段;show() 是打印辅助函数,输出 (id,total) 序列便于对比排序结果
2cmp1:普通函数比较器——先比 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
5l == 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 就当「找到了」。

把上面这个数组画出来,格子高亮会依次扫过(这正是二分「不停缩小候选区间」的直观感受):

1
3
3
3
5
7
9
11
11
当前扫描位置数组元素
5memset 陷阱与 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])
1memset 的第三个参数是字节数,它的工作方式是「把内存逐字节都写成低 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
50x3f 的妙处:每字节 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 去填。

6nth_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)
3GCC 实测结果: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 或手写快速选择。

7next_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 奖学金(比较器写法)
1struct Stu { id, chinese, total }:只存「比较用得到」的字段——数学英语不参与比较也不输出,不必保存
2cmp:三层 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,总分在读入时顺手算好
4sort(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)。
🔍 代码逐段解析 · 二分四场景程序
1long 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⁸)。
🔍 代码逐段解析 · 填数算式枚举程序
1vector<int> p = {1,2,...,9}:初始即最小排列,保证 next_permutation 从字典序第一个开始,不重不漏
2solutions = 0:解计数器;do 循环体内做「排列 → 三个三位数」的映射
3拼接三数:x = p[0]*100 + p[1]*10 + p[2]、y 取 p[3..5]、z 取 p[6..8] —— 前三位是第一个加数、中间三位第二个加数、末三位是和
4if (x + y == z):成立就输出并 solutions++ —— 全排列保证 9 个数字互不重复,「每个数字用一次」的约束自动满足
5while (next_permutation(...)):每次把 p 推进到字典序下一排列,362880 次后回到起点返回 false 结束 —— 实测共 336 组有序解(124+659=783 是字典序第一个,784+152=936 是最后一个)
6易错点:① 初始 p 不是 {1..9} 而是随手写的顺序 → 解会漏;② 输出 x、y 互换算两组的说明没写清,与「168 组无序解」混淆
📎 一句话总结:把「填数」翻译成「全排列 + 三段拼接 + 等式判断」,next_permutation 负责不重不漏。
模块 03

实践应用

完整题目 + 样例 + 多角度分析 + 代码 + 运行结果。

题1分数线划定(多关键字排序 + 向下取整)
难度普及- · sort 应用

题目背景

世博会志愿者的选拔工作正在 A 市如火如荼地进行。为了选拔最合适的人才,A 市对所有报名的选手进行了笔试,笔试分数达到面试分数线的选手方可进入面试。面试分数线根据计划录取人数的 150% 划定,即如果计划录取 m 名志愿者,则面试分数线为排名第 m×150%(向下取整)名的选手的分数,而最终进入面试的选手为笔试成绩不低于面试分数线的所有选手。

现在就请你编写程序划定面试分数线,并输出所有进入面试的选手的报名号和笔试成绩。

输入格式:第一行两个整数 n, m(5 ≤ n ≤ 5000,3 ≤ m ≤ n),中间用一个空格隔开,其中 n 表示报名参加笔试的选手总数,m 表示计划录取的志愿者人数。第二行到第 n+1 行,每行两个整数,中间用一个空格隔开,分别是选手的报名号 k 和该选手的笔试成绩 s。

输出格式:第一行两个整数,用一个空格隔开,第一个整数表示面试分数线,第二个整数表示进入面试的选手的实际人数。接下来若干行,每行两个整数表示进入面试的选手的报名号和笔试成绩,按照笔试成绩从高到低输出,如果成绩相同,则按报名号由小到大的顺序输出。

输入样例:

6 3
1000 90
3239 88
2390 95
7231 84
1005 95
1001 88

输出样例:

88 5
1005 95
2390 95
1000 90
1001 88
3239 88

数据范围:5 ≤ n ≤ 5000,3 ≤ m ≤ n,成绩 0~100;1s / 128MB。

方法一:手写冒泡 / 选择排序 + 手写比较O(n²) = 2.5×10⁷,能过但没必要

n ≤ 5000 时 O(n²) 勉强能过,但代码量大、容易在「成绩相同按报名号升序」这一步写错。

方法二:sort + 比较器O(n log n),10 行搞定

写一个两关键字比较器(成绩降序 → 报名号升序),排序后第 m*1.5 名(下标 m*3/2 - 1)的成绩就是分数线。再从前往后扫,把所有 score >= 分数线 的人输出即可——注意并列会让实际人数超过 m×150%。

关键细节:m * 150 / 100 就是 m * 3 / 2,C++ 的整数除法天然向下取整,不用额外调用 floor。

C++完整代码(sort + 双关键字比较器)
#include <bits/stdc++.h>
using namespace std;

struct P {
    int id;      // 报名号
    int score;   // 笔试成绩
};

bool cmp(const P& a, const P& b) {
    if (a.score != b.score) return a.score > b.score;  // 成绩降序
    return a.id < b.id;                              // 成绩相同报名号升序
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);

    int n, m; cin >> n >> m;
    vector<P> v(n);
    for (int i = 0; i < n; i++) cin >> v[i].id >> v[i].score;

    sort(v.begin(), v.end(), cmp);

    int rank = m * 3 / 2;            // m × 150%,整数除法自动向下取整
    int line = v[rank - 1].score;      // 第 rank 名的成绩就是分数线(下标 rank-1)

    int cnt = 0;
    while (cnt < n && v[cnt].score >= line) cnt++;   // 并列会让人数超过 rank

    cout << line << " " << cnt << "\n";
    for (int i = 0; i < cnt; i++)
        cout << v[i].id << " " << v[i].score << "\n";
    return 0;
}
运行结果
88 5
1005 95
2390 95
1000 90
1001 88
3239 88

🧠 逐步推演

排序后:(1005,95) (2390,95) (1000,90) (1001,88) (3239,88) (7231,84)

rank = 3 × 3 / 2 = 4(整数除法),第 4 名是 (1001,88),分数线 = 88。

从前往后数 score >= 88 的人数:95、95、90、88、88 共 5 人 —> 超过计划的 4 人,这正是「并列」导致的。

考点:① 多关键字排序;② 整数除法向下取整;③ 并列处理(不能简单输出前 rank 个人)。

🔍 代码逐段解析 · 分数线划定(sort + 双关键字)
1struct P { id, score } + cmp:成绩降序、同分报名号升序——两关键字 if 链,末尾 return a.id < b.id; 严格小于兜底
2读入 n、m 与 n 行 (id, score),sort(v.begin(), v.end(), cmp) 一次排好——样例排序后为 (1005,95)(2390,95)(1000,90)(1001,88)(3239,88)(7231,84)
3int rank = m * 3 / 2:m=3 时 3×3/2 = 4(整数除法自动向下取整)——「150% 向下取整」的精确翻译,绝不能写 m*1.5 再取整
4line = v[rank-1].score:第 4 名 (1001,88) 的成绩 88 就是分数线——下标 rank−1 是「第 rank 名」的 0 基换算,最易错的一行
5while (cnt < n && v[cnt].score >= line) cnt++:从前往后扫,95、95、90、88、88 共 5 人 ≥ 88 —— 并列让实际人数 (5) 超过 rank (4),这正是题目要的「所有不低于分数线的选手」
6输出 line cnt 再输出前 cnt 人的 (id, score) —— 与输出样例「88 5 + 5 行」完全一致
7易错点:① 用浮点 m*1.5 + ceil 会把分数线算到 84,多放一堆人进面试;② 只输出前 rank=4 个人,漏掉并列的 3239
📎 一句话总结:sort 排名次、m*3/2 定线、≥line 扫描收并列——三步各自对应一个考点。

💡 如果把排名算错的后果

有人会写成 m * 1.5(浮点)再 ceil,那会得到 5,分数线变成 88 之下的 84,全班都进面试。所以「150% 向下取整」必须严格翻译成 m * 3 / 2 的整数运算,绝不能用浮点。

题2A-B 数对(lower_bound 计数 / map 计数 对比)
难度普及- · 二分计数

题目背景

出题是一件痛苦的事情!同样的题目,同样的数据,实际上会得到不同的分数,这让人很苦恼。现在给定 N 个整数 A₁…Aₙ,以及一个正整数 C,求有多少对 (i, j) 满足 Aᵢ − Aⱼ = C(i ≠ j)。

输入格式:第一行两个整数 N, C。第二行 N 个整数。

输出格式:一行,表示满足条件的数对个数。

输入样例:4 1
1 1 2 3

输出样例:3

数据范围:1 ≤ N ≤ 2×10⁵,0 < C < 2³⁰,所有输入整数不超过 2³⁰;1s / 128MB。

方法一:暴力双重循环O(n²) = 4×10¹⁰,必然 TLE

方法二:排序 + 对每个 Aⱼ 二分查找 Aⱼ+C 的个数O(n log n)

排序后,对每个位置 j,值 v = A[j] + C 的所有出现次数等于 upper_bound(v) - lower_bound(v)。累加即可。这正是 lower_bound/upper_bound 组合最经典的用法。

方法三:map 计数O(n log n),思路更直白

先统计每个值的出现次数,再对每个值 v 累加 cnt[v] × cnt[v + C]。注意 C > 0,所以不会重复计数。

C++方法二:排序 + 二分计数(完整代码)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);

    int n; long long c;
    cin >> n >> c;

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

    sort(a.begin(), a.end());        // ★ 二分的前提

    long long ans = 0;               // 答案可能很大,用 long long
    for (int j = 0; j < n; j++) {
        long long target = a[j] + c;                     // 找 A[i] = A[j] + C
        auto lb = lower_bound(a.begin(), a.end(), target);  // 第一个 ≥ target
        auto ub = upper_bound(a.begin(), a.end(), target);  // 第一个 > target
        ans += ub - lb;                                       // target 的出现次数
    }
    cout << ans << "\n";
    return 0;
}
C++方法三:map 计数(思路不同,答案相同)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0);
    int n; long long c; cin >> n >> c;
    map<long long, long long> cnt;
    for (int i = 0; i < n; i++) { long long x; cin >> x; cnt[x]++; }
    long long ans = 0;
    for (auto& kv : cnt) {
        auto it = cnt.find(kv.first + c);     // ★ 用 find,别用 []
        if (it != cnt.end()) ans += kv.second * it->second;
    }
    cout << ans << "\n";
    return 0;
}
运行结果(两种代码相同)
输入: 4 1
      1 1 2 3
输出: 3
(三对为 (2,1)、(2,1)、(3,2)——两个 1 各与 2 配一次,加上 3−2)

🧠 逐步推演(a = [1,1,2,3],C = 1)

排序后 a = [1, 1, 2, 3]。

j=0, a[j]=1, target=2:lower_bound(2) 得下标 2,upper_bound(2) 得下标 3 → 贡献 1

j=1, a[j]=1, target=2:同样贡献 1

j=2, a[j]=2, target=3:lower_bound(3)=3,upper_bound(3)=4 → 贡献 1

j=3, a[j]=3, target=4:lower=upper=4 → 贡献 0

总计 1 + 1 + 1 + 0 = 3 ✅ 对应数对 (2−1)、(2−1)、(3−2),共 3 对。

注意:因为 C > 0,所以每对 (Aᵢ, Aⱼ) 恰好被算一次,不会重复。若 C = 0,则每个值会被算 cnt² 次,需要改成 cnt*(cnt-1) 的组合数。

提示:答案最大可到 O(n²) 量级(如 n = 2×10⁵、所有数相同且 C = 0 时约 4×10¹⁰),必须用 long long。ub - lb 的类型是 ptrdiff_t,赋值给 long long 是安全的。

🔍 代码逐段解析 · A-B 数对两种写法

方法二(排序 + 二分计数)逐段:

① long long c 与 vector<long long> a:值可达 2³⁰,a[j]+c 会超 int,全程 long long。

② sort(a.begin(), a.end()):二分的前提,漏掉则 ub−lb 全错。

③ 核心循环:对每个 j 求 target = a[j] + c,ans += ub - lb——[lb, ub) 恰是 target 的全部出现位置。样例四轮贡献 1+1+1+0=3。

方法三(map 计数)逐段:

④ map<long long, long long> cnt:读入时 cnt[x]++ 顺手统计频次——map 的 [] 对不存在的键自动插入 0。

⑤ 遍历 for (auto& kv : cnt):cnt.find(kv.first + c) 查「比 kv 大 C 的值」是否存在,存在则 ans += kv.second * it->second。因为 C > 0,每对只被大值一侧计一次,不重不漏。

⑥ 易错点 1:⑤ 里误写 cnt[kv.first + c]——[] 会把不存在的键插进 map,污染正在遍历的容器且虚增答案;必须用 find。

⑦ 易错点 2:若 C 可能等于 0,方法三会把同一元素和自己配上(cnt²),需改成 cnt*(cnt-1);方法二也同理需特殊处理。

题3综合:去重 + 第 k 小 + 中位数(三函数联用)
难度普及+ · 综合应用

题目背景

给定 n 个整数。要求依次完成:
(1) 输出去重后按升序排列的结果;
(2) 输出去重后第 k 小的数;
(3) 输出去重后的中位数(偶数个时输出中间两个的平均值,保留一位小数);
(4) 输出原始数组中恰好出现 2 次的值的个数。

输入格式:第一行两个整数 n, k。第二行 n 个整数。

输出格式:共 4 行,依次对应上面 4 个问题。

输入样例:7 3
5 3 5 1 3 8 3

输出样例:

1 3 5 8
5
4.0
1

数据范围:1 ≤ k ≤ 去重后元素个数 ≤ n ≤ 10⁵,|a[i]| ≤ 10⁹;1s / 256MB。

🔍 逐问对应函数

(1) 去重 + 升序:sort + unique + erase 三件套。或者直接丢进 set(上一讲学过)。

(2) 第 k 小:去重后的数组已经有序,直接 u[k-1] 即可;如果想在原始数组上做,用 nth_element 更快。

(3) 中位数:偶数长度时取 (u[m/2 - 1] + u[m/2]) / 2.0,注意要用 double 除法并 setprecision(1)。

(4) 恰好出现 2 次的值个数:用 map 统计频次后 count_if 条件为 kv.second == 2;或者在排序后一遍扫描统计「连续相等段长度」。本题用 map 更直观。

C++完整代码(四问一网打尽)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);

    int n, k; cin >> n >> k;
    vector<long long> a(n);
    map<long long, int> freq;
    for (int i = 0; i < n; i++) { cin >> a[i]; freq[a[i]]++; }

    // (1) 去重 + 升序
    vector<long long> u = a;
    sort(u.begin(), u.end());
    u.erase(unique(u.begin(), u.end()), u.end());
    for (int i = 0; i < (int)u.size(); i++)
        cout << u[i] << (i + 1 < (int)u.size() ? ' ' : '\n');

    // (2) 第 k 小:去重后有序,直接下标访问
    cout << u[k - 1] << "\n";

    // (3) 中位数
    int m = u.size();
    double med;
    if (m % 2 == 1) med = u[m / 2];
    else med = (u[m / 2 - 1] + u[m / 2]) / 2.0;   // 2.0 保证浮点除法
    cout << fixed << setprecision(1) << med << "\n";

    // (4) 恰好出现 2 次的值个数
    cout << count_if(freq.begin(), freq.end(),
                     [](const pair<const long long, int>& kv) { return kv.second == 2; })
         << "\n";
    return 0;
}
运行结果
输入: 7 3
      5 3 5 1 3 8 3
输出:
1 3 5 8
5
4.0
1

🧠 逐步推演

原数组 [5,3,5,1,3,8,3],频次:{1:1, 3:3, 5:2, 8:1}。

(1) 排序后 [1,3,3,3,5,5,8],unique 得到 [1,3,5,8] → 输出 1 3 5 8

(2) 第 3 小 = u[2] = 5 ✅

(3) m = 4 为偶数,中位数 = (u[1] + u[2]) / 2.0 = (3 + 5) / 2.0 = 4.0 ✅

(4) 恰好出现 2 次的值只有 5,共 1 个 ✅

最易错的两处:① 中位数写成 (u[1] + u[2]) / 2 —— 两个 long long 相除会得到整数 4 而不是 4.0,必须写 / 2.0 或先转 double;② count_if 的 lambda 参数类型必须与 map 的 value_type 一致,即 pair<const long long, int>,写错会编译不过。
🔍 代码逐段解析 · 四问综合程序
1读入与频次表:for (...) { cin >> a[i]; freq[a[i]]++; } —— 一趟循环同时存原数组(供 (1)(3) 用)和 map 频次表 freq(供 (4) 用)
2问 (1):拷贝 u = a → sort → u.erase(unique(...), u.end()) 去重三件套;输出时用三目控制行末空格 (i+1 < size ? ' ' : '\n')
3问 (2):u 已升序,第 k 小就是 u[k-1] —— 样例 u = {1,3,5,8},k=3 → u[2] = 5;这里下标换算 k−1 又出现了
4问 (3):m=4 为偶数,(u[m/2-1] + u[m/2]) / 2.0 = (3+5)/2.0 = 4.0;2.0 不能写成 2,否则整数除法丢掉小数;fixed << setprecision(1) 保证输出 4.0 而非 4
5问 (4):count_if(freq.begin(), freq.end(), ...) 对每个键值对判断 kv.second == 2 —— 样例中只有 5 出现 2 次,得 1。若题目要求「≥2 次」只需改比较符
6易错点:① 忘记在 (1) 之前保存原始 a,直接在 a 上去重会让后面的频次统计乱掉(本程序用 u 拷贝规避);② lambda 参数写成 pair<long long, int> 少了 const,编译错误
📎 一句话总结:unique 管去重、下标管第 k 小、/2.0 管中位数、count_if 管频次——四个函数各管一问。
模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心
sortsort(f, l, cmp),O(n log n),需要随机访问迭代器
stable_sort稳定排序,相等元素保持原相对次序,常数略大
比较器铁律返回 true = a 在前;只能用严格小于,禁止 <=
partial_sort只排前 k 个(保证是全局最小的 k 个且有序),O(n log k)
min / max返回值;多值用 min({a,b,c}) 花括号
max_element返回迭代器;下标 = it - begin()
swapswap(a,b) 交换值;x.swap(y) 交换容器内容 O(1)
reverse原地翻转 O(n),可只翻部分区间
去重三件套sort → unique → erase,三步缺一不可
lower_bound第一个 ≥ v 的位置,O(log n),要求有序
upper_bound第一个 > v 的位置;次数 = ub - lb
binary_search只返回 bool,不返回位置
accumulateaccumulate(f, l, 0LL),第三参是初值,用 0LL 防溢出
count / count_ifcount 统计等于某值;count_if 统计满足谓词
nth_element期望 O(n) 求第 k 小,第 k 小下标是 k-1
next_permutation字典序下一排列,必须从最小排列开始,单次 O(n)
memset只能填 0 / −1 / 0x3f,其他值用 fill
GCC 内建__builtin_popcount(1 的个数)、ctz(末尾 0)、clz(前导 0)
C++万能模板:标准库常用函数速记卡
#include <bits/stdc++.h>      // 包含 algorithm / numeric / 全部容器
using namespace std;

struct Node { int score, id; };

// ============ 一、排序与最值 ============
void sortDemo(vector<int>& v, vector<Node>& w) {
    sort(v.begin(), v.end());                                  // 升序
    sort(v.begin(), v.end(), greater<int>());                 // 降序
    sort(w.begin(), w.end(), [](const Node& a, const Node& b) { // 多关键字
        if (a.score != b.score) return a.score > b.score;
        return a.id < b.id;                                    // 最后必须用严格小于
    });
    stable_sort(w.begin(), w.end(),                          // 稳定排序
                [](const Node& a, const Node& b) { return a.score > b.score; });
    partial_sort(v.begin(), v.begin() + 3, v.end());          // 前 3 小且有序

    int mx = *max_element(v.begin(), v.end());
    int mxi = max_element(v.begin(), v.end()) - v.begin();
    int mn = min({3, 1, 4});                                  // 多值取最小
    auto p = minmax(5, 2);                                   // p.first 最小, p.second 最大
}

// ============ 二、区间操作 ============
void rangeDemo(vector<int>& v) {
    reverse(v.begin(), v.end());                               // 翻转
    rotate(v.begin(), v.begin() + 2, v.end());               // 循环左移
    fill(v.begin(), v.end(), 7);                               // 全部填 7
    iota(v.begin(), v.end(), 1);                              // 依次填 1,2,3,...

    // ★ 去重三件套
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());

    long long sum = accumulate(v.begin(), v.end(), 0LL);
    int c1 = count(v.begin(), v.end(), 7);
    int c2 = count_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; });
}

// ============ 三、二分三兄弟(要求区间有序!)============
void binaryDemo(vector<int>& v, int x) {
    sort(v.begin(), v.end());                                  // 前提
    bool has = binary_search(v.begin(), v.end(), x);
    int lb = lower_bound(v.begin(), v.end(), x) - v.begin();   // 第一个 >= x
    int ub = upper_bound(v.begin(), v.end(), x) - v.begin();   // 第一个 >  x
    int cnt = ub - lb;                                          // x 的出现次数
    bool realHas = (lb < (int)v.size() && v[lb] == x);        // ★ 必须验证

    // 前驱:≤ x 的最大值
    auto it = upper_bound(v.begin(), v.end(), x);
    if (it != v.begin()) { --it; /* *it 就是前驱 */ }
    // 后继:≥ x 的最小值
    auto it2 = lower_bound(v.begin(), v.end(), x);
    // if (it2 != v.end()) { ... }

    // 降序数组要传 greater
    // lower_bound(d.begin(), d.end(), x, greater<int>());
}

// ============ 四、选择与排列 ============
void selectDemo(vector<int>& v, int k) {
    // 第 k 小(期望 O(n))
    nth_element(v.begin(), v.begin() + k - 1, v.end());
    int kthSmallest = v[k - 1];

    // 第 k 大
    nth_element(v.begin(), v.begin() + k - 1, v.end(), greater<int>());
    int kthLargest = v[k - 1];

    // 全排列枚举(必须从最小排列开始)
    sort(v.begin(), v.end());
    do {
        // 处理当前排列 v
    } while (next_permutation(v.begin(), v.end()));

    // 上一个排列
    // prev_permutation(v.begin(), v.end());
}

// ============ 五、初始化与位运算内建 ============
int g[1005][1005];
void initDemo() {
    memset(g, 0, sizeof(g));              // ✅ 填 0
    memset(g, -1, sizeof(g));             // ✅ 填 -1
    memset(g, 0x3f, sizeof(g));            // ✅ 填「无穷大」0x3f3f3f3f
    // memset(g, 1, sizeof(g));              // ❌ 得到 16843009
    fill(&g[0][0], &g[0][0] + 1005 * 1005, 1);  // ✅ 真正填 1

    unsigned int x = 12345;
    int pc = __builtin_popcount(x);      // 1 的个数
    int tz = __builtin_ctz(x);           // 末尾 0 的个数(lowbit 的指数)
    int lz = __builtin_clz(x);           // 前导 0 的个数
    int hi = 31 - lz;                    // 最高位编号
    int lowb = x & -x;                   // lowbit
    // long long 版本:__builtin_popcountll / ctzll / clzll
}

🔍 代码逐段解析 · 万能模板速记卡

sortDemo(排序与最值):① sort 升序 / greater<int>() 降序;② 多关键字 lambda——if (a.score != b.score) return a.score > b.score; 后 return a.id < b.id;,末句严格小于;③ partial_sort(v.begin(), v.begin()+3, v.end()) 只保证前 3 位是最小 3 个且有序;④ *max_element 解引用取值、减 begin() 取下标;⑤ minmax(5,2) 的 first=2 是最小值。

rangeDemo(区间操作):reverse / rotate / fill / iota 各一行;去重三件套 sort → erase(unique(...), end) 写成两行——unique 的返回值直接当 erase 的左端点;accumulate(v, 0LL) 初值必须 long long。

binaryDemo(二分三兄弟):先 sort 再查;cnt = ub - lb 求次数;存在性验证 lb < v.size() && v[lb] == x 一行都不少;前驱 = upper_bound 后 --it(先判 it != begin);降序数组记得传 greater<int>()。

selectDemo(选择与排列):第 k 小 nth_element(v.begin(), v.begin()+k-1, v.end());第 k 大换 greater,nth 位置不变仍是 k−1;全排列 sort 后 do…while(next_permutation)。

initDemo(初始化与内建):memset 只写 0/−1/0x3f 三种;填 1 用 fill(&g[0][0], &g[0][0]+1005*1005, 1) 二维写法;__builtin_popcount/ctz/clz + x & -x 求 lowbit。

易错点:① 这份速记卡里所有 lambda 参数都带 const ...&,直接值传递也能编译但结构体大时慢;② 二维数组 fill 的终点 +n*m 是按内存连续排布算的,数组必须是全局或一维指针连续内存。

②拓展案例
拓1离散化:把大值域压缩成小下标

题目:给定 n 个数(n ≤ 10⁵,|a[i]| ≤ 10⁹),把它们替换成「排名」(最小的数替换为 1,次小的替换为 2……相同数替换为相同排名)。

思路:离散化三步——拷贝 + 排序 + 去重,再用 lower_bound 查每个原值在去重数组中的下标,加 1 即排名。这是树状数组 / 线段树 / 主席树等所有「按下标建树」算法的前置步骤。

unique 与 lower_bound 的配合,是离散化的标志性写法。

C++离散化标准模板
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    vector<long long> a(n);
    for (auto& x : a) cin >> x;

    // ① 拷贝一份用于排序去重
    vector<long long> b = a;
    sort(b.begin(), b.end());
    b.erase(unique(b.begin(), b.end()), b.end());

    // ② 把原数组换成 1..m 的排名
    for (int i = 0; i < n; i++)
        a[i] = lower_bound(b.begin(), b.end(), a[i]) - b.begin() + 1;

    for (int x : a) cout << x << " ";
    cout << "\n";
    cout << "不同值的个数 = " << b.size() << "\n";
    return 0;
}
运行结果
输入: 7
      1000000000 5 -3 5 999 1000000000 -3
输出: 4 2 1 2 3 4 1 
不同值的个数 = 4
(去重排序后为 [-3, 5, 999, 1000000000],排名依次为 1、2、3、4;
  两个 -3 都映射到 1,两个 1000000000 都映射到 4)
核心步骤记忆:copy → sort → unique → erase → lower_bound 查下标 + 1。全部用标准库函数,一共 5 行,这就是「熟练使用模板库」最直接的价值。

🔍 代码逐段解析 · 离散化模板

① 拷贝字典:vector<long long> b = a; —— b 是副本,原数组 a 保持输入顺序;对 b 做 sort + erase(unique(...), end) 得到「有序无重复」的值表,样例得 [-3, 5, 999, 1000000000]。

② 反查排名:a[i] = lower_bound(b.begin(), b.end(), a[i]) - b.begin() + 1 —— lower_bound 找到的下标是 0 基排名,+1 变成 1 基。两个 −3 都映射到 1,两个 10⁹ 都映射到 4。

③ 输出:先输出替换后的 a,再输出 b.size() 即不同值个数 m —— 后续树状数组/线段树就开 m 大小。

易错点:① 忘 +1 得到 0 基排名,混用于「1..m」体系的算法会错位;② 在 b 上排序去重却忘了先拷贝,把 a 本身排序后原顺序丢失。

拓2三关键字排序 + 排名并列处理

题目:n 名选手,每人有「总分、罚时、提交时间」三个属性。按总分降序 → 罚时升序 → 提交时间升序排名,分数完全相同的选手名次并列(如 1,1,3 而非 1,2,3)。输出每人的名次。

思路:排序用 tuple 的三元字典序比较(配合取负号实现「降序」),名次并列用「与前一个人完全相同时沿用上个名次」的一遍扫描。

C++三关键字排序 + 并列名次
#include <bits/stdc++.h>
using namespace std;

struct P {
    int id, score, penalty, submit, rank;
};

int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    vector<P> v(n);
    for (int i = 0; i < n; i++) {
        v[i].id = i;
        cin >> v[i].score >> v[i].penalty >> v[i].submit;
    }

    // 用 tuple 的字典序比较:注意「降序」通过取负号实现
    sort(v.begin(), v.end(), [](const P& a, const P& b) {
        return make_tuple(-a.score, a.penalty, a.submit) <
               make_tuple(-b.score, b.penalty, b.submit);
    });

    // 并列名次:与前一个人三项都相同则名次相同
    for (int i = 0; i < n; i++) {
        if (i == 0) v[i].rank = 1;
        else if (v[i].score == v[i-1].score &&
                 v[i].penalty == v[i-1].penalty &&
                 v[i].submit == v[i-1].submit)
            v[i].rank = v[i-1].rank;         // 并列
        else v[i].rank = i + 1;             // 跳过并列占用的名次
    }

    for (auto& p : v) cout << "选手" << p.id << " 第 " << p.rank << " 名\n";
    return 0;
}
运行结果
输入: 5
      300 10 1
      280 5 2
      300 10 1
      300 10 3
      250 0 4
输出: 选手0 第 1 名
      选手2 第 1 名
      选手3 第 3 名
      选手1 第 4 名
      选手4 第 5 名
(选手 0 与选手 2 三项完全相同 → 并列第 1;
  下一位选手 3 名次跳到 3,体现「并列占位」)
要点:并列名次的规则是「相同的名次相同,下一个不同的人名次 = 他所在的下标 + 1」,这正好实现了 1,1,1,1,5 的效果。不要写成「上一个名次 + 1」,那会得到 1,1,2,2,3。

🔍 代码逐段解析 · 三关键字排序程序

① 读入与编号:v[i].id = i 在读入前先存下标——排序会打乱顺序,id 是唯一能找回「原第几个人」的线索。

② tuple 比较器:make_tuple(-a.score, a.penalty, a.submit) < make_tuple(...) —— 总分取负实现「降序」,罚时、提交时间原样比较实现「升序」,一行抵三段 if;前提是各字段不溢出(score 取负需注意极值)。

③ 并列名次扫描:i==0 时 rank=1;三项都等于前一人则 rank = v[i-1].rank;否则 rank = i + 1 —— 「下标 + 1」自动跳过并列占用的名次。样例中选手 0、2 并列第 1,选手 3 落到第 3。

易错点:① 用 -a.score 时 score 若是 int 最小值会溢出(本题分数为正没问题);② 并列判断漏比 submit,会让提交时间不同的人错误并列。

拓3二分答案:用 check 函数 + 二分求解

题目:n 根原木,长度分别为 L₁…Lₙ。要把它们切成 k 段长度相同的木段(可以浪费),问每段最长能有多长?n ≤ 10⁵,L ≤ 10⁸。

思路:「答案具有单调性」——如果长度 x 可行,那么任何比 x 小的长度也可行。于是可以二分答案:把判断条件写成 check(x) = Σ⌊Lᵢ/x⌋ ≥ k,然后在 [1, 10⁸] 上二分。

本题的 check 是 O(n),二分 log(10⁸) ≈ 27 次,总复杂度 O(n log L) ≈ 2.7×10⁶,非常快。二分答案 + 标准库的 upper_bound 思路,是提高组命中率极高的套路。

C++二分答案模板(手写二分查找最大可行值)
#include <bits/stdc++.h>
using namespace std;

int n, k;
long long L[100005];

// 判断每段长度为 len 时,能否切出至少 k 段
bool check(long long len) {
    long long cnt = 0;
    for (int i = 0; i < n; i++) {
        cnt += L[i] / len;
        if (cnt >= k) return true;      // 提前剪枝
    }
    return cnt >= k;
}

int main() {
    ios::sync_with_stdio(0);
    cin >> n >> k;
    long long hi = 0;
    for (int i = 0; i < n; i++) { cin >> L[i]; hi = max(hi, L[i]); }

    long long lo = 1, ans = 0;
    while (lo <= hi) {
        long long mid = lo + (hi - lo) / 2;   // 防溢出写法
        if (check(mid)) { ans = mid; lo = mid + 1; }   // 可行 → 尝试更长
        else hi = mid - 1;
    }
    cout << ans << "\n";
    return 0;
}
运行结果
输入: 3 7
      4 6 8
输出: 2
(长度 2:2+3+4 = 9 ≥ 7 段;长度 3:1+2+2 = 5 < 7 段)
易错:① mid 写成 (lo + hi) / 2 在 lo、hi 都接近 long long 上界时会溢出,必须写 lo + (hi - lo) / 2;② check 里累加 L[i] / len 要用 long long,n × L 可能超过 int。

🔍 代码逐段解析 · 二分答案模板

① check 函数:cnt += L[i] / len 逐根累加能切出的段数(整数除法即「向下取整后还剩几整段」),cnt >= k 提前返回 true 剪枝;函数末尾的 return cnt >= k 兜底处理没触发提前返回的情况。

② 上界初始化:hi = max(hi, L[i]) —— 段长不可能超过最长原木,把 hi 压到实际范围,少二分几轮。

③ 二分主体:mid = lo + (hi - lo) / 2 防溢出;check(mid) 为真 → 长度 mid 可行,答案记为 mid 并尝试更长(lo = mid+1);为假 → hi = mid−1。循环结束 ans 即「最大的可行长度」——这是「二分求最大可行值」的标准形态。

④ 单调性:len 越小越容易切够 k 段,check 结果形如「真真真…假假」,二分找最后一个真。样例 3 7 / 4 6 8:len=2 时 2+3+4=9≥7 真,len=3 时 1+2+2=5<7 假 → 答案 2。

易错点:① ans 初值:若题目保证有解写 0 也安全,但「无解输出 -1」的题要记得检查;② check 里 len 传 0 会除零——本题 lo 从 1 起天然规避。

③思考与启发

💡 思考1:为什么 unique 要先排序?如果不排序还有别的去重办法吗?

查看参考

因为 unique 的实现只比较相邻元素(它不知道元素之间「相等」的全局关系,只知道「相邻且相等就去掉」)。排序把值相同的元素挤到一起,unique 才能发现重复。

不排序的去重办法:① 用 set 或 unordered_set 边读边插入,自动去重(O(n log n) 或平均 O(n));② 用 map 统计频次,键就是去重后的集合;③ 值域小时用布尔桶数组标记(O(n + V))。排序 + unique 的优点是常数小、不需要额外结构;缺点是必须先排序。

💡 思考2:nth_element 是 O(n),为什么还需要 sort?

查看参考

因为两者的产出不同。nth_element 只保证「第 k 个位置是对的」,左右两侧内部无序;sort 保证整个区间有序。如果你后续还要做二分查找、输出有序序列、或者需要多次查询不同的 k,那就必须 sort(或者用 nth_element 反复求,但每次 O(n))。

选型口诀:只要一个位置的值 → nth_element;要整个有序序列 → sort;要前 k 个有序 → nth_element 后 sort 前 k 个。

💡 思考3:为什么标准库的 sort 用 introsort 而不是纯粹的快速排序?

查看参考

因为纯粹的快速排序有两个致命弱点:①最坏情况退化——如果每次都选到极值当枢轴,会退化成 O(n²);②小区间开销大——递归到只剩几个元素时,函数调用的开销超过了排序本身的收益。

Introsort 的三段式策略:先用快速排序(快,平均常数小);当递归深度超过 2log₂n 时切换到堆排序(最坏也保证 O(n log n));当区间长度小于某个阈值(通常是 16)时切换到插入排序(小规模下常数最优)。

启发:工程上的最优解往往是多种算法的组合,而不是单一算法的极致优化。这个思想在竞赛中同样适用——比如「分块 + 暴力」的混合算法。

模块 05

学习检测

先找错,再背口诀——把易错点消灭在考试之前。

⚡高频错误诊断
易错1

比较器里用了 <= 或 >=

❌ 错误
bool cmp(int a, int b) { return a <= b; }
sort(a, a + n, cmp);
// 破坏严格弱序:cmp(x,x) 返回 true
// 结果:可能 RE、死循环、或数组越界
✅ 正确
bool cmp(int a, int b) { return a < b; }
// 等价于 cmp(x,x) == false ✅
// 多关键字:
// if (a.k != b.k) return a.k < b.k;
// return a.id < b.id;
口诀:比较器只写严格小于,等于返回 false;写成小于等于号,sort 直接给你 RE!
易错2

unique 之后忘记 erase

❌ 错误
vector<int> v = {1,1,2,3,3};
unique(v.begin(), v.end());
for (int x : v) cout << x;   // 1 2 3 3 3 —— 还是 5 个!
cout << v.size();              // 5,不是 3
✅ 正确
sort(v.begin(), v.end());
v.erase(unique(v.begin(), v.end()), v.end());
for (int x : v) cout << x;   // 1 2 3
cout << v.size();              // 3 ✅
口诀:unique 只是把重复挪到后面,不改变容器大小;要真删必须 erase,三件套 sort→unique→erase 一步不少!
易错3

lower_bound 返回的位置没有验证值

❌ 错误
// 数组已排序:5 10 20 30,查 15
auto it = lower_bound(a, a+n, 15);
if (it != a + n) cout << "找到了 " << *it;
// 输出「找到了 20」——但 15 根本不存在!
✅ 正确
auto it = lower_bound(a, a+n, 15);
if (it != a + n && *it == 15) cout << "找到了";
// 或直接用 binary_search 判存在性
if (binary_search(a, a+n, 15)) cout << "找到了";
口诀:lower_bound 找的是「≥v 的第一个」,不一定是 v;想判存在必须再比一次 *it == v!
易错4

memset 填非 0/−1 的值

❌ 错误
int f[1005];
memset(f, 1, sizeof(f));      // 想全部置 1
cout << f[0];                // 16843009 !!

long long d[1005];
memset(d, 0x3f, sizeof(d));   // 得到 4.5×10^18,用它做 INF 相加必溢出
✅ 正确
fill(f, f + 1005, 1);        // ✅ 每个元素真的是 1

const long long INF = 0x3f3f3f3f3f3f3f3fLL;
fill(d, d + 1005, INF);         // ✅ 显式写常数

memset(f, 0, sizeof(f));        // ✅ 只有 0/-1 能放心用 memset
memset(f, -1, sizeof(f));       // ✅
口诀:memset 按字节填,只认 0 和 −1;填 1 得千六百万,其他值一律用 fill!
易错5

对无序区间调用二分函数 / next_permutation 起点不对

❌ 错误
// ① 没排序就二分
int a[] = {5, 1, 9, 3};
cout << *lower_bound(a, a+4, 3);   // 结果未定义!

// ② next_permutation 起点不是最小排列
vector<int> p = {3, 1, 2};
do { /* ... */ } while (next_permutation(p.begin(), p.end()));
// 只枚举到 312、321,漏掉了前面的全部排列!
✅ 正确
int a[] = {5, 1, 9, 3};
sort(a, a + 4);                          // ★ 先排序
cout << *lower_bound(a, a+4, 3);            // 3 ✅

vector<int> p = {3, 1, 2};
sort(p.begin(), p.end());                  // ★ 从小排列开始
int cnt = 0;
do { cnt++; } while (next_permutation(p.begin(), p.end()));
// cnt == 6 ✅
口诀:二分的门票是「有序」,枚举的门票是「最小排列」;两张门票没买,算法给你的答案全是假的!
📋标准库函数速查表
函数作用返回值复杂度
sort(f, l, cmp)区间排序(升序 / 自定义)voidO(n log n)
stable_sort(f, l, cmp)稳定排序voidO(n log²n) 或 O(n log n)
partial_sort(f, m, l)前 m 个有序(全局最小 m 个)voidO(n log m)
nth_element(f, nth, l)第 nth 位就位,左边 ≤ 右边void期望 O(n)
min / max两值取最值 / min({a,b,c})值O(1)
minmax同时取最小最大pairO(1)
min_element / max_element区间内最值位置迭代器O(n)
swap(a, b)交换两值 / 两容器voidO(1)
reverse(f, l)原地翻转voidO(n)
rotate(f, m, l)循环左移,使 m 成为首元素迭代器O(n)
unique(f, l)相邻重复去重,返回新末尾迭代器O(n)
fill(f, l, v) / fill_n区间填充voidO(n)
iota(f, l, s)从 s 开始递增填充voidO(n)
lower_bound(f, l, v)第一个 ≥ v迭代器O(log n)
upper_bound(f, l, v)第一个 > v迭代器O(log n)
equal_range(f, l, v)v 的等价区间pairO(log n)
binary_search(f, l, v)是否存在boolO(log n)
count / count_if统计个数 / 统计满足条件个数整数O(n)
accumulate(f, l, init)累加(可换运算)初值类型O(n)
next_permutation字典序下一排列bool单次 O(n)
prev_permutation字典序上一排列bool单次 O(n)
__builtin_popcount(x)二进制中 1 的个数intO(1) 指令级
__builtin_ctz / clz末尾 0 个数 / 前导 0 个数intO(1) 指令级
📌笔记卡
📗 必记概念
  • 所有算法作用于半开区间 [first, last)
  • 比较器返回 true = a 排在 b 前面
  • 严格弱序:cmp(x, x) 必须为 false
  • unique 不改变容器大小,只返回新末尾
  • lower_bound 是 ≥,upper_bound 是 >
  • nth_element 期望 O(n) 求第 k 小
📘 必背模板
  • 去重:sort → unique → erase
  • 次数:ub - lb
  • 前驱:it = upper_bound(v); --it;
  • 第 k 小:nth_element(a, a+k-1, a+n)
  • 全排列:sort 后 do…while(next_permutation)
  • 离散化:copy → sort → unique → lower_bound+1
📙 考场提醒
  • 二分前必须 sort,否则答案全错
  • memset 只填 0 / −1
  • accumulate 初值写 0LL 防溢出
  • next_permutation 前先 sort
  • n = 5×10⁶ 时记得写快读
课后练习

分层练习作业

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

🟢 A组 · 基础(4题)

A1概念题

说出 lower_bound 与 upper_bound 的区别,并说明如何用它们求「值 x 在有序数组中出现的次数」。

查看参考答案

区别:lower_bound(f, l, x) 返回第一个 ≥ x 的位置;upper_bound(f, l, x) 返回第一个 > x 的位置。

求次数:upper_bound(a, a+n, x) - lower_bound(a, a+n, x)。因为 [lb, ub) 区间内所有元素都恰好等于 x,区间长度就是出现次数。

优点:这个写法在 x 不存在时也天然返回 0,不需要任何额外判断。

易错:误以为 lower_bound 返回的一定是 x——它返回的可能是比 x 大的值(x 不存在时)。想确认存在性还要加 *lb == x 判断。

A2编程题

输入 n 个整数,输出去重后升序排列的结果与不同值的个数。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<long long> v(n);
    for (auto& x : v) cin >> x;
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());
    cout << v.size() << "\n";
    for (long long x : v) cout << x << " ";
    cout << "\n";
}

思路:去重三件套。O(n log n)。

逐段解析:① 读入 n 与 vector<long long> v(n)(值域大,用 long long 稳妥);② sort 让重复相邻;③ v.erase(unique(v.begin(), v.end()), v.end()) 一行完成去重——unique 返回的新末尾直接当 erase 左端点;④ 先输出 v.size() 再逐个输出,与题目「两行」格式对应。

易错:漏掉 erase 会导致 size 不变、输出里还有重复值。

A3编程题

输入 n 个数与 k,输出第 k 小的数(n ≤ 10⁶)。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int a[1000005];
int main() {
    int n, k; scanf("%d %d", &n, &k);
    for (int i = 0; i < n; i++) scanf("%d", &a[i]);
    nth_element(a, a + k - 1, a + n);      // 期望 O(n)
    printf("%d\n", a[k - 1]);
}

思路:nth_element 把第 k−1 个位置放上正确的元素,其余不管。

逐段解析:① int a[1000005] 放全局区——10⁶ 个 int 约 4MB,开在 main 里会爆栈;② scanf 读 n、k 与数组(10⁶ 规模别用 cin);③ nth_element(a, a + k - 1, a + n) 期望 O(n) 划分,第 k 小落在 a[k−1];④ printf 输出。整份代码没有一行排序。

易错:写成 a + k 会求成第 k+1 小;用 cin 读 10⁶ 个数可能超时。

A4编程题

输出 1~n 的全部排列(n ≤ 8)。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> v(n);
    iota(v.begin(), v.end(), 1);              // 填 1,2,...,n
    do {
        for (int i = 0; i < n; i++) cout << setw(5) << v[i];
        cout << "\n";
    } while (next_permutation(v.begin(), v.end()));
}

思路:iota 填好最小排列,然后 do…while + next_permutation 枚举。复杂度 O(n × n!)。

逐段解析:① vector<int> v(n) 后 iota(v.begin(), v.end(), 1) 从 1 开始填 1,2,…,n——天然就是最小排列,省掉 sort;② do 循环体先用 setw(5) 右对齐宽度 5 逐个输出(题目格式要求);③ while (next_permutation(...)) 推进到下一排列,返回 false(已到最大排列)时停止。

易错:用 while (next_permutation(...)) 会漏掉第一个排列;setw 需要 «iomanip»(bits/stdc++.h 已包含)。

🟡 B组 · 提高(4题)

B1sort 应用

有 n 个区间 [lᵢ, rᵢ],问最多有多少个区间互不相交(端点可以重合)?

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<pair<long long,long long>> seg(n);
    for (auto& p : seg) cin >> p.first >> p.second;
    sort(seg.begin(), seg.end(), [](const pair<long long,long long>& a,
                                   const pair<long long,long long>& b) {
        return a.second < b.second;     // ★ 按右端点升序
    });
    long long last = LLONG_MIN; int cnt = 0;
    for (auto& p : seg)
        if (p.first >= last) { cnt++; last = p.second; }
    cout << cnt << "\n";
}

思路:经典区间调度贪心——按右端点升序排序,能选就选。

逐段解析:① vector<pair<long long,long long>> seg(n) 用 pair 存 (l, r),读入后 lambda 比较器 return a.second < b.second; 按右端点升序;② last = LLONG_MIN 初始化为最小值,保证第一个区间一定能选;③ 扫描:if (p.first >= last) 端点可重合所以是 ≥——选完立刻 last = p.second 更新边界。

易错:写成按左端点排序(那是错的经典反例);判断条件写成 p.first > last 会让「端点重合」的区间被排除。

B2二分 + 前缀和

给定递增数组 a 和 m 次询问,每次问「值 ≤ x 的元素之和」。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
long long a[100005], s[100005];
int main() {
    ios::sync_with_stdio(0);
    int n, m; cin >> n >> m;
    for (int i = 1; i <= n; i++) { cin >> a[i]; s[i] = s[i-1] + a[i]; }
    while (m--) {
        long long x; cin >> x;
        int p = upper_bound(a + 1, a + n + 1, x) - a - 1;  // ≤x 的元素个数
        cout << s[p] << "\n";
    }
}

思路:二分找出 ≤ x 的元素个数 p(用 upper_bound 的位置减 1),再用前缀和 s[p] O(1) 得到答案。总复杂度 O((n+m) log n)。

逐段解析:① 下标从 1 开始:读入同时 s[i] = s[i-1] + a[i] 建前缀和;② 每次询问 p = upper_bound(a+1, a+n+1, x) - a - 1:upper_bound 返回第一个 > x 的指针,减 a 得 1 基下标,再减 1 就是「≤ x 的最后一个元素下标」=元素个数;③ cout << s[p] 直接输出——p=0 时 s[0]=0(全局数组自动为 0),天然处理「x 比所有数都小」的情况。

易错:下标处理的细节较多——a + n + 1 表示 end,减 a 再减 1 得到「≤ x 的最后一个元素的下标」,正好等于元素个数(下标从 1 开始)。

B3综合应用

给定 n 个任务,每个有耗时 tᵢ。求一种顺序使「平均等待时间」最小,输出最小总等待时间。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<long long> t(n);
    for (auto& x : t) cin >> x;
    sort(t.begin(), t.end());          // ★ 短任务优先(SPT 规则)
    long long wait = 0, cur = 0;
    for (long long x : t) {
        cur += x;                      // 当前任务完成时刻
        wait += cur;                   // 它是所有已完成任务的等待时间之和
    }
    cout << wait << "\n";
}

思路:把耗时升序排列(SPT:Shortest Processing Time first)。直觉是「让短任务先做,它的耗时会被更多后续任务反复计入等待时间,所以越短越好」。

逐段解析:① vector<long long> t(n) 读入耗时;② sort(t.begin(), t.end()) 默认升序即 SPT,无需比较器;③ 扫描累加:cur += x 是当前任务的完成时刻(前面所有任务耗时之和 + 自己),wait += cur 把「完成时刻」累进总等待——第 i 个做任务的等待 = 它开始前等的时间 = 前面任务完成时刻,恰等于它自己的完成时刻被计入的语义(等待之和 = 各任务完成时刻之和 − 总耗时恒定,最小化完成时刻之和即最小化等待);④ 输出 wait,全程 long long。

易错:把「总等待时间」理解成「最后一个任务完成的时间」;以及忘记 sort(默认升序即可,不用写比较器)。

B4离散化

给定 n 个数(值域 10⁹),把它们替换成 1..m 的排名,并支持「查询排名 r 对应的原值」。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);
    int n, q; cin >> n >> q;
    vector<long long> a(n), b;
    for (auto& x : a) { cin >> x; b.push_back(x); }

    sort(b.begin(), b.end());
    b.erase(unique(b.begin(), b.end()), b.end());   // b 是「原值 → 排名」的字典

    for (auto& x : a)
        x = lower_bound(b.begin(), b.end(), x) - b.begin() + 1;  // 转成排名

    while (q--) {
        int r; cin >> r;
        cout << b[r - 1] << "\n";      // 排名 r 对应的原值(直接下标访问)
    }
}

思路:去重后的有序数组 b 就是「排名 → 原值」的映射表,lower_bound 提供「原值 → 排名」的反向映射。两个方向都是 O(log n)。

逐段解析:① 读入 a 同时 b.push_back(x) 复制一份;② 对 b 做 sort + erase(unique) 得「字典」;③ x = lower_bound(b.begin(), b.end(), x) - b.begin() + 1 原地替换成排名(+1 转 1 基);④ 查询 cout << b[r - 1] —— 排名 r 反查原值,−1 转回 0 基下标。一加一减两个 1 是本题全部的坑。

易错:排名从 1 开始计数,所以要 +1 和 -1;忘记去重会导致相同值的排名不一致。

🔴 C组 · 挑战(4题)

C1初赛真题

若要对数组 a[0..n-1] 降序排序,下列写法正确的是:

A. sort(a, a+n, less<int>());
B. sort(a, a+n, greater<int>());
C. sort(a, a+n, [](int x,int y){return x >= y;});
D. sort(a, a+n, [](int x,int y){return x <= y;});

查看参考答案

答案:B

greater<int> 的比较语义是「左边的值更大时返回 true」,即「大的排前面」,因此是降序。而 less<int> 是升序。

C 和 D 为什么错:它们用了 >= 和 <=,在元素相等时返回 true,破坏了 sort 要求的严格弱序,属于未定义行为(可能 RE)。

考点:比较器的方向与严格弱序约束。

C2综合编程

「拼数」:给定 n 个正整数,把它们首尾拼接,使拼成的数最大。n ≤ 20,每个数 ≤ 10⁹。

查看参考答案

关键洞察:这不是普通的数值排序!例如 32 和 321,数值上 321 > 32,但拼接 32|321 = 32321 比 321|32 = 32132 更大。所以比较规则应该是「把两个字符串拼起来比字典序」:若 a+b > b+a 则 a 排前面。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<string> v(n);
    for (auto& s : v) cin >> s;

    sort(v.begin(), v.end(), [](const string& a, const string& b) {
        return a + b > b + a;          // ★ 拼接后比字典序
    });

    for (auto& s : v) cout << s;
    cout << "\n";
}

正确性直觉:若 a+b > b+a 且 b+c > c+b,则可证 a+c > c+a——比较关系满足传递性,因此可以作为合法的排序比较器。

逐段解析:① vector<string> v(n) 读入为字符串(大数拼接会溢出,绝不能转数值);② 比较器 return a + b > b + a; —— 拼接字符串比较字典序:32+321=32321 > 321+32=32132,所以 32 排在 321 前;③ 拼接输出 for (auto& s : v) cout << s;。sort 只保证相邻交换有序,但该比较器有传递性,整体有序等价于全局最优。

易错:直接按数值或字符串字典序排序(那是错的);用 >= 破坏严格弱序。

C3思维题

为什么 accumulate 的第三个参数必须显式写成 0LL 而不是 0?

查看参考答案

原因:accumulate 的返回值类型是「第三个参数的类型」,而不是元素类型。如果写 accumulate(v.begin(), v.end(), 0),内部累加全程按 int 进行——当 n = 10⁵、每个元素 10⁹ 时,总和达 10¹⁴,int 会溢出,结果变成负数或随机值。

正确写法:accumulate(v.begin(), v.end(), 0LL),让累加从 long long 开始。

延伸:同样的坑也出现在 reduce(C++17)、手写的 for 循环累加。只要是「累加大量数据」,第一件事就是确认变量类型够不够大。

考点:模板函数的推导规则与整数溢出意识。

C4综合编程

用二分答案求解:n 段绳子长度 Lᵢ,切成 k 段等长绳,求最长长度(保留 2 位小数)。

查看参考答案

思路:「能切出 k 段长度 x 的绳子」这一判定对 x 具有单调性(x 越小越容易满足),因此可以二分答案。由于要求保留 2 位小数,可以把长度乘 100 变成整数后再二分,避免浮点误差。

#include <bits/stdc++.h>
using namespace std;
int n, k;
long long L[10005];       // 已乘 100 的整数长度

bool check(long long x) {
    if (x == 0) return true;
    long long cnt = 0;
    for (int i = 0; i < n; i++) {
        cnt += L[i] / x;
        if (cnt >= k) return true;
    }
    return false;
}

int main() {
    cin >> n >> k;
    long long hi = 0;
    for (int i = 0; i < n; i++) {
        double d; cin >> d;
        L[i] = (long long)(d * 100 + 0.5);   // 四舍五入转整数,消除浮点误差
        hi = max(hi, L[i]);
    }
    long long lo = 0, ans = 0;
    while (lo <= hi) {
        long long mid = lo + (hi - lo) / 2;
        if (check(mid)) { ans = mid; lo = mid + 1; }
        else hi = mid - 1;
    }
    printf("%.2f\n", ans / 100.0);
}

易错:① 直接对 double 二分会因精度问题陷入死循环(要用固定次数的 for 循环或 eps 判断);② ans / 100.0 必须写 100.0,写 100 会做整数除法;③ L[i] / x 累加要用 long long。

逐段解析:① 读入 double d 后 L[i] = (long long)(d * 100 + 0.5) —— 乘 100 化整 + 0.5 四舍五入,把「保留 2 位小数」的浮点题变成纯整数题;② check(x) 与拓3 完全同构:累加 L[i] / x 判断能否切出 k 段;③ 二分主体 lo + (hi - lo)/2 防溢出,可行则 ans=mid、lo=mid+1;④ 输出 printf("%.2f", ans / 100.0) —— ans 是「厘米×100」的整数,除以 100.0 还原。注意本题 check(0) 返回 true 是防御性的:lo=0 起步时 mid 可能为 0,除 0 保护。

🏆 洛谷原题精选(12道)

以下 12 道洛谷题目均与 sort / 二分 / unique / nth_element / next_permutation 相关,按难度从入门到提高排列。

1
P1177
【模板】排序入门
2
P1223
排队接水(排序贪心)入门
3
P1104
生日(结构体排序)入门
4
P1059
明明的随机数(sort + unique)入门
5
P1923
【深基9.例4】求第 k 小的数普及-
6
P1102
A-B 数对(lower_bound 计数)普及-
7
P1012
[NOIP1998 提高组] 拼数普及-
8
P1583
魔法照片(多关键字排序)普及-
9
P1068
[NOIP2009 普及组] 分数线划定普及-
10
P1706
全排列问题(next_permutation)普及-
11
P1088
[NOIP2004 普及组] 火星人普及-
12
P2678
[NOIP2015 提高组] 跳石头(二分答案)普及+/提高-