一、算法效率
1.如何衡量一个算法的好坏
在学习算法之前,我们首先需要知道如何衡量算法的好坏。
即需要对算法的效率进行分析,只有分析出算法的效率,我们才能从众多算法中选出优秀的算法来解决问题。
算法效率的分析分为对时间效率和对空间效率的分析。
比如我们想知道斐波那契数列中的第n个数字是多少:
long long Fib(int N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
斐波那契数列的递归实现方式非常简洁,但简洁一定好吗?那该如何衡量其好与坏呢?
2.算法的复杂度
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
- 时间复杂度主要衡量一个算法的运行快慢。
- 空间复杂度主要衡量一个算法运行所需要的额外空间。
在计算机发展的早期,计算机的存储容量很小且很贵,所以对算法的空间复杂度很在意。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不再特别关注一个算法的空间复杂度。
但是单个CPU的计算性能已经开发了到极致,若提升计算机的运算处理能力,只能追求多处理器(多核CPU)。我们为了减少CPU的计算量,对于算法时间效率的优化还是有持续地追求,因此依旧关注一个算法的时间复杂度。
Note:摩尔定律
戈登·摩尔是著名芯片公司Intel的创始人之一。
摩尔定律的内容为:当价格不变时,集成电路上可容纳的晶体管数目,约每隔18个月便会增加一倍,性能也将提升一倍。换言之,每一美元所能买到的电脑性能,将每隔18个月翻两倍以上。
这一定律揭示了信息技术进步的速度。所以我们的电脑内存现在越来越大,内存越来越便宜,我们的程序多占一点点内存也无所谓,不太关注内存。
3.复杂度在校招中的考察
解答以上问题,前提我们得知道什么是复杂度,怎么算复杂度。
二、时间复杂度
1.时间复杂度的概念
在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。
Note:
①这里的函数不是C语言的函数子程序,而是数学里面带有未知数的函数表达式。
②时间复杂度并不是去统计我们这个算法在电脑中运行了多少秒,这是为什么呢?
比如写了一个冒泡排序,对100w个数进行排序。分别放在2核cpu、2g内存的机器上与8核cpu、8g内存的机器上去执行,他们运行的时间显然不一样。
该例子说明同一个算法在不同的环境下时间是不一样的,运行的具体时间和机器有关,但是我们并没有规定标准的机器是什么,所以没办法去计算准确的时间。
所以算法的时间复杂度不是上机运行该算法看具体的运行时间,而是去手头计算该算法的执行次数。
③对100w个数进行排序,算法1的基本操作执行了100w次;算法2的基本操作执行了100*100w次
显然算法1运算速度更快;我们这样可以脱离环境,直观比较算法的优劣好坏。
例子:下面请计算一下Func1中++count语句总共执行了多少次?
void Func1(int N)
{
int count = 0;
for (int i = 0; i < N; ++i)
{
for (int j = 0; j < N; ++j)
{
++count;
}
}
for (int k = 0; k < 2 * N; ++k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
可以看出,基本语句++count的执行次数是一个和问题规模N相关的函数式,Func1执行的基本操作次数为。
10 | 130 |
100 | 10210 |
1000 | 1002010 |
2.大O的渐进表示法
Func1中我们发现越大,后两项对结果的影响越小,的占比越大。在实际计算中,我们只关注影响最大的项,可以将该表达式简化为。
实际中我们计算时间复杂度时,其实并不需要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法。
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。(估算)
推导大O阶方法:
- 用常数1取代运行时间中的所有加法常数。
- 在修改后的运行次数函数中,只保留最高阶项。
- 如果最高阶项存在且不是1,则去除与这个项目相乘的常数。
最终得到的结果就是大O阶。
使用大O的渐进表示法以后,Func1的时间复杂度为。
10 | 100 |
100 | 10000 |
1000 | 1000000 |
通过上面我们会发现大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了地表示出了执行次数。
另外有些算法的时间复杂度存在最好、平均和最坏情况:
- 最坏情况:任意输入规模的最大运行次数(上界)
- 平均情况:任意输入规模的期望运行次数
- 最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
在实际中一般关注算法的最坏运行情况,所以数组中搜索数据时间复杂度为。
3.常见时间复杂度计算举例
实例1
// 计算Func2的时间复杂度?
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 2 * N; ++k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
基本语句++count的精确执行次数是;越大,对结果的影响越小;且和在同一个量级,故算法的时间复杂度为。
实例1基本操作执行了次,通过推导大O阶方法知道,时间复杂度为。
实例2
// 计算Func3的时间复杂度?
void Func3(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++k)
{
++count;
}
for (int k = 0; k < N; ++k)
{
++count;
}
printf("%d\n", count);
}
一般情况下,时间复杂度计算时未知数都是用的,但是也可以是、等其他的字母。
问题规模和大小关系未知,我们就认为++count基本操作的执行次数是。
但如果这道题加上条件:
条件 | 时间复杂度 |
M远大于N | |
M远小于N | |
M和N差不多大 |
如果没有说明M和N的大小关系,就是。
实例2基本操作执行了次,有两个未知数和,时间复杂度为。
实例3
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k)
{
++count;
}
printf("%d\n", count);
}
没有未知数,++count的精确执行次数为;用常数1代表所有加法常数,时间复杂度为。
Note:在以后刷LeetCode题目时,把算法优化到时间复杂度,并不是说你只能运算1次,而是你只能运算常数次。
实例3基本操作执行了100次,通过推导大O阶方法,时间复杂度为。
实例4
// 计算strchr的时间复杂度?
const char* strchr(const char* str, int character);
strchr()用于查找字符串中的一个字符,并返回该字符在字符串中第一次出现的位置。
实例1、2、3算法规模一样的话结果永远都是一样的,不会随着情况的变化而变化。但是实例4算法规模一样时它的结果是有变化的。
比如字符串为"Byte Dance":
查找目标字母 | 最好、平均和最坏情况 | 时间复杂度 |
B | 最好情况:任意输入规模的最小运行次数(下界) | |
D | 平均情况:任意输入规模的期望运行次数 | |
e | 最坏情况:任意输入规模的最大运行次数(上界) |
实例4基本操作执行最好1次,最坏N次,时间复杂度一般看最坏,时间复杂度为。
Note:
①如果一个字符串数组长度不知道,我们就认为它的长度(问题规模)是。
②当一个算法问题规模一样时,随着输入不同,时间复杂度不同,时间复杂度做悲观预期,看最坏的情况。
比如说你写了一个算法,你的领导问你算法怎么样,最好1秒钟跑出来,最坏10秒钟跑出来,你难道直接给老板报最好的结果吗?往往会打脸,所以我们时间复杂度只做悲观的预期,那我们的结果只会有惊喜。
③平均情况量级和最坏情况量级一样,有什么意义吗?
大部分情况我们不会看平均情况。但是有希尔排序我们看平均情况,它很少出现最坏情况,并且最坏情况的时间复杂度函数式太复杂了,所以我们在希尔排序算平均复杂度。
99%情况下我们去看最坏情况,个别情况下我们看平均情况(这种情况基本上都是套用特别复杂的数学公式去算)。
实例5
// 计算BubbleSort的时间复杂度?
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
冒泡排序是排序算法里面比较简单的一个排序。它重复地走访要排序的数列,一次比较两个数据元素,如果顺序不对则进行交换,并一直重复这样的走访操作,直到没有要交换的数据元素为止。
最坏情况下,第一趟比较了次,第二趟比较次,第三趟比较次,······,最后一趟比较次。
等差数列求和共比较了次,时间复杂度为。
因此实例5基本操作最好执行了次,最坏执行了次,通过推导大O阶方法+时间复杂度一般看最坏,时间复杂度为。
实例6
// 计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n - 1;
while (begin <= end)
{
int mid = (begin + end) / 2;
if (a[mid] < x)
begin = mid + 1;
else if (a[mid] > x)
end = mid - 1;
else
return mid;
}
return -1;
}
二分查找思想:假设有一升序的数据集合,先找出升序集合中最中间的元素,将数据集合划分为两个子集,将最中间的元素和关键字key进行比较,如果等于key则返回,如果大于关键字key,则在前一个数据集合中查找,否则在后一个子集中查找,直到找到为止,如果没找到则返回-1。
二分查找的时间复杂度不能去看代码的几层循环了,而是要去看该算法的思想。
最好的情况:查找元素在数组中间,一次就找到了。
最坏的情况:找不到或begin=end时是最坏的情况。假设一个数组长度为N,每次查找后数组长度减半,第一次查找后数组长度为,第二次查找后数组长度为,第k次查找后数组长度为,最坏情况下数组长度为1时找到该数或找不到,即=1,解得。
因此实例6基本操作执行最好1次,最坏次,时间复杂度为。
Note:二分查找算法是一个非常牛逼的算法,时间复杂度为。
N 大概查找次数 1000 10次(2^10=1024) 100w 20次 10亿 30次 华人同胞有14亿,2^31约为20亿,查找一个人最多31次。31次对于我们现在的cpu就跟玩一样。
但是二分查找有缺陷,数据要有序,排序的消耗很大。
实例7
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
递归一次创建一次栈帧,递归了N次,创建N个栈帧。
递归算法的时间复杂度计算:总递归次数*每次递归基本操作执行的次数
因此实例7通过计算分析发现基本操作递归了N次,时间复杂度为。
实例8
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
下面假设问题规模N为5,方便研究。
第一层递归2^0次,第二层递归2^1次,第三层递归2^2次,第四层与第五层有缺失,分别递归6次和2次。(第x层若是满的,则第x层递归2^(x-1)次。)
因此当问题规模为N时,总共递归了2^0+2^1+2^2+…+2^(n-1)-x次(x为右下角提前递归结束的部分,影响不大,可以忽略不计)。
故斐波那契递归的时间复杂度为。
Note:在实际生活中,斐波那契的递归写法完全是一个没用的算法,因为求值效率太慢了。所以斐波那契计算不要用递归,要用循环。
因此实例8通过计算分析发现基本操作递归了次,时间复杂度为。
三、空间复杂度
1.空间复杂度的概念
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时的额外占用存储空间大小的量度。
空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。
Note:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
2.常见空间复杂度计算举例
实例1
// 计算BubbleSort的空间复杂度?
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
数组a和数组规模n是算法本身需要的,不是额外的空间。
虽然说每次循环重新创建变量i,但是上一个循环变量i已经结束销毁,下一次循环定义的i还是原来的空间。因此额外占用的空间F(N)为常数级,即。
实例1使用了常数个额外空间,所以空间复杂度为。
实例2
// 计算Fibonacci的空间复杂度?
// 返回斐波那契数列的前n项
long long* Fibonacci(size_t n)
{
if (n == 0)
return NULL;
long long* fibArray = (long long*)malloc((n + 1) * sizeof(long long));
fibArray[0] = 0;
fibArray[1] = 1;
for (int i = 2; i <= n; ++i)
{
fibArray[i] = fibArray[i - 1] + fibArray[i - 2];
}
return fibArray;
}
返回前n项,开辟了一个n+1空间的数组,即。
实例2动态开辟了n+1个空间,空间复杂度为。
Note:
①斐波那契的循环的时间复杂度,比递归效率提高了很多。
②如果不是求数组,而是求斐波那契第n个数,空间复杂度就是。
③斐波那契递归实现的空间复杂度不是,而是。
Fib(N)要递归Fib(N-1)和Fib(N-2),但是要先递归调用Fib(N-1),同理往下推。即先递归调用第一列,第一列调用完之后,所创建的栈帧会被销毁,让别的递归调用创建栈帧,重复利用不会累计。后面的每一列用的空间不会比第一列创建的栈帧多,最多建立N个栈帧,因此最大只会开辟N个空间。
空间是可以重复利用,不累计的;时间是一去不复返的,累积的。
实例3
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if (N == 0)
return 1;
return Fac(N - 1) * N;
}
递归调用了N次,建立了N个栈帧,每个栈帧使用了常数个空间。因此空间复杂度为。
递归算法的空间复杂度计算:需要看栈帧的消耗,即递归的深度
实例3递归调用了N次,开辟了N个栈帧,每个栈帧使用了常数个空间。空间复杂度为。
四、常见复杂度对比
一般算法常见的复杂度如下: