【数据结构基础】时间复杂度和空间复杂度
算法的时间复杂度和空间复杂度
【本节目标】
- 1.算法效率
- 2.时间复杂度
- 3.空间复杂度
- 4.常见时间复杂度以及复杂度oj练习
数据结构指的是“一组数据的存储结构”,算法指的是“操作数据的一组方法”。
数据结构是为算法服务的,算法是要作用再特定的数据结构上的。
算法的复杂度
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度
和空间复杂度
。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。
在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。
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循环,
for (int k = 0; k < 2 * N ; ++ k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
可以看到上面代码中共出现了4个循环:
第一个for循环与第二个循环嵌套,所以执行次数为N2;
第三个循环执行次数为2*N;
第四个循环执行次数为10;
Func1 执行的基本操作次数
:
f(N) = N2 + 2*N + 10
- N = 10 时 F(N) = 130
- N = 100 时 F(N) = 10210
- N = 1000 时 F(N) = 1002010
我们发现N越大,后面系数和常量对整体f(n)的影响越小。
实际上现在,10和100000次,相差的时间都很小,这主要依赖于cpu的强大:
#include<stdio.h>
#include<time.h>
int main()
{
size_t begin = clock();//开始计时
size_t n = 0;
for (size_t i = 0; i < 10; ++i)
{
++n;
}
size_t end = clock();//结束计时
printf("%d毫秒\n", end - begin);//差为时间,单位是毫秒
return 0;
}
执行0次结果:
执行100000次结果:
上面我们是在Debug环境下的测试,但是如果在Release环境下,即使是执行一亿次时间都是0毫秒!
所以实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法。
1.1 大O的复杂度表示法
大O时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。
当n无限大时,公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略,所以只需要记录一个最大量级就可以了。
即推导大O阶方法:
1、用常数1取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
使用大O的渐进表示法以后,Func1的时间复杂度为:
O(N^2)
- N = 10 F(N) = 100
- N = 100 F(N) = 10000
- N = 1000 F(N) = 1000000
通过上面我们会发现大O的渐进表示法
去掉了那些对结果影响不大的项,只保留了最大量级,简洁明了的表示出了执行次数,而且对精确计算影响不大。
另外有些算法的复杂度存在最好、平均和最坏情况:
一、复杂度分析的4个概念
1.最坏情况时间复杂度:代码在最坏情况下执行的时间复杂度,即任意输入规模的最大运行次数(上界)。
2.最好情况时间复杂度:代码在最理想情况下执行的时间复杂度,即任意输入规模的最小运行次数(下界)。
3.平均时间复杂度:代码在所有情况下执行的次数的加权平均值,即任意输入规模的期望运行次数。
4.均摊时间复杂度:在代码执行的所有复杂度情况中绝大部分是低级别的复杂度,个别情况是高级别复杂度且发生具有时序关系时,可以将个别高级别复杂度均摊到低级别复杂度上。基本上均摊结果就等于低级别复杂度。
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
代码复杂度在不同情况下出现量级差别时才需要区别这四种复杂度。大多数情况下,是不需要区别分析它们的。
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)
1.2常见时间复杂度实例分析
多项式阶:随着数据规模的增长,算法的执行时间和空间占用,按照多项式的比例增长。包括,
O(1)(常数阶)、O(logn)(对数阶)、O(n)(线性阶)、O(nlogn)(线性对数阶)、O(n2)(平方阶)、O(n3)(立方阶)
非多项式阶:随着数据规模的增长,算法的执行时间和空间占用暴增,这类算法性能极差。包括,
O(2^n)(指数阶)、O(n!)(阶乘阶)
练习及解析:
实例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);
}
答案是O(N),2N+10
实例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);
}
实例3:
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
实例4:
/ 计算strchr的时间复杂度?
const char * strchr ( const char * str, int character );
实例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;
}
}
实例6:
// 计算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;
}
实例7:
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if(0 == N)
return 1;
return Fac(N-1)*N;
}
实例8:
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
实例答案及分析:
- 实例1基本操作执行了2N+10次,通过推导大O阶方法去掉常量,系数,时间复杂度为 O(N)
- 实例2基本操作执行了M+N次,有两个未知数M和N,时间复杂度为 O(N+M)
- 实例3基本操作执行了10次,通过推导大O阶方法将常量改为1,时间复杂度为 O(1)
- 实例4,strchr函数相当于
while(*str)
{
if(*str == character)
return str;
else
str++;
}
实际上就是查找字符串元素并返回该位置的指针,但是我们并不知道字符串的大小,所以基本操作执行最好1次,最坏N次,时间复杂度一般看最坏,时间复杂度为 O(N)
- 实例5冒泡排序,
第一趟需要比n-1次,第二趟比较n-2次,n-1 + n-2 +……+2+1
基本操作执行最好N次,最坏执行次数为(首项+末项)*项数/2,即(N*(N-1)/2次,通过推导大O阶方法+时间复杂度一般看最坏,时间复杂度为 O(N^2) - 实例6 二分查找
基本操作执行最好1次,最坏的情况是只剩一个元素,O(logN)次,时间复杂度为 O(logN)
logN在算法分析中表示是底数为2,对数为N。 - 实例7通过计算分析发现基本操作递归了N次,每次调用了常数次,所以时间复杂度为O(N)。
- 实例8斐波那契数列
根据大O复杂度表示法通过计算分析发现基本操作递归了2N 次,时间复杂度为O(2N)。
2.空间复杂度
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度 。
空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。
空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
练习及解析:
实例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;
}
}
实例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;
}
实例3:
// 计算阶乘递归Fac的空间复杂度
long long Fac(size_t N)
{
if(N == 0)
return 1;
return Fac(N-1)*N;
}
实例4:
// 计算斐波那契递归Fib的空间复杂度?
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
实例答案及分析:
- 实例1使用了常数个额外空间,所以空间复杂度为 O(1)。
- 实例2动态开辟了N个空间,空间复杂度为 O(N)。
- 实例3递归调用了N次,开辟了N个栈帧,每个栈帧使用了常数个空间。空间复杂度为O(N)。
- 可以参考时间复杂度的图来理解,空间是可以重复利用的Fib(N-1)和Fib(N-2)实际上是调用同一块空间,可以理解为每一层建立一个函数栈帧,所以空间复杂度为O(N)。
这里我们可以通过对下面两个函数调用来理解,下面是函数栈帧开辟空间的演示
如图程序运行后,输出的第一次调用和第二次调用的两个变量的地址是一样的,
调用这两个函数时开辟的空间在同一个位置,斐波那契数中Fib(N-1)和Fib(N-2)的调用与之类似。
1、消失的数字OJ链接:https://leetcode-cn.com/problems/missing-number-lcci/
这里我们就会有很多思路:
思路1:求和相减
(n+1)*n/2 - (数组中所有相加)
时间复杂度:O(N)
空间复杂度:O(1)
思路2: qsort排序/ 冒泡排序
时间复杂度:O(logN*N) O(N2)
空间复杂度:O(logN) O(1)
思路3:异或(不开辟新数组)
//思路1
int missingNumber(int* nums, int numsSize)
{
int N = numsSize;
int ret = N*(N+1)/2;
for(int i = 0;i < numsSize;++i)
{
ret -= nums[i];
}
return ret;
}
//思路3
int missingNumber(int* nums, int numsSize)
{
int N = numsSize;
int x = 0;
for(size_t i = 0;i < numsSize; ++i)
{
x ^= nums[i];
}
for(size_t j = 0;j < N+1;++j)
{
x ^= j;
}
return x;
}
2、旋转数组OJ链接:https://leetcode-cn.com/problems/rotate-array/
代码:
第一种思路,效率太低,这里不作讲解,只给出参考
思路二:
void rotate(int* nums, int numsSize, int k)
{
k %=numsSize;//为避免k>numSize
//numsSize是变长数组
int tmp[numsSize];
//后k个拷贝前面
int j = 0;
for(int i = numsSize-k;i<numsSize;++i)
{
tmp[j] = nums[i];
++j;
}
//前n-k个拷贝到后面
for(int i = 0;i<numsSize-k;++i)
{
tmp[j] = nums[i];
++j;
}
//拷贝回去
for(int i = 0;i<numsSize;++i)
{
nums[i] = tmp[i];
}
}
思路三:
void reverse(int *a, int begin, int end)//交换函数封装
{
while(begin < end)
{
int tmp = a[begin];
a[begin] = a[end];
a[end] = tmp;
++begin;
--end;
}
}
void rotate(int*nums,int numsSize,int k)
{
k %=numsSize;
reverse(nums,0,numsSize-k-1);//调用函数
reverse(nums,numsSize-k,numsSize-1);//调用函数
reverse(nums,0,numsSize-1);//调用函数
}