使用流 API 对集合中的 n 个随机不同元素执行操作

2022-09-04 19:17:42

但是,我正在尝试使用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很小,这将消除很多洗牌的开销:

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());

答案 1

洗牌方法工作得相当不错,正如fge评论中和ZouZou在另一个答案中所建议的那样。以下是洗牌方法的通用版本:

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);
}

我会注意到,使用比获取流然后调用更可取,如其他一些答案所示,因为生成的流具有已知大小并且可以更有效地拆分。subListlimit(n)

洗牌方法有几个缺点。它需要复制出所有元素,然后需要洗牌所有元素。如果元素的总数很大,而要选择的元素数量很少,这可能非常昂贵。

OP和其他几个答案建议的一种方法是随机选择元素,同时拒绝重复元素,直到选择了所需数量的唯一元素。如果要选择的元素数量相对于总数较小,这很有效,但是随着要选择的元素数量的增加,由于选择重复项的可能性也会增加,因此速度会减慢很多。

如果有一种方法可以在输入元素的空间上进行一次传递,并准确选择所需的数字,并且随机均匀地进行选择,那不是很好吗?事实证明,有,像往常一样,答案可以在高德纳找到。参见 TAOCP 第 2 卷,第 3.4.2 节,随机抽样和随机抽样,算法 S。

简而言之,该算法是访问每个元素,并根据访问的元素数量和选择的元素数量来决定是否选择它。在Knuth的表示法中,假设你有N个元素,并且你想随机选择其中的n个。应以概率选择下一个元素

(n - 米) / (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 艾迪生-卫斯理.


答案 2

您始终可以创建一个“哑”比较器,该比较器将随机比较列表中的元素。调用将确保元素是唯一的(从队列中)。distinct()

像这样:

static List<Integer> nDistinct(Collection<Integer> queue, int n) {
    final Random rand = new Random();
    return queue.stream()
                .distinct()
                .sorted(Comparator.comparingInt(a -> rand.nextInt()))
                .limit(n)
                .collect(Collectors.toList());
}

但是,我不确定将元素放在列表中,洗牌并返回子列表会更有效。

static List<Integer> nDistinct(Collection<Integer> queue, int n) {
    List<Integer> list = new ArrayList<>(queue);
    Collections.shuffle(list);
    return list.subList(0, n);
}

哦,从语义上讲,返回a而不是a可能更好,因为这些元素是不同的。这些方法也被设计为采用s,但是将它们设计为通用并不困难。:)SetListInteger

请注意,Stream API看起来像一个工具箱,我们可以将其用于所有事情,但情况并非总是如此。如您所见,第二种方法更具可读性(IMO),可能更有效率,并且没有更多的代码(甚至更少!