Created
January 18, 2026 10:11
-
-
Save BarclayII/8b759ded9db6a4ea676d54c26b56974d to your computer and use it in GitHub Desktop.
shanten calculator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Generic Shanten Calculator for Mahjong | |
| # This script calculates the shanten number for a given Mahjong hand. | |
| # It supports standard hands with different number of melds and pairs. | |
| # | |
| # The algorithm first tabulates the shanten of one suit only, considering different number of melds and whether a pair is present. | |
| # Then it combines the results from all suits to get the final shanten number. | |
| # | |
| # Tabulation: | |
| # Tabulation is done using dynamic programming to efficiently compute the minimum shanten. | |
| # Let f_{m,p}(n1, n2, ..., n9) be the minimum number of tiles needed to complete m melds and p pairs from the given tile counts n1 to n9 for man, sou or pin suits, initialized with infinity. | |
| # (f^z_{m,p}(n1, n2, ..., n7) is used for honors.) | |
| # (m ranges from 0 to 4, p is either 0 or 1.) | |
| # 1. We initialize the base case where the number of tiles needed is 0: | |
| # a. Start with 0 melds and 0 pairs | |
| # f_{0,0}(0, 0, ..., 0) = 0 | |
| # Add (0, 0, ..., 0) to the processing queue and mark it as visited. | |
| # b. Pop the state from the queue, visit "neighboring" states by adding melds or pairs: | |
| # f_{m+1,p}(n1, n2, ..., n9) = min( | |
| # f_{m+1,p}(n1, n2, ..., n9), | |
| # f_{m,p}(n1 - 3, n2, ..., n9), | |
| # f_{m,p}(n1, n2 - 3, ..., n9), | |
| # ... | |
| # f_{m,p}(n1, n2, ..., n9 - 3), # triplets | |
| # f_{m,p}(n1 - 1, n2 - 1, n3 - 1, n4, ..., n9), | |
| # f_{m,p}(n1, n2 - 1, n3 - 1, n4 - 1, ..., n9), | |
| # ... | |
| # f_{m,p}(n1, n2, ..., n7 - 1, n8 - 1, n9 - 1), # sequences | |
| # ) | |
| # f_{m,p+1}(n1, n2, ..., n9) = min( | |
| # f_{m,p+1}(n1, n2, ..., n9), | |
| # f_{m,p}(n1 - 2, n2, ..., n9), | |
| # f_{m,p}(n1, n2 - 2, ..., n9), | |
| # ... | |
| # f_{m,p}(n1, n2, ..., n9 - 2), | |
| # ) # pairs | |
| # Add newly visited states to the processing queue. | |
| # (for f^z, only consider triplets and pairs) | |
| # c. Repeat until the queue is empty. | |
| # 2. After finding all f's whose values are 0, use them as "seeds" and flood-fill to find all other states: For each state in the queue, visit "neighboring" unvisited states by removing or adding a single tile. If the unvisited state comes from adding a tile, set its value to the current state's value (as it does not impact how many tiles are needed). If the unvisited state comes from removing a tile, set its value to the current state's value + 1 (as it increases the number of tiles needed by 1). More specifically: | |
| # Maintain a processing queue initialized with all states f_{m,p}(n1, n2, ..., n9) and f^z_{m,p}(n1, n2, ..., n7) whose values are 0. | |
| # Pop a state from the queue, visit "neighboring" states by adding or removing a single tile: | |
| # f_{m,p}(n1, n2, ..., ni + 1, ..., n9) = min( | |
| # f_{m,p}(n1, n2, ..., ni + 1, ..., n9), | |
| # f_{m,p}(n1, n2, ..., ni, ..., n9) | |
| # ) | |
| # f_{m,p}(n1, n2, ..., ni - 1, ..., n9) = min( | |
| # f_{m,p}(n1, n2, ..., ni - 1, ..., n9), | |
| # f_{m,p}(n1, n2, ..., ni, ..., n9) + 1 | |
| # ) | |
| # (same for f^z) | |
| # If a state got updated (i.e. its value changes from its previous value), add it to the processing queue. | |
| # Repeat until the queue is empty. | |
| # | |
| # Combining results from all suits: | |
| # After tabulation, we get the shanten number for getting m melds and p pairs by: | |
| # 1. Enumerate all possible distributions of melds and pairs among the suits. | |
| # 2. For each distribution, sum up the shanten numbers from each suit. | |
| # 3. The minimum sum across all distributions is the final shanten number. | |
| # | |
| # This tabulation-combination approach can also be extended to different yakus (e.g. Honitsu, Sanshoku, Sanankou, etc.) by starting from different base cases and adjusting the combination step accordingly. | |
| """ | |
| Shanten Calculator using BFS/Flood-fill Algorithm | |
| Optimized version: Only computes states with <= 14 tiles total | |
| """ | |
| import time | |
| from collections import deque | |
| def precompute_suit_tables(): | |
| """ | |
| Precompute f_{m,p}(n1,...,n9) for suited tiles (man/pin/sou) | |
| Only compute states where sum(tiles) <= 14 | |
| Returns: dict[(m, p)] -> dict[state_tuple] -> min_tiles_needed | |
| """ | |
| print("Precomputing suited tile tables (max 14 tiles)...") | |
| start = time.time() | |
| tables = {} | |
| for m in range(5): # 0 to 4 melds | |
| for p in range(2): # 0 or 1 pair | |
| tables[(m, p)] = {} | |
| # Phase 1: Find all states with f=0 (perfect formations) | |
| print(" Phase 1: Finding perfect formations...") | |
| initial_state = (0,) * 9 | |
| tables[(0, 0)][initial_state] = 0 | |
| queue = deque([(0, 0, initial_state)]) | |
| visited_phase1 = set([(0, 0, initial_state)]) | |
| while queue: | |
| m, p, state = queue.popleft() | |
| # Try adding melds (if we haven't reached 4 melds yet) | |
| if m < 4: | |
| # Try adding triplets at each position | |
| for i in range(9): | |
| new_state = list(state) | |
| new_state[i] += 3 | |
| # Check: total tiles <= 14 and each tile type <= 4 | |
| if sum(new_state) <= 14 and new_state[i] <= 4: | |
| new_state = tuple(new_state) | |
| key = (m + 1, p, new_state) | |
| if key not in visited_phase1: | |
| visited_phase1.add(key) | |
| tables[(m + 1, p)][new_state] = 0 | |
| queue.append((m + 1, p, new_state)) | |
| # Try adding sequences (positions 0-6 can start a sequence) | |
| for i in range(7): | |
| new_state = list(state) | |
| new_state[i] += 1 | |
| new_state[i + 1] += 1 | |
| new_state[i + 2] += 1 | |
| # Check: total tiles <= 14 and each tile type <= 4 | |
| if sum(new_state) <= 14 and all( | |
| new_state[j] <= 4 for j in [i, i + 1, i + 2] | |
| ): | |
| new_state = tuple(new_state) | |
| key = (m + 1, p, new_state) | |
| if key not in visited_phase1: | |
| visited_phase1.add(key) | |
| tables[(m + 1, p)][new_state] = 0 | |
| queue.append((m + 1, p, new_state)) | |
| # Try adding pairs (if we don't have a pair yet) | |
| if p < 1: | |
| for i in range(9): | |
| new_state = list(state) | |
| new_state[i] += 2 | |
| # Check: total tiles <= 14 and each tile type <= 4 | |
| if sum(new_state) <= 14 and new_state[i] <= 4: | |
| new_state = tuple(new_state) | |
| key = (m, p + 1, new_state) | |
| if key not in visited_phase1: | |
| visited_phase1.add(key) | |
| tables[(m, p + 1)][new_state] = 0 | |
| queue.append((m, p + 1, new_state)) | |
| phase1_count = sum(len(tables[(m, p)]) for m in range(5) for p in range(2)) | |
| print(f" Found {phase1_count} perfect states") | |
| # Phase 2: Flood-fill from all f=0 states | |
| print(" Phase 2: Flood-filling all states...") | |
| for m in range(5): | |
| for p in range(2): | |
| # Use BFS with cost levels | |
| # Start with all f=0 states | |
| current_level = deque() | |
| for state in tables[(m, p)]: | |
| if tables[(m, p)][state] == 0: | |
| current_level.append(state) | |
| cost = 0 | |
| while current_level: | |
| next_level = deque() | |
| while current_level: | |
| state = current_level.popleft() | |
| # Try adding a tile (cost stays the same) | |
| for i in range(9): | |
| new_state = list(state) | |
| new_state[i] += 1 | |
| # Check: total tiles <= 14 and each tile type <= 4 | |
| if sum(new_state) <= 14 and new_state[i] <= 4: | |
| new_state = tuple(new_state) | |
| if new_state not in tables[(m, p)]: | |
| tables[(m, p)][new_state] = cost | |
| current_level.append(new_state) | |
| # Try removing a tile (cost increases by 1) | |
| for i in range(9): | |
| if state[i] > 0: | |
| new_state = list(state) | |
| new_state[i] -= 1 | |
| new_state = tuple(new_state) | |
| # No need to check sum <= 14 since we're removing | |
| if new_state not in tables[(m, p)]: | |
| tables[(m, p)][new_state] = cost + 1 | |
| next_level.append(new_state) | |
| current_level = next_level | |
| cost += 1 | |
| if cost > 14: # Safety limit | |
| break | |
| elapsed = time.time() - start | |
| total_states = sum(len(tables[(m, p)]) for m in range(5) for p in range(2)) | |
| print(f" Suited tables precomputed in {elapsed:.2f}s") | |
| print(f" Total states: {total_states:,} (down from ~19.5M)") | |
| return tables | |
| def precompute_honor_tables(): | |
| """ | |
| Precompute f^z_{m,p}(n1,...,n7) for honor tiles | |
| Only compute states where sum(tiles) <= 14 | |
| (Only triplets and pairs, no sequences) | |
| """ | |
| print("Precomputing honor tile tables (max 14 tiles)...") | |
| start = time.time() | |
| tables = {} | |
| for m in range(5): | |
| for p in range(2): | |
| tables[(m, p)] = {} | |
| # Phase 1: Perfect formations | |
| initial_state = (0,) * 7 | |
| tables[(0, 0)][initial_state] = 0 | |
| queue = deque([(0, 0, initial_state)]) | |
| visited_phase1 = set([(0, 0, initial_state)]) | |
| while queue: | |
| m, p, state = queue.popleft() | |
| # Try adding triplets | |
| if m < 4: | |
| for i in range(7): | |
| new_state = list(state) | |
| new_state[i] += 3 | |
| # Check: total tiles <= 14 and each tile type <= 4 | |
| if sum(new_state) <= 14 and new_state[i] <= 4: | |
| new_state = tuple(new_state) | |
| key = (m + 1, p, new_state) | |
| if key not in visited_phase1: | |
| visited_phase1.add(key) | |
| tables[(m + 1, p)][new_state] = 0 | |
| queue.append((m + 1, p, new_state)) | |
| # Try adding pairs | |
| if p < 1: | |
| for i in range(7): | |
| new_state = list(state) | |
| new_state[i] += 2 | |
| # Check: total tiles <= 14 and each tile type <= 4 | |
| if sum(new_state) <= 14 and new_state[i] <= 4: | |
| new_state = tuple(new_state) | |
| key = (m, p + 1, new_state) | |
| if key not in visited_phase1: | |
| visited_phase1.add(key) | |
| tables[(m, p + 1)][new_state] = 0 | |
| queue.append((m, p + 1, new_state)) | |
| # Phase 2: Flood-fill | |
| for m in range(5): | |
| for p in range(2): | |
| current_level = deque() | |
| for state in tables[(m, p)]: | |
| if tables[(m, p)][state] == 0: | |
| current_level.append(state) | |
| cost = 0 | |
| while current_level: | |
| next_level = deque() | |
| while current_level: | |
| state = current_level.popleft() | |
| # Add a tile | |
| for i in range(7): | |
| new_state = list(state) | |
| new_state[i] += 1 | |
| # Check: total tiles <= 14 and each tile type <= 4 | |
| if sum(new_state) <= 14 and new_state[i] <= 4: | |
| new_state = tuple(new_state) | |
| if new_state not in tables[(m, p)]: | |
| tables[(m, p)][new_state] = cost | |
| current_level.append(new_state) | |
| # Remove a tile | |
| for i in range(7): | |
| if state[i] > 0: | |
| new_state = list(state) | |
| new_state[i] -= 1 | |
| new_state = tuple(new_state) | |
| if new_state not in tables[(m, p)]: | |
| tables[(m, p)][new_state] = cost + 1 | |
| next_level.append(new_state) | |
| current_level = next_level | |
| cost += 1 | |
| if cost > 14: | |
| break | |
| elapsed = time.time() - start | |
| total_states = sum(len(tables[(m, p)]) for m in range(5) for p in range(2)) | |
| print(f" Honor tables precomputed in {elapsed:.2f}s") | |
| print(f" Total states: {total_states:,}") | |
| return tables | |
| def calculate_shanten(hand_str, suit_tables, honor_tables): | |
| """ | |
| Calculate shanten number for a given hand. | |
| hand_str format: "123456m1378s2256p" or with honors like "123m456p11z" | |
| """ | |
| # Parse hand into tile counts | |
| tiles = [0] * 34 | |
| current_suit = [] | |
| for char in hand_str: | |
| if char.isdigit(): | |
| current_suit.append(int(char)) | |
| elif char in "mpsz": | |
| suit_offset = {"m": 0, "p": 9, "s": 18, "z": 27}[char] | |
| for num in current_suit: | |
| tiles[suit_offset + num - 1] += 1 | |
| current_suit = [] | |
| # Split into suits | |
| man_state = tuple(tiles[0:9]) | |
| pin_state = tuple(tiles[9:18]) | |
| sou_state = tuple(tiles[18:27]) | |
| honor_state = tuple(tiles[27:34]) | |
| # Try all distributions of melds and pairs | |
| min_cost = float("inf") | |
| # m1 + m2 + m3 + m4 = 4 (total 4 melds) | |
| # p1 + p2 + p3 + p4 = 1 (total 1 pair) | |
| for m1 in range(5): | |
| for m2 in range(5): | |
| for m3 in range(5): | |
| for m4 in range(5): | |
| if m1 + m2 + m3 + m4 != 4: | |
| continue | |
| for p1 in range(2): | |
| for p2 in range(2): | |
| for p3 in range(2): | |
| for p4 in range(2): | |
| if p1 + p2 + p3 + p4 != 1: | |
| continue | |
| # Look up costs | |
| cost1 = suit_tables[(m1, p1)].get( | |
| man_state, float("inf") | |
| ) | |
| cost2 = suit_tables[(m2, p2)].get( | |
| pin_state, float("inf") | |
| ) | |
| cost3 = suit_tables[(m3, p3)].get( | |
| sou_state, float("inf") | |
| ) | |
| cost4 = honor_tables[(m4, p4)].get( | |
| honor_state, float("inf") | |
| ) | |
| total_cost = cost1 + cost2 + cost3 + cost4 | |
| min_cost = min(min_cost, total_cost) | |
| # Shanten = tiles_needed - 1 | |
| shanten = min_cost - 1 | |
| return max(-1, shanten) | |
| # Main execution | |
| if __name__ == "__main__": | |
| print("=" * 70) | |
| print("SHANTEN CALCULATOR - BFS with 14-tile Optimization") | |
| print("=" * 70) | |
| print() | |
| # Precompute tables | |
| suit_tables = precompute_suit_tables() | |
| honor_tables = precompute_honor_tables() | |
| print("\n" + "=" * 70) | |
| print("Testing shanten calculation") | |
| print("=" * 70) | |
| # Test cases | |
| test_hands = [ | |
| "123456m1378s2256p", # The original 14-tile hand | |
| "123456m137s2256p", # 13-tile version | |
| "123m456p789s1122z", # Different hand | |
| "11122233344455m", # Many pairs | |
| "147m258p369s1234z", # Scattered | |
| "19m19p19s1234567z", # Kokushi attempt | |
| "1112345678999m", # 13 tiles, all man | |
| "2258m408p134s4577z", | |
| "79m189p34455s1456z", | |
| "14m244p46s1122346z", | |
| "238m3446788p23s13z", | |
| "22679m259p1255s35z", | |
| "12244455677889p", | |
| "11122344677899p", | |
| "11222346688999p", | |
| "11222234556899p", | |
| ] | |
| for hand in test_hands: | |
| # Count tiles | |
| tile_count = sum(1 for char in hand if char.isdigit()) | |
| shanten = calculate_shanten(hand, suit_tables, honor_tables) | |
| print(f"\nHand: {hand}") | |
| print(f" Tiles: {tile_count}") | |
| print(f" Shanten: {shanten}") | |
| if shanten == -1: | |
| print(" Status: WINNING HAND!") | |
| elif shanten == 0: | |
| print(" Status: TENPAI (ready)") | |
| else: | |
| print(f" Status: {shanten} away from tenpai") | |
| print("\n" + "=" * 70) | |
| print("Optimization Impact:") | |
| print(" Previous version: ~19.5M suited states") | |
| print( | |
| f" This version: {sum(len(suit_tables[(m, p)]) for m in range(5) for p in range(2)):,} suited states" | |
| ) | |
| print( | |
| f" Reduction: ~{100 * (1 - sum(len(suit_tables[(m, p)]) for m in range(5) for p in range(2)) / 19_531_250):.1f}%" | |
| ) | |
| print("=" * 70) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment