使用Linq获取集合中的最后N个元素?

340

给定一个集合,有没有一种方法可以获取该集合的最后N个元素?如果框架中没有提供方法,编写一个扩展方法的最佳方式是什么?


3
https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.takelast - Neo
10
正如@Neo和@Ray所指出的,TakeLast()在.Net Core 2.0及更高版本以及.Net Standard 2.1及更高版本中可用。 - IowaEric
21个回答

506
collection.Skip(Math.Max(0, collection.Count() - N));

这种方法保留了项目顺序,而不依赖于任何排序,并且在几个LINQ提供程序中具有广泛的兼容性。

重要的是要注意不要使用负数调用Skip。一些提供程序,例如Entity Framework,在提供负参数时会产生ArgumentException。对Math.Max的调用可以很好地避免这种情况。

下面的类具有扩展方法的所有基本要素,包括:静态类、静态方法和使用this关键字。

public static class MiscExtensions
{
    // Ex: collection.TakeLast(5);
    public static IEnumerable<T> TakeLast<T>(this IEnumerable<T> source, int N)
    {
        return source.Skip(Math.Max(0, source.Count() - N));
    }
}

关于性能的简要说明:

因为调用 Count() 可能会导致对某些数据结构进行枚举,所以这种方法有可能导致对数据进行两次遍历。对于大多数可枚举对象来说,这并不是问题;事实上,已经存在针对列表、数组和甚至 EF 查询的优化,以在 O(1) 时间内计算 Count() 操作。

然而,如果您必须使用前向只枚举,并希望避免进行两次遍历,请考虑像 Lasse V. KarlsenMark Byers 描述的一遍算法。这两种方法都使用临时缓冲区来保存在枚举时的项目,一旦找到集合的末尾就会产生。


2
+1,因为它适用于Linq to Entities/SQL。我猜在Linq to Objects中也比James Curran的策略更高效。 - StriplingWarrior
12
根据收集的性质而定。Count() 可能是 O(N)。 - James Curran
4
@James:完全正确。如果严格处理IEnumerable集合,这可能是一个需要两次遍历的查询。我非常希望能看到一种保证一次遍历的算法。这可能会很有用。 - kbrimington
4
进行了一些基准测试,结果表明LINQ与对象的性能会根据所使用集合的类型进行一些优化。使用数组、列表和链表时,詹姆斯的解决方案往往更快,但并不是数量级的差别。如果IEnumerable是通过计算(例如Enumerable.Range)得出的,则詹姆斯的解决方案需要更长的时间。我想不到任何方法来确保单次遍历,除非了解实现细节或将值复制到另一个数据结构中。 - StriplingWarrior
1
@DharmaTurtle,有趣的观点。也许将查询材料化为列表在内存集合中是合适的,其中每个实体的材料化成本很高; 然而,对于L2S或EF查询,我会不赞成这种想法,因为它会导致比必要的更多的实体被材料化。此外,虽然构建列表是O(N),但比大多数集合仅迭代还要昂贵。 - kbrimington
显示剩余14条评论

75
coll.Reverse().Take(N).Reverse().ToList();


public static IEnumerable<T> TakeLast<T>(this IEnumerable<T> coll, int N)
{
    return coll.Reverse().Take(N).Reverse();
}

更新:针对clintp的问题:a)使用我上面定义的TakeLast()方法解决了这个问题,但如果你真的想不使用额外的方法来做到这一点,那么你只需要认识到虽然Enumerable.Reverse()可以用作扩展方法,但你并不一定要这样使用:

List<string> mystring = new List<string>() { "one", "two", "three" }; 
mystring = Enumerable.Reverse(mystring).Take(2).Reverse().ToList();

我对此的问题是,如果我这样说:List<string> mystring = new List<string>() { "one", "two", "three" }; mystring = mystring.Reverse().Take(2).Reverse(); 我会得到一个编译器错误,因为.Reverse()返回void,编译器选择了该方法而不是返回IEnumerable的Linq方法。有什么建议吗? - Clinton Pierce
1
你可以通过将 mystring 显式转换为 IEnumerable<String> 来解决这个问题: ((IEnumerable<String>)mystring).Reverse().Take(2).Reverse() - Jan Hettich
简单易懂,但需要完全反转顺序两次。这可能是最好的方法。 - shashwat
除了kbrimington的答案,我还喜欢它。如果您在获取最后N条记录后不关心顺序,则可以跳过第二个Reverse - ZoolWay
1
@shashwat 它不会“完全”两次颠倒顺序。第二个反转仅适用于N项的集合。此外,取决于Reverse()的实现方式,第一次调用它可能仅翻转N项。(.NET 4.0实现将把集合复制到数组中,并向后索引) - James Curran

66

我正在使用.NET Standard 2.0,但它不可用。出了什么问题? :( - SuperJMN
1
@SuperJMN 尽管您可能正在引用 .net standard 2.0 库,但您的项目可能没有针对正确版本的 dotnet core 进行定位。该方法不适用于 v1.x (netcoreapp1.x),而仅适用于 dotnetcore 的 v2.0 和 v2.1 (netcoreapp2.x)。您可能正在针对完整框架(例如 net472)进行定位,这也是不受支持的。(.net standard 库可以被上述任何一个使用,但只能公开特定于目标框架的某些 API。请参见 https://learn.microsoft.com/en-us/dotnet/standard/frameworks) - Ray
5
这些现在需要更优先考虑。无需重新发明轮子。 - James Woodley
1
@SuperJMN 正确。这在标准2.0中不可用。但是它在标准2.1中是可用的。 - bytedev

55

注意:我错过了您的问题标题,标题中写着使用Linq,因此我的答案实际上没有使用Linq。

如果你想避免缓存整个集合的非惰性副本,你可以编写一个简单的方法,使用链表来实现。

下面的方法将会把它在原始集合中找到的每个值添加到一个链表中,并将链表裁剪到所需的项数。由于它保持整个迭代过程中的链表项数不超过N,因此它只会保留原始集合中最多N个项目的副本。

它不需要你知道原始集合中的项目数量,也不需要对其进行多次迭代。

用法:

IEnumerable<int> sequence = Enumerable.Range(1, 10000);
IEnumerable<int> last10 = sequence.TakeLast(10);
...

扩展方法:

public static class Extensions
{
    public static IEnumerable<T> TakeLast<T>(this IEnumerable<T> collection,
        int n)
    {
        if (collection == null)
            throw new ArgumentNullException(nameof(collection));
        if (n < 0)
            throw new ArgumentOutOfRangeException(nameof(n), $"{nameof(n)} must be 0 or greater");

        LinkedList<T> temp = new LinkedList<T>();

        foreach (var value in collection)
        {
            temp.AddLast(value);
            if (temp.Count > n)
                temp.RemoveFirst();
        }

        return temp;
    }
}

我仍然认为即使它不是技术上使用Linq,你的回答仍然是好的、有效的,所以我仍然给你一个+1 :) - Matthew Groves
1
我认为这是唯一的解决方案,不会导致源枚举器运行两次(或更多),也不会强制枚举的实现,因此在大多数应用程序中,它在内存和速度方面都更加高效。 - Sprotty
@Sprotty 我认为你需要根据你的集合进行测试。然而,我对非常大量的 int 集合进行的测试表明,Skip 总是比较快(快了约 10 倍)。 - bytedev
1
值得注意的是,.NET Core 添加了一个 TakeLast 方法,该方法使用队列而不是链表。 - Panagiotis Kanavos

33

这里有一种适用于任何可枚举对象的方法,但只使用O(N)的临时存储空间:

public static class TakeLastExtension
{
    public static IEnumerable<T> TakeLast<T>(this IEnumerable<T> source, int takeCount)
    {
        if (source == null) { throw new ArgumentNullException("source"); }
        if (takeCount < 0) { throw new ArgumentOutOfRangeException("takeCount", "must not be negative"); }
        if (takeCount == 0) { yield break; }

        T[] result = new T[takeCount];
        int i = 0;

        int sourceCount = 0;
        foreach (T element in source)
        {
            result[i] = element;
            i = (i + 1) % takeCount;
            sourceCount++;
        }

        if (sourceCount < takeCount)
        {
            takeCount = sourceCount;
            i = 0;
        }

        for (int j = 0; j < takeCount; ++j)
        {
            yield return result[(i + j) % takeCount];
        }
    }
}

使用方法:

List<int> l = new List<int> {4, 6, 3, 6, 2, 5, 7};
List<int> lastElements = l.TakeLast(3).ToList();

它使用大小为N的环形缓冲区来存储元素,将旧元素覆盖为新元素。当枚举结束时,环形缓冲区包含最后N个元素。


2
+1:这个应该比我的性能更好,但是你要确保当集合中的元素少于n时它仍然能正常工作。 - Lasse V. Karlsen
大多数情况下,我认为人们会在从SO复制代码用于生产之前自行添加此类内容,这可能不是问题。如果您要添加它,请考虑检查null的集合变量。否则,解决方案非常好 :) 我曾考虑过使用环形缓冲区,因为链表会增加GC压力,但是我已经有一段时间没有做过了,而且我不想费心编写测试代码来确定我是否做对了。我必须说我正在爱上LINQPad :) http://www.linqpad.net/ - Lasse V. Karlsen
3
可能的优化是检查可枚举对象是否实现了IList接口,并在实现时使用简单的解决方案。如果实现了该接口,则只需要在真正的“流式”可枚举对象中使用临时存储方法。 - piers7
1
微不足道的挑剔:ArgumentOutOfRangeException 的参数顺序不正确(R#说) - piers7

12

我很惊讶没有人提到这一点,但是SkipWhile确实有一个方法使用元素的索引

public static IEnumerable<T> TakeLastN<T>(this IEnumerable<T> source, int n)
{
    if (source == null)
        throw new ArgumentNullException("Source cannot be null");

    int goldenIndex = source.Count() - n;
    return source.SkipWhile((val, index) => index < goldenIndex);
}

//Or if you like them one-liners (in the spirit of the current accepted answer);
//However, this is most likely impractical due to the repeated calculations
collection.SkipWhile((val, index) => index < collection.Count() - N)

这个解决方案相较于其他方案唯一的显著优点是你可以选择添加一个谓词,以便创建更强大和高效的LINQ查询,而不必进行两次遍历IEnumerable的单独操作。
public static IEnumerable<T> FilterLastN<T>(this IEnumerable<T> source, int n, Predicate<T> pred)
{
    int goldenIndex = source.Count() - n;
    return source.SkipWhile((val, index) => index < goldenIndex && pred(val));
}

9
在RX的System.Interactive程序集中使用EnumerableEx.TakeLast。它是一个O(N)实现,类似于@Mark's的实现,但是它使用了队列而不是环形缓冲区结构(并在达到缓冲容量时将项目出队)。
(注:这是IEnumerable版本-而不是IObservable版本,尽管两者的实现基本相同)

这是最好的答案。如果有适合的库并且RX团队质量高,就不要自己编写。 - bradgonesurfing
如果你打算使用这个,就从Nuget上安装它 - http://www.nuget.org/packages/Ix-Async/ - nikib3ro
C# 的 Queue<T> 不是使用 循环缓冲区 实现的吗? - tigrou
@tigrou 不,它不是循环的。 - citykid
1
那么文档一定是在撒谎。 - tigrou

6
如果你正在处理带有键的集合(例如来自数据库的条目),一个快速(即比所选答案更快)的解决方案是:
collection.OrderByDescending(c => c.Key).Take(3).OrderBy(c => c.Key);

+1 对我来说很好,并且易于阅读,我的列表中只有少量对象。 - fubo

5

如果您不介意将Rx作为单子的一部分,您可以使用TakeLast

IEnumerable<int> source = Enumerable.Range(1, 10000);

IEnumerable<int> lastThree = source.AsObservable().TakeLast(3).AsEnumerable();

2
如果您引用RX的System.Interactive而不是System.Reactive(请参见我的答案),则无需使用AsObservable()函数。 - piers7

4

我尝试结合效率和简洁,最终得出这个:

public static IEnumerable<T> TakeLast<T>(this IEnumerable<T> source, int count)
{
    if (source == null) { throw new ArgumentNullException("source"); }

    Queue<T> lastElements = new Queue<T>();
    foreach (T element in source)
    {
        lastElements.Enqueue(element);
        if (lastElements.Count > count)
        {
            lastElements.Dequeue();
        }
    }

    return lastElements;
}

关于性能:在C#中,Queue<T>是使用一个循环缓冲区实现的,因此在每个循环中没有对象实例化(仅当队列增长时才实例化)。我没有设置队列容量(使用专用构造函数),因为有人可能会使用count = int.MaxValue调用此扩展。为了提高性能,您可以检查源是否实现了IList<T>,如果是,则直接使用数组索引提取最后的值。


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