【问题标题】:Efficiently randomly sampling List while maintaining order在保持顺序的同时有效地随机抽样 List
【发布时间】:2015-09-24 19:38:17
【问题描述】:

我想在保持顺序的同时从非常大的列表中随机抽取样本。我写了下面的脚本,但是它需要.map(idx => ls(idx)),这非常浪费。我可以看到一种通过辅助函数和尾递归来提高效率的方法,但我觉得必须有一个更简单的解决方案,我错过了。

有没有一种干净且更有效的方法来做到这一点?

import scala.util.Random

def sampledList[T](ls: List[T], sampleSize: Int) = {
  Random
    .shuffle(ls.indices.toList)
    .take(sampleSize)
    .sorted
    .map(idx => ls(idx))
}

val sampleList = List("t","h","e"," ","q","u","i","c","k"," ","b","r","o","w","n")
// imagine the list is much longer though

sampledList(sampleList, 5) // List(e, u, i, r, n)

编辑: 看来我不清楚:我指的是保持值的顺序,而不是原始的 List 集合。

【问题讨论】:

  • 使用 Vector 而不是 List - 它是一棵宽树,所以随机访问是 O(log n)。
  • 谢谢lmm,但我需要它是一个列表,因为我应用于此集合的大多数其他操作在它是一个列表时效果最好。
  • 真的吗?没有多少可以作为列表更好地工作(其中大部分是由于结构共享,鉴于您想重复随机抽样,这似乎不太可能)。所以我同意 Vector 似乎是更好的选择
  • 如果您愿意采集不是特定大小而是近似百分比的样本,您可以使用平面图和随机数生成器在 O(n) 时间内完成。

标签: performance list scala random


【解决方案1】:

如果通过

保持值的顺序

您了解将示例中的元素与ls 列表中的元素保持相同的顺序,然后对您的原始解决方案稍作修改,即可大大提高性能:

import scala.util.Random

def sampledList[T](ls: List[T], sampleSize: Int) = {
  Random.shuffle(ls.zipWithIndex).take(sampleSize).sortBy(_._2).map(_._1)
}

这个解决方案的复杂度为 O(n + k*log(k)),其中 n 是列表的大小,k 是样本大小,而您的解决方案是 O(n + k * log(k) + n*k)。

【讨论】:

    【解决方案2】:

    这是一个(更复杂的)替代方案,具有O(n) 复杂性。就复杂性而言,您再好不过了(尽管您可以通过使用另一个集合来获得更好的性能,特别是具有恒定时间size 实现的集合)。我做了一个快速基准测试,表明加速非常可观。

    import scala.util.Random
    import scala.annotation.tailrec
    
    def sampledList[T](ls: List[T], sampleSize: Int) = {
      @tailrec
      def rec(list: List[T], listSize: Int, sample: List[T], sampleSize: Int): List[T] = {
        require(listSize >= sampleSize, 
          s"listSize must be >= sampleSize, but got listSize=$listSize and sampleSize=$sampleSize"
        )
        list match {
          case hd :: tl => 
            if (Random.nextInt(listSize) < sampleSize)
              rec(tl, listSize-1, hd :: sample, sampleSize-1)
            else rec(tl, listSize-1, sample, sampleSize)
          case Nil =>
            require(sampleSize == 0, // Should never happen
              s"sampleSize must be zero at the end of processing, but got $sampleSize"
            )
            sample
        }
      }
      rec(ls, ls.size, Nil, sampleSize).reverse
    }
    

    上面的实现只是简单地遍历列表并根据概率保留(或不保留)当前元素,该概率旨在为每个元素提供相同的机会。我的逻辑可能有一个流程,但乍一看,这对我来说似乎是合理的。

    【讨论】:

    • 哇,太好了!仅通过查看您的解决方案为何有效,我很难理解,因此我在纸上草拟了一致性证明以说服自己:D
    • 我认为这与我不久前写的东西是一样的,chris-martin.org/2009/randomization-pipeline 以防万一我的旧分析对任何人都有帮助:)
    【解决方案3】:

    这是另一个 O(n) 实现,每个元素应该有一个统一的概率:

      implicit class SampleSeqOps[T](s: Seq[T]) {
        def sample(n: Int, r: Random = Random): Seq[T] = {
          assert(n >= 0 && n <= s.length)
    
          val res = ListBuffer[T]()
    
          val length = s.length
          var samplesNeeded = n
    
          for { (e, i) <- s.zipWithIndex } {
            val p = samplesNeeded.toDouble / (length - i)
    
            if (p >= r.nextDouble()) {
              res += e
              samplesNeeded -= 1
            }
          }
    
          res.toSeq
        }
      }
    

    我经常使用它来处理 > 100'000 个元素的集合,而且性能似乎还不错。

    这可能与 Régis Jean-Gilles 的回答中的想法相同,但我认为在这种情况下,命令式解决方案更具可读性。

    【讨论】:

    • 我必须同意它对我来说更具可读性,尽管如果您试图通过归纳证明它的正确性,递归解决方案(如我的)通常更容易遵循。
    • 附带说明,当列表大小和样本大小都非常大时,使用Double 值进行概率检查会使您面临潜在的不精确性,从而使生成的样本有可能具有大小不同于请求的样本大小。使用整数代替了这种可能性。
    • 嗯,你认为整数范围内的双精度数会发生这种情况吗?不过,这也可以通过为 for 循环的枚举器 if samplesNeeded &gt; 0 添加保护来解决。如果n s.length.,守卫还会阻止算法遍历整个集合
    • 当然这接近于吹毛求疵,但它可能会发生,因为您正在做的是将一个 Int 与另一个 Int 分开(转换为 Double 之后),这可能导致在 Double 中无法精确表示的值。考虑以下表达式,其中xyInts:((x.toDouble/y)*y).toInt == x。例如,对于x = 7y=5,这将返回true,这意味着我们在转换为Double 并返回Int 时不会丢失精度。但是对于x = 2147483392y = x-1,这将返回false,这意味着我们失去了一些精度。
    • 另外,添加保护 if samplesNeeded &gt; 0 只会处理不精确导致循环选择太多元素的情况,而不是选择太少的情况(另外你仍然会有轻微的偏差分布)。
    【解决方案4】:

    也许我不太明白,但由于列表是不可变的,因此您不必担心“维护顺序”,因为原始列表从未被触及。以下内容还不够吗?

    def sampledList[T](ls: List[T], sampleSize: Int) =
      Random.shuffle(ls).take(sampleSize)
    

    【讨论】:

    • 谢谢melps,但我希望保持顺序,因为我使用的特定列表有一个预定义的顺序,这是以后操作所必需的。
    • 对不起,如果我的速度很慢,但是订单怎么没有维护?在您的示例中 sampleList 是原始列表 - 它的顺序永远不会改变,因为它是不可变的。 sampledList 的输出是“随机的”,因此排序不相关。我错过了什么?
    • 对不起,我好像不清楚。我指的是值的顺序,而不是原始列表。也许使用与日期相关的示例会使这一点更清楚一些。例如:如果列表lsList('2015-01-01','2015-01-02','2015-01-05','2015-01-03', ...etc),您的版本可能会产生类似于List('2015-01-3', '2015-01-01', ... etc) 的内容。如果需要按时间顺序排列的东西,则需要再次对其进行重新排序,这是一种浪费,因为排序最初是正确的。此外,这可能与输入顺序至关重要的类似队列的数据相关。
    • 明白了!看起来 kosii 下面的答案就是你想要的。
    【解决方案5】:

    虽然我之前的答案具有线性复杂性,但它确实有需要两次通过的缺点,第一次对应于在执行任何其他操作之前需要计算长度。除了影响运行时间,我们可能想要对一个非常大的集合进行采样,一次将整个集合加载到内存中既不实用也不高效,在这种情况下,我们希望能够使用简单的迭代器。 碰巧的是,我们不需要发明任何东西来解决这个问题。有一个名为reservoir sampling 的简单而聪明的算法正是这样做的(在我们迭代集合时构建一个样本,一次完成)。只需稍作修改,我们还可以根据需要保留顺序:

    import scala.util.Random
    def sampledList[T](ls: TraversableOnce[T], sampleSize: Int, preserveOrder: Boolean = false, rng: Random = new Random): Iterable[T] = {  
      val result = collection.mutable.Buffer.empty[(T, Int)]
      for ((item, n) <- ls.toIterator.zipWithIndex) {
        if (n < sampleSize) result += (item -> n)
        else {
          val s = rng.nextInt(n)
          if (s < sampleSize) {
            result(s) = (item -> n)
          }
        }
      }
      if (preserveOrder) {
        result.sortBy(_._2).map(_._1)
      } 
      else result.map(_._1)
    }
    

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2021-12-17
      • 2012-02-15
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多