时间复杂度
什么是时间复杂度?
时间复杂度是用来描述算法执行所需时间随输入规模变化而变化的函数。通过时间复杂度,我们可以预测算法在处理大数据时的性能。它通常用大O符号(Big O notation)表示,专注于描述算法在最坏情况下的表现。
如何推导大 O 阶?
推导大 O 阶的步骤包括:
- 识别基本操作:找到算法中最核心的操作,如比较、交换、赋值等。
- 计数基本操作的执行次数:根据输入规模(通常记为 n),计算这些操作的执行次数。
- 忽略低阶项和常数系数:在大规模输入下,低阶项和常数系数对增长速度的影响较小,因此可以忽略。
- 用大 O 符号表示结果:最终用大 O 符号表示复杂度,强调增长率而非具体时间。
常见的时间复杂度分类
1. O(1) - 常数时间复杂度(常数阶)
定义:不管输入规模多大,算法的运行时间都是恒定的。
类比:想象你在一座图书馆寻找一本特定的书,你事先知道书的确切位置。你直接走到那个位置拿下书,无需遍历整个图书馆。这就是O(1)的时间复杂度。
示例:访问数组的某个元素。
int getElement(int arr[], int index) {
return arr[index];
}
解释:不管数组有多大,访问任意元素的时间都是一样的。
2. O(n) - 线性时间复杂度(线性阶)
定义:算法的运行时间与输入规模成正比。
类比:想象你要检查书架上的每一本书,看看哪一本是最重的。你需要查看每一本书一次,这就是O(n)的时间复杂度。
示例:遍历一个数组。
int findMax(int arr[], int size) {
int max = arr[0];
for (int i = 1; i < size; i++) {
if (arr[i] > max) {
max = arr[i];
}
}
return max;
}
解释:需要遍历整个数组,随着数组长度的增加,运行时间也线性增加。
3. O(n^2) - 平方时间复杂度(平方阶)
定义:算法的运行时间与输入规模的平方成正比。
类比:想象你在一个教室里,每个学生都需要和其他每一个学生握手。如果有n个学生,每个人都要握手(n-1)次,总共需要握手n*(n-1)/2次,这就是O(n^2)的时间复杂度。
示例:冒泡排序。
void bubbleSort(int arr[], int size) {
for (int i = 0; i < size - 1; i++) {
for (int j = 0; j < size - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
解释:每次都需要比较和交换未排序部分的所有元素,内外两个循环都遍历数组,使得运行时间随着输入规模的平方增长。
当然,让我们详细讲解线性对数时间复杂度(O(n log n))、对数时间复杂度(O(log n))、指数时间复杂度(O(2^n))和阶乘时间复杂度(O(n!))的定义、类比和示意。
4. O(log n) - 对数时间复杂度
定义:算法的运行时间与输入规模的对数成正比,通常基于二进制对数。
类比:想象你在一本有序的电话簿中查找某个人的电话号码。如果每次你都将搜索范围缩小一半,这种查找方式就是二分查找。每次查询后,你只需再查找剩余的一半数据。
示例:二分查找
int binarySearch(int arr[], int size, int target) {
int left = 0, right = size - 1;
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(log n)。
5. O(n log n) - 线性对数时间复杂度
定义:算法的运行时间与输入规模的乘积成正比,其中一个因子是对数。
类比:想象你有一大堆需要排序的纸张。你每次将纸张分成两半,然后分别排序每一半,最后再合并。这种方法类似于归并排序。
示例:归并排序
void mergeSort(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);
}
}
void merge(int arr[], int left, int mid, int right) {
// 合并两个子数组的逻辑
}
解释:归并排序每次将数组分成两半,然后合并,每个层级的合并操作是 O(n),而分层级数是 O(log n),因此总时间复杂度是 O(n log n)。
6. O(2^n) - 指数时间复杂度
定义:算法的运行时间与输入规模的指数成正比。
类比:想象你在打电话传递信息,每个接到电话的人都再打给另外两个人,信息迅速传播。每增加一个人,电话数量会以指数增长。
示例:斐波那契数列的递归计算
int fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
解释:每次计算 fibonacci(n) 都需要计算 fibonacci(n-1) 和 fibonacci(n-2),随着 n 的增大,计算次数呈指数增长,因此时间复杂度是 O(2^n)。
7. O(n!) - 阶乘时间复杂度
定义:算法的运行时间与输入规模的阶乘成正比。
类比:想象你在安排一场大型宴会,需要安排 n 个嘉宾的座位,每个座位的排列组合数量是 n!(n 的阶乘)。排列组合的数量随着 n 的增加迅速增长。
示例:排列组合生成
void permute(int arr[], int l, int r) {
if (l == r) {
// 输出排列
} else {
for (int i = l; i <= r; i++) {
swap(&arr[l], &arr[i]);
permute(arr, l + 1, r);
swap(&arr[l], &arr[i]); // 回溯
}
}
}
解释:每次生成一个排列时,都要考虑每个元素的位置,排列组合的数量是 n!,因此时间复杂度是 O(n!)。
示意图
以下是各类时间复杂度的示意图,用于直观展示随输入规模 n 增长时,算法运行时间的增长速度:
时间复杂度图示(横轴为输入规模 n,纵轴为运行时间):
运行时间
|
| O(n!)
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
|*
|
| O(2^n)
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
|*
|
| O(n^2)
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
|*
|
| O(n log n)
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
|*
|
| O(n)
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
|*
|
| O(log n)
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
| *
|*
|
| O(1)
|*****************************
|----------------------------------------------------
这些示意图展示了不同时间复杂度的增长趋势,从常数时间复杂度 O(1) 到阶乘时间复杂度 O(n!),帮助我们更直观地理解它们随输入规模变化的表现。
推导大 O 阶的详细示例
让我们更详细地推导一个算法的时间复杂度:
void exampleAlgorithm(int arr[], int size) {
int sum = 0; // O(1)
for (int i = 0; i < size; i++) { // O(n)
sum += arr[i]; // O(1)
}
for (int i = 0; i < size; i++) { // O(n)
for (int j = 0; j < size; j++) { // O(n)
arr[j] = sum; // O(1)
}
}
}
步骤:
-
识别基本操作:
int sum = 0;
是一个赋值操作,时间复杂度是 O(1)。sum += arr[i];
是一个加法和赋值操作,时间复杂度是 O(1)。arr[j] = sum;
是一个赋值操作,时间复杂度是 O(1)。
-
计数基本操作的执行次数:
- 第一个
for
循环:执行size
次,每次执行一个 O(1) 操作,因此总时间复杂度是 O(n)。 - 第二个
for
循环:包含一个嵌套的for
循环,总体执行次数是size * size
,每次执行一个 O(1) 操作,因此总时间复杂度是 O(n^2)。
- 第一个
-
忽略低阶项和常数系数:
- 总时间复杂度是 O(1) + O(n) + O(n^2)。
- 在大规模输入下,O(n^2) 会主导增长,因此忽略 O(1) 和 O(n)。
-
用大 O 符号表示结果:
- 最终时间复杂度是 O(n^2)。