Які гарантії існують на складність часу роботи (Big-O) методів LINQ?


120

Нещодавно я почав використовувати LINQ зовсім небагато, і я насправді не бачив жодної згадки про складність виконання жодного з методів LINQ. Очевидно, що тут грає багато факторів, тому давайте обмежимось обговоренням простого IEnumerableпостачальника LINQ-до-об'єктів. Далі припустимо, що будь-який Funcпереданий як селектор / мутатор / тощо є дешевою операцією O (1).

Видається очевидним , що всі операції за один прохід ( Select, Where, Count, Take/Skip, Any/Allі т.д.) буде O (п), так як вони тільки повинні пройти послідовність один раз; хоча навіть це підлягає ліні.

Все складніше для складніших операцій; безліч подібних операторів ( Union, Distinct, Exceptі т.д.) роботи з використанням GetHashCodeза замовчуванням (AFAIK), так що здається розумним припустити , що вони використовують хеш-таблицю всередині, що робить ці операції O (п), а також , в цілому. А як щодо версій, які використовують IEqualityComparer?

OrderByзнадобиться сортування, тому, швидше за все, ми дивимось на O (n log n). Що робити, якщо це вже відсортовано? Як щодо того, якщо я скажу OrderBy().ThenBy()і надаю однаковий ключ для обох?

Я міг бачити GroupByJoin), використовуючи або сортування, або хешування. Що це таке?

Containsбуде O (n) на a List, але O (1) на a HashSet- чи LINQ перевіряє базовий контейнер, щоб побачити, чи може він прискорити роботу?

І справжнє питання - досі я вважаю, що операції виконуються. Однак чи можу я взяти участь у цьому? Наприклад, контейнери STL чітко вказують на складність кожної операції. Чи є подібні гарантії на продуктивність LINQ у специфікації бібліотеки .NET?

Більше запитання (у відповідь на коментарі): Я
не дуже думав про накладні витрати, але я не очікував, що там буде дуже багато простого Linq-to-Objects. Публікація CodingHorror говорить про Linq-to-SQL, де я можу зрозуміти, що аналіз запиту і створення SQL додасть вартість - чи є аналогічна вартість і для постачальника об'єктів? Якщо так, то чи відрізняється він, якщо ви використовуєте декларативний чи функціональний синтаксис?


Хоча я справді не можу відповісти на ваше запитання, я хочу прокоментувати, що загалом велика частина продуктивності буде "накладними" порівняно з основною функціональністю. Це, звичайно, не той випадок, коли у вас дуже великі набори даних (> 10 к. Елементів), тому їм цікаво, у якому випадку ви хочете дізнатися.
Анрі

2
Re: "чи це інакше, якщо ви використовуєте декларативний чи функціональний синтаксис?" - компілятор переводить декларативний синтаксис у функціональний синтаксис, щоб вони були однаковими.
Джон Раш

"Контейнери STL чітко визначають складність кожної операції" .NET контейнери також чітко визначають складність кожної операції. Розширення Linq схожі на алгоритми STL, а не на контейнери STL. Так само, як коли ви застосовуєте алгоритм STL до контейнера STL, вам потрібно поєднати складність розширення Linq зі складністю операцій (-ів) контейнерів .NET, щоб правильно проаналізувати результуючу складність. Сюди входить облік спеціалізації за шаблонами, як згадується у відповіді Аронахоуда.
Тімбо

Основне питання полягає в тому, чому Microsoft не більше переймається тим, що оптимізація IList <T> буде обмеженою корисністю, враховуючи, що розробнику доведеться покладатися на незадокументовану поведінку, якщо його код залежатиме від його виконання.
Едвард Брей

AsParallel () у отриманому списку множин; має дати вам ~ O (1) <O (n)
затримка

Відповіді:


121

Гарантій дуже, дуже мало, але є кілька оптимізацій:

  • Методи розширення , які використовують індексний доступ, такі як ElementAt, Skip, Lastабо LastOrDefault, перевірятиме, дійсно чи основні знаряддя типу IList<T>, так що ви отримаєте O (1) доступ замість O (N).

  • У Countметод перевіряє для ICollectionреалізації, так що ця операція являє собою О (1) замість O (N).

  • Distinct, GroupBy JoinІ я вважаю також методи набору агрегації ( Union, Intersectа Except) використання хешування, тому вони повинні бути близькі до O (N) замість O (N²).

  • Containsперевіряє ICollectionреалізацію, тому може бути O (1), якщо базовий збірник також є O (1), наприклад a HashSet<T>, але це залежить від фактичної структури даних і не гарантується. Хеш-набори перекривають Containsметод, тому вони є O (1).

  • OrderBy Методи використовують стабільний швидкий корт, тому вони є середнім випадком O (N log N).

Я думаю, що це стосується більшості, якщо не всіх вбудованих методів розширення. Дійсно є дуже мало гарантій продуктивності; Сама Linq намагатиметься скористатися ефективними структурами даних, але це не вільний пропуск для написання потенційно неефективного коду.


Як щодо IEqualityComparerперевантажень?
цзаман

@tzaman: А що з ними? Якщо ви не використовуєте дійсно неефективний звичай IEqualityComparer, я не можу пояснити, що це вплине на асимптотичну складність.
Aaronaught

1
О, так. Я не зрозумів , EqualityComparerінвентар GetHashCode, а також Equals; але це звичайно має сенс.
цзаман

2
@imgen: Петля приєднується до O (N * M), що узагальнює O (N²) для неспоріднених множин. Linq використовує хеш-з'єднання, які є O (N + M), що узагальнює O (N). Це передбачає наполовину пристойну хеш-функцію, але це важко зіпсувати в .NET.
Aaronaught

1
все Orderby().ThenBy()ще є N logNчи це (N logN) ^2щось подібне?
М.казем Ахгарі

10

Я давно знаю, що .Count()повертається, .Countякщо перерахування є IList.

Але я завжди був трохи втомлений про час виконання якої складності операцій Set: .Intersect(), .Except(), .Union().

Ось декомпільована реалізація BCL (.NET 4.0 / 4.5) для .Intersect()(коментарі мої):

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

Висновки:

  • продуктивність O (M + N)
  • реалізація не скористається, коли колекції вже є наборами. (Це може бути не обов'язково просто, тому що використане IEqualityComparer<T>також має відповідати.)

Для повноти тут наведено реалізацію для .Union()та .Except().

Попередження спойлера: вони теж мають складність 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

Все, на що ви дійсно можете звернутися, - це те, що численні методи добре написані для загального випадку і не будуть використовувати наївні алгоритми. Ймовірно, є сторонні матеріали (блоги тощо), які описують алгоритми, які фактично використовуються, але вони не є офіційними або гарантованими в тому сенсі, що це алгоритми STL.

Для ілюстрації тут відображений вихідний код (люб’язно надано ILSpy) для Enumerable.Countвід 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;
    }
}

Як бачите, докладається певних зусиль, щоб уникнути наївного рішення просто перерахувати кожен елемент.


ітерація через весь об'єкт, щоб отримати графа (), якщо це IEnumerable, здається мені досить наївним ...
Zonko

4
@Zonko: Я не розумію вашої точки зору. Я змінив свою відповідь, щоб показати, що Enumerable.Countце не повторюється, якщо немає явної альтернативи. Як би ти зробив це менш наївним?
Марсело Кантос

Ну так, методи реалізуються найбільш ефективно, враховуючи джерело. Однак найефективнішим способом іноді є наївний алгоритм, і слід бути обережним при використанні linq, оскільки він приховує реальну складність викликів. Якщо ви не знайомі з базовою структурою об'єктів, якими ви маніпулюєте, ви могли легко використовувати неправильні методи для своїх потреб.
Зонко

@MarceloCantos Чому масиви не обробляються? Це те ж саме для ElementAtOrDefault методу referenceource.microsoft.com/#System.Core/System/Linq/…
Freshblood

@Freshblood Вони є. (Масиви реалізують ICollection.) Хоча не знаю про ElementAtOrDefault. Я здогадуюсь, масиви також реалізують ICollection <T>, але мій .Net в ці дні досить іржавий.
Марсело Кантос

3

Я щойно вибухнув рефлектор, і вони перевіряють базовий тип, коли Containsвикликається.

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

Правильна відповідь - "це залежить". залежить від того, який тип лежить в основі IEnumerable. Я знаю, що для деяких колекцій (наприклад, колекцій, які реалізують ICollection або IList) існують спеціальні кодові шляхи, які використовуються. Однак реальна реалізація не гарантує нічого особливого. наприклад, я знаю, що ElementAt () має особливий випадок для колекцій, що індексуються, як і Count (). Але в цілому ви, мабуть, припускаєте найгірший випадок O (n).

Взагалі я не думаю, що ви збираєтеся знайти потрібні гарантії продуктивності, хоча якщо ви зіткнетесь з певною проблемою продуктивності з оператором linq, ви завжди можете просто повторно доповнити її для вашої конкретної колекції. Також існує багато блогів та проектів розширення, які поширюють Linq на об’єкти, щоб додати такі види гарантій продуктивності. ознайомтеся з індексованим LINQ, який розширює та додає до набору операторів для отримання більшої користі від продуктивності.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.