P4000 斐波那契数列 解题报告

P4000 斐波那契数列 解题报告

题目大意

计算斐波那契数列的第 \(n\) 项对 \(p\) 取模的结果,即 \(f_n \pmod p\)

其中 \(n\) 是一个超大的数字(最多有 300 万位),\(p\) 是一个普通的整数。

思路分析

第一步:常规思路的碰壁

看到求斐波那契数列的第 \(n\) 项,大部分同学的第一反应是矩阵快速幂。斐波那契数列的递推关系可以表示为矩阵形式:

\[\begin{pmatrix} f_{n} \\ f_{n-1} \end{pmatrix} = \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix} \begin{pmatrix} f_{n-1} \\ f_{n-2} \end{pmatrix} \]

通过累推,我们可以得到:

\[\begin{pmatrix} f_{n+1} \\ f_{n} \end{pmatrix} = \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^n \begin{pmatrix} f_{1} \\ f_{0} \end{pmatrix} \]

我们只需要计算出转移矩阵 \(\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)\)

  1. 暴力法:我们可以从 \((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)\) 的复杂度依然无法接受。

  2. 随机化 + 生日悖论:既然暴力枚举太慢,我们能不能“投机取巧”?
    我们不一定要找到完整的周期 \(\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 人。

    这个思想告诉我们,在一堆样本中寻找碰撞,比我们想象的要快得多。

    我们可以这样做:

    1. 创建一个哈希表(unordered_map),用来存储我们见过的数对和对应的下标。
    2. 不断地随机生成一个下标 \(i\)
    3. 用矩阵快速幂计算出 \((f_i \pmod p, f_{i+1} \pmod p)\) 这个数对。
    4. 在哈希表中查找这个数对:
      • 如果找到了:说明之前有一个下标 \(j\) 也生成了同样的数对。太棒了!我们找到了一个碰撞。令周期长度的倍数 \(L = |i-j|\)。我们的任务完成了。
      • 如果没找到:将这个数对 \((f_i \pmod p, f_{i+1} \pmod p)\) 和下标 \(i\) 存入哈希表,继续下一次随机。

    根据生日悖论,我们期望在随机 \(O(\sqrt{\text{周期长度}})\) 次之后就能找到一次碰撞。由于周期长度 \(\pi(p)\)\(O(p)\) 级别的,所以我们期望的随机次数是 \(O(\sqrt{p})\)

第四步:整合算法流程

现在,我们可以梳理出完整的解题步骤:

  1. 寻找周期倍数 L

    • 利用上述的“生日悖论”随机化算法。
    • 循环地随机下标 x,用矩阵快速幂计算 (f_x, f_{x+1}) mod p
    • 使用哈希表记录遇到的 (数对, 下标)
    • 一旦发现碰撞,即当前生成的数对已在哈希表中,就计算出两个下标的差 len,这就是我们找到的周期倍数。这个过程的期望时间复杂度是 \(O(\sqrt{p} \cdot \log(\text{随机值}))\)
  2. 优化斐波那契计算
    在第 1 步中,每次随机都要计算一次矩阵快速幂,如果随机值的范围很大,log 项会拖慢速度。我们可以使用一种名为O(√p) - O(1)的快速幂技巧(也叫光速幂、BSGS 思想)来优化它。

    • 预处理:将指数 x 拆分为 a * B + b 的形式(B 是一个块大小,比如 \(2^{18}\))。我们预计算出 (转移矩阵)^b(转移矩阵)^(a*B) 的所有可能值。
    • 查询:对于任意指数 x,我们把它拆分,然后用预处理好的两个矩阵相乘即可,查询时间是 \(O(1)\)
    • 这样,寻找周期倍数 L 的总时间复杂度就优化到了 \(O(\sqrt{p})\)
  3. 处理超大数 n

    • 将输入的 n 当作一个字符串来读。
    • 一边读字符串,一边进行高精度的取模运算,计算 n' = n % L。方法如下:
      long long remainder = 0;
      for (char c : n_string) {
          remainder = (remainder * 10 + (c - '0')) % L;
      }
      
  4. 计算最终结果

    • 我们已经有了 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\) 的直接计算。通过发现斐波那契数列在模意义下的周期性,将问题转化为寻找周期。而寻找周期这个看似困难的任务,又通过“生日悖论”的随机化思想,以很高的效率得以解决。最终,结合光速幂和高精度取模等技巧,构成了一个完整且高效的算法。这个解法充分体现了算法竞赛中,将数论、概率论和数据结构知识融会贯通的魅力。

posted @ 2025-07-20 16:35  surprise_ying  阅读(17)  评论(0)    收藏  举报