我正在尝试使用 Java 8 中的 Streams API 从集合中检索 n 个唯一的随机元素以进行进一步处理,但是,没有太多运气。
更准确地说,我想要这样的东西:
Set<Integer> subList = new HashSet<>();
Queue<Integer> collection = new PriorityQueue<>();
collection.addAll(Arrays.asList(1,2,3,4,5,6,7,8,9));
Random random = new Random();
int n = 4;
while (subList.size() < n) {
subList.add(collection.get(random.nextInt()));
}
sublist.forEach(v -> v.doSomethingFancy());
我想尽可能高效地完成它。
这能做到吗?
编辑:我的第二次尝试——虽然不完全是我的目标:
List<Integer> sublist = new ArrayList<>(collection);
Collections.shuffle(sublist);
sublist.stream().limit(n).forEach(v -> v.doSomethingFancy());
编辑:第三次尝试(受 Holger 启发),如果 coll.size() 很大而 n 很小,这将消除很多 shuffle 的开销:
int n = // unique element count
List<Integer> sublist = new ArrayList<>(collection);
Random r = new Random();
for(int i = 0; i < n; i++)
Collections.swap(sublist, i, i + r.nextInt(source.size() - i));
sublist.stream().limit(n).forEach(v -> v.doSomethingFancy());
最佳答案
正如 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。
关于java - 使用 Streams API 对集合中的 n 个随机不同元素执行操作,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/28651908/