目录
前言:
在我们之前有时候会提到O(N)这样的概念,那么这个O(N)到底是什么呢?我们今天就来学习了解一下。
1. 算法
在我们了解时间复杂度与空间复杂度的时候,我们首先要了解什么是算法?
算法(Algorithm): 就是定义良好的计算过程,他取一个或一组的值为输入,并产生处一个或一组值作为输出。 简单来说算法就是一系列的计算步骤,用来将输入数据转换成输出结果。
1.1 算法的复杂度
当我们了解了算法之后,我们怎么样来判断一个算法的好坏呢?
这里我们斐波那契数列的递归与迭代进行比较?
递归方法:
long long Fib(int n)
{
if (n < 3)
return 1;
else
return Fib(n - 1) + Fib(n - 2);
}
迭代方法:
long long Fib(int n)
{
int a = 1;
int b = 1;
int c = 1;
while (n>2)
{
c = a + b;
a = b;
b = c;
n--;
}
return c;
}
我们看见递归方法写的非常简洁,但是简洁的代码一定好吗?其实不然。
这里我们就需要从算法的复杂度来进行分析了。
什么是算法的复杂度?
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。
2. 时间复杂度
2.1 时间复杂度概念
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
这里很多人就有一个误区,顾名思义的以为时间复杂度是一个算法执行完时所需要花费的时间。
其实这是错误的。
举个例子:
比如说我们想要知道一个算法跑了多少秒,假设你的电脑是近年买的,我的电脑是十年前买的,那么就会有差异,算法的运行速度跟电脑的配置有关系,我们就没有办法具体算出算法跑了多少秒,我们也没有办法来定义一个标准的电脑。
2.2 计算时间复杂度
// 请计算一下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);
}
在这里Func1中有3个循环,根据我们上面的时间复杂度概念可知,循环执行了多少次,就是我们的算法的时间复杂度。
第一段循环:第一段循环里面的for循环里面嵌套了一层for循环,上面循环执行一次,下面循环执行N次,所以上面循环执行了N次,下面循环就要执行N*N次,所以第一段循环++count执行了N*N次。
第二段循环:for循环里面++count执行了2*N次。
第三段循环:规定了M为10,所以++count执行了10次
所以时间复杂度则为: F(N) = N²+ 2*N + 10
但是呢!我们在实际计算时间复杂度的过程时,其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法。
2.3 大O渐进法
什么是大O渐进法呢?
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
推导大O渐进法的方法:
1. 用常数1取代运行时间中的所有加法常数。
2. 在修改后的运行次数函数中,只保留最高阶项。
3. 如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。本质上:
那么我们使用大O渐进法之后Func1的时间复杂度就是O(N²)
为什么呢?
当Func1的时间复杂度为F(N) = N²+ 2*N + 10时。
当我们学会了用大O渐进法来估算时间复杂度时,我们就可以判断一个算法的快慢了。
举例:
这里我们通过大O渐进法观察,可以得出,中间的算法最快,最上面的算法其次,最下面的算法最慢。
这里有人就有疑惑了,当N很小的时候,不就反过来了吗?其实呢,当N很小的时候,这3个算法的速度没有差异,为什么呢?
因为我们电脑的cpu跑的非常的快(即使是再差的CPU,它的执行次数也能达到每秒上亿次),所以当N很小的时候运算速度没有差异。
这里我们可以举例验证一下:
#include<stdio.h>
#include<time.h>
int main()
{
int n = 100000000;//1亿
int x = 10;
int begin = clock();
for (int i = 0; i < n; i++)
{
x++;
}
int end = clock();
printf("%d\n", x);
printf("%d ms\n", end - begin);
}
这里for循环了1亿次,运行时间为61毫秒。所以说,当N太小的时候,其实3个算法的运算速度没有差异
补充:
clock()函数:
头文件:time.h
作用:用来捕捉从程序开始运行到clock()被调用时所耗费的时间(单位是毫秒)。
这里我们有一个需要注意的点
#include<time.h>
int main()
{
int n = 10000;//1万
int x = 10;
int begin = clock();
for (int i = 0; i < n; i++)
{
x++;
}
int end = clock();
printf("%d\n", x);
printf("%d ms\n", end - begin);
}
这里我们发现结果是0毫秒,我们就可能理解为0毫秒是没有消耗,其实不然,0毫秒不是没有消耗,而是指的是消耗比1毫秒还要小。
2.4 常见的时间复杂度计算举例
通过上面时间复杂度的概念我们可能不是那么容易理解,我们计算几个简单的时间复杂度例子来加深一下我们的理解。
题目一:
// 计算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);
}
这个代码的时间复杂度为0(N)。
解析
这个代码有两个循环,第一个循环++count了2*N次,第二个循环++count了10次,这里我们知道 F(N) = 2*N+10,那我们通过大O渐进法就可以写成O(N),这里2*N里面的2影响也很大啊。
其实呢,大O渐进表示法计算的是算法时间复杂度属于哪个量级的。
我们这里来拿财富来举例,比如说张三有一千亿,李四有两千亿,那么它们两其实没有区别,为什么呢?因为李四能买的起的东西,张三也能买的起,所以说,当N无限大的时候,那么N前面系数就对N没有那么大的影响了。
题目二:
// 计算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);
}
这个代码的时间复杂度为0(M+N)
解析
这个代码的第一个循环++count了M次,第二个循环++count了N次,所以我们知道了F(N) = M+N;这里有两个未知数,我们用大O渐进表示法就是O(M+N);这里如果明确的表示了M>N的话,那么时间复杂度就可以写成O(M);如果明确表示了M<N的话,那么时间复杂度就可以写成O(N)。
题目三:
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
这个代码的时间复杂度为O(1)
解析:
很多人看见这个代码里面的循环只循环了100次,就以为时间复杂度为O(100),那如果这个循环跑了3次,5次,8次,那么我们就写成O(3),O(5),O(8)吗?,那么这里的O(8)就和韩国的那个欧巴就容易分不清楚了,所以大O渐进法就规定了,用O(1)来表示常数次的循环。
题目四:
// 计算strchr的时间复杂度?
const char* strchr(const char* str, int character)
{
while (str)
{
if (*str == character)
{
return str;
}
else
str++;
}
}
这个代码的时间复杂度为0(N)
解析:
其实这个代码是在一个字符数组里面寻找一个字符,这个代码的时间复杂度可以分为三种情况。
情况1:查找一次就找到了 时间复杂度O(1)
情况2:查找了N\2次找到了 时间复杂度O(N\2),其实还是O(N)
情况3:查找了N次找到了 时间复杂度O(N)
在实际中一般情况关注的是算法运行最坏的情况,所以在数组中查找数据的时间复杂度为0(N)。
因为一般给你一个比较低的预期,可能会有惊喜的收获。时间复杂度就是这样的。
题目五:
// 计算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;
}
}
这个代码的时间复杂度为O(N^2)
这里如果我们通过数循环次数,可以看出,这个代码有两个循环,每个循环都循环了N次,所以它的时间复杂度为O(N^2),但是更加细致的计算应该是,我们了解冒泡排序的思路,是两两相比,前面的值比后面的大就进行交换,冒泡排序第一趟都是N-1,然后是N-2,N-3...1,0
我们把次数相加,这个趟数是一个等差数列,我们知道等差数列的求和公式是首相+尾项*项数除以2,那我们就可以写成(N-1+0)*N\2,结果为(N^2-N) \2,去掉影响不大的项,那么时间复杂度就为O(N^2)
题目六:
// 计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n-1;
// [begin, end]:begin和end是左闭右闭区间,因此有=号
while (begin <= end)
{
int mid = begin + ((end-begin)>>1);
if (a[mid] < x)
begin = mid+1;
else if (a[mid] > x)
end = mid-1;
else
return mid;
}
return -1;
}
这个代码的时间复杂度为O(㏒2N)
这个代码就不能数循环来判断时间复杂度了,因为我们知道2分查找,是查找一次,直接减去一半的数据,那么查找一次,就是N\2,查找两次就是N\4...,情况最好是查找一次就找到了时间复杂度是0(N),最坏的情况是查到数组只剩一个数据,或者找不到了,那么我们计算这个时间复杂度就是算查找了多少次,就除以了多少个2, 假设我们查找了x次,那么就是2^x = N,那么N = ㏒2N,时间复杂度就是O(㏒2N)。
题目七:
//计算func5的时间复杂度
void func5(int n)
{
int x = 0;
for (int i = 1; i < n; i *= 2)
{
++x;
}
printf("%d\n", x);
}
这个代码的时间复杂度也是O(Log2N)
解析
这个代码其实数循环容易出错的,但是这个代码有个规律,1*2*2*2*2 = N。假设这个代码走了x次,那么乘了x个2,就是2*x = N,跟上面的那个代码一样,x = log2N,时间复杂度就是
O(log2N)。
在这里补充一下,时间复杂度当中,为了方便,long2N可以直接写成logN,但是如果不是以2为底数的数,就不能省略。(但是其他底数很少出现)。
题目八:
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if(0 == N)
return 1;
return Fac(N-1)*N;
}
这个递归代码的时间复杂度为O(N)
解析:
我们首先要了解递归的思想,是将大的问题化成一个个小的问题来解决,那么这个代码就是
Fac(N-1)*N,Fac(N-2)*N,Fac(N-3)*N...Fac(0)*N, 递归了多少次,N+1次,去掉影响不大的项,那么它的时间复杂度就是O(N)。
// 计算斐波那契递归Fib的时间复杂度? long long Fib(size_t N) { if(N < 3) return 1; return Fib(N-1) + Fib(N-2); }
小知识:递归代码的时间复杂度:所有递归次数的累加
题目九:
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
这个代码的时间复杂度是:O(2^n)
解析:
我们知道递归的时间复杂度是所有递归调用次数的累积,那么我们直接算调用了多少次就能得到这个代码的时间复杂度。
其实这个计算这个代码的时间复杂度是缺失了了一部分,但是不是很影响,这个代码是O(2^n)这个量级的
其实呢,这个代码写成O(2^N)这样,只有理论意义,实践中太慢了。
3. 空间复杂度
3.1 空间复杂度概念
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度 。
空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。简单来说,时间复杂度算的不是时间,而是次数,空间复杂度算的不是空间,而是算的是变量个数
这里就有人会有疑问了,我使用了1字节的空间,你使用了100字节的空间,这样还不大,其实这样也不大,我们知道1M = 1024KB,1KB = 1024Byte,那么1M = 1024*1024Byte,1GB = 1024M,
1GB = 1024*1024*1024字节,其实影响就没有那么大了。
3.2 常见的空间复杂度计算举例
题目一:
// 计算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;
}
}
这个代码的时间复杂度为0(N^2),空间复杂度为O(1)
解析:
我们根据空间复杂度的概念得知,空间复杂度其实是算变量的个数,那么这个冒泡排序中,使用了size_t end,int exchange,size_t i这3个变量,是常数次变量,所以它的空间复杂度就是O(1)。有人就有疑问了,说这个数组a算不算呢?其实是不算的,空间复杂度是计算解决这个问题时,算法中额外开辟的空间。
题目二:
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if(N == 0)
return 1;
return Fac(N-1)*N;
}
计算阶乘的代码时间复杂度为0(N),空间复杂度也是O(N)
解析:
这里,递归的思路是F(N),F(N-1),F(N-2),...FN(1),F(0),每次递归的时候都会创建一个栈帧空间,那么递归了多少次,就创建了多少个栈帧空间,递归了N+1次,那么就调用了N+1次的空间,所以它的空间复杂度是O(N)。
题目三:
// 计算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;
}
这个代码的时间复杂度为O(2^N),空间复杂度为0(N)
解析:
我们在前面计算斐波那契额数列的时候,知道了它有N-2层,那么每一程都创建了一个栈帧空间,那么它的空间复杂度就是O(N)
3.3 常见的空间复杂度
我们比较常见的空间复杂度有三个:
O(1) | 创建了常数次变量 |
O(N) | 创建了一个一维数组 |
O(N^2) | 创建了一个二维数组 |