【C语言数据结构与算法】一、时间复杂度与空间复杂度
算法的时间复杂度和空间复杂度
一、前言
1·算法的效率
算法就是用来操作数据、解决程序问题的一组方法。对于同一个问题,我们去使用不同的算法,结果或许会一样,但不同的地方就在于你所用算法所耗费的资源和时间,这决定着算法的质量如何。
算法效率分为两种:第一种是时间效率,第二种是空间效率。时间效率和空间效率对应的参数即为时间复杂度和空间复杂度。
时间效率主要衡量的是一个算法的运行速度。空间效率主要衡量的是一个算法所需要的额外空间。
在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展和摩尔定律的发现,计算机的存储容量已经达到了很高的程度并且内存越来越便宜。所以我们如今已经不需要再特别关注一个算法的空间效率。但是,我们对时间效率仍然是有持续追求的。
摩尔定律
1、集成电路芯片上所集成的电路的数目,每隔18个月就翻一番。
2、微处理器的性能每隔18个月提高一倍,而价格下降一半。
3、用一美元所能买到的计算机性能,每隔18个月翻两番。
2·复杂度的表示方法:大O渐进表示法
这里以时间复杂度举例说明
void Func1(int N)
{
int count = 0;
//嵌套循环
for (int i = 0; i < N; ++i) //嵌套循环等价于执行N*N次
{
for (int j = 0; j < N; ++j) //内部循环执行N次
{
++count;
}
}
for (int k = 0; k < 2 * N; ++k) //执行2*N次
{
++count;
}
int M = 10;
while (M--) //执行10次
{
++count;
}
//执行总次数F(N)=N*N+2*N+10
printf("%d\n", count);
}
执行总次数F(N)=N^2+2*N+10
- N = 10 F(N) = 130
- N = 100 F(N) = 10210
- N = 1000 F(N) = 1002010
由于在实际工作中,需要用算法解决的问题是相当复杂的,因此该数学函数表达式中的N是非常大的,与高等数学中的无穷大相似,取最高阶为复杂度即可。因此这里的时间复杂度并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法来表示时间复杂度。
因此这个函数的时间复杂度:O(N^2)。
3·大O渐进表示法的规则
- (1)用常数1取代执行次数中的所有加法常数。
- (2)在修改后的运行次数函数中,只保留最高阶项。
- (3)如果最高阶项存在且不是1而是【常数*N】的形式,那么去除与这个项目相乘的常数。得到的结果就是大O阶。
另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
在实际中一般情况关注的是算法的最坏情况(期望越小,失望越小),所以数组中搜索数据时间复杂度为O(N)。
二、时间复杂度
1·时间复杂度的概念
在计算机科学中,算法的时间复杂度精确值用一个数学函数表达式表示,它形象表达了该算法的运行时间。
一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。
由于一个算法所花费的时间与其中语句的执行次数成正比例,所以我们把算法中的基本操作的执行次数,定义为算法的时间复杂度。
2·简单时间复杂度计算举例
实例1
//双重循环
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 2 * N; ++k) //执行次数2*N
{
++count;
}
int M = 10;
while (M--) //执行次数10
{
++count;
}
//总执行次数F(N)=2*N+10
printf("%d\n", count);
}
总执行次数F(N)=2*N+10,所以时间复杂度为O(N)。
实例2
//特殊双重循环
void Func3(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++k) //执行次数M
{
++count;
}
for (int k = 0; k < N; ++k) //执行次数N
{
++count;
}
//总执行次数F(N)=M+N
printf("%d\n", count);
}
总执行次数F(N)=M+N,分为4种情况:
- M=N时,可以理解为执行次数为2M或2N,因此时间复杂度为O(M)或O(N)
- M>>N时,时间复杂度为O(M)
- M<<N时,时间复杂度为O(N)
- M和N大小关系未知时,时间复杂度为O(M+N)
实例3
//常数循环
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k) //执行次数为100
{
++count;
}
//总执行次数F(N)=100
printf("%d\n", count);
}
总执行次数F(N)=100,所以时间复杂度为O(1)。
3·复杂时间复杂度的计算举例
实例1:冒泡排序的时间复杂度
// 冒泡排序的时间复杂度
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个数进行排序时,第1趟最坏预期执行N-1次,第2趟最坏预期执行N-2次,……第N-1趟执行1次,因此总次数为F(N)=(N-1)+(N-2)+(N-3)+……1={N*(N-1)}/2,,因此时间复杂度为O(N^2)。
实例2:二分查找的时间复杂度
//二分查找的时间复杂度
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;
else
return mid;
}
return -1;
}
计算时间复杂度时,最坏预期为有N个数时,从第1个数出发,但是要找的数是最后一个数,执行1次数据缩放为N/2,执行2次数据缩放为(N/2)/2,执行3次数据缩放为((N/2)/2)/2,……直到执行x次数据缩放为1,那么N/(2^x)=1,因此执行次数为x=log2(N),因此时间复杂度为O(log2(N))。
说明:二分查找算法非常强大,如表所示:
| 数据个数 | 查找次数 |
|---|---|
| 1k | 10 |
| 100w | 20 |
| 10亿 | 30 |
| 14亿 | 31 |
二分查找的缺陷是要先将数据排序,然后再查找,其中排序的消耗比较大。
实列3:递归函数的时间复杂度
递归函数的复杂度=递归的次数*每次递归执行的次数
// 阶乘递归函数的时间复杂度
long long Factorial(size_t N)
{
return N < 2 ? N : Factorial(N - 1) * N;
}
该函数每次递归执行的次数为常数(可视为O(1)),递归次数为N次,因此时间复杂度为O(N)。
// 斐波那契递归函数的时间复杂度
long long Fibonacci(size_t N)
{
return N < 2 ? N : Fibonacci(N - 1) + Fibonacci(N - 2);
}
该函数每次递归执行的次数也为常数(可视为O(1))
但是递归次数相对复杂,如下图:

递归调用次数为等比数列求和后减一个常数X:(2^0 + 2^1 + 2^2 +……+2^(N-1) )- X = (2^N - 1) - X。这里X<<2^N, 因此时间复杂度为:O(2^N)。
斐波那契数列的递归写法在实际中是不会被用到的,因为它的时间复杂度是随N指数增长的,计算非常慢。
三、空间间复杂度
1·空间复杂度的概念
空间复杂度是对一个算法在运行过程中临时额外占用存储空间大小的量度 。
空间复杂度不是程序占用了多少字节的空间,因为这个没太大意义,而是空间复杂度算的是变量的个数。
空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。
注意:由于函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要是通过函数在运行时候显式申请的额外空间来确定的。
2·空间复杂度计算举例
实例1:冒泡排序函数的空间复杂度
//冒泡排序函数的空间复杂度
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个,局部变量不是2*n个,而是2个,因为这2个变量所占用的地址空间始终不变,只有它们所占用的地址空间所存储的值在不断变化。因此这个函数最多有4个变量,空间复杂度为O(1)。
实例2:斐波那契数列的空间复杂度
// 斐波那契数列的空间复杂度
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;
}
这个函数使用动态内存管理函数malloc开辟了元素个数为n+1的变量空间,另外还有常数个局部变量,这些变量空间是临时占用的存储空间,在函数调用结束后就自动销毁了。因此空间复杂度为O(n)。
实例3:递归函数的空间复杂度
//阶乘递归的空间复杂度
long long Factorial(size_t N)
{
return N < 2 ? N : Factorial(N - 1) * N;
}
这个递归函数的形参在每次调用结束时都会销毁地址空间,但是递归函数一直在调用自己,函数调用一次后需要调用自己N-1次才结束,每次调用都会复制1块与实参同样大小的变量空间,因此这个递归函数调用次数为N-1次,那么相当于共有N-1个与实参同样大小的变量空间,因此空间复杂度为O(N)。
// 斐波那契递归函数的空间复杂度
long long Fibonacci(size_t N)
{
return N < 2 ? N : Fibonacci(N - 1) + Fibonacci(N - 2);
}

调用这个递归函数会先从Fibonacci(N - 1)中出来再调用Fibonacci(N - 2)之后再调用Fibonacci(N - 3),……直到N=1时函数才调用结束,在调用时Fibonacci(N - 1)结束后的变量地址空间还未被销毁,而是供调用Fibonacci(N - 2)使用,以此类推,调用Fibonacci(N - 2)之后供调用Fibonacci(N - 3)使用……直到N=1时函数调用结束后,所有变量才销毁,所以实际上共有N个与实参同样大小的变量地址空间被反复利用,因此空间复杂度为O(N)。

浙公网安备 33010602011771号