题目背景
这是一道来自蓝桥杯算法赛的题目(热身赛),题号为4,标题为"研究斐波那契【算法赛】"。
问题描述
小蓝即将准备第十七届蓝桥杯省赛,于是找来了往届省赛真题琢磨高频考点。经过一番研究后,他发现斐波那契数列也是一个非常常见的考点,于是设计了一道题来考考你。
小蓝定义了一个数列,前两项均为1,从第三项开始,每一项都等于前两项之和。也就是说,这个数列形如:
1, 1, 2, 3, 5, 8, 13, 21, 34, ...
现在,系统会在第 i 天产生一个数值,恰好等于该数列的第 i 项。
对于每天产生的数值,系统都会对 m 取余,得到当天的结果值。如果某一天得到的结果值恰好为 a,那么这一天就被称为"达标日"。
请你求出,第一个达标日是第几天。
如果这样的日子永远不会出现,请输出 -1。
输入格式
输入一行,包含两个整数 m (2 ≤ m ≤ 10⁶) 和 a (0 ≤ a < m)。
输出格式
输出一个整数,表示第一个达标日的天数。如果这样的日子永远不会出现,输出 -1。
样例输入
4 0
样例输出
6
样例说明
斐波那契数列前几项为:1, 1, 2, 3, 5, 8, ...
将这些数对 4 取余,可以得到:1, 1, 2, 3, 1, 0, ...
可以看到,第 6 天得到的结果值恰好为 0,因此答案为 6。
三个相似的代码,三种不同的结果
在解决这个问题的过程中,我尝试了三种看似相似的代码,但结果却大相径庭:
代码1:正确的(100%通过)
cpp
#include <iostream>
#include <bits/stdc++.h>
using namespace std;
vector<int> b(3, 1);
int main() {
int m, a;
cin >> m >> a;
for (int i = 3; i <= 1000000; i++) {
b.push_back((b[i-1] + b[i-2]) % m); // 关键:计算时立即取模
}
int flag = 0;
for (int i = 1; i < b.size(); i++) {
if (b[i] == a) { // 直接比较余数
flag = 1;
cout << i;
break;
}
}
if (flag == 0) {
cout << -1;
}
return 0;
}
代码2:错误的(10%通过)
cpp
#include <iostream>
#include <bits/stdc++.h>
using namespace std;
vector<int> b(3, 1);
int main() {
int m, a;
cin >> m >> a;
for (int i = 3; i <= 1000000; i++) {
b.push_back(b[i-1] + b[i-2]); // 不取模,直接相加
}
int flag = 0;
for (int i = 1; i < b.size(); i++) {
if (b[i] % m == a) { // 比较时才取模
flag = 1;
cout << i;
break;
}
}
if (flag == 0) {
cout << -1;
}
return 0;
}
代码3:改进但依然错误的(20%通过)
cpp
#include <iostream>
#include <bits/stdc++.h>
using namespace std;
vector<long long> b(3, 1);
int main() {
int m, a;
cin >> m >> a;
for (int i = 3; i <= 1000000; i++) {
b.push_back(b[i-1] + b[i-2]); // 还是不取模
}
int flag = 0;
for (int i = 1; i < b.size(); i++) {
if (b[i] % m == a) { // 比较时才取模
flag = 1;
cout << i;
break;
}
}
if (flag == 0) {
cout << -1;
}
return 0;
}
为什么会有如此大的差异?
1. 斐波那契数列的增长速度
斐波那契数列的增长是指数级的:
-
第1项:1
-
第10项:55
-
第20项:6765
-
第30项:832040
-
第40项:102334155
-
第50项:12586269025(已超过int范围)
int类型(32位有符号整数)的最大值是2,147,483,647。大约在第47项,斐波那契数就会超过这个值,导致整数溢出。
2. 整数溢出的后果
在C++中,有符号整数溢出是未定义行为。当计算结果超过int范围时:
-
可能变成负数
-
可能变成0
-
可能变成任意值
-
结果完全不可预测
代码2使用int,大约从第47项开始,所有计算都是错误的。代码3使用long long,范围更大(约9.2×10¹⁸),但第94项左右仍然会溢出。
关键区别:取模的时机
代码1:计算时立即取模
cpp
b.push_back((b[i-1] + b[i-2]) % m);
代码2和3:延迟取模
cpp
b.push_back(b[i-1] + b[i-2]); // 先计算完整值
// ... 之后
if(b[i] % m == a) // 比较时才取模
为什么立即取模不会溢出?------深入理解计算过程
核心问题:先加法后取模,加法会不会溢出?
在代码1中,每次计算(b[i-1] + b[i-2]) % m时,确实是先做加法,后取模。但关键是:
-
加法操作数的范围 :
b[i-1]和b[i-2]都是对m取模后的余数 -
它们的取值范围 :
0 ≤ b[i-1] < m,0 ≤ b[i-2] < m -
加法结果的范围 :
0 ≤ b[i-1] + b[i-2] ≤ 2m-2
核心原因 :在循环中,每次计算后都立即对结果取模,所以数组中存储的每个b[i]都是斐波那契数对m取模后的余数。这意味着在计算下一个元素b[i]时,参加加法的b[i-1]和b[i-2]已经是前两项对m取模后的余数,它们的值都很小(小于m)。
当m = 10^6(最大值)时:
-
最大和 =
2 × 10^6 - 2 = 1,999,998 -
int最大值 =
2,147,483,647 -
1,999,998 < 2,147,483,647,所以不会溢出
中间结果存储在哪里?
计算表达式(b[i-1] + b[i-2]) % m时:
cpp
// 编译器会生成类似这样的代码:
int temp = b[i-1] + b[i-2]; // 1. 加法,结果存储在临时变量/寄存器中
int result = temp % m; // 2. 取模
b.push_back(result); // 3. 存储结果
-
加法结果存储位置:
-
首先存储在CPU的寄存器中
-
或者如果编译器需要,可能存储在栈上的临时变量中
-
但不会存储在vector
b中
-
-
取模操作:
-
对寄存器中的临时值进行取模
-
取模结果存储在
result中 -
然后将
result存入vectorb
-
-
关键点 :加法结果
temp只是短暂存在,不会长期保存。它的值很小(≤1,999,998),不会溢出。
数学原理:为什么可以提前取模?
这基于模运算的一个重要性质:
cpp
(a + b) mod m = [(a mod m) + (b mod m)] mod m
对于斐波那契数列:
cpp
F(n) mod m = [F(n-1) + F(n-2)] mod m
= [(F(n-1) mod m) + (F(n-2) mod m)] mod m
这意味着:
-
我们不需要计算完整的斐波那契数
-
只需要保存余数,就可以递推出下一个余数
-
所有计算都在小范围内进行,不会溢出
与错误代码的对比
代码2(int类型,延迟取模):
cpp
b.push_back(b[i-1] + b[i-2]); // 这里b[i-1]和b[i-2]是完整的斐波那契数
// 例如:i=47时,b[46]≈1,836,311,903,b[45]≈1,134,903,170
// 它们的和≈2,971,215,073 > 2,147,483,647(int最大值)
// 结果溢出,变成负数或错误值
代码3(long long类型,延迟取模):
cpp
b.push_back(b[i-1] + b[i-2]); // 这里b[i-1]和b[i-2]是完整的斐波那契数
// 大约在i=94时,b[93]≈1.22×10^19,超过long long范围
// 结果溢出
代码1(立即取模):
cpp
b.push_back((b[i-1] + b[i-2]) % m); // 这里b[i-1]和b[i-2]是余数
// 例如:m=10^6时,b[i-1]≤999,999,b[i-2]≤999,999
// 它们的和≤1,999,998,不会溢出
更深层的思考
1. "反正最后都要取模,不如提前取模"
这是解决这类问题的核心思想。既然题目只要求余数,我们就可以:
-
不关心完整的斐波那契数
-
只关心它对m的余数
-
用余数递推余数
2. 取模多次没有副作用
如果x < m,那么x % m = x。所以即使对小于m的数取模,结果也不变。这就是为什么我们可以放心地在每一步都取模。
3. 算法的优雅性
正确的算法体现了计算机科学中的一个重要思想:用合适的抽象解决问题。我们不需要模拟整个物理过程(计算完整斐波那契数),只需要关注问题的核心(余数)。
性能对比
| 方法 | 计算复杂度 | 空间复杂度 | 是否溢出 | 正确性 |
|---|---|---|---|---|
| 立即取模 | O(n) | O(n) | 否 | 100% |
| 延迟取模(int) | O(n) | O(n) | 是(约n=47) | 10% |
| 延迟取模(long long) | O(n) | O(n) | 是(约n=94) | 20% |
更优的解决方案
实际上,代码1还有优化空间。斐波那契数列对m取模的余数是有周期性的(Pisano周期),周期最大为6m。我们可以利用这个性质提前结束计算:
cpp
#include <iostream>
#include <vector>
using namespace std;
int main() {
int m, a;
cin >> m >> a;
vector<int> fib = {1%m, 1%m};
// 检查前两项
if (a == 1%m) {
cout << 1;
return 0;
}
if (fib[1] == a) {
cout << 2;
return 0;
}
// 从第3项开始,最多计算6m项
for (int i = 3; i <= 6*m; i++) {
int next = (fib[i-2] + fib[i-3]) % m;
fib.push_back(next);
if (next == a) {
cout << i;
return 0;
}
// 检查循环:如果再次出现1,1,说明开始重复
if (next == 1 && fib[i-2] == 1) {
break;
}
}
cout << -1;
return 0;
}
关于"先加后取模"的补充说明
Q: 在(b[i-1] + b[i-2]) % m中,确实是先加法后取模,为什么加法不会溢出?
A: 因为参加加法的两个数b[i-1]和b[i-2]已经是对m取模后的余数,所以它们都很小(小于m)。这是循环递推的关键特性:由于每次计算后都立即取模,数组中存储的每个值都是余数,而不是完整的斐波那契数。因此,在计算下一个元素时,参加加法的两个操作数都是小于m的余数。
即使m取最大值10^6,它们的和最大为1,999,998,远小于int的最大值2,147,483,647。所以加法结果不会溢出。
Q: 加法结果存储在哪里?
A: 在表达式求值过程中,加法结果首先存储在CPU寄存器 或临时变量中。这是一个短暂存在的中间结果,不会长期保存。取模操作对这个中间结果进行运算,得到最终结果,然后将最终结果存入vector。
Q: 为什么不立即取模就会溢出?
A: 如果不立即取模,b[i-1]和b[i-2]存储的是完整的斐波那契数,这些数增长极快,很快超过int或long long的范围,导致加法溢出。
总结
通过这三个代码的对比,我学到了:
-
理解问题本质:题目只要求余数,不要求完整数值
-
利用数学性质 :
(a+b) mod m = [(a mod m)+(b mod m)] mod m -
预防整数溢出:在计算过程中及时取模,避免数值过大
-
选择合适的数据类型:即使使用更大的数据类型,也不能解决根本问题
-
优化算法:利用周期性等性质进一步提高效率
-
理解计算过程:中间结果存储在寄存器中,只要数值范围安全就不会溢出
-
循环递推的关键:由于每次计算都取模,数组中存储的都是余数,保证了后续计算的加数都很小,不会溢出
这个问题的核心教训是:在设计算法时,不仅要考虑逻辑正确性,还要考虑计算机的实际限制。整数溢出是一个常见的陷阱,而提前取模是一个简单有效的解决方案。
在编程中,我们经常需要平衡数学上的简洁性和计算机的实际限制。这个例子完美展示了如何通过深入理解问题本质,找到既正确又高效的解决方案。
2026.4.8