文章目录
前言
算法效率分析分为两种:第一种是时间效率,第二种是空间效率。 时间效率被称为时间复杂度,而空间效率被称作空间复杂度。 时间复杂度主要衡量的是一个算法的运行速度,而空间复杂度主要衡量一个算法所需要的额外空间。
在计算机发展的早期,计算机的存储容量很小,所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度,这就是为什么你总是听到别人说这个算法的时间复杂度是…而不谈论空间复杂度了。
至于为什么要学时间复杂度呢?未来你和你的同事谈论谁的算法更厉害的时候,时间复杂度就是比较的依据。
【本节目标】
- 什么是时间复杂度和空间复杂度?
- 如何计算常见算法的时间复杂度和空间复杂度?
- 有复杂度要求的算法题练习
一、时间复杂度
1.1. 时间复杂度是什么?
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但这过于麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度,使用大O渐进表示法。
1.2. 如何计算常见算法的时间复杂度?——大O的渐进表示法
// 请计算一下Func1基本操作执行了多少次?
void Func1(int N)
{
int count = 0;
for (int i = 0; i < N ; ++ i)
{
for (int j = 0; j < N ; ++ j)
{
++count; //count循环执行N*N次
}
}
for (int k = 0; k < 2 * N ; ++ k)
{
++count; //count循环执行2*N次
}
int M = 10;
while (M--)
{
++count; //count循环执行10次
}
printf("%d\n", count);
}
Func1执行的基本操作次数: F ( N ) = N 2 + 2 ∗ N + 10 F(N) = N^2 + 2*N + 10 F(N)=N2+2∗N+10
实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法。
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
推导大O阶方法:
1、用常数1取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。
得到的结果就是大O阶。
这是什么意思呢?以 F(N) = 3*(N^2) + 2*N + 10
为例,
1、常数项修改为1,这里可以直接丢掉常数项1。——> F(N) = 3*(N^2) + 2*N
2、修改后的函数中,丢掉其余项,保留最高项。——> F(N) = 3*(N^2)
3、如果最高阶项系数存在(不是1),则丢掉最高项系数。——> F(N) = N^2
得到的时间复杂度就是 O(N2)
也就是说,当N趋近于无穷大时,去掉对结果影响不大的项,简洁明了的表示出了大概的执行次数,得到的就是我们的时间复杂度。
另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)
2.2. 常见时间复杂度计算举例
实例1:
// 计算Func2的时间复杂度?
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 2 * N ; ++ k)
{
++count; //执行2*N次
}
int M = 10;
while (M--)
{
++count; //执行10次
}
printf("%d\n", count);
}
基本操作执行了2*N+10次,通过推导大O阶方法,丢掉常数项和最高项系数,时间复杂度为 O(N)
实例2:
// 计算Func3的时间复杂度?
void Func3(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++ k)
{
++count; //执行M次
}
for (int k = 0; k < N ; ++ k)
{
++count; //执行N次
}
printf("%d\n", count);
}
基本操作执行了M+N次,=M和N是两个未知数,所以时间复杂度为 O(N+M)
如果 M远大于 N,时间复杂度为 O(M);N远大于M,时间复杂度为 O(N);
如果 M和 N差不多大,时间复杂度为 O(M)或者 O(N) 。
实例3:
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count; //执行100次,常数次
}
printf("%d\n", count);
}
基本操作执行了100次,通过推导大O阶方法,将常数改为1,所以常数次的时间复杂度不管多大都为 O(1)
实例4:
// 计算strchr的时间复杂度?
const char * strchr ( const char * str, char character )
{
//字符串中查找某一个字符
while(*str != '\0')
{
if(*str == character)
return str;
++str;
}
return NULL;
}
假设字符串长度为N
最好情况:第一个字符就是要找的那个,基本操作执行一次。
最坏情况:最后一个字符是要找的那个,基本操作执行N次。
时间复杂度一般看最坏,所以字符串中查找某一个字符的时间复杂度为 O(N)
实例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;
}
}
第一趟冒泡:N
第二趟冒泡:N-1
第三趟冒泡:N-2
…
第N趟冒泡:1
最好情况:一趟就排好,基本操作执行N次。
最坏情况:最后一趟才排好,所有基本操作次数相加(等差数列),执行(N*(N+1))/2次。
通过推导大O阶方法+时间复杂度一般看最坏,所以冒泡排序的时间复杂度为 O(N2)
实例6:
// 计算二分查找BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n;
while (begin < end)
{
int mid = begin + ((end-begin)>>1); //不断的折半查找
if (a[mid] < x)
begin = mid+1;
else if (a[mid] > x)
end = mid;
else
return mid;
}
return -1;
}
最好情况:折半一次就找到,基本操作执行1次。
最坏情况:最后一次折半才找到,假设找 (折半) 了X次,序列长度为N
1* 2 * 2 * 2…*2 = N
2X = N
执行次数:X = log2N(以2为底)
在算法的复杂度计算中,喜欢省略简写成logN,因为很多地方不好写底数,所以二分查找的时间复杂度为 O(logN)。有些书本或者网上资料会写成 O(lgN),严格来说这样写是不对的。
实例7:
// 计算阶乘递归Factorial的时间复杂度?
long long Factorial(size_t N)
{
return N < 2 ? N : Factorial(N-1)*N;
}
Factorial(10)
↓
Factorial(9) * 10
↓
Factorial(8) * 9
↓
…
Factorial(2) * 3
↓
Factorial(1) * 2
↓
1
递归调用了N次,每次递归,函数内语句执行1次,整体的时间复杂度就是 O(N)
并不是说一层循环就是 O(N),两层循环就是 O(N2),具体要看程序,最好通过算法过程去分析。
2.3. 时间复杂度对比
常见的时间复杂度:O(1)、O(N)、O(logN)、O(N2)
见如下复杂度对比图,横轴是元素数量,纵轴是操作次数,线的陡峭程度可以看做算法的复杂程度,越陡峭算法越复杂,反之。算法的时间复杂度越低,算法执行越快,效率越高,反之。
可以看出,O(1)、O(logN)、O(N) 的线很平稳,时间复杂度低,算法效率高;O(1)的算法复杂度最低,算法效率最高;O(1)、O(logN) 的线几乎重合,所以O(logN)也是很高效的算法。
其次是O(NlogN),再次O(N2),也是我们写程序会常用的。
再往后可以看到O(2n)和O(n!)非常陡峭,我们一般不谈论这两种时间复杂度,这样的算法我们视为无效算法。
二、空间复杂度
2.1. 空间复杂度是什么?
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度 。 空间复杂度不是程序占用了多少字节的空间,因为这个也没太大意义,所以 空间复杂度算的是变量的个数 。空间复杂度计算规则基本跟时间复杂度类似,也使大O渐进表示法。
2.2. 常见空间复杂度的计算举例
实例1:
// 计算冒泡排序BubbleSort的空间复杂度?
void BubbleSort(int* a, int n) //变量1和变量2
{
assert(a);
for (size_t end = n; end > 0; --end) //变量3
{
int exchange = 0; //变量4
for (size_t i = 1; i < end; ++i) //变量5
{
if (a[i-1] > a[i])
{
Swap(&a[i-1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
我们要知道,时间是累计的,空间是不累计的。 相当于第一次循环开辟了一个空间,用完之后销毁,第二次循环再开辟同一块空间,以此类推。循环走了N次, 重复利用的都是一个空间。
这里开辟了5个变量,是常数级别的,所以冒泡排序的空间复杂度为 O(1)
实例2:
// 计算Fibonacci的空间复杂度?
long long* Fibonacci(size_t n) //变量1
{
if(n==0)
return NULL;
long long * fibArray = (long long *)malloc((n+1) * sizeof(long long));
//变量2 + malloc开辟了n+1个空间
fibArray[0] = 0; //变量3
fibArray[1] = 1; //变量4
for (int i = 2; i <= n ; ++i) //变量5
{
fibArray[i ] = fibArray[ i - 1] + fibArray [i - 2];
}
return fibArray ;
}
这个例子有 5+(n+1)个变量,同样的,我们只保留对结果影响最大的那一项,所以空间复杂度为 O(N)
实例3:
// 计算阶乘递归Factorial的空间复杂度?
long long Factorial(size_t N)
{
return N < 2 ? N : Factorial(N-1)*N;
}
递归调用时,建立栈帧;递归返回时,销毁栈帧。
递归调用了N层,每次调用都建立一个栈帧,每个栈帧使用了常数个空间,所以空间复杂度为O(N)
三、有复杂度要求的算法题练习
3.1. 消失的数字 OJ链接:https://leetcode-cn.com/problems/missing-number-lcci/
题目要求时间复杂度是O(n),这里提供两种方法。
第一种:所有数字之和减去输入数字之和,得到的差就是消失的那个数字,时间复杂度为O(N)
class Solution {
public:
int missingNumber(vector<int>& nums) {
int n = nums.size(),sum=0;
for(int i=0;i<n;++i)
sum += nums[i];
return (1+n)*n/2-sum; //n项和-数组和
}
};
第二种:异或,利用的是相同的数异或会抵消,即x^x=x ,0^x=x,异或是可以交换顺序的,
例如:把0 1 2 3 5和0 1 2 3 4 5异或
1 3 5抵消,剩下0和4同样异或,最后剩下的就是消失的数,时间复杂度为O(N),代码如下:
class Solution {
public:
int missingNumber(vector<int>& nums) {
int n = nums.size(),ans=0;
for(int i=0;i<n;++i)
{
ans ^= nums[i]^i;
}
return ans^n;
}
};
3.2. 旋转数组OJ链接:https://leetcode-cn.com/problems/rotate-array/
题目进阶要求:
1.尽可能想出更多的解决方案,至少有 三种 不同的方法可以解决这个问题。
2.你可以使用空间复杂度为 O(1) 的 原地算法解决这个问题吗?
第一种:常规一点的方法,用两层循环一次一次的轮转,时间复杂度O(N^2),代码如下:
class Solution {
public:
void rotate(vector<int>& nums, int k) {
int n = nums.size();
while(k--) //轮转k次
{
int tmp=nums[n-1];
for(int i=n-1;i>0;i--)
{
nums[i] = nums[i-1];
}
nums[0] = tmp;
}
return;
}
};
第二种:高级一点的方法,直接一次性把数放到轮转k次后的位置。用空间换时间,开辟一个新数组 a,一段一段的存。
例如:nums = [ 1,2,3,4,5,6,7 ],k = 3
先将后k个移到新数组的前面 a = [ 5,6,7 ]
再将前n-k个移到新数组的后面 a = [ 5,6,7 ,1,2,3,4 ]
一次性完成k次轮转,时间复杂度O(N) ,代码如下:
class Solution {
public:
void rotate(vector<int>& nums, int k) {
int n = nums.size(),a[n],cnt=0;
for(int i=n-(k%n);i<n;++i) //后k个移到a的前面 ***(k%n)是考虑n<k的情况
a[cnt++] = nums[i];
for(int i=0;i<n-(k%n);++i) //前n-k个移到a的后面 ***(k%n)是考虑n<k的情况
a[cnt++] = nums[i];
for(int i=0;i<n;++i) //a复制给nums
nums[i] = a[i];
return;
}
};
第三种:进阶方法,逆置(左右互换),例如
——nums 1 2 3 4 5 6 7
后k个逆置 1 2 3 4 7 6 5 (5.7换)
前n-k个逆置 4 3 2 1 7 6 5 (4.1换,2.3换)
再整体逆置 5 6 7 1 2 3 4 (左右互换)
时间复杂度为 O(1) 的原地算法,代码如下:
class Solution {
public:
void Reverse(vector<int>& nums,int left,int right)
{
while(left < right)
{
int tmp = nums[left];
nums[left] = nums[right];
nums[right] = tmp;
left ++;
right --;
}
}
void rotate(vector<int>& nums, int k) {
int n = nums.size(),a[n],cnt=0;
Reverse(nums,0,n-(k%n)-1);
Reverse(nums,n-(k%n),n-1);
Reverse(nums,0,n-1);
return;
}
};
总结
今天学习了数据结构的入门第一章——算法复杂度,同时也在题目中体会到了时间复杂度和空间复杂度,一步一步的优化所写算法。等之后见过的代码多了,便能一眼看出一个算法的复杂度了,可以利用空间换时间或者其他思路来优化算法,加油!