NOIP 算法笔记全部章节
本章目录

第 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;
}

三步:

  1. 二分什么:就是答案本身,确定它的范围 [下界, 上界]。
  2. 写 check(x):通常是从左到右贪心地扫一遍。写之前先想清楚:x 变大,check 是更容易成立还是更难成立?
  3. 套二分:求“最大的可行值”,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;
}

模型四:从最受限制的元素入手。 “最小的数只能和谁配”“最左边的点只能被哪个区间盖住”——选择最少的那个元素先安排,它的选法往往是唯一的。

贪心没把握时怎么办

  1. 先写一个暴力:n ≤ 10 的子集枚举或全排列(第 2 章),保证对。
  2. 写出贪心,和暴力对拍几百组随机小数据。
  3. 对拍出了反例,就对着反例改策略;改不出来,暴力那一档分也已经在手里了。

考场上不要求证明,但要求对拍。“想当然的贪心”是 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() 是无符号数这三个坑