.NET如何写正确的“抽奖”——数组乱序算法

数组乱序算法常用于抽奖等生成临时数据操作。就拿年会抽奖来说,如果你的算法有任何瑕疵,造成了任何不公平,在年会现场code review时,搞不好不能活着走出去。

这个算法听起来很简单,简单到有时会拿它做面试题去考候选人,但它实际又很不容易,因为细节很重要,稍不留神就错了。

首先来看正确的做法:

T[] ShuffleCopy<T>(IEnumerable<T> data, Random r)
{
var arr = data.ToArray(); for (var i = arr.Length - 1; i > 0; --i)
{
int randomIndex = r.Next(i + 1); T temp = arr[i];
arr[i] = arr[randomIndex];
arr[randomIndex] = temp;
} return arr;
}

可以在LINQPad 6中,使用如下代码,测试随机打乱0-10的数列,进行50万条次模拟统计:

int[] Measure(int n, int maxTime)
{
var data = Enumerable.Range(0, n);
var sum = new int[n]; var r = new Random();
for (var times = 0; times < maxTime; ++times)
{
var result = ShuffleCopy(data, r);
for (var i = 0; i < n; ++i)
{
sum[i] += result[i] != i ? 1 : 0;
}
} return sum;
}

然后可以使用LINQPad特有的报表函数,将数据展示为图表:

Util.Chart(
Measure(10, 50_0000).Select((v, i) => new { X = i, Y = v}),
x => x.X, y => y.Y, Util.SeriesType.Bar
).Dump();

运行效果如下(记住这是正确的示例):

可见50万次测试中,曲线基本平稳,0-10的分布基本一致,符合统计学上的概率相等。

再来看看如果未做任何排序的代码:

T[] ShuffleCopy<T>(IEnumerable<T> data, Random r) => data.ToArray();

曲线:

记住这两条曲线,它们将作为我们的参考曲线。

不然呢?

其实正确的代码每一个标点符号都不能错,下面我将演示一些错误的示例

错误示例1

多年前我看到某些年会抽奖中使用了代码(使用JavaScript错误示例):

[0,1,2,3,4,5,6,7,8,9].sort((a, b) => Math.random() - 0.5)
// 或者
[0,1,2,3,4,5,6,7,8,9].sort((a, b) => Math.random() - Math.random())

返回结果如下:

(10) [8, 4, 3, 6, 2, 1, 7, 9, 5, 0]

看起来“挺”正常的,数据确实被打乱了,这些代码在C#中也能轻易写出来:

T[] ShuffleCopy<T>(IEnumerable<T> data, Random r) =>
data.OrderBy(v => r.NextDouble() < 0.5).ToArray();

50万条数据统计结果如下:

可见,排在两端的数字几乎没多大变化,如果用于公司年会抽奖,那么排在前面的人将有巨大的优势

对比一下,如果在公司年会抽奖现场,大家Code Review时在这时“揭竿而起”,是不是很正常?

为什么会这样?

因为排序算法的本质是不停地比较两个值,每个值都会比较不止一次。因此要求比较的值必须是稳定的,在此例中明显不是。要获得稳定的结果,需要将随机数固定下来,像这样:

T[] ShuffleCopy<T>(IEnumerable<T> data, Random r) => data
.Select(v => new { Random = r.NextDouble(), Value = v})
.OrderBy(v => v.Random)
.Select(x => x.Value)
.ToArray();

此时结果如下(正确):

这种算法虽然正确,但它消耗了过多的内存,时间复杂度为整个排序的复杂度,即O(N logN)

乱个序而已,肯定有更好的算法。

错误示例2

如果将所有值遍历一次,将当前位置的值与随机位置的值进行交换,是不是也一样可以精准打乱一个数组呢?

试试吧,按照这个想法,代码可写出如下:

T[] ShuffleCopy<T>(IEnumerable<T> data, Random r)
{
var arr = data.ToArray(); for (var i = 0; i < arr.Length; ++i)
{
int randomIndex = r.Next(arr.Length); T temp = arr[i];
arr[i] = arr[randomIndex];
arr[randomIndex] = temp;
} return arr;
}

运行结果如下:

有一点点不均匀,我可以保证这不是误差,因为多次测试结果完全一样,咱们拿数据说话,通过以下代码,可以算出所有值的变化比例:

Measure(10, 50_0000).Select(x => (x / 50_0000.0).ToString("P2")).Dump();

结果如下:

0 90.00%
1 90.54%
2 90.97%
3 91.29%
4 91.41%
5 91.38%
6 91.31%
7 90.97%
8 90.60%
9 90.01%

按道理每个数字偏离本值比例应该是90.00%的样子,本代码中最高偏离值高了1.41%,作为对比,可以看看正确示例的偏离比例数据:

0 90.02%
1 90.05%
2 90.04%
3 89.98%
4 90.05%
5 90.04%
6 90.07%
7 90.03%
8 89.97%
9 90.02%

可见最大误差不超过0.05%,相比高达1%的误差,这一定是有问题的。

其实问题在于随机数允许移动多次,如果出现多次随机,可能最终的值就不随机了,可以见这个示例,如果一个窗口使用这样的方式随机画点:坐标x两个随机数相加、坐标y仅一个随机数,示例代码如下:

// 安装NuGet包:FlysEngine.Desktop
using var form = new RenderWindow();
var r = new Random();
var points = Enumerable.Range(0, 10000)
.Select(x => (x: r.NextDouble() + r.NextDouble(), y: r.NextDouble()))
.ToArray();
form.Draw += (o, ctx) =>
{
ctx.Clear(Color.CornflowerBlue);
foreach (var p in points)
{
ctx.FillRectangle(new RectangleF(
(float)p.x / 2 * ctx.Size.Width,
(float)p.y * ctx.Size.Width,
ctx.Size.Width / 100, ctx.Size.Height / 100), form.XResource.GetColor(Color.Black));
}
};
RenderLoop.Run(form, () => form.Render(0, PresentFlags.None));

那么画出来的点可能是这个样子:

可见,1万条数据,x坐标两个随机数相加之后,即使下方代码中除以2了,结果已经全部偏向中间值了(和本例代码效果一样),而只使用一次的y坐标,随机程度正常。想想也能知道,就像扔色子一样,两次扔色子平均是6的机率远比平均是3的机率低。

因此可以得出一个结论:随机函数不能随意叠加

错误示例3

如何每个位置的点只交换一次呢?没错,我们可以倒着写这个函数,首先来看这样的代码:

T[] ShuffleCopy<T>(IEnumerable<T> data, Random r)
{
var arr = data.ToArray(); for (var i = arr.Length - 1; i > 0; --i)
{
int randomIndex = r.Next(i); T temp = arr[i];
arr[i] = arr[randomIndex];
arr[randomIndex] = temp;
} return arr;
}

注意循环终止条件是i > 0,而不是直接遍历的i >= 0,因为r.Next(i)的返回值一定是小于i的,用>=0没有意义,首先来看看结果:

用这个算法,每个数字出来都一定不是它自己本身,这合理吗?听起来感觉也合理,但真的如此吗?

假设某公司年会使用该算法抽奖,那结论就是第一个人不可能中奖,如果恰好你正好是抽奖名单列表的第一个人,你能接受吗?

据说当年二战时期德国的通讯加密算法,就是因为加密之前一定和原先的数据不一样,导致安全性大大降低,被英国破解的。

这个问题在于算法没允许和数字和自己进行交换,只需将r.Next(i)改成r.Next(i + 1),问题即可解决。

总结

所以先回顾一下文章最初算法:

T[] ShuffleCopy<T>(IEnumerable<T> data, Random r)
{
var arr = data.ToArray(); for (var i = arr.Length - 1; i > 0; --i)
{
int randomIndex = r.Next(i + 1); T temp = arr[i];
arr[i] = arr[randomIndex];
arr[randomIndex] = temp;
} return arr;
}

然后重新体会一下它性感的测试数据(10条数据,标准的90%):

只有写完很多个不正确的版本,才能体会出写出正确的代码,每一个标点符号都很重要的感觉。

喜欢的朋友 请关注我的微信公众号:【DotNet骚操作】

.NET如何写正确的“抽奖”——打乱数组算法的更多相关文章

  1. 常用的sort打乱数组方法真的有用?

    JavaScript 开发中有时会遇到要将一个数组随机排序(shuffle)的需求,一个常见的写法是这样: function shuffle(arr) { arr.sort(function () { ...

  2. SQL写操作 设置内容 (数组转字符串)

    SQL写操作 设置内容 (数组转字符串) SQL set内容 SQL操作数组转字符串 SQL写操作 set内容 (数组转字符串) [ 封装方法 ] function getSqlSet( $data ...

  3. [Swift]LeetCode384. 打乱数组 | Shuffle an Array

    Shuffle a set of numbers without duplicates. Example: // Init an array with set 1, 2, and 3. int[] n ...

  4. ShuffleElements(随机打乱数组中的元素)

    给定一个数组,随机打乱数组中的元素,题意很简单直接上代码: package Array; import java.util.Arrays; import java.util.Collections; ...

  5. 打乱数组——shuffle

    在学习vue移动端音乐项目时,看到一个打乱数组函数,感觉很有意思就记录一下(意外发现:slice是个有趣的知识点) 原理:遍历数组,(let i = 0; i < _arr.length; i+ ...

  6. js简易随机打乱数组方法

    打乱随机数算法很多,不过看这个还挺简便的,记录下来. function shuffle(a) { var len = a.length; for(var i=0;i<len;i++){ var ...

  7. js打乱数组的实战应用

    文章首发于: https://www.xiabingbao.com/post/javascript/js-random-array.html 在js中,能把数组随机打乱的方法有很多,每个方法都有自己的 ...

  8. 用C#写一个函数,在一个数组中找出随意几个值相加等于一个值 与迭代器对比

    算法!用C#写一个函数,在一个数组中找出随意几个值相加等于一个值比如,数组{1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20}  要找出那些数相加等 ...

  9. 384 Shuffle an Array 打乱数组

    打乱一个没有重复元素的数组.示例:// 以数字集合 1, 2 和 3 初始化数组.int[] nums = {1,2,3};Solution solution = new Solution(nums) ...

随机推荐

  1. Linux 远程登录命令telnet

    一.telnet简介: telnet命令通常用来远程登录.telnet程序是基于TELNET协议的远程登录客户端程序.Telnet协议是TCP/IP协议族中的一员,是Internet远程登陆服务的标准 ...

  2. 系统定时任务crond

    1.Linux的定时任务:crond(crontab)服务 (1)crond 是什么? crond 是 linux 系统中用来定期执行命令或者指定程序任务的一种服务和软件:crond 服务默认情况(每 ...

  3. K8s 从懵圈到熟练 – 镜像拉取这件小事

    作者 | 声东 阿里云售后技术专家 导读:相比 K8s 集群的其他功能,私有镜像的自动拉取,看起来可能是比较简单的.而镜像拉取失败,大多数情况下都和权限有关.所以,在处理相关问题的时候,我们往往会轻松 ...

  4. Nginx开启Gzip压缩提升页面加载速度

    1.在 nginx 的conf 目录下新建 gzip.conf 文件 #开启gzip压缩 gzip on; #设置允许压缩的页面最小字节数 gzip_min_length 1k; #申请4个单位为16 ...

  5. IOS上传到App Store出现证书未安装问题

    今天在提交自己的APP到苹果商店去审核的时候,编译成功后.upload过程中,提示 XXX Select the certificates you wish to include in this pr ...

  6. LeetCode初级算法--数组01:只出现一次的数字

    LeetCode初级算法--数组01:只出现一次的数字 搜索微信公众号:'AI-ming3526'或者'计算机视觉这件小事' 获取更多算法.机器学习干货 csdn:https://blog.csdn. ...

  7. 如何使用Externalizable接口自定义Java中的序列化

    Java序列化过程的缺点 我们都知道如何使用Serializable接口序列化/反序列化一个对象,并且如何使用writeObject 和readObject方法自定义序列化过程. 但是这些自定义还不够 ...

  8. Java应用在docker环境配置容器健康检查

    在<极速体验docker容器健康>一文已体验了docker容器健康检查功能,今天就来给java应用的容器加入健康检查,使应用的状态随时都可以被监控和查看. 实战环境信息 操作系统:macO ...

  9. 服务器端 SOCKET 编程

    使用 Socket 的程序在使用 Socket 之前必须调用 WSAStartup() 函数, 此函数在应用程序中用来初始化 Windows Socket DLL, 只有此函数调用成功后,应用程序才可 ...

  10. .net工作流引擎ccflow开发平台属性功能的隐藏显示介绍

    关键字: 工作流程管理系统 工作流引擎 asp.net工作流引擎 java工作流引擎. 表单引擎 工作流功能说明  工作流设计 工作流快速开发平台   业务流程管理   bpm工作流系统  java工 ...