为什么此函数O(n ^ 2)最坏的情况?


44

我正在尝试教自己如何为任意函数计算BigO表示法。我在教科书中找到了此功能。该书断言该函数为O(n 2)。它解释了为什么这样做,但是我一直在努力遵循。我想知道是否有人能够向我展示为什么会这样。从根本上讲,我知道它小于O(n 3),但我无法独立降落在O(n 2)上

假设给定了三个数字序列,A,B和C。我们将假定没有单个序列包含重复值,但是在两个或三个序列中可能存在一些数字。三向不相交问题是确定三个序列的交点是否为空,即是否不存在元素x使得x∈A,x∈B和x∈C。

顺便说一句,这对我来说不是一个作业问题,因为这艘船几年前已经航行过:),只是我想变得更聪明。

def disjoint(A, B, C):
        """Return True if there is no element common to all three lists."""  
        for a in A:
            for b in B:
                if a == b: # only check C if we found match from A and B
                   for c in C:
                       if a == c # (and thus a == b == c)
                           return False # we found a common value
        return True # if we reach this, sets are disjoint

[编辑]根据教科书:

在改进的版本中,如果幸运的话,不只是节省时间。我们声称不相交的最坏情况下的运行时间为O(n 2)。

我难以理解的这本书的解释是:

为了说明整体运行时间,我们检查了执行每一行代码所花费的时间。在A上进行for循环的管理需要O(n)时间。B上的for循环的管理总共需要O(n 2)时间,因为该循环执行了n次不同的时间。测试a == b被评估O(n 2)次。剩下的时间取决于存在多少对匹配的(a,b)。正如我们已经指出的,最多有n个这样的对,因此对C的循环的管理以及该循环体内的命令最多使用O(n 2)时间。花费的总时间为O(n 2)。

(并给予应有的荣誉……)这本书是:Michael T. Goodrich等人的《 Python中的数据结构和算法》。全部,Wiley Publishing,第16页。135

理由 下面是优化前的代码:

def disjoint1(A, B, C):
    """Return True if there is no element common to all three lists."""
       for a in A:
           for b in B:
               for c in C:
                   if a == b == c:
                        return False # we found a common value
return True # if we reach this, sets are disjoint

在上面,您可以清楚地看到这是O(n 3),因为每个循环必须充分运行。这本书会断言,在简化的示例中(首先给出),第三个循环只是O(n 2)的复杂度,因此复杂度方程为k + O(n 2)+ O(n 2),最终得出O(n 2)。

虽然我不能证明是这种情况(因此是问题),但读者可以同意简化算法的复杂度至少要比原始算法少。

[编辑]并证明简化版本是二次的:

if __name__ == '__main__':
    for c in [100, 200, 300, 400, 500]:
        l1, l2, l3 = get_random(c), get_random(c), get_random(c)
        start = time.time()
        disjoint1(l1, l2, l3)
        print(time.time() - start)
        start = time.time()
        disjoint2(l1, l2, l3)
        print(time.time() - start)

产量:

0.02684807777404785
0.00019478797912597656
0.19134306907653809
0.0007600784301757812
0.6405444145202637
0.0018095970153808594
1.4873297214508057
0.003167390823364258
2.953308343887329
0.004908084869384766

由于第二个差相等,因此简化函数的确是二次的:

在此处输入图片说明

[编辑]还有进一步的证明:

如果我假设最坏的情况(A = B!= C),

if __name__ == '__main__':
    for c in [10, 20, 30, 40, 50]:
        l1, l2, l3 = range(0, c), range(0,c), range(5*c, 6*c)
        its1 = disjoint1(l1, l2, l3)
        its2 = disjoint2(l1, l2, l3)
        print(f"iterations1 = {its1}")
        print(f"iterations2 = {its2}")
        disjoint2(l1, l2, l3)

产量:

iterations1 = 1000
iterations2 = 100
iterations1 = 8000
iterations2 = 400
iterations1 = 27000
iterations2 = 900
iterations1 = 64000
iterations2 = 1600
iterations1 = 125000
iterations2 = 2500

使用第二次差异测试,最坏情况的结果恰好是二次的。

在此处输入图片说明


6
这本书是错的,还是你的抄写是错的。
candied_orange

6
不。错是错,无论引用得多么好。要么解释为什么我们不能简单地假设这些,如果在进行大的O分析或接受您得到的结果时,会采取最糟糕的方式。
candied_orange

8
@candied_orange; 我已尽我所能添加了更多理由-不是我的强项。我想请您再次考虑到您可能确实不正确的可能性。您已正确表达了自己的观点。
SteveJ

8
随机数不是您最坏的情况。那没什么。
Telastyn

7
啊 好的。“没有序列具有重复的值”确实改变了最坏的情况,因为C每个A只能触发一次。很抱歉,我在星期六晚上
进入stackexchange时才

Answers:


63

这本书确实是正确的,并且提供了很好的论据。请注意,时序并不是算法复杂度的可靠指标。时间安排可能仅考虑特殊的数据分布,或者测试用例可能太小:算法复杂度仅描述资源使用或运行时如何扩展超出某些适当大的输入大小。

本书提出这样的论点,即复杂度为O(n²),因为if a == b分支最多输入n次。这不是显而易见的,因为循环仍被编写为嵌套的。如果我们提取它,则更加明显:

def disjoint(A, B, C):
  AB = (a
        for a in A
        for b in B
        if a == b)
  ABC = (a
         for a in AB
         for c in C
         if a == c)
  for a in ABC:
    return False
  return True

此变量使用生成器表示中间结果。

  • 在生成器中AB,我们最多具有 n个元素(因为保证输入列表不会包含重复项),并且生成生成器的复杂度为O(n²)。
  • 制造发生器ABC包括在所述发电机的环路AB长度的Ñ及以上C长度的Ñ,使得其算法复杂度是O(N²)为好。
  • 这些操作不是嵌套的,而是独立发生的,因此总复杂度为O(n²+n²)= O(n²)。

由于可以顺序检查成对的输入列表,因此可以在O(n²)时间内确定是否有任何数量的列表不相交。

这种分析是不精确的,因为它假定所有列表的长度都相同。我们可以更准确地说,AB最大长度为min(| A |,| B |),而产生它的复杂度为O(| A |•| B |)。生产ABC具有复杂度O(min(| A |,| B |)•| C |)。然后,总复杂度取决于输入列表的排序方式。与| A | ≤| B | ≤| C | 我们得到O(| A |•| C |)的总最坏情况复杂度。

请注意,如果输入容器允许进行快速成员资格测试,而不必遍历所有元素,则可能会提高效率。当对它们进行排序以便可以进行二进制搜索时,或者当它们是哈希集时,可能就是这种情况。如果没有显式的嵌套循环,则如下所示:

for a in A:
  if a in B:  # might implicitly loop
    if a in C:  # might implicitly loop
      return False
return True

或在基于生成器的版本中:

AB = (a for a in A if a in B)
ABC = (a for a in AB if a in C)
for a in ABC:
  return False
return True

4
如果我们只是取消这个不可思议的n变量,并谈论实际的变量,那将更加清楚。
亚历山大

15
@code_dredd不,不是,它与代码没有直接连接。这是一个预想的抽象,len(a) == len(b) == len(c)尽管在时间复杂度分析的上下文中这是正确的,但往往会使对话变得混乱。
亚历山大

10
也许说OP的代码具有最坏情况的复杂度O(| A |•| B | + min(| A |,| B |)•| C |)足以触发理解?
Pablo H

3
关于计时测试的另一件事:如您所知,它们并没有帮助您了解正在发生的事情。另一方面,他们似乎让您更有信心应对各种错误但有力的说法,即该书显然是错误的,这是一件好事,在这种情况下,您的测试胜过直观的挥舞。为了理解,一种更有效的测试方法是在调试器中运行该调试器,并在每个循环的入口处设置断点(或添加变量值的打印内容)。
sdenham

4
“请注意,时间并不是算法复杂度的有用指标。”我认为,如果说“严谨”或“可靠”而不是“有用”,这将更为准确。
累计

7

请注意,如果假定每个列表中的所有元素都不相同,则只能对A中的每个元素进行一次C迭代(如果B中的元素相等)。因此内循环总数为O(n ^ 2)


3

我们将假定没有单个序列包含重复项。

是非常重要的信息

否则,当A和B相等且包含一个元素重复n次时,优化版本的最坏情况仍将是O(n³):

i = 0
def disjoint(A, B, C):
    global i
    for a in A:
        for b in B:
            if a == b:
                for c in C:
                    i+=1
                    print(i)
                    if a == c:
                        return False 
    return True 

print(disjoint([1] * 10, [1] * 10, [2] * 10))

输出:

...
...
...
993
994
995
996
997
998
999
1000
True

因此,基本上,作者认为O(n³)最坏情况不应该发生(为什么?),并“证明”最坏情况现在是O(n²)。

真正的优化是使用集合或字典来测试O(1)中的包含。在这种情况下,disjoint每个输入为O(n)。


您最后的评论很有趣,没想到。您是否建议这是由于您能够连续执行三个O(n)操作?
史蒂夫·J

2
除非您获得一个完美的哈希,并且每个输入元素至少有一个存储桶,否则您将无法测试O(1)中的包含。排序的集合通常具有O(log n)查找。除非您在谈论平均成本,否则这不是问题所在。尽管如此,拥有平衡的二进制集变得很难O(n log n)是微不足道的。
Jan Dorniak

@JanDorniak:非常好的评论,谢谢。现在有点尴尬了:key in dict就像作者一样,我忽略了的最坏情况。:-/在我的辩护中,我认为要找到具有n键和n哈希冲突的字典比仅创建具有n重复值的列表要困难得多。有了set或dict,实际上也不会有任何重复的值。因此,最坏的情况确实是O(n²)。我将更新我的答案。
埃里克·杜米尼尔

2
@JanDorniak我认为集合和字典是python中的哈希表,而不是C ++中的红黑树。因此,绝对最坏情况会更糟,一次搜索最多为0(n),但平均情况为O(1)。与C ++ Wiki.python.org/moin/TimeComplexity的 O(log n)相反。鉴于这是一个python问题,并且问题的领域导致平均案例性能的可能性很高,所以我认为O(1)声明并不算糟糕。
Baldrickk

3
我想我在这里看到了问题:当作者说“我们将假设没有单个序列包含重复值”时,这不是回答问题的步骤;相反,这是解决这个问题的前提。出于教学目的,这将一个无趣的问题变成了挑战人们对大O的直觉的问题-从强烈坚持认为O(n²)必须是错误的人数来看,这似乎是成功的。 ..同样,虽然这里没有什么意义,但在一个示例中计算步数并不是一种解释。
sdenham

3

将事物放入您的书中所用的术语:

我认为您毫无疑问地了解,检查a == b是最坏情况下的O(n 2)。

现在,在最坏的情况下,第三个循环中,每个ain A都具有一个in匹配项B,因此,每次都会调用第三个循环。在a中不存在的情况下C,它将遍历整个C集合。

换句话说,这是1次,每个a1次c,或n * n。O(n 2

因此,您的书指出了O(n 2)+ O(n 2)。


0

优化方法的诀窍是偷工减料。仅当a和b匹配时,c才值得一看。现在您可能会发现,在最坏的情况下,您仍然必须评估每个c。这不是真的。

您可能认为最坏的情况是,对a == b的每一次检查都会导致C的运行,因为对a == b的每一次检查都会返回一个匹配项。但这是不可能的,因为这样做的条件是矛盾的。为此,您需要包含相同值的A和B。它们的顺序可能不同,但是A中的每个值都必须在B中具有匹配的值。

现在是踢球者。无法组织这些值,因此对于每个a,您都必须在找到匹配项之前先评估所有b。

A: 1 2 3 4 5
B: 1 2 3 4 5

这将立即完成,因为匹配的1是两个系列中的第一个元素。关于什么

A: 1 2 3 4 5
B: 5 4 3 2 1

这将在A上的第一次运行中起作用:只有B中的最后一个元素才会产生匹配。但是,由于A中的最后一个点已经被1占用了,因此A上的下一个迭代必须已经更快。实际上,这一次只需要进行四次迭代。每次下一次迭代时,情况都会有所改善。

现在我不是数学家,所以我无法证明这会以O(n2)结尾,但是我在木log上可以感觉到。


1
元素的顺序在这里不起作用。重要的要求是没有重复。然后的论点是循环可以转换成两个单独的O(n^2)循环;给出整体O(n^2)(常量被忽略)。
AnoE

@AnoE确实,元素的顺序无关紧要。这正是我要演示的。
马丁·马特

我看到您正在尝试做的事情,并且您写的没错,但是从OP的角度来看,您的答案主要是说明为什么某种特定的思路不相关;它没有解释如何得出实际的解决方案。OP似乎并未表明他实际上认为这与订单有关。因此,我不清楚这个答案将如何帮助OP。
AnoE

-1

起初感到困惑,但是Amon的回答确实很有帮助。我想看看我是否可以做一个非常简洁的版本:

对于ain 的给定值A,该函数abin中的所有可能值进行比较B,并且仅执行一次。因此,对于给定的时间,aa == b恰好执行n时间。

B不包含任何重复项(列表中没有一个重复项),因此对于给定的项,最多a只能一个匹配项。(那是关键)。如果存在匹配项,a则会将其与每种可能的结果进行比较c,这意味着将a == c精确地执行n次。没有匹配的地方,a == c根本不会发生。

因此,对于给定a,存在n比较或多个2n比较。这对于每个都会发生a,因此最佳情况是(n²),最坏情况是(2n²)。

TLDR:每一个值a是对每一个值进行比较b,并针对每一个值c,而不是针对每个组合bc。这两个问题加在一起,但不会相乘。


-3

这样考虑,一些数字可能出现在两个或三个序列中,但是这种情况的平均情况是,对于集合A中的每个元素,在b中执行穷举搜索。可以保证对集合A中的每个元素进行迭代,但是这意味着对集合b中少于一半的元素进行迭代。

对集合b中的元素进行迭代时,如果存在匹配项,则会发生迭代。这意味着该不相交函数的平均情况为O(n2),但绝对最差情况可能为O(n3)。如果这本书没有详细介绍,它可能会为您提供一般情况作为答案。


4
这本书很清楚,O(n2)是最坏的情况,而不是平均情况。
SteveJ

用大O表示法描述功能通常仅提供功能增长率的上限。与大O表示法相关的是几种相关的表示法,使用符号o,Ω,ω和Θ来描述渐近增长率的其他类型的界限。维基百科-大O
candied_orange

5
“如果书不详细,它可能会为您提供一般情况作为答案。” –嗯,不。没有任何明确的条件,我们通常谈论的是RAM模型中最坏情况下的步复杂性。当谈论数据结构的操作时,从上下文中可以很明显地看出,那么我们可能会谈论RAM模型中摊销的最坏情况下的步骤复杂性。没有明确的条件,我们通常不会谈论最佳情况,平均情况,预期情况,时间复杂度或除RAM之外的任何其他模型。
约尔格W¯¯米塔格
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.