日麻听牌归类算法

前言

——什么环节只要用算法判断一次,就能知道是否听牌立直、还差什么牌就可以荣和自摸?

——只要在缺一张手牌(如1、4、7、10、13张时)的情况下判断是否听牌、听哪些牌,就可以为上面的复杂判断提供基础。

但网上大部分方法会用大量遍历、查表等方法,解决效率问题这也就是我探索新方法的初衷

分类

分类思路

为了简明地探讨这个问题,我先举一个已经和牌的例子:

🀇🀈🀉 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 🀃🀃

如果未立直被点北风,那就是个很惨的役牌1番40符233

为了更好看清,我分为了4个面子和1个雀头,这时如果拿走一张牌就能,让它变成听牌的形式,共有3种情况:

首先下一个定义:几张连续或相同的牌,我称为1块(Block),下面例子中会用空格分开各块

  • 拿走刻子的一张,还剩5

🀇🀈🀉 🀜🀝🀞 🀖🀗🀘 🀆🀆 🀃🀃

  • 拿走顺子的边张后,还剩下5

🀈🀉 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 🀃🀃

  • 拿走顺子的坎(嵌)张,变成6块(这是听牌情况下,块数最多的情况)

🀇 🀉 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 🀃🀃

  • 拿走雀头的一张,还剩5

🀇🀈🀉 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 🀃

此外,和牌时还有更复杂的复合形式,即有一块里既有雀头又有面子,但归根结底还是上面这些形式的复合,这里举个简单的例子:

🀇🀈🀉🀊🀊 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆

  • 它如果缺一张四万时,会形成一块不完整型,既含有雀头也含有面子:

🀇🀈🀉🀊 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆

  • 它如果缺一张二万时,会形成两块不完整型,既含有雀头也含有面子:

🀇 🀉🀊🀊 🀜🀝🀞 🀖🀗🀘 🀆🀆🀆

  • 这些便是所有情况的基本形式,我把它分为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
{
/// <summary>
/// 两张手牌间关系
/// </summary>
/// <param name="hands">手牌</param>
/// <param name="num">前张牌序号</param>
public static int GetRelation(this List<Tile> hands, int num)
{
try
{
return hands[num + 1].Val - hands[num].Val;
}
catch (Exception)
{
// (尽量大的数)
return int.MaxValue;
}
}

/// <summary>
/// 摸牌
/// </summary>
/// <param name="hands">手牌</param>
/// <param name="tile">进张</param>
/// <returns>插入牌的位置</returns>
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
{
...
/// <summary>
/// 听牌判断(在摸牌前判断)
/// </summary>
/// <returns>听的牌</returns>
public List<Tile> ReadyHandJudge()
{
var readyHands = new List<Tile>();
...
}
...
}

然后是特殊牌型的判断(国士无双和七对子):

由于算法很简单也很多样,我就不做详细介绍,只有大致介绍:

这里我的牌对应数字的定义有一些优势:

一萬 ~ 九萬 0 ~ 8
一筒 ~ 九筒 16 ~ 24
一索 ~ 九索 32 ~ 40
48
56
西 64
72
80
88
96

牌的序号*8就是对应的幺九牌,此外用shortageredundancy两个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
{
...
/// <summary>
/// 国士牌型判断
/// </summary>
/// <returns>听牌</returns>
private IEnumerable<Tile> ThirteenOrphansJudge()
{
// 是否缺了某张幺九牌(0或1)
var shortage = false;
// 是否多了某张幺九牌(0或1)
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;
}
// 若有多张,记听一面或记听一面(红中)(因为红中在最后不会被redundancy记录)
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
{
...
/// <summary>
/// 七对牌型判断
/// </summary>
/// <returns>听的牌</returns>
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
{
/// <summary>
/// 块内牌数(至少一张)
/// </summary>

public int Len { get; set; } = 1;

/// <summary>
/// 类型(真(3n)为完整型(由整数个面子组成),假为不完整型(含有雀头、不完整的面子))
/// </summary>
public IntegrityType Integrity { get; set; } = IntegrityType.Type0;

/// <summary>
/// 块内首张牌的序号
/// </summary>
public int FirstLoc { get; }

public int LastLoc => FirstLoc + Len - 1;

/// <summary>
/// 完整类型
/// </summary>
public enum IntegrityType
{
/// <summary>
/// 完整型(3n)
/// </summary>
Type0,

/// <summary>
/// 雀面不完整型或半不完整型(3n+1)
/// </summary>
Type1,

/// <summary>
/// 雀头不完整型或面子不完整型(3n+2)
/// </summary>
Type2,

/// <summary>
/// 雀半不完整型(3n)
/// </summary>
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
{
...
/// <summary>
/// 获取分块
/// </summary>
/// <returns>不完整的块数(最多3个)</returns>
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;
// 筛选完整型Lv.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]);
// 若块序号达到(6 - 副露数)或有4个不完整型则无听
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();
}
// 通过完整型Lv.1的块,筛选完整型Lv.2发现有一块不完整,则为不完整型加半不完整型,多于一块则无听
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组开始,一组一组地判断:

  • 先杠刻子:如果遇到3个没杠掉的圈,3个一起杠掉;

  • 再杠顺子:剩下的没杠掉的如果不满3个,在本组每杠掉1个,下组和下下组也杠掉1个(也是共杠3个); 如果这组要杠掉1个,而下组或下下组不够的杠了,说明不是完整型,反之如果刚好杠完就是完整型。

拿上图举例:

第一次 ——

第二次 ——

第三次 ——

第四次 ——

🀇🀆🀆🀊🀆🀆
🀇🀆🀉🀊🀋🀆
🀇🀈🀉🀊🀋🀌

判断出这是完整型了,很简单吧?

如果是如下的牌型呢?

🀇🀇🀇🀇🀈🀉🀉🀊🀊🀊🀋🀋

第一次 ——

第二次 ——

第三次 ——

🀇🀆🀆🀆🀆
🀇🀆🀆🀊🀆
🀇🀆🀉🀊🀋
🀇🀈🀉🀊🀋

杠到第四次时,发现四万有2张,理应杠掉五万和六万各2张,但是不够了,所以这不是完整型

这种方法可以正确分离所有的类型,除了三连刻无法识别成3条顺子:

🀇🀇🀇🀈🀈🀈🀉🀉🀉

但是就听牌来说,这并不会影响到是否听牌、听哪些牌的判断,而且之后改进也十分容易

完整型判断代码实现

根据原理,实现这个并不难(写在Block类下):

  • 注1:70-76行是为了以后对接“去对”的操作,现在并没有什么用╮(╯▽╰)╭

  • 注2:TileTypeblockTiles可以记录如何分为顺(Sequence)和刻(Triplet),以后算符判断牌型时会用到,现在没有用

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 };

/// <summary>
/// 筛选完整型Lv.2
/// </summary>
/// <param name="hands">判断的牌组</param>
/// <param name="eyesLoc">雀头的序号(-1为没有雀头)</param>
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;
// 都是顺,确定后面2组分别有1张是顺
case 1:
if (groups.Count > i + 2)
{
++groups[i + 1].Confirmed;
++groups[i + 2].Confirmed;
continue;
}
break;
// 都是顺,确定后面2组分别有2张是顺
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;
// 3刻1顺,确定后面2组分别有1张是顺
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;
// 3张是刻
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个时,会有特征吗? 答案是有,而且有较为明显的区别:

  • 有1个时:该不完整型一定是雀面不完整型,例:

🀜🀝🀞 🀖🀗🀘 🀆🀆🀆 其中的 🀇🀈🀉🀊

  • 有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
{
...
/// <summary>
/// 听牌判断(在摸牌前判断)
/// </summary>
/// <returns>听的牌</returns>
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;
}
// 有可能复合二杯口,故听牌后不退出(会进入case 1或2)
}

var errBlocks = GetBlocks(out var blocks);

// 不完整型块数
switch (errBlocks.Count)
{
// 有一块不完整型(一块雀面不完整型(3n+1))
// 二杯口缺雀头会在这里出现
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;
}
// 有两块不完整型(一块面子不完整型(3n+2),一块雀头完整型(3n+2))
// 二杯口缺面子会在这里出现
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;
}
// 有三块不完整型(两块半不完整型(3n+1),一块雀头完整型(3n+2))
case 3:
{
// 如果3n+2的不完整型夹在中间或不是雀头完整型,则无听
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;
}
// 有两块不完整型(一块雀半完整型(3n),一块半不完整型(3n+1))
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
{
...
/// <summary>
/// 连接两块
/// </summary>
/// <param name="frontBlock">前块</param>
/// <param name="followBlock">后块</param>
/// <returns>连接后的牌、连接后的块、用来连接的牌</returns>
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
{
...
/// <summary>
/// 遍历
/// </summary>
/// <param name="hands">判断的牌组</param>
/// <param name="mode">是否要去对(真为雀面不完整型,假为面子不完整型)</param>
/// <returns>听的牌,可能本来它就不为空,不过在这里不影响(将来算符时可能改动)</returns>
public IEnumerable<Tile> Traversal(List<Tile> hands, bool mode)
{
// 可能的首张牌
var first = hands[FirstLoc].Val - 1;
// 如果首张是一万、筒、索或字牌,则first没有前一张,加回hands[loc]
if ((hands[FirstLoc].Val & 15) is 0 || hands[FirstLoc].Val / 8 > 5)
++first;
// 可能的末张牌
var last = hands[FirstLoc + Len - 1].Val + 1;
// 如果末张是九万、筒、索或字牌,则得last没有后一张,减回hands[loc]
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);
}
}

/// <summary>
/// 去对后完整(雀头完整型)
/// </summary>
/// <param name="hands">判断的牌组</param>
/// <returns>是否完整</returns>
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

规则参考

  1. 日麻百科

  2. 资深麻友

  3. 雀魂麻将

  4. 雀姬麻将