☰
矩阵快速幂实战:从递推加速到图论路径计数的O(log n)优化
2026/10/21 6:40:05 网站建设 项目流程

1. 我的第一次矩阵快速幂实战:当斐波那契把服务器压垮了

这事说起来挺有意思。之前做一个模拟项目,需要频繁计算某个大数的斐波那契数列项,n 动辄到 10^9 量级,本地跑着还行,一上服务端,递归加记忆化根本撑不住,单次请求的耗时就到了秒级。那时候我开始认真研究矩阵快速幂这东西——后来发现,这个工具的意义远不止"快速算斐波那契"这么简单,它几乎贯穿了我之后做的所有递推类、状态转移类、图路径类问题。

矩阵快速幂,本质上做两件事:第一,把线性递推关系转换成矩阵乘法关系;第二,用快速幂算法把计算 n 次递推的复杂度从 O(n) 压到 O(log n)。这两件事拆开看都不难,但合在一起,能解决的问题范围大得惊人。

这篇文章我打算围绕实际应用来写,不绕理论。先从一个最简单的递推式开始,把"怎么想到用矩阵"这个思维过程讲透,再逐步延伸到多阶递推、带常数项的递推、图论路径计数、概率转移矩阵这几类高频场景。每个场景我都会给出可以直接抄的矩阵构造方法和编码思路,也会把我实际踩过的坑一并交代清楚。

适合读这篇东西的人,我觉得有两类:一类是正在学算法、刷题刷到矩阵快速幂但总觉得"只会背模板"的读者;另一类是工作中遇到了递推性能问题、想找通用优化方案的开发者。看完至少能建立一套自己的分析框架——拿到一个递推问题,怎么判断能不能用矩阵、怎么构造矩阵、构造完怎么验证、写代码要注意什么。这些才是真正值钱的部分。

2. 从斐波那契开始:为什么矩阵能让 O(n) 变成 O(log n)

2.1 递推式和矩阵乘法的对照关系

斐波那契数列的定义是:

F(n) = F(n-1) + F(n-2),其中 F(1) = 1, F(2) = 1

朴素的循环递推,每求一项做一次加法,时间复杂度 O(n)。n=10^18 的时候,这个线性复杂度就是灾难。

矩阵快速幂的思路在于:把相邻两项的状态看成向量,递推行为看成一次线性变换。我们定义状态向量:

V(n) = [F(n), F(n-1)]

那么从 V(n-1) 到 V(n) 的变换可以写成:

V(n) = M × V(n-1)

其中 M 是一个 2×2 矩阵。F(n) = F(n-1) + F(n-2),意味着新向量的第一个分量等于旧向量两个分量的和;F(n-1) 直接保留旧向量的第一个分量。对应矩阵就是:

M = [1 1] [1 0]

验证一下:

[F(n)] [1 1] [F(n-1)] [F(n-1)] = [1 0] [F(n-2)]

展开来就是 F(n) = F(n-1) + F(n-2),F(n-1) = F(n-1)。完全对得上。

有了这个关系,F(n) 可以通过矩阵连乘直接得到:

[F(n)] [1 1]^(n-2) [F(2)] [F(n-1)] = [1 0] [F(1)]

于是问题从"循环 n 次"变成了"计算矩阵 M 的 n-2 次幂",后者用快速幂在 O(log n) 次矩阵乘法内搞定。

2.2 快速幂的直觉:指数拆分

快速幂的思想用在矩阵上,和用在整数上完全一样。要算 M^k,先把 k 写成二进制。比如 k=13,二进制是 1101,即 13 = 8 + 4 + 1,那么 M^13 = M^8 × M^4 × M^1。我们只需要从 M^1 开始,不断自乘得到 M^2、M^4、M^8,看二进制位为 1 就乘进去。这样,原本需要做 k 次矩阵乘法,最多只做 log2(k) 次。

这里有个容易让人困惑的点:普通整数乘法满足交换律,矩阵乘法不满足交换律。但在快速幂的场景里,我们乘的是同一个矩阵 M,所以 M^a × M^b = M^(a+b) 依然成立,因为同一个矩阵的不同幂次是可以交换的。放心用,没问题。

我在某次代码评审里被问到过:"为什么快速幂里可以直接拿当前矩阵自乘,不用管乘的顺序?"我当时回答得有点含糊,事后想了很久,结论就是上面的原因——同一个底数,幂次之间天然可交换,和一般矩阵不同。别等到面试才去弄明白这个推导,写代码之前就应该想清楚。

3. 不只是 F(n)=F(n-1)+F(n-2):复杂递推式的矩阵构造方法论

斐波那契只是入门。真正常见的递推式要复杂得多,比如 F(n) = 2F(n-1) + 3F(n-2) + 4,又比如 F(n) = F(n-1) + F(n-2) + n。这种式子怎么构造矩阵,才是大部分人卡住的地方。

3.1 状态向量怎么选:把需要的旧值都装进去

构造矩阵的第一步,永远是确定状态向量包含哪些分量。原则很简单:递推式里出现了哪些历史项、哪些辅助项,就全部往状态向量里放。

拿 F(n) = 2F(n-1) + 3F(n-2) + 4 来说,它有 F(n-1)、F(n-2)、常数项 4 三个"输入"。那状态向量选什么?

V(n) = [F(n), F(n-1), 1]^T

注意,常数项我在向量里放的是 1,而不是 4。因为矩阵乘法是线性的,如果放的是 4,转移矩阵里就没法干净地表达出新的一项;放 1,然后用矩阵里的系数去乘,就解决了。这是一个非常容易踩坑的细节,后面第 4 部分会再展开。

确定了 V(n),下一步是找 V(n) 和 V(n-1) 的关系:

F(n) = 2F(n-1) + 3F(n-2) + 4 F(n-1) = 1F(n-1) + 0F(n-2) + 0 1 = 0F(n-1) + 0F(n-2) + 1

写成矩阵形式:

[F(n)] [2 3 4] [F(n-1)] [F(n-1)] = [1 0 0] [F(n-2)] [1] [0 0 1] [1]

这个转移矩阵是 3×3 的,第一行是真正的递推系数,第二行是"当前值平移为下一轮的旧值",第三行保证常数项每一轮都保持为 1。

3.2 带累加项的递推:如何把 S(n) 也塞进矩阵

再进阶一点,题目要求算 S(n) = F(1) + F(2) + ... + F(n),其中 F 是某种递推序列。很多人第一次遇到就懵了:S 也在递推,怎么把"累加"这个非递推操作矩阵化?

思路是这样的:S(n) 本身也满足一个递推式——S(n) = S(n-1) + F(n)。所以在状态向量里同时放 F(n) 和 S(n),让它们在同一个矩阵里互相更新。

举个例子,如果 F(n) = F(n-1) + F(n-2),且 F(1)=F(2)=1,我们想算 S(n),状态向量可以选:

V(n) = [F(n), F(n-1), S(n)]^T

递推关系:

F(n) = F(n-1) + F(n-2) F(n-1) = F(n-1) S(n) = S(n-1) + F(n)

对应矩阵:

[F(n)] [1 1 0] [F(n-1)] [F(n-1)] = [1 0 0] [F(n-2)] [S(n)] [1 0 1] [S(n-1)]

看第三行:S(n) = 1×F(n-1) + 0×F(n-2) + 1×S(n-1),化简就是 S(n-1) + F(n-1),和 S(n) = S(n-1) + F(n) 等价(因为 F(n-1) 和 F(n) 差一个下标,在迭代里位置对齐后没问题)。这种"把累加状态作为向量分量"的方法,适用于所有带前缀和、累计统计的递推问题。

这里我总结一个规律,也是我在项目里实际用的检查方法:状态向量的每个分量在 V(n) 和 V(n-1) 之间都必须有一个明确的线性对应关系。如果哪个分量写不出这个关系,就说明状态向量没选全,或者漏了辅助分量(比如常数项)。这个检查做一次,后面构造矩阵基本不会错。

4. 带常数的递推:矩阵里常数 1 的技巧

4.1 为什么常数项不能当成普通分量

承接上面的例子,F(n) = 2F(n-1) + 3F(n-2) + 4 这种式子,如果你试图构造二维矩阵去表达,会发现在向量 [F(n), F(n-1)]^T 里根本找不到一个位置放 4。因为矩阵乘法是"旧状态线性组合成新状态",常数 4 不是旧状态的线性组合,它不依赖任何历史项。

解决办法就是前面提到的:在状态向量里放一个恒为 1 的辅助分量。这样常数 4 就变成了 4×1,看起来像"依赖"了某个分量,实际上是借位表达常量。

类似情况还有 F(n) = F(n-1) + n 这样的式子,变量 n 和轮数有关。处理办法是再加一个辅助分量,让 n 本身也跟着递推:状态向量里放 [F(n), n, 1],转移时 n = (n-1) + 1,于是矩阵里就有地方表达这个"加 1"的递推。

用这种思路,几乎任何"递推式 + 原子式"的组合都能破解。我在某次优化一个类似统计模型的时候,递推式里混了三个不同的累加变量和一个常数修正项,一度不知道怎么办。后来就是把所有辅助递推量全部状态化,一个 5×5 的矩阵就收工了。

4.2 验证矩阵的一个可靠方法

矩阵构造完,最怕的就是"以为对了,一跑全错"。我的习惯做法是:先按矩阵手推几步,同时用普通循环算几步,两边对比,确认前 5~10 项全部对得上,再正式提交逻辑。

具体操作很简单,比如 F(1)=1,F(2)=1 的斐波那契,用矩阵算 F(3):

初始 V(2) = [F(2)=1, F(1)=1]^T M × V(2) = [1 1] [1] = [2], 即 F(3)=2 [1 0] [1] [1]

和直接算 F(3)=F(2)+F(1)=2 一致。不要觉得这是废话,实际开发时,矩阵一写错,往往要到 F(5) 或 F(6) 才暴露出问题,而到那时排查的难度已经翻倍了。先手动验证三步,成本极低,收益极高。

5. 图论里的矩阵快速幂:邻接矩阵乘法的路径计数含义

如果说递推式是矩阵快速幂的"代数"应用,那么图论里的应用就是它的"几何"直觉。这个直觉我第一次接触时觉得很惊艳,因为它把矩阵乘法的本质暴露得很清楚。

5.1 邻接矩阵的幂次:A^k[i][j] 到底在数什么

给定一个有向图,邻接矩阵 A 的定义是 A[i][j]=1 表示从 i 到 j 有一条有向边。

现在看 A^2:

A^2[i][j] = sum_k A[i][k] × A[k][j]

每一项 A[i][k] × A[k][j],只有 A[i][k]=1 且 A[k][j]=1 时才等于 1,也就是存在一条"i 到 k 到 j"的长度为 2 的路径。所以 A^2[i][j] 数的是从 i 到 j 长度为 2 的路径条数。

以此类推,A^k[i][j] 数的是从 i 到 j长度为 k 的路径条数。

这个性质在线性代数里是教科书结论,但真正动手做图题的时候,很多人想不到用它。比如"给定一个图,问从节点 s 到节点 t 恰好走 k 步的方案数",如果 k 达到 10^9,朴素 DFS 或 DP 完全不可行,但直接算 A^k 的 [s][t] 位置,配合快速幂,复杂度只有 O(n^3 log k),n 是节点数。

我在某次做路径规划模拟时,需要统计随机游走若干步后各节点的分布情况,就是这个方法直接救命的。步骤分三步:

  1. 建邻接矩阵 A(有权图可以放边权,但路径计数场景一般用 0/1);
  2. 用快速幂算 A^k;
  3. 取结果矩阵 [s][t] 位置的值。

如果考虑带权图,A[i][j] 可以是边权,那么乘积项 A[i][k]×A[k][j] 的含义就变成"经过这条路径的权重乘积",在很多概率场景下刚好对应概率连乘,这个到第 6 部分再说。

5.2 边界条件:自环、无向图、幂次取模

实际应用里有几个边界条件,一个是自环。如果节点 i 有自环,邻接矩阵对角线上就是 1,那 A^k[i][i] 表示的路径中包含原地踏步的走法,这符合直觉,但要确认题目是否允许这样的走法。

另一个是无向图。无向图的邻接矩阵是对称的,路径计数逻辑不变,但要注意一条无向边在实际走法中正着走和反着走算两条不同的边吗?这取决于题目约定。我遇到过这种情况,约定不同,答案差了一倍不止,读题时一定要确认。

再一个是取模。矩阵快速幂一旦用在竞赛或工程的大数场景,几乎都要对着某个模数取模。这里的要点是:矩阵乘法是多项式运算,每一步都取模不影响最终结果。我第一次写的时候只在最后取模,中间用裸 long long 累加,结果 n 一大,中间若干项早溢出了,答案全错。后来统一在乘法内做(a + b) % MOD,一次性解决。

6. 概率转移矩阵:从随机游走到稳定分布的计算

6.1 转移矩阵的每行每列到底代表什么

概率转移矩阵和邻接矩阵、递推矩阵长得一样,但语义不同。假设系统有 n 个状态,P[i][j] 表示从状态 i 转移到状态 j 的概率。那么经过一步后,状态分布向量 π' 和 π 的关系是:

π' = P × π,其中 π 是列向量,π[i] 表示当前处在状态 i 的概率

等等,这里要注意维度方向。如果 π 是列向量,那么 P[i][j] 的 i 表示新状态,j 表示旧状态,所以矩阵每一列之和为 1(从某个旧状态出发,转移到所有新状态的概率和为 1)。如果你习惯用行向量,那就反过来,每一行之和为 1。

这个方向问题是我见过的第一大坑。写代码时一定要和注释里的约定对齐,否则模型建对了,矩阵转置一搞错,结果全乱。我建议写代码时先写一小段验证:构造一个只有两个状态的简单转移矩阵,手动算两步,再用代码算两步,一致了再往下做。

6.2 n 步转移和收敛分布

算 n 步后的分布,就是算 P^n,这正好落在矩阵快速幂的射程内。比如随机游走模型,从初始分布 π0 出发,走 1000 步后的分布是:

π_n = P^n × π0

P^n 需要快速幂。这里的一个额外优势是:如果图规模不大(比如 n≤50),O(n^3 log k) 完全可行;如果 n 比较大但矩阵稀疏,可以换用稀疏矩阵乘法优化,这个在第 7 部分会说。

另一个相关问题是稳定分布。当 n 趋于无穷,很多不可约非周期马尔可夫链会收敛到一个不依赖初始分布的稳定分布 π*,满足:

π* = P × π*

也就是 π* 是转移矩阵的特征值为 1 对应的特征向量。矩阵快速幂不直接求特征向量,但如果你只是想要一个足够近似的稳定分布,可以把 P 自乘足够多次,次数到了分布就不太动了。

我在某次做用户行为转移分析时,就先用 P^64 迭代看看结果稳不稳定,再用特征值分解去校验,两边差得不多才放心。那种"直接套公式算特征向量"的做法,在工程数据噪声很大时反而不如幂迭代稳健,这是我的个人体会。

7. 工程落地:矩阵乘法怎么写、怎么优化、怎么避免炸掉

7.1 从三重循环到一个高效的矩阵乘法

矩阵快速幂的核心操作是矩阵乘法,实现质量决定了整体效率。最简单的三重循环:

for (int i = 0; i < n; i++) for (int k = 0; k < n; k++) if (A[i][k] != 0) for (int j = 0; j < n; j++) C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % MOD;

注意我把第二层和第三层换了顺序,变成 i-k-j 循环,并且在 A[i][k]==0 时直接跳过。这个小小的调整在稀疏矩阵场景下能省掉大量无效乘法。特别是概率转移矩阵、邻接矩阵这类天然稀疏的矩阵,收益非常明显。

从原理上讲,i-k-j 的循环顺序让内层循环连续访问 B 的第 k 行和 C 的第 i 行,缓存局部性好,实际速度比 i-j-k 快不少。我第一次拿这个版本和朴素版本对拍,300 阶矩阵乘法,速度差了一个数量级,一度怀疑是不是哪里写错了。

7.2 单位矩阵初始化和其他性能细节

快速幂里,结果矩阵初始化为单位矩阵,就是对角线全 1、其余全 0 的矩阵。这里有个细节:很多人会顺手用 memset 把整个矩阵清零,但忘了置对角线。这个问题看起来小,一旦漏了,算出来的就是零矩阵,而且不好排查,因为某些巧合下前几项可能是对的。

矩阵的存储可以用二维数组,也可以用一维数组。一维数组在传入函数和取元素时稍微麻烦一点,但缓存命中更好。我的习惯是:矩阵规模小(n≤50)直接用二维数组,代码清晰;规模大且稀疏,用稀疏格式存非零元。工程上见过有人在 1000 阶稠密矩阵上硬跑三重循环,那个复杂度 O(10^9) 基本是把 CPU 按在地上摩擦。

稀疏矩阵优化的核心是别存储和计算 0。邻接矩阵、转移矩阵大部分元素是 0,只存非零元素及其坐标,乘法时只遍历非零项。这一步做完,很多"看起来要超时"的问题都能跑得动。

7.3 复杂度和规模预算:心里要有本账

矩阵快速幂的时间复杂度是 O(n^3 log k)。在选型之前,先按这个公式估算一下:

  • n=2,k=10^9:2^3 × 30 ≈ 240 次乘法,任何语言都是瞬间完成;
  • n=100,k=10^9:100^3 × 30 = 3×10^8 次乘法,C++ 勉强能跑,Python 就别想了;
  • n=500,k=10^9:500^3 × 30 = 3.75×10^12 次乘法,直接用矩阵快速幂基本是自杀式做法,得考虑稀疏优化、分治法、或者换算法。

所以拿到题目,先算账,再决定用不用矩阵快速幂。不要贪图"我会这个技巧"就硬上,有时候一个线性递推用特征方程算闭式解更快,图计数问题也可能有其他组合方法。

7.4 常见错误清单和定位策略

我把自己踩过的、以及帮别人排查过的矩阵快速幂错误整理一下,按出现频率排序:

错误现象定位方式
转移矩阵写错一两个系数前几项对,后面全乱手动推前 5 步对比
状态向量分量顺序搞混结果差得很离谱验证单步转移
忘记初始化为单位矩阵结果为零矩阵检查快速幂初始化
没对齐行向量/列向量约定结果像是转置了用 2 状态小例子验证
中间乘法不取模导致溢出大 n 时答案错误全部乘法内取模
稀疏矩阵当稠密算超时先估算复杂度

这里最值得说的是第一项:转移矩阵系数错。因为矩阵是二维的,一个系数错了,影响往往是"偶发正确、偶发错误",特别难抓。我的办法是坚持"先写一个普通循环的暴力版本",再和矩阵版本对拍。暴力版本虽然慢,但正确性一目了然,对拍能揪出绝大多数问题。

7.5 语言选型与取模姿势

最后聊下语言。C++ 写矩阵快速幂性能最好,因为可以控制内存布局和循环顺序;Python 有 numpy,矩阵乘法是 C 实现的,但注意 numpy 的@不支持自动取模,大数会出问题,需要先算完再整体取模,可能溢出。事实上 numpy 内部用 64 位整数,乘法一多就爆,这是我在某次数值实验中实际遇到的问题。

如果 Python 必须做,可以分块或者用object类型存 Python 大整数,速度很慢,但至少正确。能换 C++ 就换 C++,或者用 Java 的 BigInteger,也是慢。矩阵快速幂这个场景,工程上正确的姿势是在语言层面明确取模时机:每一次乘法加法都取模,不要指望最后取一次。这是所有语言通用的原则。

8. 实战选型判断:什么时候用矩阵快速幂,什么时候绕开它

8.1 三个适合的核心信号

我总结下来,适合用矩阵快速幂的问题往往具备三个特征:

  1. 存在线性递推关系:当前值可以表示为历史若干项的线性组合(允许加常数项、累加项);
  2. 递推次数极大:n 至少到 10^8 或更高,朴素 O(n) 不可行;
  3. 状态维度可控:状态向量维度 n 比较小(通常 ≤ 100),矩阵乘法的 O(n^3) 能承受。

比如经典的"求斐波那契数列第 10^18 项模 1e9+7",三个特征全中,就是标准矩阵快速幂题。

8.2 不适合甚至该绕开的情况

反过来,有些场景看着像递推,但矩阵快速幂并不是首选:

  • 递推阶数很大:比如 F(n) = F(n-1) + F(n-2) + ... + F(n-100),状态向量维数 100,虽然能做,但复杂度上来了,可能不如生成函数、特征多项式等方法;
  • 需要所有中间项:如果你要输出前 n 项的每一项,矩阵快速幂只能给你 O(log n) 求某一项的代码复杂度,但输出所有项总体上还是 O(n),没什么优势;
  • 非线性递推:带平方项、带条件分支的递推,不是线性变换,矩阵表达不了。

我某次在做数据生成器的时候,想用矩阵快速幂优化一个看似简单的计数,结果递推式里有个条件分支(当 n 为偶数时 F(n)=...,为奇数时 =...),一开始想硬拆成两个矩阵,后来发现其实可以直接分奇偶项分别用两个转移矩阵交替相乘,也能做。所以条件分支也不是完全不能碰,需要灵活处理,而不是套一个固定模板。

8.3 一个从问题到矩阵的完整推导实例

为了把前面所有内容串起来,我放一个完整的实际推演。假设的问题是:求下式的第 n 项(n 很大):

G(n) = 3G(n-1) + 2G(n-2) + n + 5,且 G(1)=1,G(2)=2

第 1 步,选状态向量。递推式里有 G(n-1)、G(n-2)、n、常数 5 四类输入,所以取:

V(n) = [G(n), G(n-1), n, 1]^T

第 2 步,写出 V(n) 的每个分量怎么由 V(n-1) 决定:

G(n) = 3G(n-1) + 2G(n-2) + n + 5 = 3×G(n-1) + 2×G(n-2) + 1×(n-1) + 6×1 // 因为 n = (n-1)+1 G(n-1) = 1×G(n-1) n = (n-1) + 1 1 = 1

注意第一步里,原递推式的 n 是当前轮的 n,而 V(n-1) 里的辅助分量只能存第 n-1 轮的 n-1,所以从 n-1 到 n 需要补一个 1,常数 5 保留,合成系数是 3、2、1、6。

第 3 步,写转移矩阵 M:

[3 2 1 6] [1 0 0 0] [0 0 1 1] [0 0 0 1]

第 4 步,验证初始向量:

V(2) = [G(2)=2, G(1)=1, n=2, 1]^T

M × V(2) 第一行:3×2 + 2×1 + 1×2 + 6×1 = 6 + 2 + 2 + 6 = 16。手算 G(3) = 3×2 + 2×1 + 3 + 5 = 16。对上了。

这个例子完整展示了"常数项、变量 n 混合在递推式里"怎么通过辅助分量化解。实际项目里再复杂的情况,核心思路都是这个。

8.4 和生成函数方法的选择对比

矩阵快速幂之外,还有一种经典做法是生成函数(母函数)。对于纯常系数线性递推,生成函数能推导出闭式公式,然后直接 O(log n) 求某一项。我在某次做技术选型时对比过两者:

维度矩阵快速幂生成函数
推导难度低,构造矩阵直接可用中高,需要代数操作
代码复用性高,同一套矩阵代码通用低,每个问题推一次公式
处理常数项/累加项容易,加辅助分量即可比较繁琐
工程可维护性好,矩阵含义直观差,公式容易推错

所以我的倾向很明确:工程代码里尽量用矩阵快速幂,除非是纯理论研究或者问题规模极端到矩阵法算不动。生成函数在数学上没有坏处,但生产环境要的是"能跑、能维护",矩阵那套东西对团队更友好。

9. 我沉淀下来的一个最小验证模板

讲了这么多,最后分享一个我每次写矩阵快速幂都会用的最小验证流程。它花不了几分钟,但能挡掉大部分低级错误:

  1. 先用暴力循环(或递归)实现正确版本,跑前 10 项存下来;
  2. 构造矩阵版本,跑同样的前 10 项;
  3. 对比两者,找出第一个不一致的下标;
  4. 如果是第 3~4 项开始偏差,大概率是转移矩阵系数或初始向量问题;如果是第 1 项就错,检查向量和矩阵的行列方向约定;
  5. 修正后再对比,直到完全一致,才允许把代码用到主流程。

我在某次开发中正是靠着这个模板,十分钟内定位到了一个"错误系数藏在高阶矩阵中"的问题。而那次如果不验证直接上,可能要在跑完整模型之后才发现,届时排错的成本就大了。

另外有一个小经验:矩阵快速幂不要试图"用代码调通逻辑",它适合"先在纸上想清楚矩阵",再动手写。矩阵写错,代码写得再快也没用;矩阵写对了,代码只是体力活。这是我个人体会最深的一点,也是我想重点传递给读者的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询