目录
一、复杂度
衡量一个算法的好坏,我们一般从两个维度来衡量:
- 时间复杂度: 主要衡量一个算法的运行快慢
- 空间复杂度: 主要衡量一个算法运行所需要的额外空间
更关注时间复杂度的原因:
在计算机发展早期,由于计算机的存储容量很小,所以人们会很在乎空间复杂度。但随着硬件技术的发展(摩尔定律),计算机的存储容量已经达到了很高的程度,内存容量已不再是瓶颈,所以如今已经不需要再特别关注一个算法的空间复杂度,时间复杂度成为了衡量算法优劣的主要标准
二、时间复杂度
在计算机科学中,时间复杂度是一个函数,它定量描述了一个算法的运行时间。
理论上来说,一个算法执行所需要的时间不能被计算出来,但是可以通过语句执行次数来分析,找到语句执行次数与问题规模N之间的数学表达式,这就是时间复杂度
1.估算时间复杂度的关键
- 大O渐进表示法:我们只关心对运行时间影响最大的部分,忽略常数项和低阶项
- 关注N很大的情况:当N很小时,算法差异不明显,CPU都能跑得飞快,因此我们对此情况不做讨论
通过简单观察和计算我们可以发现N越大对后面部分对函数整体的影响越小
我们可以通过clock函数来计算一下一个函数从开始到结束的所需时间:
c
#include<stdio.h>
#include<time.h>
int main()
{
int begin1=clock();
int n=10000000000;
int x=10;
for(int i=0;i<n;i++)
{
++x;
}
int end1=clock();
printf("%d\n",x);
printf("%d\n",end1-begin1);//计算中间消耗毫秒数
return 0;
}

2.大O渐进表示法
大O渐进表示法: 是对复杂度的大概估算法,用于描述数据规模扩大时,算法时间与空间的增长趋势,仅保留对它影响最大的那个项(最高阶项),然后进行尽可能的简化(忽略常数项与低阶项),是复杂度分析的专用记号
即用于大概估算算法属于哪个量级
推导大O阶方法:
- 用常数1取代运行时间中的所有加法常数
- 在修改后的运行次数函数中,只保留最高阶项
- 如果最高阶项存在且不是1,则去除与这个项目相乘的常数
例:计算以下算法的时间复杂度
c
#include<stdio.h>
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);
}
这个算法严格一点说应该是2*N+10,但它的时间复杂度是O(N)
原因:随着N无限变大,后面的10对它来说就没有影响了,其次是这里的系数2需要去掉,当N无限大时,系数对整体的影响也不大了
3.常见的时间复杂度对比
| 例子 | 时间复杂度 | 量级 |
|---|---|---|
| 5201314 | O(1) | 常数阶 |
| 3n+4 | O(n) | 线性阶 |
| 3n2+4n+5 | O(n2) | 平方阶 |
| 3log(2)n+4 | O(logn) | 对数阶 |
| 2n+3nlog(2)n+14 | O(nlogn) | nlogn阶 |
| n3+2n2+4n+6 | O(n3) | 立方阶 |
| 2n | O(2n) | 指数阶 |
Tips:时间复杂度O(logN)中,如果是以2为底的底数,我们习惯省略底数2写作logN,但其他底数不可省略
4.最好、最坏、平均情况
很多算法的执行时间和输入数据有关,比如:
c
const char* strchr(const char* str,int character)
{
while(*str)
{
if(*str==character)
return str;
else
++str;
}
}
存在以下三种情况:
- 字符在第一位就找到,执行1次
- 字符在最后一位或不存在,执行N次
- 字符在中间,执行N/2次
由此我们可知有些算法的时间复杂度存在为三种情况:
- 最好情况: 任意输入规模的最好运行次数(下界)
- 最坏情况: 任意输入规模的最大运行次数(上界)
- 平均情况: 任意输入规模的期望运行次数
实际工程中,我们统一按最坏情况来分析时间复杂度,所以这个函数的时间复杂度为O(N)
时间复杂度的意义:
当一个题有多个思路时,分析不同思路的时间复杂度,能够让我们通过对比写出最优解
Tips:计算时间复杂度不能单纯的数循环次数,而是要根据逻辑来算
因为有些时候数循环我们是数不出来的:
c
#include<stdio.h>
void func(int n)
{
int x=0;
for(int i=1;i<n;i*=2)
{
++x;
}
printf("%d\n",x);
}
int main()
{
func(8);
func(1024);
func(1024*1024);
}
逻辑:这个算法是12 2*2...*2,循环走了多少次就有多少个2相乘,因此这里是2x=N,可得x=logN
5.部分时间复杂度计算示例
常见时间复杂度的效率排序: O(1)>O(logN)>O(N)>O(NlogN)>O(N2)>O(2N)

下面就几个常见的时间复杂度量级进行举例
(1)O(1)常数阶
含义: 无论数据规模多大,执行次数固定不变,不随n变化
c
int a=10;
int b=20;
int c=a+b;
(2)O(N)线性阶
含义: 执行次数和数据规模n成正比
这里比较经典的是递归时间复杂度:所有调用次数累加
计算阶乘递归的时间复杂度例:
c
long long Fac(size_t N)
{
if(0=N)
return 1;
return Fac(N-1)*N;
}
这里合计一共要递归N+1次(0~N),每次递归调用都是常数次,从0到N累加为执行次数,所以这里的时间复杂度为O(N)
*Tips:这里容易被Fac(N-1)N误导,误以为时间复杂度是O(N2)❌,这里的乘只代表返回值的结果相乘,而非次数相乘
(3)O(n2)平方阶
含义: 算法总操作次数与数据规模平方成正比,常见于双层循环嵌套,数据量增大后耗时会急剧飙升
经典案例: 冒泡排序、双重遍历暴力查找,均为O(n2)
c
void bubbleSort(int arr[],int n)
{
for(int i=0;i<n;i++)
{
for(int j=0;j<n-i-1;j++)
{
if(arr[j]>arr[j+1])
{
int temp=arr[j];
arr[j]=arr[j+1];
arr[j+1]=temp;
}
}
}
}
这里双层循环的执行次数约为N*(N-1)/2,简化后时间复杂度为O(N2)
(4)O(logn)对数阶
含义: 数据规模翻倍,执行次数仅+1,算法效率极高
经典案例: 二分查找
c
int BinarySearch(int* a,int n,int x)
{
assert(a);
int begin=0;
int end=n-1;
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;
}
二分查找每次都会把查找范围缩小一半,也是2x=N,得x=logN
Tips:二分查找我们在写的时候如果上面是左闭右闭,那么我们在下面也要保左闭右闭,若上面是左闭右开则下面也同样要和上面保持一致
二分查找与暴力查找的对比:
| 数据总数 | 二分查找 | 暴力查找 |
|---|---|---|
| N | O(logN) | O(N) |
| 1000 | 10 | 1000 |
| 100W | 20 | 100W |
| 100亿 | 30 | 100亿 |
我们可以很直观的感受到二分查找的查找效率非常之高,但是它属于外强中干,有自己的缺点
二分查找的缺点:
- 必须排序
- 必须用数组结构,数组结构不方便插入删除
(5)O(2N)指数阶
这里比较经典的是计算斐波那契递归的时间复杂度
c
long long Fib(size_t N)
{
if(N<3)
return 1;
return Fib(N-1)+Fib(N-2);
}
这里我们可以发现递归调用次数呈等比数列,累计递归调用次数是20+21+...+2(N-2),结果为2(N-1)-1
这里可以用等比数列的计算公式计算,也可以用错位相减法
由于斐波那契数列为指数级,效率太低,因此在现实情况中的意义较小
把递归改为非递归,用循环解决,可以将时间复杂度从O(N2)提升为O(N)
c
long long Fib(size_t N)
{
long long f1=1;
long long f2=1;
long long f3=0;
for(size_t i=3;i<=N;i++)
{
f3=f1+f2;
f1=f2;
f2=f3;
}
}
二、空间复杂度
1.空间复杂度的定义
空间复杂度: 也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的度量
空间复杂度算的是变量的个数,计算规则与时间复杂度类似,也使用大O渐进表示法
Tips:空间复杂度只计算额外申请的空间,函数运行时所需要的栈空间在编译期间已经确定,不算在内
2.经典示例
数组旋转:
c
void _rotate(int* nums,int numsSize,int k,int temp)
{
k%=numsize;
int n=numsize;
memcpy(tmp,num+n-k,sizeof(int)*k);
memcpy(tmp+k,nums,sizeof(int)*(n-k));
memcpy(nums,tmp,sizeof(int)*(n));
}
void rocate(int* nums,int numsSize,int k)
{
int tmp[numsSize];
_roate(nums,numsSize,k,tmp);
}
这里是拿空间换时间的做法
额外申请了大小为numsSize的数组,空间复杂度为O(N)
阶乘递归斐波那契数列:
c
long long Fac(size_t N)
{
if(N==0)
return 1;
return Fac(N-1)*N;
}
每个函数调用都会建立栈帧,空间最后都会销毁,主要看最多的时候建立多少
这里递归调用建立了N个栈帧,空间复杂度为O(N)