斐波那契取模问题的深入分析:为什么提前取模是关键的

题目背景

这是一道来自蓝桥杯算法赛的题目(热身赛),题号为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时,确实是先做加法,后取模。但关键是:

  1. 加法操作数的范围b[i-1]b[i-2]都是对m取模后的余数

  2. 它们的取值范围0 ≤ b[i-1] < m0 ≤ b[i-2] < m

  3. 加法结果的范围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. 存储结果
  1. 加法结果存储位置

    • 首先存储在CPU的寄存器

    • 或者如果编译器需要,可能存储在上的临时变量中

    • 但不会存储在vector b

  2. 取模操作

    • 对寄存器中的临时值进行取模

    • 取模结果存储在result

    • 然后将result存入vector b

  3. 关键点 :加法结果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

这意味着:

  1. 我们不需要计算完整的斐波那契数

  2. 只需要保存余数,就可以递推出下一个余数

  3. 所有计算都在小范围内进行,不会溢出

与错误代码的对比

代码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的范围,导致加法溢出。

总结

通过这三个代码的对比,我学到了:

  1. 理解问题本质:题目只要求余数,不要求完整数值

  2. 利用数学性质(a+b) mod m = [(a mod m)+(b mod m)] mod m

  3. 预防整数溢出:在计算过程中及时取模,避免数值过大

  4. 选择合适的数据类型:即使使用更大的数据类型,也不能解决根本问题

  5. 优化算法:利用周期性等性质进一步提高效率

  6. 理解计算过程:中间结果存储在寄存器中,只要数值范围安全就不会溢出

  7. 循环递推的关键:由于每次计算都取模,数组中存储的都是余数,保证了后续计算的加数都很小,不会溢出

这个问题的核心教训是:在设计算法时,不仅要考虑逻辑正确性,还要考虑计算机的实际限制。整数溢出是一个常见的陷阱,而提前取模是一个简单有效的解决方案。

在编程中,我们经常需要平衡数学上的简洁性和计算机的实际限制。这个例子完美展示了如何通过深入理解问题本质,找到既正确又高效的解决方案。

2026.4.8

相关推荐
艾莉丝努力练剑2 小时前
C++ 核心编程练习:从基础语法到递归、重载与宏定义
linux·运维·服务器·c语言·c++·学习
牢姐与蒯2 小时前
模板的进阶
c++
小樱花的樱花2 小时前
1 项目概述
开发语言·c++·qt·ui
逆境不可逃2 小时前
LeetCode 热题 100 之 230. 二叉搜索树中第 K 小的元素 199. 二叉树的右视图 114. 二叉树展开为链表
算法·leetcode·职场和发展
一个有温度的技术博主2 小时前
Redis Cluster 核心原理:哈希槽与数据路由实战
redis·算法·缓存·哈希算法
Ghost Face...2 小时前
Linux USB 全栈解析:OTG + Type-C + PD 内核架构(架构师级)
linux·c语言·架构
ALex_zry2 小时前
gRPC服务熔断与限流设计
c++·安全·grpc
wfbcg2 小时前
每日算法练习:LeetCode 15. 三数之和 ✅
算法·leetcode·职场和发展
2301_822703202 小时前
开源鸿蒙跨平台Flutter开发:跨端图形渲染引擎的类型边界与命名空间陷阱:以多维雷达图绘制中的 dart:ui 及 StrokeJoin 异常为例
算法·flutter·ui·开源·图形渲染·harmonyos·鸿蒙