2.1复杂度是什么
复杂度是衡量算法和数据结构性能的重要指标。用于描述算法执行所需资源(如时间和空间)的增长率。复杂度可以帮助我们评估算法的效率,并在不同算法之间进行比较。在算法和数据结构的复杂度分析中,我们通常考虑时间复杂度和空间复杂度。
2.2时间复杂度
时间复杂度:时间复杂度描述了算法在运行过程中所需的时间资源。它表示算法执行所需的操作次数或基本运算数量,通常用大O符号(O)来表示。时间复杂度越低,算法执行所需的时间越少,效率越高。
2.2.1函数渐近上界
给定一个输入大小为 n的函数:
void algorithm(int n) {
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// 循环 n 次
for (int i = 0; i < n; i++) { // +1(每轮都执行 i ++)
cout << 0 << endl; // +1
}
}
设算法的操作数量是一个关于输入数据大小 n的函数,记为 T(n) ,则以上函数的的操作数量为:
T(n)=3+2n
T(n)是一次函数,说明其运行时间的增长趋势是线性的,因此它的时间复杂度是线性阶。
我们将线性阶的时间复杂度记为 O(n) ,这个数学符号称为大 O 记号 ,表示函数 T(n)的渐近上界 (asymptotic upper bound)。
时间复杂度分析本质上是计算“操作数量函数T(n)的渐近上界,其具有明确的数学定义。
如图下图所示,计算渐近上界就是寻找一个函数f(n),使得当 n 趋向于无穷大时,T(n)和f(n)处于相同的增长级别,仅相差一个常数项 c 的倍数。
2.2.2 推算方法
如果你感觉没有完全理解渐近上界,也无须担心。在实际使用中,我们只需要掌握推算方法,数学意义就可以逐渐领悟。
根据定义,确定f(n)之后,我们便可得到时间复杂度 O(f(n)) 。那么如何确定渐近上界 f(n) 呢?总体分为两步:首先统计操作数量,然后判断渐近上界。
2.2.2.1第一步:统计操作数量
针对代码,逐行从上到下计算即可。然而,由于上述 c*f(n) 中的常数项 c 可以取任意大小,因此操作数量T(n)中的各种系数、常数项都可以被忽略。根据此原则,可以总结出以下计数简化技巧。
- 忽略T(n)中的常数项。因为它们都与n 无关,所以对时间复杂度不产生影响。
- 省略所有系数。例如,循环 2n 次、5n+1 次等,都可以简化记为n次,因为 n 前面的系数对时间复杂度没有影响。
- 循环嵌套时使用乘法。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第
1.
点和第2.
点的技巧。
给定一个函数,我们可以用上述技巧来统计操作数量。
void algorithm(int n) {
int a = 1; // +0(技巧 1)
a = a + n; // +0(技巧 1)
// +n(技巧 2)
for (int i = 0; i < 5 * n + 1; i++) {
cout << 0 << endl;
}
// +n*n(技巧 3)
for (int i = 0; i < 2 * n; i++) {
for (int j = 0; j < n + 1; j++) {
cout << 0 << endl;
}
}
}
以下公式展示了使用上述技巧前后的统计结果,两者推出的时间复杂度都为 O(n^2) 。
2.2.2.2第二步:判断渐近上界
时间复杂度由多项式T(n)中最高阶的项来决定。这是因为在 n 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以被忽略。
下表展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当 n 趋于无穷大时,这些常数变得无足轻重。
2.2.3常见时间复杂度
设输入数据大小为 n ,常见的时间复杂度类型下图所示。
2.2.4最差、最佳、平均时间复杂度
最差时间复杂度(Worst-case Time Complexity)是指在最坏情况下,算法执行所需的时间复杂度。它考虑了算法在最坏情况下的表现,即输入数据规模最大或最复杂时的情况。对于一个给定的算法,最差时间复杂度通常是在最坏情况下的时间复杂度上界,它提供了算法在最坏情况下的性能保证。
最佳时间复杂度(Best-case Time Complexity)是指在最佳情况下,算法执行所需的时间复杂度。它考虑了算法在最佳情况下的表现,即输入数据规模最小或最简单时的情况。对于一个给定的算法,最佳时间复杂度通常是在最佳情况下的时间复杂度下界,它提供了算法在最佳情况下的性能上限。
平均时间复杂度(Average-case Time Complexity)是指在平均情况下,算法执行所需的时间复杂度。它考虑了算法在各种输入数据规模和复杂度下的平均表现。平均时间复杂度通常是通过对算法在所有可能输入情况下的时间复杂度进行加权平均得到的。平均时间复杂度可以更好地反映算法在实际应用中的性能,因为它考虑了不同输入数据的分布情况。
以冒泡排序为例,来解释这三种复杂度的概念:
void bubbleSort(int arr[], int n) {
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr[j], arr[j + 1]);
}
}
}
}
- 最差时间复杂度:最差情况下,每个元素都需要与其他元素进行比较和交换,即数组本身是逆序的。此时,内部循环的执行次数为(1+2+3+...+n) = n*(n+1)/2,因此时间复杂度为O(n^2)。例如:
int arr[] = {5, 4, 3, 2, 1}; bubbleSort(arr, 5); // 最差情况下,内部循环共执行15次
- 最佳时间复杂度:最佳情况下,输入数据已经完全有序,内部循环不需要执行任何操作,即时间复杂度为O(n)。例如:
int arr[] = {1, 2, 3, 4, 5}; bubbleSort(arr, 5); // 最佳情况下,内部循环不需要执行任何操作
- 平均时间复杂度:平均情况下,我们需要考虑所有可能的输入数据。假设输入数据为n个元素的随机排列,那么每个元素在某一个位置上的概率相等,即1/n。因此,内部循环执行次数的期望值为(1+2+3+...+n)/n = (n+1)/2,时间复杂度为O(n^2)。例如:
int arr[] = {3, 2, 4, 1, 5}; bubbleSort(arr, 5); // 平均情况下,内部循环共执行10次
这三个时间复杂度之间的关系如下:
- 最差时间复杂度提供了算法在最坏情况下的性能保证,它是算法的性能下限。
- 最佳时间复杂度提供了算法在最佳情况下的性能上限,它是算法的性能上限。
- 平均时间复杂度则更好地反映了算法在实际应用中的性能,它是算法的性能平均值。
值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。而最差时间复杂度更为实用,因为它给出了一个效率安全值,让我们可以放心地使用算法。
2.3 空间复杂度
空间复杂度:空间复杂度描述了算法在运行过程中所需的额外空间资源。它表示算法执行所需的额外内存空间或存储单元的数量,通常也用大O符号(O)来表示。空间复杂度越低,算法所需的额外空间越少,节省内存资源。
2.3.1算法相关空间
算法在运行过程中使用的内存空间主要包括以下几种。
- 输入空间:用于存储算法的输入数据。
- 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。
- 输出空间:用于存储算法的输出数据。
一般情况下,空间复杂度的统计范围是“暂存空间”加上“输出空间”。
暂存空间可以进一步划分为三个部分。
- 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
- 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
- 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。
在分析一段程序的空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分。
2.3.2推算方法
空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”。而与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
当分析算法的空间复杂度时,可以遵循以下步骤:
-
分析算法中使用的数据结构:查看算法中是否使用了数组、链表、栈、队列等数据结构,以及它们的大小和数量。
-
分析算法中使用的变量:查看算法中定义的各种变量、指针、引用等,以及它们的存储空间大小。
-
分析递归调用的空间占用:如果算法包含递归调用,需要考虑递归调用所使用的栈空间。
-
计算总体空间占用:根据以上分析,计算算法在执行过程中所使用的额外空间大小。
-
总结空间复杂度:根据算法的空间占用情况,给出算法的空间复杂度表示,通常用大O记号来表示。
需要注意的是,在计算空间复杂度时,我们通常不考虑输入参数的空间占用,因为输入参数是在调用函数时传入的,不是在算法内部分配的。只计算算法内部动态分配、临时存储或缓冲区等使用的额外空间。
2.3.3常见空间复杂度
设输入数据大小为 n ,常见的空间复杂度类型下图所示。
2.4权衡时间与空间
理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常是非常困难的。降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然。
-
时间优先:对于需要快速执行的场景,可以选择时间优先的策略。这意味着可以牺牲一定的空间复杂度来提高算法的执行速度。例如,使用缓存或预计算的方式来减少重复计算的时间。
-
空间优先:对于内存资源有限的场景,可以选择空间优先的策略。这意味着可以牺牲一定的时间复杂度来减少算法的空间占用。例如,使用压缩算法或优化数据结构来减少内存使用量。
-
平衡权衡:在某些情况下,需要权衡时间和空间,并找到一个平衡点。这意味着寻找时间和空间之间的最佳折中方案,使得算法既能在合理的时间内执行,又不过度消耗内存资源。例如,通过合理地选择数据结构、使用适当的算法优化技巧等来平衡时间和空间的需求。