按照fge 在comment 和ZouZou 在另一个answer 中的建议,改组方法效果很好。这是改组方法的通用版本:
static <E> List<E> shuffleSelectN(Collection<? extends E> coll, int n) {
assert n <= coll.size();
List<E> list = new ArrayList<>(coll);
Collections.shuffle(list);
return list.subList(0, n);
}
我会注意到,使用subList 比获取流然后调用limit(n) 更可取,如其他一些答案所示,因为生成的流具有已知大小并且可以更有效地拆分。
洗牌方法有几个缺点。它需要复制出所有元素,然后需要对所有元素进行洗牌。如果元素的总数很大而要选择的元素的数量很少,这可能会非常昂贵。
OP 和其他几个答案建议的方法是随机选择元素,同时拒绝重复,直到选择了所需数量的唯一元素。如果要选择的元素数量相对于总数来说很小,这很有效,但是随着要选择的元素数量的增加,这会减慢很多,因为选择重复的可能性也会增加。
如果有一种方法可以在输入元素的空间上进行单次传递并准确选择所需的数字,并且随机均匀地做出选择,那不是很好吗?事实证明,像往常一样,答案可以在 Knuth 中找到。请参阅 TAOCP 第 2 卷,第 3.4.2 节,随机采样和洗牌,算法 S。
简单来说,算法就是访问每个元素,根据访问的元素个数和选择的元素个数来决定是否选择它。在 Knuth 的表示法中,假设您有 N 个元素,并且您想随机选择其中的 n 个元素。应该以概率选择下一个元素
(n - m) / (N - t)
其中t是到目前为止访问的元素数,m是目前选择的元素数。
这一点并不明显,这将使所选元素均匀分布,但显然确实如此。证明留给读者作为练习;请参阅本节的练习 3。
鉴于此算法,通过循环遍历集合并根据随机测试添加到结果列表中,在“常规”Java 中实现它非常简单。 OP 询问了有关使用流的问题,所以这里是一个镜头。
算法 S 显然不适合 Java 流操作。它是完全按顺序描述的,关于是否选择当前元素的决定取决于随机决定加上从所有先前决定得出的状态。这可能使它看起来本质上是连续的,但我以前错了。我只想说,如何让这个算法并行运行并不是很明显。
不过,有一种方法可以将此算法应用于流。我们需要的是一个有状态的谓词。这个谓词将根据当前状态确定的概率返回一个随机结果,并且状态将根据这个随机结果更新——是的,变异了。这似乎很难并行运行,但至少在从并行流运行的情况下很容易使线程安全:只需使其同步即可。但是,如果流是并行的,它将降级为顺序运行。
实现非常简单。 Knuth 的描述使用 0 到 1 之间的随机数,但 Java Random 类允许我们在半开区间内选择一个随机整数。因此,我们需要做的就是记录有多少元素可以访问,有多少可以选择,等等:
/**
* A stateful predicate that, given a total number
* of items and the number to choose, will return 'true'
* the chosen number of times distributed randomly
* across the total number of calls to its test() method.
*/
static class Selector implements Predicate<Object> {
int total; // total number items remaining
int remain; // number of items remaining to select
Random random = new Random();
Selector(int total, int remain) {
this.total = total;
this.remain = remain;
}
@Override
public synchronized boolean test(Object o) {
assert total > 0;
if (random.nextInt(total--) < remain) {
remain--;
return true;
} else {
return false;
}
}
}
现在我们有了谓词,它很容易在流中使用:
static <E> List<E> randomSelectN(Collection<? extends E> coll, int n) {
assert n <= coll.size();
return coll.stream()
.filter(new Selector(coll.size(), n))
.collect(toList());
}
在 Knuth 的同一部分中也提到了另一种选择,建议以 n / N 的恒定概率随机选择一个元素。如果您不需要精确选择 n 个元素,这将很有用。它平均会选择 n 个元素,但当然会有一些变化。如果这是可以接受的,那么有状态谓词就会变得简单得多。我们可以简单地创建随机状态并从局部变量中捕获它,而不是编写整个类:
/**
* Returns a predicate that evaluates to true with a probability
* of toChoose/total.
*/
static Predicate<Object> randomPredicate(int total, int toChoose) {
Random random = new Random();
return obj -> random.nextInt(total) < toChoose;
}
要使用它,请将上面流管道中的 filter 行替换为
.filter(randomPredicate(coll.size(), n))
最后,为了比较,这里有一个使用传统Java编写的选择算法的实现,即使用for循环并添加到集合中:
static <E> List<E> conventionalSelectN(Collection<? extends E> coll, int remain) {
assert remain <= coll.size();
int total = coll.size();
List<E> result = new ArrayList<>(remain);
Random random = new Random();
for (E e : coll) {
if (random.nextInt(total--) < remain) {
remain--;
result.add(e);
}
}
return result;
}
这很简单,这并没有什么问题。它比流方法更简单、更独立。不过,流方法说明了一些有趣的技术,这些技术可能在其他情况下有用。
参考:
Knuth, Donald E. 计算机编程艺术:第 2 卷,半数值算法,第 2 版。 版权所有 1981, 1969 Addison-Wesley。