From d02e8372159e10fcb0b03e6dc95fcbf979343ab4 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 17:39:20 +0800 Subject: [PATCH] perf(sichuan): reuse ordinal hand counts --- .../table/core/round/SichuanHuEvaluator.java | 163 +++++---- .../core/round/SichuanHuEvaluatorTest.kt | 337 ++++++++++++++++-- 2 files changed, 400 insertions(+), 100 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluator.java b/src/main/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluator.java index c0a5ee2b..2d21f472 100644 --- a/src/main/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluator.java +++ b/src/main/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluator.java @@ -2,49 +2,50 @@ import top.ellan.mahjong.model.MahjongTile; import java.util.ArrayList; -import java.util.Comparator; -import java.util.EnumSet; -import java.util.HashMap; +import java.util.Arrays; import java.util.List; -import java.util.Map; final class SichuanHuEvaluator { - private static final List SICHUAN_TILES = List.of( + private static final MahjongTile[] SICHUAN_TILES = { MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, MahjongTile.M4, MahjongTile.M5, MahjongTile.M6, MahjongTile.M7, MahjongTile.M8, MahjongTile.M9, MahjongTile.P1, MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, MahjongTile.P5, MahjongTile.P6, MahjongTile.P7, MahjongTile.P8, MahjongTile.P9, MahjongTile.S1, MahjongTile.S2, MahjongTile.S3, MahjongTile.S4, MahjongTile.S5, MahjongTile.S6, MahjongTile.S7, MahjongTile.S8, MahjongTile.S9 - ); + }; + private static final int[] TILE_INDEX_BY_ORDINAL = createTileIndexLookup(); private SichuanHuEvaluator() { } static boolean canWin(List concealedHand, MahjongTile winningTile, int fixedMeldCount) { - return evaluate(concealedHand, winningTile, fixedMeldCount).valid(); + int requiredMelds = requiredMelds(concealedHand, fixedMeldCount); + if (requiredMelds < 0) { + return false; + } + int[] counts = buildCounts(concealedHand); + int winningIndex = tileIndex(winningTile); + if (counts == null || winningIndex < 0 || counts[winningIndex] >= 4) { + return false; + } + counts[winningIndex]++; + return isWinningCounts(counts, requiredMelds, fixedMeldCount == 0); } static Result evaluate(List concealedHand, MahjongTile winningTile, int fixedMeldCount) { - if (winningTile == null || concealedHand == null) { - return Result.invalid(); - } - int requiredMelds = 4 - fixedMeldCount; + int requiredMelds = requiredMelds(concealedHand, fixedMeldCount); if (requiredMelds < 0) { return Result.invalid(); } - int expectedTileCount = requiredMelds * 3 + 2; - if (concealedHand.size() + 1 != expectedTileCount) { - return Result.invalid(); - } - List totalTiles = new ArrayList<>(concealedHand.size() + 1); - totalTiles.addAll(concealedHand); - totalTiles.add(winningTile); - int[] counts = buildCounts(totalTiles); - if (counts == null) { + int[] counts = buildCounts(concealedHand); + int winningIndex = tileIndex(winningTile); + if (counts == null || winningIndex < 0 || counts[winningIndex] >= 4) { return Result.invalid(); } - if (fixedMeldCount == 0 && totalTiles.size() == 14 && isSevenPairs(counts)) { + counts[winningIndex]++; + if (fixedMeldCount == 0 && isSevenPairs(counts)) { return new Result(true, true, List.of()); } - List> winningShapes = new ArrayList<>(); + List bestShape = null; + int bestSequenceCount = Integer.MAX_VALUE; for (int index = 0; index < counts.length; index++) { if (counts[index] < 2) { continue; @@ -53,40 +54,54 @@ static Result evaluate(List concealedHand, MahjongTile winningTile, List melds = new ArrayList<>(); if (collectMelds(counts, requiredMelds, melds)) { counts[index] += 2; - winningShapes.add(List.copyOf(melds)); + int sequenceCount = sequenceCount(melds); + if (sequenceCount < bestSequenceCount) { + bestSequenceCount = sequenceCount; + bestShape = List.copyOf(melds); + } continue; } counts[index] += 2; } - return winningShapes.stream() - .min(Comparator.comparingInt(SichuanHuEvaluator::sequenceCount)) - .map(shape -> new Result(true, false, shape)) - .orElseGet(Result::invalid); + return bestShape == null ? Result.invalid() : new Result(true, false, bestShape); } static List waitingTiles(List concealedHand, int fixedMeldCount) { - if (concealedHand == null) { - return List.of(); - } - int requiredMelds = 4 - fixedMeldCount; + int requiredMelds = requiredMelds(concealedHand, fixedMeldCount); if (requiredMelds < 0) { return List.of(); } - int expectedConcealedCount = requiredMelds * 3 + 1; - if (concealedHand.size() != expectedConcealedCount) { + int[] counts = buildCounts(concealedHand); + if (counts == null) { return List.of(); } - EnumSet waits = EnumSet.noneOf(MahjongTile.class); - for (MahjongTile wait : SICHUAN_TILES) { - if (canWin(concealedHand, wait, fixedMeldCount)) { - waits.add(wait); + List waits = new ArrayList<>(); + for (int index = 0; index < SICHUAN_TILES.length; index++) { + if (counts[index] >= 4) { + continue; } + counts[index]++; + if (isWinningCounts(counts, requiredMelds, fixedMeldCount == 0)) { + waits.add(SICHUAN_TILES[index]); + } + counts[index]--; } return List.copyOf(waits); } + private static int requiredMelds(List concealedHand, int fixedMeldCount) { + if (concealedHand == null) { + return -1; + } + int requiredMelds = 4 - fixedMeldCount; + if (requiredMelds < 0 || concealedHand.size() + 1 != requiredMelds * 3 + 2) { + return -1; + } + return requiredMelds; + } + private static int[] buildCounts(List tiles) { - int[] counts = new int[SICHUAN_TILES.size()]; + int[] counts = new int[SICHUAN_TILES.length]; for (MahjongTile tile : tiles) { MahjongTile base = normalize(tile); int index = tileIndex(base); @@ -117,25 +132,19 @@ private static MahjongTile normalize(MahjongTile tile) { } private static int tileIndex(MahjongTile tile) { - if (tile == null || tile.isFlower() || GbRoundSupport.isHonor(tile)) { - return -1; - } - String name = tile.name(); - if (name.length() < 2) { - return -1; - } - char suit = name.charAt(0); - int number = Character.digit(name.charAt(1), 10); - if (number < 1 || number > 9) { - return -1; + return tile == null ? -1 : TILE_INDEX_BY_ORDINAL[tile.ordinal()]; + } + + private static int[] createTileIndexLookup() { + int[] indexes = new int[MahjongTile.values().length]; + Arrays.fill(indexes, -1); + for (int index = 0; index < SICHUAN_TILES.length; index++) { + indexes[SICHUAN_TILES[index].ordinal()] = index; } - int suitOffset = switch (suit) { - case 'M' -> 0; - case 'P' -> 9; - case 'S' -> 18; - default -> -1; - }; - return suitOffset < 0 ? -1 : suitOffset + (number - 1); + indexes[MahjongTile.M5_RED.ordinal()] = indexes[MahjongTile.M5.ordinal()]; + indexes[MahjongTile.P5_RED.ordinal()] = indexes[MahjongTile.P5.ordinal()]; + indexes[MahjongTile.S5_RED.ordinal()] = indexes[MahjongTile.S5.ordinal()]; + return indexes; } private static boolean isSevenPairs(int[] counts) { @@ -152,7 +161,25 @@ private static boolean isSevenPairs(int[] counts) { return pairs == 7; } - private static boolean canFormMelds(int[] counts, int requiredMelds, Map memo) { + private static boolean isWinningCounts(int[] counts, int requiredMelds, boolean allowSevenPairs) { + if (allowSevenPairs && isSevenPairs(counts)) { + return true; + } + for (int index = 0; index < counts.length; index++) { + if (counts[index] < 2) { + continue; + } + counts[index] -= 2; + boolean winning = canFormMelds(counts, requiredMelds); + counts[index] += 2; + if (winning) { + return true; + } + } + return false; + } + + private static boolean canFormMelds(int[] counts, int requiredMelds) { if (requiredMelds == 0) { for (int count : counts) { if (count != 0) { @@ -161,21 +188,14 @@ private static boolean canFormMelds(int[] counts, int requiredMelds, Map= 3) { counts[first] -= 3; - if (canFormMelds(counts, requiredMelds - 1, memo)) { + if (canFormMelds(counts, requiredMelds - 1)) { counts[first] += 3; - memo.put(key, true); return true; } counts[first] += 3; @@ -186,18 +206,16 @@ private static boolean canFormMelds(int[] counts, int requiredMelds, Map List(4) { tile } } + repeat(64) { seed -> + val concealed = deck.shuffled(Random(seed)).take(13) + val expected = sichuanTiles().filter { wait -> referenceCanWin(concealed, wait, 0) } + assertEquals(expected, SichuanHuEvaluator.waitingTiles(concealed, 0), "seed=$seed") + } + } + + @Test + fun `optimized evaluator preserves waits and winning shape for fixed meld counts`() { + for (fixedMeldCount in 0..4) { + repeat(24) { seed -> + val (concealed, winningTile) = randomLegalWin(seed + fixedMeldCount * 100, fixedMeldCount) + val expected = referenceEvaluate(concealed, winningTile, fixedMeldCount) + assertEquals( + expected, + SichuanHuEvaluator.evaluate(concealed, winningTile, fixedMeldCount), + "fixedMelds=$fixedMeldCount seed=$seed concealed=$concealed winning=$winningTile", + ) + assertEquals(expected.valid(), SichuanHuEvaluator.canWin(concealed, winningTile, fixedMeldCount)) + assertEquals( + sichuanTiles().filter { wait -> referenceCanWin(concealed, wait, fixedMeldCount) }, + SichuanHuEvaluator.waitingTiles(concealed, fixedMeldCount), + "waits fixedMelds=$fixedMeldCount seed=$seed", + ) + } + } + } } + +private fun sichuanTiles(): List = + listOf( + MahjongTile.M1, + MahjongTile.M2, + MahjongTile.M3, + MahjongTile.M4, + MahjongTile.M5, + MahjongTile.M6, + MahjongTile.M7, + MahjongTile.M8, + MahjongTile.M9, + MahjongTile.P1, + MahjongTile.P2, + MahjongTile.P3, + MahjongTile.P4, + MahjongTile.P5, + MahjongTile.P6, + MahjongTile.P7, + MahjongTile.P8, + MahjongTile.P9, + MahjongTile.S1, + MahjongTile.S2, + MahjongTile.S3, + MahjongTile.S4, + MahjongTile.S5, + MahjongTile.S6, + MahjongTile.S7, + MahjongTile.S8, + MahjongTile.S9, + ) + +private fun referenceCanWin( + concealed: List, + winningTile: MahjongTile, + fixedMeldCount: Int, +): Boolean { + val requiredMelds = 4 - fixedMeldCount + if (requiredMelds < 0 || concealed.size + 1 != requiredMelds * 3 + 2) return false + val tiles = sichuanTiles() + val counts = IntArray(27) + for (tile in concealed + winningTile) { + val index = tiles.indexOf(tile.baseTileForReference()) + if (index < 0 || ++counts[index] > 4) return false + } + if (fixedMeldCount == 0 && counts.filter { it != 0 }.all { it == 2 || it == 4 } && counts.sumOf { it / 2 } == 7) { + return true + } + for (pair in counts.indices) { + if (counts[pair] < 2) continue + counts[pair] -= 2 + val winning = referenceMelds(counts, requiredMelds) + counts[pair] += 2 + if (winning) return true + } + return false +} + +private fun referenceEvaluate( + concealed: List, + winningTile: MahjongTile, + fixedMeldCount: Int, +): SichuanHuEvaluator.Result { + val requiredMelds = 4 - fixedMeldCount + if (requiredMelds < 0 || concealed.size + 1 != requiredMelds * 3 + 2) { + return SichuanHuEvaluator.Result.invalid() + } + val tiles = sichuanTiles() + val counts = IntArray(27) + for (tile in concealed + winningTile) { + val index = tiles.indexOf(tile.baseTileForReference()) + if (index < 0 || ++counts[index] > 4) return SichuanHuEvaluator.Result.invalid() + } + if (fixedMeldCount == 0 && counts.filter { it != 0 }.all { it == 2 || it == 4 } && counts.sumOf { it / 2 } == 7) { + return SichuanHuEvaluator.Result(true, true, emptyList()) + } + + var bestShape: List? = null + var bestSequenceCount = Int.MAX_VALUE + for (pair in counts.indices) { + if (counts[pair] < 2) continue + counts[pair] -= 2 + val melds = mutableListOf() + if (referenceCollectMelds(counts, requiredMelds, melds)) { + val sequenceCount = melds.count { it == SichuanHuEvaluator.MeldShape.SEQUENCE } + if (sequenceCount < bestSequenceCount) { + bestSequenceCount = sequenceCount + bestShape = melds.toList() + } + } + counts[pair] += 2 + } + return bestShape?.let { SichuanHuEvaluator.Result(true, false, it) } ?: SichuanHuEvaluator.Result.invalid() +} + +private fun referenceCollectMelds( + counts: IntArray, + remaining: Int, + melds: MutableList, +): Boolean { + if (remaining == 0) return counts.all { it == 0 } + val first = counts.indexOfFirst { it > 0 } + if (first < 0) return false + if (counts[first] >= 3) { + counts[first] -= 3 + melds += SichuanHuEvaluator.MeldShape.TRIPLET + if (referenceCollectMelds(counts, remaining - 1, melds)) { + counts[first] += 3 + return true + } + melds.removeLast() + counts[first] += 3 + } + val rank = first % 9 + if (rank <= 6 && counts[first + 1] > 0 && counts[first + 2] > 0) { + counts[first]-- + counts[first + 1]-- + counts[first + 2]-- + melds += SichuanHuEvaluator.MeldShape.SEQUENCE + if (referenceCollectMelds(counts, remaining - 1, melds)) { + counts[first]++ + counts[first + 1]++ + counts[first + 2]++ + return true + } + melds.removeLast() + counts[first]++ + counts[first + 1]++ + counts[first + 2]++ + } + return false +} + +private fun randomLegalWin( + seed: Int, + fixedMeldCount: Int, +): Pair, MahjongTile> { + val random = Random(seed) + val requiredMelds = 4 - fixedMeldCount + repeat(1_000) { + val counts = IntArray(27) + counts[random.nextInt(27)] += 2 + var valid = true + repeat(requiredMelds) { + if (random.nextBoolean()) { + val index = random.nextInt(27) + counts[index] += 3 + valid = valid && counts[index] <= 4 + } else { + val index = random.nextInt(3) * 9 + random.nextInt(7) + counts[index]++ + counts[index + 1]++ + counts[index + 2]++ + valid = valid && counts[index] <= 4 && counts[index + 1] <= 4 && counts[index + 2] <= 4 + } + } + if (!valid) return@repeat + + val physical = counts.flatMapIndexed { index, count -> List(count) { sichuanTiles()[index] } }.toMutableList() + val fiveIndices = physical.indices.filter { physical[it] in listOf(MahjongTile.M5, MahjongTile.P5, MahjongTile.S5) } + if (fiveIndices.isNotEmpty()) { + val redIndex = fiveIndices[random.nextInt(fiveIndices.size)] + physical[redIndex] = + when (physical[redIndex]) { + MahjongTile.M5 -> MahjongTile.M5_RED + MahjongTile.P5 -> MahjongTile.P5_RED + MahjongTile.S5 -> MahjongTile.S5_RED + else -> error("not a five") + } + } + physical.shuffle(random) + val winningTile = physical.removeAt(random.nextInt(physical.size)) + return physical.toList() to winningTile + } + error("Unable to construct a legal Sichuan hand for seed=$seed fixedMeldCount=$fixedMeldCount") +} + +private fun referenceMelds( + counts: IntArray, + remaining: Int, +): Boolean { + if (remaining == 0) return counts.all { it == 0 } + val first = counts.indexOfFirst { it > 0 } + if (first < 0) return false + if (counts[first] >= 3) { + counts[first] -= 3 + val winning = referenceMelds(counts, remaining - 1) + counts[first] += 3 + if (winning) return true + } + val rank = first % 9 + if (rank <= 6 && counts[first + 1] > 0 && counts[first + 2] > 0) { + counts[first]-- + counts[first + 1]-- + counts[first + 2]-- + val winning = referenceMelds(counts, remaining - 1) + counts[first]++ + counts[first + 1]++ + counts[first + 2]++ + if (winning) return true + } + return false +} + +private fun MahjongTile.baseTileForReference(): MahjongTile = + when (this) { + MahjongTile.M5_RED -> MahjongTile.M5 + MahjongTile.P5_RED -> MahjongTile.P5 + MahjongTile.S5_RED -> MahjongTile.S5 + else -> this + }