斐波那契数列高效算法之快速幂法、快速倍增法与扩域法

目前网络上介绍斐波那契数列算法的文章数不胜数, 但是高效算法主要围绕矩阵快速幂法展开. 但实际上还有两种算法: 快速倍增法和扩域法, 均能达到 \(O(\log n)\) 的时间复杂度, 在一定程度的优化后甚至能做到更小的常数, 从而拥有更高的效率. 下面介绍三种算法, 同时给出必要的原理与简要推导.

快速幂法

既然要讲快速算法, 首先还是要介绍快速幂法, 因为其是快速倍增法的基础.
快速幂法基于以下递推式:

\[\begin{bmatrix} F(n)\\ F(n+1) \end{bmatrix} = \begin{bmatrix} 0 & 1\\ 1 & 1 \end{bmatrix}^{n} \begin{bmatrix} F(0)\\ F(1) \end{bmatrix} \]

或可作

\[\begin{bmatrix} F(n-1) & F(n)\\ F(n) & F(n+1)\\ \end{bmatrix} = \begin{bmatrix} 0 & 1\\ 1 & 1 \end{bmatrix}^{n} \]

这个式子原理比较简单, 网上也有很多推导过程, 故此处不再列出.

基于第二个递推式, 可构造一个无符号整数结构体, 为其实现乘法与快速幂运算, 即可通过求 \(n\) 次方得到 \(F(n)\).

use std::ops::Mul;

#[derive(Copy,Debug, Clone)]
struct Mat2x2(u64, u64, u64, u64);

impl Mul for Mat2x2 {
    type Output = Self;
    fn mul(self, rhs: Self) -> Self::Output {
        Self(
            self.0 * rhs.0 + self.1 * rhs.2,
            self.0 * rhs.1 + self.1 * rhs.3,
            self.2 * rhs.0 + self.3 * rhs.2,
            self.2 * rhs.1 + self.3 * rhs.3,
        )
    }
}
impl Mat2x2 {
    fn pow(mut self, mut exp: u64) -> Self {
        let mut ans = Self(1, 0, 0, 1);
        while exp > 0 {
            if exp & 1 == 1 {
                ans = ans * self;
            }
            self = self * self;
            exp >>= 1;
        }
        ans
    }
}
fn fib(n: u64) -> u64 {
    let ori = Mat2x2(0, 1, 1, 1);
    ori.pow(n).1
}

由快速幂原理可得, 时间复杂度取决于 pow 函数中指数的二进制位数, 故时间复杂度为 \(O(\log n)\); 使用的空间为常数, 故空间复杂度为 \(O(1)\).

快速倍增法

通过矩阵幂可推导出以下基础公式:

\[\begin{cases} F(0) &= 0\\ F(1) &= 1\\ F(2n) &= F(n)(F(n) + 2 F(n-1))\text{ if } n > 1 \\ F(2n+1) &= F(n)^2 + F(n+1)^2\text{ if } n > 1 \end{cases} \]

通过矩阵乘法公式计算 \(F(2n)\)\(F(2n+1)\) 项即可得到快速倍增法公式.

\[\begin{align*} \begin{bmatrix} F(2n-1) & F(2n)\\ F(2n) & F(2n+1)\\ \end{bmatrix} &= \begin{bmatrix} 0 & 1\\ 1 & 1 \end{bmatrix}^{2n}\\ &= \begin{bmatrix} 0 & 1\\ 1 & 1 \end{bmatrix}^{n}\begin{bmatrix} 0 & 1\\ 1 & 1 \end{bmatrix}^{n}\\ &= \begin{bmatrix} F(n-1) & F(n)\\ F(n) & F(n+1) \end{bmatrix}\begin{bmatrix} F(n-1) & F(n)\\ F(n) & F(n+1) \end{bmatrix}\\ \end{align*} \]

同时也可写作以下变体形式, 与基础公式等价, 但在 \(F(2)\) 处存在无限递归, 后续推导会用到.

\[\begin{cases} F(0) &= 0\\ F(1) &= 1\\ F(2n) &= F(n)(2F(n+1)-F(n))\text{ if } n > 1 \\ F(2n+1) &= F(n)^2 + F(n+1)^2\text{ if } n > 1 \end{cases} \]

基于快速倍增法基础公式可以得到如下代码:

fn fib(n: u64) -> u64 {
        match n {
            0 => 0,
            1 => 1,
            n if n % 2 == 0 => {
                let n = n / 2;
                fib(n) * (fib(n) + 2 * fib(n - 1))
            }
            n if n % 2 == 1 => {
                let n = n / 2;
                fib(n).pow(2) + fib(n + 1).pow(2)
            }
            _ => unreachable!(),
        }
    }

可以看到目前的方案有两个问题

  1. 计算存在重复, 比如需要求 \(F(6)\) 就要计算 \(F(3)\), \(F(2)\), 计算 \(F(3)\) 时又会再次计算 \(F(2)\), 当计算项变多时重复项也会越来越多;
  2. 计算过程是递归的, 而且不是尾递归, 可能导致性能下降.

首先解决重复计算问题, 通常解决这类问题可以采用记忆化搜索方法, 将计算的中间值用数组保存起来, 当需要用到时就直接用, 不必重复计算. 但对于快速倍增法有更好的方法, 利用变体形式, 我们每次计算 \(F(2n)\)\(F(2n+1)\) 两项就可以避免重复. 以 \((F(12), F(13))\) 计算为例进行演示:

根据变体公式, 求 \((F(12), F(13))\) 需要求 \((F(6), F(7))\), 进而需要求 \((F(3), F(4))\), 但 \((F(3), F(4))\) 不满足 \((F(2n), F(2n+1))\) 形式, 利用 \(F(4) = F(3) + F(2)\) 改为求 \((F(2), F(3))\), 最后一步步到 \((F(0), F(1))\).

求解的过程满足下表:

2n bin(2n) F(2n), F(2n+1) 满足形式 变换形式
12 1100 F(12), F(13) true -
6 110 F(6), F(7) true -
3->2 11 F(3),F(4) false F(2),F(3)
1->0 1 F(1),F(2) false F(0), F(1)

可以发现当二进制最低位为1时(奇数), 形式将不满足条件, 需要向下一级变换形式.

基于以上逻辑, 引入 fib_pair 辅助函数, 输入 \(n\), 输出 \((F(n), F(n+1))\) 对:

fn fib_pair(n: u64) -> (u64, u64) {
    match n {
        0 => (0, 1),
        n => {
            let (l, h) = fib_pair(n / 2); // low and high
            let a = l * (2 * h - l);
            let b = l * l + h * h;
            if n % 2 == 0 { (a, b) } else { (b, a + b) }
        }
    }
}

fn fib(n: u64) -> u64 {
    fib_pair(n).0
}

接下来消除递归过程, 在前文中你可能已经注意到快速倍增法与项的奇偶性密切相关, 这里通过将通过二进制按位分解来将递归化为迭代.

我们将之前的二进制表翻转, 改为从 \((F(0), F(1))\) 向上求值, 判断输入二进制的最高位到最低位, 当前位为0则用

\[\begin{cases} F(2n) &= F(n)(2F(n+1)-F(n))\text{ if } n > 1 \\ F(2n+1) &= F(n)^2 + F(n+1)^2\text{ if } n > 1 \end{cases} \]

\(F(n)\), \(F(n+1)\)\(F(2n)\), \(F(2n+1)\), 当前位为1则用 \(F(n+2) = F(n)+F(n+1)\) 上一级变换形式

bin(n) F(n), F(n+1) bin(n)最高位不为1 变换形式
1100 F(0),F(1) false F(1),F(2)
100 F(2),F(3) false F(3),F(4)
00 F(6), F(7) true -
0 F(12),F(13) true -

可以看到该过程恰好就是递归方案的逆过程, 而且该方案无需递归

fn fib_pair(n: u64) -> (u64, u64) {
    if n == 0 {
        (0, 1)
    } else {
        let (mut l, mut h) = (0u64, 1u64);
        let bit_length = n.ilog2();
        let mut mask = 1 << bit_length;
        while mask != 0 {
            (l, h) = (l * (2 * h - l), l * l + h * h);
            if n & mask != 0 {
                (l, h) = (h, l + h);
            }
            mask >>= 1;
        }
        (l, h)
    }
}

fn fib(n: u64) -> u64 {
    fib_pair(n).0
}

其中mask最高位与n的最高位一致, 其余全是0, 通过 n & mask; mask >>= 1; 就能从高到低遍历n的每一位. 从而无递归地利用快速倍增法求斐波那契数.

扩域法

接下来是比较少被人提到的方法: 扩域法.

首先我们看一下斐波那契数列的通项公式:

\[F(n) = \frac{1}{\sqrt{5}}[(\frac{1+\sqrt{5}}{2})^n-(\frac{1-\sqrt{5}}{2})^n] \]

利用这个公式理论结合浮点数快速幂能够在 \(O(\log n)\) 时间内求解, 但是浮点数运算存在精度问题, 当 \(n\) 较大时很快就会精度不足.

但注意运算过程中只存在 \(\sqrt{5}\) 这唯一一个无理数, 所有数字均可表示为 \(a+b\sqrt{5}, a,b\in Q\) 形式. 如果能通过某个方法, 在运算过程中消除这个无理数, 或者用某个数据结构代替这个无理数, 就有可能避免浮点运算.

在中学阶段, 学习到复数时, 我们通过引入 \(i\) 和数对 \((a,b)\) 让复数 \(a+bi\) 运算能够用实数计算. 仿照复数的这种构造方式, 令

\[a+b\sqrt{5} = (a,b) \]

模仿虚数的实部虚部的称呼, 本文将 \(a\) 称为有理部, \(b\) 为无理部系数.

定义取有理部和无理部系数函数:

\[\begin{align*} Ra[(a,b)] &= a\\ Ir[(a, b)] &= b \end{align*} \]

通过简单推算得到其四则运算的公式

\[\begin{align*} (a,b)\pm (c,d) &=(a\pm c, b\pm d) \\ k \times (a,b) &= (a,b) \times k = (ka, kb) \\ (a,b) \times (c,d) &= (ac + 5bd, ad+bc) \\ \frac{(a, b)}{(c,d)} &= (\frac{ac-5bd}{c^2-5d^2},\frac{bc-ad}{c^2-5d^2}) \end{align*} \]

可以发现计算结果仍满足 \((a,b)\) 结构, 在数学上这构成了一个 \(Q(\sqrt{5})\) 数域, 所以这个方法称为扩域法.

使用这个方法可以将斐波那契数列公式改写为

\[F(n) = \frac{1}{(0,1)}[(\frac{(1,1)}{2})^n - (\frac{(1,-1)}{2})^n] \]

但这个公式目前仍不能完全消除浮点数的存在, 因为在其中还存在以下几个非整数项

\[\begin{align*} &\frac{1}{(0,1)}\\ &\frac{(1,1)}{2}\\ &\frac{(1,-1)}{2}\\ \end{align*}\]

但是可以发现, 第一项 \(1/(0,1)\) 乘以后续结果的作用就是将后续结果的无理部系数提取出来, 所以可以通过以下公式避免第一项的计算

\[F(n) = Ir[(\frac{(1,1)}{2})^n - (\frac{(1,-1)}{2})^n] \]

对于 \((1,1)/2\) 等导致的浮点数, 我们可以将除法放到最后计算, 得到公式

\[F(n) = \frac {Ir[(1,1)^n-(1,-1)^n]}{2^n} \]

到此我们成功消除了所有浮点数的存在, 可以得到初步的代码

use std::ops::{Div, Mul, Sub};

#[derive(Clone, Copy)]
pub struct QSqrt5(i64, i64);

impl Mul for QSqrt5 {
    type Output = Self;
    fn mul(self, rhs: Self) -> Self::Output {
        Self(
            self.0 * rhs.0 + 5 * self.1 * rhs.1,
            self.0 * rhs.1 + self.1 * rhs.0,
        )
    }
}

impl Sub for QSqrt5 {
    type Output = Self;
    fn sub(self, rhs: Self) -> Self::Output {
        Self(self.0 - rhs.0, self.1 - rhs.1)
    }
}

impl Div<i64> for QSqrt5 {
    type Output = Self;
    fn div(self, rhs: i64) -> Self::Output {
        Self(self.0 / rhs, self.1 / rhs)
    }
}

impl QSqrt5 {
    fn pow(mut self, mut exp: u64) -> Self {
        let mut ans = QSqrt5(1, 0);
        while exp > 0 {
            if exp & 1 == 1 {
                ans = ans * self;
            }
            self = self * self;
            exp >>= 1;
        }
        ans
    }
}

fn fib(n: u64) -> u64 {
    (QSqrt5(1, 1).pow(n) - QSqrt5(1, -1).pow(n)).1 as u64 >> n
}

以上已经能够得到斐波那契数列了, 且时间复杂度为两次快速幂的指数二进制位数, 同样为 \(O(\log n)\), 使用了常数空间, 故空间复杂度为 \(O(1)\).

扩域法优化

通过之前的优化我们成功消除了公式法中所有无理数和小数, 但存在两个问题:

  • 能否消除其中的负数计算, 只保留无符号整数
  • 最后整体除 \(2^n\) 说明计算中存在大量因子没有消除, 中间项很大可能导致溢出 (虽然在模 \(2^n\) 数域中这基本不成问题)

通过推导我们发现

\[\begin{align*} Ra[(1,1)^n] &= Ra[(1, -1)^n]\\ Ir[(1,1)^n] &= -Ir[(1, -1)^n] \end{align*} \]

所以只需要计算其中任意一个就能得到另一个, 这样就可以不必计算无理部系数为负数的项, 得到新公式

\[\begin{align*} F(n) &= \frac {Ir[(1,1)^n-(1,-1)^n]}{2^n}\\ &= \frac {Ir[(1,1)^n]-Ir[(1,-1)^n]}{2^n}\\ &= \frac {Ir[(1,1)^n]-(-Ir[(1,1)^n])}{2^n}\\ &= \frac {2Ir[(1,1)^n]}{2^n} \end{align*} \]

这样就可以直接用无符号整数计算.

在求快速幂时, 计算中间值的同时将 \(2\) 及时约去可以避免溢出, 注意到初始项的 \((1,1)\) 不能被 \(2\) 整除, 所以我们将累乘结果 ans 的初始值设为 \((2, 0)\) 而非 \((1,0)\), 相当于 \(2Ir[(1,1)^n]/2^n\) 分子上的 \(2\), 不将其提前约去来保证每一项都能被 \(2\) 整除.

至此我们得到最终版的扩域法代码

use std::ops::{Div, Mul};

#[derive(Clone, Copy, Debug)]
struct QSqrt5(u64, u64);

impl Mul for QSqrt5 {
    type Output = Self;
    fn mul(self, rhs: Self) -> Self::Output {
        Self(
            self.0 * rhs.0 + 5 * self.1 * rhs.1,
            self.0 * rhs.1 + self.1 * rhs.0,
        )
    }
}

impl Div<u64> for QSqrt5 {
    type Output = Self;
    fn div(self, rhs: u64) -> Self::Output {
        Self(self.0 / rhs, self.1 / rhs)
    }
}

impl QSqrt5 {
    fn pow_div_2(mut self, mut exp: u64) -> Self {
        let mut ans = QSqrt5(2, 0);
        while exp != 0 {
            if exp & 1 == 1 {
                ans = ans * self / 2;
            }
            self = self * self / 2;
            exp >>= 1;
        }
        ans
    }
}

fn fib(n: u64) -> u64 {
    QSqrt5(1, 1).pow_div_2(n).1
}

这个方法的时间复杂度仍为 \(O(\log n)\), 空间复杂度仍为 \(O(1)\), 但常数较小.

总结

以上三个方法都能实现时间复杂度在 \(O(\log n)\)的条件下求第 \(n\) 项斐波那契数. 通过充分的优化, 三种方法耗时最终, 在个人电脑的 rust 的 release 模式下重复 \(2\times10^{12}\) 计算 F(92) (不超过u64的最大值), 耗时如下.

method F(92)time
矩阵快速幂 26.558
快速倍增法 17.667
扩域法 22.429

可以看出, 快速倍增法 > 扩域法 > 矩阵快速幂法, 性能比最大提升约 30%.

统计 \(F(0)\)\(F(92)\) 的用时并对 \(n\) 作图, 曲线符合 \(O(\log n)\) 的趋势, 有意思的是快速倍增法每当遇到2的幂次时耗时以台阶形式上升.

image

综上, 快速倍增法不论是代码长度, 编程难度和运行效率, 都显著优于另外两个办法, 所以在日常使用时推荐使用快速倍增法.

posted @ 2025-05-10 09:52  Meth_nylon  阅读(165)  评论(2)    收藏  举报