序言
未经允许严禁转载
本文章将对笔者在编写 命令行德州扑克游戏,的过程中,对所有牌型 哈希表优化 的补充。
如果想了解前因后果,请点击上方链接。
回顾
解决方案
长话短说 ,优化的思路就正如标题所说:
- 将组合数映射为自然数
我们要将 C(52,5) =2598960 情况,通过哈希 一 一映射成数组的下标。
在此之前,需要了解一个概念:
康托展开是一个 全排列 到一个 自然数 的双射,常用于构建哈希表时的空间压缩。 康托展开的实质是计算当前排列在所有由小到大全排列中的顺序,因此是可逆的。
具体康托展开是怎么计算的,网上有很多,不多赘述。
但有读者会问,康托展开是针对 排列数 的映射。如何转换为 组合数 的映射呢?
先不讨论C(52,5)这个庞大的数据,我们从C(5,2)来考虑。
对于从5个元素中,选出2个的组合数。可以看成一个位图。
0 表示每有被选, 1 表示被选。
例如在一个大小为 5 的数组里有:A,B,C,D,E
编号 | 位图 | 实际 |
---|---|---|
0 | 1.1.0.0.0 | A,B |
1 | 1.0.1.0.0 | A,C |
2 | 0.1.1.0.0 | B,C |
3 | 1.0.0.1.0 | A,D |
4 | 0.1.0.1.0 | B,D |
5 | 0.0.1.1.0 | C,D |
6 | 1.0.0.0.1 | A,E |
7 | 0.1.0.0.1 | B,E |
8 | 0.0.1.0.1 | C,E |
9 | 0.0.0.1.1 | D,E |
转化一下思路可以发现,对于组合数来说,其实本质上就是 1 和 0 的排列数。
对于 C(5,2) 来说,就是3个0,2个1的排列数。
可能到这里还是会有点云里雾里的,没关系,我们直接先上结果:
//以上面描述的大小为5的数组为例。
//我们先将A、B、C、D、E,映射为它们在数组中的下标
//那么此时的数组可以写成
int arr[5]={0,1,2,3,4};
//如果我此时有一个组合,例如,4和3,代表的就是E和D
//int group[2]={4,3};
//得先将这个组合排序,这里先按照升序(具体原因慢慢看,方便映射罢了)
int group[2]={3,4};
//此时group[0]的值是3,也就是 0.0.0.0.0 从左到右数的第3个位置,置为1(最左侧为第0个)
//即:0.0.0.1.0
//同理group[1]的值为4,也就是 0.0.0.1.0 从左到右数的第4个位置,置为1
//即:0.0.0.1.1
//正好和表中对应
- 到这里准备工作就完成了,那么如何将这个组转换为表中的编号呢?
- 这里得先引用一道公式,具体是怎么推导的,参考这篇文章
- 笔者这里思考的左右顺序和文章不同,但是同理。
//简单解释一下,M:表示要选多少张,或者说一共需要取出多少项 C(N,M);
//k:表示当前选到第几张了。1<=k<=M,那么对于示例来说就是 1<=k<=2;
//所以需要:
int b_1;//b[1]
int b_2;//b[2]
//那么对于 0.0.0.1.1 来说
b_1=group[0];//从左到右数,第一个(k=1)选中的下标(最左边的为第0位)
b_2=group[1];//从左到右数,第二个(k=2)选中的下标
//即 b[1]=3,b[2]=4;
//那么它对应的编号就等于:
POS=C(b_1,1)+C(b_2,2)
POS=C(3,1)+C(4,2)=3+6=9; Bingo!成功拿到了D,E这个组合的坐标。
- 为什么要排序?因为本质上是0,1的排列数,那么肯定要以排序。
- 先将数组里的元素,映射成它对应的下标。
- 这样当拿到一种组合的时候,组合里面的值就是位图里面 1 的下标(即被选走的)
- 对拿到的组合 排序 也是为了确保,哪一个是第一个先拿的。这里升序的话,
排序好的组合里面的值,从左到右看,正好对应 k 的值(当前选到第几张了)。
很神奇,但,就这么用的。
我们多试几个:
到这里,至少笔者自己德州扑克游戏的哈希表问题就可以得以解决了。
表的优化
- 有了这种映射支持以后,实际上在哈希表里面,甚至不需要放置键(key),
因为哈希表的下标就是键,只需要通过哈希,就可以拿到下标,访问到值。
真正的O(1)
下面是基础版本的哈希表,或者说是针对德州扑克牌力
以下代码请先了解笔者的上一篇文章 命令行德州扑克游戏,
#ifndef _CTL_HASHMAP_HPP_
#define _CTL_HASHMAP_HPP_
#include <vector>
#include <string>
using std::vector;
using std::string;
typedef struct{//对于从52张牌中选5张,每张牌用char表示,key就是5个char。
char _num0;
char _num1;
char _num2;
char _num3;
char _num4;
unsigned short _val;
}CardsKeyVal;
namespace MathFunc{
//m个不同的数里选出k个,有多少种组合情况。
int Group_M_To_K(int m,int k);//组合数C(M,K)的返回值
void SortNums(char*);//按值升序排序
void StringCopyToCardsKeyVal(string & str,CardsKeyVal & rhs);
//👆只是为了方便耦合之前的代码
}//end of MathFunc
//针对全组合对自然数映射的哈希
//目前仅针对德州扑克
class HashMap
{
public:
HashMap(int size) {
_valmap.resize(size);
}
~HashMap() {}
int Hash(char*);//哈希算法获取要插入的下标
void Insert(CardsKeyVal);//提供插入接口
unsigned short& Find(char*);//提供查询接口
bool Full();//因为我们知道表的大小,只要每个下标都有值,说明创建成功了,
vector<unsigned short> _valmap;//只需要存值val
};
inline
void MathFunc::StringCopyToCardsKeyVal(string &str,CardsKeyVal &rhs)
{
rhs._num0=str[0];
rhs._num1=str[1];
rhs._num2=str[2];
rhs._num3=str[3];
rhs._num4=str[4];
}
#endif
- 有以下几点需要说明
- 为什么要定义一个CardsKeyVal的结构体?
- 因为方便耦合之前的代码,而且如果想同时 序列化 key-val,
可以用 vector 存这个结构体,加载和写入都快。
- 因为方便耦合之前的代码,而且如果想同时 序列化 key-val,
- 为什么只提供 Insert 和 Find ?
- 因为这个表就是用来保存牌力的,根本不需要提供迭代器,或修改接口之类的。
说白了,这表就是能快速判断出牌型。两个函数,一个用来创建表,一个用来查。
- 因为这个表就是用来保存牌力的,根本不需要提供迭代器,或修改接口之类的。
- 有什么需要注意的吗?
- 有的,咱来看看实现。
- 为什么要定义一个CardsKeyVal的结构体?
#include "HashMap.hpp"
int MathFunc::Group_M_To_K(int m,int k)
{
//优化
if(m<k){return 0;}//代码块下面会说明
if(k>m/2){k=m-k;}//C(M,K)=C(M,M-K) 定理
if(k==0){return 1;}//当m=k时,上一句代码会把k变为0,其实就是C(M,M),那自然等于1
if(k==1){return m;}//C(M,1)自然等于M
int dividend=1;//被除数
int tmp1=m;
while(1){
dividend*=tmp1;
--tmp1;
if(tmp1==m-k+1){
dividend*=tmp1;
break;}
}
int tmp2=k;
int divisor=1;//除数
while(1){
divisor*=tmp2;
--tmp2;
if(tmp2==1){break;}
}
return dividend/divisor;
}
//长度为5罢了,冒泡就冒泡呗
void MathFunc::SortNums(char *num){
int i,j;
for(i=4;i>0;--i)
{
for(j=0;j<i;++j)
{
if(num[j]>num[i])
{
char tmp=num[j];
num[j]=num[i];
num[i]=tmp;
}
}
}
}
int HashMap::Hash(char *num){
int arr[5]={0};
MathFunc::SortNums(num);//反正处理前先排序
for(int i=0;i<5;++i)//对于笔者的项目来说,得转换为int,避免一些运算的隐式转化
{arr[i]=num[i];}
int b0=MathFunc::Group_M_To_K(arr[0],1);
int b1=MathFunc::Group_M_To_K(arr[1],2);
int b2=MathFunc::Group_M_To_K(arr[2],3);
int b3=MathFunc::Group_M_To_K(arr[3],4);
int b4=MathFunc::Group_M_To_K(arr[4],5);
return b0+b1+b2+b3+b4;
}
void HashMap::Insert(CardsKeyVal kv){
char* arr=&kv._num0;
//要排序后才能哈希
MathFunc::SortNums(arr);
//根据哈希后的坐标赋值过去
_valmap[Hash(arr)]=kv._val;
}
unsigned short &HashMap::Find(char* arr){
MathFunc::SortNums(arr);
return _valmap[Hash(arr)];
}
bool HashMap::Full(){
for(auto &rc:_valmap)
{if(rc==0){return false;}}
//因为构造函数那里,用到是resize,所有对于unsigned short来说初始值为0
return true;
}
- 在写 C(M,K) 函数的时候要注意,虽然在数学里不存在 C(0,K),
但是在我们上面说的组合数映射里面,其实会出现这种情况。
- 出于计算的考虑,每次出现 m<k 的情况时,必须返回结果为0.
- 即 如果 M<K, C(M,K)=0;
- 因为 k 是 >=1 的,所有M=0时也同时处理了。
至此,哈希表的优化就结束了,也成功实现了将组合数映射为下标了。
同时,序列化后,读取加载的效率也比之前一个一个插入unordered_map 快多了
直接用 mmap 然后 memcpy 到容器里面,1ms左右。
屎山质的飞跃
有了这个表,德州扑克的人机策略也会好写很多。
想了解笔者的游戏,可以去查看Gitee仓库。
这片文章,讲解了具体的设计实现流程笔记 命令行德州扑克游戏。
- 当然,还有这里还有一些问题,
- 如何去泛化这个哈希表?
- C(M,K) 因为涉及到阶乘,其实数值很容易溢出。
- 如果用 redis 数据库去存储这个表,效率会损失多少?
- 原理上可以实现下标逆推组合,该怎么实现?
以上,觉得有帮助的,希望能点个赞,感谢阅读。