从语法到算法:为什么这三个专题是分水岭
学完 C++ 语法之后,很多孩子会卡在一个瓶颈:题目读得懂、代码写得出,就是过不了评测。原因是从语法到算法这一步没有跨过去。竞赛真正考的不是语法,而是解题思路。
递归、枚举、贪心是竞赛入门组(CSP-J)的三大核心算法。掌握这三个专题,就能解决大约一半的入门组题目。它们分别代表三种思维方式:把大问题拆成同结构的小问题(递归)、把所有可能性一一试出来(枚举)、每一步都选当前最优(贪心)。
算法不是背模板,而是训练一种"把问题翻译成计算机能执行的步骤"的思维。
- 递归函数自己调用自己,必须有出口条件。
- 枚举用循环遍历所有可能,逐一验证。
- 贪心每一步选当前最优,不回头、不后悔。
- 复杂度算法跑 1 秒能处理多大数据量的经验值。
递归入门:函数自己调用自己的艺术
递归是一个函数在自己内部调用自己。听起来绕,但它的本质是把一个大问题拆成一个规模更小的同类型问题。
最经典的例子是求阶乘:5! = 5 × 4 × 3 × 2 × 1。我们可以定义:fact(n) = n × fact(n-1),出口是 fact(1) = 1。
int fact(int n) {
if (n == 1) return 1; // 出口条件
return n * fact(n - 1); // 递归调用
}
当你调用 fact(5),它会先调用 fact(4),fact(4) 又调用 fact(3),一路到 fact(1) 返回 1,再一层层乘回去,最终得到 120。
写递归的关键是两件事:出口条件(什么时候不再调用自己)和递推关系(大问题怎么拆成小问题)。缺了出口,程序会无限递归直到崩溃。
递归实战:斐波那契与汉诺塔
斐波那契数列:1、1、2、3、5、8、13……每一项等于前两项之和。递归写法:
int fib(int n) {
if (n == 1 || n == 2) return 1;
return fib(n - 1) + fib(n - 2);
}
这段代码非常简洁,但有性能问题:fib(5) 会调用 fib(4) 和 fib(3),fib(4) 又会调用 fib(3) 和 fib(2),fib(3) 被重复计算了。当 n 到 40 以上,计算时间会爆炸。这是递归的代价,以后学动态规划就是为了解决这个问题。
汉诺塔是另一个经典递归题:三根柱子,把 N 个盘子从 A 移到 C,大盘不能压小盘。解法思路:先把上面 N-1 个盘子从 A 移到 B,再把最底下的大盘从 A 移到 C,最后把 N-1 个盘子从 B 移到 C。
void hanoi(int n, char from, char aux, char to) {
if (n == 1) {
cout << from << " -> " << to << endl;
return;
}
hanoi(n - 1, from, to, aux);
cout << from << " -> " << to << endl;
hanoi(n - 1, aux, from, to);
}
孩子不用看懂这个移动顺序为什么对,只要能理解"把 N 个盘子的问题拆成 N-1 个盘子的子问题"这个递归结构,就掌握了递归的精髓。
枚举入门:把所有可能一一试出来
枚举的思想很朴素:把所有可能的情况全部列出来,一个一个验证,符合条件的就是答案。它不需要聪明的算法,只需要耐心和正确的循环嵌套。
举个例子:找出 100 以内所有的素数。素数是只能被 1 和自己整除的数。最直接的写法:
for (int i = 2; i <= 100; i++) {
bool isPrime = true;
for (int j = 2; j * j <= i; j++) {
if (i % j == 0) {
isPrime = false;
break;
}
}
if (isPrime) cout << i << " ";
}
外层循环枚举每个候选数 i,内层循环用 j 从 2 试到根号 i,看 i 能不能被 j 整除。如果都不能,i 就是素数。
| 数据规模 | 可接受的枚举复杂度 | 例子 |
|---|---|---|
| N ≤ 10 | O(N!) 全排列 | 8 皇后 |
| N ≤ 1000 | O(N²) 双重循环 | 找素数 |
| N ≤ 100000 | O(N) 单循环 | 求和 |
枚举的核心是判断数据规模:题目给的 N 是多少?1 秒内能跑多少次?根据这个决定能不能暴力枚举。
枚举实战:百钱百鸡与全排列
经典题:公鸡 5 元一只,母鸡 3 元一只,小鸡 3 只 1 元。用 100 元买 100 只鸡,各买多少只?
for (int rooster = 0; rooster <= 20; rooster++) {
for (int hen = 0; hen <= 33; hen++) {
int chick = 100 - rooster - hen;
if (chick < 0) continue;
if (5 * rooster + 3 * hen + chick / 3.0 == 100
&& chick % 3 == 0) {
cout << rooster << " " << hen << " " << chick << endl;
}
}
}
外层循环枚举公鸡数量(最多 20 只),内层枚举母鸡数量(最多 33 只),小鸡数量自动算出,再验证钱数是否等于 100。这就是典型的多重循环枚举。
再进阶:全排列——把 1 到 N 的所有排列全部列出来。N=3 时有 6 种:123、132、213、231、312、321。这种题用递归枚举最方便:第一个位置选哪个数,剩下的再递归排列。
- 多重循环外层枚举变量 1,内层枚举变量 2。
- 约束条件把题目给的限制翻译成 if 判断。
- 剪枝不符合条件的分支直接跳过,不浪费时间。
- 规模意识数据大就不能纯枚举,要想别的算法。
贪心入门:每一步都选当前最优
贪心算法的思想是:每一步都选当前看起来最好的选择,不回头、不做长远规划。它不是对所有问题都成立,但对某些特定问题非常高效。
最经典的例子是找零钱:商店要给顾客找 36 元,有 1 元、5 元、11 元三种面值。贪心策略是每次都用最大的面值:36 → 用三张 11 元剩 3 元 → 三张 1 元。总共 6 张。
但贪心不一定最优。如果面值是 1、5、11,要找 15 元,贪心会用一张 11 元加四张 1 元,共 5 张;但最优解是三张 5 元,共 3 张。所以贪心适用的题目必须证明"每一步选最优一定导向全局最优"。
竞赛里能直接用贪心的常见题型:找零问题(面值是 1、5、10、20 的标准人民币)、活动选择问题(选尽可能多的不冲突活动)、任务调度。
贪心实战:找零问题与活动选择
人民币找零问题:给定金额 N,用最少的张数凑出。标准人民币是 1、5、10、20、50、100,贪心一定最优。
int bills[] = {100, 50, 20, 10, 5, 1};
int n;
cin >> n;
int count = 0;
for (int i = 0; i < 6; i++) {
count += n / bills[i];
n = n % bills[i];
}
cout << count << endl;
从最大面值开始,能用几张用几张,剩下的余数继续给小面值。
活动选择问题:有 N 个活动,每个活动有开始时间和结束时间,教室只有一间,要选尽可能多的活动不冲突。贪心策略:每次选结束最早的活动,选完之后把所有冲突的活动去掉,再重复。
// 按结束时间排序后,依次选不冲突的
int end = -1, ans = 0;
for (int i = 0; i < n; i++) {
if (start[i] >= end) {
ans++;
end = finish[i];
}
}
cout << ans << endl;
为什么这个贪心成立?因为选结束最早的活动,意味着给后面留出最大的空间。这就是贪心正确性的证明思路。
- 先想暴力:先写枚举解法,看数据规模能不能扛住。
- 再试贪心:如果暴力超时,思考有没有局部最优的选择。
- 最后验证:贪心写完,找一组反例测试,看会不会错。
递归、枚举、贪心是竞赛的三大基本武器。把它们练熟,后面的动态规划、图论就有了根基。