C# - 分割列表的优雅方法?

49

我希望将一个列表按照指定的元素数量划分成多个子列表。

例如,假设我有列表 {1, 2, ... 11},希望将其划分为每个子列表包含4个元素,并且最后一个子列表包含尽可能多的元素。则得到的子列表应该是 {{1..4}, {5..8}, {9..11}}。

有什么优雅的方法可以实现这个功能呢?


1
我相信会有人发布一个漂亮的Linq语句。 - Preet Sangha
@Preet - 我根据你的要求发布了一个 Linq 答案 ;) - Scott Ivey
自.NET Framework 4.0版本开始,就存在一个名为Partitioner的类。相关SO问题 - Sreenikethan I
11个回答

65

这里是一个扩展方法,可以实现你想要的功能:

public static IEnumerable<List<T>> Partition<T>(this IList<T> source, Int32 size)
{
    for (int i = 0; i < (source.Count / size) + (source.Count % size > 0 ? 1 : 0); i++)
        yield return new List<T>(source.Skip(size * i).Take(size));
}

编辑: 这是函数的更加简洁版本:

public static IEnumerable<List<T>> Partition<T>(this IList<T> source, Int32 size)
{
    for (int i = 0; i < Math.Ceiling(source.Count / (Double)size); i++)
        yield return new List<T>(source.Skip(size * i).Take(size));
}

3
for (int i = 0; i < source.Count; i += size) { /* ... */ } - Roger Lipscombe
1
这种方法的不幸影响是给定的数组无法通过索引访问。这里有一个返回List的方法http://www.vcskicks.com/partition-list.php - George
8
请注意,在实际的 LINQ 实现中,SkipTake 只是在给定序列上进行循环,如果源代码实现了 IList 接口并且可以通过索引访问,则不会进行检查/优化。因此它们的时间复杂度为 O(m)(其中 m 是要跳过或取出的元素数量)。因此,这个 Partition() 扩展方法可能无法提供预期的性能。 - tigrou
@George:(至少现在)你可以在可枚举对象上调用.ToList()方法来获取一个可索引的列表。 - mklement0

36

使用LINQ,您可以在一行代码中将您的组划分如下...

var x = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 };

var groups = x.Select((i, index) => new
{
    i,
    index
}).GroupBy(group => group.index / 4, element => element.i);

你可以像下面这样遍历这些组...

foreach (var group in groups)
{
    Console.WriteLine("Group: {0}", group.Key);

    foreach (var item in group)
    {
        Console.WriteLine("\tValue: {0}", item);
    }
}

然后你将会获得类似这样的输出...

Group: 0
        Value: 1
        Value: 2
        Value: 3
        Value: 4
Group: 1
        Value: 5
        Value: 6
        Value: 7
        Value: 8
Group: 2
        Value: 9
        Value: 10
        Value: 11

2
虽然不完全符合问题要求,但是因为有一点不同的思考方式而加1分。 - RichardOD
RichardOD - 你是对的 - 我更新了示例,使输出成为一组整数而不是一组匿名类型。 - Scott Ivey
我觉得你刚刚让我大开眼界。我真的很好奇你是在哪里学到这样的语法的(我非常喜欢它)。我看过的所有LINQ文档都很好,但它们并没有很好地涵盖分组。 - Dan Esparza
大量的尝试和阅读SO问题。LINQ绝对是我在3.5中最喜欢的新功能之一 - 我通过在这里闲逛学到了很多关于它的知识。这个GroupBy的重载是我以前没有使用过的 - 所以这也是新的东西 :) - Scott Ivey
1
@ScottIvey 的分组逻辑非常好,非常适合我需要根据内部 List<>.Count() 将出站 UDP 命令拆分成多个数据包的逻辑。真不错!感谢分享。 - user514005

11
类似以下代码(未经测试):

某些代码(未经测试):

IEnumerable<IList<T>> PartitionList<T>(IList<T> list, int maxCount)
{
    List<T> partialList = new List<T>(maxCount);
    foreach(T item in list)
    {
        if (partialList.Count == maxCount)
        {
           yield return partialList;
           partialList = new List<T>(maxCount);
        }
        partialList.Add(item);
    }
    if (partialList.Count > 0) yield return partialList;
}

这将返回一个列表的枚举,而不是一个嵌套的列表,但你可以轻松地将结果封装在一个列表中:
IList<IList<T>> listOfLists = new List<T>(PartitionList<T>(list, maxCount));

我喜欢这个解决方案,但如果将大量数字传递给maxCount可能会导致问题(例如:PartitionList(list, enablePartition ? 500 : int.MaxValue))。一个可能的改进是仅在源实现ICollection时设置列表容量,并将maxCount夹紧到集合内元素的数量。 - tigrou
@tigrou - 我不确定我会保护调用者免受传递过大数字的后果,但为了能够处理任意大的分区,您可能会使用枚举而不是列表 - 例如一个方法 IEnumerable<IEnumerable<T>> PartitionEnumeration<T> (IEnumerable<T> enumeration, int maxCount),可以轻松实现而不需要分配列表。 - Joe
如果你返回 IEnumerable<IEnumerable<T>> 并依赖于一个从不分配任何东西的实现(例如:它只从源中产生元素),那么如果结果不是按顺序枚举的(例如:在枚举分区 2 之前枚举分区 4 或某些分区仅被部分枚举),你将会遇到麻烦。我认为使用列表更安全。 - tigrou

10
为了避免分组,需要避免数学和重复计算。
这种方法可以避免不必要的计算、比较和分配。同时还包括参数验证。
这里提供一个在fiddle上的演示,请点击此处查看。
public static IEnumerable<IList<T>> Partition<T>(
    this IEnumerable<T> source,
    int size)
{
    if (size < 2)
    {
        throw new ArgumentOutOfRangeException(
            nameof(size),
            size,
            "Must be greater or equal to 2.");  
    }

    T[] partition;
    int count;

    using (var e = source.GetEnumerator())
    {
        if (e.MoveNext())
        {
            partition = new T[size];
            partition[0] = e.Current;
            count = 1;
        }
        else
        {
            yield break;    
        }

        while(e.MoveNext())
        {
            partition[count] = e.Current;
            count++;

            if (count == size)
            {
                yield return partition;
                count = 0;
                partition = new T[size];
            }
        }
    }

    if (count > 0)
    {
        Array.Resize(ref partition, count);
        yield return partition;
    }
}

你的解决方案是所有可能方案中最优雅且资源消耗最少的,我不知道为什么它没有更多的赞。 - Paleta
我喜欢这个,但是为什么要在 1 处使用 ArgumentOutOfRangeException?你可以把它改成 size < 1,然后在 if (e.MoveNext() 块中分配给 partition[0] 后添加 if (size == 1) yield return partition; else count = 1; - Brett Caswell
1
我确实考虑过,但是如果你想要小于2的分区,调用函数就会非常浪费资源:只需枚举列表即可。但是,我承认这会使函数变得脆弱,或者说更具有信息性。 - Jodrell
感谢您分享您的想法,我认为您已经仔细考虑过,并得出了更好的实现方案 - 而且决定权应该由调用函数(责任范围)来确定。 - Brett Caswell

1
public static IEnumerable<IEnumerable<T>> Partition<T>(this IEnumerable<T> list, int size)
{
    while (list.Any()) { yield return list.Take(size); list = list.Skip(size); }
}

对于字符串的特殊情况

public static IEnumerable<string> Partition(this string str, int size)
{
    return str.Partition<char>(size).Select(AsString);
}

public static string AsString(this IEnumerable<char> charList)
{
    return new string(charList.ToArray());
}

1
var yourList = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 };
var groupSize = 4;

// here's the actual query that does the grouping...
var query = yourList
    .Select((x, i) => new { x, i })
    .GroupBy(i => i.i / groupSize, x => x.x);

// and here's a quick test to ensure that it worked properly...
foreach (var group in query)
{
    foreach (var item in group)
    {
        Console.Write(item + ",");
    }
    Console.WriteLine();
}

如果你需要一个实际的 List<List<T>> 而不是一个 IEnumerable<IEnumerable<T>>,那么请按以下方式更改查询:
var query = yourList
    .Select((x, i) => new { x, i })
    .GroupBy(i => i.i / groupSize, x => x.x)
    .Select(g => g.ToList())
    .ToList();

1

或者在 .Net 2.0 中,你可以这样做:

    static void Main(string[] args)
    {
        int[] values = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 };
        List<int[]> items = new List<int[]>(SplitArray(values, 4));
    }

    static IEnumerable<T[]> SplitArray<T>(T[] items, int size)
    {
        for (int index = 0; index < items.Length; index += size)
        {
            int remains = Math.Min(size, items.Length-index);
            T[] segment = new T[remains];
            Array.Copy(items, index, segment, 0, remains);
            yield return segment;
        }
    }

1
使用ArraySegments可能是一个可读性高、简洁的解决方案(需要将您的列表转换为数组进行强制转换):
var list = new List<int>() { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; //Added 0 in front on purpose in order to enhance simplicity.
int[] array = list.ToArray();
int step = 4;
List<int[]> listSegments = new List<int[]>();

for(int i = 0; i < array.Length; i+=step)
{
     int[] segment = new ArraySegment<int>(array, i, step).ToArray();
     listSegments.Add(segment);
}

1
我不确定为什么Jochem的答案使用ArraySegment被投票否决。只要您不需要扩展段(转换为IList),它就可能非常有用。例如,想象一下,您正在尝试将段传递到TPL DataFlow管道以进行并发处理。将段作为IList实例传递允许相同的代码对数组和列表进行不可知论处理。
当然,这引出了一个问题:为什么不派生一个不需要浪费内存调用ToArray()的ListSegment类呢?答案是,在某些情况下,数组实际上可以稍微更快地处理(索引稍微更快)。但是,您必须做一些相当强大的处理才能注意到很大的差异。更重要的是,没有好的方法来保护其他代码持有列表引用的随机插入和删除操作。
在我的工作站上,对包含一百万个值的数字列表调用ToArray()大约需要3毫秒。当您在使用它以获得更强大的线程安全性的好处时,而不会产生锁定的沉重代价时,这通常不是太大的代价。

0
您可以使用扩展方法:
public static IList<HashSet<T>> Partition<T>(this IEnumerable<T> input, Func<T, object> partitionFunc)
{
      Dictionary<object, HashSet> partitions = new Dictionary<object, HashSet<T>>();

  object currentKey = null;
  foreach (T item in input ?? Enumerable.Empty<T>())
  {
      currentKey = partitionFunc(item);

      if (!partitions.ContainsKey(currentKey))
      {
          partitions[currentKey] = new HashSet<T>();
      }

      partitions[currentKey].Add(item);
  }

  return partitions.Values.ToList();

}


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