前言
——什么环节只要用算法判断一次,就能知道是否听牌立直、还差什么牌就可以荣和自摸?
——只要在缺一张手牌(如1、4、7、10、13张时)的情况下判断是否听牌、听哪些牌,就可以为上面的复杂判断提供基础。
但网上大部分方法会用大量遍历、查表 等方法,解决效率问题这也就是我探索新方法的初衷
分类
分类思路
为了简明地探讨这个问题,我先举一个已经和牌的例子:
🀇🀈🀉 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 🀃🀃
如果未立直被点北风,那就是个很惨的役牌1番40符233
为了更好看清,我分为了4个面子和1个雀头,这时如果拿走一张牌就能,让它变成听牌的形式,共有3种情况:
首先下一个定义:几张连续或相同的牌,我称为1块(Block
),下面例子中会用空格分开各块
🀇🀈🀉 🀜🀝🀞 🀖🀗🀘 🀆🀆 🀃🀃
🀈🀉 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 🀃🀃
拿走顺子的坎(嵌)张,变成6 块(这是听牌情况下,块数最多的情况)
🀇 🀉 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 🀃🀃
🀇🀈🀉 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 🀃
此外,和牌时还有更复杂的复合形式,即有一块里既有雀头又有面子,但归根结底还是上面这些形式的复合,这里举个简单的例子:
🀇🀈🀉🀊🀊 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆
它如果缺一张四万时,会形成一块不完整型,既含有雀头也含有面子:
🀇🀈🀉🀊 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆
它如果缺一张二万时,会形成两块不完整型,既含有雀头也含有面子:
🀇 🀉🀊🀊 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆
这些便是所有情况的基本形式,我把它分为4大类,6小类,下面我将依次介绍:
完整型判断Lv.1
完整型判断Lv.2
~指“不完整型”
牌数
IntegrityType
类型名
判断听牌方法
备注
3n
Type0
完整型
直接判断(IntegrityJudge())
TypeEx
雀半不完整型
去对+取坎张
半与雀头 合并而成
3n+1
Type1
半不完整型
取坎张(会成对出现)
雀面不完整型
遍历+去对
雀头与面子 合并而成
3n+2
Type2
雀头不完整型
去对(去掉一个对子)
面子不完整型
遍历(3-9次)+与前后块连接
完整型(3n):顾名思义,只含有面子(刻子或顺子)的块,牌数是3的倍数,可以直接判断。但可能和半不完整型一同出现:
🀜🀝🀞 或者 🀜🀝🀞🀟🀟🀟
雀头不完整型(3n+2):包含一个雀头,虽然牌数不是3的倍数,但较完整,去对 (去掉一个对子)就可以判断出缺(听)的牌:
🀃🀃 或者 🀇🀈🀉🀊🀊
面子不完整型(3n+2):即完整型缺一张牌(但不会形成两块),听牌时会和雀头不完整型一起出现,形成多面听或者双碰,用遍历 (在该块的范围内遍历,遍历的次数不多)的方法可以找出:
🀇🀈 或者 🀇🀈🀉🀊🀋
(根据不同牌型,遍历次数比不同牌的数量 多0~2 次(至少3次、至多9次)就可以)
雀面不完整型(3n+1):即雀头不完整型缺一张牌,因为不知道缺在雀头还是在面子上,所以只能用遍历 (但遍历次数不多)后再去对 来处理:
🀇 或者 🀇🀈🀉🀊
半不完整型(3n+1):即听坎张,所以在听牌时都会成对出现,所以取坎张 (两块中间的那张)就可以了:
🀇 🀉 或者 🀇 🀉🀊🀊
雀半不完整型(3n):也听坎张,所以会和半不完整型一同出现,所以先在去对 后取坎张 就可以了(图同上)
综上,所有牌型都可以分为如上6种情况处理,可以算是一种归类或者剪枝(?)
接下来便可以写代码了,首先得先按数量分成几块(Block
),才能进行更深层的操作:
分类代码实现
首先,这个算法是针对每一家手牌进行判断的,所以针对Opponent
类编写常用的判断关系 和手牌进张 的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public static class OpponentHelper { public static int GetRelation (this List<Tile> hands, int num ) { try { return hands[num + 1 ].Val - hands[num].Val; } catch (Exception) { return int .MaxValue; } } public static int TileIn (this List<Tile>hands, Tile tile ) { var ru = 0 ; while (ru < hands.Count && tile.Val > hands[ru].Val) ++ru; hands.Insert(ru, tile); return ru; } }
开始写Opponent
类里ReadyHandJudge()
函数里的内容:
首先声明一个readyHands
铳牌列表,用于储存听的牌
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class Opponent { ... public List<Tile> ReadyHandJudge () { var readyHands = new List<Tile>(); ... } ... }
然后是特殊牌型的判断(国士无双和七对子):
由于算法很简单也很多样,我就不做详细介绍,只有大致介绍:
这里我的牌对应数字的定义有一些优势:
一萬 ~ 九萬
0 ~ 8
一筒 ~ 九筒
16 ~ 24
一索 ~ 九索
32 ~ 40
東
48
南
56
西
64
北
72
白
80
發
88
中
96
牌的序号*8就是对应的幺九牌,此外用shortage
和redundancy
两个bool
型变量便可以轻松实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 public class Opponent { ... private IEnumerable<Tile> ThirteenOrphansJudge () { var shortage = false ; var redundancy = false ; var shortTile = 0 ; for (var i = 0 ; i < 13 ; ++i) { var temp = (shortage ? 1 : 0 ) - (redundancy ? 1 : 0 ); if (Hands[i].Val == (i + temp - 1 ) * 8 ) { if (redundancy) yield break ; redundancy = true ; } else if (Hands[i].Val == (i + temp + 1 ) * 8 ) { if (shortage) yield break ; shortage = true ; shortTile = i * 8 ; } else if (Hands[i].Val != (i + temp) * 8 ) yield break ; } if (redundancy) yield return new (shortage ? shortTile : 96 ) ; else for (var i = 0 ; i < 13 ; ++i) yield return new (i * 8 ) ; } ... }
由于日麻没有龙七对的役种,只好逐张判断,一般情况下偶数序号牌和下一张是相同的,而奇数的和下张不是相同:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public class Opponent { ... private Tile? SevenPairsJudge() { var single = false ; var singleTile = 0 ; for (var i = 0 ; i < 12 ; ++i) if (((i + (single ? 1 : 0 )) % 2 ^ (Hands.GetRelation(i) > 0 ? 1 : 0 )) > 0 ) { if (Hands.GetRelation(i) is 0 || single) return null ; single = true ; singleTile = Hands[i].Val; } if (!single) singleTile = Hands[12 ].Val; return new (singleTile); } ... }
接下来是判断完整型:
首先写Block
类,其成员字段拥有3个,在代码片中有注释;通常在创建新Block
时就已经确定了其中FirstLoc
的值,所以为只读:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 public class Block { public int Len { get ; set ; } = 1 ; public IntegrityType Integrity { get ; set ; } = IntegrityType.Type0; public int FirstLoc { get ; } public int LastLoc => FirstLoc + Len - 1 ; public enum IntegrityType { Type0, Type1, Type2, TypeEx } public Block (int loc ) => FirstLoc = loc; ... }
然后写Block
的判断:
每块按牌数初步被判断为4大类,由IntegrityType
枚举记录。不难发现,在如下这种听牌情况时,块数达到了最多的6块,不完整型最多3块:
🀇 🀉 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 🀃🀃
而且在找到下一块的开头时,也会得到上一块的总长度,所以把上一块收尾和下一块的开头写在同一个循环体内 由于判断听牌时,我们只关心不完整块,所以只返回不完整块(其中判断雀不完整型所用方法IntegrityJudge()
在下一节介绍):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 public class Opponent { ... private List<Block> GetBlocks (out List<Block> blocks ) { var errBlocks = new List<Block>(4 ); blocks = new (6 ) { new (0 ) }; for (var i = 0 ; i < Hands.Count - 1 ; ++i) if (Hands.GetRelation(i) > 1 ) { blocks[^1 ].Len = i - blocks[^1 ].FirstLoc + 1 ; blocks[^1 ].Integrity = (blocks[^1 ].Len % 3 ) switch { 0 => Block.IntegrityType.Type0, 1 => Block.IntegrityType.Type1, 2 => Block.IntegrityType.Type2, _ => throw new ArgumentOutOfRangeException() }; if (blocks[^1 ].Integrity is not Block.IntegrityType.Type0) errBlocks.Add(blocks[^1 ]); if (blocks.Count + Melds.Count is 6 || errBlocks.Count is 4 ) return new (); blocks.Add(new (i + 1 )); } { blocks[^1 ].Len = Hands.Count - blocks[^1 ].FirstLoc; blocks[^1 ].Integrity = (blocks[^1 ].Len % 3 ) switch { 0 => Block.IntegrityType.Type0, 1 => Block.IntegrityType.Type1, 2 => Block.IntegrityType.Type2, _ => throw new ArgumentOutOfRangeException() }; if (blocks[^1 ].Integrity is not Block.IntegrityType.Type0) errBlocks.Add(blocks[^1 ]); if (errBlocks.Count is 4 ) return new (); } foreach (var block in blocks.Where(block => block.Integrity is Block.IntegrityType.Type0 && !block.IntegrityJudge(Hands))) if (errBlocks.Count is not 4 ) { block.Integrity = Block.IntegrityType.TypeEx; errBlocks.Add(block); errBlocks.Add(new (0 )); errBlocks.Add(new (0 )); } else return new (); return errBlocks; } }
下面就要写重要的判断完整型方法(IntegrityJudge()
):
完整型判断
完整型判断思路
为了更好看清每块的内部结构,我们需要继续细分: 定义:块(Block
)内所有相同的牌分为1组(Group
) 如此,例如:
示意图:整张图都是属于一个块的,每一列都是一个组
🀇🀇🀇🀈🀉🀉🀊🀊🀊🀋🀋🀌
然后想象自己是程序,用自动机式的思维,从最左边的第0组开始,一组一组地判断:
拿上图举例:
第一次 ——
第二次 ——
第三次 ——
第四次 ——
🀇 🀆🀆🀊 🀆🀆 🀇 🀆🀉 🀊 🀋 🀆 🀇 🀈 🀉 🀊 🀋 🀌
判断出这是完整型了,很简单吧?
如果是如下的牌型呢?
🀇🀇🀇🀇🀈🀉🀉🀊🀊🀊🀋🀋
第一次 ——
第二次 ——
第三次 ——
🀇 🀆🀆🀆🀆 🀇 🀆🀆🀊🀆 🀇 🀆🀉 🀊 🀋 🀇 🀈 🀉 🀊🀋
杠到第四次时,发现四万有2张,理应杠掉五万和六万各2张,但是不够了,所以这不是完整型
这种方法可以正确分离所有的类型,除了三连刻无法识别成3条顺子:
🀇🀇🀇🀈🀈🀈🀉🀉🀉
但是就听牌来说,这并不会影响到是否听牌、听哪些牌的判断,而且之后改进也十分容易
完整型判断代码实现
根据原理,实现这个并不难(写在Block
类下):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 public class Block { ... private enum TileType { Sequence, Triplet }; public bool IntegrityJudge (List<Tile> hands, int eyesLoc = -1 ) { var groups = GetGroups(hands); var blockTiles = new TileType[Len]; for (var i = 0 ; i < blockTiles.Length; ++i) blockTiles[i] = TileType.Sequence; if (eyesLoc is not -1 ) { ++groups[eyesLoc].Confirmed; ++groups[eyesLoc].Confirmed; blockTiles[groups[eyesLoc].Loc - FirstLoc] = TileType.Triplet; blockTiles[groups[eyesLoc].Loc - FirstLoc + 1 ] = TileType.Triplet; } for (var i = 0 ; i < groups.Count; ++i) { switch (groups[i].Len - groups[i].Confirmed) { case 0 : continue ; case 1 : if (groups.Count > i + 2 ) { ++groups[i + 1 ].Confirmed; ++groups[i + 2 ].Confirmed; continue ; } break ; case 2 : if (groups.Count > i + 2 ) { ++groups[i + 1 ].Confirmed; ++groups[i + 1 ].Confirmed; ++groups[i + 2 ].Confirmed; ++groups[i + 2 ].Confirmed; continue ; } break ; case 4 : if (groups.Count > i + 2 ) { ++groups[i + 1 ].Confirmed; ++groups[i + 2 ].Confirmed; blockTiles[groups[i].Loc - FirstLoc] = TileType.Triplet; blockTiles[groups[i].Loc - FirstLoc + 1 ] = TileType.Triplet; blockTiles[groups[i].Loc - FirstLoc + 2 ] = TileType.Triplet; continue ; } break ; case 3 : blockTiles[groups[i].Loc - FirstLoc] = TileType.Triplet; blockTiles[groups[i].Loc - FirstLoc + 1 ] = TileType.Triplet; blockTiles[groups[i].Loc - FirstLoc + 2 ] = TileType.Triplet; continue ; default : break ; } Integrity = eyesLoc is -1 ? IntegrityType.TypeEx : IntegrityType.Type2; return false ; } return true ; } }
其他不完整型判断
不完整型判断思路
有了IntegrityJudge()
函数,剩下的一切都很明朗了:只要想办法往完整型上凑就好了。之前说了如果是听牌的牌型,不完整型(errBlock
)最多只能有3个,那分别有1、2、3个时,会有特征吗? 答案是有,而且有较为明显的区别:
🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 其中的 🀇🀈🀉🀊
有2个时:会有一个雀头完整型和一个面子不完整型 ,例:
🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 其中的 🀃🀃 🀈🀉
有3个时:会有一个雀头完整型 和两个半不完整型 ,如:
🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 其中的 🀃🀃 🀇 🀉
特殊:在完整型判断Lv.1时只有一个半不完整型 ,而完整型判断Lv.2时会发现一个牌数为3n的雀半不完整型,例如:
🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 其中的 其中的 🀇 🀉🀊🀊
所以可以用一个switch
语句,来讨论这4种情况:
注:七对子可能复合二杯口,在复合的时候应该删除七对子的听牌,以防重复听牌
注:遍历有两种模式,一种遍历后直接判断是否完整(面子不完整型),一种遍历后还要去对(雀面不完整型),所以参数列表里还有个bool
类型表示是否要去对
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 public class Opponent { ... public List<Tile> ReadyHandJudge () { var readyHands = new List<Tile>(); var sevenPairsFlag = false ; if (Melds.Count is 0 ) { if (ThirteenOrphansJudge().ToList() is { Count: not 0 } readyHandsList) return readyHandsList; if (SevenPairsJudge() is { } tile) { readyHands.Add(tile); sevenPairsFlag = true ; } } var errBlocks = GetBlocks(out var blocks); switch (errBlocks.Count) { case 1 : { readyHands.AddRange(errBlocks[0 ].Traversal(Hands, true )); var index = blocks.IndexOf(errBlocks[0 ]); if (index is not 0 ) { var joint = JointBlocks(blocks[index - 1 ], blocks[index]); if (joint?.JointedBlock.IgnoreEyesJudge(joint.Value.JointedHands) is true ) readyHands.Add(joint.Value.MiddleTile); } if (index != blocks.Count - 1 ) { var joint = JointBlocks(blocks[index], blocks[index + 1 ]); if (joint?.JointedBlock.IgnoreEyesJudge(joint.Value.JointedHands) is true ) readyHands.Add(joint.Value.MiddleTile); } break ; } case 2 : { if (errBlocks[1 ].IgnoreEyesJudge(Hands)) readyHands.AddRange(errBlocks[0 ].Traversal(Hands, false )); if (errBlocks[0 ].IgnoreEyesJudge(Hands)) readyHands.AddRange(errBlocks[1 ].Traversal(Hands, false )); break ; } case 3 : { var eyesIndex = errBlocks .FindIndex(eyesBlock => eyesBlock.Integrity is Block.IntegrityType.Type2); if (eyesIndex is 1 || !errBlocks[eyesIndex].IgnoreEyesJudge(Hands)) break ; var joint = eyesIndex is 0 ? JointBlocks(errBlocks[1 ], errBlocks[2 ]) : JointBlocks(errBlocks[0 ], errBlocks[1 ]); if (joint is null ) break ; if (joint.Value.JointedBlock.IntegrityJudge(joint.Value.JointedHands)) readyHands.Add(joint.Value.MiddleTile); break ; } case 4 : { var joint = errBlocks[0 ].FirstLoc < errBlocks[1 ].FirstLoc ? JointBlocks(errBlocks[0 ], errBlocks[1 ]) : JointBlocks(errBlocks[1 ], errBlocks[0 ]); if (joint is null ) break ; if (joint.Value.JointedBlock.IgnoreEyesJudge(joint.Value.JointedHands)) readyHands.Add(joint.Value.MiddleTile); break ; } } if (sevenPairsFlag && readyHands.Count > 1 ) readyHands.RemoveAt(0 ); return readyHands; } ... }
有了上面的解释,这段代码应该不难理解,现在该写取坎张、遍历和去对的算法了:
🀓🀔 其中的 🀒🀓🀔🀕
所以说遍历次数比不同牌的数量 多0~2 次(至少3次、至多9次)就可以,在上图情况下需要遍历不同的牌数2 +2 次,而如果块中含有幺九牌或字牌,遍历次数能减少1~2 次
去对:找到所有的对子,每次去掉一个,查看它是否完整;这里直接传递给IntegrityJudge()
,让它直接认为那个对子是刻子的一部分,就可以排除对子了
不完整型判断代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 public class Opponent { ... private (List<Tile> JointedHands, Block JointedBlock, Tile MiddleTile)? JointBlocks(Block frontBlock, Block followBlock) { if (followBlock.FirstLoc - frontBlock.LastLoc is not 1 ) return null ; if (Hands.GetRelation(frontBlock.LastLoc) is not 2 ) return null ; var tempReadyHands = new Tile(Hands[frontBlock.LastLoc].Val + 1 ); var jointedHands = new List<Tile>(); var jointedBlock = new Block(0 ) { Len = frontBlock.Len + 1 + followBlock.Len }; jointedHands.AddRange(Hands.GetRange(frontBlock.FirstLoc, jointedBlock.Len - 1 )); jointedHands.Insert(frontBlock.Len, tempReadyHands); return (jointedHands, jointedBlock, tempReadyHands); } ... } public class Block { ... public IEnumerable<Tile> Traversal (List<Tile> hands, bool mode ) { var first = hands[FirstLoc].Val - 1 ; if ((hands[FirstLoc].Val & 15 ) is 0 || hands[FirstLoc].Val / 8 > 5 ) ++first; var last = hands[FirstLoc + Len - 1 ].Val + 1 ; if ((hands[FirstLoc + Len - 1 ].Val & 15 ) is 8 || hands[FirstLoc + Len - 1 ].Val / 8 > 5 ) --last; var tempBlock = new Block(0 ) { Len = Len + 1 }; var tempTile = first; for (var i = 0 ; i < last - first + 1 ; ++i, ++tempTile) { var tempHands = new List<Tile>(); for (var j = FirstLoc; j < FirstLoc + Len; ++j) tempHands.Add(new (hands[j].Val)); tempHands.TileIn(new (tempTile)); if (mode switch { true => tempBlock.IgnoreEyesJudge(tempHands), false => tempBlock.IntegrityJudge(tempHands) }) yield return new (tempTile ) ; } } public bool IgnoreEyesJudge (List<Tile> hands ) { for (int i = FirstLoc, tempGroupNum = 0 ; i < FirstLoc + Len - 1 ; ++i) { if (hands.GetRelation(i) is 1 ) ++tempGroupNum; else if (IntegrityJudge(hands, tempGroupNum)) return true ; } return false ; } }
以上就是全部的听牌算法,代码并不长,理论上可以判断出日麻里所有听的牌(不考虑振听、空听情况下),大家可以自己实验一下23333
完整代码
C#:https://github.com/Poker-sang/Mahjong/tree/master/c#
C++(C++/CLI):https://github.com/Poker-sang/Mahjong/tree/master/MahjongHelper
C++(采用C++20标准):https://github.com/Poker-sang/Mahjong/tree/master/Cpp
规则参考
日麻百科
资深麻友
雀魂麻将
雀姬麻将