web-dev-qa-db-fra.com

Pourquoi le pire des cas pour cette fonction O (n ^ 2)?

J'essaie de m'apprendre à calculer la notation BigO pour une fonction arbitraire. J'ai trouvé cette fonction dans un manuel. Le livre affirme que la fonction est O (n2). Cela explique pourquoi, mais j'ai du mal à suivre. Je me demande si quelqu'un pourrait me montrer les mathématiques qui expliquent pourquoi il en est ainsi. Fondamentalement, je comprends que c'est quelque chose de moins que O (n3), mais je ne pouvais pas atterrir indépendamment sur O (n2)

Supposons que l'on nous donne trois séquences de nombres, A, B et C. Nous supposerons qu'aucune séquence individuelle ne contient des valeurs en double, mais qu'il peut y avoir des nombres qui sont dans deux ou trois des séquences. Le problème de disjonction d'ensemble à trois voies consiste à déterminer si l'intersection des trois séquences est vide, à savoir qu'il n'y a pas d'élément x tel que x ∈ A, x ∈ B et x ∈ C.

Soit dit en passant, ce n'est pas un problème de devoirs pour moi - ce navire a navigué il y a des années:), juste moi essayant de devenir plus intelligent.

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

[Modifier] Selon le manuel:

Dans la version améliorée, ce n'est pas simplement que nous gagnons du temps si nous avons de la chance. Nous affirmons que le temps d'exécution le plus défavorable pour disjoint est O (n2).

L'explication du livre, que j'ai du mal à suivre, est la suivante:

Pour tenir compte du temps d'exécution global, nous examinons le temps passé à exécuter chaque ligne de code. La gestion de la boucle for sur A nécessite O(n) temps. La gestion de la boucle for B représente un total de O (n2), car cette boucle est exécutée n fois différents. Le test a == b est évalué O (n2) fois. Le reste du temps passé dépend du nombre de paires (a, b) correspondantes. Comme nous l'avons noté, il y a au plus n de telles paires, et donc la gestion de la boucle sur C, et les commandes dans le corps de cette boucle, utilisent au plus O (n2) temps. Le temps total passé est O (n2).

(Et pour donner un crédit approprié ...) Le livre est: Structures de données et algorithmes en Python par Michael T. Goodrich et. Tous, Wiley Publishing, p. 135

[Modifier] Une justification; Voici le code avant l'optimisation:

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

Dans ce qui précède, vous pouvez clairement voir que c'est O (n3), car chaque boucle doit fonctionner au maximum. Le livre affirmerait que dans l'exemple simplifié (donné en premier), la troisième boucle n'est qu'une complexité de O (n2), donc l'équation de complexité va comme k + O (n2) + O (n2) qui donne finalement O (n2).

Bien que je ne puisse pas prouver que c'est le cas (donc la question), le lecteur peut convenir que la complexité de l'algorithme simplifié est au moins inférieure à l'original.

[Edit] Et pour prouver que la version simplifiée est quadratique:

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)

Rendements:

0.02684807777404785
0.00019478797912597656
0.19134306907653809
0.0007600784301757812
0.6405444145202637
0.0018095970153808594
1.4873297214508057
0.003167390823364258
2.953308343887329
0.004908084869384766

La seconde différence étant égale, la fonction simplifiée est en effet quadratique:

enter image description here

[Modifier] Et encore une preuve supplémentaire:

Si je suppose le pire des cas (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)

rendements:

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

En utilisant le deuxième test de différence, le pire des résultats est exactement quadratique.

enter image description here

44
SteveJ

Le livre est en effet correct, et il fournit un bon argument. Notez que les timings ne sont pas un indicateur fiable de la complexité algorithmique. Les timings peuvent ne considérer qu'une distribution de données spéciale, ou les cas de test peuvent être trop petits: la complexité algorithmique ne décrit que la façon dont l'utilisation des ressources ou le temps d'exécution évoluent au-delà d'une taille d'entrée convenablement grande.

Le livre fait valoir que la complexité est O (n²) parce que le if a == b la branche est saisie au maximum n fois. Cela n'est pas évident car les boucles sont toujours écrites comme imbriquées. C'est plus évident si on l'extrait:

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

Cette variante utilise des générateurs pour représenter les résultats intermédiaires.

  • Dans le générateur AB, nous aurons tout au plusn éléments (en raison de la garantie que les listes d'entrée ne contiendront pas de doublons), et produisant le générateur prend la complexité O (n²).
  • La production du générateur ABC implique une boucle sur le générateur AB de longueur n et sur C de longueur n, de sorte que sa complexité algorithmique est également O (n²).
  • Ces opérations ne sont pas imbriquées mais se produisent indépendamment, de sorte que la complexité totale est O (n² + n²) = O (n²).

Étant donné que les paires de listes d'entrée peuvent être vérifiées séquentiellement, il s'ensuit que déterminer si un nombre quelconque de listes sont disjointes peut être fait en temps O (n²).

Cette analyse est imprécise car elle suppose que toutes les listes ont la même longueur. On peut dire plus précisément que AB a au plus la longueur min (| A |, | B |) et sa production a la complexité O (| A | • | B |). La production de ABC a une complexité O (min (| A |, | B |) • | C |). La complexité totale dépend alors de l'ordre des listes d'entrées. Avec | A | ≤ | B | ≤ | C | nous obtenons la complexité totale du pire des cas de O (| A | • | C |).

Notez que les gains d'efficacité sont possibles si les conteneurs d'entrée permettent des tests d'appartenance rapides plutôt que d'avoir à parcourir tous les éléments. Cela pourrait être le cas lorsqu'ils sont triés de manière à ce qu'une recherche binaire puisse être effectuée, ou lorsqu'ils sont des ensembles de hachage. Sans boucles imbriquées explicites, cela ressemblerait à:

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

ou dans la version basée sur générateur:

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
63
amon

Notez que si tous les éléments sont différents dans chacune des listes supposées, vous ne pouvez itérer C qu'une seule fois pour chaque élément dans A (s'il y a un élément dans B qui est égal). La boucle intérieure est donc O (n ^ 2) au total

7
RiaD

Nous supposerons qu'aucune séquence individuelle ne contient de doublon.

est une information très importante.

Sinon, le pire des cas de la version optimisée serait toujours O (n³), quand A et B sont égaux et contiennent un élément dupliqué n fois:

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))

qui génère:

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

Donc, fondamentalement, les auteurs supposent que le pire des cas O (n³) ne devrait pas se produire (pourquoi?), Et "prouvent" que le pire des cas est maintenant O (n²).

La véritable optimisation serait d'utiliser des ensembles ou des dict afin de tester l'inclusion dans O (1). Dans ce cas, disjoint serait O(n) pour chaque entrée.

3
Eric Duminil

Pour mettre les choses dans les termes que votre livre utilise:

Je pense que vous n'avez aucun problème à comprendre que la vérification de a == b est le pire des cas O (n2).

Maintenant, dans le pire des cas pour la troisième boucle, chaque a dans A a une correspondance dans B, donc la troisième boucle sera appelée à chaque fois. Dans le cas où a n'existe pas dans C, il sera exécuté à travers l'ensemble C.

En d'autres termes, c'est 1 fois pour chaque a et 1 fois pour chaque c, ou n * n. Sur2)

Il y a donc le O (n2) + O (n2) que votre livre souligne.

3
Mars

L'astuce de la méthode optimisée est de couper les coins ronds. Ce n'est que si a et b correspondent, que c valera le coup d'œil. Maintenant, vous pouvez comprendre que dans le pire des cas, vous devrez toujours évaluer chaque c. Ce n'est pas vrai.

Vous pensez probablement que le pire des cas est que chaque vérification de a == b entraîne un dépassement de C car chaque vérification de a == b renvoie une correspondance. Mais cela n'est pas possible car les conditions sont contradictoires. Pour que cela fonctionne, vous auriez besoin d'un A et d'un B qui contiennent les mêmes valeurs. Ils peuvent être classés différemment, mais chaque valeur dans A devrait avoir une valeur correspondante dans B.

Voici maintenant le kicker. Il n'y a aucun moyen d'organiser ces valeurs de sorte que pour chaque a, vous devrez évaluer tous les b avant de trouver votre correspondance.

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

Cela se ferait instantanément parce que les 1 correspondants sont le premier élément des deux séries. Qu'en est-il de

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

Cela fonctionnerait pour le premier passage sur A: seul le dernier élément de B donnerait un coup. Mais la prochaine itération sur A devrait déjà être plus rapide car la dernière place de B est déjà occupée par 1. Et en effet, cela ne prendrait que quatre itérations cette fois. Et cela s'améliore un peu à chaque prochaine itération.

Maintenant, je ne suis pas un mathématicien, je ne peux donc pas prouver que cela se terminera par O(n2) mais je peux le sentir sur mes sabots.

0
Martin Maat