1、使用场景
应用场景:斗地主、欢乐麻将游戏中都会存在洗牌场景
2、洗牌算法
如果要设计一个洗牌算法,怎么证明你的算法就是正确的呢?乱序算法不像排序算法,结果唯一可以很容易检验,因为「乱」可以有很多种,你怎么能证明你的算法是「真的乱」呢?
所以我们面临两个问题:
- 什么叫做「真的乱」?
- 设计怎样的算法来打乱数组才能做到「真的乱」?
这种算法称为「随机乱置算法」或者「洗牌算法」。
由抽牌、换牌和插牌衍生出三种洗牌算法,其中抽牌和换牌分别对应Fisher-Yates Shuffle和Knuth-Durstenfeld Shhuffle算法。
2.1、Fisher-Yates Shuffle算法
最早提出这个洗牌方法的是 Ronald A. Fisher 和 Frank Yates,即 Fisher–Yates Shuffle,其基本思想就是从原始数组中随机取一个之前没取过的数字到新的数组中,具体如下:
- 初始化原始数组和新数组,原始数组长度为n(已知);
- 从还没处理的数组(假如还剩k个)中,随机产生一个[0, k)之间的数字p(假设数组从0开始);
- 从剩下的k个数中把第p个数取出,把这个数从原始数组中移除并添加到新数组中;
- 重复步骤2和3直到数字全部取完;
- 从步骤3取出的数字序列便是一个打乱了的数列。
public static int[] fisherYatesShuffle(int[] array){
if (array == null || array.length == 0 || array.length == 1){
return array;
}
int[] result = new int[array.length];
List<Integer> arr = Arrays.stream(array).boxed().collect(Collectors.toList());
int idx = 0;
while (!arr.isEmpty()){//O(n)
int randIndex = random(0,arr.size()-1);
int e = arr.remove(randIndex);//O(n)
result[idx++] = e;
}
return result;
}
private static int random(int min,int max){
return min+(int) (Math.random() * (max-min+1));
}
private static void swap(int[] array,int x, int y){
int t = array[x];
array[x] = array[y];
array[y] = t;
}
这个算法:时间复杂度为O(n*n),空间复杂度为O(n).
2.2、Knuth-Durstenfeld Shuffle 算法
Knuth 和 Durstenfeld 在Fisher 等人的基础上对算法进行了改进,在原始数组上对数字进行交互,省去了额外O(n)的空间。该算法的基本思想和 Fisher 类似,每次从未处理的数据中随机取出一个数字,然后把该数字放在数组的尾部,即数组尾部存放的是已经处理过的数字。
- 建立一个数组大小为 n 的数组 arr,分别存放 1 到 n 的数值;
- 生成一个从 0 到 n - 1 的随机数 x;
- 输出 arr 下标为 x 的数值,即为第一个随机数;
- 将 arr 的尾元素和下标为 x 的元素互换;
- 同2,生成一个从 0 到 n - 2 的随机数 x;
- 输出 arr 下标为 x 的数值,为第二个随机数;
- 将 arr 的倒数第二个元素和下标为 x 的元素互换;
……
如上,直到输出 m 个数为止
private static int random(int min,int max){
return min+(int) (Math.random() * (max-min+1));
}
private static void swap(int[] array,int x, int y){
int t = array[x];
array[x] = array[y];
array[y] = t;
}
public static int[] knuthDurstenfeldShuffle(int[] array){
if (array == null || array.length == 0 || array.length == 1){
return array;
}
for (int i = 0; i < array.length; i++) {
int rand = random(i,array.length-1);
swap(array,i,rand);
}
return array;
}
这个算法:时间复杂度为O(n),空间复杂度为O(1).
3、蒙特卡罗方法验证正确性
洗牌算法,或者说随机乱置算法的正确性衡量标准是:对于每种可能的结果出现的概率必须相等,也就是说要足够随机。如果不用数学严格证明概率相等,可以用蒙特卡罗方法近似地估计出概率是否相等,结果是否足够随机。
记得高中有道数学题:往一个正方形里面随机打点,这个正方形里紧贴着一个圆,告诉你打点的总数和落在圆里的点的数量,让你计算圆周率。
这其实就是利用了蒙特卡罗方法:当打的点足够多的时候,点的数量就可以近似代表图形的面积。通过面积公式,由正方形和圆的面积比值是可以很容易推出圆周率的。当然打的点越多,算出的圆周率越准确,充分体现了大力出奇迹的真理。
类似的,我们可以对同一个数组进行一百万次洗牌,统计各种结果出现的次数,把频率作为概率,可以很容易看出洗牌算法是否正确。整体思想很简单,不过实现起来也有些技巧的,下面简单分析几种实现思路。
我们把数组 arr 的所有排列组合都列举出来,做成一个直方图(假设 arr = {1,2,3})
每次进行洗牌算法后,就把得到的打乱结果对应的频数加一,重复进行 100 万次,如果每种结果出现的总次数差不多,那就说明每种结果出现的概率应该是相等的。这种检验方案是可行的,不过可能有读者会问,arr 的全部排列有 n! 种(n 为 arr 的长度)。
如果 n 比较大,那岂不是空间复杂度爆炸了?
是的,不过作为一种验证方法,我们不需要 n 太大,一般用长度为 5 或 6 的 arr 试下就差不多了吧,因为我们只想比较概率验证一下正确性而已。