courses/csp
CSP-J/S 认证冲刺 · 六年级起,进阶冲刺
课程简介
这是信息学竞赛体系里从"会写代码"走向"会比赛"的关键一站。围绕 CSP 非专业级软件能力认证(CSP-J 入门级 / CSP-S 提高级)做专项训练:先从赛制与考点地图建立全局观,再用枚举、模拟、贪心、二分、搜索、DP、数据结构、图论八大模块逐个击破高频算法,最后用真题精讲与全真模拟收口。每一个算法都配可运行的 C++ 示例与真实输出,让学生不仅"看得懂",更"跑得通、算得对"。
本课程定位为 CSP-J 冲刺为主、为 CSP-S 铺路,难度略高于 C++ 基础课程,但仍严格控制在入门竞赛算法范围内。目标很具体:保 1、2 题满分,稳 3 题大部分分,冲 4 题,稳定冲刺一、二等奖,并为冲击 NOIP 打好地基。
适合对象
- 已完成 C++ 与基础算法课程,或通过入学测评(会数组、循环、函数、递归)。
- 目标参加当年 CSP-J / CSP-S 认证的学生,CSP-J 从六年级起即可起步冲击。
- 希望系统掌握竞赛算法、学会考试时间分配与拿分策略。
课程大纲
第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 小时)。一轮题型固定:
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.3 得分策略:会的不丢,不会的写暴力
竞赛拿奖的数学本质是总分排名,不是"做出几题"。一道 T4 拿 30 分部分分,往往比在一道 T2 上浪费时间空手而归划算得多。我们给自己定的分数线节奏:
得分策略建议(CSP-J 二轮)
1. T1、T2 要求满分:读题 → 样例 → 边界数据自测,一道都不能丢
2. T3 目标 60~100 分:先写朴素暴力拿基础分,再优化
3. T4 目标 30~70 分:只会暴搜就写暴搜,会部分 DP 就写 DP
4. 底线法则:任何一道题都先交"能跑的版本",禁止交空文件
5. 每题结尾留 3 分钟重读一遍:文件名、输入输出、数据范围、样例
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 二轮的高频考点高度收敛,我们按"必考/常考/偶尔考"分层:
必考(几乎每年都出)
· 模拟 + 大模拟:读懂题意、照规则逐步实现
· 贪心 / 二分答案 / 排序
· 搜索(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) 次:
#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 个质数
2.2 模拟:把题目"演"一遍
模拟题就是"读题规则 → 找状态 → 照着走"。做模拟唯一的要求是准确:变量别混、边界别越、循环别死。约瑟夫问题是最经典的"报数出圈"模拟——n 个人围一圈,从 1 开始报数,报到 m 的人出列,求所有人的出列顺序。用数组 + 取模模拟"围成圈":
#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
2.3 贪心入门:活动安排
贪心的想法很简单:每一步都做当前看起来最好、且不会让后面变差的选择。看经典题"活动安排":有 n 个活动,每个有开始和结束时间,一个人最多能参加几个完整活动?直觉是先选结束最早的,因为结束早给后面留的余地最大。这就是"按结束时间排序,能选就选":
#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 个同学排队接水,每人接水耗时不同,问怎样排队让所有人等待的总时间最小。答案是"耗时短的先接",也就是最短作业优先。等待时间是阶梯式累加的,一个人排得越靠前,他的耗时被"乘上"越多的人次,所以一定要把大耗时往后放:
#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 或搜索保分,别赌。
基础练习:实现约瑟夫问题 n=10、m=4 的输出,并用纸笔验证前三个出列的人。
挑战练习:有 n 个区间,选尽量多的区间使它们两两不重叠(允许端点相接)。提示:和"活动安排"只差一个符号——端点相接算不算冲突,决定了排序后用 >= 还是 > 判断。
↑ 目录第 3 章 二分与高精度
本章目标:掌握整数二分的两种模板与"二分答案"这一高频套路,学会用数组模拟超大整数加减。二分是把"求最值"变成"多次判断"的万能开关,高精度则是避开整数溢出的大数护盾。
3.1 整数二分:模板与边界
二分的前提是单调性:一个有序数组里,"小于 x 的数"和"不小于 x 的数"被一条线切开,二分就是每次扔掉一半,O(n) 查找变成 O(log n)。竞赛里 90% 的二分代码错在边界上。下面的模板找"第一个 >= x 的位置"(就是 STL 的 lower_bound):
#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
3.2 二分答案:木材切割
比二分查找更重要的是二分答案:当题目问"最大/最小能到多少"、且"给定一个值能快速判断行不行"时,就对答案本身二分。经典例题:把几根原木锯成 m 段长度相同的短木,每段最长能有多长?对"段长 L"二分,check 函数数一数所有原木能切出多少段 >= m 段即可:
#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 可行:
#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,或拆散它们后仍有更大单段)。
3.4 高精度加法:倒序存储
当整数超过 long long 的约 9.2×10^18 上限(比如几百位的大数),就要用高精度:把数字当成字符串读入,逐位存进数组运算。铁律是倒序存储——个位在下标 0,这样进位就往"高下标"方向自然延伸,最后倒着输出即可:
#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 全部去掉)。实现前提是保证被减数不小于减数,否则要先比大小并处理符号:
#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~n 的二分查找(含重复元素,查第一个等于 x 的下标),n 取 10^6 随机数据自查。
挑战练习:高精度乘法:两数位数都不超过 1000。提示:结果第 i+j 位累加 A[i]×B[j],逐位处理进位;结果最大位数是两数位数之和,需从低位开始两层循环。
↑ 目录第 4 章 搜索进阶:剪枝与记忆化
本章目标:在会写 DFS/BFS 的基础上,掌握"搜索 = 状态 + 转移 + 边界"的建模,学会用可行性剪枝砍掉死路、用记忆化把指数级递归变成线性——这是 T3 题拿分的关键武器。
4.1 搜索三要素:状态、转移、边界
任何搜索题都先问自己三个问题:状态是什么(递归参数)、能转移到哪些状态(循环里的下一步)、什么时候停(终止条件)。以输出 1~n 的全排列为例,状态是"已选了几个数、哪些数被用过",转移是"在没用过的数里挑下一个",边界是"选满 n 个就输出"。for 从小到大尝试,输出天然是字典序:
#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
4.2 可行性剪枝:提前砍掉死路
朴素搜索的时间是"搜完整棵树",很多分支从半路看就已经注定失败,提前返回就叫剪枝。看"子集和"问题:从一组数里选若干个,能否凑出目标 S?暴力要试 2^n 种选择,而剪枝能砍掉两类必败分支:当前和已经超过 S 的(继续加只会更大)、以及剩下的数全选也不够 S 的。程序里用两个计数器直观对比剪枝效果:
#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) 无数次。解决办法叫记忆化:开一个数组,算过的值记下来,下次直接查表返回。加上一个计数器就能看到差距:
#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 剪枝与记忆化的边界感
两种优化的适用范围要分清:
- 剪枝解决"分支太深太多",适合从半路就能判断必败/已够优的题,例如八皇后、数独、子集和。
- 记忆化解决"子问题重复计算",适合状态能用一个(或一组)参数完整刻画的题,例如爬楼梯、斐波那契、走棋盘、区间型题目。
- 两者常可叠加:先剪枝避免无意义分支,再记忆化避免重复子问题。
基础练习:用 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] 到底表示什么?定义错了全盘皆输。
- 状态转移:当前状态怎么由更小的状态推出来?写清楚"怎么由已知推未知"。
- 初始值:最小的状态值是多少?边界填错,结果跟着错。
5.2 线性 DP:最长上升子序列
看一个不能再经典的线性 DP——最长上升子序列(LIS,Longest Increasing Subsequence):在序列里挑一个子序列,保持原顺序且严格递增,求最长能多长。状态定义:dp[i] = 以第 i 个数结尾的 LIS 长度。转移:在 i 前面所有比 a[i] 小的 j 里,取 dp[j] 最大者 + 1:
#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, 6 或 1, 2, 6(较短)。dp 数组记的是"以谁结尾"的信息,最终答案要扫一遍所有 dp 取最大——不是 dp[n-1]。这个"答案在过程中"的特点很容易踩坑。
5.3 01 背包:倒序循环的奥义
背包是 DP 里最实用的一族。01 背包问题:容量 m 的背包,n 件物品各有一个重量 w 和价值 v,每件最多取一次,求最大价值。状态 dp[j] = 容量 j 能装的最大价值。外层枚举物品,内层容量从大到小倒序更新:
#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 再看到,相当于"这件物品可以再取一次",于是自动实现了无限取:
#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 求爬楼梯 f(50) 并输出每个 n 的方案数(验证溢出前用 long long)。
挑战练习:改成"最长非降子序列"并把序列换成 2000 个数,比较 dp 值的变化。提示:严格上升用 a[j] < a[i],非降只把 < 改成 <=,思考一下为什么这样改就能把相等的数也串起来。
↑ 目录第 6 章 数据结构:栈、队列、优先队列
本章目标:掌握栈(后进先出)、队列(先进先出)的语义与 STL 用法,理解优先队列(堆)"每次取最值"的能力,并能在解题时识别"该用哪种结构"。数据结构是算法的骨架,选对结构往往比算法本身更重要。
6.1 栈:后进先出与括号匹配
栈就像一摞盘子,只能从顶上拿、往顶上放。C++ 用 stack<T>:push 压栈、top 看栈顶、pop 弹栈、empty 判空。最经典的应用是括号匹配:遇到左括号压栈,遇到右括号就和栈顶配对,配不上或栈已空就是错的。关键在于最后还要检查栈是否为空——"(()" 这种只进不出的情况会在这里露馅:
#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 的直接出列,直到队伍清空:
#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 堆果子要合成一堆,每次合并两堆体力消耗为两堆之和,求最小总消耗。贪心显然:每次挑最小的两堆合,但合并出新堆后它可能不再是最小,需要"随时取当前最小"——这正是堆的用武之地:
#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 个最小的":
#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 点无向图并打印:
#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
7.2 BFS:无权图的最短路
当所有边权都一样(通常视为 1)时,最短路径用 BFS:从起点出发逐层扩散,第一次到达某个点的层数就是最短路。因为队列的先进先出保证按"距离 0、1、2、…"的顺序访问节点。仍用上面的图从 1 号点出发:
#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
7.3 Dijkstra:堆优化的单源最短路
带权图(边权非负)求"一个点到所有点的最短路",用 Dijkstra。朴素做法每轮找未确定点里距离最小的,复杂度 O(n²);用优先队列小根堆随时取出"当前距离最小的点",能优化到 O(m log n)。核心是 松弛:如果绕道 u 再到 v 比当前到 v 更短,就更新 v 并重新入堆。堆里可能残留过期的"大距离"记录,弹出时发现 d > dist[u] 就跳过:
#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 的关键词是"松弛":永远留一条可能更短的绕道。
7.4 并查集:合并与查询连通性
并查集解决"两个元素在不在一个集合里、以及合并集合"的问题,比如无向图连通块计数、社交网络好友圈。核心是一个 fa[] 数组:每个点记一个"父亲",祖先代表所在集合。find 沿父亲链找祖先,并顺手把路径上所有点直接挂到祖先下(路径压缩),让后续查询接近 O(1):
#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 个连通块。统计连通块 = 数一数有多少个点是自己的祖先,这正是并查集统计森林中树的数量。
基础练习:手算验证 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 先别想正解,先按数据范围分级拿分:
部分分阶梯(从低到高)
1. 数据范围很小(n ≤ 10)→ 直接暴力枚举 / 全排列 DFS
2. 数据范围中等(n ≤ 1000)→ 朴素 O(n^2) 算法(如朴素 DP、朴素最短路)
3. 数据范围很大 → 才需要堆优化 / 二分答案 / 高级剪枝
铁律
· 先写一定能跑对、哪怕慢的版本,骗到基础分
· 运行确认无误后再优化;优化一次验证一次
· 宁可交 O(n^2) 的 60 分,不交"感觉是 O(n log n)"但写错 bug 的 0 分
8.3 真题拆解:把综合题切成算法块
以近年反复出现的"综合型模拟 + 小优化"题为例,做题顺序不是直接写代码,而是先在稿纸上拆:
拆题模板(以一道"游戏规则模拟 + 最值"题为例)
第 1 步 提取状态:局面用什么数据结构表示?(数组 / 二维表 / 队列)
第 2 步 找转移:每一步游戏规则对应哪几行代码?会不会死循环?
第 3 步 识别算法:题目最后问"最大/最小/可行性"?
→ 是:二分答案或贪心或 DP
→ 否:多半是纯模拟
第 4 步 定复杂度:n 的上限允许我 O(n)、O(n^2) 还是必须 O(n log n)?
第 5 步 写保底版 → 过样例 → 造边界 → 再优化
近几年 J 组 T3/T4 常把"模拟/搜索"包装在游戏或流程外壳里,本质还是状态+转移+边界。做题时先翻译成数学语言,再翻译成代码,比对着题干猜写法可靠得多。
8.4 考前检查清单与防呆流程
交卷前最后 10 分钟,只做下面清单上的事,绝不再改逻辑。近年学生最惨痛的失分几乎全部落在清单范围内:
考前检查清单
1. 文件名是否与题目一致(freopen 的 in/out 文件名)
2. 有没有漏掉文件读入输出、或本地调试时忘注释
3. 数组大小是否够(越界是隐蔽的 RE)
4. 数据范围是否爆 int(该 long long 的地方检查一遍)
5. 是否删除了调试输出(可能被当成输出格式错误)
6. 每题的样例是否重新跑一遍通过
7. 再构造 2 个极端数据自测(n=1 / 最大值 / 全同值 / 随机)
8. 剩余时间先保已得分,再考虑难题的部分分
8.5 赛后复盘:把模拟赛变成提分杠杆
全真模拟赛的作用不在"考了多少分",而在暴露问题。赛后按下面流程复盘,每次模拟都能带走几点实质进步:
- 逐题对答案:会的题为何丢分?是读题、细节还是实现 bug?
- 重写错题:不看题解重写一遍,直到 AC;写不出就标记,考后 48 小时内问老师弄懂。
- 统计失分结构:把失分归到"算法不会 / 读题粗心 / 时间不够 / 代码 bug"四类,下次针对短板分配时间。
- 积累模板:把每章代码练到"无错默写",考场上省下的思考时间就是分数。
基础练习:把本章 8.4 清单做成一张实体卡片,放进笔袋;下次做套题时每道题按 8.1 的四问先写"读题笔记"。
挑战练习:完成 2 场全真模拟(同题量、同时长、同环境),每场后按 8.5 写复盘报告。提示:复盘报告只需三栏——题号、失分原因(归类)、下一步行动;行动越具体越好,例如"高精度减法补去前导零"。
学制安排
课次 共 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 铺路。
