Big O equivalencia para LINQ select

10

Estoy tratando de determinar si hay un cambio en la equivalencia Big O de un bucle anidado cuando uso una selección LINQ en su lugar.

public void myFunc(List<Foo> fooList, List<Bar> barList)
{
    foreach(Foo foo in fooList)
    {
        foreach(Bar bar in barList)
        {
            if(foo.PropA == bar.PropA && bar.PropZ.HasValue)
                foo.PropC = foo.PropB * bar.PropZ;
        }
    }
}

Creo que este ejemplo de bucle anidado es O (n ^ 2) por complejidad.

Reemplacé el bucle anidado con una selección LINQ como esta:

public void myFunc(List<Foo> fooList, List<Bar> barList)
{
    foreach(Foo foo in fooList)
    {
        Bar bar = (from b in barList
                    where foo.PropA == b.PropA
                    select b).FirstOrDefault();

        if(bar.PropZ.HasValue)
            foo.PropC = foo.PropB * bar.PropZ;
    }
}

Por lo menos, el código parece ser un poco más claro para leer, ya que dice explícitamente "estamos buscando este particular Barpara trabajar".

Mi pregunta es esta : ¿el uso de LINQ select reduce la complejidad de Big O?


fuente
3
Sería interesante comparar la IL producida por el compilador para cada uno de estos.
Dan Pichelman
@DanPichelman - ¡Deja de dispararme nerd! Sí, estoy de acuerdo en que sería interesante descubrirlo. ¿Son equivalentes o no?
¿Podría ahorrar algo de tiempo recorrer bars y filtrar bar.PropZ.HasValueprimero, si espera que más de una pequeña cantidad se evalúe como falsa? Realmente no responde a su pregunta, solo estoy revisando su código.
1
¿Estos dos bucles incluso están haciendo lo mismo, particularmente en el caso en que foo.PropA == bar.PropAes cierto para entradas múltiples en barList? Editar: definitivamente no, ya que el segundo arrojará un NullReferenceExceptioncuando regrese la selección null.
Philip Kendall
1
Supongo que eso .FirstOrDefault()hará que el bucle linq salga temprano si se encuentra una coincidencia, mientras que sus bucles anidados tontos no salen antes, así que sí, creo que linq tendrá un mejor big-oh. Pero no estoy seguro, de ahí un comentario y no una respuesta.
Mike Nakis

Respuestas:

14

Big O es igual, ya que ambos códigos (en el peor de los casos) deberán realizar una comparación para cada combinación de elementos de ambas listas. Por ejemplo, si no hay coincidencias entre las listas, ninguna optimización en la implementación de Linq evitará tener que verificar todos los elementos.

Pero bueno, realmente no quieres saber sobre big-O, ¿verdad? Desea saber si el segundo es más rápido . La respuesta es sí, la solución basada en linq es más rápida (siempre que sea lo suficientemente grande) porque solo tiene que realizar la verificación hasta que se encuentre la primera coincidencia en la lista, mientras que la solución anidada siempre debe visitar todos los elementos . El Wheremétodo no escanea toda la lista de inmediato, pero el evaluador crea un iterador vago. El FirstOrDefaultmétodo ejecuta entonces el repetidor hasta que se encuentre el primer partido, lo que significa que, en el caso común que no tiene que recorrer toda la lista. Por supuesto, hay algo de sobrecarga en la solución de Linq, por lo que si n es lo suficientemente bajo, el bucle anidado será más rápido.

Puede consultar la fuente usted mismo: https://github.com/dotnet/corefx/blob/master/src/System.Linq/src/System/Linq/Enumerable.cs

(Pero si agregara un breaken la primera muestra, los dos códigos serían semánticamente equivalentes, y el primero sería más rápido debido a una menor sobrecarga).

JacquesB
fuente
"la solución basada en linq es más rápida" ¿Quién sabe? LINQ tiene una sobrecarga de factor constante que puede superar fácilmente 2x.
usr
@ usr: Tienes razón, depende de la frecuencia de las coincidencias en lugar del tamaño de las listas. Cuanto mayor es la frecuencia de las coincidencias, mayor es la ventaja relativa de la solución basada en linq.
JacquesB
5

No hay diferencia en la complejidad, ambos son O (n²) porque el Dónde es O (n) de Linq.
El uso de FirstOrDefault es equivalente a hacer esto:

if(foo.PropA == bar.PropA && bar.PropZ.HasValue) 
{
    foo.PropC = foo.PropB * bar.PropZ;
    break;
}

Esta es una mejora en el tiempo de cálculo, pero no en la complejidad.

Puede hacerlo mejor usando un Hashmap para una de las dos listas.
El operador Unirse de Linq creará un Hashmap para una de las 2 listas, por lo que ganará rendimiento al buscar dos elementos coincidentes:

    public void myFunc(List<Foo> fooList, List<Bar> barList)
    {
        var rows = fooList.Join(
                        barList, 
                        f => f.PropA, 
                        b => b.PropA, 
                        (f, b) => new { Foo = f, Bar = b }
                   );

        foreach (var row in rows)
        {
            if (row.Bar.PropZ.HasValue)
                row.Foo.PropC = row.Foo.PropB * row.Bar.PropZ.Value;
        }
    }

Esto debería ejecutarse en O (n log (n))

Cyril Gandon
fuente
¿Podría explicar por qué se ejecutaría O (n log (n))? Siguiendo el código fuente, parece crear Lookup(Of TKey, TElement), iterar barList, obtener el Groupingpara cada elemento y agregar elementos al Grouping. Luego itera fooList, obtiene el Groupingy devuelve cada elemento en la agrupación. ¿De dónde viene el `log (n)?
Zach Mierzejewski