受限加权选择

6
我有一个加权选择算法是有效的,但我想在两个方面改进它(按重要性顺序):
  1. 保证每个可能的选择都选取了最少数量。
  2. 将计算复杂度从 O(nm) 降至 O(n)O(m),其中 n 是请求随机选择项目的数量,m 是可用项目的类型。
编辑: 我的目的通常需要的请求数量很小(小于100)。因此,具有复杂度 O(t)O(t+n) 的算法,其中 t 是总项目数,通常比 O(nm) 表现更差,因为 O(t) > O(m)
简化代码:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Security.Cryptography;

public class Program
{
    static void Main(string[] args)
    {
        // List of items with discrete availability
        // In this example there is a total of 244 discrete items and 3 types, 
        // but there could be millions of items and and hundreds of types. 
        List<Stock<string>> list = new List<Stock<string>>();
        list.Add(new Stock<string>("Apple", 200));
        list.Add(new Stock<string>("Coconut", 2));
        list.Add(new Stock<string>("Banana", 42));

        // Pick 10 random items
        // Chosen with equal weight across all types of items
        foreach (var item in Picker<string>.PickRandom(10, list))
        {
            // Do stuff with item
            Console.WriteLine(item);
        }
    }
}

// Can be thought of as a weighted choice
// where (Item Available) / (Sum of all Available) is the weight.
public class Stock<T>
{
    public Stock(T item, int available)
    {
        Item = item;
        Available = available;
    }
    public T Item { get; set; }
    public int Available { get; set; }
}

public static class Picker<T>
{
    // Randomly choose requested number of items from across all stock types
    // Currently O(nm), where n is requested number of items and m is types of stock
    // O(n) or O(m) would be nice, which I believe is possible but not sure how
    // O(1) would be awesome, but I don't believe it is possible
    public static IEnumerable<T> PickRandom(int requested, IEnumerable<Stock<T>> list)
    {
        // O(m) : to calcuate total items,
        // thus implicitly have per item weight -> (Item Available) / (Total Items)
        int sumAll = list.Sum(x => x.Available);

        // O(1)
        if (sumAll < requested)
            throw new ArgumentException("Requested amount must not exceed total available");

        // O(1)
        Random rng = new Random(Seed());

        // O(n) for the loop alone : O(nm) total
        for (int n = 0; n < requested; n++)
        {
            // O(1) to choose an item : uses implicit ordering
            int choice = rng.Next(1, sumAll);
            int current = 0;

            // O(m) to find the chosen item
            foreach (Stock<T> stock in list)
            {
                current += stock.Available;

                if (current >= choice)
                {
                    yield return stock.Item;

                    // O(1) to re-calculate weight once item is found
                    stock.Available -= 1;
                    sumAll--;

                    break;
                }
            }
        }
    }

    // Sufficiently random seed
    private static int Seed()
    {
        byte[] bytes = new byte[4];
        new RNGCryptoServiceProvider().GetBytes(bytes);
        return bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3];
    }
}

函数PickRandom()使用yield returnIEnumerable,但并不是必需的。我最初编写该函数时只是想聪明一些,以便可以迭代任何东西(甚至来自LINQ到SQL查询的可枚举对象)。后来,我发现虽然灵活性很好,但我从未真正需要它。
解决问题#1(保证选择每个可能选择的最小数量)的第一个想法是以完全非随机的方式选择每个类型所需的最小值,使用现有算法选择剩余的无限制部分,然后将结果混合在一起。这似乎是最自然的,并模拟了我在现实生活中做此类事情的方式,但我认为这不是最有效的方法。
我的第二个想法是先创建结果数组,首先随机选择填充所需最小值的索引,然后使用现有算法填充其余部分,但在所有尝试中,这都会增加“大O”复杂度或记录索引的混乱大乱炖。我仍然认为这种方法是可行的,但我还没有能够解决它。
然后我决定来这里,因为这个问题似乎可以抽象成一个相当通用的算法,但我使用的所有关键字通常指向基本的加权随机数生成(而不是选择具有特定可用性的类型组中的离散项,并且每个项目类型都至少选择一项)。我还没有能够找到任何限制带有每个项目类型的最小选择的问题,同时仍然保持随机化的内容。因此,我希望聪明的人可以看到一个简单高效的解决方案,或者听说过这个问题的人知道比我更好的关键字,并可以指引我正确的方向。

1
请查看http://eli.thegreenplace.net/2010/01/22/weighted-random-generation-in-python/,了解不同方法的比较。我的答案类似于*wrg*方法,似乎表现最佳。 - dtb
感谢提供链接,它很好地展示了加权数字生成的不同方法。不幸的是,它没有展示如何确保最小选择,或者如何在每次连续选择后考虑移动权重(这是离散项目加权选择和简单加权随机数之间的关键区别)。 - Sybeus
这与https://dev59.com/D3I95IYBdhLWcg3wzhd9非常相似。 - Jason Orendorff
2个回答

2
这是一个大概的想法,我相信它可以进一步改进:
1.假设每个可用物品都有一个唯一的ID,范围在[0..sumAll)之间,其中sumAll是可用物品的数量。因此,第一个苹果的ID为0,最后一个苹果的ID为199,第一个椰子的ID为200,依此类推。确定sumAll和每种类型的子范围是O(m),其中m是类型数量。
2.随机选择一个ID(所有ID的权重相同)。重复此步骤,直到您获得10个不同的ID。这是O(n),其中n是要选择的物品数量。
3.使用二分查找确定选择的每个ID的类型。这是O(n log m)。
4.从可用物品中删除被选定的物品。这是O(m)。
为了保证每种类型选择的物品数量达到最小值,在步骤1之前选择它们并将它们从可用物品中删除似乎是一个好主意。这是O(m)。

我认为你的解决方案中存在一个微妙的问题。在第二步中,当我选择一个随机ID后,该类型可选择的数量就会减少一个,这意味着我必须重新计算第一步中的范围,以便在下一次迭代中执行二分搜索。这意味着我回到了_O(nm)_,没有任何收益。 - Sybeus
这可以通过选择10个不同的ID来实现。没有必要逐个从可用项目中减去每个选定的项目;您可以在最后为所有选定的项目一起执行该操作。 - dtb
@dtb 避免删除是消除额外复杂性的好方法;问题在于,在一般情况下,考虑到问题中数据的总和为244,从这个列表中选择240个项目将需要很长时间,因为“可用项目”的大小会随着总范围的比例而减小。一个选项是在遇到一定数量的重复后重新计算权重...但这不会改变算法的大O。 - Kirk Broadhurst
没错,但可用项的数量比选定项的数量要大得多,因此碰撞的概率应该非常低。 - dtb
鉴于我的需求相对较小,这种方法可能有优点。我是否应该保留已选择的 ID 列表,并在出现重复时重新选择?我只是试图想一想,如果说所有2个椰子都被选择了,那么其中一个再次被选中,会是怎样的情况。我不想在遇到重复时就转向下一个股票类型,因为那会增加额外的负担。 - Sybeus
显示剩余2条评论

0

好问题。我认为O(mn)是基本情况,因为对于每个n(物品数量),您需要重新评估权重(这是O(m))。

Picker类似乎总是返回相同的类型 - 您在此处不会混合Stock类型。在您的示例中,Stock<string>。因此,Picker类可以将所有库存展平为单个列表 - 内存效率较低,计算效率更高。

public static IEnumerable<T> PickRandom(int requested, IEnumerable<Stock<T>> list)
{
    var allStock = list.SelectMany(item => 
        Enumerable.Range(0, item.Available).Select(r => item.Item)).ToList();

    Random rng = new Random(); 

    for (int n = 0; n < requested; n++) 
    { 
        int choice = rng.Next(0, allStock.Count - 1);

        var result = allStock[choice];
        allStock.RemoveAt(choice);

        yield return result;
    }  
}

这里的损失在于您没有修改原始的Stock对象,但这是您可以进行的实现(您的示例显示Stock对象被创建为Picker的匿名参数)。

编辑

这是另一个与您现有代码非常相似的示例。它将创建一个字典,您可以在其中查找您的选择。但是,字典(控制加权)需要在每次选择后重新评估,导致O(mn)。

public static IEnumerable<T> PickRandom(int requested, IEnumerable<Stock<T>> list)
{
    Random rng = new Random();
    for (int n = 0; n < requested; n++)
    {
        int cumulative = 0;
        var items = list.ToDictionary(item => 
            new { Start = cumulative, End = cumulative += item.Available });

        int choice = rng.Next(0, cumulative - 1);
        var foundItem = items.Single(i => 
            i.Key.Start <= choice && choice < i.Key.End).Value;

        foundItem.Available--;
        yield return foundItem.Item;
    }
}

从逻辑上讲,是否可能在不考虑所有类别的情况下重新评估权重?


我同意每个_n_选择的权重可能需要重新评估;但是,我不同意每次重新评估都需要_O(m)_。事实上,我的现有解决方案比这更好。第一次评估是_O(m)_,然后每次迭代重新评估时使用stock.Available -= 1; sumAll--;,这是一组_O(1)_操作。如果找到了足够聪明的改进来选择库存,而不是必须循环_m_次来找到它,则有机会保留此属性并降低复杂度(dtb的答案几乎做到了这一点)。 - Sybeus
在这种方法在计算上产生好处之前,我需要更大的数据集,但到那时,由于正在执行内存分配,它可能会变慢。让我们称项目数为_t_。_t_将始终大于_m_,因为_m_是_t_的分组。因此O(t)> O(m)。我应该在我的原始问题中添加的一些内容是,对于我的目的,n通常很小(小于100); 因此,即使有1,000,000个项目,1,000个类型和100个请求的对象,O(mn)在计算上仍然可以击败O(t + n)。如果我能找到一个O(m+n)或O(n log m)的解决方案,那么就没有争议了。 - Sybeus
@Sybeus - 你现有的解决方案是O(mn),正如你所指出的。每个选择的复杂度为O(n),然后每个选择需要O(m)来找到适当的组。我无法看出你的解决方案如何比每次选择后重新评估权重的O(m)更好。如果我错了,我很乐意接受指正! - Kirk Broadhurst
我理解您所提到的关于数据集大小和挑选物品数量的观点;在回答时我还不知道这一点 ;) - Kirk Broadhurst
是的,显然调整权重的算术运算为O(1)。很抱歉让你感到困惑 - 也许我的表述不够清晰。我考虑的是查找类别和调整权重这个更大的组件,正如你在问题中指出的// O(m) to find the chosen item foreach (Stock<T> stock in list)。你的算法,正如你已经注意到的那样,是O(mn)的。 - Kirk Broadhurst
显示剩余2条评论

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