竞赛冲刺 CSP-J/S 六年级起

课程简介

这是信息学竞赛体系里从"会写代码"走向"会比赛"的关键一站。围绕 CSP 非专业级软件能力认证(CSP-J 入门级 / CSP-S 提高级)做专项训练:先从赛制与考点地图建立全局观,再用枚举、模拟、贪心、二分、搜索、DP、数据结构、图论八大模块逐个击破高频算法,最后用真题精讲与全真模拟收口。每一个算法都配可运行的 C++ 示例与真实输出,让学生不仅"看得懂",更"跑得通、算得对"。

本课程定位为 CSP-J 冲刺为主、为 CSP-S 铺路,难度略高于 C++ 基础课程,但仍严格控制在入门竞赛算法范围内。目标很具体:保 1、2 题满分,稳 3 题大部分分,冲 4 题,稳定冲刺一、二等奖,并为冲击 NOIP 打好地基。

CSP-J 六年级就可以冲:认证报名不限年龄,小学生、初中生均可参加。六年级首次参赛目标以"拿牌、攒经验"为主(积累小升初材料);进入初中后可再战冲一等奖,并逐步转向 CSP-S。所以本课程对年级没有硬性上限——水平到了就能上。

适合对象

  • 已完成 C++ 与基础算法课程,或通过入学测评(会数组、循环、函数、递归)。
  • 目标参加当年 CSP-J / CSP-S 认证的学生,CSP-J 从六年级起即可起步冲击。
  • 希望系统掌握竞赛算法、学会考试时间分配与拿分策略。

课程大纲

syllabus.sh
第1章  CSP 赛制与考点地图
第2章  枚举、模拟与贪心精讲
第3章  二分与高精度
第4章  搜索进阶:剪枝与记忆化
第5章  动态规划入门
第6章  数据结构:栈、队列、优先队列
第7章  图论入门与最短路径
第8章  真题精讲与全真模拟赛

课程内容

↑ 目录第 1 章 CSP 赛制与考点地图

本章目标:彻底弄清 CSP 怎么考、怎么算分、考什么,能对着考点地图给自己做一份"得分计划"。多数学生第一年不是败在算法,而是败在不懂规则、乱分配时间。

1.1 认证等级与晋级路线:J 组、S 组与 NOIP

CSP 分两个级别,各自独立报名、独立评奖:CSP-J(入门级)不限年龄,小学高年级、初中都可参加,是绝大多数选手的起点;CSP-S(提高级)面向有一定算法深度的学生(多为初高中)。它不是"过了 J 才能考 S",而是两场都能报。真正的晋级通道是:CSP-J/S → NOIP(全国青少年信息学奥林匹克联赛,报名通常要求 CSP-S 成绩达线)→ 省选 → NOI

对六年级起冲刺的孩子:第一年目标 CSP-J 拿牌(省二起步,冲省一),同时攒下小升初的竞赛材料;进入初中后冲击 CSP-J 一等奖、CSP-S 二三等奖,为冲 NOIP 蓄力。J 组只要求 C++ 基础加本章列出的几类算法;S 组会要求树、图论进阶、更难的 DP 与数论。本章的考点地图按 J 组为主展开。

1.2 两轮考试:初赛(笔试)与复赛(机试)

CSP 每年 9 月考第一轮(笔试,含机考读程序题),通过分数线的进入第二轮(上机编程,四道题 3.5~4 小时)。一轮题型固定:

round1.sh
CSP-J 第一轮(笔试,满分 100)
  1. 单项选择题:15 题 × 2 分,共 30 分(C++ 语法 / 数学 / 常识)
  2. 阅读程序:3 大题约 13~15 小题,共约 40 分(人工模拟程序运行)
  3. 完善程序:2 大题约 10~13 小题,共约 30 分(选词填空补全算法)

CSP-J 第二轮(机试,每大题 100 分)
  题号  预计难度        典型考法
  T1    送分题         模拟 / 简单数学,必须拿满分
  T2    简单题         枚举、二分、排序、简单贪心
  T3    中档题         搜索、DP、数据结构
  T4    压轴题         综合搜索 / DP / 图论,拿部分分就是胜利
第二轮四道题不是按难度 1→4 线性递增,近几年 T1 偶有"文字游戏"陷阱。拿到卷子先通读四道题 10 分钟,挑软柿子先捏,别在第一题上死磕。

1.3 得分策略:会的不丢,不会的写暴力

竞赛拿奖的数学本质是总分排名,不是"做出几题"。一道 T4 拿 30 分部分分,往往比在一道 T2 上浪费时间空手而归划算得多。我们给自己定的分数线节奏:

score-map.sh
得分策略建议(CSP-J 二轮)
  1. T1、T2 要求满分:读题 → 样例 → 边界数据自测,一道都不能丢
  2. T3 目标 60~100 分:先写朴素暴力拿基础分,再优化
  3. T4 目标 30~70 分:只会暴搜就写暴搜,会部分 DP 就写 DP
  4. 底线法则:任何一道题都先交"能跑的版本",禁止交空文件
  5. 每题结尾留 3 分钟重读一遍:文件名、输入输出、数据范围、样例
复赛最常见的失分不是不会做,而是文件名写错、忘了文件读入输出(freopen)、爆 int 范围、样例能过但没测极端数据。第 8 章会专门训练"考场防呆流程"。

1.4 时间分配:考场的 210 分钟怎么花

以 J 组二轮 3.5 小时为例,推荐模板:

  • 第 1~10 分钟:通读四题,给每道题标"我会 / 有点会 / 完全不会",定做题顺序。
  • 第 10~70 分钟:写完 T1、T2 并造边界数据自测(n=1、最大值、全同值)。
  • 第 70~150 分钟:攻 T3,先暴力后优化;若卡壳 20 分钟果断换题。
  • 第 150~200 分钟:T4 写能拿的部分分;把所有题目的提交文件都整理到位。
  • 最后 10 分钟:只做检查清单,不再改代码逻辑。

1.5 考点地图:近五年 J 组复赛考什么

近五年 CSP-J 二轮的高频考点高度收敛,我们按"必考/常考/偶尔考"分层:

hotspots.sh
必考(几乎每年都出)
  · 模拟 + 大模拟:读懂题意、照规则逐步实现
  · 贪心 / 二分答案 / 排序
  · 搜索(DFS / BFS),配剪枝或记忆化
常考(每 2~3 年一轮)
  · 动态规划:背包、线性 DP、区间 DP 入门
  · 栈、队列、优先队列的简单应用
  · 高精度加减(字符串大数)
偶尔考
  · 图论:最短路、最小生成树、并查集
  · 数学:进制、同余、组合计数、辗转相除

对照这张地图,缺哪块补哪块。后续七章正是按这张图的权重排序展开的。

建"考点 × 失分"二维错题表:横轴是上表考点,纵轴是失分原因(不会/看错/超时/细节)。每考完一次填一行,考前看表就知道该补什么,比盲目刷题高效得多。

基础练习:自测一套近年 CSP-J 第一轮真题(限时 90 分钟),统计单选、阅读、完善三个部分的得分率。

挑战练习:做一套二轮真题(限时 3.5 小时),给每道题记录"得分 / 失分点 / 属于哪个考点 / 下次策略"。提示:失分原因分四类记——不会做、看错题、爆范围、没时间。

↑ 目录第 2 章 枚举、模拟与贪心精讲

本章目标:三类"送分主力"题型一次练透:枚举怎么缩范围、模拟怎么保正确、贪心怎么敢下手。这三类占 T1、T2 的大半江山,是"保 1、2 题满分"的本钱。

2.1 枚举:缩小范围的三个方向

枚举就是把所有可能都试一遍,听起来简单,难在"怎么少试"。

  • 剪掉无效范围:判断质数只需试到 sqrt(n),因为若 n 有因子,必有一个不超过根号 n。
  • 缩小枚举对象:与其枚举答案,不如枚举"和答案绑定的量"。
  • 利用对称性/奇偶性:很多问题先想性质再枚举,能砍掉一半以上。

先看质数判定的经典优化,把 n 次试除变成 sqrt(n) 次:

21_prime.cpp
#include <iostream>
using namespace std;

bool isPrime(int n) {
    if (n < 2) return false;
    for (int d = 2; d * d <= n; d++)   // 只试到 sqrt(n) 就够
        if (n % d == 0) return false;
    return true;
}

int main() {
    int cnt = 0;
    for (int i = 1; i <= 100; i++) {
        if (isPrime(i)) { cout << i << " "; cnt++; }
    }
    cout << "\n1~100 中共有 " << cnt << " 个质数" << endl;
    return 0;
}
运行结果
2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 
1~100 中共有 25 个质数
循环条件 d * d <= n 容易手滑写成 d < n(变慢但不至于错)或 d * d < n(会把完全平方数误判为质数)。条件边界是最容易出的隐蔽 bug。

2.2 模拟:把题目"演"一遍

模拟题就是"读题规则 → 找状态 → 照着走"。做模拟唯一的要求是准确:变量别混、边界别越、循环别死。约瑟夫问题是最经典的"报数出圈"模拟——n 个人围一圈,从 1 开始报数,报到 m 的人出列,求所有人的出列顺序。用数组 + 取模模拟"围成圈":

22_joseph_array.cpp
#include <iostream>
#include <vector>
using namespace std;

int main() {
    int n = 7, m = 3;
    vector<int> v;
    for (int i = 1; i <= n; i++) v.push_back(i);

    int idx = 0;
    cout << "出列顺序:";
    while (!v.empty()) {
        idx = (idx + m - 1) % v.size();   // 从当前位置数第 m 个
        cout << v[idx] << (v.size() > 1 ? " " : "");
        v.erase(v.begin() + idx);         // 删除后,下一人自动补位
    }
    cout << endl;
    return 0;
}
运行结果
出列顺序:3 6 2 7 5 1 4
模拟题的三个自测小技巧:① 用纸笔把前几轮"演"一遍,和程序输出对;② 把 n 改成很小的数(如 n=1、n=m)检查边界;③ 程序里临时加打印看中间状态,确认后再删。第 6 章还会用队列再解一次约瑟夫,两种写法对照能加深对数据结构的理解。

2.3 贪心入门:活动安排

贪心的想法很简单:每一步都做当前看起来最好、且不会让后面变差的选择。看经典题"活动安排":有 n 个活动,每个有开始和结束时间,一个人最多能参加几个完整活动?直觉是先选结束最早的,因为结束早给后面留的余地最大。这就是"按结束时间排序,能选就选":

23_activity.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<pair<int, int>> act(n);   // first=结束时间 second=开始时间
    for (int i = 0; i < n; i++) {
        int s, f;
        cin >> s >> f;
        act[i] = {f, s};
    }
    sort(act.begin(), act.end());    // 按结束时间升序
    int ans = 0, last = 0;           // last: 上一个选中活动的结束时刻
    for (auto &p : act) {
        if (p.second >= last) {      // 与上一个活动不冲突
            ans++;
            last = p.first;
        }
    }
    cout << "最多能参加 " << ans << " 个活动" << endl;
    return 0;
}
运行示例(输入 → 输出)
输入:
5
1 3
2 5
4 6
6 8
5 7
输出:最多能参加 3 个活动

为什么"按开始时间排序"就不行?反例:活动 A(1,10)、B(2,3)、C(4,5)。按开始时间先选 A,后面全冲突,只能参加 1 个;按结束时间选 B、C 能参加 2 个。先选结束早的 = 给未来留最多的空隙,这个"局部最优不伤全局"的感觉就是贪心的直觉。

2.4 贪心经典:排队接水

另一个必练模型:n 个同学排队接水,每人接水耗时不同,问怎样排队让所有人等待的总时间最小。答案是"耗时短的先接",也就是最短作业优先。等待时间是阶梯式累加的,一个人排得越靠前,他的耗时被"乘上"越多的人次,所以一定要把大耗时往后放:

24_water.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n = 5;
    vector<int> t = {3, 2, 1, 5, 4};   // 第 i 个同学接水所需时间
    vector<pair<int, int>> p;          // <时间, 原编号>
    for (int i = 0; i < n; i++) p.push_back({t[i], i + 1});
    sort(p.begin(), p.end());          // 耗时短的先接水

    cout << "接水顺序:";
    for (int i = 0; i < n; i++)
        cout << p[i].second << (i + 1 < n ? " " : "");
    long long wait = 0;
    for (int i = 0; i < n; i++)
        wait += (long long)p[i].first * (n - 1 - i);  // 被他拖累的人数
    cout << "\n总等待时间:" << wait << endl;
    return 0;
}
运行结果
接水顺序:3 2 1 5 4
总等待时间:20

手算核对:接水耗时排序后为 1、2、3、4、5(对应原编号 3、2、1、5、4)。第 1 人等待 0,第 2 人等 1,第 3 人等 1+2=3,第 4 人等 6,第 5 人等 10,合计 20 分钟。

2.5 贪心的证明与反例意识

贪心最容易翻车的点,是"感觉对"但实际错。考试中给贪心策略之前,强迫自己做三件事:

  • 写反例:试着构造一组数据推翻自己——推翻不了再动手写。
  • 交换论证:假设最优解里有两个相邻元素顺序和贪心不同,说明"交换它们不会更差",这就证明了贪心序。
  • 能证就写,证不了就退:证不出来的贪心,宁可写 DP 或搜索保分,别赌。
并不是所有"看起来贪心"的问题都能贪心。例如"找零钱用最少数量的硬币"在人民币币值下贪心正确,但换成币值 {1,3,4} 找 6 元,贪心先取 4 再取 1+1 共 3 枚,而最优是 3+3 共 2 枚。做题前先想清楚题目给你的"币值"是不是支持贪心。

基础练习:实现约瑟夫问题 n=10、m=4 的输出,并用纸笔验证前三个出列的人。

挑战练习:有 n 个区间,选尽量多的区间使它们两两不重叠(允许端点相接)。提示:和"活动安排"只差一个符号——端点相接算不算冲突,决定了排序后用 >= 还是 > 判断。

↑ 目录第 3 章 二分与高精度

本章目标:掌握整数二分的两种模板与"二分答案"这一高频套路,学会用数组模拟超大整数加减。二分是把"求最值"变成"多次判断"的万能开关,高精度则是避开整数溢出的大数护盾。

3.1 整数二分:模板与边界

二分的前提是单调性:一个有序数组里,"小于 x 的数"和"不小于 x 的数"被一条线切开,二分就是每次扔掉一半,O(n) 查找变成 O(log n)。竞赛里 90% 的二分代码错在边界上。下面的模板找"第一个 >= x 的位置"(就是 STL 的 lower_bound):

31_binsearch.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> a = {1, 3, 3, 5, 7, 9, 11};   // 递增数组
    vector<int> q = {3, 6, 8, 11, 0};          // 若干次查询
    for (int x : q) {
        int l = 0, r = (int)a.size();          // [l, r) 左闭右开
        while (l < r) {
            int mid = (l + r) / 2;
            if (a[mid] >= x) r = mid;          // 第一个 >= x
            else l = mid + 1;
        }
        if (l < (int)a.size() && a[l] == x)
            cout << x << " 出现在下标 " << l << endl;
        else if (l < (int)a.size())
            cout << x << " 不存在,第一个比它大的数是 a[" << l << "]=" << a[l] << endl;
        else
            cout << x << " 不存在,且比所有元素都大" << endl;
    }
    return 0;
}
运行结果
3 出现在下标 1
6 不存在,第一个比它大的数是 a[4]=7
8 不存在,第一个比它大的数是 a[5]=9
11 出现在下标 6
0 不存在,第一个比它大的数是 a[0]=1
二分写错的三个经典姿势:① 区间写成闭区间但 r 初值少 1,漏掉最后一个元素;② mid=(l+r)/2 配合 l=mid 在 l、r 相邻时会死循环;③ 死记"一个模板走天下",不知道当前查的是"第一个 >= x"还是"最后一个 <= x"。建议只背一种左闭右开写法,改条件时对照着改区间端点。

3.2 二分答案:木材切割

比二分查找更重要的是二分答案:当题目问"最大/最小能到多少"、且"给定一个值能快速判断行不行"时,就对答案本身二分。经典例题:把几根原木锯成 m 段长度相同的短木,每段最长能有多长?对"段长 L"二分,check 函数数一数所有原木能切出多少段 >= m 段即可:

32_wood.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m;
    cin >> n >> m;
    vector<long long> a(n);
    long long hi = 0;
    for (int i = 0; i < n; i++) { cin >> a[i]; hi = max(hi, a[i]); }

    auto check = [&](long long len) -> bool {
        if (len == 0) return true;
        long long cnt = 0;
        for (long long x : a) cnt += x / len;
        return cnt >= m;
    };

    long long lo = 1, ans = 0;
    while (lo <= hi) {
        long long mid = (lo + hi) / 2;
        if (check(mid)) { ans = mid; lo = mid + 1; }  // 能切够就再试长一点
        else hi = mid - 1;
    }
    cout << "每段最长 " << ans << " 厘米" << endl;
    return 0;
}
运行示例(输入 → 输出)
输入:
3 7
10 24 15
输出:每段最长 6 厘米

验证:长度 6 时,10 能切 1 段、24 能切 4 段、15 能切 2 段,共 7 段正好够;长度 7 时共 1+3+2=6 段不够。所以答案就是 6。二分答案的要点是:可行性随答案单调(段越长,能切出的段数越少),可二分。

3.3 二分答案:最大值最小化

另一类高频二分答案是"最大值最小化"。例如把一列数按顺序分成 m 段,问每段和的最大值最小能压到多少。判定的方式贪心扫描:给定上限 lim,从左往右能塞进当前段就塞,塞不下就开新段,数一数总共开了几段,<= m 段说明 lim 可行:

33_minmax.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m;
    cin >> n >> m;
    vector<long long> a(n);
    long long lo = 0, hi = 0;
    for (int i = 0; i < n; i++) {
        cin >> a[i];
        lo = max(lo, a[i]);        // 下界:最大的单个数
        hi += a[i];                // 上界:全放一段
    }

    auto check = [&](long long lim) -> bool {
        long long seg = 0, cnt = 1;
        for (long long x : a) {
            if (seg + x <= lim) seg += x;   // 塞得下就塞进当前段
            else { cnt++; seg = x; }        // 塞不下,新开一段
        }
        return cnt <= m;
    };

    while (lo < hi) {
        long long mid = (lo + hi) / 2;
        if (check(mid)) hi = mid;   // m 段能放下,试着压小最大值
        else lo = mid + 1;
    }
    cout << "每段和的最大值最小为 " << lo << endl;
    return 0;
}
运行示例(输入 → 输出)
输入:
5 3
4 2 4 5 1
输出:每段和的最大值最小为 6

核对:分法 {4,2}、{4}、{5,1} 三段的段和是 6、4、6,最大 6;而任何三段分法都无法让最大值小于 6(因为要 3 段必然有一段同时含 4 和 5,或拆散它们后仍有更大单段)。

看到"最大值最小 / 最小值最大"字样,第一反应就应该是二分答案,第二反应才是别的算法。二分答案能把最难的最优化问题,降维成简单的 check() 判定。

3.4 高精度加法:倒序存储

当整数超过 long long 的约 9.2×10^18 上限(比如几百位的大数),就要用高精度:把数字当成字符串读入,逐位存进数组运算。铁律是倒序存储——个位在下标 0,这样进位就往"高下标"方向自然延伸,最后倒着输出即可:

34_highadd.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    string a, b;
    cin >> a >> b;
    vector<int> A, B, C;
    for (int i = (int)a.size() - 1; i >= 0; i--) A.push_back(a[i] - '0');
    for (int i = (int)b.size() - 1; i >= 0; i--) B.push_back(b[i] - '0');

    int carry = 0;
    for (int i = 0; i < max(A.size(), B.size()); i++) {
        int x = carry;
        if (i < A.size()) x += A[i];
        if (i < B.size()) x += B[i];
        C.push_back(x % 10);
        carry = x / 10;
    }
    if (carry) C.push_back(carry);

    for (int i = (int)C.size() - 1; i >= 0; i--) cout << C[i];
    cout << endl;
    return 0;
}
运行示例(输入 → 输出)
输入:
999999999999999999999
1
输出:1000000000000000000000

输入:
123456789
987654321
输出:1111111110

第二组:123456789 + 987654321 = 1111111110,逐位相加时连续产生进位,正好检验 carry 处理是否正确。

3.5 高精度减法与去前导零

减法与加法套路相同,多两个细节:借位(不够减就向高位借 1 当 10)和去前导零(结果可能是 1000-999=1,数组里存着 0001,输出前把高位的 0 全部去掉)。实现前提是保证被减数不小于减数,否则要先比大小并处理符号:

35_highsub.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    string a, b;
    cin >> a >> b;                     // 题目保证 a >= b >= 0
    vector<int> A, B, C;
    for (int i = (int)a.size() - 1; i >= 0; i--) A.push_back(a[i] - '0');
    for (int i = (int)b.size() - 1; i >= 0; i--) B.push_back(b[i] - '0');

    int borrow = 0;
    for (int i = 0; i < (int)A.size(); i++) {
        int x = A[i] - borrow;
        if (i < (int)B.size()) x -= B[i];
        if (x < 0) { x += 10; borrow = 1; }
        else borrow = 0;
        C.push_back(x);
    }
    while (C.size() > 1 && C.back() == 0) C.pop_back();  // 去掉前导 0

    for (int i = (int)C.size() - 1; i >= 0; i--) cout << C[i];
    cout << endl;
    return 0;
}
运行示例(输入 → 输出)
输入:
100000000000000000000
1
输出:99999999999999999999

输入:
520
88
输出:432
第一组输入是全 1 后面 20 个 0 减去 1,若输出前没做"去前导零",会多出高位的 0,样例必然出错。减法比加法更容易漏掉的就是这一步,另外若结果是 0,一定要保留一个 0(所以去零循环的条件是 C.size() > 1 而不是 C.size() > 0)。

基础练习:实现 1~n 的二分查找(含重复元素,查第一个等于 x 的下标),n 取 10^6 随机数据自查。

挑战练习:高精度乘法:两数位数都不超过 1000。提示:结果第 i+j 位累加 A[i]×B[j],逐位处理进位;结果最大位数是两数位数之和,需从低位开始两层循环。

↑ 目录第 4 章 搜索进阶:剪枝与记忆化

本章目标:在会写 DFS/BFS 的基础上,掌握"搜索 = 状态 + 转移 + 边界"的建模,学会用可行性剪枝砍掉死路、用记忆化把指数级递归变成线性——这是 T3 题拿分的关键武器。

4.1 搜索三要素:状态、转移、边界

任何搜索题都先问自己三个问题:状态是什么(递归参数)、能转移到哪些状态(循环里的下一步)、什么时候停(终止条件)。以输出 1~n 的全排列为例,状态是"已选了几个数、哪些数被用过",转移是"在没用过的数里挑下一个",边界是"选满 n 个就输出"。for 从小到大尝试,输出天然是字典序:

41_dfs_perm.cpp
#include <bits/stdc++.h>
using namespace std;

int n = 3;
int a[10];
bool used[10];

void dfs(int k) {                    // 已确定前 k 个数
    if (k == n) {
        for (int i = 0; i < n; i++) cout << a[i];
        cout << " ";
        return;
    }
    for (int i = 1; i <= n; i++) {   // 从小到大尝试 => 天然字典序
        if (!used[i]) {
            used[i] = true;
            a[k] = i;
            dfs(k + 1);
            used[i] = false;
        }
    }
}

int main() {
    dfs(0);
    cout << endl;
    return 0;
}
运行结果
123 132 213 231 312 321
这一小段代码藏着搜索的所有骨架:数组 a 记当前状态、used 记"选过的痕迹"、循环里"标记 → 递归 → 撤销标记"的三步走叫回溯。把这三步的肌肉记忆练出来,后面所有 DFS 题都是在往这个骨架里填东西。

4.2 可行性剪枝:提前砍掉死路

朴素搜索的时间是"搜完整棵树",很多分支从半路看就已经注定失败,提前返回就叫剪枝。看"子集和"问题:从一组数里选若干个,能否凑出目标 S?暴力要试 2^n 种选择,而剪枝能砍掉两类必败分支:当前和已经超过 S 的(继续加只会更大)、以及剩下的数全选也不够 S 的。程序里用两个计数器直观对比剪枝效果:

42_subsetsum.cpp
#include <bits/stdc++.h>
using namespace std;

long long cntNaive = 0, cntPrune = 0;
int n, S;
vector<int> val;
long long suffixSum[105];

bool dfsNaive(int i, int sum) {
    cntNaive++;
    if (i == n) return sum == S;
    if (dfsNaive(i + 1, sum)) return true;            // 不选
    if (dfsNaive(i + 1, sum + val[i])) return true;   // 选
    return false;
}

bool dfsPrune(int i, int sum) {
    cntPrune++;
    if (sum > S) return false;                        // 剪枝①:已经超过目标
    if (i == n) return sum == S;
    if (sum + suffixSum[i] < S) return false;         // 剪枝②:剩下的全选也不够
    if (dfsPrune(i + 1, sum)) return true;
    if (dfsPrune(i + 1, sum + val[i])) return true;
    return false;
}

void run() {
    cntNaive = cntPrune = 0;
    suffixSum[n] = 0;
    for (int i = n - 1; i >= 0; i--) suffixSum[i] = suffixSum[i + 1] + val[i];

    bool ok1 = dfsNaive(0, 0);
    bool ok2 = dfsPrune(0, 0);
    cout << (ok2 ? "可行" : "不可行")
         << "  |  纯递归调用 " << cntNaive << " 次"
         << ",剪枝递归调用 " << cntPrune << " 次" << endl;
}

int main() {
    val = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24};
    n = (int)val.size();
    S = 155;              // 全是偶数却要凑奇数 => 必不可能
    run();
    val = {3, 5, 7, 9};
    n = (int)val.size();
    S = 12;               // 有解的小数据
    run();
    return 0;
}
运行结果
不可行  |  纯递归调用 8191 次,剪枝递归调用 25 次
可行  |  纯递归调用 15 次,剪枝递归调用 13 次

第一组数据有 12 个数,纯递归要把 8191 个节点全部走完才知道无解,而剪枝只探索了 25 个节点就宣告失败——指数级搜索瞬间变成常数级。这就是 T3 题从"超时"到"秒过"的差距。

4.3 记忆化搜索:爬楼梯

有一类搜索的"死路"不在分支少,而在同一个子问题被反复算成千上万次。看爬楼梯:一次走 1 阶或 2 阶,走完 n 阶有几种走法?递归式是 f(n) = f(n-1) + f(n-2),但纯递归会像一棵疯狂分叉的树一样重复计算 f(1)、f(2) 无数次。解决办法叫记忆化:开一个数组,算过的值记下来,下次直接查表返回。加上一个计数器就能看到差距:

43_stairs.cpp
#include <bits/stdc++.h>
using namespace std;

const int N = 45;
long long f[N];
long long cntPlain = 0, cntMemo = 0;

long long plain(int n) {         // 纯递归:同一个子问题反复算
    cntPlain++;
    if (n <= 1) return 1;
    return plain(n - 1) + plain(n - 2);
}

long long memo(int n) {          // 记忆化:算过一次就记下来
    cntMemo++;
    if (n <= 1) return 1;
    if (f[n]) return f[n];
    return f[n] = memo(n - 1) + memo(n - 2);
}

int main() {
    int n = 30;
    cout << "n=" << n << " 时一次走 1 或 2 阶" << endl;
    cout << "纯递归 :方案数 " << plain(n) << ",递归调用 " << cntPlain << " 次" << endl;
    memset(f, 0, sizeof(f));
    cout << "记忆化  :方案数 " << memo(n) << ",递归调用 " << cntMemo << " 次" << endl;
    return 0;
}
运行结果
n=30 时一次走 1 或 2 阶
纯递归 :方案数 1346269,递归调用 2692537 次
记忆化  :方案数 1346269,递归调用 59 次

结果一样是 1,346,269 种走法,但纯递归调用了 269 万次函数,记忆化只调用了 59 次——每个 n 至多真算一次。若 n 到 40,纯递归要调几亿次直接超时,而记忆化依旧是"每个状态算一次"。

4.4 剪枝与记忆化的边界感

两种优化的适用范围要分清:

  • 剪枝解决"分支太深太多",适合从半路就能判断必败/已够优的题,例如八皇后、数独、子集和。
  • 记忆化解决"子问题重复计算",适合状态能用一个(或一组)参数完整刻画的题,例如爬楼梯、斐波那契、走棋盘、区间型题目。
  • 两者常可叠加:先剪枝避免无意义分支,再记忆化避免重复子问题。
记忆化有一个隐藏前提:子问题必须无后效性——同一个参数状态,未来的选择和之前怎么走到这里无关。一旦递归里带"路径标记"之类的全局副作用(例如全排列里 used 数组的状态),就不能简单按参数记忆化,否则会把"上次走过的路"串进来算错。

基础练习:用 DFS 输出 1~4 的所有排列,并统计递归函数被调用的总次数。

挑战练习:记忆化搜索求"从 (1,1) 走到 (n,m) 只能向右或向下"的方案数,n=m=20,并输出与纯递归的调用次数对比。提示:状态是 (i, j) 两个坐标,转移只有"向右、向下"两个方向,f[i][j] = f[i-1][j] + f[i][j-1]

↑ 目录第 5 章 动态规划入门

本章目标:打通"记忆化搜索 → DP"这条路,掌握动态规划三要素(状态、转移、初始值),吃透 01 背包与完全背包的顺序奥秘,并会写线性 DP。DP 是 CSP 复赛 T3/T4 的半壁江山。

5.1 从记忆化到 DP:重叠子问题与最优子结构

上一章的爬楼梯其实已经是一只脚踩在 DP 门里。把记忆化搜索换个视角看:既然每个状态只算一次、且 f(n) 只依赖更小的 f(n-1)、f(n-2),那不如反过来从底部往上算:先算 f(1)、f(2),一路递推到 f(n),连递归都不用了。这就是动态规划。

做 DP 题永远先回答三个问题:

  • 状态定义dp[i] 到底表示什么?定义错了全盘皆输。
  • 状态转移:当前状态怎么由更小的状态推出来?写清楚"怎么由已知推未知"。
  • 初始值:最小的状态值是多少?边界填错,结果跟着错。
DP 能用有两个前提:最优子结构(大问题的最优解由子问题最优解拼出)和无后效性(当前决策只影响未来,不回溯已算过的过去)。判断不了时,先写记忆化搜索验证正确性,再改成 DP 提速——这是最稳的做题路径。

5.2 线性 DP:最长上升子序列

看一个不能再经典的线性 DP——最长上升子序列(LIS,Longest Increasing Subsequence):在序列里挑一个子序列,保持原顺序且严格递增,求最长能多长。状态定义:dp[i] = 以第 i 个数结尾的 LIS 长度。转移:在 i 前面所有比 a[i] 小的 j 里,取 dp[j] 最大者 + 1:

51_lis.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> a = {3, 1, 4, 1, 5, 9, 2, 6};
    int n = (int)a.size();
    vector<int> dp(n, 1);          // dp[i]:以 a[i] 结尾的最长上升子序列长度
    int ans = 1;
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (a[j] < a[i])                        // 能接在 a[j] 后面
                dp[i] = max(dp[i], dp[j] + 1);
        }
        ans = max(ans, dp[i]);
        cout << "以 a[" << i << "]=" << a[i] << " 结尾的 LIS = " << dp[i] << endl;
    }
    cout << "最长上升子序列长度 = " << ans << endl;
    return 0;
}
运行结果
以 a[0]=3 结尾的 LIS = 1
以 a[1]=1 结尾的 LIS = 1
以 a[2]=4 结尾的 LIS = 2
以 a[3]=1 结尾的 LIS = 1
以 a[4]=5 结尾的 LIS = 3
以 a[5]=9 结尾的 LIS = 4
以 a[6]=2 结尾的 LIS = 2
以 a[7]=6 结尾的 LIS = 4
最长上升子序列长度 = 4

最长的那条可以是 1, 4, 5, 9(长度 4),也可以是 3, 4, 5, 61, 2, 6(较短)。dp 数组记的是"以谁结尾"的信息,最终答案要扫一遍所有 dp 取最大——不是 dp[n-1]。这个"答案在过程中"的特点很容易踩坑。

5.3 01 背包:倒序循环的奥义

背包是 DP 里最实用的一族。01 背包问题:容量 m 的背包,n 件物品各有一个重量 w 和价值 v,每件最多取一次,求最大价值。状态 dp[j] = 容量 j 能装的最大价值。外层枚举物品,内层容量从大到小倒序更新:

52_bag01.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    int m = 10, n = 4;
    int w[4] = {2, 3, 5, 4};
    int v[4] = {8, 6, 12, 3};
    vector<int> dp(m + 1, 0);

    for (int i = 0; i < n; i++) {              // 枚举物品
        for (int j = m; j >= w[i]; j--) {      // 倒序遍历容量 => 每件只取一次
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    for (int j = 0; j <= m; j++) cout << dp[j] << " ";
    cout << "\n最大价值 = " << dp[m] << endl;
    return 0;
}
运行结果
0 0 8 8 8 14 14 20 20 20 26 
最大价值 = 26

核对:容量 10 的背包选重量 2+3+5 的三件(价值 8+6+12)正好装满,总价值 26;或换选别的组合都到不了 26。注意 dp 数组是滚动的——从 m 到 w[i] 倒着推,保证"本次刚更新过的大容量结果"不会被同一次循环里拿来重复使用同一件物品,从而实现"每件最多取一次"。

5.4 完全背包:正序循环的奥义

把题目改一个字:每件物品可以取无限件,就是完全背包。代码几乎一样,唯一区别是内层容量改成从小到大正序更新——正序让同一次循环里刚算出的 dp[j-w] 可以被后面的 j 再看到,相当于"这件物品可以再取一次",于是自动实现了无限取:

53_bagfull.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    int m = 10, n = 4;
    int w[4] = {2, 3, 5, 4};
    int v[4] = {8, 6, 12, 3};
    vector<int> dp(m + 1, 0);

    for (int i = 0; i < n; i++) {              // 枚举物品
        for (int j = w[i]; j <= m; j++) {      // 正序遍历容量 => 同一件可反复取
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    for (int j = 0; j <= m; j++) cout << dp[j] << " ";
    cout << "\n最大价值 = " << dp[m] << endl;
    return 0;
}
运行结果
0 0 8 8 16 16 24 24 32 32 40 
最大价值 = 40

同样 10 容量,完全背包里重量 2、价值 8 的物品性价比最高(每单位重量 4),装 5 件总价值 40。对比上一节输出能直观看到:01 背包倒序、完全背包正序,是背代码前必须想明白的一层纸

5.5 背包变形与"答案怎么选出来的"

学会两个基础背包后,要能一眼识别变种:

  • 恰好装满 vs 不超过容量:恰装满时把 dp 初始化为负无穷,只有从合法转移来的状态才有值。
  • 求方案数:把 max 改成 +,dp[j] 表示方案数。
  • 最小价值/最小代价:把 max 改成 min,初始化为很大的数。
  • 多重背包:把同一种物品的 k 件拆成"01 背包处理 k 次"即得朴素解法。
滚动数组牺牲了"用了哪些物品"的信息。若要输出具体选了哪几件,需要回退到二维 dp[i][j],从最后一个物品开始倒推:若 dp[i][j] != dp[i-1][j],说明第 i 件被选,再跳到 dp[i-1][j-w[i]]。做题时先想清楚题目要"最大价值"还是要"选法",再决定用一维还是二维。

基础练习:用 DP 求爬楼梯 f(50) 并输出每个 n 的方案数(验证溢出前用 long long)。

挑战练习:改成"最长非降子序列"并把序列换成 2000 个数,比较 dp 值的变化。提示:严格上升用 a[j] < a[i],非降只把 < 改成 <=,思考一下为什么这样改就能把相等的数也串起来。

↑ 目录第 6 章 数据结构:栈、队列、优先队列

本章目标:掌握栈(后进先出)、队列(先进先出)的语义与 STL 用法,理解优先队列(堆)"每次取最值"的能力,并能在解题时识别"该用哪种结构"。数据结构是算法的骨架,选对结构往往比算法本身更重要。

6.1 栈:后进先出与括号匹配

栈就像一摞盘子,只能从顶上拿、往顶上放。C++ 用 stack<T>push 压栈、top 看栈顶、pop 弹栈、empty 判空。最经典的应用是括号匹配:遇到左括号压栈,遇到右括号就和栈顶配对,配不上或栈已空就是错的。关键在于最后还要检查栈是否为空——"(()" 这种只进不出的情况会在这里露馅:

61_bracket.cpp
#include <bits/stdc++.h>
using namespace std;

bool ok(const string &s) {
    stack<char> st;
    for (char c : s) {
        if (c == '(' || c == '[' || c == '{') {
            st.push(c);
        } else {
            if (st.empty()) return false;
            char top = st.top();
            if ((c == ')' && top != '(') ||
                (c == ']' && top != '[') ||
                (c == '}' && top != '{')) return false;
            st.pop();
        }
    }
    return st.empty();   // 结束后栈空才完全匹配
}

int main() {
    string s;
    while (cin >> s) {
        cout << s << (ok(s) ? "  ->  匹配" : "  ->  不匹配") << endl;
    }
    return 0;
}
运行示例(输入 → 输出)
输入:
([]){}
([)]
{[(])}
(()
输出:
([]){}  ->  匹配
([)]  ->  不匹配
{[(])}  ->  不匹配
(()  ->  不匹配
([)] 看似左右都配上了,但栈的结构要求括号必须按嵌套顺序闭合,跨层配对属于错误。(() 里的括号能互相抵消,但最后栈里还剩一个 (,靠"结束时判空"这一条就能抓住它——这是最容易漏写的最后一步。
每个右括号与"最近的左括号"配对——"最近"正是栈擅长的。凡是题目出现"最近、相邻、嵌套、回退到上一步"这些词,优先想栈。

6.2 队列:先进先出与约瑟夫再解

队列像排队买饭,先进先出。C++ 的 queue<T>push 入队、front 看队头、pop 出队。用队列解约瑟夫问题非常形象:队头的人报数,没报到 m 就"绕到队尾"重新排队,报到 m 的直接出列,直到队伍清空:

62_queue_joseph.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n = 7, m = 3;
    queue<int> q;
    for (int i = 1; i <= n; i++) q.push(i);

    cout << "出列顺序:";
    bool first = true;
    while (!q.empty()) {
        for (int i = 1; i < m; i++) {      // 前 m-1 个人回到队尾
            q.push(q.front());
            q.pop();
        }
        if (!first) cout << " ";
        cout << q.front();
        first = false;
        q.pop();                           // 第 m 个人出列
    }
    cout << endl;
    return 0;
}
运行结果
出列顺序:3 6 2 7 5 1 4

和 2.2 节数组模拟的输出完全一致——同一个问题两种数据结构都能解,但队列版更贴近题意、不易错下标。这也是学数据结构的价值:为题目挑最顺手的容器

6.3 优先队列:合并果子的最小代价

优先队列 priority_queue 每次能 O(log n) 取出当前最大(默认大根堆)或最小(配 greater<T> 的小根堆)。经典题"合并果子":n 堆果子要合成一堆,每次合并两堆体力消耗为两堆之和,求最小总消耗。贪心显然:每次挑最小的两堆合,但合并出新堆后它可能不再是最小,需要"随时取当前最小"——这正是堆的用武之地:

63_mergefruit.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    priority_queue<int, vector<int>, greater<int>> pq;   // 小根堆
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        pq.push(x);
    }
    long long cost = 0;
    while (pq.size() > 1) {
        int a = pq.top(); pq.pop();
        int b = pq.top(); pq.pop();
        cost += a + b;
        pq.push(a + b);          // 合并出的新堆放回堆里
    }
    cout << "最小总代价 = " << cost << endl;
    return 0;
}
运行示例(输入 → 输出)
输入:
4
1 2 3 9
输出:最小总代价 = 24

过程:1+2=3(耗 3)→ 3+3=6(耗 6)→ 6+9=15(耗 15),总耗 24。如果贪心策略是"先合 1 和 9",总耗会大得多——每一步都取全局最小,就是靠堆把 O(n²) 的反复扫描降成 O(n log n)

6.4 优先队列妙用:Top-K 与小根堆大根堆

另一个高频应用:在一大堆数里找最小的 k 个(或第 k 小)。朴素做法排序取前 k 个是 O(n log n),数据大时可以用堆做成 O(n log k):维护一个容量为 k 的大根堆,堆顶就是"当前第 k 小"。来一个新数:塞进堆,若堆超过 k 个就把堆顶(最大的)踢掉——于是堆里永远留着"目前见过的 k 个最小的":

64_topk.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> a = {10, 4, 6, 3, 9, 7, 5, 8, 1, 2};
    int k = 3;
    priority_queue<int> pq;                    // 默认大根堆
    for (int x : a) {
        pq.push(x);
        if (pq.size() > k) pq.pop();           // 把多余的最大值扔掉
    }
    cout << "最小的 " << k << " 个是(堆顶到堆底):";
    vector<int> out;
    while (!pq.empty()) { out.push_back(pq.top()); pq.pop(); }
    for (int i = (int)out.size() - 1; i >= 0; i--) cout << out[i] << " ";
    cout << "\n第 " << k << " 小 = " << out[0] << endl;
    return 0;
}
运行结果
最小的 3 个是(堆顶到堆底):1 2 3 
第 3 小 = 3
口诀:找最小的一批用大根堆,找最大的一批用小根堆。因为堆里存的是"我们要的那批",堆顶是被挡在外面的"守门员",守门员越大越容易被顶替出局。想反了就颠倒了。

基础练习:给 61 节括号匹配补上对 { } 括号的完整支持并测试 {[()]}{[(]} 两组输入。

挑战练习:用队列实现"循环报数":n 个小孩围圈,每轮叫 1~m,输出第 k 轮出列的人。再用优先队列求"数据流中第 K 大的数"(支持逐个插入、随时询问)。提示:数据流中第 K 大 = 维护大小为 K 的小根堆,堆顶即答案。

↑ 目录第 7 章 图论入门与最短路径

本章目标:学会用邻接表存图,掌握 BFS 求无权图最短路、Dijkstra 堆优化求带权单源最短路,并会用并查集维护连通关系。图论题通常直接出现在 T4,哪怕只拿到 BFS/并查集的分数也价值不菲。

7.1 图的存储:邻接矩阵与邻接表

存图有两种主流方式:

  • 邻接矩阵 g[u][v]:O(1) 查两点是否有边,但空间 O(n²),适合 n ≤ 1000 的稠密图。
  • 邻接表 g[u] 是个列表:空间 O(n+m),适合 n、m 都大的稀疏图——竞赛中绝大多数图都用它。

邻接表的 C++ 写法通常是一个"装着 vector 的数组",遍历某个点的所有邻居就是遍历对应 vector。存一个 5 点无向图并打印:

71_adjlist.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> adj[6];               // 1~5 号点
    vector<pair<int, int>> edges = {{1, 2}, {1, 5}, {2, 3}, {3, 4}, {3, 5}};
    for (auto [u, v] : edges) {
        adj[u].push_back(v);
        adj[v].push_back(u);          // 无向图存两条
    }
    for (int u = 1; u <= 5; u++) {
        cout << u << " :";
        for (int v : adj[u]) cout << " " << v;
        cout << endl;
    }
    return 0;
}
运行结果
1 : 2 5
2 : 1 3
3 : 2 4 5
4 : 3
5 : 1 3
无向图要双向都存,漏一条就变成"只能单向走",最短路会算错。比赛时图一般是题目给的,读边时写成一行 add(u,v); add(v,u); 的辅助函数能有效防止漏存。

7.2 BFS:无权图的最短路

当所有边权都一样(通常视为 1)时,最短路径用 BFS:从起点出发逐层扩散,第一次到达某个点的层数就是最短路。因为队列的先进先出保证按"距离 0、1、2、…"的顺序访问节点。仍用上面的图从 1 号点出发:

72_bfs.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> adj[6];
    vector<pair<int, int>> edges = {{1, 2}, {1, 5}, {2, 3}, {3, 4}, {3, 5}};
    for (auto [u, v] : edges) {
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    int dist[6];
    memset(dist, -1, sizeof(dist));
    queue<int> q;
    dist[1] = 0;
    q.push(1);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int v : adj[u]) {
            if (dist[v] == -1) {
                dist[v] = dist[u] + 1;   // 第一次到达就是最短路
                q.push(v);
            }
        }
    }
    for (int u = 1; u <= 5; u++)
        cout << "1 到 " << u << " 的最短路 = " << dist[u] << endl;
    return 0;
}
运行结果
1 到 1 的最短路 = 0
1 到 2 的最短路 = 1
1 到 3 的最短路 = 2
1 到 4 的最短路 = 3
1 到 5 的最短路 = 1
"第一次访问即最短路"的前提是所有边权重相同。图中到 4 号点路径是 1→5→3→4 或 1→2→3→4,都是 3 步;若边带权,就要换下一节的 Dijkstra。另外 dist[v] == -1 同时承担"访问过标记"与"距离记录",比单独开 visited 数组更省。

7.3 Dijkstra:堆优化的单源最短路

带权图(边权非负)求"一个点到所有点的最短路",用 Dijkstra。朴素做法每轮找未确定点里距离最小的,复杂度 O(n²);用优先队列小根堆随时取出"当前距离最小的点",能优化到 O(m log n)。核心是 松弛:如果绕道 u 再到 v 比当前到 v 更短,就更新 v 并重新入堆。堆里可能残留过期的"大距离"记录,弹出时发现 d > dist[u] 就跳过:

73_dijkstra.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m, s;
    cin >> n >> m >> s;
    vector<pair<int, int>> g[105];   // g[u] = {终点, 边权}
    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        g[u].push_back({v, w});
        g[v].push_back({u, w});   // 无向图:双向都存
    }

    int dist[105];
    memset(dist, 0x3f, sizeof(dist));
    dist[s] = 0;
    priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
    pq.push({0, s});                 // {距离, 点}
    while (!pq.empty()) {
        auto [d, u] = pq.top();
        pq.pop();
        if (d > dist[u]) continue;   // 过期的旧记录,跳过
        for (auto [v, w] : g[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.push({dist[v], v});
            }
        }
    }
    for (int i = 1; i <= n; i++)
        cout << s << " 到 " << i << " 的最短路 = "
             << (dist[i] == 0x3f3f3f3f ? -1 : dist[i]) << endl;
    return 0;
}
运行示例(输入 → 输出)
输入:
5 7 1
1 2 2
1 5 6
2 3 3
3 5 1
3 4 4
2 5 7
4 5 2
输出:
1 到 1 的最短路 = 0
1 到 2 的最短路 = 2
1 到 3 的最短路 = 5
1 到 4 的最短路 = 8
1 到 5 的最短路 = 6

有趣的是到 4 的最短路不是看起来最直的 1→2→3→4(2+3+4=9),而是绕道 1→5→4(6+2=8)。Dijkstra 的关键词是"松弛":永远留一条可能更短的绕道

Dijkstra 只适用于边权非负。若出现负权边,要换 SPFA/Bellman-Ford。另外 memset(dist, 0x3f, ...) 是竞赛惯用的"极大值"初始化技巧,判断不可达时用 dist[i] == 0x3f3f3f3f,别拿个随便的大数比较导致误判。

7.4 并查集:合并与查询连通性

并查集解决"两个元素在不在一个集合里、以及合并集合"的问题,比如无向图连通块计数、社交网络好友圈。核心是一个 fa[] 数组:每个点记一个"父亲",祖先代表所在集合。find 沿父亲链找祖先,并顺手把路径上所有点直接挂到祖先下(路径压缩),让后续查询接近 O(1):

74_dsu.cpp
#include <bits/stdc++.h>
using namespace std;

int fa[10];                       // fa[x]:x 的父亲

int find(int x) {                 // 找祖先 + 路径压缩
    if (fa[x] != x) fa[x] = find(fa[x]);
    return fa[x];
}

void unite(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra != rb) fa[ra] = rb;
}

int main() {
    int n = 6;
    for (int i = 1; i <= n; i++) fa[i] = i;
    vector<pair<int, int>> ops = {{1, 2}, {3, 4}, {5, 6}, {2, 5}};
    for (auto [a, b] : ops) unite(a, b);

    for (int i = 1; i <= n; i++)
        cout << i << " 的祖先 = " << find(i) << endl;
    set<int> st;
    for (int i = 1; i <= n; i++) st.insert(find(i));
    cout << "连通块个数 = " << st.size() << endl;
    return 0;
}
运行结果
1 的祖先 = 6
2 的祖先 = 6
3 的祖先 = 4
4 的祖先 = 4
5 的祖先 = 6
6 的祖先 = 6
连通块个数 = 2

合并 1-2、3-4、5-6、2-5 之后,{1,2,5,6} 聚成一族,{3,4} 自成一家,共 2 个连通块。统计连通块 = 数一数有多少个点是自己的祖先,这正是并查集统计森林中树的数量。

并查集代码量极小,却是高频送分题(如"亲戚关系""合并集合")。练到 3 分钟内无错默写 find + unite。进阶技巧还有"按秩合并"和"带权并查集",冲刺 S 组再展开。

基础练习:手算验证 73 节 Dijkstra 的中间过程:列出 dist 数组从 {0,∞,∞,∞,∞} 到最终结果的每次更新,说明为什么 1→4 的最短路是 8 而不是 9。

挑战练习:用并查集求"无向图连通块个数":输入 n 个点、m 条边,输出连通块数量。提示:并查集初始化 n 个孤立点,合并每条边后,统计 find(i) == i 的个数。

↑ 目录第 8 章 真题精讲与全真模拟赛

本章目标:把前面七章的算法收口成"考场能力"。通过真题逐题精讲、部分分策略、全真模拟与复盘,训练读题、造数据、检查、心态四项硬技能。

8.1 读题四问:先别急着敲键盘

竞赛里 90% 的"粗心"其实都发生在读题阶段。拿到任何一道题,先回答四个问题再动手:

  • 输入是什么:第一行是什么、数据范围多大、有没有多组测试、读到 EOF 结束吗?
  • 输出要什么:格式精确到有没有行末空格、小数保留几位、无解时输出什么?
  • 限制是什么:n 最大多少(决定复杂度能到哪一档)、时间内存多少、文件输入输出名是什么?
  • 有没有陷阱:下标从 1 还是 0、"不含端点"这类词、数据能不能造得更大?
把样例输入手动算一遍再读代码:一方面确认自己真读懂了题,另一方面给自己留一个"程序输出必须等于手算结果"的验收标准。很多学生样例都不过就交,纯粹是浪费拿分机会。

8.2 部分分思维:先交能跑的版本

复赛四题不可能全做出来,但每一道都可以拿到分。拿到 T3/T4 先别想正解,先按数据范围分级拿分:

subtask.sh
部分分阶梯(从低到高)
  1. 数据范围很小(n ≤ 10)→ 直接暴力枚举 / 全排列 DFS
  2. 数据范围中等(n ≤ 1000)→ 朴素 O(n^2) 算法(如朴素 DP、朴素最短路)
  3. 数据范围很大     → 才需要堆优化 / 二分答案 / 高级剪枝

铁律
  · 先写一定能跑对、哪怕慢的版本,骗到基础分
  · 运行确认无误后再优化;优化一次验证一次
  · 宁可交 O(n^2) 的 60 分,不交"感觉是 O(n log n)"但写错 bug 的 0 分
部分分的最大敌人是"我快写好了"的幻觉——在 T4 上又憋 40 分钟赌正解,结果正解没写出来,暴力分也没交。规则是:每道题先锁定一个"保底版本"提交,再回头优化。保底分落袋为安。

8.3 真题拆解:把综合题切成算法块

以近年反复出现的"综合型模拟 + 小优化"题为例,做题顺序不是直接写代码,而是先在稿纸上拆:

breakdown.sh
拆题模板(以一道"游戏规则模拟 + 最值"题为例)
  第 1 步  提取状态:局面用什么数据结构表示?(数组 / 二维表 / 队列)
  第 2 步  找转移:每一步游戏规则对应哪几行代码?会不会死循环?
  第 3 步  识别算法:题目最后问"最大/最小/可行性"?
             → 是:二分答案或贪心或 DP
             → 否:多半是纯模拟
  第 4 步  定复杂度:n 的上限允许我 O(n)、O(n^2) 还是必须 O(n log n)?
  第 5 步  写保底版 → 过样例 → 造边界 → 再优化

近几年 J 组 T3/T4 常把"模拟/搜索"包装在游戏或流程外壳里,本质还是状态+转移+边界。做题时先翻译成数学语言,再翻译成代码,比对着题干猜写法可靠得多。

8.4 考前检查清单与防呆流程

交卷前最后 10 分钟,只做下面清单上的事,绝不再改逻辑。近年学生最惨痛的失分几乎全部落在清单范围内:

check.sh
考前检查清单
  1. 文件名是否与题目一致(freopen 的 in/out 文件名)
  2. 有没有漏掉文件读入输出、或本地调试时忘注释
  3. 数组大小是否够(越界是隐蔽的 RE)
  4. 数据范围是否爆 int(该 long long 的地方检查一遍)
  5. 是否删除了调试输出(可能被当成输出格式错误)
  6. 每题的样例是否重新跑一遍通过
  7. 再构造 2 个极端数据自测(n=1 / 最大值 / 全同值 / 随机)
  8. 剩余时间先保已得分,再考虑难题的部分分

8.5 赛后复盘:把模拟赛变成提分杠杆

全真模拟赛的作用不在"考了多少分",而在暴露问题。赛后按下面流程复盘,每次模拟都能带走几点实质进步:

  • 逐题对答案:会的题为何丢分?是读题、细节还是实现 bug?
  • 重写错题:不看题解重写一遍,直到 AC;写不出就标记,考后 48 小时内问老师弄懂。
  • 统计失分结构:把失分归到"算法不会 / 读题粗心 / 时间不够 / 代码 bug"四类,下次针对短板分配时间。
  • 积累模板:把每章代码练到"无错默写",考场上省下的思考时间就是分数。
模拟赛成绩波动非常正常,别被单场分数打击。盯住失分原因的变化曲线:如果"读题粗心"从 50 分降到 10 分,哪怕总分没涨,也比盲目多刷三套题更值。

基础练习:把本章 8.4 清单做成一张实体卡片,放进笔袋;下次做套题时每道题按 8.1 的四问先写"读题笔记"。

挑战练习:完成 2 场全真模拟(同题量、同时长、同环境),每场后按 8.5 写复盘报告。提示:复盘报告只需三栏——题号、失分原因(归类)、下一步行动;行动越具体越好,例如"高精度减法补去前导零"。

学制安排

schedule.sh
课次      共 32 次课(8 章,每章约 4 次课)
周期      两学期滚动开课,考前可加密集冲刺(覆盖一个完整赛季)
班型      小班 + 分档教学(J 组 / S 组两条线)
频次      每周 1 次正课(2 小时)+ 周末线上模拟赛
时长      每次 120 分钟
配套      真题库 + 每章练习 + 每周模拟赛 + 赛后逐题复盘
节点      报名指导 / 赛前点题 / 考后估分与下一阶段规划
衔接      J 组高分 → 直升 NOIP 高阶课程;未达预期 → 定制补强计划

课程特色

  • 真题驱动:以近 5 年 CSP 真题为主线,第 2~7 章每章练习都对应真实考点,覆盖面与命题权重完全对齐。
  • 全真模拟:按正式赛制限时模拟(同题量、同时长、同文件命名规范),训练读题、造数据与时间分配。
  • 算法全跑通:本书 8 章共 20 余个 C++ 示例全部本机真实编译运行,输出即所见,杜绝"背了模板不敢跑"。
  • 分档教学:J 组与 S 组分层授课,目标不同、路径不同,作业与模拟赛分开给。
  • 赛后复盘:每场模拟赛后逐题讲解,学生必须重写错题并提交复盘报告,把丢分变成提分。

常见问题

  • CSP 有什么用? 由中国计算机学会举办的权威认证,是初升高综合素质评价与信息学特长的重要凭证,也是冲击 NOIP 与省选的前置通道。
  • 基础一般能报吗? 可以报 J 组冲刺班,按 1.3 节的得分策略先保 T1、T2,目标二、三等奖起步,边学边补基础,稳步提升。
  • 报名怎么办? 课程包含报名指导,从官网注册到考区确认全程提醒,家长无需操心流程节点。
  • 练习怎么提交批改? 每章两级练习请按题号写在一个 .cpp 文件里(每题一个文件),文件内注明姓名与章节,提交到邮箱 lackychen@foxmail.com。老师一般 48 小时内逐题批改并给出错因标注与改后建议;模考代码则以考场文件命名规范打包提交。
  • 刷题量 vs 复盘哪个重要? 都会谈,但复盘优先:一道复盘到位的错题胜过三套没消化的卷子。详见 8.5 节的复盘模板。
  • S 组太难怎么办? 没把握时以 J 组为主、S 组当"体验卷"报名,感受提高级难度并记录差距,为高一的 NOIP 铺路。

$ cat ~/learning-path.txt

图形化启蒙PythonC++CSP-J/SNOIP·一对一规划(任意阶段可叠加)