如何使用LINQ选择具有最小或最大属性值的对象

564

我有一个Person对象,其中包含一个可空的DateOfBirth属性。是否有一种方法可以使用LINQ查询Person对象列表中具有最早/最小的DateOfBirth值的对象?

以下是我开始使用的内容:

var firstBornDate = People.Min(p => p.DateOfBirth.GetValueOrDefault(DateTime.MaxValue));

为了将空的DateOfBirth值排除在Min考虑范围之外(假设至少有一个指定了DOB),它们被设置为DateTime.MaxValue。

但对我来说,这只是将firstBornDate设置为DateTime值。我想要的是与其匹配的Person对象。我需要像下面这样编写第二个查询吗:

var firstBorn = People.Single(p=> (p.DateOfBirth ?? DateTime.MaxValue) == firstBornDate);

还有没有更简洁的方法?


29
关于您的示例,我想发表一点评论:在这里您可能不应该使用 Single。如果两个人具有相同的出生日期,它将抛出异常。 - Niki
1
请参阅几乎相同的 https://dev59.com/VnE85IYBdhLWcg3whD7k,其中有一些简洁的示例。 - goodeye
4
多么简单实用的特性啊! MinBy 应该成为标准库的一部分。我们应该向 Microsoft 提交一个拉取请求 https://github.com/dotnet/corefx - Colonel Panic
3
今天已经存在这个功能,只需提供一个函数来选择属性:a.Min(x => x.foo); - jackmott
6
为了举例说明这个问题:在Python中,max("find a word of maximal length in this sentence".split(), key=len)会返回字符串'sentence'。而在C#中,"find a word of maximal length in this sentence".Split().Max(word => word.Length)可以计算出任何单词的最大长度是8,但无法告诉你最长的单词是什么。 - Colonel Panic
显示剩余2条评论
20个回答

345
People.Aggregate((curMin, x) => (curMin == null || (x.DateOfBirth ?? DateTime.MaxValue) <
    curMin.DateOfBirth ? x : curMin))

23
可能比仅实现IComparable并使用Min(或for循环)要慢一些。但是对于O(n)的Linq解决方案,加上+1。 - Matthew Flaschen
4
另外,它需要 < curmin.DateOfBirth。否则,你会将一个DateTime与一个Person进行比较。 - Matthew Flaschen
2
使用此方法比较两个日期时间时要小心。我曾试图在一个无序集合中找到最后的更改记录,但失败了,因为我想要的记录最终具有相同的日期和时间。 - Simon Gill
10
为什么你要进行多余的检查curMin == null?只有在使用值为null的种子执行Aggregate()时,curMin才可能为空。 - Good Night Nerd Pride
6
同意。"source" 的第一个元素被用作初始聚合值。 - Wolfzoon
显示剩余3条评论

262

很遗憾,没有内置的方法可以做到这一点,但是自己实现很容易。以下是实现的要点:

public static TSource MinBy<TSource, TKey>(this IEnumerable<TSource> source,
    Func<TSource, TKey> selector)
{
    return source.MinBy(selector, null);
}

public static TSource MinBy<TSource, TKey>(this IEnumerable<TSource> source,
    Func<TSource, TKey> selector, IComparer<TKey> comparer)
{
    if (source == null) throw new ArgumentNullException("source");
    if (selector == null) throw new ArgumentNullException("selector");
    comparer ??= Comparer<TKey>.Default;

    using (var sourceIterator = source.GetEnumerator())
    {
        if (!sourceIterator.MoveNext())
        {
            throw new InvalidOperationException("Sequence contains no elements");
        }
        var min = sourceIterator.Current;
        var minKey = selector(min);
        while (sourceIterator.MoveNext())
        {
            var candidate = sourceIterator.Current;
            var candidateProjected = selector(candidate);
            if (comparer.Compare(candidateProjected, minKey) < 0)
            {
                min = candidate;
                minKey = candidateProjected;
            }
        }
        return min;
    }
}

使用示例:

var firstBorn = People.MinBy(p => p.DateOfBirth ?? DateTime.MaxValue);

请注意,如果序列为空,这将抛出异常,并且如果有多个最小值,则返回具有最小值的第一个元素。
或者,您可以使用我们在MoreLINQ中的实现,在MinBy.cs中。(当然还有相应的MaxBy。)
通过包管理器控制台安装:

PM> Install-Package morelinq


1
我会用foreach替换Ienumerator + while。 - ggf31416
7
由于循环前第一次调用MoveNext(),所以无法轻松地这样做。有替代方案,但我认为它们更加混乱。 - Jon Skeet
2
虽然我可以返回default(T),但这种做法对我来说不太合适。这更符合像First()这样的方法和Dictionary索引器的方法。如果你想的话,你也可以轻松地进行调整。 - Jon Skeet
9
我把答案授予了Paul,因为他给出了非使用库的解决方案。但是感谢你提供了这段代码和MoreLINQ库的链接,我想我会开始使用它! - slolife
1
我特别喜欢这个解决方案,因为它使用线性时间复杂度找到了最小值 - 感谢@JonSkeet。 - Yawar Murtaza
显示剩余8条评论

176

注意:我提供这个答案是为了完整性,因为问答者没有提到数据来源,我们不应该做出任何假设。

此查询可以给出正确答案,但可能会比较慢,因为它可能需要对People中的所有项目进行排序,具体取决于People是什么数据结构:

var oldest = People.OrderBy(p => p.DateOfBirth ?? DateTime.MaxValue).First();

更新:实际上我不应该称呼这个解决方案为"naive"(朴素的),但用户确实需要知道他要查询什么。这个解决方案的"缓慢"取决于底层数据。如果这是一个数组或者 List<T>,那么 LINQ to Objects 在选择第一个元素之前必须先对整个集合进行排序。在这种情况下,它将比其他提出的解决方案更慢。然而,如果这是一个 LINQ to SQL 表,且 DateOfBirth 是一个索引列,那么 SQL Server 将使用索引而不是对所有行进行排序。其他自定义的 IEnumerable<T> 实现也可以利用索引(参见i4o: Indexed LINQ,或对象数据库 db4o),使得这个解决方案比需要遍历整个集合的Aggregate()MaxBy()/MinBy() 更快。事实上,在理论上,LINQ to Objects 可以对像 SortedList<T> 这样已经排序好的集合在 OrderBy() 中进行特殊处理,但据我所知,它没有这么做。


4
有人已经发布了这个,但显然在我评论它有多么慢(和占用空间)之后删除了它 (最好情况下为O(n log n)速度,而与O(n)的“min”相比)。 :) Translated: 有人已经发布过这个内容,但是在我评论了它有多慢(以及占用空间)之后,显然将其删除了(最佳情况下为O(nlogn)速度,而与“min”的O(n)相比)。 :) - Matthew Flaschen
是的,因此我警告您这是一个天真的解决方案 :) 但它非常简单,可能在某些情况下可用(小集合或如果DateOfBirth是索引的DB列) - Lucas
另一个特殊情况(也不存在)是,可以利用orderby和first的知识进行搜索,而无需排序即可查找最低值。 - Rune FS
对集合进行排序是Nlog(N)操作,这并不比线性或O(n)时间复杂度更好。如果我们只需要从序列中获取一个元素/对象,即最小值或最大值,我认为我们应该坚持使用线性时间复杂度。 - Yawar Murtaza
@yawar 集合可能已经排序(更有可能是索引),这样你就可以拥有O(log n)。 - Rune FS

72
People.OrderBy(p => p.DateOfBirth.GetValueOrDefault(DateTime.MaxValue)).First()

可以解决问题。


1
这个很棒!在我的linq投影案例中,我使用了OrderByDesending(...).Take(1)。 - Vedran Mandić
3
这个方法使用了排序,时间复杂度超过了O(N),同时也需要O(N)的内存空间。 - George Polevoy
1
@GeorgePolevoy,这假设我们已经对数据源有相当多的了解。如果数据源已经在给定字段上有一个排序索引,那么这将是一个(低)常数,并且比需要遍历整个列表的已接受答案要快得多。另一方面,如果数据源是一个数组,那么你当然是正确的。 - Rune FS
@RuneFS -- 你仍然应该在你的回答中提到这一点,因为这很重要。 - rory.ap
性能会拖慢你的速度,我是吃过亏才明白的。如果你想要获取最小或最大值的对象,那么你不需要对整个数组进行排序,只需要扫描一次即可。可以查看被接受的答案或者使用MoreLinq包。 - Sau001
如果它被用于索引集合上,那么可能会起作用。然而,如果使用order by,查找最小值将变成二分查找,使其复杂度从O(n)变为O(log n)。 - Rune FS

60

所以你正在寻求 ArgMinArgMax。C# 没有内置的 API 可供使用。

我一直在寻找一种干净有效(时间复杂度为 O(n))的方法来实现这个功能,并且我认为我找到了:

该模式的一般形式是:

var min = data.Select(x => (key(x), x)).Min().Item2;
                            ^           ^       ^
              the sorting key           |       take the associated original item
                                Min by key(.)

特别地,针对原问题中的示例:

对于支持值元组的 C# 7.0 及以上版本:

var youngest = people.Select(p => (p.DateOfBirth, p)).Min().Item2;

对于 C# 版本 7.0 之前,可以使用匿名类型代替

var youngest = people.Select(p => new {age = p.DateOfBirth, ppl = p}).Min().ppl;
他们之所以有效,是因为值元组和匿名类型都有明智的默认比较器:对于 (x1, y1) 和 (x2, y2),首先比较 x1x2,然后比较 y1y2。这就是为什么可以在这些类型上使用内置的 .Min
由于匿名类型和值元组都是值类型,它们应该都非常高效。
注意:
在我的上述实现中,我假设 DateOfBirth 采用了类型 DateTime,以简化和清晰起见。原问题要求排除那些具有空 DateOfBirth 字段的条目:
将 Null DateOfBirth 值设置为 DateTime.MaxValue,以排除它们不被 Min 考虑(假设至少有一个具有指定 DOB)。可以通过预过滤来实现。
people.Where(p => p.DateOfBirth.HasValue)

所以对于实现ArgMinArgMax的问题来说,这是不相关的。

注意2

上述方法有一个警告:当有两个实例具有相同的最小值时,Min()的实现将尝试将实例进行比较以解决平局。但是,如果实例的类没有实现IComparable,则会抛出运行时错误:

至少有一个对象必须实现IComparable

幸运的是,这可以通过关联一个唯一的“ID”来解决平局。我们可以为每个条目使用递增的ID。仍然以人年龄为例:

var youngest = Enumerable.Range(0, int.MaxValue)
               .Zip(people, (idx, ppl) => (ppl.DateOfBirth, idx, ppl)).Min().Item3;

2
当值类型为排序键时,似乎无法正常工作。"至少有一个对象必须实现IComparable" - liang
1
太棒了!这应该是最佳答案。 - afruzan
@liang 是的,非常好的发现。幸运的是,仍然有一个干净的解决方案。请参阅“注2”部分中的更新解决方案。 - KFL
2
Select 可以给你 ID!var youngest = people.Select((p, i) => (p.DateOfBirth, i, p)).Min().Item2; - Jeremy
那个最后的解决方案真的是很丑陋。Linq 经常让困难变得简单,而简单变得困难。一般的程序员要花很多时间才能理解那个语句的含义。不过我猜你并不是一个普通的程序员。 - Ash
1
这是一个更易读的解决方案:var (minDateOfBirth, idx, youngestPerson) = people.Select((p, idx) => (p.DateOfBirth, idx, p)).Min() - Marduk

53

.NET 6 原生支持 MaxBy/MinBy,所以您只需使用简单的代码即可实现:

People.MinBy(p => p.DateOfBirth)


如今应该接受这个答案。 - Alexei - check Codidact
我会加上相同的评论,希望它能提高它的优先级。一旦我意识到他们已经本地化添加了它,这似乎是唯一明显且好的解决方案。 :) - Brian Birtle

27

不需要额外的软件包的解决方案:

var min = lst.OrderBy(i => i.StartDate).FirstOrDefault();
var max = lst.OrderBy(i => i.StartDate).LastOrDefault();

同时,您还可以将其封装为扩展程序:

public static class LinqExtensions
{
    public static T MinBy<T, TProp>(this IEnumerable<T> source, Func<T, TProp> propSelector)
    {
        return source.OrderBy(propSelector).FirstOrDefault();
    }

    public static T MaxBy<T, TProp>(this IEnumerable<T> source, Func<T, TProp> propSelector)
    {
        return source.OrderBy(propSelector).LastOrDefault();
    }
}

在这种情况下:

var min = lst.MinBy(i => i.StartDate);
var max = lst.MaxBy(i => i.StartDate);

顺便说一下,O(n^2)不是最好的解决方案。Paul Betts提供了比我更快的解决方案。但我的解决方案仍然是LINQ解决方案,比这里的其他解决方案更简单、更短。

7

从 .Net 6 (Preview 7) 或以后的版本开始,新增了内置方法Enumerable.MaxByEnumerable.MinBy来实现此操作。

var lastBorn = people.MaxBy(p => p.DateOfBirth);

var firstBorn = people.MinBy(p => p.DateOfBirth);

3
public class Foo {
    public int bar;
    public int stuff;
};

void Main()
{
    List<Foo> fooList = new List<Foo>(){
    new Foo(){bar=1,stuff=2},
    new Foo(){bar=3,stuff=4},
    new Foo(){bar=2,stuff=3}};

    Foo result = fooList.Aggregate((u,v) => u.bar < v.bar ? u: v);
    result.Dump();
}

3

完美简单地使用聚合函数(在其他语言中等同于fold):

var firstBorn = People.Aggregate((min, x) => x.DateOfBirth < min.DateOfBirth ? x : min);

唯一的缺点是每个序列元素访问该属性两次,这可能会很昂贵。这很难解决。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接