如果仅请求第一个元素,Enumerable.OrderBy 是否对完整列表进行排序

Har*_*lse 0 linq performance

如果有顺序的话。并且您只需要有序序列的第一个元素。Orderby 是否足够聪明,不会对完整序列进行排序?

IEnumerable<MyClass> myItems = ...
MyClass maxItem = myItems.OrderBy(item => item.Id).FirstOrDefault();
Run Code Online (Sandbox Code Playgroud)

因此,如果询问第一个元素,则仅将具有最小值的项目排序为序列的第一个元素。当询问下一个元素时,对剩余序列中具有最小值的项目进行排序,等等。

或者如果您只想要第一个元素,则完整序列是否完全有序?

添加

显然这个问题不清楚。让我们举个例子。

Sort 函数可以执行以下操作:

  • 创建一个包含所有元素的链表
  • 只要链表包含元素:
    • 取链表的第一个元素为最小
    • 扫描链表的其余部分一次以查找任何较小的元素
    • 从链表中删除最小的元素
    • yield 返回最小元素

代码:

public static IEnumerable<TSource> Sort<TSource, TKey>(
    this IEnumerable<TSource> source, Func<TSource, TKey> keySelector)
{
    if (source == null) throw new ArgumentNullException(nameof(source));
    if (keySelector == null) throw new ArgumentNullException(nameof(keySelector));

    IComparer<TKey> comparer = Comparer<TKey>.Default;

    // create a linkedList with keyValuePairs of TKey and TSource
    var keyValuePairs = source
        .Select(source => new KeyValuePair<TKey, TSource>(keySelector(source), source);
    var itemsToSort = new LinkedList<KeyValuePair<Tkey, TSource>>(keyValuePairs);

    while (itemsToSort.Any())
    {   // there are still items in the list
        // select the first element as the smallest one
        var smallest = itemsToSort.First();

        // scan the rest of the linkedList to find the smallest one
        foreach (var element in itemsToSort.Skip(1))
        {
           if (comparer.Compare(element.Key, smallest.Key) < 1)
        {   // element.Key is smaller than smallest.Key: element becomes the smallest:
            smallest = element;
        }
    }

    // remove the smallest element from the linked list and return the value:
    itemsToSort.Remove(smallestElement);
    yield return smallestElement.Value;
}
Run Code Online (Sandbox Code Playgroud)

假设我有一个整数序列。

Suppose I have the following sequence of integers:

{4, 8, 3, 1, 7}
Run Code Online (Sandbox Code Playgroud)

在第一次迭代时,迭代器在内部创建一个键/值对的链接列表,并将列表的第一个元素分配为最小的元素

Linked List =  4 - 8 - 3 - 1 - 7
Smallest = 4
Run Code Online (Sandbox Code Playgroud)

链表被扫描一次,看是否有更小的链表。

Linked List =  4 - 8 - 3 - 1 - 7
Smallest = 1
Run Code Online (Sandbox Code Playgroud)

最小的被从链表中删除并返回:

Linked List =  4 - 8 - 3 - 7
return 1
Run Code Online (Sandbox Code Playgroud)

第二次迭代使用较短的链表进行相同的操作

Linked List =  4 - 8 - 3 - 7
smallest = 4
Run Code Online (Sandbox Code Playgroud)

再次扫描链表一次以找到最小的链表

Linked List =  4 - 8 - 3 - 7
smallest = 3
Run Code Online (Sandbox Code Playgroud)

从链表中取出最小的并返回最小的

Linked List =  4 - 8 -  7
return 3
Run Code Online (Sandbox Code Playgroud)

很容易看出,如果您只要求排序列表中的第一个元素,则列表只会被扫描一次。每次迭代,要扫描的列表都会变小。

回到我原来的问题:

据我所知,如果您只想要第一个元素,则必须至少扫描列表一次。如果您不要求第二个元素,则列表的其余部分不会排序。

Enumerable.OrderBy 使用的排序是否如此智能,如果您只要求第一个订购的项目,则不会对列表的其余部分进行排序?

Jon*_*nna 5

这取决于版本。

\n\n

在框架版本(4.0、4.5 等)中:

\n\n
    \n
  1. 整个源代码被加载到缓冲区中。
  2. \n
  3. 生成键映射(以便每个元素仅生成一次键)​​。
  4. \n
  5. 生成整数映射,然后根据这些键进行排序(使用映射意味着如果源元素是大值类型,则交换操作具有更便宜的副本)。
  6. \n
  7. 尝试通过在结果对象上使用和FirstOrDefault来根据此映射获取第一项。它要么找到一个,要么(如果由于源为空而缓冲区为空)返回。MoveNextCurrentdefault(TSource)
  8. \n
\n\n

在 .NET Core 中,则:

\n\n
    \n
  1. 对源的操作FirstOrDefault进行扫描。IOrderedEnumerable如果没有元素,则返回,default(TSource)否则它将保留找到的第一个元素和密钥生成器生成的密钥,并将其与所有后续元素进行比较,如果下一个找到的比较值低于当前值。
  2. \n
  3. 保留的值将与框架版本通过第一次排序找到的元素相同,因此它被返回。
  4. \n
\n\n

这意味着在Framework版本中myItems.OrderBy(item => item.Id).FirstOrDefault()O(n log n)时间复杂度(最坏情况O(n\xc2\xb2))和O(n)空间复杂度,但在.NET Core版本中是O(n)时间复杂度和O(1)空间复杂度。

\n\n

这里的主要区别在于,.NET CoreFirstOrDefault()知道结果OrderByThenBy等)与其他可能来源的结果有何不同,并具有处理它的代码*,而在框架版本中则不然。

\n\n

两者都扫描整个序列(你无法知道最后一个元素myItems第一个元素),但在那之后它们的机制和效率有所不同。

\n\n
\n

当询问下一个元素时,对剩余序列中具有最小值的项目进行排序,等等。

\n
\n\n

如果询问下一个元素,那么不仅会再次进行任何排序,而且还必须按照myItems进行排序,因为同时

\n\n

如果你试图使用它来获取它,myItems.OrderBy(item => item.Id).ElementAtOrDefault(i)那么框架版本将通过首先进行排序(O(n log n))然后扫描(O(n)相对于i)来找到元素,而.NET Core版本将通过快速选择来找到它(O(n)尽管常数因子更大)比 forFirstOrDefault()并且可以O(n\xc2\xb2)与排序相同的情况下一样高,所以它比排序慢(由于这个原因O(n)它足够聪明)。两个版本也使用空间复杂度(除非.NET Core可以把它变成ElementAtOrDefault(0)FirstOrDefault()O(n)FirstOrDefault())。

\n\n

如果您找到前几个值,那么myItems.OrderBy(item => item.Id).Take(k)框架版本将再次执行排序(O(n log n))并对结果的后续枚举进行限制,以便在k获得后停止返回元素。.NET Core 版本会进行部分排序,而不是费心对元素进行排序,因为它意识到这些元素总是出现在所取部分之后,这就是O(n + k log k)时间复杂度。.NET Core 还会对Take和的组合进行单一部分排序Skip进一步减少必要的排序量。

\n\n

理论上, just 的排序OrderBy(cmp)可能会更懒惰,如下所示:

\n\n
    \n
  1. 将元素加载到缓冲区中。
  2. \n
  3. 进行排序,在分区发生时可能倾向于“左”分区。
  4. \n
  5. yield一旦发现它们是下一个要枚举的元素。
  6. \n
\n\n

这将缩短获得第一个结果的时间(获得第一个结果的时间短通常是其他 Linq 操作的一个很好的功能),并且特别有利于那些可能中途停止处理结果的消费者。然而,它给排序操作增加了额外的常量成本,并且要么阻止以减少递归量的方式选择要处理的下一个分区(基于分区的排序的重要优化),要么通常实际上不会产生任何结果,直到无论如何都快结束了(使得练习毫无意义)。这也会使排序变得更加复杂。虽然我尝试了这种方法,但某些情况下的回报并不能证明其他情况下的成本是合理的,特别是因为它似乎可能伤害的人多于受益的人。

\n\n

*严格来说,几个 linq 操作的结果知道如何以针对每个操作优化的方式查找第一个元素,并且FirstOrDefault()知道如何检测任何这些情况。

\n