使用Streams API对Collection中的n个随机不同元素执行操作

时间:2015-02-21 22:06:26

标签: java collections java-8

我正在尝试使用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());

8 个答案:

答案 0 :(得分:13)

根据fgecomment和另一ZouZouanswer的建议,改组方法运行良好。以下是改组方法的一般化版本:

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类允许我们在半开区间内选择一个随机整数。因此,我们需要做的就是保留计数器的剩余数量以及剩余的数量, et voila

/**
 * 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。

答案 1 :(得分:4)

您总是可以创建一个“哑”比较器,它将在列表中随机比较元素。调用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);
}

哦,从语义上讲,返回Set代替List可能更好,因为元素是有区别的。这些方法也被设计为采用Integer s,但设计它们是通用的没有困难。 :)

正如笔记一样,Stream API看起来像一个我们可以用于所有内容的工具箱,但情况并非总是如此。如您所见,第二种方法更具可读性(IMO),可能效率更高,代码更少(甚至更少!)。

答案 2 :(得分:2)

作为接受答案的shuffle方法的补充:

如果您只想从大型列表中选择几个项目并希望避免洗牌整个列表的开销,您可以按如下方式解决任务:

public static <T> List<T> getRandom(List<T> source, int num) {
    Random r=new Random();
    for(int i=0; i<num; i++)
        Collections.swap(source, i, i+r.nextInt(source.size()-i));
    return source.subList(0, num);
}

它的作用与shuffle的作用非常相似,但它减少了只有num随机元素而不是source.size()个随机元素的行为......

答案 3 :(得分:1)

您可以使用限制来解决问题。

http://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html#limit-long-

Collections.shuffle(collection); 

int howManyDoYouWant = 10;
List<Integer> smallerCollection = collection
    .stream()
    .limit(howManyDoYouWant)
    .collect(Collectors.toList());

答案 4 :(得分:0)

应该很清楚,流式传输集合并不是您想要的。

使用generate()limit方法:

Stream.generate(() -> list.get(new Random().nextInt(list.size())).limit(3).forEach(...);

答案 5 :(得分:0)

List<Integer> collection = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
int n = 4;
Random random = ThreadLocalRandom.current();

random.ints(0, collection.size())
        .distinct()
        .limit(n)
        .mapToObj(collection::get)
        .forEach(System.out::println);

这当然会有中间索引集的开销,如果n&gt;它将永远挂起。 collection.size()。

如果您想避免任何非常规开销,您必须制作有状态的Predicate

答案 6 :(得分:0)

如果您希望处理整个Stream时没有太多麻烦,则可以使用Collectors.collectingAndThen()创建自己的收集器:

public static <T> Collector<T, ?, Stream<T>> toEagerShuffledStream() {
    return Collectors.collectingAndThen(
      toList(),
      list -> {
          Collections.shuffle(list);
          return list.stream();
      });
}

但是,如果您想limit()生成的流,这将不能很好地执行。为了克服这一点,可以创建一个自定义的Spliterator:

package com.pivovarit.stream;

import java.util.List;
import java.util.Random;
import java.util.Spliterator;
import java.util.function.Consumer;
import java.util.function.Supplier;

public class ImprovedRandomSpliterator<T> implements Spliterator<T> {

    private final Random random;
    private final T[] source;
    private int size;

    ImprovedRandomSpliterator(List<T> source, Supplier<? extends Random> random) {
        if (source.isEmpty()) {
            throw new IllegalArgumentException("RandomSpliterator can't be initialized with an empty collection");
        }
        this.source = (T[]) source.toArray();
        this.random = random.get();
        this.size = this.source.length;
    }

    @Override
    public boolean tryAdvance(Consumer<? super T> action) {
        int nextIdx = random.nextInt(size);
        int lastIdx = size - 1;

        action.accept(source[nextIdx]);
        source[nextIdx] = source[lastIdx];
        source[lastIdx] = null; // let object be GCed
        return --size > 0;
    }

    @Override
    public Spliterator<T> trySplit() {
        return null;
    }

    @Override
    public long estimateSize() {
        return source.length;
    }

    @Override
    public int characteristics() {
        return SIZED;
    }
}

然后:

public final class RandomCollectors {

    private RandomCollectors() {
    }

    public static <T> Collector<T, ?, Stream<T>> toImprovedLazyShuffledStream() {
        return Collectors.collectingAndThen(
          toCollection(ArrayList::new),
          list -> !list.isEmpty()
            ? StreamSupport.stream(new ImprovedRandomSpliterator<>(list, Random::new), false)
            : Stream.empty());
    }

    public static <T> Collector<T, ?, Stream<T>> toEagerShuffledStream() {
        return Collectors.collectingAndThen(
          toCollection(ArrayList::new),
          list -> {
              Collections.shuffle(list);
              return list.stream();
          });
    }
}

然后您可以像使用它一样

stream
  .collect(toLazyShuffledStream()) // or toEagerShuffledStream() depending on the use case
  .distinct()
  .limit(42)
  .forEach( ... );

可以找到详细的说明here

答案 7 :(得分:0)

如果您想从流中随机抽取元素样本,可以使用基于均匀分布的过滤器来替代改组:

...
import org.apache.commons.lang3.RandomUtils

// If you don't know ntotal, just use a 0-1 ratio 
var relativeSize = nsample / ntotal;

Stream.of (...) // or any other stream
.parallel() // can work in parallel
.filter ( e -> Math.random() < relativeSize )
// or any other stream operation
.forEach ( e -> System.out.println ( "I've got: " + e ) );