说明:写作本文的出发点是最近和一个有3年开发经验的.NET开发人员聊天,他跟我说经常没有思路,在实际开发中我也见过一个具有4、5年开发经验的开发人员几乎没有灵活变通的能力,所以打算写一系列文章,在这个系列文章中我会主要讲解解题的思路,而不是讲述什么新技术新特性,借这个系列文章为初中级开发者了解遇到问题别人是如何思考和解决的。当然,如果你的思路比本文提到的更好,欢迎指出来,同时如果你对本系列文章有更好的建议或者有日常中的一些典型问题,请给我联系,我们共同探讨。目前我暂时能想到的有不重复随机数产生问题、字符串与数值转换的问题、特殊的数据库锁问题、访客来路追踪问题、在线用户统计问题、统计用户访问页面偏好问题。
好了,我现在开始本篇的讲述。本篇的最原始形态是来源于我早年做的一个Java SE应用软件,它是用来模拟彩票投注站的选好软件的。应为在早年Java SE中用swing做界面布局是一件比较痛苦的事情,所以后来我重新用C#做了一个。这个问题的原型就是解决双色球随机选号的问题,我们知道双色球红色球共包含1到33这33个红色号码球及1到16这16个蓝色号码球,一注双色球号码应包括6个红色球号码和1个蓝色球号码。蓝色号码球很好解决,随机从1到16这16个数字中随机选取一个就行了。但是红色球就存在这样一样问题,每次选取的红色球不能与本注中已经选取的号码重复,这个问题归结为生成不重复随机数问题。
在本篇我就怎么生成不重复的红色球展开讨论。
解题思路一
在早期的Java中不包含泛型,只能使用ArrayList,所以我是用ArrayList来实现的。在Java中的ArrayList和C#中的ArrayList在用法上是很相似的(这就是为什么高手经常说掌握一门语言之后再去掌握另一门语言是很容易的事情,应为思想是相通的,呵呵)。在这里我最想想到的就是使用循环,每次循环中随机生成一个随机数,判断一下这个随机数是否已经在本注中使用,如果没有使用就将这个号码保存到结果中去,反之则进行下一轮循环,循环的结束条件就是生成了满足要求的6个数字。接触到泛型之后,我知道在这里我所使用的数据类型是int类型(当然也可以使用byte类型),如果使用ArrayList保存int这样的值类型数据会存在着装箱和拆箱操作,带来不必要的性能损失,所以针对这种集合中数据类型单一的情况可以考虑泛型集合,于是得到了下面的代码:
- /// <summary>
- /// 从1到33中任意选取不重复的6个随机数
- /// </summary>
- /// <returns></returns>
- public List<int> GenerateNumber1()
- {
- //用于保存返回的结果
- List<int> result = new List<int>(6);
- Random random = new Random();
- int temp = 0;
- //如果返回的结果集合中实际的元素个数小于6个
- while (result.Count < 6)
- {
- //在[1,34)区间任意取一个随机整数
- temp = random.Next(1, 34);
- if (!result.Contains(temp))
- {
- //如果在结果集合中不存在这个数,则添加这个数
- result.Add(temp);
- }
- }
- //result.Sort();//对返回结果进行排序
- return result;
当然,上面这种思路是可以实现的,但是每次随机生成一个随机数都要判断在结果集合中是否已经存在这个数,如果存在还要继续下一个循环,这样一来并不是每一轮循环都能生成一个有效(即不重复)的随机数,并且result.Contains(temp)尽管看起来只有一句,但实际在内部还是要通过循环来判断,效率还是较低。假如有一天有个人看到这篇文章,他想:很好,我终于可以试试了,我要从1到10000个数中取出9999个不重复的随机数,用上面的这个方法,可能很长时间都得不到结果(不要以为没有这样的人,我就遇见多多次不会变通的人,实际上最好的解决办法就是从10000中随机去掉一个就可以了,而不是照搬上面的套路)。
解题思路二
刚才说到在方法一中并不是每一轮循环都能生成一个有效的、不重复的随机数,那么有没有这样的办法,保证每一轮循环都能生成一个有效地、不重复的随机数呢?答案是有的。
具体做法是这样的,我们将初始化一个容器集合,在这个容器集合中包含了所有可能的值,然后每次随机从这个容器集合中随机选取一个值保存到结果集合中去,之后我们就从容器集合中将这个已经使用过的值删除掉,然后再进行下一轮的循环。既然都已经从容器集合中删除掉了,自然在下一轮循环中随机从容器集合中取一个值,这个值自然不会重复了。因为这个集合的容量是可变的,那么自然也是使用泛型集合了,代码如下:
- /// <summary>
- /// 从1到33中任意选取不重复的6个随机数
- /// </summary>
- /// <returns></returns>
- public List<int> GenerateNumber2()
- {
- //用于存放1到33这33个数
- List<int> container = new List<int>(33);
- //用于保存返回结果
- List<int> result = new List<int>(6);
- Random random = new Random();
- for (int i = 1; i <= 33; i++)
- {
- container.Add(i);
- }
- int index = 0;
- int value = 0;
- for (int i = 1; i <= 6; i++)
- {
- //从[0,container.Count)中取一个随机值,保证这个值不会超过container的元素个数
- index = random.Next(0, container.Count);//谢谢热心朋友指出这里的错误
- //以随机生成的值作为索引取container中的值
- value = container[index];
- //将随机取得值的放到结果集合中
- result.Add(value);
- //从容器集合中删除这个值,这样会导致container.Count发生变化
- container.RemoveAt(index);
- //注意这一句与上面一句能达到同样效果,但是没有上面一句快
- //container.Remove(value);
- }
- //result.Sort();排序
- return result;
- }
经过这么一改动,确实能做到每次循环都能生成一个唯一的有效的不重复的数,这样一来就能做到在M个数中选取N个数时只需要循环M×N次就可以了(M>N,并且都是正整数)。不过也有人会说,我现在还在维护一个.NET1.1的项目,我这里也有类似的需求,可是在.NET2.0以下版本中是没有泛型的,你能给我想个办法吗?我的答案是可以的。
解题方法三
在这个方法中我们不适用泛型集合,只使用数组,这样一来这种做法就可以适用于C、Java、PHP等语言和.NET1.1中了。
思路如下,首先使用一个数组作为存储所有可能值的容器集合,然后通过循环每次生成一个随机值,这个值将来会作为下标来访问容器集合中的数值。因为数组是不可变集合,我们不能将已经使用数值从数组中删除,并且它们是简单的数据类型我们不可能给每个数值增加一个属性表示数值是否已经被使用过了,那该怎么办呢?办法就是每次从可用的下标集合中随机生成一个值,然后以这个值作为索引从容器集合中得到相应的值保存到结果集合中,除此之外再将这个已经使用过的值与数组中最后一个没有使用到的值互换位置,然后下一轮再在所有没有使用过的值中重新再取一个值。代码如下:
- public int[] GenerateNumber3()
- {
- //用于存放1到33这33个数
- int[] container = new int[33];
- //用于保存返回结果
- int[] result = new int[6];
- Random random = new Random();
- for (int i = 1; i <= 33; i++)
- {
- container[i - 1] = i;
- }
- int index = 0;
- int value = 0;
- for (int i = 0; i < 6; i++)
- {
- //从[1,container.Count + 1)中取一个随机值,保证这个值不会超过container的元素个数
- index = random.Next(1, container.Length-1-i);
- //以随机生成的值作为索引取container中的值
- value = container[index];
- //将随机取得值的放到结果集合中
- result[i]=value;
- //将刚刚使用到的从容器集合中移到末尾去
- container[index] = container[container.Length - i-1];
- //将队列对应的值移到队列中
- container[container.Length - i-1] = value;
- }
- //result.Sort();排序
- return result;
- }
这样一来,问题得到了解决了。这种做法也可以移植到不支持泛型的版本或者语言当中。
再来一点变动
上面我们处理的都是连续的情况,假如万一让我们在不连续的集合中随机选择5个不重复的,比如在某个班50个学生中随机抽取5个学生来,貌似上面的做法不行了?其实不然,依然可以沿用这种思路,比如使用上面的第三种办法即GenerateNumber3(),无非就是声明一个字符串数组,在这个字符串数组中存放全班所有同学的姓名,然后按照下标来随机取5个姓名即可(具体代码这里省略)。
不过对这种情况还有不同的,比如在快女和春晚中都有短信投票的环节,最后会从所有发送短信的手机号中随机抽取几个手机号码作为中奖号码(为了简化,将一号多投的情况视作一次,并且假设没有廉租房“连号”的“公平”情况),可能有人会想到照搬上面的情况。实际上在这种情况下不适合,有可能随机数的集合相当大,这样就不适合在内存中存储了,可以考虑使用数据库存储然后利用数据库的随机函数,在不同的数据库中取随机记录的函数可能会不同。比如在MySQL中如下(假设表名为table_name):
select * from `table_name` order by rand() limit 0,10
而在SQL Server中会是如下(假设表名为table_name):
select top 10 * from [table_name] order by newid()
至于在Oracle中如何随机抽取记录,大家google一下吧。
当然,这个情况还可以再复杂一下,可能电视台针对短信参与的用户要分一二三等奖,一等奖1名,二等奖2名,三等奖3名,纪念奖50名,那么情况就会稍微再复杂一点,不过也就是再增加一个字段而已,这个字段表示当前的号码是否已经中过奖,每次选取号码时只选择那些未曾中奖过的号码即可。
总结
关于不重复随机数生成的问题我在七八年前就遇到过,四五年前的时候曾经做过总结,最近看到有人在讨论这个问题,于是就又重新捡起这个话题了。现在捡起这个话题的目的不是想再简单介绍可能的几种算法,而是从思路上去说明,并且将情况慢慢复杂化,想要说明的是程序员们(不限于.NET程序员)不要用固定的思路去解决问题,可能同样的要求在不同的场合下会有不同的做法。明白了思路才能真正做到以不变应万变,学会一两个控件的用法或者多指导一两个API并不算什么本领,能够在遇到以前没有碰到过的问题时迅速简化解决思路才是本领,另一种本领就是遇到错误时如何快速根据经验定位错误产生原因的本领。