帶癩子的麻將胡牌判斷算法詳解)
簡介C麻將胡牌算法實現(xiàn)包面向游戲開發(fā)愛好者與算法學習者完整演示普通胡牌與癩子胡牌兩種規(guī)則的核心編碼思路。項目以回溯法遍歷順子、刻子、對子等基礎牌型組合逐一驗證胡牌條件并通過剪枝減少無效搜索癩子部分則根據(jù)當前手牌與潛在胡牌組合動態(tài)判斷最優(yōu)替代對象同時兼顧額外番數(shù)計算清晰展示萬能牌在麻將判定中的處理技巧。資源中的cpp文件負責牌型判斷與主流程控制h文件負責相關類與函數(shù)聲明代碼量不多但層次分明便于對照理解牌組表示、遞歸返回與邊界判定。壓縮包體積僅23KB共4個文件包含2個cpp與2個h結構緊湊可直接閱讀調(diào)試。目前已有1361人學習下載適合希望快速掌握麻將胡牌流程、提升C算法實現(xiàn)能力的開發(fā)者參考。 做棋牌游戲后臺的同學十有八九都會在胡牌判斷上栽過跟頭。尤其癩子玩法一開手里那張萬能牌能當萬、能當條、能當筒甚至兩張癩子還能湊一對將牌改來改去總有漏判。前陣子幫一個麻將項目重構算法模塊把帶癩子的胡牌判斷完整寫了一遍今天把思路、C代碼實現(xiàn)和調(diào)試過程中踩過的坑一起整理出來。這篇適合兩類人看一是做棋牌游戲服務端的開發(fā)者二是準備面試時被問到麻將胡牌怎么判斷的C崗位候選人??赐昴阒辽倌苤苯映环菽芘艿拇a再遇到兩張癩子能不能胡七對帶癩子怎么判這類問題也不會慌。1. 先想清楚胡牌的本質到底是什么1.1 牌型編碼與狀態(tài)表示麻將牌不管什么地區(qū)玩法核心結構就兩類數(shù)字牌和字牌。數(shù)字牌分為萬、條、筒三門每門從1到9各四張字牌包括東南西北中發(fā)白一共七種。編碼時最常用的做法是給34種牌各分配一個下標0到8代表萬子9到17代表條子18到26代表筒子27到33代表字牌。這個編碼方案最大的好處是判斷順子時可以直接用下標連續(xù)性。比如下標3、4、5對應的就是4萬、5萬、6萬只要這三個位置都有牌就能組成一組順子。字牌因為不參與順子單獨放到最后一段判斷時只需要處理刻子邏輯上天然隔離。手牌的存儲用int count[34]數(shù)組即可count[i]表示第 i 種牌的數(shù)量。比如手里有兩張紅中下標32對應的值就是2。相比用vector存儲每一張牌數(shù)組計數(shù)的方式在遞歸回溯時更方便減法加法都直接作用于下標不需要頻繁查找和刪除元素。這一步是整個算法的地基選對了后面少踩很多坑。1.2 為什么回溯法是最合適的方案麻將胡牌判定標準的定義是手牌能夠拆成一副將牌兩張相同以及若干組順子或刻子。以14張手牌為例就是1副將牌加4組面子如果是7對子玩法則另算。順著這個定義往后推最直觀的思路是枚舉所有拆分方式逐一驗證但手牌組合數(shù)量非常大直接枚舉不現(xiàn)實?;厮莘ㄊ沁@類問題最常用的解法。它的核心邏輯是每次從手牌里取出一組面子順子或刻子遞歸處理剩余牌直到所有牌都被拆完就返回成功任何分支走不通就回溯換一種拆法。因為每一層遞歸都明確地消耗掉三張牌遞歸深度最大也只有4層到5層搜索空間非常小實際運行幾乎瞬間完成。相比動態(tài)規(guī)劃或者查表法回溯法還有一個優(yōu)點擴展癩子規(guī)則時非常自然。癩子本質上就是這張牌缺什么就能補什么在遞歸過程中只需要額外維護一個癩子數(shù)量在需要湊順子、刻子、將牌時優(yōu)先消耗癩子。這個思路后面會展開講先扎實把不帶癩子的常規(guī)判斷寫對。2. 不帶癩子的基礎胡牌判斷2.1 將牌必須單獨拎出來處理普通胡牌的判斷邏輯可以拆成兩步第一步選定將牌第二步判斷剩余牌能不能全部拆成順子或刻子。為什么一定要先把將牌拎出來因為一副胡牌里只有一對將牌它的位置是唯一的如果不單獨處理遞歸拆面子時很容易把兩張相同的牌分別拆進兩個不同的組最后整個拆分結果變得混亂。將牌的選取只需要遍歷計數(shù)數(shù)組找到任何count[i] 2的位置先減去2張再對剩余牌做面子拆分判斷。如果剩余牌能全部拆完說明這副牌能胡如果不能就把減掉的2張加回去繼續(xù)嘗試下一種將牌。這一步要注意如果某一種牌正好有2張它有可能是將牌也有可能分別被用進兩個不同的順子或刻子里所以必須讓回溯搜索覆蓋到所有可能性不能看到2張就默認是將牌。2.2 遞歸拆面的核心函數(shù)面子拆分的核心函數(shù)只有一個找到第一個非零計數(shù)的牌然后嘗試把它拆成刻子或順子。這里有個細節(jié)為什么只處理第一張非零牌因為不管最終怎么拆這張牌必須屬于某個面子而且它是最左邊的牌意味著它不可能作為順子里的第二張或第三張去依賴更小的牌只能作為刻子的三張之一或者順子的第一張。這大大減少了分支數(shù)量。拆刻子的情況比較簡單條件是count[i] 3直接減掉3張遞歸判斷剩余牌。拆順子的情況就要檢查下標是否落在數(shù)字牌范圍內(nèi)且不是該門的最后兩檔然后看count[i1]和count[i2]是否都大于0如果滿足則各減1張繼續(xù)遞歸。兩個分支只要有一個能走通就返回成功都走不通就回溯恢復原狀?;A版C代碼如下先跑通這個再上癩子bool canSplit(int* cnt) { int i 0; while (i 34 cnt[i] 0) i; if (i 34) return true; // 嘗試拆刻子 if (cnt[i] 3) { cnt[i] - 3; if (canSplit(cnt)) { cnt[i] 3; return true; } cnt[i] 3; } // 嘗試拆順子只針對數(shù)字牌且下標不能是本門第7、8、9張 if (i 27 i % 9 6 cnt[i 1] 0 cnt[i 2] 0) { cnt[i]--; cnt[i 1]--; cnt[i 2]--; if (canSplit(cnt)) { cnt[i]; cnt[i 1]; cnt[i 2]; return true; } cnt[i]; cnt[i 1]; cnt[i 2]; } return false; } bool isHuBasic(int* cnt) { for (int i 0; i 34; i) { if (cnt[i] 2) { cnt[i] - 2; if (canSplit(cnt)) { cnt[i] 2; return true; } cnt[i] 2; } } return false; }這里有個容易忽略的地方canSplit里的 while 循環(huán)每次都要從頭掃描數(shù)組聽著效率不高但實際牌型只有34種遞歸層數(shù)很淺一次完整的胡牌判斷大概也就幾百次循環(huán)耗時在微秒級別。真正上線跑服務端也完全扛得住不需要過度優(yōu)化。3. 癩子加入后如何處理3.1 處理癩子的三種思路對比加入癩子后最容易想到的方案是把癩子牌的所有可能性枚舉一遍。比如有兩張癩子就把每一張依次當成34種牌去嘗試組合數(shù)最高會膨脹到34^kk是癩子數(shù)量第一次跑就把我嚇到了三層循環(huán)下去直接超時。第二種思路是預先打表把34種牌的所有胡牌組合預生成到一個哈希表里查詢時直接看手牌是否匹配。這個方案在癩子數(shù)量固定、牌型范圍小的場景下可行但工作量大而且遇到多種地方規(guī)則修改比如七對、十三幺時又要重新生成維護成本太高。真正可行的是第三種思路在遞歸過程中動態(tài)消耗癩子。癩子不是某一張具體的牌而是一種抽象的補齊能力。當遞歸發(fā)現(xiàn)手牌缺一張牌才能組成面子時直接從癩子池里扣掉一張當癩子數(shù)量不夠補這個分支就走不通。這個思路在搜索過程中自動覆蓋了癩子變成任意牌的所有可能不需要顯式枚舉復雜度只跟癩子數(shù)量和遞歸深度有關效率高得多代碼也簡潔。3.2 遞歸中消耗癩子的三條規(guī)則理解動態(tài)消耗癩子核心就三條規(guī)則。第一條組成刻子時如果某種牌只有1張或2張可以用癩子補足剩余數(shù)量比如1張真牌加2張癩子就湊一個刻子如果已經(jīng)有3張及以上就正常拆刻子。第二條組成順子時如果相鄰位置上缺牌可以用癩子代替。例如手里有5萬和7萬缺6萬遞歸處理到5萬作為順子起點時發(fā)現(xiàn)6萬位置為空就直接消耗1張癩子補上。如果癩子池里不夠補則放棄順子分支。第三條將牌也可以由癩子參與。一種情況是一張真牌加一張癩子組成將牌另一種是兩張癩子直接當一對將牌。這兩個分支要在選將的枚舉里單獨加進去否則手里只剩兩張癩子時就會誤判為不能胡。還有第四條隱藏規(guī)則所有手牌都拆完后如果癩子還有剩余剩余數(shù)量必須是3的倍數(shù)。因為剩下的癩子每3張可以組成一副刻子如果只剩1張或2張說明這副牌多出來了沒法成組的牌不能判胡。這個邊界條件特別容易被忽略我第一次寫漏了導致手里多一張癩子也誤報胡牌。3.3 完整C代碼帶癩子的胡牌判斷把上面幾條規(guī)則落到代碼里canSplitWithLaizi作為核心遞歸函數(shù)先處理第一張非零牌再看刻子和順子的分支??套臃种ё⒁庖獏^(qū)分cnt[i] 3直接拆和cnt[i] 3用癩子補兩種情況。順子分支也是類似分別統(tǒng)計i1和i2位置缺幾張癩子缺了就從癩子池里扣。bool canSplitWithLaizi(int* cnt, int laizi) { int i 0; while (i 34 cnt[i] 0) i; // 所有真牌都用完了只剩癩子 if (i 34) { return laizi % 3 0; } // 分支1拆刻子 if (cnt[i] 3) { cnt[i] - 3; if (canSplitWithLaizi(cnt, laizi)) { cnt[i] 3; return true; } cnt[i] 3; } // 分支2用癩子補齊刻子適用于 cnt[i] 1 或 2 if (cnt[i] 3 laizi 3 - cnt[i]) { int need 3 - cnt[i]; int save cnt[i]; cnt[i] 0; if (canSplitWithLaizi(cnt, laizi - need)) { cnt[i] save; return true; } cnt[i] save; } // 分支3拆順子 if (i 27 i % 9 6) { // 統(tǒng)計順子后兩張各缺幾張癩子 int need1 (cnt[i 1] 0) ? 0 : 1; int need2 (cnt[i 2] 0) ? 0 : 1; if (laizi need1 need2) { int temp1 cnt[i 1]; int temp2 cnt[i 2]; cnt[i]--; if (cnt[i 1] 0) cnt[i 1]--; else laizi--; if (cnt[i 2] 0) cnt[i 2]--; else laizi--; if (canSplitWithLaizi(cnt, laizi)) { cnt[i]; cnt[i 1] temp1; cnt[i 2] temp2; return true; } cnt[i]; cnt[i 1] temp1; cnt[i 2] temp2; } } return false; }主入口isHu在選將時擴展癩子的能力。原來的遍歷真牌選將保留再額外加兩種分支真牌加癩子做將以及雙癩子做將。這里注意雙癩子做將要在最后嘗試因為如果真牌本身已經(jīng)能當將盡量優(yōu)先用真牌避免浪費癩子導致后續(xù)面子拆不開。不過對于最終正確性來說順序不影響結果因為每個分支只要能走通最終都會返回true。bool isHuWithLaizi(int* cnt, int laizi) { // 分支1普通真牌做將 for (int i 0; i 34; i) { if (cnt[i] 2) { cnt[i] - 2; if (canSplitWithLaizi(cnt, laizi)) { cnt[i] 2; return true; } cnt[i] 2; } } // 分支2一張真牌 一張癩子做將 if (laizi 1) { for (int i 0; i 34; i) { if (cnt[i] 1) { cnt[i]--; if (canSplitWithLaizi(cnt, laizi - 1)) { cnt[i]; return true; } cnt[i]; } } } // 分支3兩張癩子自己做將 if (laizi 2) { if (canSplitWithLaizi(cnt, laizi - 2)) return true; } return false; }調(diào)用入口需要先把癩子牌從計數(shù)數(shù)組里拆出來。比如癩子固定為紅中那就是int laizi cnt[32]; cnt[32] 0;然后把普通牌數(shù)組和癩子數(shù)量一起傳進去。這里有個容易犯的錯如果把癩子牌本身留在數(shù)組里又同時傳入癩子數(shù)量遞歸時會把它既當作普通牌又當作萬能牌數(shù)量就重復計算了。4. 實戰(zhàn)中的坑與性能建議4.1 數(shù)組拷貝與恢復的坑寫這個算法時有一個非常隱蔽的坑在canSplitWithLaizi里操作順子時我一開始圖省事沒有保存cnt[i1]和cnt[i2]的原始值而是走完分支后手工加回來。表面看沒問題但一旦某個分支里遞歸函數(shù)提前返回true后面的代碼就不執(zhí)行了狀態(tài)恢復被跳過。特別是遞歸返回true時我們根本不需要恢復現(xiàn)場因為整個函數(shù)要結束了但如果后續(xù)還要嘗試其他分支就必須確保現(xiàn)場已經(jīng)完全恢復。我的做法是每個分支在遞歸調(diào)用前保存涉及的所有修改點的原值遞歸返回后立即恢復如果遞歸返回true直接return不需要再恢復。代碼里的temp1、temp2就是干這個的。另外整個判斷過程中cnt數(shù)組是會被反復修改的所以調(diào)用isHuWithLaizi之前一定要傳一份數(shù)組副本進去避免外層函數(shù)的手牌被破壞。4.2 處理特殊牌型七對與十三幺上面的算法只能判斷平胡牌型也就是常規(guī)的將牌加面子結構。但很多麻將規(guī)則里有七對、豪華七對甚至十三幺。七對的判斷其實非常簡單14張牌每一種牌的張數(shù)必須都是偶數(shù)1對、2對或3對再加癩子補對子。用癩子時更加寬松因為癩子可以補任意對子。一個常見需求是七對帶癩子判斷方式可以先統(tǒng)計真牌中的對子數(shù)量再算需要多少個癩子去補足7對。如果真牌里奇數(shù)張的存在數(shù)量不超過癩子數(shù)量再把多余癩子成對處理整體滿足7對即可。這個邏輯和平胡判斷完全獨立通常放在isHuWithLaizi之前單獨分支判斷哪個規(guī)則返回true就算胡。十三幺是比較特殊的地域玩法一手牌全是幺九和字牌再加任意一個對子。判斷時枚舉幺九字牌的種類是否齊全缺幾個用癩子補最后看有沒有對子或癩子補對子。這類牌型頻率低對性能影響不大但千萬別漏掉否則玩家摸到十三幺報不了胡投訴電話很快就會打過來。4.3 性能實測與優(yōu)化建議這套算法在遞歸深度上非??酥普G闆r下處理14張牌的判斷耗時不到1微秒單機每秒能跑上百萬次。但如果癩子數(shù)量有4張甚至更多遞歸分支會變多最壞情況耗時可能到幾十微秒。對于服務端來說仍然可以接受但如果某個房間同時有大量玩家頻繁操作還是值得做一層緩存。我的優(yōu)化經(jīng)驗有兩條。第一在遞歸函數(shù)最前面加一個快速剪枝統(tǒng)計所有剩余真牌數(shù)量加上癩子數(shù)量如果不是3的倍數(shù)直接返回false。這個剪枝看似簡單實際能省掉大量無效遞歸分支。第二利用牌總數(shù)較少的特性把所有非法分支概率最高的牌先處理優(yōu)先處理字牌和數(shù)量大于等于3的牌因為字牌不能組順子能拆就拆拆不了就盡早返回。另外服務端多線程跑房間時每個房間可以獨立使用一份計數(shù)數(shù)組避免線程間共享狀態(tài)。遞歸函數(shù)本身是無狀態(tài)的只要入口保證傳入的是副本并發(fā)安全就沒有問題。5. 常見問題速查表問題原因解決方案手里剩兩張癩子卻判胡不了雙癩子做將的枚舉分支沒加在選將階段增加 laizi 2 時 canSplit(cnt, laizi - 2) 的判斷癩子數(shù)量被重復計算癩子牌同時留在 count 數(shù)組里又傳入 laizi 參數(shù)入口處先把癩子牌從數(shù)組中清零再傳參遞歸返回后死循環(huán)或結果錯亂回溯時沒有恢復修改過的數(shù)組元素每個分支進入前保存原值return 前恢復現(xiàn)場剩余癩子不是3的倍數(shù)也判胡結束條件只判斷了真牌用完真牌用完時加判斷l(xiāng)aizi % 3 0七對帶癩子場景漏判主流程只走了平胡分支單獨寫七對判斷函數(shù)在平胡判斷之前或之后并行走字牌被當成順子拆分字牌范圍 27 到 33下標連續(xù)導致誤判順子分支加i 27且i % 9 6的條件再補充一個調(diào)試技巧測試胡牌算法時不要只看幾個正常case要把缺一張癩子補順子、兩張癩子補刻子、一張真牌一張癩子做將、雙癩子做將這四種情況各寫進單元測試里。我當初整理了一個用例文件包含二十多組手牌數(shù)據(jù)每次改動算法后跑一遍基本能攔住99%的回歸問題。6. 寫在最后的工程建議癩子胡牌算法寫完只是第一步真正考驗人的是它和整體業(yè)務代碼怎么整合。我習慣把胡牌判斷封裝成一個純函數(shù)模塊輸入是手牌數(shù)組和癩子數(shù)量輸出只有 true 或 false不依賴任何全局狀態(tài)。這樣不管是做三人麻將、四人麻將還是血流成河只要把癩子定義和特殊牌型開關作為配置傳進來同一個函數(shù)都能復用。實際項目里還有一個細節(jié)每次玩家摸牌、出牌、碰杠后都要調(diào)用一次胡牌判斷所以在接入消息循環(huán)時一定要控制調(diào)用頻率。我見過有項目直接在每幀全量判斷房間內(nèi)所有玩家的手牌結果造成明顯卡頓。正確的做法是只在有胡需求的時候判斷自己摸牌后、別人出牌后并且每次都基于當前玩家的手牌獨立判斷不緩存舊結果。這樣既不會有性能問題代碼邏輯也清晰。最后再說一句個人心得這個算法寫一次不難寫對是真的考細節(jié)。遞歸回溯的核心代碼只有幾十行但每一步都得想清楚癩子從哪里來狀態(tài)什么時候恢復邊界條件是什么。把上面幾個坑都踩一遍再回頭看你會發(fā)現(xiàn)麻將胡牌判斷也不過如此。本文還有配套的精品資源點擊獲取