LINQ查询 - 数据聚合(分组相邻)

22

我们来看一下名为Cls的类:

public class Cls
{
    public int SequenceNumber { get; set; }
    public int Value { get; set; }
}

现在,让我们用以下元素填充一些集合:

序列
数字      值
========    =====
1           9
2           9
3           15
4           15
5           15
6           30
7           9

我需要做的是枚举序列号并检查下一个元素是否具有相同的值。如果是,值将被聚合,因此期望输出如下所示:

序列号       序列号
从            至              值
========    ========    =====
1           2           9
3           5           15
6           6           30
7           7           9

如何使用LINQ查询执行此操作?


1
我认为你需要在这里使用一个标准的for-each循环,问题很有趣,而且表述得很好 +1。 - RobJohnson
4
非常有趣的问题,但我有点怀疑 LINQ 版本会比 foreach 循环版本更易读。我希望这里的答案可以证明我是错的。 - TtT23
你可以按值分组,然后在分组的集合中搜索连续的序列,然后按它们进行拆分并按“from”排序,但我认为在这种特定情况下,命令式版本的可读性不会降低太多。 - Honza Brestan
1
参见:https://dev59.com/6FrUa4cB1Zd3GeqPiUUB - mbeckish
请参考CodeGolf上的同一问题:http://codegolf.stackexchange.com/questions/10797/group-adjacent-values - Dariusz Woźniak
8个回答

23

你可以使用Linq的GroupBy的修改版本,它只对相邻的两个项进行分组,然后很容易实现:

var result = classes
    .GroupAdjacent(c => c.Value)
    .Select(g => new { 
        SequenceNumFrom = g.Min(c => c.SequenceNumber),
        SequenceNumTo = g.Max(c => c.SequenceNumber),  
        Value = g.Key
    });

foreach (var x in result)
    Console.WriteLine("SequenceNumFrom:{0} SequenceNumTo:{1} Value:{2}", x.SequenceNumFrom, x.SequenceNumTo, x.Value);

演示

结果:

SequenceNumFrom:1  SequenceNumTo:2  Value:9
SequenceNumFrom:3  SequenceNumTo:5  Value:15
SequenceNumFrom:6  SequenceNumTo:6  Value:30
SequenceNumFrom:7  SequenceNumTo:7  Value:9

这是用于将相邻项分组的扩展方法:

public static IEnumerable<IGrouping<TKey, TSource>> GroupAdjacent<TSource, TKey>(
        this IEnumerable<TSource> source,
        Func<TSource, TKey> keySelector)
    {
        TKey last = default(TKey);
        bool haveLast = false;
        List<TSource> list = new List<TSource>();
        foreach (TSource s in source)
        {
            TKey k = keySelector(s);
            if (haveLast)
            {
                if (!k.Equals(last))
                {
                    yield return new GroupOfAdjacent<TSource, TKey>(list, last);
                    list = new List<TSource>();
                    list.Add(s);
                    last = k;
                }
                else
                {
                    list.Add(s);
                    last = k;
                }
            }
            else
            {
                list.Add(s);
                last = k;
                haveLast = true;
            }
        }
        if (haveLast)
            yield return new GroupOfAdjacent<TSource, TKey>(list, last);
    }
}

并且使用的类:

public class GroupOfAdjacent<TSource, TKey> : IEnumerable<TSource>, IGrouping<TKey, TSource>
{
    public TKey Key { get; set; }
    private List<TSource> GroupList { get; set; }
    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return ((System.Collections.Generic.IEnumerable<TSource>)this).GetEnumerator();
    }
    System.Collections.Generic.IEnumerator<TSource> System.Collections.Generic.IEnumerable<TSource>.GetEnumerator()
    {
        foreach (var s in GroupList)
            yield return s;
    }
    public GroupOfAdjacent(List<TSource> source, TKey key)
    {
        GroupList = source;
        Key = key;
    }
}

2
+1 很棒的答案,不过那是很多代码,我想我会只使用常规的 for-each 循环并以此方式构建新集合。 - RobJohnson
5
很多代码?它是一个完全可重用的、通用的解决方案。考虑到这一点,代码并不算多。很棒的回答,也是工具箱里的新工具。+1 - Pete
此代码的原始来源似乎是 https://blogs.msdn.microsoft.com/ericwhite/2008/04/20/the-groupadjacent-extension-method/。 - Quails4Eva
@Quails4Eva:我必须承认,我真的不知道原始来源在哪里,我相当肯定那不是我创建的,但我也不认识那个博客。作者说:“这种方法是一两年前由 LINQ 架构师之一建议的”,所以他也不是真正的作者。 - Tim Schmelter
1
@Tim Schmelter,这是一个公正的观点。我认为他们建议了这种方法,然后他编写了代码,或者至少LINQ架构师的版本不是公开的。无论如何,我并不太担心,只是觉得在5年之间找到完全相同的解决方案很奇怪,所以我添加了一个链接到早期版本。 - Quails4Eva

3
您可以使用这个Linq查询。
示例: 演示
var values = (new[] { 9, 9, 15, 15, 15, 30, 9 }).Select((x, i) => new { x, i });

var query = from v in values
            let firstNonValue = values.Where(v2 => v2.i >= v.i && v2.x != v.x).FirstOrDefault()
            let grouping = firstNonValue == null ? int.MaxValue : firstNonValue.i
            group v by grouping into v
            select new
            {
              From = v.Min(y => y.i) + 1,
              To = v.Max(y => y.i) + 1,
              Value = v.Min(y => y.x)
            };

3

MoreLinq可以直接提供这个功能

它被称为GroupAdjacent,并作为IEnumerable的扩展方法实现:

根据指定的键选择器函数,对序列的相邻元素进行分组。

enumerable.GroupAdjacent(e => e.Key)

如果你不想引入额外的二进制Nuget包,甚至有一个Nuget "源代码"包仅包含该方法。

该方法返回一个IEnumerable<IGrouping<TKey, TValue>>,因此其输出可以像GroupBy的输出一样进行处理。


1
我认为这应该标记为正确答案。我个人更喜欢添加NuGet包进行复制/粘贴。此外,了解我一直缺少的MoreLinq中还有什么是值得的。 - Mike S.

2
你可以像这样操作:
var all = new [] {
    new Cls(1, 9)
,   new Cls(2, 9)
,   new Cls(3, 15)
,   new Cls(4, 15)
,   new Cls(5, 15)
,   new Cls(6, 30)
,   new Cls(7, 9)
};
var f = all.First();
var res = all.Skip(1).Aggregate(
    new List<Run> {new Run {From = f.SequenceNumber, To = f.SequenceNumber, Value = f.Value} }
,   (p, v) => {
    if (v.Value == p.Last().Value) {
        p.Last().To = v.SequenceNumber;
    } else {
        p.Add(new Run {From = v.SequenceNumber, To = v.SequenceNumber, Value = v.Value});
    }
    return p;
});
foreach (var r in res) {
    Console.WriteLine("{0} - {1} : {2}", r.From, r.To, r.Value);
}

这个想法是创造性地使用Aggregate:从一个由单个Run组成的列表开始,在每个聚合阶段(lambda中的if语句)检查到目前为止我们已经获得的列表内容。根据最后一个值,要么继续旧的运行,要么开始一个新的运行。

这里有一个ideone上的演示


1
在我看来,当 lambda 表达式中有大量代码时,最好使用 foreach 循环。 - juharr
@juharr,问题不仅在于代码量的多少,而在于它引起了副作用并依赖于这些副作用。当任何LINQ调用的重要部分引起副作用时,通常意味着该部分应该放在foreach中。 - Servy
@Servy 我同意 - 我不会使用LINQ来进行运行检测,原因就在于副作用。我认为这是对LINQ难题的一种治愈答案,因为OP明确要求使用LINQ。 - Sergey Kalinichenko

2

我通过创建一个自定义扩展方法来完成它。

static class Extensions {
  internal static IEnumerable<Tuple<int, int, int>> GroupAdj(this IEnumerable<Cls> enumerable) {
    Cls start = null;
    Cls end = null;
    int value = Int32.MinValue;

    foreach (Cls cls in enumerable) {
      if (start == null) {
        start = cls;
        end = cls;
        continue;
      }

      if (start.Value == cls.Value) {
        end = cls;
        continue;
      }

      yield return Tuple.Create(start.SequenceNumber, end.SequenceNumber, start.Value);
      start = cls;
      end = cls;
    }

    yield return Tuple.Create(start.SequenceNumber, end.SequenceNumber, start.Value);
  }
}

以下是实现方式:

static void Main() {
  List<Cls> items = new List<Cls> {
    new Cls { SequenceNumber = 1, Value = 9 },
    new Cls { SequenceNumber = 2, Value = 9 },
    new Cls { SequenceNumber = 3, Value = 15 },
    new Cls { SequenceNumber = 4, Value = 15 },
    new Cls { SequenceNumber = 5, Value = 15 },
    new Cls { SequenceNumber = 6, Value = 30 },
    new Cls { SequenceNumber = 7, Value = 9 }
  };

  Console.WriteLine("From  To    Value");
  Console.WriteLine("===== ===== =====");
  foreach (var item in items.OrderBy(i => i.SequenceNumber).GroupAdj()) {
    Console.WriteLine("{0,-5} {1,-5} {2,-5}", item.Item1, item.Item2, item.Item3);
  }
}

预期输出:

From  To    Value
===== ===== =====
1     2     9
3     5     15
6     6     30
7     7     9

2
这是一种没有任何辅助方法的实现方式:
var grp = 0;
var results =
from i
in
input.Zip(
    input.Skip(1).Concat(new [] {input.Last ()}),
    (n1, n2) => Tuple.Create(
        n1, (n2.Value == n1.Value) ? grp : grp++
    )
)
group i by i.Item2 into gp
select new {SequenceNumFrom = gp.Min(x => x.Item1.SequenceNumber),SequenceNumTo = gp.Max(x => x.Item1.SequenceNumber), Value = gp.Min(x => x.Item1.Value)};

思路如下:

  • 跟踪你自己的分组指示器 grp。
  • 将集合中的每个项连接到集合中的下一个项(通过 Skip(1) 和 Zip)。
  • 如果值匹配,则它们属于同一组;否则,增加 grp 以表示下一组的开始。

1

以下是未经测试的黑魔法。在这种情况下,命令式版本似乎更容易。

IEnumerable<Cls> data = ...;
var query = data
    .GroupBy(x => x.Value)
    .Select(g => new
    {
        Value = g.Key,
        Sequences = g
            .OrderBy(x => x.SequenceNumber)
            .Select((x,i) => new
            {
                x.SequenceNumber,
                OffsetSequenceNumber = x.SequenceNumber - i
            })
            .GroupBy(x => x.OffsetSequenceNumber)
            .Select(g => g
                .Select(x => x.SequenceNumber)
                .OrderBy(x => x)
                .ToList())
            .ToList()
    })
    .SelectMany(x => x.Sequences
        .Select(s => new { First = s.First(), Last = s.Last(), x.Value }))
    .OrderBy(x => x.First)
    .ToList();

0

让我提出另一个选项,它可以懒惰地生成组序列和组内元素。

.NET Fiddle演示

实现:

public static class EnumerableExtensions
{
    public static IEnumerable<IGrouping<TKey, TSource>> GroupAdjacent<TSource, TKey>(
        this IEnumerable<TSource> source,
        Func<TSource, TKey> keySelector,
        IEqualityComparer<TKey>? comparer = null)
    {
        var comparerOrDefault = comparer ?? EqualityComparer<TKey>.Default;
        using var iterator = new Iterator<TSource>(source.GetEnumerator());
        iterator.MoveNext();
        while (iterator.HasCurrent)
        {
            var key = keySelector(iterator.Current);
            var elements = YieldAdjacentElements(iterator, key, keySelector, comparerOrDefault);
            yield return new Grouping<TKey, TSource>(key, elements);
            while (iterator.HasCurrentWithKey(key, keySelector, comparerOrDefault))
            {
                iterator.MoveNext();
            }
        }
    }

    static IEnumerable<TSource> YieldAdjacentElements<TKey, TSource>(
        Iterator<TSource> iterator,
        TKey key,
        Func<TSource, TKey> keySelector,
        IEqualityComparer<TKey> comparer)
    {
        while (iterator.HasCurrentWithKey(key, keySelector, comparer))
        {
            yield return iterator.Current;
            iterator.MoveNext();
        }
    }

    private static bool HasCurrentWithKey<TKey, TSource>(
        this Iterator<TSource> iterator,
        TKey key,
        Func<TSource, TKey> keySelector,
        IEqualityComparer<TKey> comparer) =>
        iterator.HasCurrent && comparer.Equals(keySelector(iterator.Current), key);

    private sealed class Grouping<TKey, TElement> : IGrouping<TKey, TElement>
    {
        public Grouping(TKey key, IEnumerable<TElement> elements)
        {
            Key = key;
            Elements = elements;
        }

        public TKey Key { get; }

        public IEnumerable<TElement> Elements { get; }

        public IEnumerator<TElement> GetEnumerator() => Elements.GetEnumerator();

        IEnumerator IEnumerable.GetEnumerator() => Elements.GetEnumerator();
    }

    private sealed class Iterator<T> : IDisposable
    {
        private readonly IEnumerator<T> _enumerator;

        public Iterator(IEnumerator<T> enumerator)
        {
            _enumerator = enumerator;
        }

        public bool HasCurrent { get; private set; }

        public T Current => _enumerator.Current;

        public void MoveNext()
        {
            HasCurrent = _enumerator.MoveNext();
        }

        public void Dispose()
        {
            _enumerator.Dispose();
        }
    }
}

请注意,使用常规的GroupBy操作是不可能达到这种程度的懒惰的,因为它需要在产生第一组之前遍历整个集合。
特别是,在我的情况下,将GroupBy迁移到GroupAdjacent,并与整个管道的懒处理相结合,有助于解决大型序列的内存消耗问题。
总的来说,如果输入集合满足键已排序(或至少不是碎片化)的条件,并且管道中的所有操作都是懒惰的,则可以将GroupAdjacent用作GroupBy的懒惰和更高效的替代方法。

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