第 1 章 基础算法
本章定位:NOIP 近三年的 T1(词典、编辑字符串、糖果店)和 CSP-S 2026 的 T1 能量水晶,用到的都是这一章的东西:排序、二分、前缀和、双指针、贪心。T1 是四道题里最该拿满的 100 分。难题的数据范围表里,“n ≤ 1000”“所有数相同”这类档位,也常常只靠排序和前缀和就能拿到。这一章没有难懂的内容,难的是每次都写对——每个【必会】模板要练到 10 分钟内不看资料写出来。
1.1 排序与自定义比较 【必会】
一句话:把数据按某种规则排好。很多题排完序就简单了一半。 识别信号:答案与输入的先后顺序无关;题目问“最大的几个”“第 k 大”;需要“相同的放在一起”或“从小到大依次处理”。 复杂度:O(n log n),n 到 10^6 都可以放心排。
// 结构体多关键字排序。洛谷 P1093 [NOIP2007 普及组] 奖学金:
// 按总分从高到低排;总分相同,语文高的在前;还相同,学号小的在前。输出前 5 名的学号和总分。
#include <bits/stdc++.h>
using namespace std;
struct Student {
int id, chinese, total;
};
// 比较函数回答一个问题:“a 是否必须排在 b 的前面?”
// 两个元素一样时必须返回 false,所以只能用 < 和 >,不能用 <= 和 >=。
bool cmp(const Student &a, const Student &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(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<Student> s(n);
for (int i = 0; i < n; i++) {
int c, m, e;
cin >> c >> m >> e;
s[i].id = i + 1; // 排序会打乱位置,原来的编号要自己存下来
s[i].chinese = c;
s[i].total = c + m + e;
}
sort(s.begin(), s.end(), cmp);
for (int i = 0; i < 5 && i < n; i++) cout << s[i].id << ' ' << s[i].total << '\n';
return 0;
}
常用写法:
- 数组存在
a[1..n]:sort(a + 1, a + n + 1)。第二个参数是“最后一个元素的下一个位置”。 vector:sort(v.begin(), v.end())。- 从大到小:
sort(a + 1, a + n + 1, greater<int>())。
易错点
- cmp 里写了
<=或>=。两个元素相等时,cmp(a, b) 和 cmp(b, a) 都返回 true,sort内部会越界,表现为运行错误或死循环,而且小数据常常测不出来。规则只有一条:相等时必须返回 false。 - 多关键字写成
return a.total > b.total || a.chinese > b.chinese;。总分低但语文高的人也会被排到前面。必须像模板那样逐层判断:“这一项不相等就按它定,相等才看下一项”。 - 数组从 1 开始存,却写成
sort(a, a + n):a[0] 被排了进去,a[n] 被漏掉。 - 排序会打乱原来的位置。后面还要用“它原来是第几个”时,排序前把编号存进结构体(模板里的
id)。 sort不保证相等元素的先后顺序不变。需要“相等时保持输入顺序”,就把输入序号作为最后一个关键字。
练一练:洛谷 P1177 【模板】排序、P1093 奖学金、P1068 分数线划定;本系统 E02。
1.2 二分查找 【必会】
一句话:在排好序的数组里,用 O(log n) 找到“第一个大于等于 x 的位置”。 识别信号:数组有序(或者可以先排序),要反复查“x 在不在”“x 排第几”“比 x 大的最小的数是谁”。 复杂度:每次查询 O(log n)。10^9 个数里查一次也只要 30 步。
// 二分查找。洛谷 P2249 【深基13.例1】查找:
// 在从小到大排好的数列里,对每个询问输出它第一次出现的位置(从 1 数起),没有就输出 -1。
#include <bits/stdc++.h>
using namespace std;
const int N = 1000005;
int n, m, a[N];
// 返回第一个满足 a[i] >= x 的下标;如果所有数都小于 x,返回 n + 1。
// 固定写法:mid 满足条件就记进 ans、再往左找更靠前的;不满足就往右找。
int first_at_least(int x) {
int l = 1, r = n, ans = n + 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (a[mid] >= x) {
ans = mid;
r = mid - 1;
} else {
l = mid + 1;
}
}
return ans;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
while (m--) {
int x;
cin >> x;
int pos = first_at_least(x); // 等价于 lower_bound(a + 1, a + n + 1, x) - a
cout << ((pos <= n && a[pos] == x) ? pos : -1) << ' '; // 先判越界,再判是不是 x
}
cout << '\n';
return 0;
}
手写二分只记模板里这一种:while (l <= r),mid 满足条件就记进 ans。它的 l 和 r 每一轮至少移动一格,不会死循环;循环结束后直接用 ans,不用琢磨该返回 l 还是 r。
标准库里有现成的,数组 a[1..n] 已经从小到大排好时:
| 写法 | 得到什么 |
|---|---|
lower_bound(a + 1, a + n + 1, x) - a |
第一个 ≥ x 的下标;所有数都小于 x 时得到 n + 1 |
upper_bound(a + 1, a + n + 1, x) - a |
第一个 > x 的下标 |
upper_bound(…) - lower_bound(…) |
x 出现了几次 |
lower_bound(…) - a - 1 |
最后一个 < x 的下标(得到 0 表示没有) |
upper_bound(…) - a - 1 |
最后一个 ≤ x 的下标 |
易错点
- 对没排序的数组用
lower_bound。不会报错,只会给出错的结果。 - 把返回值直接当下标。它返回的是指针(或迭代器),数组要减去
a,vector 要减去v.begin()。 - 没处理“找不到”。位置是 n + 1 时再访问
a[pos]就越界了;位置合法但a[pos] != x,也说明 x 不存在。模板里的判断顺序是先查越界、再比较。 mid = (l + r) / 2在 l、r 都接近 2×10^9 时相加会溢出,习惯写l + (r - l) / 2。- 在
set、map上要用成员函数s.lower_bound(x)。写成lower_bound(s.begin(), s.end(), x)结果相同,但每次是 O(n),会超时。
练一练:洛谷 P2249 查找、P1102 A-B 数对。
1.3 二分答案 【必会】
一句话:直接求答案很难,但“给一个值 x,判断答案能不能达到 x”很容易,而且 x 越大越难(或越容易)达到——那就二分 x。 识别信号:“使最大值尽量小”“使最小值尽量大”;“至少需要多大的容量 / 多少天 / 多长的半径才能……”;答案是一个数,范围很大(10^9、10^18)。 复杂度:O(check 的复杂度 × log 答案范围)。check 通常是 O(n) 的贪心。
// 二分答案。洛谷 P2678 [NOIP2015 提高组] 跳石头:
// 起点在 0,终点在 L,中间有 n 块石头。最多移走 m 块,让“最短的一跳”尽量长,输出这个最大值。
#include <bits/stdc++.h>
using namespace std;
const int N = 50005;
int L, n, m, d[N];
// 判定:能不能做到每一跳都不短于 x?
// 从左到右贪心:离上一块留下的石头不足 x 的,就移走。
bool check(int x) {
int removed = 0, last = 0; // last:上一块留下的石头的位置,一开始是起点
for (int i = 1; i <= n + 1; i++) { // d[n + 1] 是终点
if (d[i] - last < x) removed++; // 太近,移走(终点太近时,相当于移走 last 那一块,次数一样)
else last = d[i];
}
return removed <= m;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> L >> n >> m;
for (int i = 1; i <= n; i++) cin >> d[i];
d[n + 1] = L;
// x 越大越难做到:check 的结果形如 真 真 真 假 假,要找最后一个“真”
int l = 1, r = L, ans = 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (check(mid)) {
ans = mid; // mid 可行,记下来,再试更大的
l = mid + 1;
} else {
r = mid - 1;
}
}
cout << ans << '\n';
return 0;
}
三步:
- 二分什么:就是答案本身,确定它的范围 [下界, 上界]。
- 写 check(x):通常是从左到右贪心地扫一遍。写之前先想清楚:x 变大,check 是更容易成立还是更难成立?
- 套二分:求“最大的可行值”,check(mid) 为真就记答案并
l = mid + 1(模板的写法);求“最小的可行值”,为真就记答案并r = mid - 1。
易错点
- 方向写反。动手前在草稿上写出 check 随 x 变化的样子:
真 真 真 假 假(找最后一个真)还是假 假 真 真 真(找第一个真)。 - 上下界取错。上界宁可取大——所有数的和、坐标的最大差——多二分几轮不费时间。上界到 10^18 时,l、r、mid 都要用
long long。 - check 里累加溢出。n 个 10^9 相加要用
long long。 - check 本身写错。二分答案的 bug 十有八九在 check 里。调试办法:拿一个小样例,直接打印 check(1)、check(2)、check(3)……看是不是“先全真、后全假”,再和手算的结果对照。
- 答案是小数时(少见),固定循环 100 次,不要写
while (r - l > 1e-9),精度不够时它会死循环。
练一练:洛谷 P2678 跳石头(最小值最大)、P1182 数列分段 Section II(最大值最小)、P2440 木材加工;本系统 E02。
1.4 前缀和 【必会】
一句话:预处理 s[i] = a[1] + … + a[i],之后任意一段的和 a[l] + … + a[r] 就是 s[r] − s[l−1],一步算出。 识别信号:反复问“某一段 / 某个矩形里的和、个数”;数据在询问期间不再修改。 复杂度:预处理 O(n)(二维 O(nm)),每次询问 O(1)。
一维只有两行:
for (int i = 1; i <= n; i++) s[i] = s[i - 1] + a[i]; // 预处理
long long sum = s[r] - s[l - 1]; // a[l..r] 的和
二维:
// 二维前缀和:O(nm) 预处理,之后 O(1) 回答任意子矩形的和。
// 输入:n m q,接着 n 行 m 列的矩阵,再 q 个询问 r1 c1 r2 c2(左上角和右下角的行、列)。
// 输出:每个询问的子矩形元素之和。
#include <bits/stdc++.h>
using namespace std;
const int N = 1005;
long long s[N][N]; // s[i][j]:第 1..i 行、第 1..j 列这一整块的和。下标从 1 开始,第 0 行和第 0 列都是 0
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m, q;
cin >> n >> m >> q;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) {
int a;
cin >> a;
s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + a; // 上 + 左 - 左上(加重了)+ 自己
}
while (q--) {
int r1, c1, r2, c2;
cin >> r1 >> c1 >> r2 >> c2;
// 大块 - 上面多的 - 左面多的 + 左上角(减重了)
cout << s[r2][c2] - s[r1 - 1][c2] - s[r2][c1 - 1] + s[r1 - 1][c1 - 1] << '\n';
}
return 0;
}
用法延伸:
- “这一段里有多少个满足条件的数”:满足的记 1,不满足的记 0,再做前缀和。计数题常这样用。
- 前缀异或、前缀最大值、前缀最小值是同一个想法。最大值和最小值不能做减法,只能回答“前 i 个”的。E01 补给车求的就是前缀和的最小值。
易错点
- 数组从 0 开始存,l = 0 时
s[l - 1]越界。前缀和一律从 1 开始存,让 s[0] = 0。 - s 没开
long long。2×10^5 个 10^9 加起来是 2×10^14。 - 二维公式的加减号记混。算 s 时是“上 + 左 − 左上 + 自己”;查询时是“大块 − 上 − 左 + 左上”。记不住就画一个 2×2 的格子现推。
- 全局变量取名
y1。它和数学库里的一个函数重名,在自己的电脑上可能能编译,到了考场的 Linux 上是编译错误,整道题 0 分。坐标用 r1、c1、r2、c2 这类名字。
练一练:洛谷 B3612 求区间和、P1719 最大加权矩形、P2280 激光炸弹;本系统 E01。
1.5 差分 【必会】
一句话:前缀和的反过来。要对很多个区间“整体加一个数”时,每次只改差分数组的两个位置,全部改完后做一次前缀和,就得到最终的数组。 识别信号:“把第 l 个到第 r 个都加上 v”这样的操作有很多次,最后才问结果。 复杂度:每次修改 O(1),最后还原 O(n)。
// 差分:把“一段区间整体加一个数”变成只改两个位置,全部改完后做一次前缀和还原。
// 洛谷 P2367 语文成绩:n 个成绩,p 次操作“第 x 到第 y 个人都加 z 分”,输出最后的最低分。
#include <bits/stdc++.h>
using namespace std;
const int N = 5000005;
long long d[N]; // d[i] = a[i] - a[i-1];它的前缀和 d[1] + ... + d[i] 就是 a[i]
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, p;
cin >> n >> p;
int last = 0; // 上一个人的成绩
for (int i = 1; i <= n; i++) {
int a;
cin >> a;
d[i] = a - last;
last = a;
}
while (p--) {
int x, y, z;
cin >> x >> y >> z;
d[x] += z; // 从第 x 个开始,后面的都加 z
d[y + 1] -= z; // 到第 y + 1 个取消。y 可以等于 n,所以数组要多开一位
}
long long cur = 0, lowest = LLONG_MAX;
for (int i = 1; i <= n; i++) {
cur += d[i]; // 还原:cur 就是修改之后的 a[i]
lowest = min(lowest, cur);
}
cout << lowest << '\n';
return 0;
}
差分还有第二种用法:直接观察“相邻两项的差”。NOIP 2018 的 T1 铺设道路(每次把一段区间整体减 1,问最少几次能全变成 0),答案就是所有 max(0, a[i] − a[i−1]) 加起来。看到“每次把一段区间整体加 1 或减 1”,就把差分数组写出来看一看。
二维也可以差分(把一个子矩形整体加 v,改四个角,最后做二维前缀和),用得少,知道有这回事就行。
易错点
d[y + 1]越界。y 可以等于 n,所以数组要比 n 多开几位。- 改完忘了做前缀和还原,直接把 d 当成了最终的数组。
- 修改和询问交替出现时,差分不适用(每问一次都要 O(n) 还原)。那是树状数组和线段树的工作,见第 5 章。
- “一条路径上的点都加 1”是树上的差分,见第 6 章。
练一练:洛谷 P2367 语文成绩、P5019 铺设道路、P3397 地毯(二维);本系统 E15 里“树是一条链”的那一档就是一维差分。
1.6 双指针与滑动窗口 【必会】
一句话:用两个下标 l、r 框出一段窗口。r 每次右移一格,l 只往右、不回头,两个指针加起来一共移动 2n 次,把“枚举所有区间”的 O(n^2) 降到 O(n)。 识别信号:找“最长 / 最短的一段连续区间,满足某个条件”,并且窗口越长条件越难(或越容易)满足。经常要先排序:排序之后,“选哪些数”就变成了“选连续的一段”。 复杂度:O(n),加上排序是 O(n log n)。
// 双指针(滑动窗口)。模型同 CSP-S 2026 T1 能量水晶:
// n 个数,每次操作把某个数加 1,最多操作 m 次,问最多能让多少个数变得相等。输入 n m 和 n 个数。
// 排序之后,选中的一定是连续的一段,都补到这一段的最大值;段越长花费越大,所以可以用双指针。
#include <bits/stdc++.h>
using namespace std;
const int N = 500005;
long long a[N];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
long long m;
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
sort(a + 1, a + n + 1);
int best = 0;
long long cost = 0; // 把窗口 [l, r] 里的数都补到 a[r],需要操作多少次
for (int r = 1, l = 1; r <= n; r++) {
cost += (r - l) * (a[r] - a[r - 1]); // 目标从 a[r-1] 升到 a[r]:窗口里原有的 r - l 个数各多补这么多
while (cost > m) { // 超支了:把最小的 a[l] 移出窗口
cost -= a[r] - a[l];
l++;
}
best = max(best, r - l + 1); // 此时 [l, r] 是以 r 为右端的最长合法窗口
}
cout << best << '\n';
return 0;
}
通用骨架(求最长的合法窗口):
for (r = 1, l = 1; r <= n; r++) {
把 a[r] 加入窗口;
while (窗口不合法) { 把 a[l] 移出窗口; l++; }
此时 [l, r] 是以 r 为右端的最长合法窗口,用它更新答案;
}
求最短的合法窗口时,把 while 的条件换成“窗口合法”:在 while 里面先用 [l, r] 更新答案,再把 a[l] 移出去。
l 为什么不用回头:r 右移之后窗口只会更难合法,以前被移出去的数更不可能再回来。动手之前先确认这一点成立;不成立就不能用双指针。
易错点
- 没有单调性却硬用。数组里有负数时,“和不超过 S 的最长区间”不能用双指针(窗口变长,和反而可能变小)。
- while 里没保证窗口非空,l 跑到了 r 的右边。模板里窗口只剩一个数时花费是 0,一定合法,所以不会越过去;自己写的时候要检查这一点。
- 增量算错。模板里那一行的意思是:目标从 a[r−1] 升到 a[r],窗口里原有的 r − l 个数,每个都要多补 a[r] − a[r−1]。这种“边移动边维护”的式子最容易错,写完用 n ≤ 10 的暴力对拍(暴力就是枚举所有子集,见第 2 章)。
- 乘法溢出。模板里 a 是
long long,所以(r - l) * (a[r] - a[r - 1])自动按long long算;如果 a 是int,要写成1LL * (r - l) * (…)。
练一练:洛谷 P1638 逛画展(最短窗口)、P1102 A-B 数对(排序后双指针);CSP-S 2026 T1 能量水晶(自己重写一遍,和考场代码对比)。
1.7 离散化 【常考】
一句话:数值很大(到 10^9)但个数不多(10^5 个)时,把它们换成 1 到 k 的排名,大小关系不变,就可以拿来当数组下标。 识别信号:想“以数值为下标”开数组(计数、树状数组、差分),但数值范围远远超过数组能开的大小。 复杂度:O(n log n)。
// 离散化:把很大、很分散的数值换成 1..k 的排名,大小关系不变,之后就能当数组下标用。
// 输入 n 和 n 个整数;输出不同的值有几个,以及每个数的排名(最小的是 1,相同的数排名相同)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
vector<int> vals(a); // 第一步:复制一份
sort(vals.begin(), vals.end()); // 第二步:排序
vals.erase(unique(vals.begin(), vals.end()), vals.end()); // 第三步:去重
cout << (int)vals.size() << '\n';
for (int i = 0; i < n; i++) {
int rk = lower_bound(vals.begin(), vals.end(), a[i]) - vals.begin() + 1; // 第四步:二分查排名
cout << rk << ' '; // 需要原值时用 vals[rk - 1] 取回来
}
cout << '\n';
return 0;
}
四步:复制、排序、去重、二分查排名。
易错点
- 忘了去重,相同的数得到了不同的排名。
unique只把重复的元素挪到末尾、返回新的结尾,并不真的删除,所以后面要跟erase。- 离散化只保留了“谁大谁小”,丢掉了“大多少”。题目要用到两个值的差(比如区间的长度)时,要用
vals[rk - 1]取回原值来算。 - 用
map<int, int>存排名也能做,但慢好几倍,n 到 10^6 时不要用。
练一练:洛谷 P1496 火烧赤壁、P1908 逆序对(离散化 + 树状数组,见第 5 章);本系统 E11。
1.8 贪心的常见模型 【必会】
一句话:每一步都选眼前最好的,并且说得出“这样选为什么不吃亏”。 识别信号:n 很大(10^5 以上,DP 的状态开不下);求最大或最小;排序之后似乎就有了明显的处理顺序。 复杂度:一般就是排序的 O(n log n)。
模型一:排序后依次处理。 关键是“按什么排”。常见的有:按结束时间(区间调度)、按截止时间(带期限的任务)、按单价(可以拆开拿的物品)、最小的配最大的(两两配对)。
// 区间调度(贪心)。洛谷 P1803 凌乱的yyy / 线段覆盖:
// n 场比赛各有开始时间和结束时间,同一时刻只能参加一场,问最多能参加几场。
// 贪心:按结束时间从早到晚排序,能参加就参加。结束得越早,留给后面的时间越多。
#include <bits/stdc++.h>
using namespace std;
struct Seg {
int l, r;
};
bool cmp(const Seg &a, const Seg &b) { return a.r < b.r; } // 按结束时间排,不是按开始时间
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<Seg> s(n);
for (int i = 0; i < n; i++) cin >> s[i].l >> s[i].r;
sort(s.begin(), s.end(), cmp);
int cnt = 0, last = INT_MIN; // last:上一场参加的比赛的结束时间
for (int i = 0; i < n; i++)
if (s[i].l >= last) { // 上一场已经结束(刚好结束也行),就参加这一场
cnt++;
last = s[i].r;
}
cout << cnt << '\n';
return 0;
}
模型二:邻项交换。 答案取决于排列顺序时,只盯着相邻的两个元素 A 和 B,比较“A 在前”与“B 在前”哪个更好,得到一个只和 A、B 有关的条件,拿它当 cmp 去排序。例:每个人接水要 t 分钟,问怎样排队总等待时间最短——A 排在 B 前面更好,当且仅当 t_A < t_B,所以按 t 从小到大排(洛谷 P1223)。NOIP 2012 国王游戏(P1080)也是这个模型。
模型三:反悔贪心。 先照着贪心接受当前的选择;之后发现不划算,就把以前选的里面最差的一个换掉。“最差的一个”用堆来找。
// 反悔贪心(堆)。洛谷 P2949 [USACO09OPEN] Work Scheduling G:
// n 个工作,每个要花 1 单位时间,第 i 个必须在时刻 d_i 之前做完,报酬 p_i。求最大总报酬。
// 做法:按截止时间从早到晚考虑,先接下来;接的数量超过截止时间了,就反悔,退掉报酬最低的那个。
#include <bits/stdc++.h>
using namespace std;
struct Job {
int d, p;
};
bool cmp(const Job &a, const Job &b) { return a.d < b.d; }
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<Job> job(n);
for (int i = 0; i < n; i++) cin >> job[i].d >> job[i].p;
sort(job.begin(), job.end(), cmp);
priority_queue<int, vector<int>, greater<int> > chosen; // 小根堆:已接工作的报酬,堆顶是最低的
long long total = 0;
for (int i = 0; i < n; i++) {
chosen.push(job[i].p); // 先接下
total += job[i].p;
if ((int)chosen.size() > job[i].d) { // 时刻 d 之前最多做 d 个,超了就退掉最不值的
total -= chosen.top();
chosen.pop();
}
}
cout << total << '\n';
return 0;
}
模型四:从最受限制的元素入手。 “最小的数只能和谁配”“最左边的点只能被哪个区间盖住”——选择最少的那个元素先安排,它的选法往往是唯一的。
贪心没把握时怎么办
- 先写一个暴力:n ≤ 10 的子集枚举或全排列(第 2 章),保证对。
- 写出贪心,和暴力对拍几百组随机小数据。
- 对拍出了反例,就对着反例改策略;改不出来,暴力那一档分也已经在手里了。
考场上不要求证明,但要求对拍。“想当然的贪心”是 T1 丢分最常见的原因。
易错点
- 区间调度按开始时间排,或者按区间长度排,都是错的。反例:一个开始最早但特别长的区间,会挡住后面所有的区间。
- “端点刚好相接算不算冲突”看题面。P1803 里上一场的结束时间等于下一场的开始时间,两场都能参加,所以模板里是
>=。 - 反悔贪心里堆的方向弄反。要找的是“已经选的里面报酬最低的”,用小根堆。
- 题目要输出“选了哪几个”时,排序前把原始编号存下来。
练一练:洛谷 P1803 线段覆盖、P1223 排队接水、P2949 Work Scheduling、P1090 合并果子、P1080 国王游戏;CSP-S 2025 T1 社团招新。
1.9 位运算速查 【常考】
| 写法 | 含义 |
|---|---|
x >> i & 1 |
x 的第 i 位(从 0 数起)是不是 1 |
x \| (1 << i) |
把第 i 位变成 1 |
x & ~(1 << i) |
把第 i 位变成 0 |
x ^ (1 << i) |
把第 i 位取反 |
x & (x - 1) |
去掉最低位的那个 1 |
x & -x |
只留下最低位的那个 1(树状数组里的 lowbit) |
__builtin_popcount(x) |
x 的二进制里有几个 1;long long 用 __builtin_popcountll |
(1 << n) - 1 |
低 n 位全是 1,表示 n 个元素的全集 |
a ^ b |
异或:相同得 0,不同得 1。a ^ a = 0,a ^ 0 = a |
常用技巧:
- 用一个整数表示集合:第 i 位是 1 表示元素 i 在集合里。交集是
A & B,并集是A | B,A 是否包含 B 是(A & B) == B。第 2 章的子集枚举和第 3 章的状压 DP 都靠它。 - 判断奇偶:
x & 1。 - 异或消去成对的数:一堆数里只有一个数出现了奇数次,把所有数异或起来,剩下的就是它。前缀异或
s[r] ^ s[l - 1]是区间异或和。 - 枚举 S 的所有非空子集【了解】:
for (int T = S; T; T = (T - 1) & S)。
易错点
- 优先级。
&、|、^的优先级比==、<还低:if (x & 1 == 0)实际上是x & (1 == 0),永远为假。位运算外面一律加括号,写成if ((x & 1) == 0)。 1 << 40溢出,因为 1 是int。超过 30 位要写1LL << 40。- 位运算只用在非负数上。对负数右移、把 1 移到符号位,结果都靠不住。
1.10 STL 速查 【必会】
| 容器 | 用途 | 常用操作 | 最容易踩的坑 |
|---|---|---|---|
vector<int> v |
变长数组、邻接表 | push_back size() v[i] back() clear() |
size() 是无符号数:v 为空时 v.size() - 1 是一个巨大的正数 |
pair<int, int> |
把两个值捆在一起 | make_pair(a, b) .first .second |
比较时先比 first,相等再比 second |
string |
字符串 | size() s[i] s + t substr(pos, len) |
下标从 0 开始;在循环里反复 s = s + c 是 O(n^2) |
queue / stack |
队列 / 栈 | push pop front() / top() empty() |
空的时候取元素或 pop 直接运行错误,先判 empty() |
priority_queue<int> |
堆 | push top() pop() |
默认是大根堆。小根堆要写 priority_queue<int, vector<int>, greater<int> > |
set<int> |
自动排序、不重复的集合 | insert erase(x) count(x) lower_bound(x) *s.begin() |
没有下标;multiset 的 erase(x) 会删掉所有等于 x 的,只删一个要写 erase(s.find(x)) |
map<K, V> |
按键排序的“键→值”表 | m[k] m.count(k) m.erase(k) |
m[k] 在 k 不存在时会插入一个 0。只想知道在不在,用 m.count(k) |
unordered_map<K, V> |
哈希表 | 同 map | 无序;pair 不能直接当键;个别数据下会变得很慢。没把握就用 map |
速度:vector 的 push_back、queue 和 stack 的操作是 O(1);set、map、priority_queue 的每次操作是 O(log n),10^6 次操作没有问题。
几个常用函数:sort、reverse、unique、swap、max_element、min_element、lower_bound、next_permutation。考场的编译选项是 -std=c++14,auto 和 for (auto &x : v) 可以用。
易错点
priority_queue默认大根。Dijkstra 要的是小根堆,写错了不会报错,只会答案错或者超时。- 往
priority_queue、set、map里放结构体,要重载<。在堆里,<的意思是“优先级更低”:想让 d 小的先出堆,要写bool operator<(const Node &o) const { return d > o.d; }。 for (int i = 0; i < v.size() - 1; i++)在 v 为空时停不下来,随后越界崩溃。统一写i + 1 < (int)v.size(),或者先int n = v.size();。- 在
set、map上二分要用成员函数(见 1.2)。 queue、stack、priority_queue没有clear()。多组数据时要清空,就重新赋值:q = queue<int>();。gcd()是 C++17 才有的,考场的 C++14 下是编译错误。__gcd(a, b)在考场的 g++ 上能用,但这台 Mac 的编译器没有它。最稳的是自己写三行(第 7 章)。memset是按字节填的,能放心用的只有 0、-1 和 0x3f 三种:memset(a, 0x3f, sizeof(a))让每个int变成 0x3f3f3f3f(约 10^9),每个long long变成约 4.6×10^18。想填 1 或别的数,用循环。- 遍历容器的同时增删它的元素,迭代器会失效。先别这样写。
本章必会清单
不看资料、10 分钟内写对,才算会:
- 结构体三关键字排序的 cmp(相等时返回 false)
- 手写二分:第一个 ≥ x 的位置;会用
lower_bound/upper_bound并说出两者的区别 - 二分答案的完整框架,说得出 check 的结果是“真真假假”还是“假假真真”
- 一维前缀和、二维前缀和的两条公式
- 差分:区间加的两行修改和最后的还原
- 双指针求最长合法窗口的骨架
- 离散化四步
- 区间调度(按结束时间排序)
- 小根堆的定义写法;反悔贪心的“先接下、超了就退掉最差的”
- 说得出
priority_queue默认方向、map的[]会插入、size()是无符号数这三个坑