题目描述

image.png

题目链接

https://leetcode.cn/problems/powx-n/

思路

【50】Pow(x, n) - 图2最简单的方法是通过循环将【50】Pow(x, n) - 图3【50】Pow(x, n) - 图4乘起来,依次求【50】Pow(x, n) - 图5,时间复杂度为【50】Pow(x, n) - 图6。我们可以利用「快速幂算法」将时间复杂度降低至【50】Pow(x, n) - 图7,快速幂算法有递归和迭代两个版本。我们先从递归版本开始讲起,再逐步引出迭代的版本。

当指数【50】Pow(x, n) - 图8为负数时,我们可以计算【50】Pow(x, n) - 图9再取倒数得到结果,因此我们只需考虑【50】Pow(x, n) - 图10为自然数的情况。

1. 快速幂 + 递归

「快速幂算法」的本质是分治算法。举个例子,如果我们要计算【50】Pow(x, n) - 图11,我们可以按照: :::success 【50】Pow(x, n) - 图12 ::: 的顺序,从【50】Pow(x, n) - 图13开始,每次直接把上一次的结果进行平方,计算【50】Pow(x, n) - 图14次就可以得到【50】Pow(x, n) - 图15 的值,而不需要对【50】Pow(x, n) - 图16【50】Pow(x, n) - 图17【50】Pow(x, n) - 图18

再举一个例子,如果我们要计算【50】Pow(x, n) - 图19,我们可以按照: :::success 【50】Pow(x, n) - 图20 ::: 的顺序,在【50】Pow(x, n) - 图21【50】Pow(x, n) - 图22【50】Pow(x, n) - 图23这些步骤中,我们直接把上一次的结果进行平方,而在【50】Pow(x, n) - 图24【50】Pow(x, n) - 图25【50】Pow(x, n) - 图26这些步骤中,我们把上一次的结果进行平方后,还要额外乘一个【50】Pow(x, n) - 图27

直接从左到右进行推导看上去很困难,因为在每一步中,我们不知道在将上一次的结果平方之后,还需不需要额外乘【50】Pow(x, n) - 图28。但如果我们从右往左看,分治的思想就十分明显了:

  • 当我们要计算【50】Pow(x, n) - 图29时,我们可以先递归地计算出【50】Pow(x, n) - 图30,其中【50】Pow(x, n) - 图31表示对【50】Pow(x, n) - 图32进行下取整;
  • 根据递归计算的结果,如果【50】Pow(x, n) - 图33为偶数,那么【50】Pow(x, n) - 图34;如果【50】Pow(x, n) - 图35为奇数,那么【50】Pow(x, n) - 图36
  • 递归的边界为【50】Pow(x, n) - 图37,任意数的【50】Pow(x, n) - 图38次方均为【50】Pow(x, n) - 图39

由于每次递归都会使得指数减少一半,因此递归的层数为【50】Pow(x, n) - 图40,算法可以在很快时间内得到结果。注意 Java 中的 int 变量【50】Pow(x, n) - 图41,因此当【50】Pow(x, n) - 图42时执行【50】Pow(x, n) - 图43会因越界而赋值出错。我们需要先将【50】Pow(x, n) - 图44存入 long 型变量中再进行后续操作。

  1. public double myPow(double x, int n) {
  2. long N = n;
  3. return N < 0 ? 1 / recur(x, -N) : recur(x, N);
  4. }
  5. private double recur(double x, long n) {
  6. if (n == 0) {
  7. return 1;
  8. }
  9. double y = recur(x, n >> 1);
  10. return ((n & 1) == 0) ? y * y : y * y * x;
  11. }
  • 时间复杂度:【50】Pow(x, n) - 图45,即为递归的层数。
  • 空间复杂度:【50】Pow(x, n) - 图46,即为递归的层数。这是由于递归的函数调用会使用栈空间。

    2. 快速幂 + 迭代

    由于递归需要使用额外的栈空间,我们试着将递归转写为迭代。在方法一中,我们也提到过,从左到右进行推导是不容易的,因为我们不知道是否需要额外乘【50】Pow(x, n) - 图47。但我们不妨找一找规律,看看哪些地方额外乘了【50】Pow(x, n) - 图48,并且它们对答案产生了什么影响。

我们还是以【50】Pow(x, n) - 图49作为例子:

【50】Pow(x, n) - 图50

并且把需要额外乘【50】Pow(x, n) - 图51的步骤打上了【50】Pow(x, n) - 图52标记。可以发现:

  • 【50】Pow(x, n) - 图53中额外乘的【50】Pow(x, n) - 图54【50】Pow(x, n) - 图55中贡献了【50】Pow(x, n) - 图56
  • 【50】Pow(x, n) - 图57中额外乘的【50】Pow(x, n) - 图58在之后又被平方了【50】Pow(x, n) - 图59次,因此在【50】Pow(x, n) - 图60中贡献了【50】Pow(x, n) - 图61
  • 【50】Pow(x, n) - 图62中额外乘的【50】Pow(x, n) - 图63在之后又被平方了【50】Pow(x, n) - 图64次,因此在【50】Pow(x, n) - 图65中贡献了【50】Pow(x, n) - 图66
  • 最初的【50】Pow(x, n) - 图67在之后被平方了【50】Pow(x, n) - 图68次,因此在【50】Pow(x, n) - 图69中贡献了【50】Pow(x, n) - 图70

我们把这些贡献相乘,【50】Pow(x, n) - 图71恰好等于【50】Pow(x, n) - 图72。而这些贡献的指数部分又都是【50】Pow(x, n) - 图73的幂次:【50】Pow(x, n) - 图74,这是因为每个额外乘的【50】Pow(x, n) - 图75在之后都会被平方若干次。而这些指数【50】Pow(x, n) - 图76恰好就对应了【50】Pow(x, n) - 图77的二进制表示【50】Pow(x, n) - 图78中的每个【50】Pow(x, n) - 图79

因此我们借助整数的二进制拆分,就可以得到迭代计算的方法,一般地,如果整数【50】Pow(x, n) - 图80的二进制拆分为

【50】Pow(x, n) - 图81

那么

【50】Pow(x, n) - 图82

这样一来,我们从【50】Pow(x, n) - 图83开始不断地进行平方,得到【50】Pow(x, n) - 图84,如果【50】Pow(x, n) - 图85的第【50】Pow(x, n) - 图86个(从右往左,从【50】Pow(x, n) - 图87开始计数)二进制位为【50】Pow(x, n) - 图88,那么我们就将对应的贡献【50】Pow(x, n) - 图89计入答案。

相关位运算操作:

  • 【50】Pow(x, n) - 图90: 判断【50】Pow(x, n) - 图91的二进制最右一位是否为【50】Pow(x, n) - 图92,在数学上等价于取余数【50】Pow(x, n) - 图93
  • 【50】Pow(x, n) - 图94:将【50】Pow(x, n) - 图95右移一位,可理解为删除最低位,在数学上等价于向下整除【50】Pow(x, n) - 图96

具体代码实现如下:

public double myPow(double x, int n) {
    if (n == 0) {
        return 1;
    }
    long N = n;
    return N < 0 ? 1 / recur(x, -N) : recur(x, N);
}

private double recur(double x, long n) {
    double ans = 1.0;
    // 贡献的初始值为 x
    double x_contribute = x;
    // 在对 n 进行二进制拆分的同时计算答案
    while (n > 0) {
        if ((n & 1) == 1) {
            // 如果 n 二进制表示的最低位为 1,那么需要计入贡献
            ans *= x_contribute;
        }
        // 将贡献不断地平方
        x_contribute *= x_contribute;
        // 舍弃 n 二进制表示的最低位,这样我们每次只要判断最低位即可
        n = n >> 1;
    }
    return ans;
}
  • 时间复杂度:【50】Pow(x, n) - 图97,即为对【50】Pow(x, n) - 图98进行二进制拆分的时间复杂度。
  • 空间复杂度:【50】Pow(x, n) - 图99