用C++实现的,将组合数映射为自然数(数组下标)的方法。(实现德州扑克的牌力哈希表)

序言

未经允许严禁转载

本文章将对笔者在编写 命令行德州扑克游戏,的过程中,对所有牌型 哈希表优化 的补充。
如果想了解前因后果,请点击上方链接。

回顾

问题

解决方案

长话短说 ,优化的思路就正如标题所说:

  • 将组合数映射为自然数

我们要将 C(52,5) =2598960 情况,通过哈希 一 一映射成数组的下标。
在此之前,需要了解一个概念:

康托展开是一个 全排列 到一个 自然数 的双射,常用于构建哈希表时的空间压缩。 康托展开的实质是计算当前排列在所有由小到大全排列中的顺序,因此是可逆的。

具体康托展开是怎么计算的,网上有很多,不多赘述。

但有读者会问,康托展开是针对 排列数 的映射。如何转换为 组合数 的映射呢?

先不讨论C(52,5)这个庞大的数据,我们从C(5,2)来考虑。

对于从5个元素中,选出2个的组合数。可以看成一个位图。
0 表示每有被选, 1 表示被选。
例如在一个大小为 5 的数组里有:A,B,C,D,E

编号位图实际
01.1.0.0.0A,B
11.0.1.0.0A,C
20.1.1.0.0B,C
31.0.0.1.0A,D
40.1.0.1.0B,D
50.0.1.1.0C,D
61.0.0.0.1A,E
70.1.0.0.1B,E
80.0.1.0.1C,E
90.0.0.1.1D,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

  • 有以下几点需要说明
    1. 为什么要定义一个CardsKeyVal的结构体?
      • 因为方便耦合之前的代码,而且如果想同时 序列化 key-val,
        可以用 vector 存这个结构体,加载和写入都快。
    2. 为什么只提供 InsertFind
      • 因为这个表就是用来保存牌力的,根本不需要提供迭代器,或修改接口之类的。
        说白了,这表就是能快速判断出牌型。两个函数,一个用来创建表,一个用来查。
    3. 有什么需要注意的吗?
      • 有的,咱来看看实现。
#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仓库
这片文章,讲解了具体的设计实现流程笔记 命令行德州扑克游戏

  • 当然,还有这里还有一些问题,
    1. 如何去泛化这个哈希表?
    2. C(M,K) 因为涉及到阶乘,其实数值很容易溢出。
    3. 如果用 redis 数据库去存储这个表,效率会损失多少?
    4. 原理上可以实现下标逆推组合,该怎么实现?

以上,觉得有帮助的,希望能点个赞,感谢阅读。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

人生苦难处

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值