一、时间复杂度(Time Complexity)
1. 概念
时间复杂度是用来衡量算法运行时间随着输入规模增长而增长的量级。它主要关注的是算法执行基本操作的次数与输入规模之间的关系,而非具体的运行时间(因为实际运行时间会受硬件、编程语言实现等多种因素影响),旨在从理论上分析算法的效率高低。
2. 大 O 表示法(Big O Notation)
这是描述时间复杂度最常用的方式。它用一个函数来定性描述算法的运行时间与输入规模的关系。例如,对于一个排序算法,如果它的时间复杂度表示为 O(n^2),这意味着随着输入数据量(通常用n表示元素个数等输入规模相关的量)的增加,其运行时间大致按输入规模的平方量级增长。
常见的大 O 表示法的时间复杂度类型有:
- 常数阶 O(1):无论输入规模n怎么变化,算法执行的基本操作次数是固定的,比如下面这个简单的函数:
int add(int a, int b) {
return a + b;
}
这个函数不管传入什么样的整数参数,它都只执行了一次加法操作,基本操作次数恒定,所以时间复杂度是O(1)
- 线性阶 :基本操作次数与输入规模 成正比。例如,遍历一个长度为 的数组并打印每个元素的操作:
#include <iostream>
void printArray(int arr[], int n) {
for (int i = 0; i < n; ++i) {
std::cout << arr[i] << " ";
}
}
这里循环会执行n次,随着数组元素个数 n的增多,操作次数线性增长,时间复杂度就是O(n) 。
- 平方阶 O(n^2):通常出现在嵌套循环中,外层循环执行n次,内层循环对于外层循环的每一次执行也执行 n 次,基本操作次数就是 n×n=n^2次。比如简单的冒泡排序算法的核心代码:
#include <iostream>
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]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
这里有两层嵌套循环,总的比较和交换操作次数大约是n^2量级,所以时间复杂度是O(n^2) 。
- 对数阶O(log n) :例如在二分查找算法中,每次查找都会把搜索区间缩小一半,假设数据规模是n ,最多需要查找的次数 k满足n/2^k=1 (也就是最后只剩下一个元素还没确定是否是目标元素的时候停止查找),解这个等式可得k=log2 n ,所以二分查找的时间复杂度是O(log n) (通常省略底数 2,因为不同底数的对数之间只差一个常数系数,在大 O 表示法中可以忽略常数)。示例代码如下:
#include <iostream>
int binarySearch(int arr[], int left, int right, int target) {
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == target) {
return mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
- 线性对数阶 O(nlog n):像快速排序、归并排序等高效的排序算法,它们的平均时间复杂度就是O(n log n) 。这些算法在每一层递归或者划分阶段基本操作次数和 n有关,而递归或者划分的层数和 log n 有关,综合起来就是O(n log n) 。以归并排序为例,代码如下:
#include <iostream>
#include <vector>
// 合并两个已排序的子数组
void merge(std::vector<int>& arr, int left, int mid, int right) {
int n1 = mid - left + 1;
int n2 = right - mid;
std::vector<int> L(n1), R(n2);
for (int i = 0; i < n1; ++i) {
L[i] = arr[left + i];
}
for (int j = 0; j < n2; ++j) {
R[j] = arr[mid + 1 + j];
}
int i = 0, j = 0, k = left;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k++] = L[i++];
} else {
arr[k++] = R[j++];
}
}
while (i < n1) {
arr[k++] = L[i++];
}
while (j < n2) {
arr[k++] = R[j++];
}
}
// 归并排序主函数
void mergeSort(std::vector<int>& arr, int left, int right) {
if (left < right) {
int mid = left + (right - left) / 2;
mergeSort(arr, left, mid);
mergeSort(arr, mid + 1, right);
merge(arr, left, mid, right);
}
}
3. 计算时间复杂度的步骤
- 确定算法中的基本操作,比如赋值、比较、算术运算等操作,这些操作执行次数会随着输入规模变化而影响时间复杂度。
- 分析基本操作执行次数与输入规模 的关系,用数学表达式表示出来,比如是 3n^2+5n+2 这样的式子。
- 按照大 O 表示法的规则,忽略低阶项(如 5n+2 相对于 3 × n^2 在 n 足够大时影响较小)和常数系数(这里的 3),得到最终的时间复杂度表示,像上面例子就是O(n^2) 。
二、空间复杂度(Space Complexity)
1. 概念
空间复杂度衡量的是算法运行过程中所需要的额外存储空间与输入规模之间的关系。这里强调的是额外存储空间,也就是除了输入数据本身所占空间之外,算法执行过程中临时开辟的空间。
2. 同样用大 O 表示法
- 常数阶 :算法运行过程中,不管输入规模如何变化,额外开辟的存储空间大小是固定的。例如下面这个函数,只是用了几个固定的变量,没有随输入规模增大而增大的额外空间需求:
int sum(int n) {
int result = 0;
for (int i = 1; i <= n; ++i) {
result += i;
}
return result;
}
整个函数执行过程中,只开辟了 result
和 i
这两个固定大小的变量空间,所以空间复杂度是 O(1)。
- 线性阶 O(n):如果算法执行过程中额外开辟的空间和输入规模 n成正比。比如创建一个长度为 n 的数组来存储数据,代码如下:
#include <iostream>
#include <vector>
std::vector<int> createArray(int n) {
std::vector<int> arr(n);
for (int i = 0; i < n; ++i) {
arr[i] = i;
}
return arr;
}
这里创建的 vector
数组大小取决于输入的参数 n
,所以空间复杂度是 O(n)。
- 平方阶O(n^2) :例如二维数组的情况,当创建一个 n×n 的二维数组时,其元素个数就是 n^2 个,额外空间和 n^2 成正比,空间复杂度就是 O(n^2),示例代码如下:
#include <iostream>
#include <vector>
std::vector<std::vector<int>> createMatrix(int n) {
std::vector<std::vector<int>> matrix(n, std::vector<int>(n));
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
matrix[i][j] = i * j;
}
}
return matrix;
}
3. 计算空间复杂度的要点
- 找出算法中随着输入规模变化而动态分配的额外存储空间,像动态申请的数组、递归调用栈等占用的空间。
- 分析这些额外空间大小与输入规模 n 的数量关系,用大 O 表示法来表示,同样忽略低阶项和常数系数等,得到最终的空间复杂度描述。
总之,在实际的 C++ 编程以及算法设计中,时间复杂度和空间复杂度是衡量算法优劣的重要指标,需要根据具体的应用场景(如对时间要求高还是对空间要求高)来选择合适的算法和数据结构,平衡两者之间的关系。