高效地从列表中选择N个随机元素(不使用toArray和更改列表)

10

如标题所述,我想使用Knuth-Fisher-Yates随机置乱算法从列表中选择N个随机元素,但不使用List.toArray并且不改变列表。以下是我的当前代码:

public List<E> getNElements(List<E> list, Integer n) {
    List<E> rtn = null;

    if (list != null && n != null && n > 0) {
        int lSize = list.size();
        if (lSize > n) {
            rtn = new ArrayList<E>(n);
            E[] es = (E[]) list.toArray();
            //Knuth-Fisher-Yates shuffle algorithm 
            for (int i = es.length - 1; i > es.length - n - 1; i--) {
                int iRand = rand.nextInt(i + 1);
                E eRand = es[iRand];
                es[iRand] = es[i];
                //This is not necessary here as we do not really need the final shuffle result.
                //es[i] = eRand;
                rtn.add(eRand);
            }

        } else if (lSize == n) {
            rtn = new ArrayList<E>(n);
            rtn.addAll(list);
        } else {
            log("list.size < nSub! ", lSize, n);
        }
    }

    return rtn;
}

为了避免修改原始列表,它使用list.toArray()创建一个新数组。然而,我的问题现在是,我的列表可能非常大,可以有100万个元素。那么list.toArray()太慢了。当n从1到100万时,函数的效率很低,特别是当n很小(比如2)时,它仍然需要对100万个元素的列表执行list.toArray()。

是否有人能帮助改进上述代码,使其在处理大型列表时更有效?谢谢。

在这里,我假设Knuth-Fisher-Yates随机置乱算法是从列表中选择n个随机元素的最佳算法。我是正确的吗?如果在速度和结果质量(保证真正随机)方面有比Knuth-Fisher-Yates随机置乱算法更好的其他算法,则我将非常高兴。

更新:

以下是我一些测试结果:

当从1000000个元素中选择n时。

当n < 1000000/4时,通过使用Daniel Lemire的位图函数来选择n个随机id,然后使用这些id获取元素是最快的方法:

public List<E> getNElementsBitSet(List<E> list, int n) {
        List<E> rtn = new ArrayList<E>(n);
        int[] ids = genNBitSet(n, 0, list.size());
        for (int i = 0; i < ids.length; i++) {
            rtn.add(list.get(ids[i]));
        }
        return rtn;
    }

genNBitSet使用了来自https://github.com/lemire/Code-used-on-Daniel-Lemire-s-blog/blob/master/2013/08/14/java/UniformDistinct.java的generateUniformBitmap代码。

当n大于1000000/4时,蓄水池抽样方法更快。

因此,我构建了一个结合这两种方法的函数。

7个回答

7

您可能正在寻找类似于水库抽样算法的东西。

从包含前k个元素的初始数组开始,并使用递减概率修改它:

类似Java的伪代码:

E[] r = new E[k]; //not really, cannot create an array of generic type, but just pseudo code
int i = 0;
for (E e : list) {
   //assign first k elements:
   if (i < k) { r[i++] = e; continue; }
   //add current element with decreasing probability:
   j = random(i++) + 1; //a number from 1 to i inclusive
   if (j <= k) r[j] = e;
}
return r;

这需要对数据进行一次遍历,每次迭代操作非常便宜,并且空间消耗与所需输出大小呈线性关系。

1
Resorvoir Sampling 在这里似乎比 BitSet 慢:http://lemire.me/blog/archives/2013/08/16/picking-n-distinct-numbers-at-random-how-to-do-it-fast/ Daniel Lemire 的帖子是关于选择 N 个随机数的。我的问题是从列表中选择 N 个随机元素。我只是想知道 Knuth-Fisher-Yates shuffle 是否可以比 BitSet 更快地解决我的问题。 - Changwang Zhang
@Leo 这就像拿苹果和橙子相比较。如果 k = cn,其中 c 是足够大的常数,那么蓄水池采样会更有效率。对于小的 k,像博客中描述的 O(k) w.h.p 算法或其他答案中的算法显然更好。 - Niklas B.
@NiklasB。您认为Knuth-Fisher-Yates洗牌算法的性能比Resorvoir Sampling更好吗?Resorvoir Sampling是一次遍历,但是Knuth-Fisher-Yates洗牌算法可以进行1/3或1/2的遍历以获取预期的随机元素。它需要一些额外的数据结构来记录哪个元素被交换以避免改变原始列表。 - Changwang Zhang

5
如果n远小于列表长度,取一个空的整数集合,并不断添加随机索引,直到集合达到所需大小。
如果n与列表长度相当,也是如此,但是然后返回列表中没有索引在集合中的项。
在中间地带,您可以遍历列表,并根据已经看到的项和已经返回的项的数量随机选择项。伪代码如下,如果您想从N中获取k个项:
for i = 0 to N-1
    if random(N-i) < k
        add item[i] to the result
        k -= 1
    end
end

这里的random(x)返回一个介于0(包括)和x(不包括)之间的随机数。

这会产生一个均匀随机的k个元素样本。你也可以考虑创建一个迭代器来避免构建结果列表以节省内存,假设在迭代过程中列表没有被改变。

通过分析性能,您可以确定从简单的集合构建方法切换到迭代方法的转换点。


中间代码会选择恰好k个项目吗? - Changwang Zhang
我同意,很难找到适用于所有情况的“好”解决方案 - 包括'n'与列表大小相比非常小或非常大的情况。第一个解决方案很简单,但也不完全正确:您无法证明此程序将永远终止。对于具有100个元素的列表,它可能会随机将元素42放入集合中。然后它可能会随机将元素42放入集合中。然后...永远。 (似乎是一个相当理论的问题,但应该记住) - Marco13
@Leo 这个解决方案提供了一个包含恰好 k 个项目的集合,假设您对 result 使用了有效的集合实现。但请注意,性能取决于 list 的具体实现,LinkedList 由于不支持高效的 get(i),因此性能较差。 - amit
@Marco13 这是一个拉斯维加斯算法。对于小的k值,运行时间在高概率下是“小”的。 - Niklas B.
@amit 如果你有一个链表,你可以同时使用“i”来迭代列表。 - Paul Hankin
显示剩余3条评论

3
假设您可以从 m 个彼此不相交的元素中生成 n 个随机索引,并且可以高效地在集合中查找它们。如果您不需要元素的顺序是随机的,那么您可以使用由 Robert Floyd 开发的算法。
Random r = new Random();
Set<Integer> s = new HashSet<Integer>();
for (int j = m - n; j < m; j++) {
    int t = r.nextInt(j);
    s.add(s.contains(t) ? j : t);
}

如果您需要顺序随机化,则可以运行Fisher-Yates算法,其中使用HashMap而不是数组,该哈希表仅存储键和值不同的映射。假设哈希是常数时间,则这两个算法在渐近意义下都是最优的(但很明显,如果您想要随机采样大部分数组,则有更好的数据结构)。

2

为了方便起见:使用MCVE实现了由Amit提出的Resorvoir Sampling算法(可能会有人点赞他(我只是在写一些代码))。

看起来这确实是一个很好的算法,适用于选择元素数量相对于列表大小较低的情况,以及选择元素数量与列表大小相比较高的情况(假设关于结果随机性的属性如维基百科页面所述是正确的)。

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Random;
import java.util.TreeMap;

public class ReservoirSampling
{
    public static void main(String[] args)
    {
        example();
        //test();
    }

    private static void test()
    {
        List<String> list = new ArrayList<String>();
        list.add("A");
        list.add("B");
        list.add("C");
        list.add("D");
        list.add("E");
        int size = 2;

        int runs = 100000;
        Map<String, Integer> counts = new TreeMap<String, Integer>();
        for (int i=0; i<runs; i++)
        {
            List<String> sample = sample(list, size);
            String s = createString(sample);
            Integer count = counts.get(s);
            if (count == null)
            {
                count = 0;
            }
            counts.put(s, count+1);
        }
        for (Entry<String, Integer> entry : counts.entrySet())
        {
            System.out.println(entry.getKey()+" : "+entry.getValue());
        }
    }

    private static String createString(List<String> list)
    {
        Collections.sort(list);
        StringBuilder sb = new StringBuilder();
        for (String s : list)
        {
            sb.append(s);
        }
        return sb.toString();
    }

    private static void example()
    {
        List<String> list = new ArrayList<String>();
        for (int i=0; i<26; i++)
        {
            list.add(String.valueOf((char)('A'+i)));
        }

        for (int i=1; i<=26; i++)
        {
            printExample(list, i);
        }
    }
    private static <T> void printExample(List<T> list, int size)
    {
        System.out.printf("%3d elements: "+sample(list, size)+"\n", size);
    }

    private static final Random random = new Random(0);
    private static <T> List<T> sample(List<T> list, int size)
    {
        List<T> result = new ArrayList<T>(Collections.nCopies(size, (T) null));
        int i = 0;
        for (T element : list)
        {
            if (i < size)
            {
                result.set(i, element);
                i++;
                continue;
            }
            i++;
            int j = random.nextInt(i);
            if (j < size)
            {
                result.set(j, element);
            }
        }
        return result;
    }

}

说实话,如果你不想要得到荣誉,你可以将你的答案设为社区维基。 - Niklas B.
@NiklasB 好主意,我刚把它改成了维基答案。 - Marco13
谢谢你的代码。我已经检查过了。但是它的随机质量不高。我使用来自https://github.com/lemire/Code-used-on-Daniel-Lemire-s-blog/blob/master/2013/08/14/java/UniformDistinct.java的代码进行蓄水池采样,其随机质量非常高。 - Changwang Zhang
我进行了严格的测试。我使用它从1、2、3、4、5中选择2个数字。但是你的代码无法得到组合:"12"或"21"。而且不同组合出现的概率也不同。然而,在我提供的代码中,选择不同组合的概率是相同的。我没有尝试找出原因。但如果你感兴趣,可以看一下链接。 - Changwang Zhang
Daniel Lemire的蓄水池抽样:|12.0,.1025 | 13.0,.0989 | 14.0,.0991 | 15.0,.0998 | 23.0,.1004 | 24.0,.1007 | 25.0,.1014 | 34.0,.0982 | 35.0,.0984 | 45.0,.1006 | Marco13的代码蓄水池抽样:| 13.0,.0837 | 14.0,.0823 | 15.0,.0837 | 23.0,.084 | 24.0,.0837 | 25.0,.0845 | 34.0,.1661 | 35.0,.1657 | 45.0,.1662 | - Changwang Zhang
@Leo 确实,两行代码被交换了(amit使用基于1的索引)。我更新了示例,并添加了一个测试,显示所有组合的频率现在“应该是相等的”。 - Marco13

1
如果 n 远小于 size,您可以使用此算法,它是二次的,但不取决于数组的大小。
以 size = 100 和 n = 4 为例。
choose random number from 0 to 99, lets say 42, and add it to result.
choose random number from 0 to 98, lets say 39, and add it to result.
choose random number from 0 to 97, lets say 41, but since 41 is bigger or equal than 39, increment it by 1, so you have 42, but that is bigger then equal than 42, so you have 43.
...

简单来说,你从剩余的数字中选择一个,然后计算你实际选择的是哪个数字。我会使用链表来实现这个过程,但也许还有更好的数据结构可用。

2
如果我理解正确的话,这将导致结果并不真正随机,因为连续数字出现的概率更高。(但是描述有点模糊,所以我不确定) - Marco13
我不这么认为,在每一步中,它从尚未选择的数字中均匀随机选择一个。但是描述很糟糕,我知道 :) - kajacx

0

总结Changwang的更新。 如果您需要超过250,000个项目,请使用amit的答案。否则,请像这里完整显示的{{link1:Knuth-Fisher-Yates Shuffle}}一样使用

注意:结果始终以原始顺序排列

public static <T> List<T> getNRandomElements(int n, List<T> list) {
    List<T> subList = new ArrayList<>(n);
    int[] ids = generateUniformBitmap(n, list.size());
    for (int id : ids) {
        subList.add(list.get(id));
    }
    return subList;
}

// https://github.com/lemire/Code-used-on-Daniel-Lemire-s-blog/blob/master/2013/08/14/java/UniformDistinct.java
private static int[] generateUniformBitmap(int num, int max) {
    if (num > max) {
        DebugUtil.e("Can't generate n ints");
    }
    int[] ans = new int[num];
    if (num == max) {
        for (int k = 0; k < num; ++k) {
            ans[k] = k;
        }
        return ans;
    }
    BitSet bs = new BitSet(max);
    int cardinality = 0;
    Random random = new Random();
    while (cardinality < num) {
        int v = random.nextInt(max);
        if (!bs.get(v)) {
            bs.set(v);
            cardinality += 1;
        }
    }
    int pos = 0;
    for (int i = bs.nextSetBit(0); i >= 0; i = bs.nextSetBit(i + 1)) {
        ans[pos] = i;
        pos += 1;
    }
    return ans;
}

如果您想要它们随机排列,我使用以下代码:
public static <T> List<T> getNRandomShuffledElements(int n, List<T> list) {
    List<T> randomElements = getNRandomElements(n, list);
    Collections.shuffle(randomElements);
    return randomElements;
}

0

我需要在C#中实现这个功能,以下是我的解决方案,适用于通用列表。

它选择列表中的N个随机元素,并将它们放在列表的前面。

因此,在返回时,列表的前N个元素是随机选择的。即使处理非常大量的元素,它也快速高效。

static void SelectRandom<T>(List<T> list, int n)
{
    if (n >= list.Count)
    {
        // n should be less than list.Count
        return;
    }
    int max = list.Count;
    var random = new Random();
    for (int i = 0; i < n; i++)
    {
        int r = random.Next(max);
        max = max - 1;
        int irand = i + r;
        if (i != irand)
        {
            T rand = list[irand];
            list[irand] = list[i];
            list[i] = rand;
        }
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接