JAVA麻将胡牌算法深度解析

news2025/3/28 7:25:15

目录

麻将的基本概念

麻将牌的构成

麻将的碰,杠,吃,听,胡

麻将胡牌条件

胡牌算法简介

选将拆分法

算法数据结构

构建数据结构

数据结构使用

牌花色的获取

获取某一花色的牌值

获取某一张牌相邻牌

算法代码实现

基础代码校验

选将代码实现

每种花色胡判定

花色胡判定缓存

万条筒花色胡判定逻辑

字牌花色胡判定逻辑

最终代码

总结


麻将的基本概念

麻将牌的构成

麻将牌是由下面的牌组成:

1.万字牌:1-9共9张(每个数字各一张)比如1万2万3万等

2.条子牌:1-9共9张(每个数字各一张) 比如1条,2条,3条等

3.筒子牌:1-9共9张(每个数字各一张) 比如1筒,2筒,3筒等

4.字牌:东南西北风各4张,红中、发财、白板各4张。

由基本牌组合而成的 万,条,筒的牌的数目为 4* 9 * 3 = 108 由字牌组成的牌的数量为 4 * 7 = 28 

所以整副牌就由136张牌组成。花牌(春夏秋冬)等不计算。

麻将的碰,杠,吃,听,胡

        碰、杠、吃是麻将游戏中常用的三种操作,以便于玩家将手中的牌进行组合,以尽可能地增加胡牌的机会。具体意义如下:

        碰:当玩家手中有两张相同的牌,而桌上已有一张相同的牌时,玩家可以选择碰此牌,即将自己手中的两张相同的牌与桌上的一张相同的牌组成一个刻子。

        杠:当玩家手中有一张牌,而桌上已有三张相同的牌时,玩家可以选择杠此牌,即将自己手中的一张牌与桌上的三张相同的牌组成一个杠。这里面杠有多种,在算法中一定要注意处理。 具体分类如下:

        弯杠:当自己手中有两张牌,别人打出一张牌自己进行了碰操作,然后自己又摸到了一张牌,形成了杠

        明杠:自己手中有3张牌,别人打出一张牌,形成的杠

        暗杠: 自己摸到了4张一样的牌为暗杠

        吃:当桌上有玩家打出一张牌后,其他玩家如果手中有两张数字连着的牌,就可以选择吃此牌,即将桌上的牌和手中的牌组合成一个顺子。

        听:听牌是麻将游戏中的一个术语,指的是在某一时刻,手牌中的牌差一张即可和牌,当前的手牌一定是 3n+1张。

        胡:在麻将游戏中,“胡”指的是牌手通过组成某些牌型,使结束时所剩余的牌组满足胡牌规则,从而赢得游戏的过程。

麻将胡牌条件

麻将想要胡牌,必须得满足一个条件就是 3n + 2

3 指的是由3张牌组成的面子( 顺子或者是刻子)

n 是指由n个(大于等于0)组成的面子

2 指的就是由2张牌组成的将,这个将是必不可少的,面子可以不存在,但是将必须得存在,也就是说 n可以为0,但是2必须存在。如下图所示:

        举个例子,当手牌为14张时 正好满足3n +2 3x4 + 2 = 14 。表示由4组面子和一对将组成的手牌。再举个例子当手牌为 1万1万1万 5万5万 也是满足3n+2,也可以满足胡牌的条件。

胡牌算法简介

        胡牌算法实现起来有多种多样,但是大致分为两种,一种是使用回溯算法,一种是查表法

        查表法是利用事先生成好的所有的胡牌牌型,然后将这些牌型加载到内存中,直接在内存中对比即可,效率非常快,缺点只不灵活,需要事先按规则生成好表。

        我们现在介绍的这种算法也是用到了回溯算法,我们称之为 “选将拆分法” 。

        我们先把手牌中所有可能做将的牌先找出来,然后去掉这组将牌,看剩下的牌是否能满足 3n 条件,如果可以满足则可以胡牌,如果不能满足则不能胡牌。

        这种算法中间利用适当的“剪枝” ,执行起来效率非常的快,同时这种算法对于处理任意赖子的效率也是很强悍,不比查表法慢。

        我后面会逐步的分析如何这种算法,让任何没有基础的人都可以彻底掌握这种算法。我们当前介绍的是普通情况下的胡牌,不计算赖子的情况下。

        赖子的算法我们放在下一篇来讲,只要掌握了这种算法的原理,对于赖子的牌型算法也会很容易理解。

选将拆分法

        选将拆分法就是将手牌中所有可能做将的牌,也可以说任意的牌,只要它的数量大于2,那么它都可以是将。

        我们先把这些可以做将的牌单独的列举出来。然后利用回溯法开始遍历这些将,首先将此将牌从手牌中移除,然后判断剩余的牌是否能满足3n。如果可以满足那么就可以胡牌。当所有的将牌都遍历完了却没有满足3n,则这副手牌不能胡牌。

算法数据结构

构建数据结构

        在前面我们己经讲述过了,牌一共为136张,万,条,筒各9种类型合计共27 种类型,字牌共7种类型,所以牌的类型总体加起来就是 34种类型

        因此我们可以使用一个数组来保存整副牌的数据结构。数组的下标用来表示当前牌的类型,数组的值用来表示当前类型的个数。因此我们就可以得出如下的数据结构:

int[] cards = {
           2,0,0,0,0,0,0,0,0, //-- 1-9万
           0,0,0,0,0,0,0,0,0, //-- 1-9条
           0,0,0,0,0,0,0,0,0, //-- 1-9筒
           0,0,0,0,0,0,0 //- 东南西北中发白
        };

 

        我来举个简单的例子来帮助大家理解下这个数据结构,当前数据结构中数组的下标0的值为2,这就是表示 1万这张牌的个数是2。

数据结构使用

        我们为什么使用这种数据结构而不使用其它的数据结构来构造整副牌呢?主要原因有下面几个,我会逐一为大家阐述清楚。

牌花色的获取

        使用此数据结构可以非常方便的知道任何一张牌所属的花色。我们使用 下标索引 除以9可以得出当前牌所属于的花色,比如 0/9 =0 ,9/9 =1 ,18/9=2 ,27/9 = 3 我们可以得出在数据结构中 万,条,筒它们的花色分别为 0 ,1 ,2。

获取某一花色的牌值

        使用此数据结构可以非常方便的获取任何一种花色上面所有的牌,除了字牌为每种花色的牌都是9张,所以可以使用 (i / 9) * 9 获取此花色的最小值 ,然用用最小值加8就是最大值。

示例代码如下:

int min = (i / 9) * 9;
int max = min + 8;
if (max == 35) max = 33;

        注:这上面的这段代码中因为字牌它的类型共有 7种,所以我们需要特殊处理下。

获取某一张牌相邻牌

        便捷的查找一张牌它左右的牌,到时用来判断是否可以形成顺子。

算法代码实现

基础代码校验

        有了上面的阐述,我们可以写一个简单的胡牌的方法,方法的参数接收的是我们上面说的数据结构造成的手牌数组,这个方法返回一个boolean类型用来判断是否可以胡。代码如下:

/**
 * 判断手牌是否可以胡牌
 * @param handCards
 * <pre>
 * 手牌的格式必须转换为如下数据格式,数组的下标表示牌的类型共34种类型,数组的值表示这个类型牌的数量<br>
 * cards[0]                   =                 2<br>
 *      1万                                     共有2张 <br>
 * int[] cards = {<br>
 *          2,0,0,0,0,0,0,0,0, //-- 1-9万<br>
 *          0,0,0,0,0,0,0,0,0, //-- 1-9条<br>
 *          0,0,0,0,0,0,0,0,0, //-- 1-9筒<br>
 *          0,0,0,0,0,0,0 //- 东南西北中发白<br>
 *     };
 *</pre>
 *
 * @return
 */
public static boolean checkHandCardsCanWin(int[] handCards) {

    int[] cards = new int[34];
    int cardsCount = 0;
    for (int i = 0; i < 34; ++i) {
        cards[i] = handCards[i];
        cardsCount+= handCards[i];
    }
    //当手牌数量不满足3n+2时不构成胡牌条件
    if (!(cardsCount >= 2 && cardsCount <= 14 && (cardsCount - 2) % 3 == 0)) {
        return false;
    }

    boolean hu = false;
    return hu;
}

上面的这段代码就是增加了一个基础的判断,所有类型的牌的数量和是否满足3n+2

选将代码实现

        下面我们修改这段代码增加选将的代码。选将的方法就是遍历所有的牌型然后找出牌的数量大于等于2的牌当作将。

        我们在遍历的同时也需要特殊的处理下一些特殊的牌。我们看一组下面的牌型(W表示万 T表示筒)

 

        这组牌中有一对将 就是 5T,如果我们使用上面分析的选将拆分法,将5T进行拆分出来,然后遍历剩下的牌看是否能够成胡牌,这样子的思路也是可行的。但是我们仔细观察1W 9W 这两张牌,这两张牌它左右,再或者 左左 右右都没有相邻的牌,所以它是一张散牌,必定无法胡牌。

        所以我们在遇到这种散牌的时候,可以直接判定它无法胡,无需再进行下面的判定,提高算法效率。

增加之后的代码如下:

/*
 * 用来存储所有可以做将的牌型
 */
List<Integer> eyeList = new ArrayList<>();
/*
 *   遍历所有的牌类型,找到牌的数量大于等于2的牌
 */
for (int i = 0; i < 34; ++i) {
    //这种类型牌最小值
    int min = (i / 9) * 9;
    //为种类型牌最大值
    int max = min + 8;
    //字牌的特殊处理,字牌的个数为7而其它类型数量为9
    if (max == 35) max = 33;
    if (cards[i] == 1 &&
            (i - 2 < min || cards[i - 2] == 0) &&
            (i - 1 < min || cards[i - 1] == 0) &&
            (i + 1 > max || cards[i + 1] == 0) &&
            (i + 2 > max || cards[i + 2] == 0)) {
        //这种散牌直接无法胡,除非有赖子牌的情况下
        return false;
    }
    if (cards[i] >= 2) {
        eyeList.add(i);
    }
}

        .上面的这段代码在判断散牌的处理上,首先判断了每种花色的范围,确保这张牌的上一张,上上张,下一张,下下张都在它所属的花色范围内,并且数量为0,这样子才能证明这张牌没有任何相邻的牌,是散牌

        下一步我们就是将选举出来的将,从手牌中去除,然后利用回溯法来判断牌型是否满足胡牌条件了,代码如下所示:

/*
 * 遍历所有的将来判断是否可以胡
 */
boolean win = false;

/*
 * 如果没有任何的将直接断定无法胡牌
 */
if (eyeList.size() == 0){
    return false;
}else{
    for (int i = 0; i < eyeList.size(); i++) {
        //将牌所在牌数组中的索引,后面可以根据这个索引直接从牌数组中获取牌的数量
        int eyeIndex = eyeList.get(i);
        //获取将牌的数量
        int n = cards[eyeIndex];
        //首先将[将牌]从牌堆中移除,当此将无法完成胡牌条件时,在将此牌放回牌堆,利用回溯法再进行判定下一张将牌
        cards[eyeIndex] -= 2;
        win = handleCardsWithoutEye(cards);
        cards[eyeIndex] = n;
        if (win) {
            break;
        }
    }
}
return win;

        上面这段代码首先判断了如果将列表为空的情况下直接返回false,无法胡牌,如果将列表不为空的情况下开始遍历所有的将,先把将从手牌中去掉,然后判断接下来的牌是否会胡牌,如果能胡则直接跳出迭代,否则在将这张将牌加入到手牌中。

        然后继续利用回溯算法,不断的迭代,直到找到满足胡牌条件的牌型,或者是所有的将全部迭代完毕后依然没有找到胡牌,则此副手牌不能构造胡牌。

每种花色胡判定

        OK,下面我们继续开始写 handleCardsWithoutEye 这个方法。这个方法就是用来判定除去将之外的牌是否可以构造胡牌条件。

        因为手牌中己经去除了将牌,所以如果当前的手牌构成 3n 那么就构造了胡牌的条件。

        我们知道,手牌当中可能包含 万,条,筒,字 四种不同的花色,如果说每一种花色可以满足胡牌的条件即满足 3n ,那么整体肯定是满足3n条件的。这种思想就是分而治之的思想

代码如下所示:

private static boolean handleCardsWithoutEye(int[] cards) {
    /*
     *  遍历万,条,筒三种花色依次判定此种花色是否可以构造胡牌条件,如果每种花色都可以构造胡牌条件3n则此手牌一定可以胡
     *  这里利用的是分而治之的思想
     */
    for (int i = 0; i < 3; i++) {
        /*
         * 遍历手牌中指定花色的牌
         */
        boolean win = checkNormalCardsWin(cards, i * 9, i * 9 + 8);
        if (!win){
            return false;
        }
    }
    /*
     * 处理字牌花色,字牌的花色为 3
     */
    return checkZiCardsWin(cards);
}

        上面的代码就是分开来讨论万,条,筒,字和字牌,每种花色是否满足3n条件,如果有任意一种不满足则无法胡牌。代码中通过调用 checkNormalCardsWin 将当前花色的取值范围传入方法中,由此方法校验当前花色是否满足 3n。

我们用如下的牌型给大家演示下调用的过程,帮助大家理解下原理。(W表示万 T表示筒)

        我们从上面的牌型中可以得知,一共有两个分别是 1W,5T 。下面我们分别选取不同的将来演变下过程。

当1W为将时:

因为将的花色是万字花色,需要将1W从手牌中去除掉。然后分别遍历3种不同的花色,结果如下:

万(4W,5W,6W):满足3n

筒(1tong,2tong,3tong):满足3n

条(1T,2T,3T,4T,5T,5T):不满足

当5T为将时:

将5T从手牌中去除掉,然后分别遍历3种不同的花色,结果如下:

万(1W,1W,4W,5W,6W):不满足3n

筒(1tong,2tong,3tong):满足3n

条(1T,2T,3T,4T):不满足

 

        不知大家有没有发现,无论是哪种花色为将,都将遍历三种花色,不过由于当前将牌的花色由于去掉了将所以牌不是完整的,但是其它的两种花色的牌是完整的。也就是说我们可以将其它的两种花色缓存起来,当下次的将如果还是万花色,那么我们可以直接获取 条,筒两种花色的判定结果。

        缓存的思路就是将当前非将牌的花色的判定结果进行缓存,所以当如果换了将之后,我们直接取非将花色的判定结果,无需进行计算。

        如当前我们先遍历的是1W的将,所以我们可以将筒,条两种花色进行缓存。

        在遍历5T将时,我们可以直接从缓存中获取 万,条两种花色的缓存的值,由于在缓存中只有条花色的缓存,所以条花色的判定结果可以直接从缓存中获取。

花色胡判定缓存

        根据上面我们分析的思路我们可以修改下代码加入缓存。这里我们使用一个数组来保存每种花色的判定结果

int[] checkedCache = {0, 0, 0, 0};

        使用数组的下标索引来保存花色,值来保存判定结果,在这里我们使用 0表示未判定 1表示成功 2表示失败。

        好的,我们按上面的思路修改下我们的代码,修改后的 handleCardsWithoutEye 代码如下:

/**
 * 将手牌分开不同的花色进行分别判定,如果万,条,筒,字牌每种花色都能满足胡牌条件则此手牌一定可以胡
 * @param cards     hand cards
 * @param eye_color
 * @param checkedCache
 * @return whether can hu
 */
private static boolean handleCardsWithoutEye(int[] cards,int eyeColor, int[] checkedCache) {
    /*
     *  遍历万,条,筒三种花色依次判定此种花色是否可以构造胡牌条件,如果每种花色都可以构造胡牌条件3n则此手牌一定可以胡
     *  这里利用的是分而治之的思想
     */
    for (int i = 0; i < 3; i++) {
        /*
         * 参数中传入的将的花色是否和当前遍历的花色一样,如果一样则不处理,如果不一样则将其它花色的胡的判定结果存储起来
         * 方便下次在遍历同样花色的将的时候,其它的花色的判定直接从缓存中获取不需要在重新的判定,提升算法效率
         * 比如当前传入的将为 7万 我们可以把 条,筒两种花色的判定结果保存起来
         * 当传入的将为 8万时  我们可以直接从缓存中获取到其它 条,筒花色的判定结果无需重新判定
         */
        int cacheIndex = -1;
        if (eyeColor != i) {
            cacheIndex = i;
        }
        /*
         * 遍历手牌中指定花色的牌
         */
        boolean win = checkNormalCardsWin(cards, i * 9, i * 9 + 8,cacheIndex,checkedCache);
        /*
         * 当前花色如果不是传入的将的花色则将判定结果进行存储 1表示判定成功 2表示判定失败
         */
        if (cacheIndex >0 && win){
            checkedCache[i] = 1;
        }
        if (cacheIndex >0 && !win){
            checkedCache[i] = 2;
        }
        if (!win){
            return false;
        }
    }
    /*
     * 处理字牌花色,字牌的花色为 3
     */
    int cacheIndex = -1;
    if (eyeColor != 3){
        cacheIndex = 3;
    }
    return checkZiCardsWin(cards,cacheIndex,checkedCache);
}

        上面的代码将每种非当前将牌的花色进行缓存,向缓存中放置值是由方法 checkNormalCardsWin 来实现的,同时此方法也是重点处理每种花色是否满足3n的核心逻辑方法。

        下面我们开始分析如何实现此方法。

万条筒花色胡判定逻辑

checkNormalCardsWin 前面己经讲过主要是用来处理某一花色是否满足3n条件。这个方法接收的参数有 手牌cards,此花色开始花色索引,结束索引。有了这些我们就可以获取到此花色中所有的牌。

比如有一幅手牌它如下所示:

它用我们算法中的数据结构表示如下:

 

int[] cards = {
        2,1,1,1,1,1,1,0,0, //-- 1-9万
        1,1,1,1,1,1,0,0,0, //-- 1-9条
        0,0,0,0,0,0,0,0,0, //-- 1-9筒
        0,0,0,0,0,0,0 //- 东南西北中发白
};

        我们获取到万花色的索引为 0-8,获取到的牌为 2,1,1,1,1,1,1,0,0 ,我们根据数字所在数组中的索引和具体的值就可以得出所需要的所有信息。接下来我们就要对这些数字进行加工,判断是否满足3n 。

        由于这些数字是组数中的一部分,操作起来不太方便,我们可以将这些数字提取出来,我们将这些数字按照它在数组中的索引位置,将它组成一个数字方便后面的判定运算。

        因为万,条,筒每种花色都有9张牌,所以我们可以使用一个9位数的数字来表示这个花色,这个数字从低位到高位分别保存1万-9万 或 1-9条 1-9筒,位数表示牌的类型。

        比如个位表示1万 十位表示2万 千万表示3万,而位数上面的值表示牌的数量。

        这其实和我们之前使用数组的道理是一样的,数组的索引表示牌类型,数组的值表示此牌的个数。

        举个例子,如下这个数字 001111112 它表示有 【2个1万 1个2万 1个3万 1个4万 1个5万 1个6万 1个7万 0个8万 0个9万】示意图如下所示:

0     0     1    1    1     1    1     1     2

9万 8万 7万 6万 5万 4万 3万 2万 1万

将数组中的这些数字组合成一个数字也比较容易,代码如下:

int n = 0;
for (int i = beginIndex; i <= endIndex; i++) {
    n = n * 10 + cards[i];
}

        就是每次在循环值的时候,都将这个值放置在数字上面的一个高位上面。

好了,分析完上面的逻辑,我们现在可以写一个完整的 checkNormalCardsWin 方法了,代码如下所示:

/**
 * 检查当前花色是否满足胡牌条件 即是否满足 3n
 * @param cards 手牌
 * @param beginIndex 当前花色开始索引
 * @param endIndex 当前花色结束索引
 * @param cache_index 要查询的缓存的花色索引
 * @param checkedCache 缓存
 * @return 是否可以胡 true / false
 */
private static boolean checkNormalCardsWin(int[] cards, int beginIndex, int endIndex,int cacheIndex, int[] checkedCache) {
    /*
     * 如果当前要判定的花色与将的花色一样那么 cacheIndex 值为-1,不从缓存中获取
     * 否则从缓存中拿判定的结果,无需重新判定
     */
    if (cacheIndex >= 0) {
        int n = checkedCache[cacheIndex];
        if (n > 0) {
            return n - 1 == 0;
        }
    }

    /*
     * 将当前花色中所有的牌组成一个数字,方便后面进行判定.因为万,条,筒每种花色都有9张牌,所以我们可以使用一个9位数的数字来表示这个花色
     * 此数字从低位到高位分别保存1万-9万 或 1-9条 1-9筒,位数表示牌的类型,比如个位表示1万 十位表示2万 千万表示3万,而位数上面的值表示牌的数量
     * 这其实和我们之前使用数组的道理是一样的,数组的索引表示牌类型,数组的值表示此牌的个数
     * 举个例子,如下这个数字  101000111 它表示有 1个1万 1个2万  1个3万  0个4万5万6万 1个7万 0个8万 1个9万,示意图如下所示:
     *
     * 1        0       1       0       0       0       1           1       1
     * 9万      8万     7万      6万     5万     4万      3万         2万      1万
     */
    int n = 0;
    for (int i = beginIndex; i <= endIndex; i++) {
        n = n * 10 + cards[i];
    }
    /*
     * 0表示此花色己经没有牌,满足3n
     */
    if (n == 0) {
        return true;
    }
    /*
     * 检查当前花色牌的数量是否满足 3n
     * 由于n是由多张牌组成,我们要判断此数字上面所有位数上面的数字的和是否能被3整除
     * 由简单的数学知识得知: 想要判断一个数字上面所有位数上面的和能被整除,只要此数字能被3整除即可
     */
    if (n  % 3 != 0) {
        return false;
    }

    /*
     * 开始拆分此数字n,如果数字n可以被拆分成n个顺子或者刻子则可以胡
     */
    return splitCards(n);
}

        代码中的一些逻辑上面己经讲到了,我们就不在赘述了,我们将处理分割 这个数字 n 的逻辑放在一个新的方法 splitCards 来完成。因为我们在拆分的时候要用到迭代,所以这块的逻辑由一个单独的方法来完成。

        下面我们开始分析下这个 splitCards 方法如何实现。这个方法接收一个由牌组成的数字,比如我们上面提及的由万字花色组成的数字 00111111 它表示有 【1个2万 1个3万 1个4万 1个5万 1个6万 1个7万 0个8万 0个9万】

        我们在拆分时从数字的低位开始一直向高位拆除,看拆除下来的这个数字是否可以组成刻子,或者是与前面的数字组成顺子。如果可以则表示拆除成功。将拆分成功后的新数字继续使用迭代算法拆分,直到将这个数字拆除完毕。如果其中某一步无法拆除,则这组牌不能构成胡牌。我们每拆除一个数字用 P 表示 此数字前面的数字和前前面的数字我们使用 P1 ,P2来表示。

在拆除的时候要看这个数字来决定具体拆分为顺子,还是刻子。具体拆分规则如下:

当P = 1时 与前面的两个数字,拆分为 1 1 1 顺子

当P =2 时 与前面的两个数字,拆分为 2 2 2 两个顺子

当P = 3时 直接拆分为刻子

当P = 4时 直接拆分为1 和 3 所以P = 4 时与 P=1 时为同样的处理

        我们用上面的这个数字 00111111 举例演示下拆除过程,第一步取最低位上面数字 也就是 1 ,当P=1时拆分为 1 1 1 ,所以拆分后的数字为 00111000 ,然后继续拆分最终将这个数字拆分完毕,这个数字最终值为 0 表示完全拆分,这个花色的牌可以胡牌。

        我们再使用一个新的数字,00111112 取最低位上面的数字即 2,当P=2时拆分为 2 2 2 ,但是P前面的两个数字分别为 1 1 所以无法拆分,所以这个花色所构成的牌无法胡。

我们分析完处理逻辑后,下面开始写这个方法,代码如下:

/**
 * 拆分这个数字,直到无法拆分为止,当这个数字为0时表示可以完全拆除成功
 * @param n
 * @return
 */
private static boolean splitCards(int n) {
    int p = 0;
    while (true) {
        if (n == 0) return true;
        /*
         * 找到低位数上不为0的数字
         */
        while (n > 0) {
            p = n % 10;//获取个位数
            n = n / 10;//将n去掉低位数
            if (p != 0) break;
        }
        /*
         * 1和4是一样的处理方法 4可以拆分为 1和3,3直接是刻子不用作处理
         */
        if (p == 1 || p == 4) {
            return singleNumHandle(n);

        } else if (p == 2) {
            return doubleNumHandle(n);

        } else if (p == 3) {
            //刻字不用作处理
        }
    }
}

这个方法就是不断的利用迭代,不断的拆分这个数字N 然后根据拆分下来的数字,使用不同的方法来处理。这里每拆分一次,这个数字N就会减少一个低位,直到这个数字为 0为止

下面我们开始写单个数字的处理代码,方法 singleNumHandle 代码如下所示:

private static boolean singleNumHandle(int n) {
    //获取此数字前面的p1
    int p1 = n % 10;
    //获取此数字前面的p2
    int p2 = (n % 100) / 10;

    if (p1 == 0) {
        return false;
    } else {
        n -= 1;
    }

    if (p2 == 0) {
        return false;
    } else {
        n -= 10;
    }
    //当n ==0 表示此花色的牌完全满足 3n法则 可以胡牌
    if (n == 0) {
        return true;
    }

    return splitCards(n);
}

2个数字的处理代码,如下所示:

/**
 * 尾数为2的处理方法,形式为  2 2 2
 * @param n
 * @return
 */
private static boolean doubleNumHandle(int n) {
    //获取此数字前面的p1
    int p1 = n % 10;
    //获取此数字前面的p2
    int p2 = (n % 100) / 10;

    if (p1 < 2) {
        return false;
    } else {
        n -= 2;
    }

    if (p2 < 2) {
        return false;
    } else {
        n -= 20;
    }
    //当n ==0 表示此花色的牌完全满足 3n法则 可以胡牌
    if (n == 0) {
        return true;
    }

    return splitCards(n);
}

        以上两个方法分别为处理P=1 P=2的情况。代码中的处理逻辑也比较简单,就是比较P前面的两个数字P1 P2是否可以构成 111 ,2 2 2 这两种情况,如果可以造成则将P, P1 ,P2全部消除,然后使用这个新的数字继续迭代,继续判定,直到这个数字为0为止。

        以上的代码就是全部处理万,条,筒三种花色的全部逻辑和代码。在这里我们要注意的是缓存那部分的逻辑,以及迭代拆分数字那部分逻辑,这部分逻辑是核心逻辑。

字牌花色胡判定逻辑

        如果大家己经理解了上面讲的 万,条,筒花色的处理逻辑,那么对于字牌的处理逻辑就显得很简单了。因为字牌只能是组成刻子,所以我们只需要判定某一张牌的数量是否为3即可。当然了,有一些地方的玩法可能字牌之前可以组成顺子,那么就需要特殊处理了,处理方法参照上面万,条,筒花色的处理方法。

最终代码

下面为大家附上一个最终的代码



import java.util.ArrayList;
import java.util.List;

public class MahjongSplitEyeAlgorithm {

    /**
     * 判断手牌是否可以胡牌,使用选将拆分法来实现
     * @param handCards
     * <pre>
     * 手牌的格式必须转换为如下数据格式,数组的下标表示牌的类型共34种类型,数组的值表示这个类型牌的数量<br>
     * cards[0]                   =                 2<br>
     *      1万                                     共有2张 <br>
     * int[] cards = {<br>
     *          2,0,0,0,0,0,0,0,0, //-- 1-9万<br>
     *          0,0,0,0,0,0,0,0,0, //-- 1-9条<br>
     *          0,0,0,0,0,0,0,0,0, //-- 1-9筒<br>
     *          0,0,0,0,0,0,0 //- 东南西北中发白<br>
     *     };
     *</pre>
     *
     * @return true可以胡  false
     */
    public static boolean checkHandCardsCanWin(int[] handCards) {

        int[] cards = new int[34];
        int cardsCount = 0;
        for (int i = 0; i < 34; ++i) {
            cards[i] = handCards[i];
            cardsCount+= handCards[i];
        }
        //当手牌数量不满足3n+2时不构成胡牌条件
        if (!(cardsCount >= 2 && cardsCount <= 14 && (cardsCount - 2) % 3 == 0)) {
            return false;
        }
        /*
         * 用来存储所有可以做将的牌型
         */
        List<Integer> eyeList = new ArrayList<>();
        /*
         *   遍历所有的牌类型,找到牌的数量大于等于2的牌
         */
        for (int i = 0; i < 34; ++i) {
            //这种类型牌最小值
            int min = (i / 9) * 9;
            //为种类型牌最大值
            int max = min + 8;
            //字牌的特殊处理,字牌的个数为7而其它类型数量为9
            if (max == 35) max = 33;
            if (cards[i] == 1 &&
                    (i - 2 < min || cards[i - 2] == 0) &&
                    (i - 1 < min || cards[i - 1] == 0) &&
                    (i + 1 > max || cards[i + 1] == 0) &&
                    (i + 2 > max || cards[i + 2] == 0)) {
                //这种散牌直接无法胡,除非有赖子牌的情况下
                return false;
            }
            if (cards[i] >= 2) {
                eyeList.add(i);
            }
        }

        /*
         * 遍历所有的将来判断是否可以胡
         */
        boolean win = false;

        /*
         * 如果没有任何的将直接断定无法胡牌
         */
        if (eyeList.size() == 0){
            return false;
        }else{
            int[] checkedCache = {0, 0, 0, 0};
            for (int i = 0; i < eyeList.size(); i++) {
                //将牌所在牌数组中的索引,后面可以根据这个索引直接从牌数组中获取牌的数量
                int eyeIndex = eyeList.get(i);
                //获取将牌的数量
                int n = cards[eyeIndex];
                //首先将[将牌]从牌堆中移除,当此将无法完成胡牌条件时,在将此牌放回牌堆,利用回溯法再进行判定下一张将牌
                cards[eyeIndex] -= 2;
                win = handleCardsWithoutEye(cards,eyeIndex / 9,checkedCache);
                cards[eyeIndex] = n;
                if (win) {
                    break;
                }
            }
        }
        return win;
    }


    /**
     * 将手牌分开不同的花色进行分别判定,如果万,条,筒,字牌每种花色都能满足胡牌条件则此手牌一定可以胡
     * @param cards     hand cards
     * @param eye_color
     * @param checkedCache
     * @return whether can hu
     */
    private static boolean handleCardsWithoutEye(int[] cards,int eyeColor, int[] checkedCache) {
        /*
         *  遍历万,条,筒三种花色依次判定此种花色是否可以构造胡牌条件,如果每种花色都可以构造胡牌条件3n则此手牌一定可以胡
         *  这里利用的是分而治之的思想
         */
        for (int i = 0; i < 3; i++) {
            /*
             * 参数中传入的将的花色是否和当前遍历的花色一样,如果一样则不处理,如果不一样则将其它花色的胡的判定结果存储起来
             * 方便下次在遍历同样花色的将的时候,其它的花色的判定直接从缓存中获取不需要在重新的判定,提升算法效率
             * 比如当前传入的将为 7万 我们可以把 条,筒两种花色的判定结果保存起来
             * 当传入的将为 8万时  我们可以直接从缓存中获取到其它 条,筒花色的判定结果无需重新判定
             */
            int cacheIndex = -1;
            if (eyeColor != i) {
                cacheIndex = i;
            }
            /*
             * 遍历手牌中指定花色的牌
             */
            boolean win = checkNormalCardsWin(cards, i * 9, i * 9 + 8,cacheIndex,checkedCache);
            /*
             * 当前花色如果不是传入的将的花色则将判定结果进行存储 1表示判定成功 2表示判定失败
             */
            if (cacheIndex >0 && win){
                checkedCache[i] = 1;
            }
            if (cacheIndex >0 && !win){
                checkedCache[i] = 2;
            }
            if (!win){
                return false;
            }
        }
        /*
         * 处理字牌花色,字牌的花色为 3
         */
        int cacheIndex = -1;
        if (eyeColor != 3){
            cacheIndex = 3;
        }
        return checkZiCardsWin(cards,cacheIndex,checkedCache);
    }

    /**
     * 检查当前花色是否满足胡牌条件 即是否满足 3n
     * @param cards 手牌
     * @param beginIndex 当前花色开始索引
     * @param endIndex 当前花色结束索引
     * @param cacheIndex 要查询的缓存的花色索引
     * @param checkedCache 缓存
     * @return 是否可以胡 true / false
     */
    private static boolean checkNormalCardsWin(int[] cards, int beginIndex, int endIndex,int cacheIndex, int[] checkedCache) {
        /*
         * 如果当前要判定的花色与将的花色一样那么 cacheIndex 值为-1,不从缓存中获取
         * 否则从缓存中拿判定的结果,无需重新判定
         */
        if (cacheIndex >= 0) {
            int n = checkedCache[cacheIndex];
            if (n > 0) {
                return n - 1 == 0;
            }
        }

        /*
         * 将当前花色中所有的牌组成一个数字,方便后面进行判定.因为万,条,筒每种花色都有9张牌,所以我们可以使用一个9位数的数字来表示这个花色
         * 此数字从低位到高位分别保存1万-9万 或 1-9条 1-9筒,位数表示牌的类型,比如个位表示1万 十位表示2万 千万表示3万,而位数上面的值表示牌的数量
         * 这其实和我们之前使用数组的道理是一样的,数组的索引表示牌类型,数组的值表示此牌的个数
         * 举个例子,如下这个数字  101000111 它表示有 1个1万 1个2万  1个3万  0个4万5万6万 1个7万 0个8万 1个9万,示意图如下所示:
         *
         * 1        0       1       0       0       0       1           1       1
         * 9万      8万     7万      6万     5万     4万      3万         2万      1万
         */
        int n = 0;
        for (int i = beginIndex; i <= endIndex; i++) {
            n = n * 10 + cards[i];
        }
        /*
         * 0表示此花色己经没有牌,满足3n
         */
        if (n == 0) {
            return true;
        }
        /*
         * 检查当前花色牌的数量是否满足 3n
         * 由于n是由多张牌组成,我们要判断此数字上面所有位数上面的数字的和是否能被3整除
         * 由简单的数学知识得知: 想要判断一个数字上面所有位数上面的和能被整除,只要此数字能被3整除即可
         */
        if (n  % 3 != 0) {
            return false;
        }

        /*
         * 开始拆分此数字n,如果数字n可以被拆分成n个顺子或者刻子则可以胡
         */
        return splitCards(n);
    }

    /**
     * 拆分这个数字,直到无法拆分为止,当这个数字为0时表示可以完全拆除成功
     * @param n
     * @return
     */
    private static boolean splitCards(int n) {
        int p = 0;
        while (true) {
            if (n == 0) return true;
            /*
             * 找到低位数上不为0的数字
             */
            while (n > 0) {
                p = n % 10;//获取个位数
                n = n / 10;//将n去掉低位数
                if (p != 0) break;
            }
            /*
             * 1和4是一样的处理方法 4可以拆分为 1和3,3直接是刻子不用作处理
             */
            if (p == 1 || p == 4) {
                return singleNumHandle(n);

            } else if (p == 2) {
                return doubleNumHandle(n);

            } else if (p == 3) {
                //刻字不用作处理
            }
        }
    }

    /**
     * 单个数字的处理,需要拆分为  1 1 1 形式
     * @param n
     * @return
     */
    private static boolean singleNumHandle(int n) {
        //获取此数字前面的p1
        int p1 = n % 10;
        //获取此数字前面的p2
        int p2 = (n % 100) / 10;

        if (p1 == 0) {
            return false;
        } else {
            n -= 1;
        }

        if (p2 == 0) {
            return false;
        } else {
            n -= 10;
        }
        //当n ==0 表示此花色的牌完全满足 3n法则 可以胡牌
        if (n == 0) {
            return true;
        }

        return splitCards(n);
    }

    /**
     * 尾数为2的处理方法,形式为  2 2 2
     * @param n
     * @return
     */
    private static boolean doubleNumHandle(int n) {
        //获取此数字前面的p1
        int p1 = n % 10;
        //获取此数字前面的p2
        int p2 = (n % 100) / 10;

        if (p1 < 2) {
            return false;
        } else {
            n -= 2;
        }

        if (p2 < 2) {
            return false;
        } else {
            n -= 20;
        }
        //当n ==0 表示此花色的牌完全满足 3n法则 可以胡牌
        if (n == 0) {
            return true;
        }

        return splitCards(n);
    }

    /**
     * 字牌花色的处理逻辑
     * @param cards
     * @param cacheIndex
     * @param cache
     * @return
     */
    private static boolean checkZiCardsWin(int[] cards, int cacheIndex, int[] checkedCache) {
        /*
         * 如果当前要判定的花色与将的花色一样那么 cacheIndex 值为-1,不从缓存中获取
         * 否则从缓存中拿判定的结果,无需重新判定
         */
        if (cacheIndex >= 0) {
            int n = checkedCache[cacheIndex];
            if (n > 0) {
                return n - 1 == 0;
            }
        }
        /*
         * 字牌的索引为 27至 34 字牌的判定很简单因为字牌只能是组成刻子不能组成顺子,所以我们只需要判定此牌的个数是否为3即可
         */
        for (int i = 27; i < 34; i++) {
            int n = cards[i];
            if (n == 0) {
                continue;
            }
            if (n != 3){
                return false;
            }
        }
        return true;
    }

    public static void main(String[] args) {
        //1万2万3万 1条2条3条 1筒2筒2筒  5万5万5万 8条8条
        int[] case1 = {
                1,1,1,0,3,0,0,0,0, //-- 1-9万
                1,1,1,0,0,0,0,2,0, //-- 1-9条
                1,1,1,0,0,0,0,0,0, //-- 1-9筒
                0,0,0,0,0,0,0 //- 东南西北中发白
        };
        boolean win = checkHandCardsCanWin(case1);
        System.out.println(win);
    }

}

总结

        现在我们把前面所讲的东西做一个小结。判定麻将胡牌的方法,主要考虑用到,回溯算法,分而治之思想。回溯算法用在遍历所有可能真正做将的牌,如果当前的将不是真正的将,那么回溯到下一个将,继续判定,直到找到真正的将,这块的逻辑也是整个算法的核心逻辑所在,在使用回溯算法时要注意适当的应用 “剪纸” 可以加快算法的执行效率。

        而分而治之的思想主要是体现在将手牌分为不同的花色,分别讨论是否满足胡牌条件。如果所有的单个的花色都满足,那么整合也满足。

        这个算法并没有涉及到赖子,如果有赖子的话,那么在选将的方法上面,分而治之讨论单个花色是否可以组成N个顺子,刻子时就要复杂一些。但是总体的思路是不变的。

下一节我们讨论如何完善此算法,支持任意赖子。

如大家有关于此算法任意的问题,均可在留言区或私信我。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/703594.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

word另存为pdf失败的原因及处理方法

我们知道&#xff0c;Word可以通过另存为方式直接保存为PDF&#xff0c;其原理其实跟打印机打印差不多&#xff0c;PDF就是一台虚拟的打印机&#xff0c;但有些同学反映word另存为pdf失败&#xff0c;可能的原因是什么呢&#xff1f;又该如何处理呢&#xff1f; word另存为pdf…

【Java|多线程与高并发】CAS以及ABA问题

文章目录 1. 什么是CAS2. ABA问题3. ABA问题的解决 1. 什么是CAS CAS&#xff08;Compare and Swap,“比较和交换”&#xff09;是一种并发编程中常用的原子操作&#xff0c;用于解决多线程环境下的数据竞争和并发访问问题。 CAS操作包含三个操作数&#xff1a;内存位置&#x…

【Git】 Git初相识

文章目录 1. Git的必要性1.1 提出问题1.2 解决问题1.3 注意事项 2. Git的安装2.1 kali下的安装2.3 Windows下的安装 3. Git的配置 1. Git的必要性 1.1 提出问题 在我们的学习或者工作中&#xff0c;经常会遇到一些比较难搞的上司或者老师&#xff0c;让我们交上去的文档改了又…

英伟达开发板学习系列---通过主机名称进行远程登录

1.前言 我们使用Jetson nx 的时候经常需要使用远程工具&#xff0c;而默认的网络配置IP地址采用动态分配&#xff0c;会造成IP地址不确定的问题&#xff0c;故我们可以设置hostname 唯一名字&#xff0c;通过hostname 进行远程连接。 2.通过主机名称进行登录 1.修改主机名称…

使用docker搭建mysql集群

一、技术架构 1、架构图 2、解说 mysql_1、mysql_2、mysql_3是一组主从模式,同理mysql_4、mysql_5、mysql_6也是一组主从模式从上面的图可以看出mysql_1和mysql_4是主节点,可以进行增删改查操作,但是子几点只能查询操作如果mysql_1节点出现问题了&#xff0c;有mysql_4节点组…

Python:使用prometheus-client提交数据到实现prometheus+ grafana数据监控

相关资料 prometheus文档&#xff1a;https://prometheus.io/grafana文档&#xff1a;https://grafana.com/grafana github: https://github.com/grafana/grafanaPyhton客户端https://pypi.org/project/prometheus-client/ 目录 1、使用Python提供数据源2、启动 prometheus3、…

eDNA暴露人类活动轨迹!你的DNA信息可能随时随地在泄露!

环境DNA&#xff08;environmental DNA, eDNA&#xff09;领域发展迅速&#xff0c;但人类eDNA的应用仍未得到充分利用和重视。eDNA分析的广泛应用将为病原体监测、生物多样性监测、濒危和入侵物种检测以及群体遗传学带来许多公认的好处。 佛罗里达大学野生动物疾病基因组学教…

【pytorch函数笔记】torch.sum()、torch.unsqueeze()

1.torch.sum torch.sum(imgs, dim0) # 按列求和 torch.sum(imgs, dim1) # 按行求和 imgs torch.Tensor([i for i in zip( range(10), range(10))]) print(imgs) s1torch.sum(imgs, dim0) # 按列求和 s2torch.sum(imgs, dim1) # 按行求和 print(s1) print(s2) 2.torch.uns…

在前端开发中使用 Python

推荐给需要鼓捣前端应用又不熟悉 JS 的 Pythoner 简介 在使用 Python 进行数据分析的时候&#xff0c;经常需要创建一些动态、交互式的可视化作品。一般会用到如 Plotly、Pyecharts、Bokeh 等库&#xff0c;这些库都是基于前端技术创建可视化作品。所以在自定义可视化的时候&a…

Python3,有了此库,不需要再为导包顺序而烦恼了,小刀拉屁股,涨知识了。

usort详解 1、引言2、代码实战2.1 usort 安装2.2 代码示例 3、总结 1、引言 小鱼&#xff1a;小屌丝&#xff0c;这段代码是你的写的不&#xff1f; 小屌丝&#xff1a;是我写的啊&#xff0c;咋 鱼哥 小鱼&#xff1a;你看你的导包顺序&#xff0c;挺乱的 小屌丝&#xff1a;…

一些共享资料

大家好&#xff0c;才是真的好。 我们好久没有分享资料了&#xff0c;上次分享还是上次——我也忘记了多久。 本次Engage2023开完之后&#xff0c;从会议上也流出了很多各类技术和主题方面的PPT、PDF资料等&#xff0c;我搜索了一些Domino技术话题有关的共享在这里。 主题涉…

算法----二叉树的最近公共祖先

题目 给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。 百度百科中最近公共祖先的定义为&#xff1a;“对于有根树 T 的两个节点 p、q&#xff0c;最近公共祖先表示为一个节点 x&#xff0c;满足 x 是 p、q 的祖先且 x 的深度尽可能大&#xff08;一个节点也可以是它…

轻量服务器外网访问不了的原因分析

​  轻量服务器外网访问不了原因的分析。很多用户在选择轻量服务器的时候都没考虑&#xff0c;直接就购买了&#xff0c;导致在使用的时候遇见了很多问题&#xff0c;下面我们就简单的聊聊关于轻量服务器外网无法访问的原因。 这里我们按照标题的意思可以解读为两种情况&…

2023最新互联网公司时长排行榜出炉

今天给大家分享一个对于选择公司来说另一个非常重要的参考指标&#xff1a;“互联网时长”。 我们在选择一个公司的时候&#xff0c;除了需要关注总收入package 以外&#xff0c;还需要考虑这家公司的加班时长是否人性化。 我们的工作时长是周工作小时数。法定工作时间是40小…

这8种Python加速运行技巧真棒

关注“Python专栏”微信公众号&#xff0c;回复暗号【面试大全】&#xff0c;立即领取面试题简历模板。 Python 是一种脚本语言&#xff0c;相比 C/C 这样的编译语言&#xff0c;在效率和性能方面存在一些不足。但是&#xff0c;有很多时候&#xff0c;Python 的效率并没有想象…

使用wpf做得计算器

最近在学习wpf&#xff0c;使用wpf做得计算器练手&#xff0c;实现功能增删改查&#xff0c;求平方根&#xff0c;倒数&#xff0c;取反&#xff0c;模拟实体计算器的M,MS,MC,MR功能 上面截图相关的计算为 8乘5加-2加3.2加&#xff08;9除以3的余数&#xff09;

一路乱飙,从手工测试进阶测试开发岗,“我“的测试之路不简单

目录&#xff1a;导读 前言一、Python编程入门到精通二、接口自动化项目实战三、Web自动化项目实战四、App自动化项目实战五、一线大厂简历六、测试开发DevOps体系七、常用自动化测试工具八、JMeter性能测试九、总结&#xff08;尾部小惊喜&#xff09; 前言 测试人员的职能 …

StrSubstitutor和StrUtil占位符字符串替换

目录 一、commons-lang包下的StrSubstitutor1、使用场景一2、使用场景二 二、hutoo包下的StrUtil1、使用场景一2、使用场景二3、使用场景三 三、原生的三种方式 一、commons-lang包下的StrSubstitutor StrSubstitutor是commons-lang包提供的一个字符串替换类&#xff0c;整体使…

Redis实战——短信登录(二)

Redis代替session redis中设计key 在使用session时&#xff0c;每个用户都会有自己的session&#xff0c;这样虽然验证码的键都是“code”&#xff0c;但是相互不影响&#xff0c;从而确保每个用户获取到的验证码只能够自己使用&#xff0c;当使用redis时&#xff0c;redis的ke…

MCU单片机智能控制落地扇解决方案

随着科技进步和消费升级&#xff0c;消费者对电风扇的智能化需求日趋强烈。为了快速响应市场需求&#xff0c;以更低的成本打造智能电风扇。 MCU单片机智能控制落地扇解决方案以灵动微MM32SPIN040C主控。 方案采用的主控MM32SPIN040工作频率可达48MHz&#xff0c;内置16KB Fl…