P4000 斐波那契数列 解题报告
P4000 斐波那契数列 解题报告
题目大意
计算斐波那契数列的第 \(n\) 项对 \(p\) 取模的结果,即 \(f_n \pmod p\)。
其中 \(n\) 是一个超大的数字(最多有 300 万位),\(p\) 是一个普通的整数。
思路分析
第一步:常规思路的碰壁
看到求斐波那契数列的第 \(n\) 项,大部分同学的第一反应是矩阵快速幂。斐波那契数列的递推关系可以表示为矩阵形式:
通过累推,我们可以得到:
我们只需要计算出转移矩阵 \(\begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}\) 的 \(n-1\) 次方(或 \(n\) 次方,取决于初始向量),就可以求出 \(f_n\)。这个过程的时间复杂度是 \(O(\log n)\)。
然而,题目中的 \(n\) 最大有 \(3,000,000\) 位,远远超过了任何标准整型(如 long long
)的表示范围。我们无法直接将 \(n\) 作为指数进行计算,因此常规的矩阵快速幂方法行不通。
第二步:发现关键性质——周期性
当一个序列的所有项都对一个数 \(p\) 取模时,这个序列迟早会进入一个循环。为什么呢?
考虑斐波那契数列中相邻的两项组成的数对 \((f_i \pmod p, f_{i+1} \pmod p)\)。由于每一项都小于 \(p\),这样的数对最多只有 \(p \times p\) 种。根据鸽巢原理,当我们生成的数对数量超过 \(p^2\) 时,必然会出现重复的数对。
一旦某个数对 \((f_i, f_{i+1})\) 与之前的某个数对 \((f_j, f_{j+1})\) 相同,那么后续的整个数列也会重复之前的部分,因为下一项完全由前两项决定。
这个循环的长度被称为皮萨诺周期 (Pisano Period),记作 \(\pi(p)\)。有了周期,问题就简单了:我们只需要计算 \(f_{n \pmod{\pi(p)}} \pmod p\) 即可。
第三步:如何寻找周期?——从暴力到随机
现在的问题转化为了如何快速找到这个周期 \(\pi(p)\)。
-
暴力法:我们可以从 \((f_1, f_2)\) 开始,不断计算下一项并取模,直到再次遇到 \((f_1, f_2)\) 或者 \((f_0, f_1)\)(通常是 \((0,1)\))。这个方法的时间复杂度与周期长度成正比。有一个重要的结论是 \(\pi(p) \leq p^2\),甚至有更紧的界 \(\pi(p) \leq 6p\)。但当 \(p\) 很大时(接近 \(2^{31}\)),\(O(p)\) 的复杂度依然无法接受。
-
随机化 + 生日悖论:既然暴力枚举太慢,我们能不能“投机取巧”?
我们不一定要找到完整的周期 \(\pi(p)\),只要找到它的一个倍数 \(L\) 就可以了。因为如果 \(L\) 是周期的倍数,那么 \(n \pmod L\) 在周期意义下和 \(n \pmod{\pi(p)}\) 也能得到正确的结果(需要一点点数学证明,但直觉上是可行的)。如何找到这个倍数 \(L\) 呢?
我们想找到两个不相等的下标 \(i\) 和 \(j\),使得数对 \((f_i, f_{i+1})\) 和 \((f_j, f_{j+1})\) 在模 \(p\) 意义下相等。如果找到了,那么 \(|i-j|\) 就是周期的一个倍数。直接随机两个下标 \(i, j\) 然后判断,命中的概率太低。但我们可以借鉴一个著名的概率模型——生日悖论。
生日悖论:在一个班级里,需要多少人,才能让“至少有两个人生日相同”的概率超过 50%?答案不是 183(\(365/2\)),而仅仅是 23 人。
这个思想告诉我们,在一堆样本中寻找碰撞,比我们想象的要快得多。
我们可以这样做:
- 创建一个哈希表(
unordered_map
),用来存储我们见过的数对和对应的下标。 - 不断地随机生成一个下标 \(i\)。
- 用矩阵快速幂计算出 \((f_i \pmod p, f_{i+1} \pmod p)\) 这个数对。
- 在哈希表中查找这个数对:
- 如果找到了:说明之前有一个下标 \(j\) 也生成了同样的数对。太棒了!我们找到了一个碰撞。令周期长度的倍数 \(L = |i-j|\)。我们的任务完成了。
- 如果没找到:将这个数对 \((f_i \pmod p, f_{i+1} \pmod p)\) 和下标 \(i\) 存入哈希表,继续下一次随机。
根据生日悖论,我们期望在随机 \(O(\sqrt{\text{周期长度}})\) 次之后就能找到一次碰撞。由于周期长度 \(\pi(p)\) 是 \(O(p)\) 级别的,所以我们期望的随机次数是 \(O(\sqrt{p})\)。
- 创建一个哈希表(
第四步:整合算法流程
现在,我们可以梳理出完整的解题步骤:
-
寻找周期倍数 L:
- 利用上述的“生日悖论”随机化算法。
- 循环地随机下标
x
,用矩阵快速幂计算(f_x, f_{x+1}) mod p
。 - 使用哈希表记录遇到的
(数对, 下标)
。 - 一旦发现碰撞,即当前生成的数对已在哈希表中,就计算出两个下标的差
len
,这就是我们找到的周期倍数。这个过程的期望时间复杂度是 \(O(\sqrt{p} \cdot \log(\text{随机值}))\)。
-
优化斐波那契计算:
在第 1 步中,每次随机都要计算一次矩阵快速幂,如果随机值的范围很大,log
项会拖慢速度。我们可以使用一种名为O(√p) - O(1)
的快速幂技巧(也叫光速幂、BSGS 思想)来优化它。- 预处理:将指数
x
拆分为a * B + b
的形式(B
是一个块大小,比如 \(2^{18}\))。我们预计算出(转移矩阵)^b
和(转移矩阵)^(a*B)
的所有可能值。 - 查询:对于任意指数
x
,我们把它拆分,然后用预处理好的两个矩阵相乘即可,查询时间是 \(O(1)\)。 - 这样,寻找周期倍数
L
的总时间复杂度就优化到了 \(O(\sqrt{p})\)。
- 预处理:将指数
-
处理超大数 n:
- 将输入的
n
当作一个字符串来读。 - 一边读字符串,一边进行高精度的取模运算,计算
n' = n % L
。方法如下:long long remainder = 0; for (char c : n_string) { remainder = (remainder * 10 + (c - '0')) % L; }
- 将输入的
-
计算最终结果:
- 我们已经有了
n'
和模数p
。现在问题变成了一个常规的矩阵快速幂问题。 - 计算 \(f_{n'} \pmod p\) 即可。我们甚至可以直接复用第 2 步中那个 \(O(1)\) 查询的快速幂函数。
- 我们已经有了
代码解读
下面是对题解中提供的 C++ 代码的解读:
#include<bits/stdc++.h>
using namespace std;
// ... 省略一些宏定义 ...
unordered_map < ull , ll > circ; // 哈希表,key是数对,value是下标
ll len; // 找到的周期倍数
int MOD; // 模数 p
int MX = 1 << 18; // 光速幂的块大小 B
mt19937_64 rnd(time(0)); // 64位随机数生成器
// 矩阵结构体和乘法重载
struct matrix{
ll arr[2][2];
// ...
};
// T[0] 存 (矩阵)^b, T[1] 存 (矩阵)^(a*B)
matrix G , T[2][1 << 18 | 1];
signed main(){
static char str[300000003]; // 存储超大数 n
scanf("%s %d" , str + 1 , &MOD);
// --- O(1)快速幂的预处理部分 ---
// T[0][0] 和 T[1][0] 是单位矩阵
T[0][0][0][0] = T[0][0][1][1] = T[1][0][0][0] = T[1][0][1][1] = 1;
// 基础转移矩阵
T[0][1][0][1] = T[0][1][1][0] = T[0][1][1][1] = 1; // [[1,1],[1,0]]
// 预计算 T[0][i] = (基础矩阵)^i
for(int i = 2 ; i <= MX ; ++i) T[0][i] = T[0][i - 1] * T[0][1];
// T[1][1] = (基础矩阵)^MX
T[1][1] = T[0][MX];
// 预计算 T[1][i] = ((基础矩阵)^MX)^i
for(int i = 2 ; i <= MX ; ++i) T[1][i] = T[1][i - 1] * T[1][1];
// --- 寻找周期倍数 len ---
while(1){
// 随机一个 36 位的下标 x
ll x = (rnd() << 28 >> 28);
// O(1) 计算 (基础矩阵)^x
matrix C = T[0][x & (MX - 1)] * T[1][x >> 18];
// 将数对 (f_{x+1}, f_x) 打包成一个 ull 作为哈希表的 key
// C[0][0] 是 f_{x+1}, C[0][1] 是 f_x
ull val = ((1ull * C[0][0]) << 32) | C[0][1];
// 检查碰撞
if(circ.find(val) != circ.end()){
len = abs(circ[val] - x); // 找到碰撞,计算下标差
break; // 退出循环
}
circ[val] = x; // 未碰撞,存入哈希表
}
// --- 计算 n mod len ---
ll sum = 0;
for(int i = 1 ; str[i] ; ++i) sum = (sum * 10 + str[i] - '0') % len;
// --- 计算最终结果 f_{sum} mod MOD ---
// 再次使用 O(1) 快速幂计算 (基础矩阵)^sum,并输出 f_{sum}
// f_{sum} 存在于结果矩阵的 [0][1] 位置
cout << (T[0][sum & (MX - 1)] * T[1][sum >> 18])[0][1];
return 0;
}
总结
本题的核心思想是巧妙地绕过了对超大数 \(n\) 的直接计算。通过发现斐波那契数列在模意义下的周期性,将问题转化为寻找周期。而寻找周期这个看似困难的任务,又通过“生日悖论”的随机化思想,以很高的效率得以解决。最终,结合光速幂和高精度取模等技巧,构成了一个完整且高效的算法。这个解法充分体现了算法竞赛中,将数论、概率论和数据结构知识融会贯通的魅力。