从集合中选择随机子集的最佳方法?


69

我在Vector中有一组对象,我想从中选择一个随机子集(例如,返回100个项目;随机选择5个)。在我的第一遍(非常仓促)中,我做了一个非常简单甚至过于聪明的解决方案:

Vector itemsVector = getItems();

Collections.shuffle(itemsVector);
itemsVector.setSize(5);

尽管这样做的好处是简单易用,但我怀疑它的伸缩性不会很好,即Collections.shuffle()至少必须为O(n)。我不太聪明的选择是

Vector itemsVector = getItems();

Random rand = new Random(System.currentTimeMillis()); // would make this static to the class    

List subsetList = new ArrayList(5);
for (int i = 0; i < 5; i++) {
     // be sure to use Vector.remove() or you may get the same item twice
     subsetList.add(itemsVector.remove(rand.nextInt(itemsVector.size())));
}

关于从集合中抽取随机子集的更好方法的任何建议?


严格来说,您的代码假定您正在处理列表/向量。如果处理任意集合,则首先必须将其所有项目提取到一个列表/向量/数组中,这可能会非常昂贵。这是因为通常的改组算法仅适用于列表/数组。
亚历山大

2
我发现弗洛伊德(Floyd)算法可在所有子集中提供可证明的均匀分布,因此我强烈推荐Eyal Schneider的答案,该答案链接到详细的文章,包括。证明和实施。
Jean-Philippe Pellet

1
itemsVector.remove是O(n)docs.oracle.com/javase/7/docs/api/java/util/…。我认为O(k)运行时间是可能的。
Kunukn

Answers:


11

乔恩·本特利(Jon Bentley)在“编程珍珠”或“更多编程珍珠”中对此进行了讨论。您需要谨慎对待N个M个选择过程,但我认为显示的代码可以正常工作。您可以只对前N个位置进行混洗,而不是对所有项目进行随机混洗-当N << M时,这是一个有用的节省方法。

Knuth还讨论了这些算法-我相信这将是第3卷“排序和搜索”,但是我的场景已经打包好等待搬家,所以我无法正式对其进行检查。


+1击败了我。我还写了关于执行前五个步骤的随机洗牌的操作:从1到M选择随机数,将第一个元素与该索引处的元素交换,从2到M选择一个随机数,交换第二个元素,依此类推。
亚历山大

感谢每个人提供的所有重要信息。尽管它们都有很多要添加的东西,但我之所以选择它,是因为这可能是我重构代码的方式:*设置i = 0 *将随机元素r从i捕获到n *将元素@ i与元素@ r交换* i ++ *重复,直到获得所需的内容
汤姆(Tom)

8

@乔纳森

我相信这是您正在谈论的解决方案:

void genknuth(int m, int n)
{    for (int i = 0; i < n; i++)
         /* select m of remaining n-i */
         if ((bigrand() % (n-i)) < m) {
             cout << i << "\n";
             m--;
         }
}

它位于乔恩·本特利(Jon Bentley)的《 Programming Pearls》的第127页上,它基于Knuth的实现。

编辑:我只是在页面129上看到了进一步的修改:

void genshuf(int m, int n)
{    int i,j;
     int *x = new int[n];
     for (i = 0; i < n; i++)
         x[i] = i;
     for (i = 0; i < m; i++) {
         j = randint(i, n-1);
         int t = x[i]; x[i] = x[j]; x[j] = t;
     }
     sort(x, x+m);
     for (i = 0; i< m; i++)
         cout << x[i] << "\n";
}

这是基于以下思想:“ ...我们只需要对数组的前m个元素进行混洗...”


5

如果您尝试从n个列表中选择k个不同的元素,则上面给出的方法将是O(n)或O(kn),因为从Vector中删除一个元素会导致arraycopy将所有元素向下移动。

由于您正在寻求最佳方法,因此这取决于您对输入列表的处理方式。

如果可以像在示例中那样修改输入列表,则可以将k个随机元素交换到列表的开头,并在O(k)时间返回,如下所示:

public static <T> List<T> getRandomSubList(List<T> input, int subsetSize)
{
    Random r = new Random();
    int inputSize = input.size();
    for (int i = 0; i < subsetSize; i++)
    {
        int indexToSwap = i + r.nextInt(inputSize - i);
        T temp = input.get(i);
        input.set(i, input.get(indexToSwap));
        input.set(indexToSwap, temp);
    }
    return input.subList(0, subsetSize);
}

如果列表必须以开始时的相同状态结束,则可以跟踪所交换的头寸,然后在复制选定的子列表后将列表恢复为原始状态。这仍然是O(k)解决方案。

但是,如果您根本无法修改输入列表,并且k小于n(例如100中的5),那么最好不要每次都删除选定的元素,而只需选择每个元素,并且如果得到副本,将其扔掉并重新选择。这将为您提供O(kn /(nk)),当n主导k时,它仍然接近O(k)。(例如,如果k小于n / 2,则它减小为O(k))。

如果k不由n决定,并且您不能修改列表,则最好复制原始列表并使用第一个解决方案,因为O(n)与O(k)一样好。

正如其他人指出的那样,如果您依赖于每个子列表均可能(且无偏见)的强随机性,那么您肯定需要比强大的东西java.util.Random。请参阅java.security.SecureRandom


4

几周前,我写了一个有效的实现方法。它在C#中,但是到Java的翻译很简单(本质上是相同的代码)。有利的一面是,它也完全没有偏见(有些现有答案不是)-一种测试方法

它基于Fisher-Yates随机播放的Durstenfeld实现。


很棒的文章。我认为可以用来改进原始问题中的代码的一个要点是交换元素而不是删除它们。这样可以避免在删除元素时必须折叠列表而导致的性能损失。
qualidafial

这接近于仅链接的答案-有人可以用相关代码更新它吗?
亚伦·霍尔

1
链接断开。
安托万

多亏了WaybackMachine的强大功能,您可以在此处
j.aevyz

2

但是,使用随机选择元素的第二种方法听起来不错:


感谢您提供使用更好种子的技巧;我将检查您发布的链接。完全同意使用ArrayList和Vector;但是,这是一个返回Vector的第三方库,我无法控制要返回的数据类型。谢谢!
汤姆”

大声笑,我现在需要修复随机代码...我也使用System.nanoTime()作为种子!感谢提供这篇好文章。
Pyrolistical

听起来不错,但不是最好的方法。它比需要的要慢。
戴夫

1

这个是关于stackoverflow的非常相似的问题。

总结一下该页面上我最喜欢的答案(用户Kyle给出的最答案):

  • O(n)解决方案:遍历您的列表,并以概率(#needed / #remaining)复制出一个元素(或对其的引用)。示例:如果k = 5且n = 100,则采用概率5/100的第一个元素。如果复制该副本,则选择概率为4/99的副本;但是如果您不参加第一个,则概率为5/99。
  • O(k log k)或O(k 2:通过随机选择一个<n,然后随机选择一个数字,建立一个由k个索引({0,1,...,n-1}中的数字)组成的排序列表<n-1,等等。在每个步骤中,您都需要重新选择您的选择以避免冲突并保持概率不变。例如,如果k = 5且n = 100,并且您的第一个选择是43,则下一个选择是在[0,98]范围内,并且如果> = 43,则将其加1。因此,如果您的第二个选择是50,则将其加1,然后得到{43,51}。如果您的下一个选择是51,则将其加2得到{43,51,53}。

这是一些伪python-

# Returns a container s with k distinct random numbers from {0, 1, ..., n-1}
def ChooseRandomSubset(n, k):
  for i in range(k):
    r = UniformRandom(0, n-i)                 # May be 0, must be < n-i
    q = s.FirstIndexSuchThat( s[q] - q > r )  # This is the search.
    s.InsertInOrder(q ? r + q : r + len(s))   # Inserts right before q.
  return s 

我是说时间复杂度是O(k 2O(k log k),因为它取决于您搜索和插入容器中的s的速度。如果s是一个普通列表,则这些操作之一是线性的,则得到k ^ 2。但是,如果您愿意将s构建为平衡的二叉树,则可以节省O(k log k)时间。


这些是体面的,但不是最好的方法。可以在O(k)中完成。
戴夫

这些不会弄乱原始数组。如果没有操纵原始数组,我还没有看到任何解决方案也可以做到。
泰勒

我在上面添加了这样的解决方案。只要k显着小于n,最好从列表中选择随机元素,然后抛出重复,直到得到k。
Dave L.

如果您使用哈希集来快速检查冲突,那么这实际上是一种有用的算法。但是从理论分析来看,最坏的情况实际上是O(无穷大),因为您无法保证碰撞次数的限制。一个未哈希的版本每次碰撞检查= k log k total仍需要O(log k)。
泰勒

确实,您显然应该使用散列集来检查冲突。由于我们正在处理随机算法,因此分析输入的最坏情况的复杂性很重要,但随机值的预期情况则要分析复杂性。
戴夫

0

除去多少费用?因为如果需要将数组重写到新的内存块,那么您已经在第二个版本中完成了O(5n)操作,而不是之前想要的O(n)。

您可以创建一个设置为false的布尔数组,然后:

for (int i = 0; i < 5; i++){
   int r = rand.nextInt(itemsVector.size());
   while (boolArray[r]){
       r = rand.nextInt(itemsVector.size());
   }
   subsetList.add(itemsVector[r]);
   boolArray[r] = true;
}

如果您的子集比总大小小很多,则此方法有效。当这些大小彼此接近时(即大小的1/4左右),在该随机数生成器上会遇到更多的冲突。在这种情况下,我会列出一个较大数组的整数列表,然后将整数列表洗牌,然后从中取出第一个元素,以得到(非冲突的)指标。这样,您将花费O(n)来构建整数数组,并在混洗中花费另一个O(n),但是内部的while Checker不会产生任何冲突,并且所消除的潜在O(5n)可能要少一些。


O(5N)=== O(N); 这就是big-O表示法的重点。但是,当您有两种方法(均为O(N))时,常数乘数和常数相加项就变得很重要(以及任何相关的子线性项)。
乔纳森·勒夫勒

0

我个人选择您的最初实现方式:非常简洁。性能测试将显示其扩展能力。我已经以一种体面的滥用方法实现了一个非常相似的代码块,并且可以充分扩展。特定代码还依赖于包含> 10,000个项目的数组。


0
Set<Integer> s = new HashSet<Integer>()
// add random indexes to s
while(s.size() < 5)
{
    s.add(rand.nextInt(itemsVector.size()))
}
// iterate over s and put the items in the list
for(Integer i : s)
{
    out.add(itemsVector.get(i));
}

如果没有概率的运行时间,那将会很棒,当n接近集合的大小时,运行时间会大大增加……
Jean-Philippe Pellet

0

我认为这里没有出现两个解决方案-对应内容相当长,并且包含一些链接,但是,我认为所有帖子均与从N个元素集中选择K个元素的替换问题有关。 。[通过“设置”,我指的是数学术语,即所有元素仅出现一次,顺序不重要]。

Sol 1:

//Assume the set is given as an array:
Object[] set ....;
for(int i=0;i<K; i++){
randomNumber = random() % N;
    print set[randomNumber];
    //swap the chosen element with the last place
    temp = set[randomName];
    set[randomName] = set[N-1];
    set[N-1] = temp;
    //decrease N
    N--;
}

这看起来与丹尼尔给出的答案相似,但实际上却大不相同。它的运行时间为O(k)。

另一个解决方案是使用一些数学运算:将数组索引视为Z_n,因此我们可以随机选择2个数字,x是n的素数,即chgose gcd(x,n)= 1,另一个是a,即“起点”-然后是序列:a%n,a + x%n,a + 2 * x%n,... a +(k-1)* x%n是一个不同数字的序列(只要k <= n)。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.