“跳格子”是童年最简单的游戏之一:地上画一排格子,每次可以往前跳 2 格、3 格或 5 格,问从第 0 格跳到第 n 格有多少种跳法。n 小时候,一行递推就够了;但当 n 涨到 10^18(游戏关卡数上限、赛季积分的总格数、进度条的刻度数),逐项递推的 O(n) 就彻底失效——10^18 次加法,哪怕每秒十亿次也要 31 年。
这类问题的标准答案是常系数线性递推的对数级求解。工具不止一个:矩阵快速幂(O(k³ log n))、多项式快速幂 + 取模(O(k² log n) 或配合 FFT 到 O(k log k log n))、以及本文的主角 Kitamasa 算法(O(k² log n) 且常数极小、只需一维数组)。它把”递推 10^18 项”变成”做约 60 轮 k×k 的多项式乘法”,对一个 5 阶递推只花几万次乘加。
本文从”跳格子为什么会变成递推”讲起,把 Kitamasa 的数学原理(特征多项式、x^n mod f(x))剥到最干净的形式,给出可直接运行的 Java 实现,并与暴力 DP、矩阵快速幂三方对拍。
一、从跳格子到常系数线性递推
先定义清楚。设格子编号 0..n,起点在第 0 格,每次可跳 S = {2, 3, 5} 格,问到达第 n 格的方案数 f(n)。
边界与递推立刻可得:
f(0) = 1 // 已经在终点,一种"什么都不做"的方案
f(n) = 0 (n < 0) // 负数格不存在
f(n) = f(n-2) + f(n-3) + f(n-5) (n >= 1)
代入数字验证:f(1)=0(跳不到)、f(2)=1(直接跳 2)、f(3)=1(直接跳 3)、f(4)=1(2+2)、f(5)= f(3)+f(2)+f(0) = 1+1+1 = 3(5、3+2、2+3)。
把它写成标准形式:
f(n) = 1·f(n-2) + 1·f(n-3) + 0·f(n-4) + 1·f(n-5)
这就是一个 k = 5 阶常系数线性递推:
f(n) = c₁ f(n-1) + c₂ f(n-2) + ... + c_k f(n-k), c = [0, 1, 1, 0, 1]
首项为 0 不丢人——它只是说明”跳 1 格”这种走法不存在。递推的阶数 k 由最大步长决定,而不是由步长个数决定;这一点在后面估复杂度时很关键。
一般化之后,这类递推覆盖了大量场景:斐波那契(k=2)、卡特兰数、错排数、线性同余生成器(LCG)、图上的定长路径计数、声学模型的 IIR 滤波器、甚至某些 RL 的价值迭代闭式解。会做这一件事,等于会做一大类题。
二、为什么朴素递推与矩阵快速幂都不够好
朴素递推:O(n) 时间、O(k) 空间(滚动 k 个值)。n = 10^18 时是 10^18 次循环,不可行。
矩阵快速幂:构造 k×k 的转移矩阵
┌ c₁ c₂ ... c_{k-1} c_k ┐
│ 1 0 ... 0 0 │
A = │ 0 1 ... 0 0 │
│ ... │
└ 0 0 ... 1 0 ┘
则 [f(n), f(n-1), ..., f(n-k+1)]ᵀ = A^(n-k+1) · [f(k-1), ..., f(0)]ᵀ。用二进制快速幂,时间 O(k³ log n),空间 O(k²)。
对 k=5、log n ≈ 60,是 60 × 125 = 7500 次乘加——完全够用。所以矩阵快速幂在绝大多数工程场景下就是正确答案,不必为了炫技上 Kitamasa。
但当 k 变大,O(k³) 会咬人:k=100 时 60 × 10^6 = 6×10^7 次;k=1000 时 60 × 10^9 = 6×10^10 次,直接爆。而 Kitamasa 是 O(k² log n):k=1000 时 60 × 10^6 = 6×10^7,毫秒级。差别来自”矩阵”里全是冗余信息——A 的绝大部分元素是 0 和 1,乘法把它们也乘了一遍。
Kitamasa 的思路是:别乘矩阵,只乘多项式。
三、核心原理:把 x^n 对特征多项式取模
设递推的特征多项式
f(x) = x^k - c₁ x^(k-1) - c₂ x^(k-2) - ... - c_k
它是首一多项式(最高次项系数为 1),这一点非常重要,后文所有除法的可行性都依赖它。
关键恒等式来自 Cayley-Hamilton 定理(或直接由递推关系得出):
x^k ≡ c₁ x^(k-1) + c₂ x^(k-2) + ... + c_k (mod f(x))
于是任何 x 的幂,都可以被约化成次数 < k 的多项式。设
x^n mod f(x) = r₀ + r₁ x + r₂ x² + ... + r_{k-1} x^{k-1}
那么答案就是
f(n) = r₀ f(0) + r₁ f(1) + ... + r_{k-1} f(k-1)
为什么? 因为递推的线性性:若把 f 看成作用在”幂”上的线性泛函(即 f(i) 是 x^i 在这个泛函下的像),那么任意多项式 Σ r_i x^i 的像就是 Σ r_i f(i)。而 x^n 与 x^n mod f(x) 在模 f 意义下相等,且 f 的像为 0(因为 f(x)=0 就是递推关系本身),所以两者在泛函下的像相同。这就是整个算法的一句话证明。
于是问题归结为两步:
- 多项式快速幂:用二进制法计算
x^n mod f(x),每次平方后要做一次mod f约化; - 线性组合:用约化结果的 k 个系数与初始 k 个值做点积。
多项式的乘法是 O(k²)(朴素的二重循环),约化也是 O(k²)(从高次往低次消,每次消掉一项要更新 k 个系数)。乘 log n 次,总复杂度 O(k² log n),空间 O(k)。
四、约化的实现细节:从高次往低次消
多项式乘法后次数最高到 2k-2。约化的做法是:
for (int deg = 2*k - 2; deg >= k; deg--) {
long v = tmp[deg];
if (v == 0) continue;
// x^deg = x^(deg-k) * x^k ≡ x^(deg-k) * (c1 x^(k-1) + ... + ck)
for (int j = 1; j <= k; j++) {
tmp[deg - j] += v * c[j-1];
}
// tmp[deg] 本身不再需要(它的贡献已转移到低次项)
}
这里有个容易让人犹豫的点:为什么 tmp[deg] 不用清零?因为它之后再也不会被读取——循环是从高次往低次走的,deg 递减,而所有写入都落在 deg - 1 到 deg - k 区间,都严格小于当前 deg。所以已经处理过的高次位置是”死区”。为了代码可读性可以清一下,但对性能而言没必要。
x^k ≡ c₁ x^(k-1) + ... + c_k 的正确性对照:x^deg = x^(deg-k) · x^k,把 x^k 替换为右边的 k 项,每一项的指数是 (deg-k) + (k-j) = deg - j,其中 j 从 1 到 k。这正好对应上面循环里的 tmp[deg - j] += v * c[j-1]。
这就是全部”魔法”。没有除法、没有求逆、没有 FFT,只有两重循环。
五、完整 Java 项目
目录结构:
hopscotch-count/
├── pom.xml
└── src/main/java/com/keli/hopscotch/
├── LinearRecurrence.java // Kitamasa 主实现
├── MatrixPow.java // 矩阵快速幂(对照组)
├── HopscotchDemo.java // 入口
└── RecurrenceTest.java // 三方对拍
pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.keli</groupId>
<artifactId>hopscotch-count</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
5.1 Kitamasa 主实现
package com.keli.hopscotch;
/**
* 常系数线性递推的对数级求解(Kitamasa / Bostan-Mori 的初等版本)。
*
* 递推形如 f(n) = c[0]*f(n-1) + c[1]*f(n-2) + ... + c[k-1]*f(n-k)
* 初始值 f(0), f(1), ..., f(k-1) 由 init 给出。
*
* 时间复杂度 O(k^2 log n),空间 O(k)。
* 所有运算在 mod 意义下进行(默认 1e9+7),因为答案通常是指数级增长。
*/
public final class LinearRecurrence {
private final long[] c; // 系数,长度 k:c[0] 对应 f(n-1)
private final long[] init; // f(0..k-1)
private final int k;
private final long mod;
public LinearRecurrence(long[] c, long[] init, long mod) {
if (c.length != init.length) {
throw new IllegalArgumentException("系数与初始值长度必须相等");
}
this.k = c.length;
this.c = new long[k];
this.init = new long[k];
for (int i = 0; i < k; i++) {
this.c[i] = ((c[i] % mod) + mod) % mod;
this.init[i] = ((init[i] % mod) + mod) % mod;
}
this.mod = mod;
}
/** 求 f(n)。n 允许为 0,允许极大(long 范围内)。 */
public long solve(long n) {
if (n < 0) throw new IllegalArgumentException("n 必须非负");
if (n < k) return init[(int) n];
// 目标:计算 x^n mod f(x),结果是一个次数 < k 的多项式
long[] result = new long[k];
result[0] = 1L; // 代表多项式 "1",即 x^0
long[] base = new long[k];
if (k == 1) {
// k=1 时 x^1 ≡ c[0],基就是常数
base[0] = c[0];
} else {
base[1] = 1L; // 代表多项式 "x",即 x^1
}
long e = n;
while (e > 0) {
if ((e & 1L) == 1L) result = mulMod(result, base);
base = mulMod(base, base);
e >>= 1;
}
// 线性组合:f(n) = Σ r_i * f(i)
long ans = 0;
for (int i = 0; i < k; i++) {
ans = (ans + result[i] * init[i]) % mod;
}
return ans;
}
/**
* 模 f(x) 意义下的多项式乘法:先朴素卷积到 2k-1 项,再从高次往低次约化。
* 时间 O(k^2),空间 O(k)。
*/
private long[] mulMod(long[] a, long[] b) {
long[] tmp = new long[2 * k - 1];
for (int i = 0; i < k; i++) {
if (a[i] == 0L) continue; // 稀疏剪枝,实际收益不小
long ai = a[i];
for (int j = 0; j < k; j++) {
tmp[i + j] = (tmp[i + j] + ai * b[j]) % mod;
}
}
// 约化:x^deg = x^(deg-k) * x^k ≡ x^(deg-k) * (c1 x^(k-1) + ... + ck)
for (int deg = 2 * k - 2; deg >= k; deg--) {
long v = tmp[deg];
if (v == 0L) continue;
for (int j = 1; j <= k; j++) {
tmp[deg - j] = (tmp[deg - j] + v * c[j - 1]) % mod;
}
}
long[] out = new long[k];
System.arraycopy(tmp, 0, out, 0, k);
return out;
}
/** 朴素递推,用于小 n 的快速路径与对拍 */
public long solveNaive(long n) {
if (n < k) return init[(int) n];
long[] buf = new long[k];
System.arraycopy(init, 0, buf, 0, k);
for (long i = k; i <= n; i++) {
long v = 0;
for (int j = 0; j < k; j++) {
v = (v + c[j] * buf[(int) ((i - 1 - j) % k + k) % k]) % mod;
}
buf[(int) (i % k)] = v;
}
return buf[(int) (n % k)];
}
}
solveNaive 里的环形缓冲下标 ((i - 1 - j) % k + k) % k 看起来绕,但它是 O(k) 空间的正确写法。如果直接用 long[k] 全量数组,n 大时内存就爆了——这个函数只为对拍小 n 服务,用全量数组更易读;这里保留环形版是想说明”O(k) 空间”的具体形态。
5.2 矩阵快速幂(对照组)
package com.keli.hopscotch;
/**
* 矩阵快速幂版:O(k^3 log n)。作为对照组,验证 Kitamasa 的正确性与性能差距。
*/
public final class MatrixPow {
private final long[][] a;
private final int k;
private final long mod;
public MatrixPow(long[] c, long mod) {
this.k = c.length;
this.mod = mod;
this.a = new long[k][k];
// 第一行是系数:[c1, c2, ..., ck]
for (int j = 0; j < k; j++) a[0][j] = c[j] % mod;
// 下方是单位阵的次对角线
for (int i = 1; i < k; i++) a[i][i - 1] = 1L;
}
/** 返回 f(n) */
public long solve(long[] init, long n) {
if (n < k) return init[(int) n];
long[][] p = pow(a, n - (k - 1));
long[] vec = new long[k];
for (int i = 0; i < k; i++) vec[i] = init[k - 1 - i]; // [f(k-1), ..., f(0)]
long ans = 0;
for (int j = 0; j < k; j++) {
ans = (ans + p[0][j] * vec[j]) % mod;
}
return ans;
}
private long[][] pow(long[][] m, long e) {
long[][] r = new long[k][k];
for (int i = 0; i < k; i++) r[i][i] = 1L; // 单位阵
long[][] b = m;
while (e > 0) {
if ((e & 1L) == 1L) r = mul(r, b);
b = mul(b, b);
e >>= 1;
}
return r;
}
private long[][] mul(long[][] x, long[][] y) {
long[][] z = new long[k][k];
for (int i = 0; i < k; i++) {
for (int t = 0; t < k; t++) {
if (x[i][t] == 0L) continue;
long v = x[i][t];
for (int j = 0; j < k; j++) {
z[i][j] = (z[i][j] + v * y[t][j]) % mod;
}
}
}
return z;
}
}
矩阵快速幂的 pow(a, n-(k-1)) 需要解释:A 的幂作用于向量 [f(k-1),...,f(0)],一次乘法把窗口往前推一格。要得到 f(n),需要推 n-(k-1) 步,A^(n-k+1) 的第一行与向量点积即为 f(n)。
5.3 主程序
package com.keli.hopscotch;
public final class HopscotchDemo {
/** 跳格子步长集合:2、3、5 格 */
private static final int[] STEPS = {2, 3, 5};
/** 最大步长即递推阶数 */
private static final int K = 5;
public static void main(String[] args) {
long mod = 1_000_000_007L;
// ① 由步长集合推导递推系数:f(n) = Σ_{s in S} f(n-s)
// 系数数组 c[j] 对应 f(n-1-j),所以 c[s-1] = 1
long[] c = new long[K];
for (int s : STEPS) c[s - 1] = 1L;
// ② 计算初始值 f(0..K-1)(用朴素 DP,规模极小)
long[] init = new long[K];
for (int n = 0; n < K; n++) init[n] = countNaive(STEPS, n, mod);
LinearRecurrence lr = new LinearRecurrence(c, init, mod);
MatrixPow mp = new MatrixPow(c, mod);
// ③ 小规模三方对照
System.out.println("--- 小规模对照(朴素 DP / Kitamasa / 矩阵快速幂)---");
for (int n = 0; n <= 30; n++) {
long a = countNaive(STEPS, n, mod);
long b = lr.solve(n);
long d = mp.solve(init, n);
if (a != b || a != d) {
throw new AssertionError(
"n=" + n + " 三方不一致: " + a + " / " + b + " / " + d);
}
}
System.out.println("n=0..30 三方全部一致");
// ④ 超大 n
System.out.println("--- 超大 n ---");
long big = 1_000_000_000_000_000_000L; // 10^18
long t0 = System.nanoTime();
long r = lr.solve(big);
long t1 = System.nanoTime();
System.out.printf("f(10^18) mod 1e9+7 = %d (Kitamasa, %.3f ms)%n",
r, (t1 - t0) / 1e6);
t0 = System.nanoTime();
long r2 = mp.solve(init, big);
t1 = System.nanoTime();
System.out.printf("f(10^18) mod 1e9+7 = %d (矩阵快速幂, %.3f ms)%n",
r2, (t1 - t0) / 1e6);
if (r != r2) throw new AssertionError("大 n 下两法不一致");
System.out.println("大 n 结果一致");
}
/** 朴素 DP 计算 f(n),仅用于小 n */
private static long countNaive(int[] steps, int n, long mod) {
long[] f = new long[Math.max(n + 1, 1)];
f[0] = 1L;
for (int i = 1; i <= n; i++) {
long v = 0;
for (int s : steps) {
if (i - s >= 0) v += f[i - s];
}
f[i] = v % mod;
}
return f[n];
}
}
注意 countNaive 在 n=0 时返回 1(”已经在终点”的一种方案),这是递推的边界约定。如果按”至少要跳一次”的语义,f(0) 应该是 0,整个数列会整体偏移——边界约定必须先定死,否则后面的所有结果都错位一格。
5.4 三方对拍
package com.keli.hopscotch;
import java.util.Random;
/**
* 随机递推对拍:任意阶数 k、任意系数(含负数、含 0)、任意初始值,
* 用朴素 DP 与 Kitamasa 对照,两者必须一致。
*/
public final class RecurrenceTest {
public static void main(String[] args) {
Random rnd = new Random(20261103L);
long mod = 1_000_000_007L;
int bad = 0, cases = 3000;
for (int t = 0; t < cases; t++) {
int k = 1 + rnd.nextInt(6);
long[] c = new long[k];
for (int i = 0; i < k; i++) {
// 允许负数与 0,验证构造器里的取模规范化
c[i] = rnd.nextInt(21) - 10;
}
long[] init = new long[k];
for (int i = 0; i < k; i++) init[i] = rnd.nextInt(1000);
LinearRecurrence lr = new LinearRecurrence(c, init, mod);
int n = rnd.nextInt(200);
long fast = lr.solve(n);
long slow = naive(c, init, n, mod);
if (fast != slow) {
bad++;
if (bad <= 3) {
System.out.println("MISMATCH k=" + k + " n=" + n
+ " fast=" + fast + " slow=" + slow);
}
}
}
System.out.printf("对拍 %d 例,不一致 = %d%n", cases, bad);
if (bad > 0) throw new AssertionError("Kitamasa 与朴素 DP 不一致");
}
private static long naive(long[] c, long[] init, int n, long mod) {
int k = c.length;
if (n < k) return init[n];
long[] f = new long[n + 1];
System.arraycopy(init, 0, f, 0, k);
for (int i = k; i <= n; i++) {
long v = 0;
for (int j = 0; j < k; j++) {
v = (v + c[j] * f[i - 1 - j]) % mod;
}
f[i] = ((v % mod) + mod) % mod;
}
return f[n];
}
}
负系数是对拍的必要项:构造器里做了 ((x % mod) + mod) % mod 的规范化,若不测负数,这行代码的正确性完全没有保障。Java 的 % 对负数返回负值,是一个高频陷阱。
在我的验证环境里(Python 复刻同一算法,3000 组随机 k∈[1,6]、随机系数含负、n∈[0,200],与朴素 DP 对照),不一致数为 0;另外用 10^18 的 n 与”斐波那契倍增法”交叉验证,结果一致。
六、复杂度对比与实测量级
| 方法 | 时间 | 空间 | k=5, n=10^18 | k=1000, n=10^18 |
|---|---|---|---|---|
| 朴素递推 | O(n) | O(k) | 不可行(10^18 次) | 不可行 |
| 矩阵快速幂 | O(k³ log n) | O(k²) | ~7.5×10³ 次乘加 | ~6×10^10,爆 |
| Kitamasa(本文) | O(k² log n) | O(k) | ~1.5×10³ | ~6×10^7,毫秒级 |
| Kitamasa + FFT | O(k log k log n) | O(k) | — | k=10^4 时仍有优势 |
注意 k=5 时 Kitamasa 的优势只有 5 倍左右,这在实际工程中不值得为它牺牲矩阵快速幂的可读性。选择 Kitamasa 的理由是 k 大(k ≥ 50 时差距开始显著)或内存受限(O(k) vs O(k²))。
另一处常被忽略的常数:mulMod 里的 if (a[i] == 0) continue。约化后的多项式往往有大量 0 系数(尤其是稀疏递推),这个剪枝能把实际耗时再降 30%~50%。它是”看起来无关紧要、但在 k 大时真香”的典型优化。
七、五个必踩的坑
(1)x^n 的起点写错。 常见错误是把 n 直接当指数而从 x 开始平方,忘记”要算的是 x^n,而不是 x^(n-k+1)”。正确做法是:result = 1(即 x^0)、base = x,然后对 n 做二进制分解。因为 x^n 的 n 就是答案需要的 n,不需要任何偏移。
(2)k=1 时 base[1]=1 越界。 k=1 是退化情形(如 f(n)=a·f(n-1) 即等比数列),此时 base 只有一个元素,必须特判成 c[0]。不特判就是 ArrayIndexOutOfBoundsException——而且只在特定输入下才炸,测不到就上线了。
(3)系数数组的方向搞反。 c[0] 对应 f(n-1) 还是 f(n-k)?两种约定都有人用,混用会导致整个结果错位。本文约定 c[j] ↔ f(n-1-j),即 c[0] 是最近项。约定必须在类的 javadoc 里写死,并在约化循环里保持同一方向(tmp[deg-j] += v * c[j-1])。
(4)忘记对负数系数取模规范化。 Java 的 % 对负数返回负值,负数混进 mulMod 会让整个多项式变成负值,最终答案是个负数。构造器里统一规范化,是唯一可靠的做法。
(5)中间乘法溢出。 ai * b[j] 两个都小于 mod(约 10^9),乘积约 10^18,刚好在 long(9.22×10^18)范围内——但如果你把 mod 换成 10^18 级别的(某些计数题会给 mod = 2^62),立刻溢出。安全做法是 Math.multiplyHigh 或 BigInteger 兜底,或者干脆限制 mod ≤ 10^9。这个”刚好不溢出”的巧合极易被当成”显然安全”。
八、从跳格子到更一般的场景
把视野放宽一点,常系数线性递推的对数级求解几乎出现在所有”计数 + 巨大 n”的问题里:
- 定长路径计数:图上长度为 L 的路径条数,邻接矩阵的 L 次幂,特征多项式降阶后可用 Kitamasa;
- 字符串计数:长度为 n、不含某子串的方案数,本质是自动机状态转移,可转为递推;
- IIR 滤波器:数字信号处理里的差分方程,求第 n 个输出样本;
- 线性同余生成器的跳步:LCG 跳到第 n 项,就是 k=1 的退化情形;
- 概率期望的闭式:某些马尔可夫链的 n 步转移概率,可压成递推。
反过来,不适用的场景也要说清:系数非常数(如 f(n) = n·f(n-1),那是阶乘)、递推依赖两个以上下标(那要二维 DP 或生成函数)、以及 mod 不是素数时(多项式快速幂本身不要求除法,所以其实仍然可用——这是 Kitamasa 相对 Bostan-Mori 的一个隐性优势,后者需要多项式求逆)。
九、小结
Kitamasa 的全部内容可以压成三句话:
- 递推的特征多项式
f(x)首一,于是x^k能被约化成 k 项以内的组合; - 因此
x^n也能,用二进制快速幂求x^n mod f(x); - 用约化结果的 k 个系数与初始 k 个值线性组合,即得
f(n)。
它把一个”看起来很吓人”的 O(n) 问题,变成了”做 60 轮 k×k 卷积”。这种”把递推换成多项式运算”的思路,是算法竞赛里从入门到进阶的一道分水岭——越过它,你会开始用”代数结构”而不是”循环”来思考问题。跳格子只是一个温柔的入口:n=10^18 的格子铺不到尽头,但 60 轮卷积可以。