1. 麻将胡牌检测算法设计与实现
麻将作为中国传统的棋牌游戏,其胡牌规则的复杂性一直是游戏开发中的难点。本文将详细介绍一个基于Lua实现的通用麻将胡牌检测算法,该算法支持多种常见牌型判断,包括平胡、七对、碰碰胡等,并提供了详细的牌型分析和杠数计算功能。
1.1 麻将牌编码规则设计
麻将牌的统一编码是算法实现的基础。我们采用数字编码方案,将不同类型的牌分配在不同的数字区间:
-- 牌编码规则: -- 1-9 : 万 -- 11-19 : 条 -- 21-29 : 筒 -- 31-34 : 东南西北(风牌) -- 35-37 : 中发白(箭牌) -- 41-48 : 春夏秋冬 梅兰竹菊(花牌)这种编码方式有几个优点:
- 通过数字区间可以快速判断牌的类型
- 同类型牌的点数可以通过简单计算得到
- 编码连续,便于排序和遍历处理
在实际代码中,我们使用常量定义代替魔法数字,提高代码可读性和可维护性:
MAHJONG_SUIT_WAN = 1 -- 万 MAHJONG_SUIT_TIAO = 2 -- 条 MAHJONG_SUIT_TONG = 3 -- 筒 MAHJONG_SUIT_FENG = 4 -- 风牌 MAHJONG_SUIT_JIAN = 5 -- 箭牌 MAHJONG_SUIT_HUA = 6 -- 花牌1.2 基础功能实现
1.2.1 牌的花色和点数获取
function MAHJONG_GetSuit(nTile) if nTile <= 9 then return MAHJONG_SUIT_WAN, nTile elseif nTile <= 19 then return MAHJONG_SUIT_TIAO, nTile - 10 elseif nTile <= 29 then return MAHJONG_SUIT_TONG, nTile - 20 elseif nTile <= 34 then return MAHJONG_SUIT_FENG, nTile - 30 elseif nTile <= 37 then return MAHJONG_SUIT_JIAN, nTile - 34 elseif nTile <= 48 then return MAHJONG_SUIT_HUA, nTile - 40 else error("无效的牌编码:" .. nTile) end end这个函数是算法的基础,它完成了两个功能:
- 返回牌的花色(万、条、筒、风、箭、花)
- 返回牌的点数(对于数字牌是1-9,风牌是1-4,箭牌是1-3,花牌是1-8)
1.2.2 断幺九检测
断幺九是指手牌中不含幺九牌(万、条、筒的1和9)和字牌(风牌、箭牌):
function MAHJONG_IsNo19(tHandTiles) for _, nTile in ipairs(tHandTiles) do local nSuit, nRank = MAHJONG_GetSuit(nTile) if nSuit <= MAHJONG_SUIT_TONG then if nRank == 1 or nRank == 9 then return false end elseif nSuit == MAHJONG_SUIT_FENG or nSuit == MAHJONG_SUIT_JIAN then return false end end return true end实现要点:
- 遍历所有手牌
- 对于数字牌(万、条、筒),检查是否为1或9
- 对于风牌和箭牌,直接返回false
- 花牌不参与判断
2. 特殊牌型检测算法
2.1 七对牌型检测
七对是指手牌由7个对子组成(共14张牌),其中允许有"龙七对"(四张相同的牌算作两个对子):
local function Is7DuiInternal(tHandTiles) if #tHandTiles ~= 14 then return false end local tCounts = {} -- 统计每种牌出现的次数 for _, v in ipairs(tHandTiles) do tCounts[v] = (tCounts[v] or 0) + 1 end local nPairsCount = 0 -- 对子总数 for _, nCnt in pairs(tCounts) do if nCnt % 2 ~= 0 then return false end -- 出现奇数次则不能成对 nPairsCount = nPairsCount + nCnt // 2 end return nPairsCount == 7 end算法逻辑:
- 首先检查牌数是否为14张
- 统计每种牌出现的次数
- 检查每种牌的数量是否为偶数
- 计算总对子数是否为7
2.2 四对牌型检测
四对是8张牌时的特殊牌型,通常出现在带杠的情况下:
local function Is4DuiInternal(tHandTiles) if #tHandTiles ~= 8 then return false end local tCounts = {} for _, v in ipairs(tHandTiles) do tCounts[v] = (tCounts[v] or 0) + 1 end local nPairsCount = 0 for _, nCnt in pairs(tCounts) do if nCnt % 2 ~= 0 then return false end nPairsCount = nPairsCount + nCnt // 2 end return nPairsCount == 4 end实现方式与七对类似,只是牌数和判断条件不同。
2.3 平胡检测算法
平胡是最常见的胡牌形式,由4个顺子或刻子加1个对子组成:
function MAHJONG_IsPingHu(tHandTiles) -- 过滤花牌 local tNonFlower = {} for _, nTile in ipairs(tHandTiles) do if MAHJONG_GetSuit(nTile) ~= MAHJONG_SUIT_HUA then table_insert(tNonFlower, nTile) end end local nLen = #tNonFlower if (nLen - 2) % 3 ~= 0 then return false end local tSorted = {} for _, v in ipairs(tNonFlower) do table_insert(tSorted, v) end table_sort(tSorted) -- 遍历所有可能的将牌对 local i = 1 while i <= nLen - 1 do if tSorted[i] == tSorted[i + 1] then local tRemaining = {} for j = 1, nLen do if j ~= i and j ~= i + 1 then table_insert(tRemaining, tSorted[j]) end end if CanFormMeldOnlySeq(tRemaining) then return true end -- 跳过相同点数的重复对子 local nCur = tSorted[i] while i <= nLen - 1 and tSorted[i] == nCur and tSorted[i + 1] == nCur do i = i + 1 end else i = i + 1 end end return false end关键点:
- 首先过滤花牌
- 检查牌数是否符合3n+2的规则
- 排序手牌便于处理
- 遍历寻找所有可能的将牌对
- 对每种可能的将牌对,检查剩余牌是否能全部组成顺子
3. 递归分解算法与杠数计算
3.1 递归分解面子算法
这是胡牌检测的核心算法,用于将牌分解为顺子、刻子或杠子:
local function GetMaxGangInMeld(tTiles) if #tTiles == 0 then return 0 end local nFirst = tTiles[1] local nSuit1, nRank1 = MAHJONG_GetSuit(nFirst) local nMaxGang = nil -- 尝试杠子 (4张相同) if #tTiles >= 4 and tTiles[2] == nFirst and tTiles[3] == nFirst and tTiles[4] == nFirst then local tRemaining = {} for i = 5, #tTiles do table_insert(tRemaining, tTiles[i]) end local nSub = GetMaxGangInMeld(tRemaining) if nSub then nMaxGang = 1 + nSub end end -- 尝试刻子 (3张相同) if #tTiles >= 3 and tTiles[2] == nFirst and tTiles[3] == nFirst then local tRemaining = {} for i = 4, #tTiles do table_insert(tRemaining, tTiles[i]) end local nSub = GetMaxGangInMeld(tRemaining) if nSub then local nTotal = nSub if not nMaxGang or nTotal > nMaxGang then nMaxGang = nTotal end end end -- 尝试顺子(仅万、条、筒) if nSuit1 <= MAHJONG_SUIT_TONG then local tIdx2List, tIdx3List = {}, {} for i = 2, #tTiles do local nS, nR = MAHJONG_GetSuit(tTiles[i]) if nS == nSuit1 and nR == nRank1 + 1 then table_insert(tIdx2List, i) end if nS == nSuit1 and nR == nRank1 + 2 then table_insert(tIdx3List, i) end end for _, nIdx2 in ipairs(tIdx2List) do for _, nIdx3 in ipairs(tIdx3List) do if nIdx2 < nIdx3 then local tRemaining = {} for i = 1, #tTiles do if i ~= 1 and i ~= nIdx2 and i ~= nIdx3 then table_insert(tRemaining, tTiles[i]) end end local nSub = GetMaxGangInMeld(tRemaining) if nSub then local nTotal = nSub if not nMaxGang or nTotal > nMaxGang then nMaxGang = nTotal end end end end end end return nMaxGang end算法特点:
- 递归实现,逐步分解牌组
- 优先尝试杠子(4张相同),然后是刻子(3张相同),最后是顺子
- 对于顺子,需要找到点数连续的牌
- 记录所有可能的分解方式中的最大杠数
3.2 仅顺子分解算法
平胡要求所有面子都是顺子,因此需要专门的判断函数:
local function CanFormMeldOnlySeq(tTiles) if #tTiles == 0 then return true end local nFirst = tTiles[1] local nSuit1, nRank1 = MAHJONG_GetSuit(nFirst) if nSuit1 <= MAHJONG_SUIT_TONG then local tIdx2List, tIdx3List = {}, {} for i = 2, #tTiles do local nS, nR = MAHJONG_GetSuit(tTiles[i]) if nS == nSuit1 and nR == nRank1 + 1 then table_insert(tIdx2List, i) end if nS == nSuit1 and nR == nRank1 + 2 then table_insert(tIdx3List, i) end end for _, nIdx2 in ipairs(tIdx2List) do for _, nIdx3 in ipairs(tIdx3List) do if nIdx2 < nIdx3 then local tRemaining = {} for i = 1, #tTiles do if i ~= 1 and i ~= nIdx2 and i ~= nIdx3 then table_insert(tRemaining, tTiles[i]) end end if CanFormMeldOnlySeq(tRemaining) then return true end end end end end return false end与通用分解算法相比,这个函数:
- 只处理顺子情况
- 不处理刻子和杠子
- 找到一种可行的顺子分解方式即可返回
4. 高级牌型检测与综合判断
4.1 清一色检测
清一色是指所有牌都是同一花色(仅限万、条、筒):
function MAHJONG_IsQingYiSe(tHandTiles) if #tHandTiles == 0 then return false end local nFirstSuit = nil for _, nTile in ipairs(tHandTiles) do local nSuit = MAHJONG_GetSuit(nTile) if nSuit >= MAHJONG_SUIT_FENG then return false end if not nFirstSuit then nFirstSuit = nSuit elseif nSuit ~= nFirstSuit then return false end end return nFirstSuit ~= nil end实现要点:
- 检查所有牌的花色是否相同
- 风牌和箭牌不能出现在清一色中
- 花牌不影响判断(通常不计入清一色判断)
4.2 碰碰胡检测
碰碰胡是指所有面子都是刻子或杠子:
function MAHJONG_IsPongHu(tTiles) local function CanFormPongHu(tTiles, bHasJiang) if #tTiles == 0 then return true end local nFirst = tTiles[1] local nCount = 0 for _, v in ipairs(tTiles) do if v == nFirst then nCount = nCount + 1 end end -- 尝试将牌 if not bHasJiang and nCount >= 2 then local tRemaining = {} local nSkip = 2 for i = 1, #tTiles do if tTiles[i] == nFirst and nSkip > 0 then nSkip = nSkip - 1 else table_insert(tRemaining, tTiles[i]) end end if CanFormPongHu(tRemaining, true) then return true end end -- 尝试刻子 if nCount >= 3 then local tRemaining = {} local nSkip = 3 for i = 1, #tTiles do if tTiles[i] == nFirst and nSkip > 0 then nSkip = nSkip - 1 else table_insert(tRemaining, tTiles[i]) end end if CanFormPongHu(tRemaining, bHasJiang) then return true end end -- 尝试杠子 if nCount >= 4 then local tRemaining = {} local nSkip = 4 for i = 1, #tTiles do if tTiles[i] == nFirst and nSkip > 0 then nSkip = nSkip - 1 else table_insert(tRemaining, tTiles[i]) end end if CanFormPongHu(tRemaining, bHasJiang) then return true end end return false end local tSorted = {} for _, v in ipairs(tTiles) do table_insert(tSorted, v) end table_sort(tSorted) return CanFormPongHu(tSorted, false) end算法特点:
- 递归实现
- 必须有一个将牌对
- 其他牌必须能组成刻子或杠子
- 不允许有顺子
4.3 综合胡牌判断
function MAHJONG_GetHuDetail(tHandTiles) -- 过滤花牌 local tNonFlower = {} for _, nTile in ipairs(tHandTiles) do if MAHJONG_GetSuit(nTile) ~= MAHJONG_SUIT_HUA then table_insert(tNonFlower, nTile) end end local tDetail = { isHu = false, gangCount = 0, isQingYiSe = MAHJONG_IsQingYiSe(tHandTiles), isDanDiao = false, isNo19 = MAHJONG_IsNo19(tHandTiles) } local tSorted = {} for _, v in ipairs(tNonFlower) do table_insert(tSorted, v) end table_sort(tSorted) -- 1. 检查七对 if #tSorted == 14 and Is7DuiInternal(tSorted) then tDetail.isHu = true local tCounts = {} for _, v in ipairs(tSorted) do tCounts[v] = (tCounts[v] or 0) + 1 end for _, nCnt in pairs(tCounts) do if nCnt == 4 then tDetail.gangCount = tDetail.gangCount + 1 end end return tDetail end -- 2. 检查四对 if #tSorted == 8 and Is4DuiInternal(tSorted) then local tCounts = {} for _, v in ipairs(tSorted) do tCounts[v] = (tCounts[v] or 0) + 1 end for _, nCnt in pairs(tCounts) do if nCnt == 4 then tDetail.gangCount = tDetail.gangCount + 1 end end if tDetail.gangCount > 0 then tDetail.isHu = true return tDetail end end -- 3. 普通胡牌检测 local nLen = #tSorted if (nLen - 2) % 3 ~= 0 then return tDetail end -- 遍历所有可能的将牌对 local nMaxGang = 0 local bFound = false local nJiangCount = 0 local i = 1 while i <= nLen - 1 do if tSorted[i] == tSorted[i + 1] then local tRemaining = {} for j = 1, nLen do if j ~= i and j ~= i + 1 then table_insert(tRemaining, tSorted[j]) end end local nGang = GetMaxGangInMeld(tRemaining) if nGang then bFound = true nJiangCount = nJiangCount + 1 if nGang > nMaxGang then nMaxGang = nGang end end -- 跳过相同点数的重复对子 local nCur = tSorted[i] while i <= nLen - 1 and tSorted[i] == nCur and tSorted[i + 1] == nCur do i = i + 1 end else i = i + 1 end end if bFound then tDetail.isHu = true tDetail.gangCount = nMaxGang if nJiangCount == 1 then tDetail.isDanDiao = true end end return tDetail end这个综合判断函数:
- 首先检查七对和四对特殊牌型
- 然后进行普通胡牌检测
- 返回包含各种牌型信息的详细结果
- 计算杠数用于番数计算
- 判断是否单吊(只有一种将牌选择)
5. 牌库生成与洗牌算法
5.1 牌库生成
function MAHJONG_GenerateTilePool(tConfig) local tPool = {} -- 万:编码 1~9 if tConfig.wan and tConfig.wan > 0 then for i = MAHJONG_TILE_WAN_MIN, MAHJONG_TILE_WAN_MAX do for _ = 1, tConfig.wan do table_insert(tPool, i) end end end -- 其他牌类型类似... return tPool end这个函数根据配置生成牌库,支持自定义每种牌的数量,便于实现不同规则的麻将游戏。
5.2 洗牌算法
采用Fisher-Yates洗牌算法,保证洗牌的随机性和公平性:
function MAHJONG_Shuffle(tTilePool, bInPlace) local tPool = tTilePool if not bInPlace then tPool = {} for _, v in ipairs(tTilePool) do table_insert(tPool, v) end end local n = #tPool for i = n, 2, -1 do local j = Random(i) -- 生成1~i的随机整数 tPool[i], tPool[j] = tPool[j], tPool[i] end return tPool end算法特点:
- 时间复杂度O(n)
- 每个排列出现的概率相等
- 支持原地洗牌或创建新牌组
6. 算法优化与注意事项
6.1 性能优化建议
- 预处理排序:在调用核心算法前先对手牌进行排序,可以大幅提高后续处理效率
- 缓存中间结果:对于复杂的递归分解,可以考虑缓存中间结果避免重复计算
- 尽早终止:在递归过程中,一旦找到可行的分解方式就可以提前返回,不必遍历所有可能
6.2 常见问题排查
- 牌数不正确:确保传入的手牌数量符合规则(通常为14张或特定牌型的数量)
- 花牌处理:注意花牌不参与胡牌组合的判断,但可能影响番数计算
- 特殊牌型优先级:七对、清一色等特殊牌型应该优先判断,它们可能有特殊的胡牌规则
6.3 扩展性考虑
- 规则可配置:将牌型判断规则设计为可配置的,便于支持不同地区的麻将规则
- 番数计算分离:胡牌判断和番数计算最好分离,便于灵活调整计分规则
- 日志记录:在调试阶段可以添加详细的日志记录,便于排查复杂的胡牌判断问题
在实际开发中,这个算法模块已经经过多次优化和测试,能够高效准确地判断各种常见牌型。对于特殊的麻将变种规则,可以在现有基础上进行扩展,保持核心算法不变的情况下调整判断逻辑。