使用LINQ获取所有可能的不同三元组

5

我有一个包含这些值的列表:{1, 2, 3, 4, 5, 6, 7}。我想要能够检索三个唯一组合。结果应该像这样:

{1,2,3}
{1,2,4}
{1,2,5}
{1,2,6}
{1,2,7}
{2,3,4}
{2,3,5}
{2,3,6}
{2,3,7}
{3,4,5}
{3,4,6}
{3,4,7}
{3,4,1}
{4,5,6}
{4,5,7}
{4,5,1}
{4,5,2}
{5,6,7}
{5,6,1}
{5,6,2}
{5,6,3}

我已经有了两个for循环可以完成这个:

for (int first = 0; first < test.Count - 2; first++)
{
  int second = first + 1;
  for (int offset = 1; offset < test.Count; offset++)
  {
    int third = (second + offset)%test.Count;
    if(Math.Abs(first - third) < 2)
      continue;
    List<int> temp = new  List<int>();
    temp .Add(test[first]);
    temp .Add(test[second]);
    temp .Add(test[third]);
    result.Add(temp );
  }
}

但是,既然我正在学习LINQ,我想知道是否有更聪明的方法来做到这一点?


“Smarter” 这个形容词不太合适,我认为。 - Keith Payne
我的意思是在性能方面更好。此外,LINQ看起来比for循环酷多了 :) - Tony Dinh
@Sajeetharan:这是另一个问题。你链接的问题是找到多个序列之间的笛卡尔积;而这个问题是找到特定序列的排列。 - Eric Lippert
@EricLippert 是的,我现在懂了! - Sajeetharan
Tony Dinh,我有点困惑你是否希望排列是有序的。我注意到{3, 4, 1}在列表中,但不包括{1, 3, 4}。这是有意为之的吗?顺序很重要吗?还是{1, 3, 4}和{3, 4, 1}一样好? - Eric Lippert
@EricLippert 顺序不重要,{1,3,4} 被视为与 {3,4,1}、{4,3,1} 等相同。 - Tony Dinh
3个回答

30

更新: 我将这个问题作为一系列文章的主题,从这里开始; 在那个系列中,我会介绍两种略有不同的算法。感谢这个好问题!


到目前为止发布的两个解决方案对于数字变大的情况来说是正确但效率低下的。 到目前为止发布的解决方案使用的算法是:首先枚举所有可能性:

{1, 1, 1 }
{1, 1, 2 }, 
{1, 1, 3 }, 
...
{7, 7, 7}   

在这个过程中,筛选出第二个数不大于第一个数,第三个数不大于第二个数的所有数字。这将执行7 x 7 x 7个筛选操作,数量并不是很多,但是如果你想从30个元素中获取10个元素的排列,那么就需要进行30 x 30 x 30 x 30 x 30 x 30 x 30 x 30 x 30 x 30个筛选操作,这是相当多的。你可以做得更好。

我会按照以下方式解决这个问题。首先,创建一个高效的不可变集合数据结构。让我非常清楚地说明一下什么是不可变集合,因为你可能不熟悉它们。通常,你认为集合是可以添加和删除元素的。但是不可变集合具有Add操作,但它不会更改集合;它会返回一个新的集合,其中包含了添加了该项的元素。移除也是同样。

这里是一个实现整数0到31的不可变集合的示例:

using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System;

// A super-cheap immutable set of integers from 0 to 31 ; 
// just a convenient wrapper around bit operations on an int.
internal struct BitSet : IEnumerable<int>
{
  public static BitSet Empty { get { return default(BitSet); } }
  private readonly int bits;
  private BitSet(int bits) { this.bits = bits; }
  public bool Contains(int item) 
  {
    Debug.Assert(0 <= item && item <= 31); 
    return (bits & (1 << item)) != 0; 
  }
  public BitSet Add(int item) 
  { 
    Debug.Assert(0 <= item && item <= 31); 
    return new BitSet(this.bits | (1 << item)); 
  }
  public BitSet Remove(int item) 
  { 
    Debug.Assert(0 <= item && item <= 31); 
    return new BitSet(this.bits & ~(1 << item)); 
  }
  IEnumerator IEnumerable.GetEnumerator() { return this.GetEnumerator(); }
  public IEnumerator<int> GetEnumerator()
  {
    for(int item = 0; item < 32; ++item)
      if (this.Contains(item))
        yield return item;
  }
  public override string ToString() 
  {
    return string.Join(",", this);
  }
}

仔细阅读此代码,了解其工作原理。请记住,向此集合添加元素不会改变该集合。它会生成 一个新的集合,其中包含添加的项。

好的,既然我们明白了这一点,让我们考虑一个更高效的算法来生成排列。

我们将通过递归方法来解决问题。递归解决方案始终具有相同的结构:

  • 我们可以解决一个微不足道的问题吗?如果可以,请解决它。
  • 如果不能,请将问题分解为若干个较小的问题,并解决每个问题。

让我们从微不足道的问题开始。

假设您有一个集合,希望从中选择零项。答案很清楚:有且只有一个可能的排列,即空集。

假设您有一个包含n个元素的集合,并且您想选择超过n个元素。显然没有解决方案,甚至连空集都没有。

现在我们已经处理了集合为空或所选元素数量大于总元素数量的情况,因此我们必须从至少有一个元素的集合中选择至少一件事。

在可能的排列中,一些排列中包含第一个元素,而另一些排列则不包含。找出所有包含第一个元素的排列并产生它们。我们通过在缺少第一个元素的集合上递归地选择较少的一个元素来实现这一点。

我们通过枚举没有第一个元素的集合的排列,找到不包含第一个元素的排列。

static class Extensions
{
  public static IEnumerable<BitSet> Choose(this BitSet b, int choose)
  {
    if (choose < 0) throw new InvalidOperationException();
    if (choose == 0) 
    { 
      // Choosing zero elements from any set gives the empty set.
      yield return BitSet.Empty; 
    }
    else if (b.Count() >= choose) 
    {
      // We are choosing at least one element from a set that has 
      // a first element. Get the first element, and the set
      // lacking the first element.

      int first = b.First();
      BitSet rest = b.Remove(first);
      // These are the permutations that contain the first element:
      foreach(BitSet r in rest.Choose(choose-1))
        yield return r.Add(first);
      // These are the permutations that do not contain the first element:
      foreach(BitSet r in rest.Choose(choose))
        yield return r;
    }
  }
}

现在我们可以问你需要答案的问题:

class Program
{
  static void Main()
  {
    BitSet b = BitSet.Empty.Add(1).Add(2).Add(3).Add(4).Add(5).Add(6).Add(7);
    foreach(BitSet result in b.Choose(3))
      Console.WriteLine(result);
  }
}

我们完成了。我们只生成了实际需要的序列。(尽管我们进行了许多集合操作,但是集合操作很便宜。)这里的要点是理解这个算法的工作原理非常有教益。对于许多专业程序员来说,递归编程和不可变结构是一种强大的工具,他们的工具箱中可能没有这些知识。


10
我对你抽出时间编写了一个既能解决原帖问题、又具有良好可扩展性和适用于更广泛的场景的解决方案印象深刻。 - vc 74
感谢@Eric提供的出色答案。我肯定可以用它来优化我的项目的其他部分。有一件事我想澄清,ToString()对我来说没有编译。所以我必须编写一个单独的函数来打印所有正位索引。我错过了什么吗? - Tony Dinh
@TonyDinh:不客气。也许你正在使用旧版本的BCL,该版本没有String.Join的那个重载;我记得这是相对较新的。只需编写自己的ToString即可。 - Eric Lippert

2
您可以这样做:
var data = Enumerable.Range(1, 7);
var r = from a in data
        from b in data
        from c in data
        where a < b && b < c
        select new {a, b, c};
foreach (var x in r) {
    Console.WriteLine("{0} {1} {2}", x.a, x.b, x.c);
}

演示。

编辑:感谢Eric Lippert简化答案!


在条件始终为真的情况下加入自己,与仅执行选择多个查询有何不同? - Eric Lippert
@EricLippert 它看起来很对称。 - Sergey Kalinichenko
1
我对你的评论感到困惑。from a in data from b in data from c in data where a < b where b < c ... 怎么会不够“对称”呢?你的解决方案在内存中构建数据结构,这是不必要的。 - Eric Lippert
@EricLippert 谢谢!我想到了另一种语法,那里它不会对称。 - Sergey Kalinichenko
不用谢。我注意到这个查询相当低效 - 请看我的答案,了解一些想法。您可以通过将“where a> b”子句向上移动来使其稍微更有效率。from a in d from b in d where a > b from c in d where c > b select ... - Eric Lippert

0
var ints = new int[] { 1, 2, 3, 4, 5, 6, 7 };

var permutations = ints.SelectMany(a => ints.Where(b => (b > a)).
                        SelectMany(b => ints.Where(c => (c > b)).
                        Select(c => new { a = a, b = b, c = c })));

1
这将产生{1, 2, 3}和{2, 1, 3},这不是原始帖子作者想要的。 - Eric Lippert
是的,我认为楼主在寻找所有的组合。 - vc 74
我注意到你的检查 c > a 对于 c > b 是多余的。在第一个 Where 中,你已经确定了 b > a,因此你知道如果 c > b,那么它肯定也大于 a - Eric Lippert
@EricLippert 您说得对,这是我之前代码的遗留问题。 - vc 74

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