在C语言阶段,我们学过了一些排序和查找算法,冒泡排序,快速排序,二分查找等等,哪种算法更好呢,我们如何衡量一个算法的好坏呢?本篇来学习算法的时间复杂度和空间复杂度,相信学完后你就会明白了。
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。
随着计算机行业的高速发展,计算机储存容量已经达到了很高的程序,已经不需要特别关注一个算法的空间效率了,重点关注其时间复杂度。
算法中的基本操作的==执行次数==,为算法的时间复杂度。
直接上实例来讲解具体的计算方法吧
//计算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);
}
算法执行次数函数表达式:F(N) = N2 + 2 * N + 10
N = 10,F(N) = 130;N = 100,F(N) = 10210;N = 1000,F(N) = 1002010;
通过计算发现,N越大,对结果影响越小,所以在实际计算时间复杂度时,我们并不需要计算精确的执行次数,而只需要计算大概执行次数,使用大O渐进表示法(估算),只保留对结果影响最大的一项。
1、推导大O阶方法:
用常数1取代运行时间中的所有加法常数。
在修改后的运行次数函数中,只保留最高阶项。
如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
这些方法看起来有些许摸不着头脑哈,我们来举几个例子说明,比如:
- 执行次数函数 F(N) = 10,使用大O渐进表示法后,时间复杂度为:O(1)
- 执行次数函数 F(N) = N2 + 2 * N + 10,使用大O渐进表示法后,时间复杂度为:O(N2)
- 执行次数函数 F(N) = 2 * N + 10,使用大O渐进表示法后,时间复杂度为:O(N)
2、有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为 N 数组中搜索一个数据 X
最好情况:1次就找到
最坏情况:N次才找到(一般以最坏情况为准)
平均情况:N/2次找到
而在实际中一般情况关注的是算法的最坏运行情况,是一种保底思维,没有比这更差了哈哈,所以数组中搜索数据时间复杂度为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);
}
执行次数函数 F(N) = 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;
}
for (int k = 0; k < N; ++k)
{
++count;
}
printf("%d\n", count);
}
执行次数函数 F(M,N) = M + N,有两个未知数 M 和 N,我们不知道谁大谁小,
所以时间复杂度可以写成:O(M + N) 或 O(max(M,N))
如果有条件限定:
如果能说明 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;
}
printf("%d\n", count);
}
执行次数函数 F(N) = 100,使用大O渐进表示法后,时间复杂度为:O(1)
(时间复杂度 O(1) 中的 1 不是代表 1 次,而是常数次)
实例4:
// 计算strchr的时间复杂度
// strchr - 定位字符串中第一个出现的字符
const char* strchr(const char* str, int character);
// 函数内部大致逻辑
while(*str)
{
if(*str == character)
return str;
else
++str;
}
循环中的 if 语句要比较 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; //发生了交换,赋值为1
}
}
if (exchange == 0) //没有发生交换,说明已经有序了
break;
}
}
若初始文件是正序的,一趟扫描即可完成排序。所以冒泡排序最好的时间复杂度为:O(n)
若初始文件是反序的,n 个数,需要进行 n-1 趟排序 ,每一趟重复地走访过要排序的元素列,依次比较两个相邻的元素,在这种情况下,比较和交换次数均达到最大值:
第 1 趟比较 n-1 次,
第 2 趟比较 n-2 次,
第 3 趟比较 n-3 次,
…………,
第 n-2 趟比较 2 次,
第 n-1 趟比较 1 次。
共 n-1 趟,每一趟 if 语句执行次数累加为:
n-1 + n-2 + n-3 + n-4 + …… + 3 + 2 + 1 = (n - 1)(1 + n - 1) / 2 = n(n-1) / 2
等差数列前 n 项和公式为:
Sn = n * a1 + n (n - 1)d / 2
Sn = n(a1 + an) / 2
所以冒泡排序最差的时间复杂度为:O(n2)
实例6:
// 计算二分查找BinarySearch的时间复杂度(二分查找前提是排序数组)
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) / 2;
int mid = begin + ((end - begin) >> 1); // mid时排序数组a中间元素的下标
if (x > a[mid]) // 大于中间元素
begin = mid + 1;
else if (x < a[mid]) // 小于中间元素
end = mid - 1;
else
return mid;
}
return -1;
}
二分(折半)查找,从 n 个数中找,计算一共折半了多少次才找到该数,那么时间复杂度就是折半的次数
图解说明:
注:对数时间复杂度记法,O(log2N),可以简化写成O(logN)
实例7:
// 计算阶乘递归Fac的时间复杂度
long long Fac(size_t N)
{
if (1 == N)
return 1;
return Fac(N - 1) * N;
}
递归次数为 N 次,每一次递归中运行次数为 1,所以时间复杂度为:O(N)
实例8:
// 计算斐波那契递归Fib的时间复杂度
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
图解说明:
F(N) = 1 + 2 + 4 + 8 + …… + 2N-3 + 2N-2 = (1 - 2N-2 * 2)/ (1 - 2) = 2N-1 - 1
补充:
这个二叉树的右边其实是有缺失的,右边没有N - 1层,所以算出来的 F(N) 还要减去右边缺的一些调用(常数)
使用大O渐进表示法后,所以时间复杂度为:O(2N)
计算时间复杂度,
就是计算出该算法中的基本操作的执行次数,
一般以算法的最坏运行情况为准,
最终结果用大O渐进表示法(估算)书写
N = 10,2N = 1024
N = 20,2N = 100万+
N = 30,2N = 10亿+
N = 40,2N = 10000亿+
N = 50,2N = 1000万亿+
所以尽量用非递归的方式去算斐波那契数,用数组,或者用三个变量倒的方法
三个变量f1、f2、f3,
f1 和 f2 存头两个斐波数Fib1和Fib2,
第三个变量 f3 算斐波数Fib3 = Fib1 + Fib2,
然后往前移动一下,f1 存 Fib2,f2 存 Fib3,然后再算出 Fib4 存到 f3 中
不断循环上述过程
N = 1000,log2N ≈ 10(210 = 1024)
N = 100万,log2N ≈ 20(220 = 100万+)
N = 10亿,log2N ≈ 30(230 = 10亿+)
N = 10000亿,log2N ≈ 40(240 = 10000亿+)
从这个角度分析,我们就能知道二分查找算法是一个非常好的算法,假设所有中国人的身份证号码已排好序存起来(14亿),查找一个人最多要找几次?
log214亿 ≈ 31次(因为230 = 10亿+,231 = 20亿+)
但二分查找也有一个致命的问题,就是必须要数据有序才行,而排序也是一个挺大的工程,所以实际中查找用得并没有那么多,查找更多的是用后面回讲到的一种数据结构:搜索树
空间复杂度是对一个**算法在运行过程中临时占用存储空间大小**的量度 。
空间复杂度不是程序占用了多少字节的空间,因为这个也没太大意义,空间复杂度算的是变量的个数。
空间复杂度计算规则基本跟时间复杂度类似,也使用大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;
}
}
数组 a 不是因为这个算法而开辟的,所以它是不参与计算的,该算法在运行时,定义了变量(end、exchange、i),临时占用了常数个储存空间的,所以空间复杂度是:O(1)
这时可能有人会有疑问:每次循环中 exchange 都被重新定义了,为啥只占用了常数个空间呀
因为空间是可以重复利用的,每个函数调用,都会开辟一个栈帧,里面存放函数运行时所需的局部变量、参数等
实例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;
}
算法在运行时,使用了 malloc 动态开辟并临时占用了 N 个储存空间,空间复杂度为:O(N)
实例3:
// 计算阶乘递归Fac的空间复杂度
long long Fac(size_t N)
{
if (N == 1)
return 1;
return Fac(N - 1) * N;
}
递归调用了N次,最多递归了N - 1层,开辟了N个栈帧,每个栈帧使用了常数个空间,空间复杂度为:O(N)
思考这道题:
// 计算斐波那契递归Fib的空间复杂度
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
空间是可以重复利用的,栈帧被开辟,返回时又会被销毁,最多递归了N - 1层,开辟了N - 1个栈帧,每个栈帧使用了常数个空间,空间复杂度为:O(N)
时间复杂度大小比较:O(1) < O(logn) < O(n) < O(nlogn) < O(n2) < O(n3) < O(2n)
复杂度介绍完啦,下次再见!