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

Posted

技术标签:

【中文标题】使用 Linq 获取集合的最后 N 个元素?【英文标题】:Using Linq to get the last N elements of a collection? 【发布时间】:2011-03-28 01:35:05 【问题描述】:

给定一个集合,有没有办法获取该集合的最后 N 个元素?如果框架中没有方法,那么编写扩展方法来执行此操作的最佳方法是什么?

【问题讨论】:

docs.microsoft.com/en-us/dotnet/api/… 正如@Neo 和@Ray 所指出的,TakeLast() 在 .Net Core 2.0 及更高版本和 .Net Standard 2.1 及更高版本中可用 【参考方案1】:

老实说,我对这个答案并不感到非常自豪,但对于小型收藏,您可以使用以下方法:

var lastN = collection.Reverse().Take(n).Reverse();

有点老套,但它可以完成工作;)

【讨论】:

【参考方案2】:
//detailed code for the problem
//suppose we have a enumerable collection 'collection'
var lastIndexOfCollection=collection.Count-1 ;
var nthIndexFromLast= lastIndexOfCollection- N;

var desiredCollection=collection.GetRange(nthIndexFromLast, N);
---------------------------------------------------------------------

// use this one liner
var desiredCollection=collection.GetRange((collection.Count-(1+N)), N);

【讨论】:

感谢您发布答案。但是,仅代码答案不是很有帮助。请添加说明如何解决 OP 的问题。 另外,在回答旧的、很好回答的问题时,您必须解释您的答案对现有答案的补充。我想知道你是否注意到 this existing answer 基本上是一样的。【参考方案3】:

注意:我错过了你说Using Linq的问题标题,所以我的回答实际上并没有使用Linq。

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

以下方法会将它在原始集合中找到的每个值添加到一个链表中,并将链表修剪到所需的项目数。由于它在遍历集合的过程中始终将链表修剪为该数量的项目,因此它只会保留原始集合中最多 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 :) 我认为它是唯一不会导致源枚举器运行两次(或更多)并且不会强制实现枚举的解决方案,所以在大多数应用程序中我会说它在内存和速度方面会更有效率。 @Sproty 我认为您必须根据您的收藏来测试它。然而,我对非常大量的 int 集合进行的测试,Skip 的结果总是快得多(快约 10 倍)。 值得注意的是,.NET Core 增加了一个TakeLast 方法,该方法使用的是Queue而不是LinkedList【参考方案4】:

.NET Core 2.0+ 提供了LINQ方法TakeLast()

https://docs.microsoft.com/en-us/dotnet/api/system.linq.enumerable.takelast

例子

Enumerable
    .Range(1, 10)
    .TakeLast(3) // <--- takes last 3 items
    .ToList()
    .ForEach(i => System.Console.WriteLine(i))

// outputs:
// 8
// 9
// 10

【讨论】:

我正在使用 :NET Standard 2.0,但我没有它可用。怎么了? :( @SuperJMN 虽然您可能引用了 .net 标准 2.0 库,但您的项目中可能没有针对正确版本的 dotnet core。此方法不适用于 v1.x (netcoreapp1.x),但仅适用于 dotnetcore (netcoreapp2.x) 的 v2.0 和 v2.1。您可能会针对同样不受支持的完整框架(例如net472)。 (.net 标准库可以被上述任何一个使用,但可能只公开特定于目标框架的某些 API。请参阅docs.microsoft.com/en-us/dotnet/standard/frameworks) 这些现在需要更高了。无需重新发明*** @SuperJMN 正确。这在标准 2.0 中不可用。然而,它在标准 2.1 中【参考方案5】:

使用循环缓冲区的实现略有不同。基准测试表明,该方法比使用 Queue 的方法快大约两倍(在 System.Linq 中实现 TakeLast),但并非没有成本 - 它需要一个随着请求的元素数量而增长的缓冲区,即使你有一个小集合,你也可以获得巨大的内存分配。

public IEnumerable<T> TakeLast<T>(IEnumerable<T> source, int count)

    int i = 0;

    if (count < 1)
        yield break;

    if (source is IList<T> listSource)
    
        if (listSource.Count < 1)
            yield break;

        for (i = listSource.Count < count ? 0 : listSource.Count - count; i < listSource.Count; i++)
            yield return listSource[i];

    
    else
    
        bool move = true;
        bool filled = false;
        T[] result = new T[count];

        using (var enumerator = source.GetEnumerator())
            while (move)
            
                for (i = 0; (move = enumerator.MoveNext()) && i < count; i++)
                    result[i] = enumerator.Current;

                filled |= move;
            

        if (filled)
            for (int j = i; j < count; j++)
                yield return result[j];

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

    

【讨论】:

【参考方案6】:
collection.Skip(Math.Max(0, collection.Count() - N));

这种方法在不依赖任何排序的情况下保留项目顺序,并且在多个 LINQ 提供程序之间具有广泛的兼容性。

注意不要用负数调用Skip,这一点很重要。某些提供程序(例如实体框架)在出现否定参数时会产生 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. Karlsen 或 Mark Byers 描述。这两种方法在枚举时都使用临时缓冲区来保存项目,一旦找到集合的末尾就会产生这些项目。

【讨论】:

+1,因为这适用于 Linq to Entities/SQL。我猜它在 Linq to Objects 中的性能也比 James Curran 的策略更好。 取决于收集的性质。 Count() 可能是 O(N)。 @James:绝对正确。如果严格处理 IEnumerable 集合,这可能是一个两遍查询。我很想看到有保证的 1-pass 算法。它可能有用。 做了一些基准测试。事实证明,LINQ to Objects 会根据您使用的集合类型执行一些优化。使用数组Lists 和LinkedLists,James 的解决方案往往更快,尽管不是一个数量级。如果计算 IEnumerable(例如通过 Enumerable.Range),James 的解决方案需要更长的时间。在不了解实现或将值复制到不同的数据结构的情况下,我想不出任何方法来保证一次通过。 @RedFilter - 很公平。我想我的寻呼习惯在这里泄露了。感谢您的敏锐目光。【参考方案7】:

使用此方法获取所有范围而不会出错

 public List<T> GetTsRate( List<T> AllT,int Index,int Count)
        
            List<T> Ts = null;
            try
            
                Ts = AllT.ToList().GetRange(Index, Count);
            
            catch (Exception ex)
            
                Ts = AllT.Skip(Index).ToList();
            
            return Ts ;
        

【讨论】:

【参考方案8】:

我试图将效率和简单性结合起来,最终得到了这样的结果:

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&lt;T&gt; 是使用 circular buffer 实现的,因此每个循环都没有对象实例化(仅当队列增长时)。我没有设置队列容量(使用专用构造函数),因为有人可能会用 count = int.MaxValue 调用这个扩展。为了获得额外的性能,您可以检查源是否实现 IList&lt;T&gt;,如果是,则使用数组索引直接提取最后一个值。

【讨论】:

【参考方案9】:
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&lt;string&gt; mystring = new List&lt;string&gt;() "one", "two", "three" ; mystring = mystring.Reverse().Take(2).Reverse(); 我得到一个编译器错误,因为 .Reverse() 返回 void 并且编译器选择该方法而不是返回 IEnumerable 的 Linq 方法.建议? 您可以通过将 mystring 显式转换为 IEnumerable 来解决此问题: ((IEnumerable)mystring).Reverse().Take(2).Reverse() 简单易行,但需要完全颠倒两次顺序。这可能是最好的方法 除了 kbrimington 接受的答案之外,我还喜欢它。如果您在拥有最后一个 N 记录后不关心订单,则可以跳过第二个 Reverse @shashwat 它不会“完全”两次颠倒顺序。第二次反转仅适用于 N 项的集合。此外,根据 Reverse() 的实现方式,第一次调用它可能只会反转 N 项。 (.NET 4.0 实现会将集合复制到数组中,并向后索引)【参考方案10】:

这是我的解决方案:

public static class EnumerationExtensions

    public static IEnumerable<T> TakeLast<T>(this IEnumerable<T> input, int count)
    
        if (count <= 0)
            yield break;

        var inputList = input as IList<T>;

        if (inputList != null)
        
            int last = inputList.Count;
            int first = last - count;

            if (first < 0)
                first = 0;

            for (int i = first; i < last; i++)
                yield return inputList[i];
        
        else
        
            // Use a ring buffer. We have to enumerate the input, and we don't know in advance how many elements it will contain.
            T[] buffer = new T[count];

            int index = 0;

            count = 0;

            foreach (T item in input)
            
                buffer[index] = item;

                index = (index + 1) % buffer.Length;
                count++;
            

            // The index variable now points at the next buffer entry that would be filled. If the buffer isn't completely
            // full, then there are 'count' elements preceding index. If the buffer *is* full, then index is pointing at
            // the oldest entry, which is the first one to return.
            //
            // If the buffer isn't full, which means that the enumeration has fewer than 'count' elements, we'll fix up
            // 'index' to point at the first entry to return. That's easy to do; if the buffer isn't full, then the oldest
            // entry is the first one. :-)
            //
            // We'll also set 'count' to the number of elements to be returned. It only needs adjustment if we've wrapped
            // past the end of the buffer and have enumerated more than the original count value.

            if (count < buffer.Length)
                index = 0;
            else
                count = buffer.Length;

            // Return the values in the correct order.
            while (count > 0)
            
                yield return buffer[index];

                index = (index + 1) % buffer.Length;
                count--;
            
        
    

    public static IEnumerable<T> SkipLast<T>(this IEnumerable<T> input, int count)
    
        if (count <= 0)
            return input;
        else
            return input.SkipLastIter(count);
    

    private static IEnumerable<T> SkipLastIter<T>(this IEnumerable<T> input, int count)
    
        var inputList = input as IList<T>;

        if (inputList != null)
        
            int first = 0;
            int last = inputList.Count - count;

            if (last < 0)
                last = 0;

            for (int i = first; i < last; i++)
                yield return inputList[i];
        
        else
        
            // Aim to leave 'count' items in the queue. If the input has fewer than 'count'
            // items, then the queue won't ever fill and we return nothing.

            Queue<T> elements = new Queue<T>();

            foreach (T item in input)
            
                elements.Enqueue(item);

                if (elements.Count > count)
                    yield return elements.Dequeue();
            
        
    

代码有点笨重,但作为一个可重复使用的插入式组件,它应该在大多数情况下都表现得尽可能好,并且它会保持使用它的代码简洁明了。 :-)

我的TakeLast 用于非IList`1 是基于与@Mark Byers 和@MackieChan 的答案相同的环形缓冲区算法。有趣的是它们有多么相似——我完全独立地写了我的。猜猜实际上只有一种方法可以正确地执行环形缓冲区。 :-)

看看@kbrimington 的回答,可以为IQuerable&lt;T&gt; 添加一个额外的检查,以回退到与实体框架一起工作的方法——假设我目前没有。

【讨论】:

【参考方案11】:

我知道现在回答这个问题为时已晚。但是,如果您正在使用 IList 类型的集合并且您不关心返回集合的顺序,那么此方法的工作速度更快。我使用了Mark Byers answer 并做了一些改动。所以现在方法 TakeLast 是:

public static IEnumerable<T> TakeLast<T>(IList<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; 

    if (source.Count > takeCount)
    
        for (int z = source.Count - 1; takeCount > 0; z--)
        
            takeCount--;
            yield return source[z];
        
    
    else
    
        for(int i = 0; i < source.Count; i++)
        
            yield return source[i];
        
    

对于测试,我使用了 Mark Byers 方法和 kbrimington's andswer。这是测试:

IList<int> test = new List<int>();
for(int i = 0; i<1000000; i++)

    test.Add(i);


Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();

IList<int> result = TakeLast(test, 10).ToList();

stopwatch.Stop();

Stopwatch stopwatch1 = new Stopwatch();
stopwatch1.Start();

IList<int> result1 = TakeLast2(test, 10).ToList();

stopwatch1.Stop();

Stopwatch stopwatch2 = new Stopwatch();
stopwatch2.Start();

IList<int> result2 = test.Skip(Math.Max(0, test.Count - 10)).Take(10).ToList();

stopwatch2.Stop();

这里是取 10 个元素的结果:

获取 1000001 个元素的结果是:

【讨论】:

【参考方案12】:

使用 LINQ 获取集合的最后一个 N 有点低效,因为上述所有解决方案都需要遍历集合。 TakeLast(int n)中的System.Interactive也有这个问题。

如果你有一个列表,更有效的做法是使用以下方法对其进行切片

/// Select from start to end exclusive of end using the same semantics
/// as python slice.
/// <param name="list"> the list to slice</param>
/// <param name="start">The starting index</param>
/// <param name="end">The ending index. The result does not include this index</param>
public static List<T> Slice<T>
(this IReadOnlyList<T> list, int start, int? end = null)

    if (end == null)
    
        end = list.Count();
    
     if (start < 0)
    
        start = list.Count + start;
    
     if (start >= 0 && end.Value > 0 && end.Value > start)
    
        return list.GetRange(start, end.Value - start);
    
     if (end < 0)
    
        return list.GetRange(start, (list.Count() + end.Value) - start);
    
     if (end == start)
    
        return new List<T>();
    
     throw new IndexOutOfRangeException(
        "count = " + list.Count() + 
        " start = " + start +
        " end = " + end);

public static List<T> GetRange<T>( this IReadOnlyList<T> list, int index, int count )

    List<T> r = new List<T>(count);
    for ( int i = 0; i < count; i++ )
    
        int j=i + index;
        if ( j >= list.Count )
        
            break;
        
        r.Add(list[j]);
    
    return r;

还有一些测试用例

[Fact]
public void GetRange()

    IReadOnlyList<int> l = new List<int>()  0, 10, 20, 30, 40, 50, 60 ;
     l
        .GetRange(2, 3)
        .ShouldAllBeEquivalentTo(new[]  20, 30, 40 );
     l
        .GetRange(5, 10)
        .ShouldAllBeEquivalentTo(new[]  50, 60 );


 [Fact]
void SliceMethodShouldWork()

    var list = new List<int>()  1, 3, 5, 7, 9, 11 ;
    list.Slice(1, 4).ShouldBeEquivalentTo(new[]  3, 5, 7 );
    list.Slice(1, -2).ShouldBeEquivalentTo(new[]  3, 5, 7 );
    list.Slice(1, null).ShouldBeEquivalentTo(new[]  3, 5, 7, 9, 11 );
    list.Slice(-2)
        .Should()
        .BeEquivalentTo(new[] 9, 11);
     list.Slice(-2,-1 )
        .Should()
        .BeEquivalentTo(new[] 9);

【讨论】:

【参考方案13】:

如果可以选择使用第三方库,MoreLinq 定义的 TakeLast() 正是这样做的。

【讨论】:

【参考方案14】:

下面是如何从集合(数组)中获取最后 3 个元素的真实示例:

// split address by spaces into array
string[] adrParts = adr.Split(new string[]  " " ,StringSplitOptions.RemoveEmptyEntries);
// take only 3 last items in array
adrParts = adrParts.SkipWhile((value, index) =>  return adrParts.Length - index > 3; ).ToArray();

【讨论】:

【参考方案15】:

如果您正在处理带有键的集合(例如数据库中的条目),那么快速(即比所选答案更快)的解决方案将是

collection.OrderByDescending(c => c.Key).Take(3).OrderBy(c => c.Key);

【讨论】:

+1 对我有用,而且很容易阅读,我的列表中有少量对象【参考方案16】:

这是一种适用于任何可枚举但仅使用 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 个元素。

【讨论】:

+1:这应该比我的性能更好,但是当集合包含的元素少于n 时,您应该确保它做正确的事情。 嗯,大多数时候我认为人们在从 SO 复制代码以供生产使用以自己添加这些东西时会小心,这可能不是问题。如果要添加它,请考虑检查集合变量是否为空。否则,很好的解决方案:)我正在考虑自己使用环形缓冲区,因为链表会增加 GC 压力,但我已经有一段时间没有这样做了,我不想用测试代码来弄清楚如果我做对了。我必须说我爱上了 LINQPad :) linqpad.net 一种可能的优化是检查可枚举是否实现了 IList,如果是,则使用简单的解决方案。只有真正的“流式”IEnumerables 才需要临时存储方法 琐碎的挑剔:您对 ArgumentOutOfRangeException 的参数顺序错误(R# 说)【参考方案17】:

我很惊讶没有人提到它,但 SkipWhile 确实有一个方法 uses the element's index。

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

【讨论】:

【参考方案18】:

在 RX 的 System.Interactive 程序集中使用 EnumerableEx.TakeLast。这是一个类似于@Mark 的 O(N) 实现,但它使用队列而不是环形缓冲区构造(并在达到缓冲区容量时将项目出列)。

(注意:这是 IEnumerable 版本 - 不是 IObservable 版本,尽管两者的实现几乎相同)

【讨论】:

这是最好的答案。如果有合适的库可以完成这项工作并且 RX 团队的质量很高,请不要自行开发。 如果您要使用这个,请从 Nuget 安装它 - nuget.org/packages/Ix-Async C# Queue&lt;T&gt; 不是使用circular buffer 实现的吗? @tigrou 。不,它不是圆形的 Then documentation must be lying【参考方案19】:

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

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

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

【讨论】:

如果您引用 RX 的 System.Interactive 而不是 System.Reactive,则不需要 AsObservable()(请参阅我的回答)

以上是关于使用 Linq 获取集合的最后 N 个元素?的主要内容,如果未能解决你的问题,请参考以下文章

如何从 MongoDB 集合中获取具有匹配键的最后 N 个元素

如何从 MongoDB 集合中获取具有匹配键的最后 N 个元素

在c#中使用Linq匹配2个集合之间的元素

LINQ查询操作符

使用 LINQ 拆分数组

LINQ查询操作符之FirstFirstOrDefaultLastLastOrDefaultElementAtElementAtOrDefaultContainsAnyAllCoun