时间复杂度 & 空间复杂度

先来啰嗦两句,这两个复杂度在很久之前就略有了解,但写了几十道算法题了,依然对这两个参数是略有了解。因此,每次写算法题时,对比一道题不同解法效率上的差别,在脑海中分析起来就很无力。个人感觉这非常致命,学习算法的初衷就在于想要用它对代码进行一定的优化,但写了这么多,连它具体在时间和空间上能产出多大的优势都不知道,就感觉太过于空洞了。

于是乎!小马儿决定要调头吃草,再咀嚼一次。这次,定要把棵草咽肚子里,给它消化了。


一、★★ 时间复杂度

1. 时间频度

符号:T(n)

一个算法花费的时间与算法中语句的循环次数成正比。在一个算法中,语句的执行次数称之为语句频度,所花费的时间称之为时间频度

我们一起来看一个🌰,看看时间频度是如何计算的:

int total = 0;
int end = 100;
// 方法一:使用for循环计算
for(int i=1; i<=end; i++){
  total+=i;
}

我们都知道,这串代码用于计算 1+2+...+100 的和,但这里有个坑,提问大家一下,这串代码会执行多少次?

有多少同学,第一反应会认为执行次数是 100 次。哈哈哈哈,我一开始也是这么想的。

当 i = 100 的时候,判断 100 <= end 成立,然后执行 total += 1,这时是代码执行的第 100 次。接下来,i++,尽管 i<=end 会拦住代码的执行,但是,执行了 i++ 就表示已经开始了第 101 次执行,只不过立马就夭折了,虽然并没有执行循环体内的代码,但我们不能否定它开始了,对吧。

所以我们这串代码应该说它循环执行了 101 次。所以这个算法对应的 T(n) = n+1

我们把求和的代码修改一下:

int total = 0;
int end = 100;
// 方法二:使用前n项和的通项公式计算
total = (1+end)*end/2;

同样是计算 1~100 这 100 项的和,方法二只需要自上而下顺序执行即可,不需要任何的循环语句。这个时候呢,语句频度,或者是时间频度 T(n) = 1

2. 时间频度的特征

我们一起看一个表格

 T(n)=2n+20T(2n)T(3n+10)T(3n)
1222133
2244166
530102515
836163424
1550305545
30806010090
100220200310300
300620600910900

我们会发现,自变量 n 逐渐趋于无穷大,T(n) = 2n+20 与 T(n) = 2n 这两列的结果也在逐渐接近,T(3n+10) 也和 T(3n) 逐渐接近。

也就是说常数项对一个表达式的影响,要远小于未知项所带来的影响(我们暂时不考虑系数为分数等别的情况)这很容易理解。这个时候,我们说常数项可以忽略

我们再来看一种情况:

 T(2n^2+3n+10)T(2n^2)T(n^2+5n+20)T(n^2)
1152261
2248344
575507025
8162128125464
15505450320225
30190018001070900
10020310200001052010000

在自变量 n 逐渐增大的过程中,T(2n^2+3n+10) 和 T(2n^2) 逐渐接近,T(n^2+5n+20) 和 T(n^2) 也逐渐接近。

这一组对照,我们可以得出这样的结论:指数较大的项对表达式的影响,要远大于指数较小的项。

放到算法中,就可以说高频词比低频词对时间复杂度的影响要大很多。所以我们可以忽略低频次

其次,很多人都说系数的影响也可以忽略,这个我感觉完全不合适。距两个简单的例子:

  1. 对小数字而言,1 和 6 区别当然很大。
  2. 对于大数而言,1 百亿和 6 百亿区别当然也很大,我们让 6 百亿作为纵坐标的顶点,1 百亿还不到它的一半!

从小数到大数,我们是能说 1 和 6 接近呢?还是可以说 1 百亿和 6 百亿接近呢?完全不合适的吧~不过这也都是我个人的观点,如果大家觉得我分析的有什么不正确的地方,还请各位看官老爷多多指教。

3. 渐进时间复杂度

符号:O(n)

渐进时间复杂度(也称时间复杂度)的定义是这样的:

时间频度为 T(n),若有某个辅助函数 f(n),使得当 n 趋近于无穷大时,T(n)/f(n) 的极限值为不等于 0 的常数。则称 f(n) 为 T(n) 的同数量级函数,记作 T(n) = O(f(n)),称 O(f(n)) 为算法的渐进时间复杂度。简称时间复杂度

说的这么复杂,其实(渐进)时间复杂度,不就是要我们忽略时间频度的低次项、常数项、和系数🐎~

举个例子:T(n) = n^2+7n+6,它的时间复杂度为 O(n^2),而 T(n) = 3n^2+2n+2 的时间复杂度也是 O(n^2)

从上面的例子我们可以看出,两个算法即使时间频度不同,时间复杂度依旧是有可能相同的。

我们来看一些常见的时间复杂度(里面所有的常数只是一个例子,可以根据需求将其中的常数换成任意一个常数):

常数阶O( 1 )代码没有循环等复杂结构,只会顺序执行
对数阶O( \log _2n )常见于while循环 int i = 1; while( i<n ){ i = i*2 }
线性阶O( n )常见于单层的for循环 for( i=1; i<n; i++){ }
线性对数阶O( n\log_2n )很好理解,就是while循环和for循环结合
平方阶O( n^{2} )双层for循环
立方阶O( n^{3} )3层n循环
k次方阶O( n^{k} )类比上面两个例子
指数阶O( 2^{n} )递归

常见算法时间复杂度排序:O( 1 ) < O( \log _2n ) < O( n ) < O( n\log_2n ) < O( n^{2} ) < O( n^{3} ) < O( n^{k} ) < O( 2^{n} ) < O( n! )

从上面的图中可以看出来,这个排序,其实就是通过:当 n 趋限于无穷大的时候,对应变化速度的一个排序。

随着问题规模 n 的不断增大,一个算法的时间复杂度越高,其对应的执行效率也就越低。所以,我们要尽可能地避免指数阶的算法。

虽然要避免,但是我们还是要了解了解它到底是在什么情况下产生的。

指数阶算法

提到指数阶算法,大多数人最喜欢举的一个例子就是斐波那契数列,我们来稍微地研究一下,求解斐波那契数列的三种常用算法。

 

4. 斐波那契数列

4.1 递归

用了递归算法的斐波那契数列,其时间复杂度就满足指数阶模型:

function recFibonacci(n) {    
    if (n <= 1) {        
        return 1;
    } else {        
        return Fibonacci(n - 1) + Fibonacci(n - 2);
    }
}

我们假设用 f(n) 来表示斐波那契数列的某一项,相邻的三项之间有很明显的递推关系,所以我们可以用递推公式来表示这个关系:f(n) = f(n-1) + f(n-2) 。

递推公式对应的时间频度应该是的: T(n) = T(n-1) + T(n-2) + 1。1 表示对 T(n-1) 与 T(n-2) 的结果做加法。T(n-1)、T(n-2) 分别表示两个不相干的分支,两个分支计算完之后要做加法,自然需要再加上 1。

我们先通过递推公式,计算一下斐波那契数列的通项公式,然后通过通项公式计算时间复杂度

下面是计算通项公式的过程:

f(n) = f(n-1) + f(n-2) 是一个二阶常系数齐次线性差分方程。

【在《经济数学》的微积分章节中有提到,

形如

y_{x+1}+ay_x=f(x)

这样的式子,可以被称之为 “ 一阶常系数线性差分方程 ” 。其中,当 f(x)=0 时,方程为齐次方程;f(x)\neq 0 时为非齐次方程。

形如

y_{x+2}+ay_{x+1}+by_x=f(x)

 这样的式子为 “ 二阶常系数线性差分方程 ” 。齐次性同上。

我们把斐波那契数列的递推公式进行一个简单的变形,得到:f(n) - f(n-1) - f(n-2) = 0,可以看出来,这就是一个 “ 二阶常系数齐次线性差分方程 ” 。

“ 二阶常系数齐次线性差分方程 ” 有一套自己的求解通式,其中关键的一点,就是我们要设 y_x=\lambda ^{x},设的这个值其实是可以推导出来的,但是推导的过程比较复杂,我们这里就不拓展了。通过我们设的值,可以得到:y_{x+1}=\lambda ^{x+1} 、 y_{x+2}=\lambda ^{x+2}

于是乎, “ 二阶常系数齐次线性差分方程 ” 的递推公式就被化简成了这样:

\lambda ^{x+2}+a\lambda ^{x+1}+b\lambda ^{x}=0

再化简一下,就得到了:

\lambda ^{2}+a\lambda+b=0(齐次方程的特征方程

这个特征方程的解我们都会求,也就是:

\lambda = \frac{​{-a\pm \sqrt{a^{2}-4*1*b}}}{2*1}

两个解对应 f(x) 的通解 

f(x)=c_1\lambda_1^x+c_2\lambda_2^x

中的 \lambda _1 和 \lambda _2

带入两个\lambda 再求出  c_1 和 c_2 ,这个时候的 f(x) 也就是斐波那契数列递归算法的通项公式了。

我们向通解中带入两个已知的数据(在本题中就比如 f(0) = 0,f(1) = 1 ),即可求出 c_1 和 c_2 。将 c_1 和 c_2 再回带入通解,即可得到 f(n) 的通项公式。

于是,我们直接推导出 f(n) = f(n-1) + f(n-2) 的特征方程为:\lambda ^{2}-\lambda-1

求出特征方程的解:

\lambda = \frac{​{-(-1)\pm \sqrt{(-1)^{2}-4*1*(-1)}}}{2*1}

\lambda _1=\frac{1+\sqrt{5}}{2}, \lambda _2=\frac{1-\sqrt{5}}{2}

所以 f(n) 的通解就是:

f(n)=c_1(\frac{1+\sqrt{5}}{2})^n+c_2(\frac{1-\sqrt{5}}{2})^n

其次我们知道,f(0) = 0,f(1) = 1,代入上式就可以求出来:

c_1=\frac{1}{\sqrt{5}}, c_2=-\frac{1}{\sqrt{5}}

整理 f(n) 的通解,最终的结果就是我们想要的斐波那契数列的通项公式

f(n)=\frac{1}{\sqrt{5}}\left [ (\frac{1+\sqrt{5}}{2})^n+(\frac{1-\sqrt{5}}{2})^n \right ]=T(n)

我们找个数据代入验证一下,f(4) 的计算结果的确等于 3。一定要注意,0 是斐波那契数列的第 0 项,前面我们代数的时候,代的是 f(0) = 0,而不是 f(1) = 0。其次,通项公式也是斐波那契数列的时间频度表达式。

我们要再对其做一些处理,才可以变成我们研究的时间复杂度表达式。

首先,我们分析,随着 n 逐渐地增大,(\frac{1+\sqrt{5}}{2})^n 会变得越来越大,趋近于无穷。我们可以将(\frac{1-\sqrt{5}}{2})^n 看作 (\frac{1-2.5}{2})^n=(-\frac{3}{4})^n ,这是一个摆动函数,但由于场景的限制,n 只能取非负整数,所以函数的值域很小,仅仅处于 (-1, 1] 之间,其大小和  (\frac{1+\sqrt{5}}{2})^n 相比较,可以直接忽略,然后再去掉系数 \frac{1}{\sqrt{5},就得到了斐波那契数列的时间复杂度

f(n)=(\frac{1+\sqrt{5}}{2})^n=O(n)

我们可以看到,这个时候就满足了指数阶算法的格式: O(n)=2^n

有一些博主说递归的斐波那契时间复杂度 O(n)> (\frac{3}{2})^n 。这个比较好理解,其实就是对我们计算出来的真实时间复杂度进行了一个放缩,将\sqrt{5}缩小为2,这时 

O(n)=(\frac{1+\sqrt{5}}{2})^n>(\frac{1+2}{2})^n=(\frac{3}{2})^n

O(n)>(\frac{3}{2})^n

尽管单纯地使用“递归” ,效率会非常低,但是“递归”的每一步成果,例如:递归的递推公式、递推公式所对应的通解时间复杂度公式,都是其他方法的基石,所以,就算递归效率非常低,但我们还非学不可。

 

4.2 动态数组

这个方法总结自 leet-code 算法题库的第70题的视频,说实话,看了代码,还确实不知道和数组有什么关系,不过这个方法确实让斐波那契数列的计算效率大大地提高了很多。

function nonRecFibonacci(n) {
    if (n <= 2)
        return 1;
    else {
        var num1 = 1;
        var num2 = 1;
        for (var i = 2; i < n - 1; i++) {
            num2 = num1 + num2;
            num1 = num2 - num1;
        }
        return num1 + num2;
    }
}

时间复杂度:O(n)

 

4.3 ★★ 借助矩阵求解 ★★

时间复杂度:O(\log n)

这是三个算法中,最高效的一个,所以,一定要详细地分析一下这个算法。

中间用到了《线性代数》的一些知识,很简单,大家可以和我一起来学习一下。

在借助矩阵来求解斐波那契数列之前,我们得先讲解一个十分重要的算法:快速求幂。也就是借助位移运算符快速求解幂运算。它也是矩阵求解提升效率的核心之一。

function FastExponentiation(base, exp){
  if(exp == 0){
    return 1;
  }

  var res;

  while(exp){
    // 1. 如果指数对应的二进制数最后一位为 1,我们需要将这一位对应的数乘到变量上去。
    if( exp&1 ){  
      res *= base;
    }  

    // 2. 我们需要将指数进行位移,计算出下一位对应的结果
    // 下面这两行代码,可以依次产出3^2、3^3、3^4、3^5...3^15至于需不需要用到它,由上面的if判断来决定。
    // 当 exp 从最后一个1,再次向右移动一位,便变成了0。因为下次循环在判断到exp已经等于0了,不再需要循环了,因此我们在这时再去计算就是毫无意义的
    exp>>=1;
    if(exp){
      base*=base;
    }
  }
  return res;
}

while 为该算法的核心,我们来仔细地分析一下。

计算指数幂最高效的方法就是借助位运算。所以,核心也就是位运算的部分。

在这里,位运算的核心有两点:

  1. 指数的处理,
  2. 结果的处理。

本次方案采用:指数转化为二进制,对二进制为 1 的位,将“当前位所对应的结果”,乘到存放结果的变量上即可。
    
举个例子:3^5

其中 5 的二进制数:101。  -- 4 -- 2 -- 1 --

按照上面的说法,两个 1 从左到右分别代表着我们要计算出 3^43^1

那么,我们就需要先计算出 3^1 的具体值,然后计算出 3^4 的具体值,依次将两个数乘到变量上去。

虽然当循环经过第二位 0 时,我们并没有将该位上对应的结果 3^2 乘到变量上,但是我们依然需要借助该位,计算出下一位的结果,也就是需要借助 3^2,计算出 3^4

之所以借助位运算可以如此的高效,就是二进制相邻两位所表示的十进制数之间,存在的倍数关系:

  1      1      1      1      1      1      1      1

256  128   64    32    16     8      2      1
    
我们可以得出规律,我们想要第三位对应的值,也就是想要 3^4 对应的值,只需要按照顺序,先求出 3^1,然后平方两次即可。第一次平方,两个指数 1 相加为 2,再次平方,两个指数 2 相加为 4。
因此,想要 3^4,只需要对 3^1 循环平方两次即可。

我们知道了如何计算 3^1,也知道如何计算 3^4。那么,想要 3^5,只需要将两者乘在一起即可。

 

到这里,我们已经知道如何借助位运算求解幂运算了,是不是也并没有很难。

矩阵运算的一大难关被攻克,接下来,我们就来分析一下相对简单一些的矩阵运算逻辑:

在讲解矩阵运算之前,还是要再来回顾一下矩阵的乘法:

\begin{Bmatrix} A & B\\ C & D \end{Bmatrix}*\begin{Bmatrix} E & F\\ G & H \end{Bmatrix}=\begin{Bmatrix} AE+BG & AF+BH\\ CE+DG & CF+DH \end{Bmatrix}

由矩阵的通项公式可以知道:

\begin{Bmatrix} f(n)\\ f(n-1) \end{Bmatrix} = \begin{Bmatrix} f(n-1)+f(n-2)\\ f(n-1) \end{Bmatrix} = \begin{Bmatrix} 1*f(n-1)+1*f(n-2)\\ 1*f(n-1)+ 0*f(n-2)\end{Bmatrix}

=\begin{Bmatrix} 1 & 1\\ 1 & 0 \end{Bmatrix}*\begin{Bmatrix} f(n-1)\\ f(n-2) \end{Bmatrix} = \begin{Bmatrix} 1 & 1\\ 1 & 0 \end{Bmatrix}*\begin{Bmatrix} 1 & 1\\ 1 & 0 \end{Bmatrix}*\begin{Bmatrix} f(n-2)\\ f(n-3) \end{Bmatrix}

=\left ( \begin{Bmatrix} 1 & 1\\ 1 & 0 \end{Bmatrix}\right )^{n-1}*\begin{Bmatrix} f(1)\\ f(0) \end{Bmatrix}

=\left ( \begin{Bmatrix} 1 & 1\\ 1 & 0 \end{Bmatrix}\right )^{n-1}*\begin{Bmatrix} 1\\ 0 \end{Bmatrix}

这个时候,幂运算就出来了。

你可能会有一些疑惑,这些都是矩阵的幂运算,而我们之前写的都是整数的幂运算。

其实,我们只需要对我们自己写的快速求幂的算法进行简单的修改,就可以对矩阵求幂了。

回看我们写的算法,针对指数的处理,不论是一般的整数还是矩阵,处理思路都是不变的。而对于我们传进去的底数,进行的乘法运算,并不适用于矩阵。

那么问题就变简单了,我们只要去写一个矩阵乘法的函数就行了:

/**
 * 
 * @param {*} matrix1 做乘法的第一个矩阵
 * @param {*} matrix2 做乘法的第二个矩阵
 * @variable {*} Multiplication_result_matrix 保存两个矩阵 做乘法 所得结果 的一个矩阵
 */
function MatrixMultiplication(matrix1, matrix2){
  var Multiplication_result_matrix=[[0, 0], [0, 0]];
  Multiplication_result_matrix[0][0] = matrix1[0][0]*matrix2[0][0] + matrix1[0][1]*matrix2[1][0];
  Multiplication_result_matrix[0][1] = matrix1[0][0]*matrix2[0][1] + matrix1[0][1]*matrix2[1][1];
  Multiplication_result_matrix[1][0] = matrix1[1][0]*matrix2[0][0] + matrix1[1][1]*matrix2[1][0];
  Multiplication_result_matrix[1][1] = matrix1[1][0]*matrix2[0][1] + matrix1[1][1]*matrix2[1][1];
  return Multiplication_result_matrix;
}

变量名可能有点长,主要是为了好看懂。

然后,我们把矩阵幂运算的算法修改一下:

/**
 * 
 * @param {*} Standard_matrix 传入的标准矩阵
 * @param {*} power 对标准矩阵所求的次方
 * @variable {*} Power_result_matrix 初始值为一个单位矩阵,后面用来保存每次运算的结果
 */
function MatrixPower(StandardMatrix, power){
  var Power_result_matrix = [[1, 0], [0, 1]];  // 单位矩阵
  while(power){
    if(power & 1){
      Power_result_matrix = MatrixMultiplication(Power_result_matrix, StandardMatrix);
    }
    power >>= 1;
    if(power){
      StandardMatrix = MatrixMultiplication(StandardMatrix, StandardMatrix);
    }
  }
  return Power_result_matrix;
}

到这里就完事了,是不是脑袋瓜 wong~ wong~ wong~ 的,确实有点绕,最开始我也在纠结要不要去看这种方法的时候,注意到了它的时间复杂度。的确是一个很高效的算法,所以,确实有必要去花点时间去钻研一下。

加油!我相信你也可以的!

再把整个代码放上去,给大家做个参考:

function FibonacciSequence(a){
  if( a==1 ){
    return 1;
  }else if( a==2 ){
    return 1;
  }
  var Standard_matrix = [[1, 1], [1, 0]];  // 标准矩阵,进行乘方用的
  var result_matrix = MatrixPower(Standard_matrix, a-1);
  console.log( result_matrix );
  return result_matrix[0][0];
  
}

/**
 * 矩阵快速幂运算
 * @param {*} Standard_matrix 传入的标准矩阵
 * @param {*} power 对标准矩阵所求的次方
 */
function MatrixPower(StandardMatrix, power){
  var Power_result_matrix = [[1, 0], [0, 1]];  // 单位矩阵
  while(power){
    if(power & 1){
      Power_result_matrix = MatrixMultiplication(Power_result_matrix, StandardMatrix);
    }
    power >>= 1;
    if(power){
      StandardMatrix = MatrixMultiplication(StandardMatrix, StandardMatrix);
    }
  }
  return Power_result_matrix;
}

/**
 * 矩阵乘法
 * @param {*} matrix1 做乘法的第一个矩阵
 * @param {*} matrix2 做乘法的第二个矩阵
 */
function MatrixMultiplication(matrix1, matrix2){
  var Multiplication_result_matrix=[[0, 0], [0, 0]];
  Multiplication_result_matrix[0][0] = matrix1[0][0]*matrix2[0][0] + matrix1[0][1]*matrix2[1][0];
  Multiplication_result_matrix[0][1] = matrix1[0][0]*matrix2[0][1] + matrix1[0][1]*matrix2[1][1];
  Multiplication_result_matrix[1][0] = matrix1[1][0]*matrix2[0][0] + matrix1[1][1]*matrix2[1][0];
  Multiplication_result_matrix[1][1] = matrix1[1][0]*matrix2[0][1] + matrix1[1][1]*matrix2[1][1];
  return Multiplication_result_matrix;
}
console.log( "result:"+FibonacciSequence(6) )

下面的东西就不是太重要了,大家可以粗略地看一下,注意要仔细看一下为什么空间复杂度就远不如时间复杂度重要。

 

5. 平均时间复杂度 & 最坏时间复杂度

平均时间复杂度是指:所有不同的情况,均以等概率出现的情况下,计算出来的主要时间。

最坏时间复杂度是指:在最坏的情况下,所需要的主要时间。 

我们来看一下各个算法的复杂度情况:

我们讨论的复杂度均是最坏情况下的时间复杂度,它是任何输入实例上运行时间的界限。这样就可以保算法的运行时间不会比最坏时间更长。

 

二、空间复杂度

定义:一个算法所耗费的存储空间。它也是问题规模 n 的函数。

有的算法要占用的临时工作单元数与解决问题的规模 n 有关,它随着 n 的增大而增大,例如快速排序和归并排序。

在做算法分析时,主要讨论的是时间复杂度,从用户使用体验上看,程序执行的速度更重要。在硬件方面,发展可谓是相当之快,所以单纯站在用户角度来说,空间往往是有剩余的。并且,一些缓存产品(例如 redis、memcache)以及一些算法(例如基数排序)的本质就是空间换时间。所以我们不用过多考虑空间复杂度。重点还是在时间复杂度上。

 

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

麦田里的POLO桔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值