使用LINQ如何获取序列中除最后一个元素以外的所有元素?

172

假设我有一个序列。

IEnumerable<int> sequence = GetSequenceFromExpensiveSource();
// sequence now contains: 0,1,2,3,...,999999,1000000

获取这个序列是不廉价的,而且是动态生成的,我只想遍历一次。

我想要获取0-999999(即除最后一个元素外的所有元素)。

我知道我可以像这样做:

sequence.Take(sequence.Count() - 1);

但这会导致对大序列进行两次枚举。

是否有 LINQ 构造可让我执行:

sequence.TakeAllButTheLastElement();

3
你必须在O(2n)时间复杂度和O(count)空间复杂度算法之间做出选择,后者还需要在内部数组中移动项目。 - Dykam
1
Dario,请你为那些不太了解大O符号的人解释一下,好吗? - alexn
请参考此重复问题:https://dev59.com/U2855IYBdhLWcg3wvnKE - stakx - no longer contributing
1
最终我通过将集合转换为List并调用sequenceList.RemoveAt(sequence.Count - 1);来进行缓存。在我的情况下,这是可以接受的,因为在所有LINQ操作之后,我必须将其转换为数组或IReadOnlyCollection。我想知道你的使用情况,在那里你甚至不考虑缓存?正如我所看到的,即使是批准的答案也会进行一些缓存,因此在我看来,简单地转换为List更容易和更短。 - Pavels Ahmadulins
22个回答

99

Enumerable.SkipLast(IEnumerable<TSource>, Int32)方法在.NET Standard 2.1中添加。它正是你想要的。

IEnumerable<int> sequence = GetSequenceFromExpensiveSource();

var allExceptLast = sequence.SkipLast(1);

https://learn.microsoft.com/zh-cn/dotnet/api/system.linq.enumerable.skiplast

返回一个新的可枚举集合,其中包含源中省略最后 count 个元素的元素。


3
这个也存在于MoreLinq中。 - Leperkawn
2
+1 for SkipLast。我最近从.NET Framework转来,不知道这个功能,他们没有在那里添加它。 - PRMan
@Leperkawn 感谢您指出这一点,因为 SkipLast 方法在 4.7.2 中不存在。 - dpberry178
我本能地输入了那个方法名,但没有找到建议,然后来到这里发现我在错误的.Net版本中输入了正确的代码。 - The Lemon

68

我不知道Linq的解决方案 - 但是你可以使用生成器(yield return)轻松编写该算法。

public static IEnumerable<T> TakeAllButLast<T>(this IEnumerable<T> source) {
    var it = source.GetEnumerator();
    bool hasRemainingItems = false;
    bool isFirst = true;
    T item = default(T);

    do {
        hasRemainingItems = it.MoveNext();
        if (hasRemainingItems) {
            if (!isFirst) yield return item;
            item = it.Current;
            isFirst = false;
        }
    } while (hasRemainingItems);
}

static void Main(string[] args) {
    var Seq = Enumerable.Range(1, 10);

    Console.WriteLine(string.Join(", ", Seq.Select(x => x.ToString()).ToArray()));
    Console.WriteLine(string.Join(", ", Seq.TakeAllButLast().Select(x => x.ToString()).ToArray()));
}

或者作为一种通用解决方案,丢弃最后n个项目(使用评论中建议的队列):
public static IEnumerable<T> SkipLastN<T>(this IEnumerable<T> source, int n) {
    var  it = source.GetEnumerator();
    bool hasRemainingItems = false;
    var  cache = new Queue<T>(n + 1);

    do {
        if (hasRemainingItems = it.MoveNext()) {
            cache.Enqueue(it.Current);
            if (cache.Count > n)
                yield return cache.Dequeue();
        }
    } while (hasRemainingItems);
}

static void Main(string[] args) {
    var Seq = Enumerable.Range(1, 4);

    Console.WriteLine(string.Join(", ", Seq.Select(x => x.ToString()).ToArray()));
    Console.WriteLine(string.Join(", ", Seq.SkipLastN(3).Select(x => x.ToString()).ToArray()));
}

7
现在你能否将其推广以涵盖除最后n个元素之外的所有元素? - Eric Lippert
4
好的。有一个小错误;队列大小应该被初始化为 n+1,因为这是队列的最大容量。 - Eric Lippert
ReSharper不理解你的代码(在条件表达式中使用赋值语句),但我有点喜欢它+1。 - Ruslan

50

如果您不想创建自己的方法,且元素的顺序不重要,可以使用以下方法:

var result = sequence.Reverse().Skip(1);

61
请注意,这需要足够的内存来存储整个序列,并且当然仍然需要迭代整个序列两次,一次用于构建反转序列,另一次用于迭代它。不管怎样,这基本上都比计数解决方案更差;它既慢又需要更多内存。 - Eric Lippert
我不知道'Reverse'方法的工作原理,所以我不确定它使用了多少内存。但是我同意需要两次迭代。如果集合很大或性能很重要,则不应使用此方法。 - Kamarey
6
你如何实现“反转”?你能否想出一种在不存储整个序列的情况下普遍地实现它的方法? - Eric Lippert
没有比O(n)空间更好的选择。肯定最好使用自定义方法的解决方案来实现这个目的。 - Kamarey
2
我喜欢它,而且它符合不重复生成序列的条件。 - Amy B
14
另外,您还需要将整个序列再次反转以使其保持原样。 equence.Reverse().Skip(1).Reverse() 不是一个好的解决方案。 - shashwat

42
因为我不太喜欢显式地使用 Enumerator,所以这里提供一种替代方案。请注意,需要包装器方法来让无效参数尽早抛出异常,而不是在实际枚举序列之前延迟检查。
public static IEnumerable<T> DropLast<T>(this IEnumerable<T> source)
{
    if (source == null)
        throw new ArgumentNullException("source");

    return InternalDropLast(source);
}

private static IEnumerable<T> InternalDropLast<T>(IEnumerable<T> source)
{
    T buffer = default(T);
    bool buffered = false;

    foreach (T x in source)
    {
        if (buffered)
            yield return buffer;

        buffer = x;
        buffered = true;
    }
}

根据Eric Lippert的建议,它可以轻松地扩展到n个项目:

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

    if (n < 0)
        throw new ArgumentOutOfRangeException("n", 
            "Argument n should be non-negative.");

    return InternalDropLast(source, n);
}

private static IEnumerable<T> InternalDropLast<T>(IEnumerable<T> source, int n)
{
    Queue<T> buffer = new Queue<T>(n + 1);

    foreach (T x in source)
    {
        buffer.Enqueue(x);

        if (buffer.Count == n + 1)
            yield return buffer.Dequeue();
    }
}

我现在在yield之前进行缓冲,而不是之后,这样就不需要对n == 0的情况进行特殊处理了。


在第一个示例中,在分配“buffer”之前在else子句中设置“buffered=false”可能会稍微快一点。条件已经被检查了,但这样可以避免每次循环都冗余地设置“buffered”。 - James
有人能告诉我这种方法与被选答案相比的优缺点吗? - Sinjai
将实现放在缺少输入检查的单独方法中有什么优点?此外,我只是放弃单个实现并为多个实现提供默认值。 - jpmc26
@jpmc26 通过将检查放在单独的方法中,实际上你会在调用DropLast时获得验证。否则,验证将仅在实际枚举序列时发生(在生成的 IEnumerator 上的第一次调用 MoveNext)。当在生成 IEnumerable 和实际枚举它之间可能存在任意数量的代码时,这不是一个有趣的调试事情。现在我会将InternalDropLast编写为DropLast的内部函数,但当我9年前编写这段代码时,这个功能在C#中并不存在。 - Joren

16

C# 8.0 可以使用 范围和索引 来实现。

var allButLast = sequence[..^1];

默认情况下,C# 8.0 需要 .NET Core 3.0 或 .NET Standard 2.1(或更高版本)。查看此主题以在旧实现中使用。


12

在BCL(或者我相信是MoreLinq)中没有这个功能,但是你可以创建自己的扩展方法。

public static IEnumerable<T> TakeAllButLast<T>(this IEnumerable<T> source)
{
    using (var enumerator = source.GetEnumerator())
        bool first = true;
        T prev;
        while(enumerator.MoveNext())
        {
            if (!first)
                yield return prev;
            first = false;
            prev = enumerator.Current;
        }
    }
}

这段代码不能运行... 可能应该是 if (!first) 并将 first = false 移出 if 语句块。 - Caleb
这段代码看起来有问题。逻辑似乎是“在第一次迭代中返回未初始化的prev,之后无限循环”。 - Phil Miller
代码似乎有“编译时”错误。也许您想更正它。 但是,我们可以编写一个扩展程序,迭代一次并返回除最后一个项之外的所有项。 - Manish Basantani
@Caleb:你说得完全正确 - 我当时确实很匆忙!现在已经修复了。 @Amby:嗯,不确定你在用什么编译器。我没有遇到这种情况。:P - Noldorin
@RobertSchmidt 抱歉,是的。我现在添加了一个 using 语句。 - Noldorin

7
希望.NET Framework能够附带这样的扩展方法会很有帮助。
public static IEnumerable<T> SkipLast<T>(this IEnumerable<T> source, int count)
{
    var enumerator = source.GetEnumerator();
    var queue = new Queue<T>(count + 1);

    while (true)
    {
        if (!enumerator.MoveNext())
            break;
        queue.Enqueue(enumerator.Current);
        if (queue.Count > count)
            yield return queue.Dequeue();
    }
}

4
如果你没有时间自己开发扩展,这里有一个更快的方法:
var next = sequence.First();
sequence.Skip(1)
    .Select(s => 
    { 
        var selected = next;
        next = s;
        return selected;
    });

3
Joren的优雅解决方案稍作扩展:
public static IEnumerable<T> Shrink<T>(this IEnumerable<T> source, int left, int right)
{
    int i = 0;
    var buffer = new Queue<T>(right + 1);

    foreach (T x in source)
    {
        if (i >= left) // Read past left many elements at the start
        {
            buffer.Enqueue(x);
            if (buffer.Count > right) // Build a buffer to drop right many elements at the end
                yield return buffer.Dequeue();    
        } 
        else i++;
    }
}
public static IEnumerable<T> WithoutLast<T>(this IEnumerable<T> source, int n = 1)
{
    return source.Shrink(0, n);
}
public static IEnumerable<T> WithoutFirst<T>(this IEnumerable<T> source, int n = 1)
{
    return source.Shrink(n, 0);
}

当缩小实现简单地向前计数以删除前面的left个元素,并使用相同的丢弃缓冲区来删除后面的right个元素。


3
如果您可以获取可枚举对象的CountLength,在大多数情况下都可以这样做,只需使用Take(n - 1)即可。 数组示例
int[] arr = new int[] { 1, 2, 3, 4, 5 };
int[] sub = arr.Take(arr.Length - 1).ToArray();

使用 IEnumerable<T> 的示例

IEnumerable<int> enu = Enumerable.Range(1, 100);
IEnumerable<int> sub = enu.Take(enu.Count() - 1);

1
问题涉及到IEnumerables,而你提供的解决方案正是OP试图避免的。你的代码会对性能产生影响。 - nawfal

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