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

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

前言


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

一副扑克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 个数字进行模拟。

image.png

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

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

假设随机出了 2

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

image.png

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

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

image.png

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

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

image.png

结果是1/5!!!

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


还是 1/5 !


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


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

image.png

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

image.png

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


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


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

image.png

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


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

image.png

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


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


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

image.png

算法结束。

你看,在整个过程中,每一个元素出现在每一个位置的概率,都是 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;
}

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

总结


算法从来不是枯燥的逻辑堆砌,而是神一样的逻辑创造。尽管这个世界很复杂,但竟也如此的简洁,优雅。

一起加油!

目录
相关文章
|
4天前
|
机器学习/深度学习 数据采集 算法
Python实现GBDT(梯度提升树)分类模型(GradientBoostingClassifier算法)并应用网格搜索算法寻找最优参数项目实战
Python实现GBDT(梯度提升树)分类模型(GradientBoostingClassifier算法)并应用网格搜索算法寻找最优参数项目实战
|
10天前
|
算法
基于Dijkstra算法的最优行驶路线搜索matlab仿真,以实际城市复杂路线为例进行测试
使用MATLAB2022a实现的Dijkstra算法在城市地图上搜索最优行驶路线的仿真。用户通过鼠标点击设定起点和终点,算法规划路径并显示长度。测试显示,尽管在某些复杂情况下计算路径可能与实际有偏差,但多数场景下Dijkstra算法能找到接近最短路径。核心代码包括图的显示、用户交互及Dijkstra算法实现。算法基于图论,不断更新未访问节点的最短路径。测试结果证明其在简单路线及多数复杂城市路况下表现良好,但在交通拥堵等特殊情况下需结合其他数据提升准确性。
|
1月前
|
算法 数据挖掘 开发者
LeetCode题目55:跳跃游戏【python5种算法贪心/回溯/动态规划/优化贪心/索引哈希映射 详解】
LeetCode题目55:跳跃游戏【python5种算法贪心/回溯/动态规划/优化贪心/索引哈希映射 详解】
|
1月前
|
算法
m基于PSO粒子群优化的LDPC码NMS译码算法最优归一化参数计算和误码率matlab仿真
MATLAB2022a仿真实现了基于遗传优化的NMS LDPC译码算法,优化归一化参数以提升纠错性能。NMS算法通过迭代处理低密度校验码,而PSO算法用于寻找最佳归一化因子。程序包含粒子群优化的迭代过程,根据误码率评估性能并更新解码参数。最终,展示了迭代次数与优化过程的关系,并绘制了SNR与误码率曲线。
26 2
|
1月前
|
算法 JavaScript 前端开发
【经典算法】LCR187:破冰游戏(约瑟夫问题,Java/C/Python3/JavaScript实现含注释说明,Easy)
【经典算法】LCR187:破冰游戏(约瑟夫问题,Java/C/Python3/JavaScript实现含注释说明,Easy)
23 1
|
18天前
|
机器学习/深度学习 人工智能 算法
技术经验解读:【转】完美洗牌算法学习
技术经验解读:【转】完美洗牌算法学习
10 0
|
1月前
|
算法
【经典LeetCode算法题目专栏分类】【第9期】深度优先搜索DFS与并查集:括号生成、岛屿问题、扫雷游戏
【经典LeetCode算法题目专栏分类】【第9期】深度优先搜索DFS与并查集:括号生成、岛屿问题、扫雷游戏
|
1月前
|
算法 机器人
【经典LeetCode算法题目专栏分类】【第5期】贪心算法:分发饼干、跳跃游戏、模拟行走机器人
【经典LeetCode算法题目专栏分类】【第5期】贪心算法:分发饼干、跳跃游戏、模拟行走机器人
|
1月前
|
算法
m基于PSO粒子群优化的LDPC码OMS译码算法最优偏移参数计算和误码率matlab仿真
MATLAB2022a仿真实现了Offset Min-Sum (OMS)译码算法与粒子群优化(PSO)结合,以优化偏移参数,提升LDPC码解码性能。PSO通过迭代寻找最小化误码率(BER)的最佳偏移量。核心程序运用PSO进行参数更新和适应度函数(BER)评估,最终在不同信噪比下展示OMS解码性能,并保存结果。
27 0
|
1月前
|
SQL 算法 数据可视化
python 贪心算法 动态规划实现 跳跃游戏ll【力扣题45】
python 贪心算法 动态规划实现 跳跃游戏ll【力扣题45】