时间复杂度描述了算法运行时间如何随输入规模的增长而增长。它可以根据不同目的用不同的记号表示,为算法提供上界、下界或平均性能。
数学上,时间复杂度可以定义为一个函数 ,其中 是所有算法的集合1, 是输入大小, 是以 为输入的可能函数的集合,通常 ,其中 , 是算法的输入大小。当算法 的输入大小为 时,其时间复杂度为 ,其中 。
这可能有点抽象,但这并不是我们在实践中常用的通用时间复杂度定义。在实际应用中,我们为时间复杂度增加了另一层抽象,从不同方面对算法进行基准测试,例如最坏情况、最好情况和平均情况时间复杂度。我们将在以下章节中讨论这些。
让我们考虑线性搜索的例子,这是一种在数组中搜索目标元素的简单算法。线性搜索算法的伪代码如下所示。我们简单地指定一个目标元素,并从数组的开头到结尾遍历数组,将每个元素与目标进行比较。如果找到目标,我们返回元素的索引;否则,我们返回 -1 以表示目标不在数组中。现在我们将分析线性搜索算法的时间复杂度。
算法 1 线性搜索
Require: 数组 ,目标值
Ensure: 的索引,若不存在则返回
1:for to do
2:if then
3:return // 在索引 处找到目标值
4:end if
5:end for
6:return // 未找到目标值
为了弄清楚线性搜索的时间复杂度,我们首先需要确定哪个元数据决定了算法的输入大小。显然,在这个算法中,我们只线性地遍历数组,这意味着算法的输入大小是数组的长度。因此,我们将输入大小表示为 ,线性搜索算法的时间复杂度是 ,其中 是数组的长度。
现在我们看一个稍微不同的例子,它也与线性搜索有关。
def linear_search(arr: list[int], target: int) -> int: """ 用于在数组中查找目标元素的线性搜索算法。 """ for i in range(len(arr)): if arr[i] == target: return i # 目标在索引 i 处找到 return -1 # 在数组中未找到目标
target = 5target_list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]print(linear_search(target_list, target)) # 输出: 4在此调用中,线性搜索算法的时间复杂度是多少?
答案是常数函数,,而不是线性函数,。原因是这里的数组长度是固定的,而不是一个变量。因此,此调用中线性搜索算法的时间复杂度是常数。对于一个长度为 100,000 的给定数组也是如此,因为数组的长度是固定的。这个例子可能会让一些人感到困惑,但别担心,我们将在以下章节中使用线性搜索作为示例,明确讨论最坏情况、最好情况和平均情况时间复杂度。
最坏情况时间复杂度 (大O记号)
正如我们所说,当我们评估算法的时间复杂度时,我们通常会考虑某些因素,最常见的一个因素是算法的性能有多差。这被称为最坏情况时间复杂度,它为算法的运行时间提供了一个上界。我们使用大O记号来表示最坏情况时间复杂度,例如 , , 等。
假设算法的输入是可变大小 ,我们使用大O记号来分析当 时时间复杂度的渐近上界。我们断言,在最坏情况下,输入大小为 的算法的时间复杂度 满足 ,如果 存在常数 和 使得对于所有 ,。 其中 是算法的精确时间复杂度, 是时间复杂度的上界。
用自然语言来说,如果存在一个常数 和一个正整数 ,使得对于所有大于 的输入大小 ,算法的时间复杂度上界为 ,那么我们说算法的时间复杂度是 。
尽管如此,我们仍然以线性搜索为例,尽管它很特殊,因为它的实际最坏情况时间复杂度与大O记号完全相同,但在其他算法中情况并非总是如此。我们将在未来看到更多例子。
对于线性搜索算法,最坏情况时间复杂度是 ,其中 是数组的长度。这是因为,我们有 ,和 ,当 时,对于所有 ,。
在这种情况下,我们了解到的关于最坏情况时间复杂度的信息比我们需要的多,因为我们只需要对于所有 ,,然而我们有对于所有 ,。这是由线性搜索算法的性质决定的,它非常简单直接。然而,同样,这并非总是如此,我们将在未来看到更多例子。
最好情况时间复杂度 (大Omega记号)
与最坏情况时间复杂度相反,我们有最好情况时间复杂度,它为算法的运行时间提供了一个下界。我们使用大Omega记号来表示最好情况时间复杂度,例如 , , 等。
假设算法的输入是可变大小 ,我们使用大Omega记号来分析当 时时间复杂度的渐近下界。我们断言,在最好情况下,输入大小为 的算法的时间复杂度 满足 ,如果 存在常数 和 使得对于所有 ,。 其中 是算法的精确时间复杂度, 是时间复杂度的下界。
用自然语言来说,如果存在一个常数 和一个正整数 ,使得对于所有大于 的输入大小 ,算法的时间复杂度下界为 ,那么我们说算法的时间复杂度是 。
我们再次使用线性搜索作为示例来说明最好情况时间复杂度。
对于线性搜索算法,最好情况时间复杂度是 ,这意味着最好情况时间复杂度是常数。这是因为,我们有 ,和 ,当 时,对于所有 ,。
平均情况时间复杂度 (大Theta记号)
令 为所有大小为 的输入实例的集合,令 表示算法在 中输入 上的运行时间。假设这些输入上有一个概率分布 ,例如均匀分布,这意味着对于所有 ,我们有 那么平均运行时间定义为 如果每个输入的可能性均等,则此表达式简化为
我们说一个算法的平均情况时间复杂度为 ,如果存在正常数 和 以及一个函数 ,使得对于所有足够大的 ,算法在大小为 的输入上的平均运行时间被 和 分别从下方和上方界定。换句话说,平均情况时间复杂度是 ,如果存在常数 , , 和 ,使得对于所有 ,我们有 。
平均情况分析是算法性能更现实的衡量标准,但它也比最坏情况或最好情况时间复杂度更难确定。它需要了解输入的概率分布以及算法在每个输入上的预期运行时间。
为了更容易理解,我们可以说,平均时间复杂度,在概率论中,是算法时间复杂度的期望值,给定一个输入的概率分布,这个分布通常是均匀的。
现在我们将讨论线性搜索算法的平均情况时间复杂度,作为平均情况分析的一个示例。
对于线性搜索算法,假设目标元素在数组中任何位置或根本不存在的可能性均等,平均情况时间复杂度是 。如果目标存在,平均比较次数是 。如果目标不存在,则需要 次比较。假设这 种情况(目标在索引 0 到 n-1,或不存在)的可能性均等,平均比较次数是 ,这简化为一个与 成正比的值。因此,。
空间复杂度
空间复杂度是对算法所需工作存储量的度量。也就是说,算法在任何一点需要的内存量,在最坏情况下是多少。与时间复杂度一样,我们通常关心的是空间需求如何随输入规模的增长而增长,并且我们使用渐近记号来描述这种增长。
令 为算法的空间复杂度,其中 是输入大小。这包括输入变量、辅助变量和输出变量的空间。
- 辅助空间: 这指的是算法使用的临时空间,不包括输入所占用的空间。对于许多算法,辅助空间复杂度比总空间复杂度更有趣,特别是当输入给定且不能修改时。
所呈现的线性搜索算法的空间复杂度为 。这是因为它只使用固定数量的变量(例如,i、target 和输入数组本身,它被认为是给定的)。所需的空间不会随输入数组 的大小而增长。
对于递归算法,空间复杂度必须考虑调用栈使用的空间。考虑计算第 个斐波那契数的递归实现:
def fib(n): if n <= 1: return n else: return fib(n-1) + fib(n-2)虽然它的时间复杂度很高,但它的空间复杂度由递归树的最大深度决定,即 。因此,。
算法复杂度的严谨解释2
对于具有数学分析和集合论背景的读者,我们可以进一步形式化算法复杂度的概念。
形式化时间和空间
令 为一个固定算法。令 为 所有可能输入的集合。对于每个输入 ,令 表示其大小。
-
成本函数 可以表示时间(操作数量)或空间(使用的内存单元数量)。
-
特定情况(最坏、最好、平均)的复杂度函数是一个函数 。
- 最坏情况复杂度:
- 最好情况复杂度:
- 平均情况复杂度: 需要在输入集合 上的概率分布 。那么,。对于大小为 的 个可能输入上的离散均匀分布,这变为 。
作为集合关系的渐近记号
记号 , , 和 描述了函数集合之间的关系。令 为所有函数 的集合。
当我们陈述 时,我们的意思是函数 (可以是 , 等)是集合 的一个元素。
计算模型的作用
精确的成本函数 取决于底层的计算模型(例如,随机存取机,图灵机)。例如:
- 在 RAM 上,算术运算可能需要 时间。
- 在多带图灵机上,如果数字可以任意大,它们可能需要 时间。
然而,在计算机科学的大多数算法分析中,隐含地假设了 RAM 模型,并且重点在于相对于输入大小的操作数量,抽象掉了常数因子和低阶项。这就是为什么渐近分析如此强大——它提供了一种与机器无关的方式来按基本增长率对算法进行分类。
讨论