O(1) 时间复杂度的抽奖算法 - The Alias Method

news2025/1/10 1:07:10

0 背景

在营销等场景下,有种常见的玩法,即抽奖,不论前端抽奖界面如何炫酷,底层抽奖组件具有一致性。本文不讨论奖池的抽取规则、奖池奖品配置、奖池切换、抽奖机会、奖品扣减和发放、告警和降级等,主要聚焦于抽奖算法。

1 抽奖算法

对于 1.1 和 1.2 中的算法这里作如下约定:

  • 待抽取的奖品有1 部 iphone(10%),3 部 ipad(30%),6 副 airpods(60%)

1.1 暴力算法

最容易想到的就是,所有的物品都放在一个箱子里,然后抽取。以当前的物品为例,按照某种顺序(个数)排列如下:
暴力算法
生成一个 [0, 10) 的随机数,落在哪里就取出对应的奖品,预处理空间复杂度 O(奖品总个数),预处理时间复杂度 O(奖品总个数),生成随机数(角标)取出对应奖品的时间复杂度 O(1)。算法时间复杂度 O(奖品总个数),空间复杂度 O(奖品总个数)。

1.2 离散算法

参看暴力算法的图解,很容易发现相同颜色的代表同一个物品,每一个格子都是一样的内容,那我们考虑消除这些冗余的格子,如果仅仅留下红蓝绿三个格子,那么如果直接随机,我们发现无法保重对应物品的概率。因此我们需要给物品一定的权重,权重就是物品的概率,处理后的数组如下:
![在这里插入图片描述](https://img-blog.csdnimg.cn/2fa766296d5644ce8c56ce577346afc6.png
在这里插入图片描述
现在预处理数组长度就是奖品种类,预处理的数组中的值是前缀和 sum of [0, count(i)) ,现在的规则还是生成一个 [0, 10) 的随机数,记做 R,但是角标无法直接找到对应的奖品。因此需要遍历数组进行比较,找到第一个数组值大于改 R 的位置的奖品即可,为了加快查询速度,可以使用二分查找(upper_bound)。算法时间复杂度 O(奖品种类),空间复杂度 O(奖品种类)。

有没有更快的算法,答案是有!

1.3 The Alias Method

中文名字叫做别名方法或别名采样算法,是一种时间复杂度为 O(1) 的离散采样算法,可以用于抽奖算法。

这里根据上述论文按照图文的方式简单介绍一下,并做简要的论证,详细论证自行参见论文。

这里也是按照论文的例子进行叙述,避免重复作图。

1.3.1 算法的思路起源:

1、让我们产生一组如下概率分布的离散采样: 1 2 、 1 3 、 1 12 、 1 12 \frac {1} {2}、\frac {1} {3}、\frac {1} {12}、\frac {1} {12} 2131121121
然后我们作如下柱状图,概率为其高度,概率和为1,如果我们记宽度为 w,那面积就是 1 w。
在这里插入图片描述

2、现在我们把上图的其余部分填充,使得其成为一个矩形,如图所示:
在这里插入图片描述

3、我们把左边的最大的概率变成1,所有概率乘以2,如图所示
在这里插入图片描述

4、补充每一个列的上方,每一个列都是一个概率为1的事件,我们做两次操作,第一次掷数字范围在 [1, 4] 的骰子决定我去哪一个列,第二次我们产生 0~1 之间的随机数,如果落在有概率的范围内,我们就返回这个事件,但是要注意,如果我们落在概率范围之外,那么我们需要重新采样。比如我们第一次数字是 2,那么我们将有 1/3 的概率失败,需要重新采样,最好情况的时间复杂度是 O(1),最差情况的话时间复杂度是无穷。那平均复杂度是多少呢,可以看到命中的面积时 2w,没有命中的面试是 2w,可以认为命中概率 1 - (1 / 2)^x 中 x = n(物品数)就是一个接近 1 的数字,可以认为平均时间复杂度是 O(n)

在这里插入图片描述

说到这里,我们需要处理连续采样没有命中的情况!这样我们才有可能在 O(1) 时间复杂的范围完成采样。

在上文中,我们有一个东西没有用到,但是我们还给它画了出来,那就是没有命中的那一块区域的面积!

那有没有可能我们把没有命中的地方面积(红色)使用其他命中的面积(非红色)中移过来一点进行填充,那这样岂不是必定会命中某一个事件。

接下来,就是正菜 The Alias Method!

1.3.2 The Alias Method

还是上述柱状图,不过我们使得矩形的宽度 w = 1,得到下图:
在这里插入图片描述

我们对上图的矩形进行伸缩,伸缩尺度为 n = 4(事件个数),得到下图
在这里插入图片描述

我们再次作一个 1 * 4 的矩形,如下图:
在这里插入图片描述

正如你所看,我们考虑将多出的矩形面积用来填充到红色区域,我们将绿色拿出一部分放在灰色上面,如下图所示:
在这里插入图片描述

那现在有个问题,我们能直接把绿色和粉色的对于面积放在黄色上面吗,如果放在上面,我们第三列将会有三个事件,最糟糕情况第三列将会有 N 个事件,N 个事件的概率问题又回到最初的原点,因此我们需要保证每一列不超过两种事件,这样就是一种非 ab 选择,这也是 The Alias Method 需要保证的。所以我们可以使用绿色面积填充到黄色上面。
在这里插入图片描述

然后多余粉色部分填充到绿色上方,这样我们的到一个面积全是 1 且每一列只有两种事件的最终矩形!
在这里插入图片描述

这里采样算法采样过程还是两次,具体不在讲解,很好理解。
在这里插入图片描述

可以看出,The Alias Method 关键在于如何构建 Prob 和 Alias 数组。

构建方法:找出大于 1 的事件(A)和小于 1 的事件(B),然后让其事件 A 将事件 B 的面积补到 1,然后事件 A 面积减去被使用的面积判断事件 A 是否需要补充(< 1),如果不需要,那可以继续作为其他事件的面积的补充,知道所有的事件(列)面积都为 1,构建时间复杂度O(n^2)。

我们可以考虑使用优先队列进行加速,使得时间复杂度降为 O(nlogn)。

存在性证明
那么Alias Table一定存在吗,如何去证明呢?
要证明Alias Table一定存在,就说明上述的算法是能够一直运行下去,直到所有列的面积都为1了为止,而不是会中间卡住。
上述方法每运行一轮,就会使得剩下的没有匹配的总面积减去1,在第n轮,剩下的面积为N-n,如果存在有小于1的面积,则一定存在大于1的面积,则一定可以用大于1的面积那部分把小于1部分给填充到1,这样就进入到了第n+1轮,最后一直到所有列面积都为1。

2 代码实现

2.1 The Alias Method

Alias.h

//
// Created by liyang on 2022/9/4.
//

#ifndef DEMO_ALIAS_H
#define DEMO_ALIAS_H

#include <vector>
#include <queue>

class Alias {
public:
    class Node {

    public:
        int pos;
        double val;

        Node() {}

        Node(int i, double p) {
            this->pos = i;
            this->val = p;
        }

        bool operator<(const Node &node) const {
            return this->val < node.val;
        }

        bool operator>(const Node &node) const {
            return this->val > node.val;
        }
    };

private:
    const double ONE = 1;
    int N;
    bool generated = false;
    std::vector<double> initialProbList;
    std::vector<double> probList;
    std::vector<int> aliasList;
    std::priority_queue<Node, std::vector<Node>, std::less<Node>> maxQue;        // 大顶堆
    std::priority_queue<Node, std::vector<Node>, std::greater<Node> > minQue;    // 小顶堆


public:
    Alias(std::vector<double> p);

    void generateAliasTable();
    int sampling();
    bool myEqual(double a, double b);
    void myPush(Node& node);
    long getTimeNs();

};


#endif //DEMO_ALIAS_H

Alias.cpp

//
// Created by liyang on 2022/9/4.
//

#include "Alias.h"
#include <vector>
#include <cstdlib>
#include <ctime>

using namespace std;

void Alias::generateAliasTable() {
    for (int i = 0; i < N; ++i) {
        probList.push_back(Alias::initialProbList[i] * N);
        myPush(*new Node(i, probList[i]));
    }

    Node bigNode, smallNode;
    while (!maxQue.empty() && !minQue.empty()) {
        bigNode = maxQue.top();
        maxQue.pop();

        smallNode = minQue.top();
        minQue.pop();

        double diff = ONE - smallNode.val;
        probList[bigNode.pos] -= diff;
        bigNode.val -= diff;

        aliasList[smallNode.pos] = bigNode.pos;

        myPush(bigNode);
    }

    generated = true;
}

int Alias::sampling() {
    if (!generated) {
        generateAliasTable();
    }
    int i = (int) rand() % N;
    double r = ((double) rand() / (RAND_MAX)); // double [0, 1)
    if (r < probList[i]) {
        return i;
    }
    return aliasList[i];
}

bool Alias::myEqual(double a, double b) {
    double smallDiff = 0.000001;
    return a >= b ? a - b <= smallDiff : b - a <= smallDiff;
}

Alias::Alias(std::vector<double> p) {
    this->initialProbList = p;
    this->N = initialProbList.size();
    this->aliasList = vector<int>(N, -1);
    // set rand seed
    srand(getTimeNs());
}

void Alias::myPush(Node &node) {
    if (myEqual(node.val, ONE)) {
        return;
    }
    if (probList[node.pos] > ONE) {
        maxQue.push(node);
    } else if (probList[node.pos] < ONE) {
        minQue.push(node);
    }
}

long Alias::getTimeNs() {
    struct timespec ts;
    clock_gettime(CLOCK_REALTIME, &ts);
    return ts.tv_sec * 1000000000 + ts.tv_nsec;
}

测试下 The Alias Method 算法

main.cpp


#include <iostream>
#include "Alias.h"
#include <unordered_map>

using namespace std;

int main() {

    vector<string> prizeList{"airpods", "ipad", "iphone", "mac"};
    vector<double> probList{1.0 / 2.0, 1.0 / 3.0, 1.0 / 12.0, 1.0 / 12.0};
    unordered_map<string, string> nameProbMap;
    nameProbMap["airpods"] = "0.50";
    nameProbMap["ipad"] = "0.33333";
    nameProbMap["iphone"] = "0.08333";
    nameProbMap["mac"] = "0.08333";


    Alias *aliasObj = new Alias(probList);
    int pos;
    const int testCnt = 1000000;
    unordered_map<string, int> um;
    for (int i = 0; i < testCnt; ++i) {
        pos = aliasObj->sampling();
        um[prizeList[pos]]++;
    }


    cout << "总抽奖次数:" << testCnt << endl;
    for (auto e: um) {
        cout << "奖品名字:" << e.first << ",抽中次数:" << e.second;
        cout << ",抽中概率:" << e.second * 1.0 / (testCnt * 1.0);
        cout << ", 理论抽奖概率:" << nameProbMap[e.first] << endl;
    }

    return 0;
}

结果:
在这里插入图片描述

2.2 Vose’s Alias Method

那有没有更快的 Initialization Time,事实上,我们并不需要每次找到最小的和最大的,我们只要找到一个大于1,一个小于1的即可,所以我们维护两个 worklist,存放大于1的称作 large worklist,存放小与1的称为 small worklist,然后不断使用 large 去填补 small 即可,这里我们存角标,具体见下图:
在这里插入图片描述

论文中使用栈实现,因为给予栈可以方便的使用数组实现,但我这里直接使用队列实现,因为我有 STL,开箱即用!

代码如下:
Alias.h

    /**
     * Vose's Alias Method
     * liyang 20230414
     */
private:
    std::queue<int> small;
    std::queue<int> large;

public:
    void generateVoseAliasTable();
    void myVosePush(int index);
    int voseSampling();


void Alias::myVosePush(int index) {
    if (myEqual(probList[index], ONE)) {
        return; // todo:直接 set one
    }
    if (probList[index] > ONE) {
        large.push(index);
    } else { // if (probList[index] < ONE)
        small.push(index);
    }

}

Alias.cpp

void Alias::myVosePush(int index) {
    if (myEqual(probList[index], ONE)) {
        return; // todo:直接 set one
    }
    if (probList[index] > ONE) {
        large.push(index);
    } else { // if (probList[index] < ONE)
        small.push(index);
    }

}

void Alias::generateVoseAliasTable() {
    for (int i = 0; i < N; ++i) {
        probList.push_back(Alias::initialProbList[i] * N);
        myVosePush(i);
    }

    int l, g;
    while (!small.empty() && !large.empty()) {
        l = small.front();
        small.pop();
        g = large.front();
        large.pop();
        aliasList[l] = g;
        probList[g] -= (ONE - probList[l]);
        myVosePush(g);
    }

    generated = true;
}

int Alias::voseSampling() {
    if (!generated) {
        generateVoseAliasTable();
    }
    int i = (int) rand() % N;
    double r = ((double) rand() / (RAND_MAX)); // double [0, 1)
    if (r < probList[i]) {
        return i;
    }
    return aliasList[i];
}

可以看到我并没有实现红色圈中的步骤,因为我通过myVosePush调用myEqual的时候就考虑了浮点数的精度问题,因此这一步在这就没有必要了。

bool Alias::myEqual(double a, double b) {
    double smallDiff = 0.000001;
    return a >= b ? a - b <= smallDiff : b - a <= smallDiff;
}

将调用改成 pos = aliasObj->voseSampling();,测试下结果:
在这里插入图片描述

3 总结

使用 Vose‘s Alias Method,我们可以实现初始化为 O(n) 的算法,采样 O(1) 时间复杂度,空间负责度 O(n)。
如果抽奖的奖品不考虑库存,那么可以抽奖算法就是 O(1) 事件复杂度。每次考虑库存,抽奖物品的种类变动,直接动态生成即可。
在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/440860.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Nginx之TCP/UDP反向代理

Nginx从1.9.13起开始发布ngx_stream_core_module模块不仅能支持TCP代理及负载均衡,其实也是支持UDP协议的。 1.Nginx下载 wget http://nginx.org/download/nginx-1.24.0.tar.gz 2.Nginx安装 #yum -y install proc* openssl* pcre* # tar -zxvf nginx-1.24.0.tar.gz #cd n…

【Docker学习三部曲】—— 核心篇

容器数据卷 基本概念 容器数据卷是 Docker 中用于持久化存储容器数据的一种解决方案它允许容器中的数据在容器重新创建或迁移时得以保留&#xff0c;而不会丢失数据卷可以看作是 Docker 主机和容器之间的一个共享目录容器可以将数据写入数据卷&#xff0c;而这些数据将储存在…

【Jpom】docker-compose 部署 RabbitMQ 3.11.X (包含延迟队列插件)

文章目录 前言参考目录前置准备系统版本软件版本 部署步骤1、Jpom 配置节点信息2、Dockerfile 文件3、插件上传4、修改 docker-compose.yml5、构建 Dockerfile&#xff08;可选&#xff09;6、执行 docker-compose 编排7、Jpom 查看 Docker8、登录 RabbitMQ9、直接执行 docker-…

OrCAD原理图检查

OrCAD原理图检查 FPGA或处理器芯片原理图封装检查OrCad元件Part Reference与Reference位号不同检查所有器件是否与CIS库元件匹配用CIS库中的元器件替换已存在器件方法1方法2 DRC检查修改页码Annotate重排位号利用Intersheet References功能进行off-page索引检查封装、厂家、型号…

[数据结构 - C语言] 顺序表

目录 1、线性表 2、顺序表 2.1 顺序表的概念 2.2 接口 3、接口实现 3.1 初始化 3.2 销毁 3.3 容量检测 3.4 打印数据 3.5 顺序表的头插 3.6 顺序表的尾插 3.7 顺序表的头删、尾删 3.8 顺序表查找 3.9 指定位置插入数据 1、线性表 线性表&#xff08;linear list&…

认识HTTPS以及了解HTTPS的加密过程

目录 简单认识HTTPS&#xff1a; 运营商劫持&#xff1a; 加密的理解&#xff1a; HTTPS的工作过程&#xff1a; 对称加密&#xff1a; 非对称加密&#xff1a; 中间人攻击 证书 简单认识HTTPS&#xff1a; HTTPS 也是一个应用层协议。是在 HTTP 协议的基础上引…

逆向-还原代码之(*point)[4]和char *point[4] (Interl 32)

// source code #include <stdio.h> #include <string.h> #include <stdlib.h> /* * char (*point)[4] // 数组指针。 a[3][4] // 先申明二维数组,用它来指向这个二维数组 * char *point[4] // 指针数组。 a[4][5] // 一连串的指针…

客快物流大数据项目(一百一十六):远程调用 Spring Cloud Feign

文章目录 远程调用 Spring Cloud Feign 一、​​​​​​​简介

OpenGL入门之 深入三角形

一、引言 本教程使用GLEW和GLFW库。  通过本教程&#xff0c;你能轻松的、深入的理解OpenGL如何绘制一个三角形。  如果你不了解OpenGL是什么&#xff0c;可以阅读OpenGL深入理解。 二、基本函数和语句介绍 通过阅读以下的函数&#xff0c;你的大脑里能留下关于OpenGL基本函…

【每日一题Day184】LC1187使数组严格递增 | dp

使数组严格递增【LC1187】 给你两个整数数组 arr1 和 arr2&#xff0c;返回使 arr1 严格递增所需要的最小「操作」数&#xff08;可能为 0&#xff09;。 每一步「操作」中&#xff0c;你可以分别从 arr1 和 arr2 中各选出一个索引&#xff0c;分别为 i 和 j&#xff0c;0 <…

前端学习:HTML块、类、Id

目录 快 一、块元素、内联元素 二、HTML 元素 三、HTML元素 类 一、分类块级元素 二、分类行内元素 Id 一、使用 id 属性 二、 class与ID的差异 三、总结 快 一、块元素、内联元素 大多数HTML元素被定义为块级元素或内联元素。 块级元素在浏览器显示时&#xff0c;通常会…

Docker常用命令详解,有这些足够了

首先启动类 启动docker&#xff1a;systemctl start docker 停止docker&#xff1a;systemctl stop docker 重启docker&#xff1a;systemctl restart docker 查看docker状态&#xff1a;systemctl status docker 开机自启动&#xff1a;systemctl enable docker 查看docker概要…

【CocosCreator入门】CocosCreator组件 | Widget(对齐)组件

Cocos Creator 是一款流行的游戏开发引擎&#xff0c;具有丰富的组件和工具&#xff0c;其中的Widget组件用于UI布局和调整&#xff0c;可以通过调整Widget组件来实现UI元素的自适应和排版。 目录 一、组件介绍 二、组件属性 三、组件使用 四、脚本示例 一、组件介绍 在Coc…

Python中的统计学(二)

大数定律和中心极限定律都是概率论中重要的定理。它们之间的不同在于它们所涉及的随机变量和极限的不同。 大数定律是指随着样本容量的增大&#xff0c;样本均值越来越接近于总体均值的定律。即样本均值的极限等于总体均值&#xff0c;也就是说&#xff0c;当样本量足够大时&a…

绝了!!PDF转换没想到这么简单

PDF处理是很多小伙伴的“痛”&#xff0c;在工作学习中&#xff0c;PDF转换、PDF编辑、PDF和图片的各种问题都是需要快速解决的&#xff0c;但市面上不少付费的软件让我们很是肉痛&#xff01; 今天给大家推荐5个免费的神仙PDF转换网站&#xff0c;解决你的所以PDF问题~ 记得…

Simulink 自动代码生成电机控制:硬件开发板系统介绍

目录 前言 电源电路 MCU电路 开发板接口 关于电流采样和过流保护 驱动部分 总结 前言 在介绍开发板之前突然有感而发想多说两句&#xff0c;本人从事电控行业也是有一些年头了&#xff0c;除了刚刚毕业就接触的电机控制外&#xff0c;就是电源控制相关的&#xff0c;像三相P…

Point-to Analysis指针分析(2)

https://blog.csdn.net/qq_43391414/article/details/111046505 下面介绍一种新的指针分析的算法Steensgaard算法&#xff0c;并将其与上一篇文章介绍 Steensgaard算法 不同于Andersen算法,Steensgaard在前者的基础上&#xff0c;再次对问题进行了简化&#xff0c;从而指针分析…

远程访问及控制

目录 一、SSH远程管理 1&#xff09;SSH的简介 2&#xff09;SSH的优点 3&#xff09;常用的SSH软件的介绍 4&#xff09;SSH 的组成 5&#xff09;SSH的密钥登录 密钥登录的过程&#xff1a; 二、SSH的运用 1 &#xff09;SSH配置文件信息 2&#xff09;存放ssh服务…

JAVA 进程CPU过高排查

1. top命令看一下JAVA进程&#xff1a; 占用500%多&#xff0c;非常恐怖&#xff0c;程序卡得动不了了。 2. 使用命令top -H -p PID 此处PID就是上一步获取的进程PID&#xff0c;我的PID是13342&#xff0c;通过此命令可以查看实际占用CPU最高的的线程的ID&#xff0c;此处几位…

ChatGPT+Ai绘图【stable-diffusion实战】

ai绘图 stable-diffusion生成【还有很大的提升空间】 提示词1 Picture a planet where every living thing is made of light. The landscapes are breathtakingly beautiful, with mountains and waterfalls made of swirling patterns of color. What kind of societies m…