如何随机选择一行并考虑权重?

15

我有一个表格,长这样:

id: primary key
content: varchar
weight: int

我的目标是从这个表中随机选择一行,但要考虑权重。例如,如果我有3行:

id, content, weight
1, "some content", 60
2, "other content", 40
3, "something", 100

第一行有30%的选中几率,第二行有20%的选中几率,第三行有50%的选中几率。

有什么方法可以做到这一点吗?如果我必须执行2或3个查询也没有问题。


3
好的,我会尽力完成翻译。以下是您需要翻译的内容:看这个问题:https://dev59.com/vHVD5IYBdhLWcg3wL4iM - nickf
7个回答

19

我认为最简单的方法实际上是使用加权水塘抽样:

SELECT
  id,
  -LOG(RAND()) / weight AS priority
FROM
  your_table
ORDER BY priority
LIMIT 1;

这是一种很好的方法,可以让您从N个元素中选择M个元素,每个元素被选中的概率与其权重成比例。如果只需要选择一个元素也同样适用。

该方法在这篇文章中有详细描述。请注意,他们选择POW(RAND(), 1/weight)的最大值,这等价于选择-LOG(RAND()) / weight的最小值。


4
这是一个很好的答案!谢谢!我想补充一点:为了避免log(0),是否更优雅一些将log(1-rand())写成日志函数中的形式,因为随机值可能在[0,1]之间(没有检查过)? - Thomas Baruchel
这看起来像是一个不错的方法,但分布可能非常倾斜。我尝试为几行使用权重,其中所有权重都是67或33(即约为2/3或1/3),在我的实例中所有选定的行都具有较高的权重。不知道为什么。 - JosephDoggie

3

我尝试过Van的解决方案,虽然它有效,但速度不够快。

我的解决方案

我解决这个问题的方式是通过维护一个与权重相关联的单独链接表格。基本表结构类似于以下内容:

CREATE TABLE `table1` (
  `id` int(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  `name` varchar(100),
  `weight` tinyint(4) NOT NULL DEFAULT '1',
);

CREATE TABLE `table1_weight` (
  `id` bigint(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  `table1_id` int(11) NOT NULL
);

如果我在table1中有一个权重为3的记录,那么我将创建3个记录在table1_weight中,并通过table1_id字段与table1链接。无论table1中的weight值是多少,我都会在table1_weight中创建相应数量的链接记录。

测试

在一个包含976条记录和总权重为2031的table1数据集上,我运行了以下两个SQL语句:
  1. A version of van's solution

    SELECT t.*
    FROM table1 t
    INNER JOIN
      ( SELECT t.id,
           SUM(tt.weight) AS cum_weight
       FROM table1 t
       INNER JOIN table1 tt ON tt.id <= t.id
       GROUP BY t.id) tc ON tc.id = t.id,
      ( SELECT SUM(weight) AS total_weight
       FROM table1) tt,
      ( SELECT RAND() AS rnd) r
    WHERE r.rnd * tt.total_weight <= tc.cum_weight
    ORDER BY t.id ASC
    LIMIT 1
    
  2. Joining to a secondary table for the weighting

SELECT t.*
FROM table1 t
INNER JOIN table1_weight w
    ON w.table1_id = t.id
ORDER BY RAND()
LIMIT 1

SQL 1一直需要0.4秒完成。

SQL 2耗时在0.01至0.02秒之间。

结论

如果随机加权记录的选择速度不是问题,那么van建议的单表SQL就可以,并且不需要维护单独的表。

如果像我这样,短时间内选择速度很关键,则建议使用两个表的方法。


主要缺点是对于大表的表格大小 :) - Roelant
仅支持高权重,不支持分数权重。 - Jasen

3

这段代码在 MSSQL 中可以运行,我相信只需要更改几个关键词就可以使其在 MySQL 中运行(甚至更好):

SELECT      TOP 1 t.*
FROM        @Table t
INNER JOIN (SELECT      t.id, sum(tt.weight) AS cum_weight
            FROM        @Table t
            INNER JOIN  @Table tt ON  tt.id <= t.id
            GROUP BY    t.id) tc
        ON  tc.id = t.id,
           (SELECT  SUM(weight) AS total_weight FROM @Table) tt,
           (SELECT  RAND() AS rnd) r
WHERE       r.rnd * tt.total_weight <= tc.cum_weight
ORDER BY    t.id ASC

这个想法是为每一行(子查询1)设置一个累积权重,然后找到跨越的RAND()在这个累积范围内的位置。


2

一种简单的方法(避免使用连接或子查询)是将权重乘以介于0和1之间的随机数,以产生一个临时权重来进行排序:

SELECT t.*, RAND() * t.weight AS w 
FROM table t 
ORDER BY w DESC
LIMIT 1
要理解这一点,考虑到RAND() * 2x大约有三分之二的时间会比RAND() * x大。因此,随着时间的推移,每行应该以与其相对权重成比例的频率被选择(例如,具有100个权重的行将被选择约100次,而具有1个权重的行将被选择约1次等)。

更新:实际上,这种方法并不能产生正确的分布,所以目前不要使用它!(请参见下面的评论)。我认为仍然应该有一个类似于上述方法的简单方法可以工作,但目前涉及连接的更复杂的方法可能更好。我保留这个答案是因为:(a)下面的评论中有相关的讨论,(b)如果/当我有机会时,我会尝试修复它。


当您从少量行中选择(最佳2)时,它的效果很好。我需要从50行中随机选择。其中1个权重为32,1个权重为3,48个权重为1,总权重为83。因此,我的32行应该有38.6%的被选择机会,但是使用这种方法,它比所有权重为1的行更容易被选择32次。有没有一种方法可以考虑总权重?谢谢! - fgcarto
1
这在你的情况下不起作用吗?在你的情况下,选择具有32个权重的行的机会应为32/83(0.386或38.6%)。选择具有1个权重的行的机会应为1/83(0.012或1.2%)。但是由于32/83 = 32 * 1/83,因此具有32个权重的物品仍然应比具有1个权重的物品多选择32次! - Nick F
很抱歉,我不理解这里的问题。它肯定应该比其他选项多选择32次吧?这是我的查询的预期行为,但也符合您所期望的:因为38.6 = 32 * 1.2,所以这只是另一种说法,即如果您期望某事发生38.6%的时间,那么“根据定义”,您必须期望它比发生1.2%的事情多发生约32倍。我看不出为什么需要您的临时表。请仔细思考并确保这里真的存在问题! - Nick F
1
我理解你的意思。当然,它应该有32倍的机会被选中,而其他权重为1。我的意思是,在我的脚本中,它被选择的次数比其他所有权重加起来多32倍。在1000次测试中,我有大约960次选择了权重为32的那个,其余40次选择了其他的。根据我的观察,我应该选择它大约386次。我的评论是基于我的观察。 - fgcarto
2
很确定这不会给你期望的分布。考虑一个有3行的权重为80,10和10的数据集。我们期望第一行被选中的概率是80%,其他两行被选中的概率相等,都是20%。如果 rand()*80 > 10,那么我们必须选择第一行。如果 rand()*80 在 [0, 80] 之间均匀分布,超过10的概率是69/81,即85%。它将被过度表示。即使我在这里有一些差一错误。 - Daniel Papasian
显示剩余5条评论

0

这个看起来能用,但我不确定背后的数学原理。

SELECT RAND() / t.weight AS w, t.* 
FROM table t 
WHERE t.weight > 0
ORDER BY 1
LIMIT 1

我猜它能够工作的原因是升序查找最小结果,并通过将较高权重除以来使随机结果更紧密地聚集在零附近。

我进行了测试(实际上是在postgresql中使用相同的算法),对3000行进行了209000个查询,权重表示正确无误。

我的输入数据:

select count(*),weight from t group by weight
 count | weight 
-------+--------
  1000 |     99
  1000 |     10
  1000 |    100
(3 rows)

我的结果:

jasen=# with g as ( select generate_series(1,209000) as i )
,r as (select (  select t.weight as w 
    FROM  t 
    WHERE t.weight > 0
    ORDER BY ( random() / t.weight ) + (g.i*0)  LIMIT 1 ) from g)

select r.w, count(*), r.w*1000 as expect from r group by r.w;

  w  | count | expect 
-----+-------+--------
  99 | 98978 |  99000
  10 | 10070 |  10000
 100 | 99952 | 100000
(3 rows)

+(g.i*0) 对算术结果没有影响,但需要一个外部引用来强制计划程序重新评估在 g 中生成的每个 209K 输入行的子选择。


-1
也许是这个:
SELECT * FROM <Table> T JOIN (SELECT FLOOR(MAX(ID)*RAND()) AS ID FROM <Table> ) AS x ON T.ID >= x.ID LIMIT 1;

或者这个:

SELECT * FROM tablename
          WHERE somefield='something'
          ORDER BY RAND() LIMIT 1

你正在忽略权重,具有更高权重的记录应更频繁地出现在结果中。 - Jasen

-4

我不记得如何在mysql中使用RND()函数,但这里有一个MSSQL的工作示例:

SELECT TOP(1) (weight +RAND ()) r, id, content, weight FROM Table
ORDER BY 1 DESC

如果TOP(1)不适用,您可以从总结果集中获取第一条记录。

这样随机权重就比任何权重都更加重要;-) - Michael Krelin - hacker
好的,它的重量是100,抱歉,但这并不重要;-) - Michael Krelin - hacker
SELECT *, weight*random() as o FROM table ORDER BY o DESC LIMIT 1 是我所指的。 - Michael Krelin - hacker
5
SELECT * FROM table ORDER BY weight*random() DESC LIMIT 1 这个查询语句更加优美简洁,传输的数据量也更少。;-) - Michael Krelin - hacker
Cowan,这个问题被标记为mysql,哈哈。现在我的评论得到了赞,我想知道是否应该在没有更多思考的情况下将其发布为答案。;-) - Michael Krelin - hacker
显示剩余6条评论

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