24、递归与数学:快速幂算法的递归实现

快速幂,这个名字听起来挺唬人的。其实说白了,就是教你如何高效地计算 a 的 n 次方。

你可能会想:「这有什么难的?一个 for 循环不就搞定了?」没错,如果 n 是 10、100,甚至 1000,循环确实没问题。但假如 n 是 10 亿呢?或者你的程序需要在嵌入式芯片上每秒算几千次幂运算?这时候,O(n) 的循环就扛不住了。

我在做网络协议栈的时候,遇到过加密模块的性能瓶颈。当时用的就是快速幂,把指数运算从 O(n) 降到了 O(log n)。嗯,这个提升可不是一星半点。

从暴力到优雅:为什么需要快速幂?

先看最朴素的写法:

// 暴力循环法
long long pow_naive(long long a, long long n) {
    long long result = 1;
    for (long long i = 0; i < n; i++) {
        result *= a;
    }
    return result;
}

这个函数,n 次乘法,时间复杂度 O(n)。当 n = 10^9 时,你的程序基本就卡死了。

那快速幂是怎么做的?它利用了指数的一个基本性质:

  • 如果 n 是偶数:a^n = (a^(n/2))^2
  • 如果 n 是奇数:a^n = a * a^(n-1)

你看,每次都能把指数砍掉一半。这就是 O(log n) 的来源。

核心思想: 快速幂的本质就是「分治」。把大指数拆成小指数,然后合并结果。递归在这里用得恰到好处。

递归实现:最直观的写法

我个人习惯先写递归版本,因为它最贴近数学定义,逻辑清晰,不容易出错。

// 递归实现快速幂
long long fast_pow_recursive(long long a, long long n) {
    // 基础情况:任何数的 0 次方等于 1
    if (n == 0) {
        return 1;
    }
    
    // 奇数情况
    if (n % 2 == 1) {
        return a * fast_pow_recursive(a, n - 1);
    }
    
    // 偶数情况
    long long half = fast_pow_recursive(a, n / 2);
    return half * half;
}

这段代码,你仔细看:

  • 递归终止条件:n == 0,返回 1
  • 奇数:先算 a^(n-1),再乘 a
  • 偶数:先算 a^(n/2),再平方

为什么偶数情况要先把结果存到 half 里?因为如果你写成 fast_pow_recursive(a, n/2) * fast_pow_recursive(a, n/2),那就递归调用了两次,时间复杂度又变回 O(n) 了。嗯,这个坑我曾经踩过。

小技巧: 递归函数中,如果同一个递归调用需要多次使用,一定要先存到局部变量里。这不仅是性能问题,有时候还会导致栈溢出。

递归的调用过程:画图理解

我们算一下 2^10,看看递归是怎么展开的:

fast_pow_recursive(2, 10)
  → n=10 偶数
  → half = fast_pow_recursive(2, 5)
      → n=5 奇数
      → 2 * fast_pow_recursive(2, 4)
          → n=4 偶数
          → half = fast_pow_recursive(2, 2)
              → n=2 偶数
              → half = fast_pow_recursive(2, 1)
                  → n=1 奇数
                  → 2 * fast_pow_recursive(2, 0)
                      → n=0 返回 1
                  → 返回 2 * 1 = 2
              → 返回 2 * 2 = 4
          → 返回 4 * 4 = 16
      → 返回 2 * 16 = 32
  → 返回 32 * 32 = 1024

一共只做了 4 次乘法(不算递归返回时的乘法),而暴力法需要 10 次。当 n 越大,差距越明显。

知识体系图

下面这张图展示了快速幂在整个递归知识体系中的位置:

快速幂递归实现 · 知识体系 递归思想(分治策略) 数学基础:指数运算法则 偶数:a^n = (a^(n/2))^2 奇数:a^n = a * a^(n-1) 递归函数:fast_pow_recursive(a, n) 时间复杂度 O(log n) · 空间复杂度 O(log n)(递归栈)

避坑指南:递归的代价

递归版本虽然优雅,但有一个问题:每次递归调用都会消耗栈空间。当 n 很大时(比如 10^6),递归深度可能达到 20 层左右,这还好。但如果你的指数是 10^9,递归深度也就 30 层,完全没问题。

真正的问题在于——我曾经在某个嵌入式项目中,递归深度只有 10 层,但栈空间只给了 1KB。结果呢?栈溢出了。所以,如果你在资源受限的环境下编程,递归版本可能不是最佳选择。

注意: 递归深度不是问题,栈空间大小才是问题。每层递归调用至少需要保存返回地址和局部变量,大约 16-32 字节。30 层递归大约需要 1KB 栈空间,在大多数平台上都能接受。但如果你在单片机或者 RTOS 的小任务里,就要小心了。

迭代版本:空间换时间

如果你对栈空间有顾虑,可以改成迭代版本。我个人建议:先理解递归版本,再掌握迭代版本。两者相辅相成。

// 迭代实现快速幂
long long fast_pow_iterative(long long a, long long n) {
    long long result = 1;
    long long base = a;
    
    while (n > 0) {
        // 如果当前位是 1,乘上 base
        if (n & 1) {
            result *= base;
        }
        // base 自乘,准备下一位
        base *= base;
        // n 右移一位
        n >>= 1;
    }
    
    return result;
}

这个版本没有递归调用,空间复杂度 O(1)。它利用了二进制的思想:把 n 写成二进制,每一位对应一个 base 的幂。比如 n = 10(二进制 1010),就是 a^8 * a^2。

你想想看,迭代版本其实就是把递归版本的「分治」过程,用循环模拟出来了。底层逻辑完全一样。

实战:大数取模

在实际项目中,我们很少直接算 a^n,因为结果会溢出。更常见的场景是计算 a^n mod m,也就是带模的快速幂。

// 带模的快速幂(递归版本)
long long mod_pow_recursive(long long a, long long n, long long m) {
    if (n == 0) {
        return 1 % m;
    }
    
    if (n % 2 == 1) {
        return (a * mod_pow_recursive(a, n - 1, m)) % m;
    }
    
    long long half = mod_pow_recursive(a, n / 2, m);
    return (half * half) % m;
}

这个函数在密码学、哈希计算中非常常见。我在做 RSA 算法实现时,核心就是靠这个函数。每次加密解密,都要算大数的模幂,没有快速幂,RSA 根本跑不起来。

关键点: 每次乘法后立即取模,可以防止中间结果溢出。C 语言中 long long 最大约 9e18,如果两个 10^9 级别的数相乘,结果约 10^18,还在范围内。但如果 a 和 m 都是 10^9 级别,乘法后不取模,直接溢出。

性能对比:递归 vs 迭代

维度 递归版本 迭代版本
时间复杂度 O(log n) O(log n)
空间复杂度 O(log n)(栈空间) O(1)
代码可读性 高(贴近数学定义) 中(需要理解二进制)
适用场景 教学、原型开发、栈空间充足 嵌入式、高性能、栈空间受限

我个人建议:学习阶段先用递归版本,把逻辑理清楚。实际项目中,如果性能要求高或者环境受限,再换成迭代版本。两个版本都掌握,你才算真正理解了快速幂。

好了,快速幂的递归实现就讲到这里。记住一句话:递归不是目的,分治才是。快速幂只是分治思想在指数运算中的一个漂亮应用。

公众号:蓝海资料掘金营,微信 deep3321