【问题标题】:Weighted random pick加权随机选择
【发布时间】:2015-07-05 09:04:02
【问题描述】:

我有一套物品。我需要随机选择一个。问题是它们每个的权重为 1-10。权重为 2 表示该项目被拣选的可能性是权重 1 的两倍。权重为 3 的可能性是其三倍。

我目前用每个项目填充一个数组。如果权重为 3,我将项目的三个副本放入数组中。然后,我选择了一个随机项目。

我的方法很快,但占用大量内存。我试图想出一种更快的方法,但什么都没有想到。有人有这个问题的诀窍吗?

编辑:我的代码...

显然,我并不清楚。我不想使用(或改进)我的代码。这就是我所做的。

//Given an array $a where $a[0] is an item name and $a[1] is the weight from 1 to 100.
$b = array();
foreach($a as $t)
    $b = array_merge($b, array_fill(0,$t[1],$t));
$item = $b[array_rand($b)];

这需要我检查 $a 中的每个项目,并为数组使用 $a 内存的 max_weight/2*size。我想要一个完全不同的算法。

此外,我在半夜用手机问了这个问题。在手机上输入代码几乎是不可能的,因为那些愚蠢的虚拟键盘简直糟透了。它会自动更正所有内容,破坏我输入的任何代码。

更进一步,我今天早上醒来时发现了一种全新的算法,它完全不使用虚拟内存,也不需要检查数组中的每一项。我将其作为答案发布在下面。

【问题讨论】:

  • 请发布您的代码
  • 大量内存?你一般有多少物品?
  • 平均案例大约是 100,000 个项目,平均重量为 5。这意味着我每次想随机选择一个项目时都会创建一个 500.000 个元素的数组。
  • 将项目保留在数据库中,当您随机选择一项时,仅将那一项从数据库中拉出。这样你就不需要数组中的项目了。
  • 这不是题外话,但它是多重重复的(例如12 等)。应重新打开,以便正确关闭。

标签: php


【解决方案1】:

这是你的越橘。

  $arr = array(
    array("val" => "one", "weight" => 1),
    array("val" => "two", "weight" => 2),
    array("val" => "three", "weight" => 3),
    array("val" => "four", "weight" => 4)
  );

  $weight_sum = 0;
  foreach($arr as $val)
  {
    $weight_sum += $val['weight'];
  }

  $r = rand(1, $weight_sum);
  print "random value is $r\n";

  for($i = 0; $i < count($arr); $i++)
  {
    if($r <= $arr[$i]['weight'])
    {
      print "$r <= {$arr[$i]['weight']}, this is our match\n";
      print $arr[$i]['val'] . "\n";
      break;
    }
    else
    {
      print "$r > {$arr[$i]['weight']}, subtracting weight\n";
      $r -= $arr[$i]['weight'];
      print "new \$r is $r\n";
    }
  }

无需为每个权重生成包含一个项目的数组,无需为权重 n 填充 n 个元素的数组。只需生成一个介于 1 和总权重之间的随机数,然后遍历数组,直到找到小于随机数的权重。如果它不小于该数字,则从随机数中减去该权重并继续。

样本输出:

# php wr.php
random value is 8
8 > 1, subtracting weight
new $r is 7
7 > 2, subtracting weight
new $r is 5
5 > 3, subtracting weight
new $r is 2
2 <= 4, this is our match
four

这也应该支持分数权重。

修改后的版本使用按权重而不是按项目键控的数组

  $arr2 = array(
  );

  for($i = 0; $i <= 500000; $i++)
  {
    $weight = rand(1, 10);
    $num = rand(1, 1000);
    $arr2[$weight][] = $num;
  }

  $start = microtime(true);

  $weight_sum = 0;
  foreach($arr2 as $weight => $vals) {
    $weight_sum += $weight * count($vals);
  }

  print "weighted sum is $weight_sum\n";

  $r = rand(1, $weight_sum);
  print "random value is $r\n";
  $found = false;
  $elem = null;

  foreach($arr2 as $weight => $vals)
  {
    if($found) break;
    for($j = 0; $j < count($vals); $j ++)
    {
      if($r < $weight)
      {
        $elem = $vals[$j];
        $found = true;
        break;
      }
      else
      {
        $r -= $weight;
      }
    }
  }
  $end = microtime(true);

  print "random element is: $elem\n";
  print "total time is " . ($end - $start) . "\n";

带有样本输出:

# php wr2.php
weighted sum is 2751550
random value is 345713
random element is: 681
total time is 0.017189025878906

测量几乎不科学 - 并且会根据元素落在数组中的位置(显然)而波动,但对于大型数据集来说似乎足够快。

【讨论】:

  • 有趣的方法,使用大约 1/2 的内存作为书面和准确的,但它快吗?为此,您最多需要进行 100 万次加法/减法运算,而不是生成随机数。
  • 循环的次数还是比较少的,而且电脑的加法也不错
  • 是的,这将是一个有趣的基准。我认为我的速度更快的原因是因为它只循环权重(10)而不是每个项目(500000)
  • 好吧,我想这取决于输入 - 但上面的内容可以很容易地转换为处理按权重键控的数组。
  • 是的,这也会减少内存使用量。
【解决方案2】:

这种方式需要两次随机计算,但它们应该更快,并且需要大约 1/4 的内存,但如果权重的计数不成比例,精度会有所降低。 (请参阅更新以牺牲一些内存和处理来提高准确性)

存储一个多维数组,其中每个项目根据其权重存储在一个数组中:

$array[$weight][] = $item;
// example: Item with a weight of 5 would be $array[5][] = 'Item'

n 个权重生成一个权重 (1-10) 出现 n 次的新数组:

foreach($array as $n=>$null) {
  for ($i=1;$i<=$n;$i++) {
    $weights[] = $n;
  }
}

上面的数组类似于:[ 1, 2, 2, 3, 3, 3, 4, 4, 4, 4 ... ]

第一次计算:从我们刚刚创建的加权数组中获取一个随机权重

$weight = $weights[mt_rand(0, count($weights)-1)];

第二次计算:从该权重数组中获取一个随机键

$value = $array[$weight][mt_rand(0, count($array[$weight])-1)];

为什么会这样:您通过使用我们创建的加权整数数组来解决加权问题。然后从该加权组中随机选择。


更新:由于每个重量的物品计数可能不成比例,您可以为计数添加另一个循环和数组以提高准确性。

foreach($array as $n=>$null) {
  $counts[$n] = count($array[$n]);
}

foreach($array as $n=>$null) {
  // Calculate proportionate weight (number of items in this weight opposed to minimum counted weight)
  $proportion = $n * ($counts[$n] / min($counts));
  for ($i=1; $i<=$proportion; $i++) {
    $weights[] = $n;
  }
}

如果您有 2000 个 10 和 100 个 1,它将添加 200 个 10(20 * 10、20 因为它的计数是 20 倍,而 10 因为它的权重是 10)而不是 10 个 10与那里有多少反对最小重量计数成正比。因此,准确地说,不是为每个可能的键添加一个,而是根据最小的权重数量来保持比例。

【讨论】:

  • 如果某个重量不存在怎么办?例如。如果原始集没有重量为5 的项目,并且您选择了$weight=5,则下一步将产生错误。当然,有 100000 件物品时,这种场景很少见,但仍然如此。
  • 此外,对于一组[1,1,2,3,...10](在权重意义上),OP 的实现应该以1/56 的概率选择每个1,并以10/56 的概率选择10。在您的实现中,您将以 1/55*1/2=1/110 的概率选择每个 1,并以 10/55*1/1=10/55 的概率选择 10
  • @Passerby,这是我没有想到的好点。我想从这个意义上说,您需要遍历 $array 的第一级来确定 $n 而不是 for 循环。
  • @Passerby,我相信我的修改很好地解决了准确性问题。
【解决方案3】:

我非常感谢上面的答案。请考虑这个答案,它不需要检查原始数组中的每个项目。

// Given $a as an array of items
// where $a[0] is the item name and $a[1] is the item weight.
// It is known that weights are integers from 1 to 100.
for($i=0; $i<sizeof($a); $i++) // Safeguard described below
{
    $item = $a[array_rand($a)];
    if(rand(1,100)<=$item[1]) break;
}

此算法只需要存储两个变量($i 和 $item),因为 $a 在算法启动之前已经创建。它不需要大量重复项或间隔数组。

在最好的情况下,该算法将触及原始数组中的一项并完成。在最坏的情况下,它会触及 n 项数组中的 n 项(不一定是数组中的每一项,因为有些项目可能会被多次触及)。

如果没有保障,这可能会永远运行下去。如果它根本不挑选一个项目,那么安全措施就会停止算法。触发防护措施时,最后触摸的项目是选定的项目。但是,在使用包含 100,000 个随机权重为 1 到 10 的随机数据集(在我的代码中将 rand(1,100) 更改为 rand(1,10))的数百万次测试中,从未触发过保护措施。

我制作了直方图,比较了在我的原始算法、上述答案中的项目和本答案中的项目中选择的项目的频率。频率的差异是微不足道的 - 很容易归因于随机数的差异。

编辑...对我来说很明显,我的算法可以与 pala_ 张贴的算法相结合,从而无需保护。

在pala_的算法中,需要一个列表,我称之为区间列表。为简化起见,您从一个相当高的 random_weight 开始。您逐步减少项目列表并减去每个项目的重量,直到您的 random_weight 降至零(或更少)。然后,您结束的项目是您要退回的项目。我已经测试过这个区间算法的变体,pala_'s 是一个非常好的算法。但是,我想避免列出清单。我只想使用给定的加权列表,从不触及所有项目。以下算法将我对随机跳跃的使用与 pala_ 的间隔列表合并。我没有列出一个列表,而是随机地在列表中跳来跳去。我保证最终会归零,所以不需要任何保障。

// Given $a as the weighted array (described above)
$weight = rand(1,100); // The bigger this is, the slower the algorithm runs.
while($weight>0)
{
    $item = $a[array_rand($a)];
    $weight-= $item[1];
}
// $item is the random item you want.

我希望我能同时选择 pala_ 和这个答案作为正确答案。

【讨论】:

  • 正如您所说,在最坏的情况下,它可能(非常不可能)永远运行 - 并且使用保护措施,它可能根本不会返回值。考虑一个简单的例子(我承认我已经有近 20 年没有接触过概率了)。项目 1,权重 30。项目 2,权重 70。有 50% 的机会选择其中任何一个,然后有 70% 或 30% 的机会满足权重条件。当然这意味着有 35% 的机会选择体重 70,有 15% 的机会选择体重 30,剩下 50% 的机会不得不再次选择?
  • 如果安全措施被击中,最后选择的值是返回的值。但是,我还没有用真实的数据集来保护它。至于你的例子,每轮有 50% 的机会选择一个项目。这意味着在 2 个项目的集合中(这对于该数据集的意图来说太小了),有 75% 的机会根据重量选择一个项目,并且有 25% 的机会选择一个项目随机的。随着数据集变得越来越大,达到保护措施的机会就会缩小。 (续...)
  • (...继续) 考虑你的例子,没有保障。第一轮有 50% 的几率选择物品。在第二轮之后有 75% 的机会选择一个项目。在第三轮之后有 87.5% 的机会选择一个项目。第四轮后有 93.75% 的几率选择项目。如您所见,如果我有一个包含 100,000 个项目的数据集,我会很快选择一个项目,并且命中安全措施的机会非常小。
  • 哦,我毫不怀疑,随着数据的增长,失败的可能性很小,实际上在较小的规模上更糟。
  • 我非常同意。对于较小的数据集,我不会使用此算法。我只将它用于非常大的数据集。我也喜欢你的解决方案。如果我改变它以极大地限制我触摸的物品数量,我会发现在一系列低重量物品之后的高重量物品受到青睐——如果我不尝试限制我触摸的物品数量,情况就不是这样了。这就是为什么我考虑随机跳过整个列表——这就是这个算法的来源。
【解决方案4】:

我不确定这是否“更快”,但我认为它可能在内存使用和速度之间更“平衡”。

想法是将您当前的实现(500000 项数组)转换为等长数组(100000 项),以最低的“原点”位置为键,原点索引为值:

<?php
$set=[["a",3],["b",5]];
$current_implementation=["a","a","a","b","b","b","b","b"];
// 0=>0 means the lowest "position" 0
// points to 0 in the set;
// 3=>1 means the lowest "position" 3
// points to 1 in the set;
$my_implementation=[0=>0,3=>1];

然后随机选择一个介于 0 和最高“原点”位置之间的数字:

// 3 is the lowest position of the last element ("b")
// and 5 the weight of that last element
$my_implemention_pick=mt_rand(0,3+5-1);

完整代码:

<?php
function randomPickByWeight(array $set)
{
    $low=0;
    $high=0;
    $candidates=[];
    foreach($set as $key=>$item)
    {
        $candidates[$high]=$key;
        $high+=$item["weight"];
    }
    $pick=mt_rand($low,$high-1);
    while(!array_key_exists($pick,$candidates))
    {
        $pick--;
    }
    return $set[$candidates[$pick]];
}
$cache=[];
for($i=0;$i<100000;$i++)
{
    $cache[]=["item"=>"item {$i}","weight"=>mt_rand(1,10)];
}
$time=time();
for($i=0;$i<100;$i++)
{
    print_r(randomPickByWeight($cache));
}
$time=time()-$time;
var_dump($time);

3v4l.org demo
3v4l.org 对代码有时间限制,所以演示没有完成。在我的笔记本电脑上,上述演示在 10 秒内完成(i7-4700 HQ)

【讨论】:

    【解决方案5】:

    这是我的提议,以防我没听错。我让你看看,如果有什么问题我会解释。 提前说几句:

    我的样品只有 3 个重量级 - 需要明确 - 当我模拟你的主循环时,我只数到 100。 - 数组必须使用一组初始数字初始化,如我的示例中所示。 - 在主循环的每一次通过中,我只得到一个随机值,并且我完全保持权重。

    <?php
    $array=array(
        0=>array('item' => 'A', 'weight' => 1),
        1=>array('item' => 'B', 'weight' => 2),
        2=>array('item' => 'C', 'weight' => 3),
    );
    $etalon_weights=array(1,2,3);
    $current_weights=array(0,0,0);
    $ii=0;
    while($ii<100){ // Simulates your main loop
        // Randomisation cycle
        if($current_weights==$etalon_weights){
            $current_weights=array(0,0,0);
        }
        $ft=true;
        while($ft){
            $curindex=rand(0,(count($array)-1));
            $cur=$array[$curindex];
            if($current_weights[$cur['weight']-1]<$etalon_weights[$cur['weight']-1]){
                echo $cur['item'];
                $array[]=$cur;
                $current_weights[$cur['weight']-1]++;
                $ft=false;
            }
        }
        $ii++;
    }
    ?>
    

    【讨论】:

    • 我拒绝我的回答,因为@Devon 的方式是最快和最简单的。
    【解决方案6】:

    我将使用这个输入数组来解释:

    $values_and_weights=array(
        "one"=>1,
        "two"=>8,
        "three"=>10,
        "four"=>4,
        "five"=>3,
        "six"=>10
    );
    

    简单的版本不适合你,因为你的数组太大了。它不需要修改数组,但可能需要迭代整个数组,这会破坏交易。

    /*$pick=mt_rand(1,array_sum($values_and_weights));
    $x=0;
    foreach($values_and_weights as $val=>$wgt){
        if(($x+=$wgt)>=$pick){
            echo "$val";
            break;
        }
    }*/
    

    对于您的情况,重新构建数组会带来很大的好处。 生成新数组的内存成本将越来越合理:

    1. 数组大小增加和
    2. 选择数量增加。

    新数组需要通过将前一个元素的权重添加到当前元素的权重来将每个值的“权重”替换为“限制”。

    然后翻转数组,使得界限是数组键,值是数组值。

    选择逻辑是:选择的值将具有>= $pick的最低限制。

    // Declare new array using array_walk one-liner:
    array_walk($values_and_weights,function($v,$k)use(&$limits_and_values,&$x){$limits_and_values[$x+=$v]=$k;});
    
    //Alternative declaration method - 4-liner, foreach() loop:
    /*$x=0;
    foreach($values_and_weights as $val=>$wgt){
        $limits_and_values[$x+=$wgt]=$val;
    }*/
    var_export($limits_and_values);
    

    $limits_and_values 看起来像这样:

    array (
      1 => 'one',
      9 => 'two',
      19 => 'three',
      23 => 'four',
      26 => 'five',
      36 => 'six',
    )
    

    现在生成随机 $pick 并选择值:

    // $x (from walk/loop) is the same as writing: end($limits_and_values); $x=key($limits_and_values);
    $pick=mt_rand(1,$x);  // pull random integer between 1 and highest limit/key
    while(!isset($limits_and_values[$pick])){++$pick;}  // smallest possible loop to find key
    echo $limits_and_values[$pick];  // this is your random (weighted) value
    

    这种方法非常棒,因为isset() 非常快,而while 循环中isset() 调用的最大数量只能与数组中的最大权重(不要与限制混淆)一样多。

    对于您的情况,此方法将在 10 次或更少的迭代中找到价值!

    这是我的Demo,它将接受一个加权数组(如$values_and_weights),然后只需四行:

    • 重构数组,
    • 生成一个随机数,
    • 找到正确的值,然后
    • 显示它。

    【讨论】:

    • @pala_ 因为我们的速度测试只测量选择随机值的时间而不是数组创建本身,我相信我的方法比你的方法快 100 倍(如果不是数千倍)。你能看看我的方法,告诉我你的想法吗? 3v4l.org/FnTBj
    猜你喜欢
    • 2010-09-08
    • 2017-12-26
    • 1970-01-01
    • 2023-03-31
    • 2020-01-22
    • 1970-01-01
    • 2022-01-08
    相关资源
    最近更新 更多