Que garantias existem sobre a complexidade do tempo de execução (Big-O) dos métodos LINQ?

120

Recentemente, comecei a usar bastante o LINQ e não vi nenhuma menção à complexidade do tempo de execução para nenhum dos métodos do LINQ. Obviamente, há muitos fatores em jogo aqui, então vamos restringir a discussão ao IEnumerableprovedor LINQ-to-Objects simples . Além disso, vamos supor que qualquer Funcpassado como um seletor / mutador / etc. seja uma operação O (1) barata.

Parece evidente que todas as operações de uma só passagem ( Select, Where, Count, Take/Skip, Any/All, etc.) vai ser O (n), uma vez que só precisa de caminhar a sequência de uma vez; embora mesmo isso esteja sujeito à preguiça.

As coisas são mais obscuras para as operações mais complexas; o conjunto-como operadores ( Union, Distinct, Except, etc.) trabalho usando GetHashCodepor padrão (afaik), então parece razoável supor que eles estão usando um hash-table internamente, tornando estas operações O (n), bem como, em geral. E as versões que usam um IEqualityComparer?

OrderByprecisaria de uma classificação, então provavelmente estamos olhando para O (n log n). E se já estiver classificado? Que tal se eu disser OrderBy().ThenBy()e fornecer a mesma chave para ambos?

Eu poderia ver GroupBy(e Join) usando classificação ou hash. Qual é?

Containsseria O (n) em a List, mas O (1) em a HashSet- o LINQ verifica o contêiner subjacente para ver se ele pode acelerar as coisas?

E a verdadeira questão - até agora, tenho acreditado que as operações são eficazes. No entanto, posso apostar nisso? Os contêineres STL, por exemplo, especificam claramente a complexidade de cada operação. Há alguma garantia semelhante no desempenho do LINQ na especificação da biblioteca .NET?

Mais perguntas (em resposta aos comentários):
Não pensei muito sobre a sobrecarga, mas não esperava que houvesse muito para o simples Linq-to-Objects. O post CodingHorror está falando sobre Linq-to-SQL, onde posso entender que analisar a consulta e fazer SQL aumentaria os custos - há um custo semelhante para o provedor de objetos também? Em caso afirmativo, é diferente se você estiver usando a sintaxe declarativa ou funcional?

tzaman
fonte
Embora eu realmente não possa responder à sua pergunta, quero comentar que, em geral, a maior parte do desempenho será "sobrecarga" em comparação com a funcionalidade principal. Obviamente, este não é o caso quando você tem conjuntos de dados muito grandes (> 10k itens), então estou curioso para saber qual caso você deseja saber.
Henri
2
Re: "é diferente se você estiver usando a sintaxe declarativa ou funcional?" - o compilador traduz a sintaxe declarativa na sintaxe funcional para que sejam iguais.
John Rasch
"Os contêineres STL especificam claramente a complexidade de cada operação" Os contêineres .NET também especificam claramente a complexidade de cada operação. As extensões do Linq são semelhantes aos algoritmos STL, não aos contêineres STL. Assim como quando você aplica um algoritmo STL a um contêiner STL, é necessário combinar a complexidade da extensão Linq com a complexidade das operações do contêiner .NET para analisar adequadamente a complexidade resultante. Isso inclui a contabilização de especializações de modelo, como menciona a resposta de Aaronaught.
Timbó
Uma questão subjacente é por que a Microsoft não estava mais preocupada que uma otimização IList <T> fosse de utilidade limitada, visto que um desenvolvedor teria que confiar em um comportamento não documentado se seu código dependesse disso para ter um bom desempenho.
Edward Brey
AsParallel () no conjunto resultante List; deve dar a você ~ O (1) <O (n)
Latência

Respostas:

121

Existem muito, muito poucas garantias, mas existem algumas otimizações:

  • Os métodos de extensão que usam acesso indexado, como ElementAt, Skip, Lastou LastOrDefault, irá verificar para ver se ou não os implementos tipo subjacente IList<T>, para que você obtenha O (1) o acesso em vez de O (N).

  • O Countmétodo verifica se há uma ICollectionimplementação, de modo que essa operação seja O (1) em vez de O (N).

  • Distinct, GroupBy Join e eu acredito que também os métodos de agregação de conjuntos ( Union, Intersecte Except) usam hash, então eles devem estar próximos de O (N) em vez de O (N²).

  • Contains verifica por um ICollection implementação, então pode ser O (1) se a coleção subjacente também for O (1), como a HashSet<T>, mas isso depende da estrutura de dados real e não é garantido. Conjuntos de hash substituem o Containsmétodo, é por isso que eles são O (1).

  • OrderBy os métodos usam um quicksort estável, então eles são O (N log N) caso médio.

Acho que cobre a maioria, senão todos os métodos de extensão integrados. Existem realmente poucas garantias de desempenho; O próprio Linq tentará tirar proveito de estruturas de dados eficientes, mas não é um passe livre para escrever código potencialmente ineficiente.

Aaronaught
fonte
Que tal as IEqualityComparersobrecargas?
tzaman
@tzaman: E quanto a eles? A menos que você use um costume realmente ineficiente IEqualityComparer, não posso pensar que afete a complexidade assintótica.
Aaronaught
1
Oh, certo. Eu não tinha percebido os EqualityComparerimplementos GetHashCodetambém Equals; mas é claro que isso faz todo o sentido.
tzaman
2
@imgen: as junções de loop são O (N * M) que generaliza para O (N²) para conjuntos não relacionados. Linq usa junções de hash que são O (N + M), que generalizam para O (N). Isso pressupõe uma função hash decente, mas é difícil de bagunçar no .NET.
Aaronaught
1
Orderby().ThenBy()ainda é N logNou é (N logN) ^2ou algo parecido?
M.kazem Akhgary
10

Eu sei há muito tempo que .Count()retorna .Countse a enumeração for umIList .

Mas eu estava sempre um pouco cansado sobre a complexidade de tempo de execução das acções constantes: .Intersect(), .Except(),.Union() .

Aqui está a implementação BCL (.NET 4.0 / 4.5) descompilada para .Intersect()(comentários meus):

private static IEnumerable<TSource> IntersectIterator<TSource>(IEnumerable<TSource> first, IEnumerable<TSource> second, IEqualityComparer<TSource> comparer)
{
  Set<TSource> set = new Set<TSource>(comparer);
  foreach (TSource source in second)                    // O(M)
    set.Add(source);                                    // O(1)

  foreach (TSource source in first)                     // O(N)
  {
    if (set.Remove(source))                             // O(1)
      yield return source;
  }
}

Conclusões:

  • o desempenho é O (M + N)
  • a implementação não leva vantagem quando as coleções já são definidas. (Pode não ser necessariamente simples, porque o usado IEqualityComparer<T>também precisa corresponder.)

Para completar, aqui estão as implementações para .Union()e.Except() .

Alerta de spoiler: eles também têm complexidade O (N + M) .

private static IEnumerable<TSource> UnionIterator<TSource>(IEnumerable<TSource> first, IEnumerable<TSource> second, IEqualityComparer<TSource> comparer)
{
  Set<TSource> set = new Set<TSource>(comparer);
  foreach (TSource source in first)
  {
    if (set.Add(source))
      yield return source;
  }
  foreach (TSource source in second)
  {
    if (set.Add(source))
      yield return source;
  }
}


private static IEnumerable<TSource> ExceptIterator<TSource>(IEnumerable<TSource> first, IEnumerable<TSource> second, IEqualityComparer<TSource> comparer)
{
  Set<TSource> set = new Set<TSource>(comparer);
  foreach (TSource source in second)
    set.Add(source);
  foreach (TSource source in first)
  {
    if (set.Add(source))
      yield return source;
  }
}
Cristian Diaconescu
fonte
8

Tudo o que você pode realmente confiar é que os métodos Enumerable são bem escritos para o caso geral e não usam algoritmos ingênuos. Provavelmente, há coisas de terceiros (blogs, etc.) que descrevem os algoritmos realmente em uso, mas eles não são oficiais ou garantidos no sentido em que os algoritmos STL são.

Para ilustrar, aqui está o código-fonte refletido (cortesia de ILSpy) Enumerable.Countde System.Core:

// System.Linq.Enumerable
public static int Count<TSource>(this IEnumerable<TSource> source)
{
    checked
    {
        if (source == null)
        {
            throw Error.ArgumentNull("source");
        }
        ICollection<TSource> collection = source as ICollection<TSource>;
        if (collection != null)
        {
            return collection.Count;
        }
        ICollection collection2 = source as ICollection;
        if (collection2 != null)
        {
            return collection2.Count;
        }
        int num = 0;
        using (IEnumerator<TSource> enumerator = source.GetEnumerator())
        {
            while (enumerator.MoveNext())
            {
                num++;
            }
        }
        return num;
    }
}

Como você pode ver, é difícil evitar a solução ingênua de simplesmente enumerar cada elemento.

Marcelo Cantos
fonte
iterar por todo o objeto para obter o Count () se for um IEnnumerable parece muito ingênuo para mim ...
Zonko
4
@Zonko: Não entendo seu ponto. Eu alterei minha resposta para mostrar que Enumerable.Countnão itera, a menos que não haja alternativa óbvia. Como você o teria tornado menos ingênuo?
Marcelo Cantos
Bem, sim, os métodos são implementados da maneira mais eficiente de acordo com a fonte. No entanto, a maneira mais eficiente às vezes é um algoritmo ingênuo, e deve-se ter cuidado ao usar o linq porque ele oculta a complexidade real das chamadas. Se você não está familiarizado com a estrutura subjacente dos objetos que está manipulando, pode facilmente usar os métodos incorretos para suas necessidades.
Zonko
@MarceloCantos Por que os arrays não são tratados? É o mesmo para o método de referência
ElementAtOrDefault
@Freshblood Eles são. (Arrays implementam ICollection.) Não sei sobre ElementAtOrDefault, entretanto. Estou supondo que os arrays também implementam ICollection <T>, mas meu .Net está bastante enferrujado atualmente.
Marcelo Cantos
3

Acabei de abrir o refletor e eles verificam o tipo subjacente quando Containsé chamado.

public static bool Contains<TSource>(this IEnumerable<TSource> source, TSource value)
{
    ICollection<TSource> is2 = source as ICollection<TSource>;
    if (is2 != null)
    {
        return is2.Contains(value);
    }
    return source.Contains<TSource>(value, null);
}
ChaosPandion
fonte
3

A resposta correta é "depende". depende de que tipo é o IEnumerable subjacente. Eu sei que para algumas coleções (como coleções que implementam ICollection ou IList) existem codepaths especiais que são usados. No entanto, a implementação real não tem garantia de fazer nada de especial. por exemplo, eu sei que ElementAt () tem um caso especial para coleções indexáveis, da mesma forma com Count (). Mas, em geral, você provavelmente deve assumir o pior caso de desempenho O (n).

Em geral, eu não acho que você vai encontrar o tipo de garantia de desempenho que deseja, embora se você encontrar um problema de desempenho específico com um operador linq, você pode sempre reimplementá-lo para sua coleção específica. Além disso, existem muitos blogs e projetos de extensibilidade que estendem o Linq para objetos para adicionar esses tipos de garantias de desempenho. confira o LINQ indexado que se estende e adiciona ao conjunto do operador para obter mais benefícios de desempenho.

Lucas
fonte