1. 题目背景与需求解析
斗地主作为国内最受欢迎的扑克游戏之一,其牌型判定一直是编程面试中的经典题型。这道题目要求我们实现一个"最长顺子"判定算法,主要考察以下几个核心能力:
- 字符串处理能力:需要解析输入的手牌和已出牌字符串,格式为"3-4-5-6-7"这样的连字符分隔形式
- 数据结构应用:需要合理使用哈希表等数据结构来统计牌面数量
- 贪心算法思维:在多个可能的顺子中,需要按照特定规则选择最优解
- 边界条件处理:需要考虑各种异常情况,如牌数不足、无法构成顺子等
实际开发中,这类问题常见于游戏开发、规则引擎设计等场景。比如在开发棋牌类App时,就需要准确判断各种牌型组合。
2. 牌型规则与技术难点
2.1 顺子的定义与限制
根据题目要求,合法的顺子必须满足以下条件:
- 长度在5到12张牌之间
- 牌面范围:3到A(即3,4,5,6,7,8,9,10,J,Q,K,A)
- 不能包含2和大小王(B/C)
- 不计花色,只比较牌面值
牌面的大小顺序为:
code复制3 < 4 < 5 < 6 < 7 < 8 < 9 < 10 < J < Q < K < A < 2 < B(小王) < C(大王)
2.2 算法实现难点
- 牌面映射问题:需要将字符串形式的牌面(如"J"、"Q")映射为可比较的数字索引
- 牌数统计问题:需要准确计算手牌减去已出牌后的剩余牌数
- 顺子枚举问题:需要高效地枚举所有可能的顺子组合
- 最优解选择问题:当存在多个相同长度的顺子时,需要选择牌面最大的那个
3. 解决方案设计与实现
3.1 数据结构设计
我们使用哈希表(Map)来存储牌面到索引的映射,以及各牌面的剩余数量:
java复制// Java示例
Map<String, Integer> rankToIndex = new HashMap<>();
String[] ranks = {"3","4","5","6","7","8","9","10","J","Q","K","A"};
for (int i = 0; i < ranks.length; i++) {
rankToIndex.put(ranks[i], i);
}
// 统计剩余牌数
Map<String, Integer> available = new HashMap<>();
3.2 核心算法流程
- 输入处理:读取手牌和已出牌字符串,分割为单张牌
- 牌数统计:分别统计手牌和已出牌中各牌面的数量
- 剩余牌计算:手牌数减去已出牌数,得到可用牌数
- 顺子枚举:
- 从最小的牌面(3)开始,尝试构建不同长度的顺子
- 检查每张牌是否都有至少一张可用
- 结果选择:
- 优先选择长度最长的顺子
- 长度相同时,选择起始牌面更大的顺子
3.3 Java实现详解
java复制import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
String hand = scanner.nextLine();
String played = scanner.nextLine();
// 牌值映射
Map<String, Integer> rankToIndex = new HashMap<>();
String[] ranks = {"3","4","5","6","7","8","9","10","J","Q","K","A"};
for (int i = 0; i < ranks.length; i++) {
rankToIndex.put(ranks[i], i);
}
// 统计牌数
Map<String, Integer> handCount = countCards(hand);
Map<String, Integer> playedCount = countCards(played);
// 计算可用牌
Map<String, Integer> available = new HashMap<>();
for (String card : handCount.keySet()) {
int left = handCount.get(card) - playedCount.getOrDefault(card, 0);
if (left > 0 && rankToIndex.containsKey(card)) {
available.put(card, left);
}
}
// 寻找最长顺子
String bestChain = findLongestChain(ranks, rankToIndex, available);
System.out.println(bestChain != null ? bestChain : "NO-CHAIN");
}
private static String findLongestChain(String[] ranks, Map<String, Integer> rankToIndex,
Map<String, Integer> available) {
String bestChain = null;
int maxLength = 0;
int maxStartIndex = -1;
for (int start = 0; start < ranks.length; start++) {
for (int len = 5; len <= 12 && start + len <= ranks.length; len++) {
if (isValidChain(ranks, start, len, available)) {
if (len > maxLength || (len == maxLength && start > maxStartIndex)) {
maxLength = len;
maxStartIndex = start;
bestChain = buildChainString(ranks, start, len);
}
}
}
}
return bestChain;
}
private static boolean isValidChain(String[] ranks, int start, int len,
Map<String, Integer> available) {
for (int i = 0; i < len; i++) {
String card = ranks[start + i];
if (available.getOrDefault(card, 0) == 0) {
return false;
}
}
return true;
}
private static String buildChainString(String[] ranks, int start, int len) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < len; i++) {
if (i > 0) sb.append("-");
sb.append(ranks[start + i]);
}
return sb.toString();
}
private static Map<String, Integer> countCards(String cardsStr) {
Map<String, Integer> count = new HashMap<>();
String[] cards = cardsStr.split("-");
for (String card : cards) {
count.put(card, count.getOrDefault(card, 0) + 1);
}
return count;
}
}
3.4 Go实现对比
Go语言的实现思路与Java类似,但在语法和部分实现细节上有所不同:
go复制package main
import (
"fmt"
"strings"
)
func main() {
var hand, played string
fmt.Scanln(&hand)
fmt.Scanln(&played)
ranks := []string{"3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"}
rankToIndex := make(map[string]int)
for i, r := range ranks {
rankToIndex[r] = i
}
handCount := countCards(hand)
playedCount := countCards(played)
available := make(map[string]int)
for card, total := range handCount {
left := total - playedCount[card]
if left > 0 && contains(ranks, card) {
available[card] = left
}
}
bestChain := findLongestChain(ranks, available)
if bestChain != "" {
fmt.Println(bestChain)
} else {
fmt.Println("NO-CHAIN")
}
}
func findLongestChain(ranks []string, available map[string]int) string {
bestChain := ""
maxLength := 0
maxStart := -1
for start := 0; start < len(ranks); start++ {
for length := 5; length <= 12 && start+length <= len(ranks); length++ {
if isValidChain(ranks, start, length, available) {
if length > maxLength || (length == maxLength && start > maxStart) {
maxLength = length
maxStart = start
bestChain = strings.Join(ranks[start:start+length], "-")
}
}
}
}
return bestChain
}
func isValidChain(ranks []string, start, length int, available map[string]int) bool {
for i := 0; i < length; i++ {
if available[ranks[start+i]] == 0 {
return false
}
}
return true
}
func countCards(cardsStr string) map[string]int {
count := make(map[string]int)
cards := strings.Split(cardsStr, "-")
for _, card := range cards {
count[card]++
}
return count
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
4. 算法优化与边界处理
4.1 性能优化思路
虽然题目中牌的种类固定(12种),时间复杂度为O(1),但在实际应用中可以考虑以下优化:
- 提前终止:当找到长度为12的顺子时可以直接返回,因为这是可能的最长顺子
- 反向枚举:从最大的牌面(A)开始向下枚举,这样可以在找到第一个合法顺子时就确定是牌面最大的
- 位运算优化:可以用位掩码来表示牌的可用性,加速连续性的判断
4.2 边界情况处理
在实际编码中需要特别注意以下边界情况:
- 输入为空:手牌或已出牌字符串可能为空
- 非法牌面:输入中可能包含不合法的牌面(如2、B、C等)
- 牌数不足:剩余牌数不足以构成任何顺子
- 重复牌处理:同一牌面可能有多个花色,但题目要求不计花色
4.3 测试用例设计
全面的测试用例应该包括:
java复制// 正常情况
"3-4-5-6-7-8-9-10-J-Q-K-A" "4-5-6-7-8" → "9-10-J-Q-K-A"
// 多个相同长度顺子
"3-4-5-6-7-8-9-10-J" "4-5-6" → "7-8-9-10-J" (比3-4-5-6-7牌面大)
// 无法构成顺子
"3-3-3-8-8-8" "K-K-K" → "NO-CHAIN"
// 边界情况:刚好5张
"3-4-5-6-7" "" → "3-4-5-6-7"
// 包含非法牌
"3-4-5-6-7-2-B-C" "2-B" → "3-4-5-6-7"
5. 实际应用与扩展思考
5.1 在游戏开发中的应用
这类算法在实际游戏开发中非常常见,比如:
- 斗地主、德州扑克等棋牌游戏的牌型判断
- 游戏AI的出牌策略决策
- 游戏回放和观战系统中的牌型提示
5.2 可能的扩展方向
- 支持更多牌型:除了顺子,还可以实现连对、飞机、炸弹等牌型判断
- 多玩家场景:考虑多个玩家手牌的情况下,判断可能的牌型组合
- 概率计算:根据已出牌计算形成某种牌型的概率
- 性能优化:针对大规模牌型判断的场景,设计更高效的算法
5.3 编码实践建议
- 模块化设计:将牌型判断逻辑独立成模块,便于复用和测试
- 单元测试:为各种边界情况编写全面的单元测试
- 日志输出:在调试阶段可以输出中间结果,便于排查问题
- 代码可读性:使用有意义的变量名和方法名,添加必要的注释
在实现这类算法时,我发现最容易出错的地方是牌面的大小比较和连续性的判断。特别是在处理"10"这个牌面时(它是一个字符串而不是单个字符),需要特别注意与其他牌面的比较逻辑。建议在开发过程中使用调试器逐步验证中间结果,确保每种牌面都被正确处理。