Quali garanzie ci sono sulla complessità del runtime (Big-O) dei metodi LINQ?


120

Di recente ho iniziato a utilizzare LINQ un po 'e non ho davvero visto alcun accenno alla complessità del runtime per nessuno dei metodi LINQ. Ovviamente, ci sono molti fattori in gioco qui, quindi limitiamo la discussione al semplice IEnumerableprovider LINQ-to-Objects. Inoltre, supponiamo che qualsiasi operazione Funcpassata come selettore / mutatore / ecc. Sia un'operazione O (1) economica.

Appare evidente che tutte le operazioni single-pass ( Select, Where, Count, Take/Skip, Any/All, etc.) saranno O (n), in quanto solo bisogno di percorrere la sequenza una volta; anche se anche questo è soggetto a pigrizia.

Le cose sono più oscure per le operazioni più complesse; il set-come operatori ( Union, Distinct, Except, etc.) lavoro utilizzando GetHashCodedi default (afaik), così sembra ragionevole supporre che stanno usando un hash-table internamente, rendendo queste operazioni O (n), nonché, in generale. E le versioni che usano un IEqualityComparer?

OrderByavrebbe bisogno di un ordinamento, quindi molto probabilmente stiamo guardando O (n log n). E se fosse già ordinato? Che ne dici se dico OrderBy().ThenBy()e fornisco la stessa chiave a entrambi?

Ho potuto vedere GroupBy(e Join) usare l'ordinamento o l'hashing. Cos'è questo?

Containssarebbe O (n) su a List, ma O (1) su a HashSet- LINQ controlla il contenitore sottostante per vedere se può accelerare le cose?

E la vera domanda: finora, ho creduto che le operazioni fossero performanti. Tuttavia, posso scommettere su questo? I contenitori STL, ad esempio, specificano chiaramente la complessità di ogni operazione. Esistono garanzie simili sulle prestazioni di LINQ nella specifica della libreria .NET?

Altre domande (in risposta ai commenti):
non avevo davvero pensato al sovraccarico, ma non mi aspettavo che ci fosse molto per il semplice Linq-to-Objects. Il post di CodingHorror parla di Linq-to-SQL, dove posso capire che l'analisi della query e rendere SQL aggiungerebbe dei costi - c'è un costo simile anche per il provider di oggetti? Se è così, è diverso se stai usando la sintassi dichiarativa o funzionale?


Anche se non posso davvero rispondere alla tua domanda, voglio commentare che in generale la maggior parte delle prestazioni sarà "overhead" rispetto alle funzionalità di base. Questo ovviamente non è il caso quando hai set di dati molto grandi (> 10k elementi) quindi sono curioso di sapere in quale caso vuoi sapere.
Henri

2
Ri: "è diverso se stai usando la sintassi dichiarativa o funzionale?" - il compilatore traduce la sintassi dichiarativa nella sintassi funzionale in modo che siano le stesse.
John Rasch

"I contenitori STL specificano chiaramente la complessità di ogni operazione" I contenitori .NET specificano anche chiaramente la complessità di ogni operazione. Le estensioni di Linq sono simili agli algoritmi STL, non ai contenitori STL. Proprio come quando si applica un algoritmo STL a un contenitore STL, è necessario combinare la complessità dell'estensione Linq con la complessità delle operazioni del contenitore .NET per analizzare correttamente la complessità risultante. Ciò include la contabilizzazione delle specializzazioni dei modelli, come menziona la risposta di Aaronaught.
Timbo

Una domanda di fondo è perché Microsoft non fosse più preoccupata del fatto che un'ottimizzazione di IList <T> avrebbe un'utilità limitata, dato che uno sviluppatore avrebbe dovuto fare affidamento su comportamenti non documentati se il suo codice dipendesse da esso per essere performante.
Edward Brey,

AsParallel () sul set List risultante; dovrebbe darti ~ O (1) <O (n)
Latenza

Risposte:


121

Ci sono pochissime garanzie, ma ci sono alcune ottimizzazioni:

  • Metodi di estensione che utilizzano l'accesso indicizzato, quali ElementAt, Skip, Lasto LastOrDefault, controllerà per vedere se le attrezzature di tipo sottostanti IList<T>, in modo da ottenere O (1) di accesso al posto di O (N).

  • Il Countmetodo verifica ICollectionun'implementazione, in modo che questa operazione sia O (1) invece di O (N).

  • Distinct, GroupBy JoinE ritengo anche i metodi set-aggregazione ( Union, Intersecte Except) utilizzo hashing, quindi dovrebbero essere vicino a O (N) invece di O (n²).

  • Containsverifica la presenza di ICollectionun'implementazione, quindi potrebbe essere O (1) se la raccolta sottostante è anche O (1), ad esempio a HashSet<T>, ma ciò dipende dalla struttura dei dati effettiva e non è garantito. I set di hash sovrascrivono il Containsmetodo, ecco perché sono O (1).

  • OrderBy i metodi usano un quicksort stabile, quindi sono un caso medio O (N log N).

Penso che copra la maggior parte se non tutti i metodi di estensione incorporati. Ci sono davvero pochissime garanzie sulle prestazioni; La stessa Linq proverà a trarre vantaggio da strutture dati efficienti, ma non è un passaggio gratuito per scrivere codice potenzialmente inefficiente.


E i IEqualityComparersovraccarichi?
tzaman

@tzaman: E loro? A meno che tu non usi un'usanza davvero inefficiente IEqualityComparer, non posso ragionare perché influenzi la complessità asintotica.
Aaronaught

1
Oh giusto. Non avevo realizzato EqualityComparerattrezzi GetHashCodecosì come Equals; ma ovviamente ha perfettamente senso.
tzaman

2
@imgen: i loop join sono O (N * M) che generalizza a O (N²) per insiemi non correlati. Linq utilizza hash join che sono O (N + M), che generalizza a O (N). Ciò presuppone una funzione hash decente, ma è difficile sbagliare in .NET.
Aaronaught

1
è Orderby().ThenBy()ancora N logNo è (N logN) ^2o qualcosa del genere?
M.kazem Akhgary

10

So da tempo che .Count()restituisce .Countse l'enumerazione è un file IList.

Ma ero sempre un po 'stanco della complessità in fase di esecuzione delle azioni indicate: .Intersect(), .Except(), .Union().

Ecco l'implementazione BCL decompilata (.NET 4.0 / 4.5) per .Intersect()(commenti miei):

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;
  }
}

conclusioni:

  • la performance è O (M + N)
  • l'implementazione non trae vantaggio quando le collezioni sono già set. (Potrebbe non essere necessariamente semplice, perché anche l'usato IEqualityComparer<T>deve corrispondere.)

Per completezza, ecco le implementazioni per .Union()e .Except().

Avviso spoiler: anche loro hanno complessità 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;
  }
}

8

Tutto ciò su cui puoi davvero fare affidamento è che i metodi Enumerable sono ben scritti per il caso generale e non useranno algoritmi ingenui. Probabilmente ci sono cose di terze parti (blog, ecc.) Che descrivono gli algoritmi effettivamente in uso, ma questi non sono ufficiali o garantiti nel senso che lo sono gli algoritmi STL.

Per illustrare, ecco il codice sorgente riflesso (per gentile concessione di ILSpy) Enumerable.Countda 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;
    }
}

Come puoi vedere, è necessario un certo sforzo per evitare la soluzione ingenua di enumerare semplicemente ogni elemento.


iterare attraverso l'intero oggetto per ottenere Count () se è un IEnnumerable mi sembra piuttosto ingenuo ...
Zonko

4
@Zonko: non capisco il tuo punto. Ho modificato la mia risposta per mostrare che Enumerable.Countnon itera a meno che non ci sia un'alternativa ovvia. Come avresti fatto a renderlo meno ingenuo?
Marcelo Cantos

Ebbene, sì, i metodi sono implementati nel modo più efficiente data la fonte. Tuttavia, il modo più efficiente a volte è un algoritmo ingenuo, e bisogna stare attenti quando si usa linq perché nasconde la reale complessità delle chiamate. Se non hai familiarità con la struttura sottostante degli oggetti che stai manipolando, potresti facilmente utilizzare i metodi sbagliati per le tue esigenze.
Zonko

@MarceloCantos Perché gli array non vengono gestiti? È lo stesso per il metodo ElementAtOrDefault referencesource.microsoft.com/#System.Core/System/Linq/…
Freshblood

@Freshblood Sono. (Gli array implementano ICollection.) Non so però di ElementAtOrDefault. Immagino che gli array implementino anche ICollection <T>, ma il mio .Net è piuttosto arrugginito in questi giorni.
Marcelo Cantos

3

Ho appena rotto il riflettore e controllano il tipo sottostante quando Containsviene chiamato.

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

3

La risposta corretta è "dipende". dipende dal tipo di oggetto IEnumerable sottostante. So che per alcune raccolte (come le raccolte che implementano ICollection o IList) ci sono codepath speciali che vengono utilizzati, tuttavia non è garantito che l'implementazione effettiva faccia qualcosa di speciale. per esempio so che ElementAt () ha un caso speciale per le raccolte indicizzabili, in modo simile a Count (). Ma in generale dovresti probabilmente presumere la prestazione O (n) nel caso peggiore.

In generale non penso che troverai il tipo di garanzie di prestazioni che desideri, anche se se incontri un particolare problema di prestazioni con un operatore linq puoi sempre reimplementarlo per la tua particolare raccolta. Inoltre ci sono molti blog e progetti di estensibilità che estendono Linq agli oggetti per aggiungere questo tipo di garanzie di prestazioni. controlla LINQ indicizzato che estende e si aggiunge al set di operatori per ulteriori vantaggi in termini di prestazioni.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.