先来啰嗦两句,这两个复杂度在很久之前就略有了解,但写了几十道算法题了,依然对这两个参数是略有了解。因此,每次写算法题时,对比一道题不同解法效率上的差别,在脑海中分析起来就很无力。个人感觉这非常致命,学习算法的初衷就在于想要用它对代码进行一定的优化,但写了这么多,连它具体在时间和空间上能产出多大的优势都不知道,就感觉太过于空洞了。
于是乎!小马儿决定要调头吃草,再咀嚼一次。这次,定要把棵草咽肚子里,给它消化了。
一、★★ 时间复杂度
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+20 | T(2n) | T(3n+10) | T(3n) | |
---|---|---|---|---|
1 | 22 | 2 | 13 | 3 |
2 | 24 | 4 | 16 | 6 |
5 | 30 | 10 | 25 | 15 |
8 | 36 | 16 | 34 | 24 |
15 | 50 | 30 | 55 | 45 |
30 | 80 | 60 | 100 | 90 |
100 | 220 | 200 | 310 | 300 |
300 | 620 | 600 | 910 | 900 |
我们会发现,自变量 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) | |
---|---|---|---|---|
1 | 15 | 2 | 26 | 1 |
2 | 24 | 8 | 34 | 4 |
5 | 75 | 50 | 70 | 25 |
8 | 162 | 128 | 1254 | 64 |
15 | 505 | 450 | 320 | 225 |
30 | 1900 | 1800 | 1070 | 900 |
100 | 20310 | 20000 | 10520 | 10000 |
在自变量 n 逐渐增大的过程中,T(2n^2+3n+10) 和 T(2n^2) 逐渐接近,T(n^2+5n+20) 和 T(n^2) 也逐渐接近。
这一组对照,我们可以得出这样的结论:指数较大的项对表达式的影响,要远大于指数较小的项。
放到算法中,就可以说高频词比低频词对时间复杂度的影响要大很多。所以我们可以忽略低频次。
其次,很多人都说系数的影响也可以忽略,这个我感觉完全不合适。距两个简单的例子:
- 对小数字而言,1 和 6 区别当然很大。
- 对于大数而言,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( ) | 常见于while循环 int i = 1; while( i<n ){ i = i*2 } |
线性阶 | O( n ) | 常见于单层的for循环 for( i=1; i<n; i++){ } |
线性对数阶 | O( ) | 很好理解,就是while循环和for循环结合 |
平方阶 | O( ) | 双层for循环 |
立方阶 | O( ) | 3层n循环 |
k次方阶 | O( ) | 类比上面两个例子 |
指数阶 | O( ) | 递归 |
常见算法时间复杂度排序:O( 1 ) < O( ) < O( n ) < O( ) < O( ) < O( ) < O( ) < O( ) < 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) 是一个二阶常系数齐次线性差分方程。
【在《经济数学》的微积分章节中有提到,
形如
这样的式子,可以被称之为 “ 一阶常系数线性差分方程 ” 。其中,当 时,方程为齐次方程; 时为非齐次方程。
形如
这样的式子为 “ 二阶常系数线性差分方程 ” 。齐次性同上。
我们把斐波那契数列的递推公式进行一个简单的变形,得到:f(n) - f(n-1) - f(n-2) = 0,可以看出来,这就是一个 “ 二阶常系数齐次线性差分方程 ” 。
“ 二阶常系数齐次线性差分方程 ” 有一套自己的求解通式,其中关键的一点,就是我们要设 ,设的这个值其实是可以推导出来的,但是推导的过程比较复杂,我们这里就不拓展了。通过我们设的值,可以得到: 、 。
于是乎, “ 二阶常系数齐次线性差分方程 ” 的递推公式就被化简成了这样:
再化简一下,就得到了:
(齐次方程的特征方程)
这个特征方程的解我们都会求,也就是:
两个解对应 f(x) 的通解
中的 和 。
带入两个 再求出 和 ,这个时候的 也就是斐波那契数列递归算法的通项公式了。
我们向通解中带入两个已知的数据(在本题中就比如 f(0) = 0,f(1) = 1 ),即可求出 和 。将 和 再回带入通解,即可得到 f(n) 的通项公式。
】
于是,我们直接推导出 f(n) = f(n-1) + f(n-2) 的特征方程为:。
求出特征方程的解:
,
所以 f(n) 的通解就是:
其次我们知道,f(0) = 0,f(1) = 1,代入上式就可以求出来:
,
整理 f(n) 的通解,最终的结果就是我们想要的斐波那契数列的通项公式:
我们找个数据代入验证一下,f(4) 的计算结果的确等于 3。一定要注意,0 是斐波那契数列的第 0 项,前面我们代数的时候,代的是 f(0) = 0,而不是 f(1) = 0。其次,通项公式也是斐波那契数列的时间频度表达式。
我们要再对其做一些处理,才可以变成我们研究的时间复杂度表达式。
首先,我们分析,随着 n 逐渐地增大, 会变得越来越大,趋近于无穷。我们可以将 看作 ,这是一个摆动函数,但由于场景的限制,n 只能取非负整数,所以函数的值域很小,仅仅处于 (-1, 1] 之间,其大小和 相比较,可以直接忽略,然后再去掉系数 ,就得到了斐波那契数列的时间复杂度:
我们可以看到,这个时候就满足了指数阶算法的格式: 。
有一些博主说递归的斐波那契时间复杂度 。这个比较好理解,其实就是对我们计算出来的真实时间复杂度进行了一个放缩,将缩小为2,这时
即
尽管单纯地使用“递归” ,效率会非常低,但是“递归”的每一步成果,例如:递归的递推公式、递推公式所对应的通解、时间复杂度公式,都是其他方法的基石,所以,就算递归效率非常低,但我们还非学不可。
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;
}
}
时间复杂度:
4.3 ★★ 借助矩阵求解 ★★
时间复杂度:
这是三个算法中,最高效的一个,所以,一定要详细地分析一下这个算法。
中间用到了《线性代数》的一些知识,很简单,大家可以和我一起来学习一下。
在借助矩阵来求解斐波那契数列之前,我们得先讲解一个十分重要的算法:快速求幂。也就是借助位移运算符快速求解幂运算。它也是矩阵求解提升效率的核心之一。
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 的位,将“当前位所对应的结果”,乘到存放结果的变量上即可。
举个例子:
其中 5 的二进制数:101。 -- 4 -- 2 -- 1 --
按照上面的说法,两个 1 从左到右分别代表着我们要计算出 、。
那么,我们就需要先计算出 的具体值,然后计算出 的具体值,依次将两个数乘到变量上去。
虽然当循环经过第二位 0 时,我们并没有将该位上对应的结果 乘到变量上,但是我们依然需要借助该位,计算出下一位的结果,也就是需要借助 ,计算出 。
之所以借助位运算可以如此的高效,就是二进制相邻两位所表示的十进制数之间,存在的倍数关系:
1 1 1 1 1 1 1 1
256 128 64 32 16 8 2 1
我们可以得出规律,我们想要第三位对应的值,也就是想要 对应的值,只需要按照顺序,先求出 ,然后平方两次即可。第一次平方,两个指数 1 相加为 2,再次平方,两个指数 2 相加为 4。
因此,想要 ,只需要对 循环平方两次即可。
我们知道了如何计算 ,也知道如何计算 。那么,想要 ,只需要将两者乘在一起即可。
到这里,我们已经知道如何借助位运算求解幂运算了,是不是也并没有很难。
矩阵运算的一大难关被攻克,接下来,我们就来分析一下相对简单一些的矩阵运算逻辑:
在讲解矩阵运算之前,还是要再来回顾一下矩阵的乘法:
由矩阵的通项公式可以知道:
这个时候,幂运算就出来了。
你可能会有一些疑惑,这些都是矩阵的幂运算,而我们之前写的都是整数的幂运算。
其实,我们只需要对我们自己写的快速求幂的算法进行简单的修改,就可以对矩阵求幂了。
回看我们写的算法,针对指数的处理,不论是一般的整数还是矩阵,处理思路都是不变的。而对于我们传进去的底数,进行的乘法运算,并不适用于矩阵。
那么问题就变简单了,我们只要去写一个矩阵乘法的函数就行了:
/**
*
* @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)以及一些算法(例如基数排序)的本质就是空间换时间。所以我们不用过多考虑空间复杂度。重点还是在时间复杂度上。