神奇的Knuth洗牌算法

目录

•写在前面

•一个公平的随机算法?

•Knuth算法

•证明思路


•写在前面

谈到随机算法,我们可能脑子里会出现很多种解决方案(ps:想不到解决方案的,可能是random函数用多了,哈哈哈),不过我这里要讲的Knuth随机算法,在我第一次接触到之后,就不得不感叹一声,确实很神奇,它的神奇之处不在于这个算法有多高深,实现有多复杂,而是在于这个算法的思想极其简单,简单到核心代码就两行,艺术般的思想实现了一个公平的随机算法。正因为算法很简单,容易被忽略为什么,所以我想从算法的本质上思考,不仅要理解算法,还要想想为什么我们想不到这么精妙的算法,相信会有所收获。

•一个公平的随机算法?

在我们正式讲解Knuth算法之前,我觉得应该先思考这个问题,如果你遇到了一个面试,面试官想让你设计一个公平的洗牌算法,你会怎么设计?问题其实就是要让我们把一连串的数(数组)随机打乱顺序,擅长用random的我们,可能第一时间想到的是把所有牌放到一个数组中,每次取两张牌交换位置,随机 k 次即可。想法很天真可爱,那么请问随机k此时具体多少次呢?很明显,我们需要将这个k与数组元素的数量进行相关联,这样的方式比随机的k此合理多了。然后这个时候我们就送了一口气,终于把这个算法设计完了。

如果你这么想,那就太单纯了,可能上面说的这个算法思路是一个随机洗牌的想法,可以帮助我们把牌打乱。不过我们要思考一个问题,回到题目本身来“一个公平的洗牌算法”,上面说的这种思路“公平”么?其实这就是我们大多数人忽略的一个关键点,“公平”的洗牌算法可能才是这道题的精髓所在。如果保证我们随机的很公平?其实在数学上就是我们常说的等概率。

可能这个时候有人就会说了,我们可以将所有元素的排列可能列出来,之后随机选择其中一种排列情况,这就完美的达到了等概率,即“公平”。不过这个算法真的好么?不好,因为这个思路虽然做到了等概率,但是时间复杂度非常的高,要知道,n!是比指数爆炸还要恐怖的复杂度。

其实我们可以在全排列的想法上得到一些启发,全排列其实就是为了所有元素随机等概率的出现在某一个位置,这个时候,我们可以将视野从整个数组降到单个元素上来讨论,也就是说,我们只要保证每个元素能够等概率的出现在任意一个位置,就能实现我们说的公平。

•Knuth算法

我们可以在最开始随机交换两个元素的思路下进一步思考,我们怎么保证每一个位置都能等概率地放置每个元素。其实并不难,我们从数组最后一个元素往前循环,每次循环,我们随机挑选一个元素和当前循环所在元素进行交换值就可以了,可能这样说的不直观,我先把算法代码弄上来,边理解,如下

for (int i = n - 1; i >= 0; i--) {
    swap(arr[i], arr[rand() % (i + 1)]);
}

只有两行代码,你没看错,呈现在你眼前的就是著名的Knuth洗牌算法。我们可以先简单的理解一下这个循环在做什么。其实非常简单,i 从后向前,每次随机一个 [0...i] 之间的下标,然后将 arr[i] 和这个随机的下标元素,也就是 arr[rand() % (i + 1)] 交换位置。值得一提的是,由于每次是随机一个 [0...i] 之间的下标,所以,我们的计算方式是 rand() % (i + 1),要对 i + 1 取余,保证随机的索引在 [0...i] 之间。

很好理解吧,那么你可能会问了,哪里体现了公平呢?这不和原先的思路一样嘛?不过是规定了顺序而已。差别当然不一样,我们来用概率的方法来证明一下为什么我们的这种算法是公平的(不要一听到概率证明就害怕哦,其实很简单的),这也是这个算法的精妙之处。

•证明思路

我们先用一个例子,边看例子边证明我们的算法,这样会更加直观一些。首先我们简单的使用5个数字来进行模拟,也就是1,2,3,4,5。假设最开始这五个数是按照如下这样排列的。

1 2 3 4 5

那么,根据这个算法,首先会在这五个元素中选一个元素,和最后一个元素 5 交换位置,假设随机出了 2,交换之后如下。

1 5 3 4 2

这个时候,我们计算 2 出现在最后一个位置的概率是多少?非常简单,因为是从 5 个元素中选的嘛,就是 1/5。实际上,根据这一步,任意一个元素出现在最后一个位置的概率,都是 1/5。不信?我们接着往下看。根据这个算法,选中的我们就先不管了,也就是说已经不用管 2 了,而是在前面 4 个元素中,随机一个元素,放在倒数第二的位置。假设我们随机的是 3,3 和现在倒数第二个位置的元素 4 交换位置(标位灰色就意味着不会再被选中了)。

1 5 4 3 2

那么,这个时候,3 出现在这个位置的概率是多少?计算方式是这样的:P = 4/5 * 1/4 = 1/5

这是一个很简单的计算概率的方式,因为在前一轮筛选中,3没有被选中的概率是4/5。在这一轮,一共有4个元素,所以 3 被选中的概率是 1/4。因此,最终,3 出现在这个倒数第二的位置,概率是 4/5 * 1/4 = 1/5。这里我接着模拟一轮,情况如下,假设下一次被抽中的是1

4 5 1 3 2

我们进行概率计算,1在第一轮没有被选中的概率是4/5,第二轮没有被选中的概率是3/4,第三轮被选中的概率是1/3,最后计算出来的P = 4/5 * 3/4 * 1/3 = 1/5

实际上,用这个方法计算,任意一个元素出现在这个倒数第二位置的概率,都是 1/5。你不信可以继续算下去,我这里就不模拟了。上面只是举例子,这个证明可以很容易地拓展到数组元素个数为 n 的任意数组,整个算法的复杂度是 O(n) 的,是不是很神奇。不过,这个时候可能有人要问了,我可不可以从前往后,当然可以,不过代码会更复杂一点,但并不影响其他的。其实,在很多随机的地方,都能使用。比如,扫雷生成随机的盘面。我们可以把扫雷的二维盘面先逐行连接,看作是一维的。之后,把 k 颗雷依次放在开始的位置。

你可能感兴趣的:(算法及数据结构)