时空复杂度
1. 算法基础知识
1.1 什么是算法
算法是一组明确定义的、有限的、具有特定顺序的计算机指令或规则,用于解决特定问题或完成特定任务。简而言之,算法就是解决问题的一系列清晰指令。一个好的算法应该具有以下特点:
- 输入:算法具有零个或多个输入
- 输出:算法至少有一个输出
- 有穷性:算法在执行有限的步骤后终止
- 确定性:算法的每一步都有确切的定义
- 可行性:算法的每一步都能在有限时间内完成
算法是计算机科学的基础,也是编程的灵魂。学习算法可以提高我们解决问题的能力,帮助我们设计出更加高效、优雅的程序。无论是在实际开发还是面试中,扎实的算法功底都是不可或缺的。
1.2 算法的特性
1.2 算法的特性
算法具有以下几个重要特性:
-
正确性(Correctness):算法应该能够正确地解决问题,对于合法的输入,算法应该产生正确的输出。
-
可读性(Readability):算法应该易于理解和实现。一个好的算法应该结构清晰、命名规范、注释完备,方便其他程序员理解和维护。
-
健壮性(Robustness):算法应该能够处理各种边界情况和异常输入,不会因为非法输入而崩溃或产生错误结果。
-
效率(Efficiency):算法应该尽可能地高效,在时间和空间上都有良好的性能。效率通常用时间复杂度和空间复杂度来衡量。
-
简洁性(Simplicity):算法应该尽可能地简洁,不包含多余的步骤。简洁的算法更容易理解、实现和维护。
-
可扩展性(Scalability):算法应该能够适应问题规模的增长,在处理大规模数据时仍然能保持良好的性能。
-
可复用性(Reusability):一个好的算法应该是通用的,可以应用于解决同类型的其他问题。
-
优雅性(Elegance):优雅的算法通常简洁、巧妙,能够用最少的代码解决问题,体现出设计者的智慧和创造力。
在设计和实现算法时,我们应该综合考虑这些特性,争取设计出正确、高效、简洁、优雅的算法。当然,在实际开发中,有时也需要在这些特性之间进行权衡取舍。
1.3 算法的设计原则
在设计算法时,我们应该遵循以下一些基本原则:
-
分治(Divide and Conquer):将复杂问题分解成若干个相同或相似的子问题,分别求解这些子问题,然后再合并子问题的解以得到原问题的解。这种方法可以将难以直接解决的大问题化简为规模较小、易于解决的小问题,如归并排序、快速排序等。
-
动态规划(Dynamic Programming):将复杂问题分解成若干个子问题,但不同的是这些子问题不是相互独立的,而是有重叠的部分。动态规划算法通过保存子问题的解来避免重复计算,如斐波那契数列、最长公共子序列等。
-
贪心(Greedy):在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是最好或最优的算法。贪心算法在有最优子结构的问题中尤为有效,如Huffman编码、Prim和Kruskal最小生成树算法等。
-
回溯(Backtracking):回溯算法是一种探索所有可能潜在解的算法,尽管候选解可能不是完整解。通过不断"回溯"寻找问题的解直到将问题解决。回溯算法适用于一些需要递归求解的问题,如N皇后问题、图的着色问题等。
-
分支限界(Branch and Bound):在用回溯方法解决最优化问题时,采用分支限界方法可以避免enumeration of all possible sequences。分支限界法是一种在问题的解空间树中搜索问题解的方法,在搜索至某一节点时,若其不可能产生最优解,则跳过对该节点为根的子树,从而缩小搜索范围。
-
减治(Reduction):将问题转化为规模较小的同类问题,通过解决这些较小的问题来解决原问题。这种方法通常用于简化复杂的问题,如将线性规划问题转化为单纯形法、将字符串匹配问题转化为KMP算法等。
-
转化(Transform):通过改变问题的表述方式或者数据的表示方式,将问题转化为另一个更容易解决的问题。如将矩阵链乘问题转化为动态规划问题,将字符串匹配问题转化为有限状态机等。
这些设计原则并不是孤立的,在实际设计算法时,往往需要综合运用多种方法。了解这些原则可以帮助我们找到解决问题的思路,设计出更加高效、优雅的算法。
2. 时间复杂度
2.1 什么是时间复杂度
时间复杂度是一个函数,它定量描述了一个算法的运行时间。更specifically,时间复杂度描述了一个算法的运行时间如何随着输入大小的增加而增长。通常使用大O符号来表示时间复杂度,大O符号描述了算法的上限,即最坏情况下的时间复杂度。
举个例子,如果一个算法的时间复杂度为O(n),这意味着该算法的运行时间与输入大小n成正比。如果输入大小加倍,算法的运行时间也将加倍。
我们为什么要关注时间复杂度呢?因为它可以帮助我们比较不同算法的效率,预测算法在大规模输入下的表现。两个可以解决同一问题的算法,时间复杂度更低的那个通常是更好的选择。
然而,时间复杂度并不是衡量算法优劣的唯一标准。有时,一个时间复杂度较高的算法可能因为常数因子较小而在实际运行中更快。此外,算法的空间复杂度(即算法需要的内存空间)也是需要考虑的重要因素。
尽管如此,时间复杂度仍然是评估算法效率的最重要指标之一。在设计和选择算法时,我们应该始终关注算法的时间复杂度,力争设计出时间复杂度尽可能低的算法。
案例:
假设我们有两个排序算法:插入排序和归并排序。插入排序的时间复杂度为O(n^2),而归并排序的时间复杂度为O(n log n)。从时间复杂度来看,归并排序显然优于插入排序。
然而,当我们实际运行这两个算法时,情况可能并非如此。让我们用C++实现这两个算法:
好的,让我们用C++来实现插入排序和归并排序,并比较它们的实际运行时间。
#include <iostream>
#include <vector>
#include <algorithm>
#include <random>
#include <chrono>
using namespace std;
using namespace std::chrono;
void insertion_sort(vector<int>& arr) {
for (int i = 1; i < arr.size(); i++) {
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
void merge(vector<int>& arr, int l, int m, int r) {
int n1 = m - l + 1;
int n2 = r - m;
vector<int> L(n1), R(n2);
for (int i = 0; i < n1; i++)
L[i] = arr[l + i];
for (int j = 0; j < n2; j++)
R[j] = arr[m + 1 + j];
int i = 0, j = 0, k = l;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k] = L[i];
i++;
}
else {
arr[k] = R[j];
j++;
}
k++;
}
while (i < n1) {
arr[k] = L[i];
i++;
k++;
}
while (j < n2) {
arr[k] = R[j];
j++;
k++;
}
}
void merge_sort(vector<int>& arr, int l, int r) {
if (l < r) {
int m = l + (r - l) / 2;
merge_sort(arr, l, m);
merge_sort(arr, m + 1, r);
merge(arr, l, m, r);
}
}
int main() {
vector<int> sizes = {10, 100, 1000, 10000};
random_device rd;
mt19937 gen(rd());
uniform_int_distribution<> dis(0, 1000);
for (int size : sizes) {
vector<int> arr1(size), arr2(size);
generate(arr1.begin(), arr1.end(), [&](){ return dis(gen); });
arr2 = arr1;
auto start_time = high_resolution_clock::now();
insertion_sort(arr1);
auto end_time = high_resolution_clock::now();
auto insertion_time = duration_cast<microseconds>(end_time - start_time);
start_time = high_resolution_clock::now();
merge_sort(arr2, 0, size - 1);
end_time = high_resolution_clock::now();
auto merge_time = duration_cast<microseconds>(end_time - start_time);
cout << "For size " << size << ":\n";
cout << "Insertion sort time: " << insertion_time.count() << " microseconds\n";
cout << "Merge sort time: " << merge_time.count() << " microseconds\n\n";
}
return 0;
}
在这个C++版本中,我们使用<chrono>
库来精确测量算法的运行时间,单位为微秒。我们也使用<random>
库来生成随机数组。
编译并运行这段代码,我们可能会得到如下输出:
For size 10:
Insertion sort time: 2 microseconds
Merge sort time: 8 microseconds
For size 100:
Insertion sort time: 16 microseconds
Merge sort time: 181 microseconds
For size 1000:
Insertion sort time: 1401 microseconds
Merge sort time: 2215 microseconds
For size 10000:
Insertion sort time: 140060 microseconds
Merge sort time: 26788 microseconds
对于小规模数据,插入排序比归并排序更快。但随着数据规模的增长,归并排序的优势变得明显。
需要注意的是,具体的运行时间会因机器和编译器的不同而有所差异。但总的趋势应该是一致的:插入排序在小规模数据上更快,但归并排序在大规模数据上更快。
这再次说明,在评估算法时,我们需要同时考虑时间复杂度和常数因子。一个理想的算法应该在这两个方面都有好的表现。
2.2 如何计算时间复杂度
计算时间复杂度的基本步骤如下:
- 找到算法的关键操作。
- 计算关键操作的执行次数。
- 用大O表示法表示执行次数。
让我们以几个具体的例子来说明这个过程。
例1:线性查找
int linear_search(int arr[], int n, int x) {
for (int i = 0; i < n; i++) {
if (arr[i] == x)
return i;
}
return -1;
}
在这个算法中,关键操作是比较arr[i]
和x
。这个操作在最坏情况下会执行n次(当x不在数组中时)。因此,这个算法的时间复杂度是O(n)。
例2:二分查找
int binary_search(int arr[], int l, int r, int x) {
while (l <= r) {
int m = l + (r - l) / 2;
if (arr[m] == x)
return m;
if (arr[m] < x)
l = m + 1;
else
r = m - 1;
}
return -1;
}
在这个算法中,关键操作是比较arr[m]
和x
。每次比较后,搜索范围会减半。因此,在最坏情况下,比较操作会执行log(n)次。所以这个算法的时间复杂度是O(log n)。
例3:冒泡排序
void bubble_sort(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]);
}
}
}
在这个算法中,关键操作是比较相邻元素并交换它们。在最坏情况下,每次比较都需要交换,这个操作会执行n(n-1)/2次。因此,这个算法的时间复杂度是O(n^2)。
例4:快速排序
int partition (int arr[], int low, int high) {
int pivot = arr[high];
int i = (low - 1);
for (int j = low; j <= high- 1; j++) {
if (arr[j] <= pivot) {
i++;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[high]);
return (i + 1);
}
void quick_sort(int arr[], int low, int high) {
if (low < high) {
int pi = partition(arr, low, high);
quick_sort(arr, low, pi - 1);
quick_sort(arr, pi + 1, high);
}
}
快速排序的时间复杂度分析比较复杂。在最坏情况下,每次分区只减少一个元素,时间复杂度退化为O(n^2)。但在平均情况下,快速排序的时间复杂度是O(n log n)。
为了分析平均情况,我们需要考虑递归树。在每一层递归中,我们需要处理所有的元素,但每次我们都将问题规模减半。这导致了一个log(n)的递归深度。在每一层,我们执行线性时间的分区。因此,快速排序的平均时间复杂度是O(n log n)。
从这些例子可以看出,计算时间复杂度的关键是找出算法的关键操作,并分析这个操作执行的次数。对于简单的算法,这通常很直观。但对于更复杂的算法,特别是那些涉及递归的算法,分析时间复杂度可能需要更多的数学工具和技巧。
尽管如此,掌握计算时间复杂度的基本方法对于设计和优化算法是非常重要的。它可以帮助我们预测算法的性能,识别性能瓶颈,并选择最适合特定问题的算法。
2.3 常见的时间复杂度表示法(大O表示法)
在计算时间复杂度时,我们通常使用大O表示法。大O表示法描述了当输入大小趋近无穷大时,算法的运行时间或空间需求的上界。
下面是一些常见的时间复杂度,按照从最好到最坏的顺序排列:
-
O(1) - 常数时间复杂度
无论输入大小如何,算法都会在constant time内完成。一个例子是访问数组的特定元素。 -
O(log n) - 对数时间复杂度
算法的运行时间与输入大小的对数成正比。一个典型的例子是二分查找。 -
O(n) - 线性时间复杂度
算法的运行时间与输入大小成正比。一个例子是线性查找。 -
O(n log n) - 线性对数时间复杂度
这通常是通过应用具有对数时间复杂度的算法多次来实现的。一个典型的例子是归并排序。 -
O(n^2) - 平方时间复杂度
算法的运行时间与输入大小的平方成正比。一个例子是冒泡排序。 -
O(2^n) - 指数时间复杂度
算法的运行时间随输入大小呈指数增长。一个例子是计算斐波那契数列的朴素递归解法。 -
O(n!) - 阶乘时间复杂度
算法的运行时间与输入大小的阶乘成正比。一个例子是旅行商问题的暴力解法。
一般来说,我们希望设计时间复杂度尽可能低的算法。O(1), O(log n)和O(n)都被认为是高效的,O(n log n)在大多数情况下也是可以接受的。但O(n^2)及以上的时间复杂度在输入大小较大时通常是不实用的。
然而,需要注意的是,大O表示法描述的是最坏情况下的时间复杂度。一个算法在平均情况下可能有更好的表现。例如,快速排序在最坏情况下的时间复杂度是O(n^2),但在平均情况下是O(n log n)。
此外,大O表示法忽略了常数因子和低阶项。所以,一个O(n)的算法可能比一个O(log n)的算法运行得更快,如果前者有更小的常数因子。
尽管有这些限制,大O表示法仍然是分析和比较算法效率的有力工具。它provides了一种简洁的方式来描述算法的性能,使我们能够在高层次上理解和比较不同的算法。
在设计算法时,我们应该始终努力达到最低的时间复杂度。但在实践中,我们也需要平衡时间复杂度与其他因素,如空间复杂度、代码复杂性和可读性。有时,一个时间复杂度稍高但更简单、更容易理解的算法可能比一个时间复杂度更低但非常复杂的算法更可取。
好的,让我们来看看这些常见时间复杂度的C++案例。
2.4 常见算法的时间复杂度(如:常数阶O(1),对数阶O(logn),线性阶O(n)等)
- O(1) - 常数时间复杂度
int getFirst(int arr[], int n) {
return arr[0]; // 无论数组有多大,这个操作总是需要相同的时间
}
- O(log n) - 对数时间复杂度
int binarySearch(int arr[], int l, int r, int x) {
while (l <= r) {
int m = l + (r - l) / 2;
if (arr[m] == x)
return m;
if (arr[m] < x)
l = m + 1;
else
r = m - 1;
}
return -1;
}
- O(n) - 线性时间复杂度
int linearSearch(int arr[], int n, int x) {
for (int i = 0; i < n; i++)
if (arr[i] == x)
return i;
return -1;
}
- O(n log n) - 线性对数时间复杂度
void mergeSort(int arr[], int l, int r) {
if (l < r) {
int m = l + (r - l) / 2;
mergeSort(arr, l, m);
mergeSort(arr, m + 1, r);
merge(arr, l, m, r);
}
}
- O(n^2) - 平方时间复杂度
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]);
}
- O(2^n) - 指数时间复杂度
int fibonacci(int n) {
if (n <= 1)
return n;
return fibonacci(n-1) + fibonacci(n-2);
}
- O(n!) - 阶乘时间复杂度
int factorial(int n) {
if (n == 0)
return 1;
return n * factorial(n - 1);
}
请注意,这些只是简单的例子来说明每种时间复杂度。在实践中,你可能会遇到更复杂的算法。但是,理解这些基本的时间复杂度对于分析和设计高效的算法至关重要。
还需要注意的是,虽然我们使用这些例子来演示特定的时间复杂度,但实际上,一个算法的时间复杂度可能因输入的不同而有所不同。例如,快速排序在最坏情况下的时间复杂度是O(n^2),但在平均情况下是O(n log n)。
因此,在分析算法的时间复杂度时,我们通常考虑最坏情况,因为它给出了算法性能的上界。但在某些情况下,平均情况时间复杂度可能更有意义,特别是当最坏情况很少发生时。
3. 空间复杂度
3.1 什么是空间复杂度
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度。与时间复杂度类似,空间复杂度通常也使用大O表示法。
算法在执行时往往需要一些额外的空间来存储中间结果,例如变量、数组、对象等。这些额外的空间可以是常数大小,也可以随输入的大小而变化。
空间复杂度的计算包括以下两部分:
-
固定部分:指的是算法本身所消耗的空间,与输入数据的大小无关。这包括程序本身所占的空间、常量空间和固定大小的变量空间等。
-
可变部分:指的是算法执行过程中动态分配的空间,与输入数据的大小有关。这包括动态分配的数组、对象,以及递归调用时使用的栈空间等。
一个算法的总空间复杂度等于固定部分与可变部分之和。
让我们看一个例子:
int sum(int n) {
if (n <= 0) {
return 0;
}
return n + sum(n - 1);
}
在这个递归计算和的函数中,固定部分包括程序本身的空间和变量n
的空间。可变部分包括每次递归调用时在调用栈上创建的空间。由于最多会有n层递归,每层递归需要常数的空间,因此这个算法的空间复杂度是O(n)。
再看另一个例子:
int sum(int n) {
int result = 0;
for (int i = 1; i <= n; i++) {
result += i;
}
return result;
}
在这个迭代计算和的函数中,只使用了固定数量的变量,不管n有多大,所需的额外空间都是一样的。因此,这个算法的空间复杂度是O(1),也就是常数级别。
从这些例子可以看出,递归算法通常具有较高的空间复杂度,因为每次递归调用都需要额外的栈空间。相比之下,迭代算法通常具有较低的空间复杂度。
然而,这并不意味着递归算法总是坏的选择。在某些情况下,递归可以使算法更容易理解和实现。而且,一些递归算法可以通过尾递归优化来减少栈空间的使用。
在设计算法时,我们需要同时考虑时间复杂度和空间复杂度。一个好的算法应该在时间和空间上都有良好的性能。但在某些情况下,我们可能需要在时间和空间之间做出权衡。例如,我们可能选择使用更多的空间来换取更快的运行时间,或者选择使用更少的空间而牺牲一些性能。
总的来说,理解空间复杂度对于设计和优化算法非常重要。它可以帮助我们评估算法的内存使用情况,识别潜在的问题,并在必要时做出优化。
3.2 如何计算空间复杂度
计算空间复杂度的步骤与计算时间复杂度类似:
- 确定算法的输入大小。通常用变量n来表示。
- 分析算法,确定每个变量或数据结构所占的空间。
- 计算算法的总空间占用,并用大O表示法表示。
下面我们通过几个例子来说明如何计算空间复杂度。
例1:
int sumOfArray(int arr[], int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
sum += arr[i];
}
return sum;
}
在这个函数中,我们使用了两个整型变量sum
和i
,无论输入的数组有多大,这些变量所占的空间都是固定的。因此,这个算法的空间复杂度是O(1)。
例2:
void printPairs(int arr[], int n) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
cout << "(" << arr[i] << ", " << arr[j] << ")" << endl;
}
}
}
这个函数打印出数组中所有可能的数对。虽然它使用了嵌套循环,但是它并没有使用任何额外的数据结构。所使用的变量i
和j
的数量是固定的。因此,这个算法的空间复杂度也是O(1)。
例3:
int fibonacci(int n) {
if (n <= 1) {
return n;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
这是一个计算斐波那契数的递归函数。虽然它没有显式地使用任何额外的数据结构,但是每次递归调用都需要在调用栈上分配空间。在最坏的情况下,递归的深度可能达到n,因此这个算法的空间复杂度是O(n)。
例4:
void sortArray(int arr[], int n) {
vector<int> temp(n);
// 使用temp数组进行排序
}
在这个函数中,我们创建了一个大小为n的临时数组temp
。这个临时数组的大小随输入大小线性增长。因此,这个算法的空间复杂度是O(n)。
从这些例子可以看出,在计算空间复杂度时,我们需要考虑算法使用的所有数据结构和变量。对于递归算法,我们还需要考虑调用栈的空间使用。
在许多情况下,空间复杂度的计算比时间复杂度更直观,因为我们可以直接看到算法使用了哪些数据结构。但对于一些复杂的算法,特别是那些使用递归的算法,计算空间复杂度可能需要更仔细的分析。
在设计算法时,我们应该始终努力最小化空间复杂度,特别是当我们处理大规模数据时。高效利用内存不仅可以让我们的程序在有限的内存中运行,还可以减少内存访问的时间,从而提高程序的整体性能。
3.3 常见的空间复杂度表示法(大O表示法)
就像时间复杂度一样,我们通常使用大O表示法来描述空间复杂度。下面是一些常见的空间复杂度,按照从最好到最坏的顺序排列:
-
O(1) - 常数空间复杂度
算法只需要固定大小的额外空间,与输入大小无关。一个例子是交换两个数:void swap(int& a, int& b) { int temp = a; a = b; b = temp; }
-
O(log n) - 对数空间复杂度
算法需要的额外空间与输入大小的对数成正比。一个例子是二分查找(当然,这里指的是递归版本):int binarySearch(int arr[], int l, int r, int x) { if (r >= l) { int mid = l + (r - l) / 2; if (arr[mid] == x) return mid; if (arr[mid] > x) return binarySearch(arr, l, mid - 1, x); return binarySearch(arr, mid + 1, r, x); } return -1; }
-
O(n) - 线性空间复杂度
算法需要的额外空间与输入大小成正比。一个例子是创建一个与输入数组大小相同的临时数组:void func(int arr[], int n) { int temp[n]; // 使用temp数组进行一些操作 }
-
O(n log n) - 超线性空间复杂度
这通常发生在算法需要建立一个类似二叉树的结构来存储数据的情况下。一个例子是归并排序:void mergeSort(int arr[], int l, int r) { if (l < r) { int m = l + (r - l) / 2; mergeSort(arr, l, m); mergeSort(arr, m + 1, r); merge(arr, l, m, r); } }
-
O(n^2) - 平方空间复杂度
算法需要的额外空间与输入大小的平方成正比。一个例子是一个存储了所有数对的二维数组:void func(int n) { int arr[n][n]; // 使用这个二维数组进行一些操作 }
-
O(2^n) - 指数空间复杂度
算法需要的额外空间随输入大小呈指数增长。这通常发生在算法需要建立一个大小为2^n的结构(如集合的幂集)的情况下。
需要注意的是,虽然这些空间复杂度的形式与时间复杂度相似,但它们描述的是不同的东西。时间复杂度描述的是算法的运行时间,而空间复杂度描述的是算法的内存使用。
在实践中,我们经常会遇到空间复杂度为O(1),O(n)或O(n log n)的算法。O(log n)的空间复杂度相对较少见,主要出现在一些特定的递归算法中。O(n^2)及以上的空间复杂度通常应该尽量避免,因为它们会很快耗尽内存。
当然,就像时间复杂度一样,大O表示法只给出了增长率的上界。实际的内存使用可能会受到常数因子和低阶项的影响。此外,一些算法的空间复杂度可能会因输入的不同而有所不同。
在设计算法时,我们应该始终关注空间复杂度,并努力设计空间效率高的算法。但在某些情况下,我们可能需要在时间和空间之间做出权衡。理解这些常见的空间复杂度可以帮助我们做出更明智的设计决策。
4. 复杂度的渐进表示
4.1 大O表示法的定义
大O表示法是一种用于描述算法时间复杂度或空间复杂度的数学表示法。它表示了算法的性能随着输入大小的增长而增长的上限。更formally,我们说一个函数f(n)属于O(g(n)),如果存在正常数c和n0,使得对于所有n≥n0,都有0≤f(n)≤cg(n)。
大O表示法关注的是算法的增长率,而不是具体的常数因子。例如,如果一个算法的运行时间是2n^2+3n+1,
我们可以说它的时间复杂度是O(n^2),
因为当n趋向无穷大时,n^2项占主导地位。
常见的大O复杂度包括:
- O(1):常数复杂度
- O(log n):对数复杂度
- O(n):线性复杂度
- O(n log n):线性对数复杂度
- O(n^2):平方复杂度
- O(2^n):指数复杂度
一般来说,我们希望设计时间复杂度尽可能低的算法。O(1),O(log n)和O(n)都被认为是高效的,O(n log n)在大多数情况下也是可以接受的。但O(n^2)及以上的复杂度在输入大小较大时通常是不实用的。
4.2 其他渐进表示符号(大Ω和大Θ)
除了大O表示法,还有其他两个常用的渐进表示符号:大Ω和大Θ。
大Ω表示法用于描述算法的最佳情况复杂度,也称为渐进下界。我们说一个函数f(n)属于Ω(g(n)),如果存在正常数c和n0,使得对于所有n≥n0,都有0≤cg(n)≤f(n)。
大Θ表示法用于描述算法的平均情况复杂度,也称为渐进紧界。我们说一个函数f(n)属于Θ(g(n)),如果存在正常数c1,c2和n0,使得对于所有n≥n0,都有0≤c1g(n)≤f(n)≤c2g(n)。
换句话说,如果一个算法的时间复杂度是Θ(g(n)),那么它的最佳情况和最坏情况复杂度都是O(g(n))。
例如,二分查找的最佳情况时间复杂度是Ω(1),最坏情况时间复杂度是O(log n),平均情况时间复杂度是Θ(log n)。
4.3 复杂度的渐进上界和下界
在分析算法时,我们通常关注算法的渐进上界,即最坏情况复杂度。这是因为它保证了算法在任何输入下的性能。但在某些情况下,我们也需要考虑算法的渐进下界,即最佳情况复杂度。
例如,考虑一个算法,它的最佳情况时间复杂度是Ω(n),最坏情况时间复杂度是O(n^2)。虽然这个算法在最坏情况下的性能不佳,但如果我们知道输入数据通常接近最佳情况,那么这个算法可能在实践中表现得很好。
此外,有时我们可以通过分析算法的渐进下界来证明算法的最优性。如果我们可以证明任何解决该问题的算法的时间复杂度都不可能低于Ω(g(n)),并且我们的算法达到了这个下界,那么我们可以说我们的算法是最优的。
总的来说,虽然大O表示法是分析算法复杂度的主要工具,但大Ω和大Θ表示法在某些情况下也非常有用。同时考虑算法的上界和下界可以给我们一个更全面的算法性能图景。
习题
习题练习1
-
下列哪个时间复杂度描述的是算法的最优情况运行时间?
A. O(1)
B. Ω(1)
C. Θ(1)
D. o(1) -
如果一个算法的时间复杂度是O(n),那么当输入大小加倍时,运行时间会如何变化?
A. 加倍
B. 增加一个常数
C. 保持不变
D. 减半 -
以下哪个算法的平均情况时间复杂度是O(n log n)?
A. 冒泡排序
B. 插入排序
C. 选择排序
D. 快速排序 -
一个算法的时间复杂度是O(log n),这意味着什么?
A. 算法的运行时间与输入大小成正比
B. 算法的运行时间与输入大小的平方成正比
C. 算法的运行时间与输入大小的对数成正比
D. 算法的运行时间是常数 -
下列哪个大O表示法描述的是最差情况时间复杂度?
A. O(n)
B. Ω(n)
C. Θ(n)
D. o(n) -
一个算法的空间复杂度是O(n),这意味着什么?
A. 算法使用的额外空间与输入大小无关
B. 算法使用的额外空间与输入大小成正比
C. 算法使用的额外空间与输入大小的平方成正比
D. 算法使用的额外空间与输入大小的对数成正比 -
以下哪个算法的时间复杂度是O(n^2)?
A. 线性查找
B. 二分查找
C. 冒泡排序
D. 快速排序 -
如果一个算法的空间复杂度是O(1),那么当输入大小增加时,空间使用会如何变化?
A. 线性增加
B. 指数增加
C. 保持不变
D. 对数增加 -
下列哪个时间复杂度描述的是算法的最差情况运行时间?
A. O(n)
B. Ω(n)
C. Θ(n)
D. o(n) -
一个算法的时间复杂度是Θ(n),这意味着什么?
A. 算法的最优情况运行时间是O(n)
B. 算法的最差情况运行时间是O(n)
C. 算法的平均情况运行时间是O(n)
D. 算法的最优,最差和平均情况运行时间都是O(n) -
以下哪个算法的最差情况空间复杂度是O(n)?
A. 冒泡排序
B. 选择排序
C. 插入排序
D. 归并排序 -
如果一个算法的时间复杂度是O(n!),那么当输入大小增加时,运行时间会如何变化?
A. 线性增加
B. 指数增加
C. 保持不变
D. 阶乘增加 -
下列哪个大O表示法描述的是平均情况时间复杂度?
A. O(n)
B. Ω(n)
C. Θ(n)
D. o(n) -
一个算法的空间复杂度是O(log n),这意味着什么?
A. 算法使用的额外空间与输入大小无关
B. 算法使用的额外空间与输入大小成正比
C. 算法使用的额外空间与输入大小的平方成正比
D. 算法使用的额外空间与输入大小的对数成正比 -
以下哪个算法的时间复杂度是O(n log n)?
A. 线性查找
B. 二分查找
C. 冒泡排序
D. 归并排序 -
如果一个算法的空间复杂度是O(n^2),那么当输入大小加倍时,空间使用会如何变化?
A. 加倍
B. 增加四倍
C. 保持不变
D. 减半 -
下列哪个时间复杂度描述的是算法的所有情况运行时间?
A. O(n)
B. Ω(n)
C. Θ(n)
D. o(n) -
一个算法的时间复杂度是O(2^n),这意味着什么?
A. 算法的运行时间与输入大小成正比
B. 算法的运行时间与输入大小的平方成正比
C. 算法的运行时间与输入大小的对数成正比
D. 算法的运行时间随着输入大小指数增长 -
以下哪个算法的最优情况时间复杂度是O(n log n)?
A. 冒泡排序
B. 插入排序
C. 选择排序
D. 堆排序 -
如果一个算法的空间复杂度是Θ(n),那么当输入大小增加时,空间使用会如何变化?
A. 保持不变
B. 对数增加
C. 线性增加
D. 指数增加
习题解析1
好的,让我们来详细解析这20道题。
-
下列哪个时间复杂度描述的是算法的最优情况运行时间?
A. O(1)
B. Ω(1)
C. Θ(1)
D. o(1)答案: B
解析: Ω表示算法的最优情况时间复杂度,也称为最佳渐近行为。它描述了算法在最有利输入下的运行时间下界。 -
如果一个算法的时间复杂度是O(n),那么当输入大小加倍时,运行时间会如何变化?
A. 加倍
B. 增加一个常数
C. 保持不变
D. 减半答案: A
解析: 如果一个算法的时间复杂度是O(n),那么运行时间与输入大小成正比。当输入大小加倍时,运行时间也会加倍。 -
以下哪个算法的平均情况时间复杂度是O(n log n)?
A. 冒泡排序
B. 插入排序
C. 选择排序
D. 快速排序答案: D
解析: 快速排序的平均情况时间复杂度是O(n log n)。虽然它的最坏情况时间复杂度是O(n^2),但这种情况很少发生。 -
一个算法的时间复杂度是O(log n),这意味着什么?
A. 算法的运行时间与输入大小成正比
B. 算法的运行时间与输入大小的平方成正比
C. 算法的运行时间与输入大小的对数成正比
D. 算法的运行时间是常数答案: C
解析: 如果一个算法的时间复杂度是O(log n),那么运行时间与输入大小的对数成正比。这通常意味着算法的效率很高。 -
下列哪个大O表示法描述的是最差情况时间复杂度?
A. O(n)
B. Ω(n)
C. Θ(n)
D. o(n)答案: A
解析: O表示算法的最差情况时间复杂度,也称为渐近上界。它描述了算法在最不利输入下的运行时间上界。 -
一个算法的空间复杂度是O(n),这意味着什么?
A. 算法使用的额外空间与输入大小无关
B. 算法使用的额外空间与输入大小成正比
C. 算法使用的额外空间与输入大小的平方成正比
D. 算法使用的额外空间与输入大小的对数成正比答案: B
解析: 如果一个算法的空间复杂度是O(n),那么它使用的额外空间与输入大小成正比。 -
以下哪个算法的时间复杂度是O(n^2)?
A. 线性查找
B. 二分查找
C. 冒泡排序
D. 快速排序答案: C
解析: 冒泡排序的时间复杂度是O(n^2),因为它需要进行n(n-1)/2次比较和交换操作。 -
如果一个算法的空间复杂度是O(1),那么当输入大小增加时,空间使用会如何变化?
A. 线性增加
B. 指数增加
C. 保持不变
D. 对数增加答案: C
解析: 如果一个算法的空间复杂度是O(1),那么它使用的额外空间是常数,与输入大小无关。无论输入如何增加,空间使用都不会变化。 -
下列哪个时间复杂度描述的是算法的最差情况运行时间?
A. O(n)
B. Ω(n)
C. Θ(n)
D. o(n)答案: A
解析: O表示算法的最差情况时间复杂度,也称为渐近上界。它描述了算法在最不利输入下的运行时间上界。 -
一个算法的时间复杂度是Θ(n),这意味着什么?
A. 算法的最优情况运行时间是O(n)
B. 算法的最差情况运行时间是O(n)
C. 算法的平均情况运行时间是O(n)
D. 算法的最优,最差和平均情况运行时间都是O(n)答案: D
解析: Θ表示算法的平均情况时间复杂度,也称为渐近紧界。如果一个算法的时间复杂度是Θ(n),那么它的最优,最差和平均情况运行时间都是O(n)。 -
以下哪个算法的最差情况空间复杂度是O(n)?
A. 冒泡排序
B. 选择排序
C. 插入排序
D. 归并排序答案: D
解析: 归并排序的最差情况空间复杂度是O(n),因为它需要创建一个大小为n的临时数组来存储合并的结果。 -
如果一个算法的时间复杂度是O(n!),那么当输入大小增加时,运行时间会如何变化?
A. 线性增加
B. 指数增加
C. 保持不变
D. 阶乘增加答案: D
解析: 如果一个算法的时间复杂度是O(n!),那么运行时间会随着输入大小的增加而阶乘增加。这是一种非常低效的时间复杂度。 -
下列哪个大O表示法描述的是平均情况时间复杂度?
A. O(n)
B. Ω(n)
C. Θ(n)
D. o(n)答案: C
解析: Θ表示算法的平均情况时间复杂度,也称为渐近紧界。它描述了算法在随机输入下的预期运行时间。 -
一个算法的空间复杂度是O(log n),这意味着什么?
A. 算法使用的额外空间与输入大小无关
B. 算法使用的额外空间与输入大小成正比
C. 算法使用的额外空间与输入大小的平方成正比
D. 算法使用的额外空间与输入大小的对数成正比答案: D
解析: 如果一个算法的空间复杂度是O(log n),那么它使用的额外空间与输入大小的对数成正比。这通常发生在使用分治策略的算法中。 -
以下哪个算法的时间复杂度是O(n log n)?
A. 线性查找
B. 二分查找
C. 冒泡排序
D. 归并排序答案: D
解析: 归并排序的时间复杂度是O(n log n),因为它将问题分成两个子问题,然后递归地解决这些子问题。 -
如果一个算法的空间复杂度是O(n^2),那么当输入大小加倍时,空间使用会如何变化?
A. 加倍
B. 增加四倍
C. 保持不变
D. 减半答案: B
解析: 如果一个算法的空间复杂度是O(n^2),那么空间使用与输入大小的平方成正比。当输入大小加倍时,空间使用会增加四倍。 -
下列哪个时间复杂度描述的是算法的所有情况运行时间?
A. O(n)
B. Ω(n)
C. Θ(n)
D. o(n)答案: C
解析: Θ表示算法的平均情况时间复杂度,也称为渐近紧界。如果一个算法的时间复杂度是Θ(n),那么它的最优,最差和平均情况运行时间都是O(n)。 -
一个算法的时间复杂度是O(2^n),这意味着什么?
A. 算法的运行时间与输入大小成正比
B. 算法的运行时间与输入大小的平方成正比
C. 算法的运行时间与输入大小的对数成正比
D. 算法的运行时间随着输入大小指数增长答案: D
解析: 如果一个算法的时间复杂度是O(2^n),那么运行时间会随着输入大小指数增长。这是一种非常低效的时间复杂度。 -
以下哪个算法的最优情况时间复杂度是O(n log n)?
A. 冒泡排序
B. 插入排序
C. 选择排序
D. 堆排序答案: D
解析: 堆排序的最优,最差和平均情况时间复杂度都是O(n log n)。其他选项中的算法在最优情况下的时间复杂度都是O(n)。 -
如果一个算法的空间复杂度是Θ(n),那么当输入大小增加时,空间使用会如何变化?
A. 保持不变
B. 对数增加
C. 线性增加
D. 指数增加答案: C
解析: 如果一个算法的空间复杂度是Θ(n),那么它使用的额外空间与输入大小成正比。当输入大小增加时,空间使用会线性增加。