游戏洗牌算法——常用+详解最优Knuth_Durstenfeld算法

目录

前言

基于Unity的洗牌算法代码实现

内容

抽牌洗牌

原理

复杂度

优缺点

Fisher_Yates算法

原理

扫描二维码关注公众号,回复: 14539249 查看本文章

复杂度

代码实现

优缺点

Knuth_Durstenfeld算法(最佳洗牌算法)

详解

Inside_Out算法

原理

复杂度

代码实现

random_shuffle

总结


前言

洗牌算法是一个比较常见的面试题。

一副扑克54张牌,有54!种排列方式。而最佳的洗牌算法,应该能够等概率地生成这54!种结果中的一种。

基于Unity的洗牌算法代码实现

GitHub链接:LinHowe_GameAlgorithm/Assets/Scripts/03-shuffle at master · IceLanguage/LinHowe_GameAlgorithm · GitHub

内容

抽牌洗牌

原理

这是完全合乎现实洗牌逻辑的算法。

就是抽出纸牌的最后一张随机插入到牌库中,这般抽54次就完成了对扑克牌的洗牌。

复杂度

空间O(1),时间O(n^2)

优缺点

优点:逻辑简单。

缺点:如果牌库是以一个数组描述,这种插入式的洗牌不可避免地要大量移动元素。

Fisher_Yates算法

原理

取两个列表,一个是洗牌前的序列A{1,2….54),一个用来放洗牌后的序列B,B初始为空

while A不为空

随机从A取一张牌加入B末尾

复杂度

空间O(n),时间O(n^2)

代码实现

List<int> list = new List<int>(pukes.pukes);//洗牌前的序列A
List<int> newlist = new List<int>(list.Count);//洗牌后的序列B
for(int i = 0 ; i < pukes.pukes.Length ; ++i)
{
  int randomIndex = Random.Range(0, list.Count);
  int r = list[randomIndex];//随机取牌
   newlist.Add(r);
   list.RemoveAt(randomIndex);
}
pukes.ResetPuke(newlist.ToArray());//序列B为洗牌后的结果

优缺点

优点:算法原理清晰,通过54次生成的随机数取1/54,1/53,…1/1能等概率地生成这54!种结果中的一种。

缺点:额外开辟了一个List,而且为List删除元素是不可避免地需要移动元素。

Knuth_Durstenfeld算法(最佳洗牌算法)

Knuth 和Durstenfeld 在Fisher 等人的基础上对算法进行了改进。 每次从未处理的数据中随机取出一个数字,然后把该数字放在数组的尾部, 即数组尾部存放的是已经处理过的数字 。 这是一个原地打乱顺序的算法,算法时间复杂度也从Fisher算法的 O ( n 2 )提升到了 O ( n )。

for(int i = pukes.pukes.Length - 1;i>0;--i)
 {
     int randomIndex = Random.Range(0, i+1);
     pukes.Swap(randomIndex, i);
 }
void Knuth_Durstenfeld_Shuffle(vector<int>&arr)
{
	for (int i=arr.size()-1;i>=0;--i)
	{
		srand((unsigned)time(NULL));
		swap(arr[rand()%(i+1)],arr[i]);
	}
} 

详解

是时候仔细的看一下,这个简单的算法,为什么能做到保证:对于生成的排列,每一个元素都能等概率的出现在每一个位置了。

其实,简单的吓人:)

在这里,我们模拟一下算法的执行过程,同时,对于每一步,计算一下概率值。

我们简单的只是用 5 个数字进行模拟。

假设初始的时候,是按照 1,2,3,4,5 进行排列的

那么,根据这个算法,首先会在这五个元素中选一个元素,和最后一个元素 5 交换位置。

假设随机出了 2

下面,我们计算 2 出现在最后一个位置的概率是多少?非常简单,因为是从 5 个元素中选的嘛,就是 1/5。

实际上,根据这一步,任意一个元素出现在最后一个位置的概率,都是 1/5

下面,根据这个算法,我们就已经不用管 2 了,而是在前面 4 个元素中,随机一个元素,放在倒数第二的位置。假设我们随机的是 3。

3 和现在倒数第二个位置的元素 4 交换位置

下面的计算非常重要。3 出现在这个位置的概率是多少?计算方式是这样的:

结果是1/5!!!

其实很简单,因为 3 逃出了第一轮的筛选,概率是 4/5,但是 3 没有逃过这一轮的选择。在这一轮,一共有4个元素,所以 3 被选中的概率是 1/4。因此,最终,3 出现在这个倒数第二的位置,概率是 4/5 * 1/4 = 1/5。

还是 1/5 !

实际上,用这个方法计算,任意一个元素出现在这个倒数第二位置的概率,都是 1/5。


到这里已经大概了解了。我们再进行下一步,在剩下的三个元素中随机一个元素,放在中间的位置。假设我们随机的是 1。

关键是:“ 1 ”出现在这个位置的概率是多少?计算方式是这样的: 

答案1/5

即 1 首先在第一轮没被选中,概率是 4/5,在第二轮又没被选中,概率是 3/4 ,但是在第三轮被选中了,概率是 1/3。乘在一起,4/5 * 3/4 * 1/3 = 1/5。

用这个方法计算,任意一个元素出现在中间位置的概率,都是 1/5。


这个过程继续,现在,我们只剩下两个元素了,在剩下的两个元素中,随机选一个,比如是4。

将4放到第二个位置

然后,4 出现在这个位置的概率是多少?4 首先在第一轮没被选中,概率是 4/5;在第二轮又没被选中,概率是 3/4;第三轮还没选中,概率是 2/3,但是在第四轮被选中了,概率是 1/2。乘在一起,4/5 * 3/4 * 2/3 * 1/2 = 1/5。

用这个方法计算,任意一个元素出现在第二个位置的概率,都是 1/5。


最后,就剩下元素5了。它只能在第一个位置呆着了。

那么 5 留在第一个位置的概率是多少?即在前 4 轮,5 都没有选中的概率是多少?

在第一轮没被选中,概率是 4/5;在第二轮又没被选中,概率是 3/4;第三轮还没选中,概率是 2/3,在第四轮依然没有被选中,概率是 1/2。乘在一起,4/5 * 3/4 * 2/3 * 1/2 = 1/5。

得到仍是1/5!!!

算法结束。


你看,在整个过程中,每一个元素出现在每一个位置的概率,都是 1/5 !

所以,这个算法是公平的。

当然了,上面只是举例子。这个证明可以很容易地拓展到数组元素个数为 n 的任意数组。整个算法的复杂度是 O(n) 的。

通过这个过程,大家也可以看到,同样的思路,我们也完全可以从前向后依次决定每个位置的数字是谁。不过从前向后,代码会复杂一些。

(因为生成 [0, i] 范围的随机数比生成 [i, n) 范围的随机数简单,直接对 i+1 求余就好了)

Inside_Out算法

C++ stl中random_shuffle使用的就是这种算法。

原理

在[0, i]之间随机一个下标j,然后用位置j的元素替换掉位置i的数字。

通过54次生成的随机数取1/1,1/2,…1/54能等概率地生成这54!种结果中的一种。

复杂度

空间O(1),时间O(n)

代码实现

public static void Shuffle(Pukes pukes)
  {
      int len = pukes.pukes.Length;
      for (int i = 0; i < len; ++i)
      {
          int randomIndex = Random.Range(0, i + 1);
          pukes.Swap(i, randomIndex);
      }
  }

详解请参考:PCFG中inside和outside算法详解_算法码上来的博客-CSDN博客

random_shuffle

关于c++ stl 的random_shuffle。

它的算法原理和Knuth_Durstenfeld类似。

先从所有元素中选一个与位置1的元素交换,然后再从剩下的n-1个元素中选择一个放到位置2,以此类推。

测试代码如下:

#include <iostream>

using namespace std;

#include <algorithm>
#include <vector>
#include <ctime>

class myPrint
{
public:
	void operator()(int val)
	{
		cout << val << " ";
	}
};

void test01()
{
	srand((unsigned int)time(NULL));
	vector<int> v;
	for(int i = 0 ; i < 10;i++)
	{
		v.push_back(i);
	}
	for_each(v.begin(), v.end(), myPrint());
	cout << endl;

	//打乱顺序
	random_shuffle(v.begin(), v.end());
	for_each(v.begin(), v.end(), myPrint());
	cout << endl;
}

int main() {

	test01();

	system("pause");

	return 0;
}

此方法比较实用,使用时记得加随机数种子。

总结

算法从来不是枯燥的逻辑堆砌,而是神一样的逻辑创造。

尽管这个世界很复杂,但竟也如此的简洁,优雅。

一起加油!

猜你喜欢

转载自blog.csdn.net/flyTie/article/details/126548063