【问题标题】:Select N random elements from a List efficiently (without toArray and change the list)有效地从列表中选择 N 个随机元素(无需 toArray 并更改列表)
【发布时间】:2014-05-18 08:29:22
【问题描述】:

如标题所示,我想使用 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 shuffle 是从列表中选择 n 个随机元素的最佳算法。我对吗?如果有其他算法比 Knuth-Fisher-Yates shuffle 在速度和结果质量(保证真正的随机性)方面完成这项工作,我将非常高兴。

更新:

这是我的一些测试结果:

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

当 n

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 时,Reservoir Sampling 方法更快。

所以我构建了一个函数来结合这两种方法。

【问题讨论】:

    标签: java algorithm random


    【解决方案1】:

    您可能正在寻找类似Resorvoir Sampling 的东西。

    从具有第一个 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;
    

    这需要对数据进行单次传递,每次迭代都使用非常便宜的操作,并且空间消耗与所需的输出大小成线性关系。

    【讨论】:

    • Resorvoir Sampling 似乎比这里的 BitSet 慢:lemire.me/blog/archives/2013/08/16/… Daniel Lemire 的帖子是关于选择 N 个随机数的。我的是关于从列表中选择 N 个随机元素。我只是想知道 Knuth-Fisher-Yates shuffle 是否可以比 BitSet 更快地解决我的问题。
    • @Leo 这是在比较苹果和橙子。如果 k = cn 对于某个足够大的常数 c,则水库采样将更有效。对于小 k,像该博客或其他答案中描述的那样的 O(k) w.h.p 算法显然更好
    • @NiklasB。你认为,Knuth-Fisher-Yates shuffle 可以比 Resorvoir Sampling 表现更好吗? Resorvoir Sampling 是一次通过,但 Knuth-Fisher-Yates shuffle 可以进行 1/3 或 1/2 次通过以获得预期的随机元素。它应该需要一些额外的数据结构来记录交换了哪个元素以避免更改原始列表。
    【解决方案2】:

    如果 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个项目吗?
    • 我同意很难为所有情况找到一个“好的”解决方案 - 包括与列表大小相比“n”非常小非常大的情况.第一个解决方案很简单,但也不完全正确:您无法证明该程序将永远终止。对于包含 100 个元素的列表,它可能会随机将元素 42 放入集合中。然后它可能会随机将元素 42 放入集合中。然后……再一次。永远。 (似乎是一个相当理论的问题,但应牢记)
    • @Leo 这个解决方案给出了一组精确的k 项目,假设您使用result 的有效集合实现。但是请注意,性能取决于list 的具体实现,LinkedList 将受到性能不佳的影响,因为它不支持高效的get(i)
    • @Marco13 这是一个Las Vegas 算法。对于小 k,运行时间“小”的概率很高
    • @Marco13 实际上有一个非常好的解决方案(添加答案)......此外,算法将始终终止,具有恒定后缀的向量的概率为 0 - 所以概率1,它将终止。
    【解决方案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 仅存储键和值不同的映射。假设散列是常数时间,这两种算法都是渐近最优的(尽管很明显,如果你想随机采样大部分数组,那么会有更好的常数数据结构)。

    【讨论】:

      【解决方案4】:

      为方便起见:MCVEResorvoir Sampling proposed by amit 的实现(可能的赞成票应该交给他(我只是在破解一些代码))

      看起来这确实是一个算法,它很好地涵盖了与列表大小相比,要选择的元素数量的情况,以下情况与列表大小相比,元素的数量(假设维基百科页面上关于结果随机性的属性是正确的)。

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

      【讨论】:

      • FWIW 如果您不想获得荣誉,您可以将您的答案设为社区 wiki
      • @NiklasB 好主意,只是将其更改为 wiki 答案
      • 他。感谢您的代码。我已经检查过了。但其随机质量不高。我使用来自github.com/lemire/Code-used-on-Daniel-Lemire-s-blog/blob/master/… 的代码进行 Resorvoir Sampling,它的随机质量非常高。
      • 我已经认真测试过了。我用它从 1、2、3、4、5 中选择 2 个数字。而且您的代码无法获得组合:“12”或“21”。并且拥有不同梳子的概率是不同的。在我链接的代码中,选择不同组合的概率是相同的。我没有试图找出原因。但如果你有兴趣,你可能想看看链接。
      • 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
      【解决方案5】:

      如果n 比大小小得多,你可以使用这个算法,不幸的是,女巫与n 是二次方的,但根本不取决于数组的大小。

      大小 = 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.
      ...
      

      很快,您从剩余的数字中进行选择,然后计算出您实际选择的数字。我会为此使用链接列表,但也许有更好的数据结构。

      【讨论】:

      • 如果我理解正确,这将导致结果不是真正随机的,因为它会有更高的概率出现连续个数字结果。 (但描述有点模糊,所以我不确定)
      • 我不这么认为,在每一步中,它都会从尚未选择的那些中选择统一的随机数。但是描述很烂,我知道:)
      【解决方案6】:

      总结 Changwang 的更新。 如果您想要超过 250,000 个项目,请使用 amit 的答案。否则使用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;
      }
      

      【讨论】:

        【解决方案7】:

        我在 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;
                }
            }
        }
        

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2014-02-07
          • 2017-01-26
          • 2021-12-30
          • 2013-10-19
          • 2010-09-08
          • 1970-01-01
          • 2023-01-11
          相关资源
          最近更新 更多