大多数计算机科学专业的毕业生肯定知道Big O代表什么。它帮助我们衡量算法的可扩展性。
但是我很好奇,你们是如何计算或近似计算算法复杂度的呢?
大多数计算机科学专业的毕业生肯定知道Big O代表什么。它帮助我们衡量算法的可扩展性。
但是我很好奇,你们是如何计算或近似计算算法复杂度的呢?
我会尽力用简单的术语来解释,但请注意这个主题需要我的学生们几个月才能最终掌握。您可以在《Java数据结构和算法》一书的第2章中找到更多信息。
没有机械程序可以用来获得BigOh。
作为一个“食谱”,要从一段代码中获取BigOh,你首先需要意识到你正在创建一个数学公式,以计算在给定某个大小的输入时执行了多少步计算。
目的很简单:从理论角度比较算法,无需执行代码。步骤越少,算法越快。
例如,假设你有这段代码:
int sum(int* data, int N) {
int result = 0; // 1
for (int i = 0; i < N; i++) { // 2
result += data[i]; // 3
}
return result; // 4
}
这个函数返回数组中所有元素的总和,我们想要创建一个公式来计算该函数的计算复杂度:
Number_Of_Steps = f(N)
所以我们有一个名为f(N)
的函数,用来计算计算步骤的数量。该函数的输入是要处理的结构的大小。这意味着该函数被调用如下:
Number_Of_Steps = f(data.length)
N
取data.length
的值。现在我们需要函数f()
的实际定义。这是从源代码中完成的,其中每个有趣的行都从1到4进行编号。C
个计算步骤。data
数组的大小。f(N) = C + ??? + C
for
语句的值。记住,我们正在计算计算步骤的数量,这意味着for
语句的主体会执行N
次。这相当于将C
加上N
次:f(N) = C + (C + C + ... + C) + C = C + N * C + C
没有机械规则来计算for循环体执行的次数,你需要通过观察代码来计数。为了简化计算,我们忽略了for语句中变量初始化、条件和增量部分。
要得到实际的BigOh,我们需要函数的渐近分析。大致步骤如下:
我们的f()有两项:
f(N) = 2 * C * N ^ 0 + 1 * C * N ^ 1
去除所有的C
常量和冗余部分:
f(N) = 1 + N ^ 1
由于最后一项是当 f()
趋近无穷大时增长最快的(请思考极限),这就是 BigOh 的论点,而 sum()
函数的 BigOh 为:
O(N)
有一些技巧可以解决一些棘手的问题:尽可能使用求和。
例如,可以使用求和轻松解决此代码:
for (i = 0; i < 2*n; i += 2) { // 1
for (j=n; j > i; j--) { // 2
foo(); // 3
}
}
首先需要问的是foo()
执行顺序。虽然通常为O(1)
,但您需要向教授询问。 O(1)
表示(几乎,大多数情况下)常数C
,与大小N
无关。
第一句话中的for
语句有些棘手。虽然索引结束于2*N
,但增量增加了两个。这意味着第一个for
只执行N
步,并且我们需要将计数除以二。
f(N) = Summation(i from 1 to 2 * N / 2)( ... ) =
= Summation(i from 1 to N)( ... )
第二个句子更加棘手,因为它取决于i的值。看一下:索引i取值为:0、2、4、6、8、...、2 * N,第二个for
循环被执行:第一个循环N次,第二个循环N-2次,第三个循环N-4次...一直到N/2阶段,在该阶段第二个for
循环不再被执行。
公式表示如下:
f(N) = Summation(i from 1 to N)( Summation(j = ???)( ) )
再次,我们正在计算步骤的数量。根据定义,每个求和公式应始于一,并以大于或等于一的数字结束。
f(N) = Summation(i from 1 to N)( Summation(j = 1 to (N - (i - 1) * 2)( C ) )
foo()
是O(1)
并且需要C
步。)i
取值N / 2 + 1
以上时,内部求和的结果为负数!这是不可能的,也是错误的。我们需要把求和分成两部分,在i
取N / 2 + 1
的时刻进行分割。f(N) = Summation(i from 1 to N / 2)( Summation(j = 1 to (N - (i - 1) * 2)) * ( C ) ) + Summation(i from 1 to N / 2) * ( C )
自关键时刻i > N / 2以来,内部的for
不会被执行,我们假设它的主体具有恒定的C执行复杂度。
现在,可以使用一些身份规则简化求和:
w
无关)应用一些代数:
f(N) = Summation(i from 1 to N / 2)( (N - (i - 1) * 2) * ( C ) ) + (N / 2)( C )
f(N) = C * Summation(i from 1 to N / 2)( (N - (i - 1) * 2)) + (N / 2)( C )
f(N) = C * (Summation(i from 1 to N / 2)( N ) - Summation(i from 1 to N / 2)( (i - 1) * 2)) + (N / 2)( C )
f(N) = C * (( N ^ 2 / 2 ) - 2 * Summation(i from 1 to N / 2)( i - 1 )) + (N / 2)( C )
=> Summation(i from 1 to N / 2)( i - 1 ) = Summation(i from 1 to N / 2 - 1)( i )
f(N) = C * (( N ^ 2 / 2 ) - 2 * Summation(i from 1 to N / 2 - 1)( i )) + (N / 2)( C )
f(N) = C * (( N ^ 2 / 2 ) - 2 * ( (N / 2 - 1) * (N / 2 - 1 + 1) / 2) ) + (N / 2)( C )
=> (N / 2 - 1) * (N / 2 - 1 + 1) / 2 =
(N / 2 - 1) * (N / 2) / 2 =
((N ^ 2 / 4) - (N / 2)) / 2 =
(N ^ 2 / 8) - (N / 4)
f(N) = C * (( N ^ 2 / 2 ) - 2 * ( (N ^ 2 / 8) - (N / 4) )) + (N / 2)( C )
f(N) = C * (( N ^ 2 / 2 ) - ( (N ^ 2 / 4) - (N / 2) )) + (N / 2)( C )
f(N) = C * (( N ^ 2 / 2 ) - (N ^ 2 / 4) + (N / 2)) + (N / 2)( C )
f(N) = C * ( N ^ 2 / 4 ) + C * (N / 2) + C * (N / 2)
f(N) = C * ( N ^ 2 / 4 ) + 2 * C * (N / 2)
f(N) = C * ( N ^ 2 / 4 ) + C * N
f(N) = C * 1/4 * N ^ 2 + C * N
而大O表示法是:
O(N²)
n
是元素数量,则为O(n)
,如果x
和y
是数组的维度,则为O(x*y)
。大O表示法是“相对于输入”的,因此它取决于您的输入是什么。 - Mooing Duck大 O 表示算法的时间复杂度上限。通常它与处理数据集(列表)一起使用,但也可以用于其他领域。
以下是如何在 C 代码中使用几个示例。
假设我们有一个包含 n 个元素的数组
int array[n];
x = array[0];
for(int i = 0; i < n; i++){
if(array[i] == numToFind){ return i; }
}
这将是O(n),因为我们最多需要查看整个列表才能找到我们的数字。即使我们第一次就找到了我们的数字并运行了一次循环,但Big-O仍然是O(n),因为Big-O描述了算法的上限(omega用于下限,theta用于紧密边界)。
当涉及到嵌套循环时:
for(int i = 0; i < n; i++){
for(int j = i; j < n; j++){
array[j] += 2;
}
}
对于每次外部循环的遍历(O(n)),我们必须再次遍历整个列表,因此n相乘,导致时间复杂度为O(n^2)。
这只是冰山一角,当你分析更复杂的算法时,会涉及到证明的复杂数学。但至少希望这让你熟悉基础。
O(1)
的工作。例如,在C标准API中,bsearch
本质上是O(log n)
,strlen
是O(n)
,而qsort
是O(n log n)
(严格来说它没有保证,快速排序本身的最坏情况复杂度是O(n²)
,但假设你的libc
作者不是个白痴,它的平均情况复杂度是O(n log n)
,并且使用了一种减少命中O(n²)
情况的枢轴选择策略)。如果比较器函数是病态的,bsearch
和qsort
都可能更糟。 - ShadowRanger虽然知道如何为您的特定问题计算大O时间是有用的,但了解一些常见情况可以在帮助您做出算法决策方面起到很大作用。
以下是一些最常见的情况,摘自http://en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions:
O(1) - 确定一个数字是奇数还是偶数;使用固定大小的查找表或哈希表
O(logn) - 在排序数组中查找项目,使用二分搜索
O(n) - 在未排序列表中查找项目;将两个n位数相加
O(n2) - 通过简单算法乘以两个n位数;将两个n×n矩阵相加;冒泡排序或插入排序
O(n3) - 通过简单算法乘以两个n×n矩阵
O(cn) - 使用动态规划查找巡回推销员问题的(精确)解决方案;使用蛮力法确定两个逻辑语句是否等价
O(n!) - 通过蛮力搜索解决旅行推销员问题
O(nn) - 经常用于推导渐近复杂度的简化公式,而不是O(n!)
x&1==1
来检查奇偶性呢? - Samie Bencherifx&1
即可,无需检查== 1
;在C中,x&1 == 1
被解释为x&(1 == 1)
感谢运算符优先级,因此实际上与测试x&1
相同)。但我认为你误读了答案;那里有一个分号,不是逗号。它并没有说你需要一个查找表来测试奇偶性,而是说测试奇偶性和检查查找表都是O(1)
操作。 - ShadowRanger小提示:大 O 符号用于表示渐近复杂度(即当问题的大小趋向于无限大时),并且它隐藏了一个常数。
这意味着,对于 O(n) 和 O(n2) 的算法而言,最快的不总是第一个(虽然总存在一个 n 值,使得在问题大小 >n 时,第一个算法最快)。
请注意,隐藏常数非常取决于实现!
此外,在某些情况下,运行时间不是输入大小 n 的确定性函数。以使用快速排序进行排序为例:对于 n 个元素的数组进行排序所需的时间不是常数,而是取决于数组的起始配置。
有不同的时间复杂度:
平均情况(通常更难找出...)
...
算法分析导论(作者:R. Sedgewick 和 P. Flajolet)是一本很好的介绍书。
正如你所说,过早优化是万恶之源
,而且(如果可能的话)在优化代码时应该始终使用性能分析。它甚至可以帮助你确定算法的复杂度。
(define (fac n)
(if (= n 0)
1
(* n (fac (- n 1)))))
这个函数是一个递归函数,用于计算给定数字的阶乘。
第一步是尝试确定该函数体的性能特征,仅考虑函数体内部,此时函数体中没有任何特殊操作,只有一个乘法(或返回值1)。
因此,函数体的性能为:O(1)(常数)。
接下来,尝试确定递归调用的次数。在这种情况下,我们有n-1个递归调用。
因此,递归调用的性能为:O(n-1)(顺序为n,因为我们丢弃了不重要的部分)。
然后将它们放在一起,就可以得到整个递归函数的性能:
1 * (n-1) = O(n)
Peter, 为了回答你提出的问题,我在这里描述的方法可以很好地处理这个问题。但请记住,这仍然是一个近似而不是完全正确的数学答案。这里描述的方法也是我们在大学学习的方法之一,如果我没记错的话,它被用于比我在这个例子中使用的阶乘更高级的算法。
当然,这完全取决于您能够估计函数体的运行时间和递归调用的次数的程度,但对于其他方法同样如此。
如果你的成本是一个多项式,只需保留最高阶项而不带其乘数。例如:
O((n/2 + 1)*(n/2)) = O(n2/4 + n/2) = O(n2/4) = O(n2)
请注意,这对于无限级数不起作用。对于一般情况没有单一的方法,但对于一些常见情况,以下的不等式适用:
O(log N) < O(N) < O(N log N) < O(N2) < O(Nk) < O(en) < O(n!)
从信息角度思考问题。任何问题都包含了学习一定数量的信息。
你的基本工具是决策点及其熵的概念。决策点的熵是它将为你提供的平均信息量。例如,如果一个程序包含一个有两个分支的决策点,则它的熵是每个分支的概率乘以该分支倒数概率的对数的总和。这就是通过执行该决策所学到的信息量。
例如,一个有两个等可能分支的if
语句的熵为 1/2 * log(2/1) + 1/2 * log(2/1) = 1. 因此,它的熵为1比特。
假设你正在搜索一个 N 个项目的表格,比如 N=1024。这是一个包含 10 位信息量的问题,因为 log(1024) = 10 比特。所以,如果你可以使用具有等可能结果的 IF 语句进行搜索,那么需要 10 个决策。
这就是二分查找得到的结果。
假设你在进行线性搜索。你查看第一个元素并询问它是否是你想要的。这个决策的概率是1/1024是正确的,而1023/1024不正确。该决策的熵是 1/1024*log(1024/1) + 1023/1024 * log(1024/1023) = 1/1024 * 10 + 1023/1024 * 约等于0 = 约等于0.01比特。你学到的很少!第二个决策也不会好到哪里去。这就是为什么线性搜索如此缓慢,实际上它是与需要学习的比特数呈指数关系。
假设你正在进行索引。假设表格已经预先分成许多箱子,并且你使用关键字中的一些或全部位来直接索引到表格条目。如果有1024个箱子,则所有1024种可能结果的熵为1/1024 * log(1024) + 1/1024 * log(1024) + ...。对于那个索引操作,这是 1/1024 * 10 倍的 1024 种结果,或者说有10比特的熵。这就是为什么索引搜索很快的原因。让我们从头开始。
首先,接受这个原则:某些简单的数据操作可以在O(1)
的时间内完成,即在时间上与输入的大小无关。在C语言中,这些原始操作包括:
这个原则的正当性需要对计算机的机器指令(原始步骤)进行详细研究。每个描述的操作都可以用一些小的机器指令来完成;通常只需要一两个指令。
因此,C语言中的几种语句可以在O(1)
的时间内执行,即在某个常数数量的时间内独立于输入。这些简单的语句包括:
在C语言中,许多for循环是通过将索引变量初始化为某个值,并每次循环将该变量增加1来形成的。当索引达到某个限制时,for循环结束。例如,下面是一个for循环:
for (i = 0; i < n-1; i++)
{
small = i;
for (j = i+1; j < n; j++)
if (A[j] < A[small])
small = j;
temp = A[small];
A[small] = A[i];
A[i] = temp;
}
使用索引变量 i。每次循环它会将 i 增加 1,当 i 达到 n-1 时,迭代就停止了。
然而,暂时只关注 for 循环的简单形式,其中“最终值与初始值之间的差除以索引变量增加的数量告诉我们我们要循环多少次”。这个计数是精确的,除非有跳转语句退出循环;在任何情况下,它都是迭代次数的上界。
例如,for 循环迭代 ((n-1)-0)/1=n-1
次,因为 i 的初始值为 0,n-1 是 i 取得的最高值(即当 i 达到 n-1 时,循环停止且不存在 i=n-1 的迭代),并且在循环的每次迭代中向 i 添加了 1。
在最简单的情况下,即在每次迭代中循环体花费的时间相同的情况下,“大 O 记号”表示法中的上界可以通过“循环次数”乘以循环体的大 O 上限得出。严格地说,我们必须为初始化循环索引和第一次比较循环索引与极限添加 O(1) 时间,因为我们测试的次数比我们循环的次数多一次。但是,除非循环可能执行零次,否则初始化循环和测试极限一次的时间是可以通过求和规则省略的低阶项。
现在考虑这个例子:
(1) for (j = 0; j < n; j++)
(2) A[i][j] = 0;
我们知道第一行的时间复杂度为O(1)
。很明显,我们要循环n次,可以通过在第一行找到的下限和上限之间相减并加1来确定。由于循环体第二行的时间复杂度为O(1),我们可以忽略增加j的时间和将j与n进行比较的时间,这两者也都是O(1)。因此,第一行和第二行的运行时间是n乘以O(1)的结果,即O(n)
。(2) for (i = 0; i < n; i++)
(3) for (j = 0; j < n; j++)
(4) A[i][j] = 0;
我们已经确定线路(3)和(4)的循环需要O(n)时间。因此,我们可以忽略每次迭代中增加i和测试i < n所需的O(1)时间,从而得出外部循环的每次迭代需要O(n)时间。O(n^2)
运行时间。
如果您想通过经验估计您的代码顺序而不是通过分析代码,您可以插入一系列递增的n值并计时您的代码。将时间绘制在对数坐标上。如果代码是O(x ^ n),那么值应该落在斜率为n的直线上。
这比仅仅研究代码有几个优点。首先,您可以看到运行时间接近其渐近顺序的范围。此外,您可能会发现,某些代码,例如由于库调用所花费的时间,您原本认为是O(x)顺序,实际上是O(x ^ 2)顺序。