我知道你们会各类花式排序算法,可是若是叫你打乱一个数组,你是否能作到成竹在胸?即使你拍脑壳想出一个算法,怎么证实你的算法就是正确的呢?乱序算法不像排序算法,结果惟一能够很容易检验,由于「乱」能够有不少种,你怎么能证实你的算法是「真的乱」呢?git
因此咱们面临两个问题:算法
这种算法称为「随机乱置算法」或者「洗牌算法」。数组
本文分两部分,第一部分详解最经常使用的洗牌算法。由于该算法的细节容易出错,且存在好几种变体,虽有细微差别但都是正确的,因此本文要介绍一种简单的通用思想保证你写出正确的洗牌算法。第二部分讲解使用「蒙特卡罗方法」来检验咱们的打乱结果是否是真的乱。蒙特卡罗方法的思想不难,可是实现方式也各有特色的。app
PS:我认真写了 100 多篇原创,手把手刷 200 道力扣题目,所有发布在 labuladong的算法小抄,持续更新。建议收藏,按照个人文章顺序刷题,掌握各类算法套路后投再入题海就如鱼得水了。ide
此类算法都是靠随机选取元素交换来获取随机性,直接看代码(伪码),该算法有 4 种形式,都是正确的:测试
// 获得一个在闭区间 [min, max] 内的随机整数 int randInt(int min, int max); // 第一种写法 void shuffle(int[] arr) { int n = arr.length(); /******** 区别只有这两行 ********/ for (int i = 0 ; i < n; i++) { // 从 i 到最后随机选一个元素 int rand = randInt(i, n - 1); /*************************/ swap(arr[i], arr[rand]); } } // 第二种写法 for (int i = 0 ; i < n - 1; i++) int rand = randInt(i, n - 1); // 第三种写法 for (int i = n - 1 ; i >= 0; i--) int rand = randInt(0, i); // 第四种写法 for (int i = n - 1 ; i > 0; i--) int rand = randInt(0, i);
分析洗牌算法正确性的准则:产生的结果必须有 n! 种可能,不然就是错误的。这个很好解释,由于一个长度为 n 的数组的全排列就有 n! 种,也就是说打乱结果总共有 n! 种。算法必须可以反映这个事实,才是正确的。spa
咱们先用这个准则分析一下第一种写法的正确性:设计
// 假设传入这样一个 arr int[] arr = {1,3,5,7,9}; void shuffle(int[] arr) { int n = arr.length(); // 5 for (int i = 0 ; i < n; i++) { int rand = randInt(i, n - 1); swap(arr[i], arr[rand]); } }
for 循环第一轮迭代时,i = 0
,rand
的取值范围是 [0, 4]
,有 5 个可能的取值。3d
for 循环第二轮迭代时,i = 1
,rand
的取值范围是 [1, 4]
,有 4 个可能的取值。code
后面以此类推,直到最后一次迭代,i = 4
,rand
的取值范围是 [4, 4]
,只有 1 个可能的取值。
能够看到,整个过程产生的全部可能结果有 n! = 5! = 5*4*3*2*1
种,因此这个算法是正确的。
分析第二种写法,前面的迭代都是同样的,少了一次迭代而已。因此最后一次迭代时 i = 3
,rand
的取值范围是 [3, 4]
,有 2 个可能的取值。
// 第二种写法 // arr = {1,3,5,7,9}, n = 5 for (int i = 0 ; i < n - 1; i++) int rand = randInt(i, n - 1);
因此整个过程产生的全部可能结果仍然有 5*4*3*2 = 5! = n!
种,由于乘以 1 无关紧要嘛。因此这种写法也是正确的。
若是以上内容你都能理解,那么你就能发现第三种写法就是第一种写法,只是将数组从后往前迭代而已;第四种写法是第二种写法从后往前来。因此它们都是正确的。
若是读者思考过洗牌算法,可能会想出以下的算法,可是这种写法是错误的:
void shuffle(int[] arr) { int n = arr.length(); for (int i = 0 ; i < n; i++) { // 每次都从闭区间 [0, n-1] // 中随机选取元素进行交换 int rand = randInt(0, n - 1); swap(arr[i], arr[rand]); } }
如今你应该明白这种写法为何会错误了。由于这种写法获得的全部可能结果有 n^n
种,而不是 n!
种,并且 n^n
不多是 n!
的整数倍。
好比说 arr = {1,2,3}
,正确的结果应该有 3!= 6
种可能,而这种写法总共有 3^3 = 27
种可能结果。由于 27 不能被 6 整除,因此必定有某些状况被「偏袒」了,也就是说某些状况出现的几率会大一些,因此这种打乱结果不算「真的乱」。
上面咱们从直觉上简单解释了洗牌算法正确的准则,没有数学证实,我想你们也懒得证实。对于几率问题咱们可使用「蒙特卡罗方法」进行简单验证。
洗牌算法,或者说随机乱置算法的正确性衡量标准是:对于每种可能的结果出现的几率必须相等,也就是说要足够随机。
若是不用数学严格证实几率相等,能够用蒙特卡罗方法近似地估计出几率是否相等,结果是否足够随机。
记得高中有道数学题:往一个正方形里面随机打点,这个正方形里紧贴着一个圆,告诉你打点的总数和落在圆里的点的数量,让你计算圆周率。
这其实就是利用了蒙特卡罗方法:当打的点足够多的时候,点的数量就能够近似表明图形的面积。经过面积公式,由正方形和圆的面积比值是能够很容易推出圆周率的。固然打的点越多,算出的圆周率越准确,充分体现了大力出奇迹的真理。
相似的,咱们能够对同一个数组进行一百万次洗牌,统计各类结果出现的次数,把频率做为几率,能够很容易看出洗牌算法是否正确。总体思想很简单,不过实现起来也有些技巧的,下面简单分析几种实现思路。
第一种思路,咱们把数组 arr 的全部排列组合都列举出来,作成一个直方图(假设 arr = {1,2,3}):
每次进行洗牌算法后,就把获得的打乱结果对应的频数加一,重复进行 100 万次,若是每种结果出现的总次数差很少,那就说明每种结果出现的几率应该是相等的。写一下这个思路的伪代码:
void shuffle(int[] arr); // 蒙特卡罗 int N = 1000000; HashMap count; // 做为直方图 for (i = 0; i < N; i++) { int[] arr = {1,2,3}; shuffle(arr); // 此时 arr 已被打乱 count[arr] += 1; } for (int feq : count.values()) print(feq / N + " "); // 频率
这种检验方案是可行的,不过可能有读者会问,arr 的所有排列有 n! 种(n 为 arr 的长度),若是 n 比较大,那岂不是空间复杂度爆炸了?
是的,不过做为一种验证方法,咱们不须要 n 太大,通常用长度为 5 或 6 的 arr 试下就差很少了吧,由于咱们只想比较几率验证一下正确性而已。
PS:我认真写了 100 多篇原创,手把手刷 200 道力扣题目,所有发布在 labuladong的算法小抄,持续更新。建议收藏,按照个人文章顺序刷题,掌握各类算法套路后投再入题海就如鱼得水了。
第二种思路,能够这样想,arr 数组中全都是 0,只有一个 1。咱们对 arr 进行 100 万次打乱,记录每一个索引位置出现 1 的次数,若是每一个索引出现的次数差很少,也能够说明每种打乱结果的几率是相等的。
void shuffle(int[] arr); // 蒙特卡罗方法 int N = 1000000; int[] arr = {1,0,0,0,0}; int[] count = new int[arr.length]; for (int i = 0; i < N; i++) { shuffle(arr); // 打乱 arr for (int j = 0; j < arr.length; j++) if (arr[j] == 1) { count[j]++; break; } } for (int feq : count) print(feq / N + " "); // 频率
这种思路也是可行的,并且避免了阶乘级的空间复杂度,可是多了嵌套 for 循环,时间复杂度高一点。不过因为咱们的测试数据量不会有多大,这些问题均可以忽略。
另外,细心的读者可能发现一个问题,上述两种思路声明 arr 的位置不一样,一个在 for 循环里,一个在 for 循环以外。其实效果都是同样的,由于咱们的算法总要打乱 arr,因此 arr 的顺序并不重要,只要元素不变就行。
本文第一部分介绍了洗牌算法(随机乱置算法),经过一个简单的分析技巧证实了该算法的四种正确形式,而且分析了一种常见的错误写法,相信你必定可以写出正确的洗牌算法了。
第二部分写了洗牌算法正确性的衡量标准,即每种随机结果出现的几率必须相等。若是咱们不用严格的数学证实,能够经过蒙特卡罗方法大力出奇迹,粗略验证算法的正确性。蒙特卡罗方法也有不一样的思路,不过要求没必要太严格,由于咱们只是寻求一个简单的验证。