¿Por qué es el peor caso para esta función O (n ^ 2)?

44

Estoy tratando de enseñarme a mí mismo cómo calcular la notación BigO para una función arbitraria. Encontré esta función en un libro de texto. El libro afirma que la función es O (n 2 ). Da una explicación de por qué esto es así, pero estoy luchando por seguirlo. Me pregunto si alguien podría mostrarme las matemáticas detrás de por qué esto es así. Básicamente, entiendo que es algo menor que O (n 3 ), pero no pude aterrizar independientemente en O (n 2 )

Supongamos que se nos dan tres secuencias de números, A, B y C. Asumiremos que ninguna secuencia individual contiene valores duplicados, pero que puede haber algunos números que están en dos o tres de las secuencias. El problema de la desunión del conjunto de tres vías es determinar si la intersección de las tres secuencias está vacía, es decir, que no hay un elemento x tal que x ∈ A, x ∈ B y x ∈ C.

Por cierto, este no es un problema de tarea para mí, ese barco ha navegado hace años :), solo yo tratando de ser más inteligente.

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

[Editar] De acuerdo con el libro de texto:

En la versión mejorada, no es simplemente que ahorremos tiempo si tenemos suerte. Afirmamos que el peor tiempo de ejecución para disjuntas es O (n 2 ).

La explicación del libro, que me cuesta seguir, es esta:

Para tener en cuenta el tiempo de ejecución general, examinamos el tiempo dedicado a ejecutar cada línea de código. La gestión del bucle for sobre A requiere tiempo O (n). La gestión del ciclo for sobre B representa un total de tiempo O (n 2 ), ya que ese ciclo se ejecuta en diferentes momentos. La prueba a == b se evalúa O (n 2 ) veces. El resto del tiempo dedicado depende de cuántos pares coincidentes (a, b) existen. Como hemos señalado, existen a lo sumo n de tales pares, por lo que la administración del bucle sobre C y los comandos dentro del cuerpo de ese bucle, se usan como máximo O (n 2 ). El tiempo total empleado es O (n 2 ).

(Y para dar el crédito adecuado ...) El libro es: Estructuras de datos y algoritmos en Python por Michael T. Goodrich et. todos, Wiley Publishing, pág. 135

[Editar] Una justificación; A continuación se muestra el código antes de la optimización:

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

En lo anterior, puede ver claramente que esto es O (n 3 ), porque cada ciclo debe ejecutarse al máximo. El libro afirmaría que en el ejemplo simplificado (dado primero), el tercer bucle es solo una complejidad de O (n 2 ), por lo que la ecuación de complejidad es como k + O (n 2 ) + O (n 2 ) que finalmente produce O (n 2 ).

Si bien no puedo probar que este sea el caso (por lo tanto, la pregunta), el lector puede estar de acuerdo en que la complejidad del algoritmo simplificado es al menos menor que la original.

[Editar] Y para demostrar que la versión simplificada es cuadrática:

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)

Rendimientos:

0.02684807777404785
0.00019478797912597656
0.19134306907653809
0.0007600784301757812
0.6405444145202637
0.0018095970153808594
1.4873297214508057
0.003167390823364258
2.953308343887329
0.004908084869384766

Como la segunda diferencia es igual, la función simplificada es de hecho cuadrática:

ingrese la descripción de la imagen aquí

[Editar] Y aún más pruebas:

Si asumo el peor de los casos (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)

rendimientos:

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

Usando la segunda prueba de diferencia, el resultado del peor de los casos es exactamente cuadrático.

ingrese la descripción de la imagen aquí

SteveJ
fuente
66
O el libro está equivocado o su transcripción lo está.
candied_orange
66
No Mal está mal, independientemente de lo bien citado. Explique por qué no podemos simplemente asumir que estos van de la peor manera posible al hacer un gran análisis de O o aceptar los resultados que está obteniendo.
candied_orange
8
@candied_orange; He agregado algunas justificaciones adicionales a lo mejor de mi habilidad, no a mi fuerte. Le pediría que nuevamente permita la posibilidad de que pueda ser incorrecto. Has hecho tu punto, debidamente tomado.
SteveJ
8
Los números aleatorios no son tu peor caso. Eso no prueba nada.
Telastyn
77
ahh bueno. La "secuencia no tiene valores duplicados" cambia el peor de los casos, ya que C solo puede activarse una vez por cada A. Perdón por la frustración, eso es lo que obtengo por estar en stackexchange tarde un sábado: D
Telastyn

Respuestas:

63

El libro es correcto, y proporciona un buen argumento. Tenga en cuenta que los tiempos no son un indicador confiable de la complejidad algorítmica. Los tiempos solo podrían considerar una distribución de datos especial, o los casos de prueba podrían ser demasiado pequeños: la complejidad algorítmica solo describe cómo el uso de recursos o el tiempo de ejecución se extiende más allá de un tamaño de entrada adecuadamente grande.

El libro argumenta que la complejidad es O (n²) porque la if a == brama se ingresa como máximo n veces. Esto no es obvio porque los bucles todavía se escriben como anidados. Es más obvio si lo extraemos:

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

Esta variante utiliza generadores para representar resultados intermedios.

  • En el generador AB, tendremos a lo sumo n elementos (debido a la garantía de que las listas de entrada no contendrán duplicados), y producir el generador requiere complejidad O (n²).
  • La producción del generador ABCimplica un bucle sobre el generador ABde longitud n y Cde longitud n , de modo que su complejidad algorítmica es también O (n²).
  • Estas operaciones no están anidadas, sino que suceden independientemente, de modo que la complejidad total es O (n² + n²) = O (n²).

Debido a que los pares de listas de entrada se pueden verificar secuencialmente, se deduce que determinar si cualquier número de listas es disjunto puede hacerse en tiempo O (n²).

Este análisis es impreciso porque supone que todas las listas tienen la misma longitud. Podemos decir con mayor precisión que ABtiene como máximo min (| A |, | B |) y su producción tiene complejidad O (| A | • | B |). Producir ABCtiene complejidad O (min (| A |, | B |) • | C |). La complejidad total depende de cómo se ordenan las listas de entrada. Con | A | ≤ | B | ≤ | C | obtenemos la complejidad total en el peor de los casos de O (| A | • | C |).

Tenga en cuenta que las ganancias de eficiencia son posibles si los contenedores de entrada permiten pruebas rápidas de membresía en lugar de tener que repetir todos los elementos. Este podría ser el caso cuando se ordenan para que se pueda realizar una búsqueda binaria, o cuando son conjuntos hash. Sin bucles anidados explícitos, esto se vería así:

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

o en la versión basada en generador:

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
amon
fuente
44
Esto sería mucho más claro si aboliéramos esta nvariable mágica y hablamos de las variables reales en juego.
Alexander
15
@code_dredd No, no lo es, no tiene conexión directa con el código. Es una abstracción que imagina que len(a) == len(b) == len(c), aunque es cierto en el contexto del análisis de la complejidad del tiempo, tiende a confundir la conversación.
Alexander
10
¿Quizás decir que el código de OP tiene la peor complejidad de caso O (| A | • | B | + min (| A |, | B |) • | C |) es suficiente para activar la comprensión?
Pablo H
3
Otra cosa sobre las pruebas de tiempo: como descubrió, no le ayudaron a comprender lo que estaba sucediendo. Por otro lado, parecen haberle dado una confianza adicional para hacer frente a varias afirmaciones incorrectas pero enérgicamente declaradas de que el libro obviamente estaba equivocado, por lo que es algo bueno, y en este caso, su prueba superó el agitar intuitivo de la mano ... Para comprenderlo, una forma más efectiva de probar sería ejecutarlo en un depurador con puntos de interrupción (o agregar impresiones de los valores de las variables) en la entrada de cada bucle.
sdenham
44
"Tenga en cuenta que los tiempos no son un indicador útil de la complejidad algorítmica". Creo que esto sería más preciso si dijera "riguroso" o "confiable" en lugar de "útil".
Acumulación el
7

Tenga en cuenta que si todos los elementos son diferentes en cada una de las listas que se supone, puede iterar C solo una vez para cada elemento en A (si hay un elemento en B que es igual). Entonces el bucle interno es O (n ^ 2) total

RiaD
fuente
3

Asumiremos que ninguna secuencia individual contiene duplicados.

Es una información muy importante.

De lo contrario, el peor de los casos de la versión optimizada sería O (n³), cuando A y B son iguales y contienen un elemento duplicado n veces:

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

que salidas:

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

Básicamente, los autores suponen que el peor caso de O (n³) no debería suceder (¿por qué?), Y "prueban" que el peor de los casos es ahora O (n²).

La optimización real sería utilizar conjuntos o dictados para probar la inclusión en O (1). En ese caso, disjointsería O (n) para cada entrada.

Eric Duminil
fuente
Su último comentario es bastante interesante, no había pensado en eso. ¿Está sugiriendo que se debe a que puede hacer tres operaciones O (n) en serie?
SteveJ
2
A menos que obtenga un hash perfecto con al menos un depósito por elemento de entrada, no puede probar la inclusión en O (1). Un conjunto ordenado generalmente tiene una búsqueda O (log n). A menos que esté hablando del costo promedio, pero esa no es la cuestión. Aún así, tener un conjunto binario equilibrado que se pone difícil O (n log n) es trivial.
Jan Dorniak
@ JanDorniak: Excelente comentario, gracias. Ahora es un poco incómodo: ignoré el peor de los casos key in dict, tal como lo hicieron los autores. : - / En mi defensa, creo que es mucho más difícil encontrar un dict con nclaves y ncolisiones hash que simplemente crear una lista con nvalores duplicados. Y con un conjunto o dict, tampoco puede haber ningún valor duplicado. Por lo tanto, el peor de los casos es O (n²). Actualizaré mi respuesta.
Eric Duminil
2
@ JanDorniak Creo que los conjuntos y los dictos son tablas hash en python en lugar de los árboles rojo-negros en C ++. Entonces, el peor de los casos es peor, hasta 0 (n) para una búsqueda, pero el caso promedio es O (1). A diferencia de O (log n) para C ++ wiki.python.org/moin/TimeComplexity . Dado que es una pregunta de Python, y que el dominio del problema conduce a una alta probabilidad de rendimiento promedio del caso, no creo que la afirmación O (1) sea pobre.
Baldrickk
3
Creo que veo el problema aquí: cuando los autores dicen "asumiremos que ninguna secuencia individual contiene valores duplicados", ese no es un paso para responder la pregunta; es, más bien, una condición previa bajo la cual se abordará la cuestión. Para fines pedagógicos, esto convierte un problema poco interesante en uno que desafía las intuiciones de las personas sobre la gran O, y parece haber tenido éxito en eso, a juzgar por el número de personas que han insistido firmemente en que O (n²) debe estar equivocado. .. Además, aunque es discutible aquí, contar el número de pasos en un ejemplo no es una explicación.
sdenham
3

Para poner las cosas en los términos que usa su libro:

Creo que no tiene ningún problema para entender que la verificación para a == bO es el peor de los casos (n 2 ).

Ahora, en el peor de los casos para el tercer bucle, cada aen Acuenta en un partido B, por lo que el tercer bucle se llama cada vez. En el caso de aque no exista C, se ejecutará por todo el Cconjunto.

En otras palabras, es 1 vez por cada ay 1 vez por cada c, o n * n. O (n 2 )

Entonces, está el O (n 2 ) + O (n 2 ) que su libro señala.

Marte
fuente
0

El truco del método optimizado es cortar esquinas. Solo si a y b coinciden, se dará un vistazo a c. Ahora puede darse cuenta de que, en el peor de los casos, aún tendría que evaluar cada c. Esto no es verdad.

Probablemente piense que el peor de los casos es que cada verificación para a == b resulta en una ejecución sobre C porque cada verificación para a == b devuelve una coincidencia. Pero esto no es posible porque las condiciones para esto son contradictorias. Para que esto funcione necesitaría una A y una B que contengan los mismos valores. Se pueden ordenar de manera diferente, pero cada valor en A debería tener un valor coincidente en B.

Ahora aquí está el pateador. No hay forma de organizar estos valores para que para cada a tenga que evaluar todas las b antes de encontrar su coincidencia.

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

Esto se haría instantáneamente porque los 1 coincidentes son el primer elemento de ambas series. Qué pasa

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

Eso funcionaría para la primera ejecución sobre A: solo el último elemento en B produciría un golpe. Pero la siguiente iteración sobre A ya tendría que ser más rápida porque el último lugar en B ya está ocupado por 1. Y, de hecho, esta vez solo tomaría cuatro iteraciones. Y esto mejora un poco con cada próxima iteración.

Ahora que no soy matemático, no puedo probar que esto terminará en O (n2), pero puedo sentirlo en mis obstrucciones.

Martin Maat
fuente
1
El orden de los elementos no juega un papel aquí. El requisito significativo es que no hay duplicados; El argumento es que los bucles se pueden transformar en dos O(n^2)bucles separados ; que da en general O(n^2)(las constantes se ignoran).
AnoE
@AnoE De hecho, el orden de los elementos no importa. Que es exactamente lo que estoy demostrando.
Martin Maat
Veo lo que está tratando de hacer, y lo que está escribiendo no está mal, pero desde la perspectiva de OP, su respuesta muestra principalmente por qué un tren particular de pensamiento es irrelevante; No está explicando cómo llegar a la solución real. OP no parece dar una indicación de que realmente piensa que esto está relacionado con el orden. Entonces no me queda claro cómo esta respuesta ayudaría a OP.
AnoE
-1

Al principio estaba desconcertado, pero la respuesta de Amon es realmente útil. Quiero ver si puedo hacer una versión realmente concisa:

Para un valor dado de ain A, la función se compara acon cada posible bin B, y lo hace solo una vez. Entonces, para un hecho, ase realiza a == bexactamente nveces.

Bno contiene ningún duplicado (ninguna de las listas lo hace), por lo que para un determinado ahabrá como máximo una coincidencia. (Esa es la clave). Cuando haya una coincidencia, ase comparará con todas las posibles c, lo que significa que a == cse lleva a cabo exactamente n veces. Donde no hay coincidencia, a == cno sucede en absoluto.

Entonces, para un hecho a, hay ncomparaciones o 2ncomparaciones. Esto sucede para todos a, por lo que el mejor caso posible es (n²) y el peor es (2n²).

TLDR: cada valor de ase compara con cada valor de by con cada valor de c, pero no con cada combinación de by c. Los dos problemas se suman, pero no se multiplican.

Douglas Winship
fuente
-3

Piénselo de esta manera, algunos números pueden estar en dos o tres de las secuencias, pero el caso promedio de esto es que para cada elemento en el conjunto A, se realiza una búsqueda exhaustiva en b. Se garantiza que cada elemento del conjunto A se repetirá, pero implica que menos de la mitad de los elementos del conjunto b se repetirán.

Cuando los elementos del conjunto b se repiten, se produce una iteración si hay una coincidencia. Esto significa que el caso promedio de esta función disjunta es O (n2), pero el peor caso absoluto podría ser O (n3). Si el libro no entrara en detalles, probablemente le daría un caso promedio como respuesta.

Eddie Ekpo
fuente
44
El libro es bastante claro que O (n2) es el peor de los casos, no el caso promedio.
SteveJ
Una descripción de una función en términos de notación O grande generalmente solo proporciona un límite superior en la tasa de crecimiento de la función. Asociadas con la notación O grande hay varias notaciones relacionadas, usando los símbolos o, Ω, ω y Θ, para describir otros tipos de límites en las tasas de crecimiento asintótico. Wikipedia - Big O
candied_orange
55
"Si el libro no entrara en detalles, probablemente le daría un caso promedio como respuesta". - Uhm, no. Sin ninguna calificación explícita, generalmente estamos hablando de la complejidad de los pasos en el peor de los casos en el modelo RAM. Cuando hablamos de operaciones en estructuras de datos, y está claro por el contexto, entonces podríamos estar hablando de la complejidad amortizada en el peor de los casos en el modelo RAM. Sin una calificación explícita , generalmente no hablaremos sobre el mejor caso, el caso promedio, el caso esperado, la complejidad del tiempo o cualquier otro modelo, excepto RAM.
Jörg W Mittag