六、循环:递推、模拟与综合
这一页 4 章 32 道题,其中第 19 章标了「选学」。
到这一页,你应该已经能熟练地写循环、读入、判断、累加。这一页不再教你新的语法,教的是怎么把一段自然语言的描述翻译成循环。
三个新套路:
- 递推:后一项由前一项算出来(斐波那契、兔子繁殖)
- 逆推:从结果倒着往回算(猴子吃桃)
- 模拟:把题目描述的过程,一步一步照着演一遍(角谷猜想、开关灯)
第三种最重要。大部分「应用题」都是模拟——题目怎么说,你就怎么写,不需要想什么技巧。
18. 数列与递推
18.1 递推:用前一项算后一项
斐波那契数列:1、1、2、3、5、8、13……每项等于前两项之和。
int a = 1, b = 1;
for (int i = 3; i <= n; i++) {
int c = a + b;
a = b;
b = c;
}
printf("%d\n", b);
关键在于 a = b; b = c; 这两行——把窗口往后挪一格。配合 a、b 两个变量,就能一路推出第 n 项,不需要存整个数列。
这个技巧叫「滚动变量」。它省空间,也快。如果 n 很大(比如 100 万),存数组会爆内存,滚动变量不会。
滚动变量的赋值顺序不能反
a = b; // 先把 b 覆盖掉 a
b = a + b; // 这时 a 已经是新的值了,算出来是 2b
正确顺序必须是「先算出新值,再挪窗口」:
int c = a + b; // 先算
a = b; // 再挪
b = c;
顺序反了不会有编译错误,只是答案全错。这是递推最经典的 bug。
18.2 逆推:从结果倒着算
猴子吃桃:第一天吃一半多一个,第二天吃剩下的一半多一个……第 n 天早上剩 1 个,问最初有多少个。
正着算不知道从哪开始,但倒着算很清楚:
int ans = 1; // 第 n 天的桃子数
for (int i = 1; i < n; i++) {
ans = (ans + 1) * 2; // 往前推一天
}
正着是「除以 2 再减 1」,反着就是「加 1 再乘 2」。逆运算的规则要自己推一遍,别背公式。
逆推
- 猴子分天吃桃求初始数量 —— 先想清楚「正着做什么」,再写出它的逆运算
建议:拿 n = 3 手算一遍(答案应该是 10),确认你的公式对,再写代码。
18.3 带条件的累加
有时候循环不是「把所有项都加上」,而是「满足条件的才加」。
// 1 到 n 的奇数和
int sum = 0;
for (int i = 1; i <= n; i++) {
if (i % 2 == 1) sum += i;
}
交错求和是另一种:符号一正一负。
// 1 - 1/2 + 1/3 - 1/4 + ...
double sum = 0;
int sign = 1;
for (int i = 1; i <= n; i++) {
sum += sign * 1.0 / i;
sign = -sign; // 每轮翻转符号
}
sign = -sign 这个写法比 if (i % 2 == 1) ... else ... 更简洁。符号交替 = 一个每次取反的变量。
18.4 数位与映射
有一类题要求把每一位数字「翻译」成别的东西,比如中文拼音。
int d = n % 10;
if (d == 0) printf("ling");
else if (d == 1) printf("yi");
// ...
这种「数字 → 字符串」的映射,最直接的写法就是 if 链或者数组:
const char* name[] = {"ling", "yi", "er", "san", "si",
"wu", "liu", "qi", "ba", "jiu"};
printf("%s", name[d]);
数组下标直接就是数字本身,不用判断。这个写法第 22 章学数组后会非常常用,现在先看一眼。
数位映射
18.5 分数递推
分数数列
- 斐波那契分数求和 —— 分子分母各自是斐波那契数列
这题的分子和分母要分别递推,两个滚动变量组。做完想想:能不能只用一个?
第 18 章验收
- 会写滚动变量的递推,且赋值顺序不会错
- 知道逆推就是「正着做的逆运算」
- 会用
sign = -sign实现交替 - 看到「反复做某事直到……」,知道用
while (true) + break
19. 组合计数(选学)
这一章是选学
这 5 道题涉及排列组合,属于「有余力再看」的内容。不影响后面章节的学习,卡住了可以先跳过,第 22 章之后回来做也不迟。
如果你的目标是打竞赛,这部分要认真学;如果只是把基础语法练熟,知道「有乘法原理这回事」就够了。
19.1 乘法原理:分步做的事,数量相乘
一件事分几步完成,每步有若干种选择,总方案数就是各步选择的乘积。
有 3 件上衣、4 条裤子,一共有多少种搭配?→ 3 × 4 = 12
代码上就是两个嵌套循环,或者直接相乘:
int ans = 1;
for (int i = 1; i <= k; i++) ans *= choices[i];
「可重排列」(每步选择互不影响)就是纯乘法;「不可重」(选过的不能再选)要递减:
// 从 n 个里依次取 k 个,不能重复
int ans = 1;
for (int i = 0; i < k; i++) ans *= (n - i);
19.2 组合与分配
组合数 C(n, m) 表示从 n 个里选 m 个(不计顺序)。算法是:
C(n,m) = n×(n-1)×…×(n-m+1) / (m×(m-1)×…×1)
long long comb(int n, int m) {
long long r = 1;
for (int i = 1; i <= m; i++) {
r = r * (n - i + 1) / i; // 边乘边除,防止溢出
}
return r;
}
r = r * (n-i+1) / i 这个「边乘边除」的写法很重要:每一步都保证能整除,且中间结果不会太大。如果先算完分子的阶乘再除,20! 早就溢出了。
19.3 直线上的整点
整点计数
- 直线上的整点 —— 通常和最大公约数有关
提示:一条从 (0,0) 到 (a,b) 的线段,内部的整点数是 gcd(a,b) - 1。为什么?想想 gcd 是什么意思。
第 19 章验收
这一章只要记住一件事:先分清「有没有顺序」「能不能重复」,再选算法。 分清之前别动笔。
20. 生活模拟
这一章是「模拟题」——题目描述一个过程,你照着演一遍。
模拟题不需要技巧,只需要耐心。 把题目读三遍,把每个步骤写成一行代码,就完了。
20.1 按规则一步步走
角谷猜想:奇数乘 3 加 1,偶数除以 2,问几步变成 1。
int n, cnt = 0;
scanf("%d", &n);
while (n != 1) {
if (n % 2 == 1) n = n * 3 + 1;
else n = n / 2;
cnt++;
}
printf("%d\n", cnt);
这段代码几乎就是题目描述的直接翻译。模拟题就该这么写——不要试图找规律、不要试图优化,除非它超时。
模拟题最容易错的两处
- 边界:「直到变成 1」是
while (n != 1),不是while (n > 1)——虽然对这道题结果一样,但换成别的题就错了 - 计数时机:
cnt++放在循环体内还是循环体外,差一次。做完拿题目样例对一下步数
20.2 逐项判断与计数
int cnt = 0;
for (int i = 1; i <= n; i++) {
int x;
scanf("%d", &x);
if (满足条件(x)) cnt++;
}
这个模式前面见过很多次了。模拟类题目里,条件往往比较长,建议把条件写成独立的函数(第 27 章会讲),主循环会干净很多。
逐项判断
20.3 连续状态
「连续多少天正常」「最长的一段连续……」——这类题目需要维护两个东西:当前长度 和 最大长度。
int cur = 0, best = 0;
for (int i = 1; i <= n; i++) {
if (满足条件) {
cur++;
if (cur > best) best = cur;
} else {
cur = 0; // 断了,重新开始
}
}
else { cur = 0; } 这一行是关键。忘了它,cur 会一直累加,得到的就不是「连续」的长度了。
20.4 日期累计
「一年中的第几天」这类题,思路是把前面各月的天数加起来:
int days[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
int sum = 0;
for (int i = 1; i < m; i++) sum += days[i];
sum += d;
if (isLeap(y) && m > 2) sum++; // 闰年 2 月多一天
注意 days[0] = 0 这个占位——让下标从 1 开始对应月份,能省掉一堆 -1。
m > 2 这个条件也别漏:闰年只影响 3 月及以后的日期。
日期
- 一年中的第几天 —— 用上面那段
自己测:2024 年 3 月 1 日应该是第 61 天(闰年),2023 年 3 月 1 日是第 60 天。
第 20 章验收
- 拿到模拟题,能把题目描述逐句翻译成代码
- 知道「连续段」要用两个变量
- 日期题会用数组存每月天数,
days[0] = 0占位
21. 循环综合
这一章是循环板块的收尾,题目都是前面套路的组合。
21.1 边读边更新最优
int best = -1e9, pos = 0;
for (int i = 1; i <= n; i++) {
int x;
scanf("%d", &x);
if (x > best) {
best = x;
pos = i; // 记住位置
}
}
「记住位置」只要多一个变量。注意用 > 而不是 >=——用 > 时,相同值会保留第一次出现的位置;用 >= 会保留最后一次。题目要哪个,看清。
21.2 枚举找最优解
有些题的最优解没法直接算,只能把所有可能试一遍:
int bestCost = 1e9;
for (int k = 1; k <= 最大值; k++) {
int cost = 计算方案k的花费();
if (cost < bestCost) bestCost = cost;
}
这种「枚举所有方案取最优」的写法,只要枚举范围不大就能过。不要觉得暴力很low——能过的暴力就是好算法。
枚举
- 买铅笔 —— 不同包装的价格不同,枚举买几包
62 的坑:可能有「买多了也没关系」的情况,也可能要求正好够。读题。
21.3 模拟过程
过程模拟
- 小鱼的航程(改进版) —— 按天模拟,注意休息日
- 乌龟兔子赛跑问题 —— 两个人的进度分别模拟
- 雇佣兵 —— 典型的「每天更新状态」
这三题的共同点:循环的每一轮代表一天(或一个时间单位),循环体里更新所有相关状态。
写之前先把「有哪些状态需要记录」列出来,比如乌龟的位置、兔子的位置、兔子是否在睡觉。
21.4 状态翻转
「开关灯」这类题:每个操作把一段区间的状态翻转一次。
bool on[1005] = {false};
for (int k = 1; k <= m; k++) {
for (int i = k; i <= n; i += k) {
on[i] = !on[i]; // 翻转
}
}
!on[i] 就是取反。用 bool 数组比用 0/1 的 int 数组更清楚,也省内存。
注意 for (int i = k; i <= n; i += k) 这个写法——它枚举的是 k 的所有倍数,用来做「每隔 k 个操作一次」正合适。
翻转
- 开关灯 —— 可能数据量大,注意复杂度
如果 n 很大(比如 1000 万),两重循环可能超时。想想能不能只记录「被操作了多少次」,最后看奇偶。
21.5 连续段与双指针
「和为 m 的连续段」是双指针的入门题:
int l = 1, r = 0, sum = 0, cnt = 0;
while (r < n) {
r++;
sum += a[r]; // 右端点右移,加入新元素
while (sum > m && l <= r) {
sum -= a[l]; // 左端点右移,移除旧元素
l++;
}
if (sum == m) cnt++;
}
思路是:右端点一直往右走,当和超过 m 时,左端点也跟着往右走,直到和不超过 m。两个指针都只往右走,所以总共只扫一遍。
这是第一个「不是简单两重循环」的算法。 如果看不懂,先写个暴力版本(枚举左右端点,两重循环),再回来看这段。
连续段
- 和为 m 的连续段 —— 先用暴力做,过了就行;过不了再想双指针
循环板块总结
78 道循环题做完,你应该具备这些能力:
- 看到「遍历」「统计」「找最值」,能立刻写出对应的循环骨架
- 知道什么时候用
for(次数确定)、什么时候用while(条件退出) - 会用滚动变量做递推,赋值顺序不会错
- 能把模拟题的描述逐句翻译成代码
- 会维护「当前值 + 最优值」这类双变量模式
循环是整个编程里最基础也最重要的结构。 后面数组、字符串、结构体,全都是在循环外面套一层壳。这里不扎实,后面每一章都会卡。
如果这一页的题有一半是靠猜过掉的,回去重做。猜对的题,下次还是不会。
延伸阅读
服务器上有一整套 OI-Wiki(621 页),是给竞赛选手写的参考手册。下面几页跟这一页相关,但不是每一页都该现在看——标了「现在跳过」的,等以后再说。
- 学完这一页再看:抽屉原理 —— 同上,选学。读完能解释「为什么 13 个人里必有两个同月生」
- 学完这一页再看:排列组合 —— 这章是选学,想看就看。组合数的公式推导比结果有用
- 学完这一页再看:斐波那契数列 —— 递推关系的经典例子。看完你写的
f[i] = f[i-1] + f[i-2]才叫「有根据」 - 学完这一页再看:模拟 —— 「按题目说的步骤一步步走」这个思路的正式名字,以及为什么它容易写错
看不懂是正常的,不是你的问题
OI-Wiki 默认读者已经会写程序、目标是打 NOI。你现在需要的那部分,讲义里已经写了; wiki 是用来「以后查得更深」的,不是用来补课的。