☰
斗地主最长顺子判定:C语言计数数组与区间扫描全解析
2026/10/14 15:42:01 网站建设 项目流程

OD机试的算法题库里,斗地主这个系列一直挺有意思。它们披着牌类游戏的外衣,内核全是数据结构与基础算法,今天要聊的“最长顺子判定”就是典型代表。题目要求在一组手牌里找出能组成的最长顺子,C语言实现。很多人的第一反应是先把牌排序再扫一遍,能做,但C语言版本真正的分水岭在于处理多字符牌面(10、J、Q、K、A)的输入解析、牌面到数组下标的映射,以及顺子边界条件的判断。

这道题本质上考的是计数数组加最长连续区间的扫描:先把3到A映射成12个计数位,2和大小王直接剔除,然后线性扫描找出连续非零的最长片段,长度不足5张就判定无顺子。思路清晰,代码量不大,但埋了不少坑。这篇文章我把完整思路、C语言代码、边界用例和常见翻车点全部拆开讲清楚,机试前刷到这道题的朋友可以直接照着复现。

1. 考题拆解:跑得快规则里的算法信号

1.1 顺子规则帮你限定了数据范围

跑得快和斗地主里的顺子规则基本一致:至少5张点数连续的牌,3是最小端,A是最大端,2和大小王都不参与顺子。这个规则直接带来一个关键结论——合法参与顺子的牌面只有3、4、5、6、7、8、9、10、J、Q、K、A这12种。

12这个数字很微妙。它意味着我们可以用固定长度的数组来装手牌信息,而不需要引入HashMap这种结构。C语言里数组就是天然的哈希表,把牌面字符映射成下标,用一个长度为12的int数组记录每个点数是否出现,空间开销几乎可以忽略。

另外要注意一个细节:顺子是需要“连续点数”的,而不是随便凑几张不同牌。比如手牌是3、5、7、9、J、K、A,虽然点数都不重复,但它们不连续,无法构成任何顺子。所以核心逻辑不是“有多少种不同的牌”,而是“这些牌在点数轴上能连成多长的一段”。

1.2 题目真正想考的三种能力

这类题目在机试里属于“中等偏易”的定位,但它一次考察了三个基本功。

第一个是输入解析。牌面不是单纯的一个字符,10是两个字符,J、Q、K、A又是字母,如果用scanf("%c")去读,非常容易出错。正确姿势是用scanf("%s")按字符串读取,然后逐个比较。这个环节看起来简单,实际考场上不少人在这里丢分,尤其是C语言新手对字符串处理不熟,缓冲区越界、比较逻辑写错的情况屡见不鲜。

第二个是哈希统计。把牌面字符映射到数组下标,本质上是设计一个转换函数。这个小函数写对了,后面全部顺理成章;写错了,连续区间判断就全乱套。

第三个是区间扫描。给定一个01数组(0代表该点数未出现,1代表出现),找最长连续1的段。这个逻辑用双指针或分段扫描都行,关键是要注意多个等长区间时取哪一个——题目通常会要求输出牌面最小的一组,也就是从前往后第一个找到的最长区间。

这三个能力串起来,就是整道题的解题框架。我后面详细拆每一步。

2. 思路选型:计数数组加区间扫描为何最稳

2.1 为什么不先排序再判断

如果手牌是纯数字,很多人会想到排序后遍历,遇到相邻差值为1就累计长度,否则断开重来。排序法的问题在于:

第一,牌面里有J、Q、K、A这种字符牌,字符的ASCII码并不等于牌面的大小顺序。比如字符'A'的ASCII码是65,小于'J'的74,如果直接按字符排序,A会被排到3前面,完全错误。你需要先做一层映射,把所有牌面转换成数值,这个转换本身就等于构建了一张哈希表。

第二,排序法对重复牌的处理很别扭。手牌里可能出现同一点数的牌不止一张(多副牌混用或题目设定允许重复输入),排序后连续的重复牌会干扰断点判断,你得专门跳过重复项。而计数数组天然把相同点数的牌合并成一个存在位,逻辑上简洁得多。

第三,排序的时间复杂度是O(n log n),虽然n不超过几十张,但完全没有必要。计数数组扫一遍O(n)统计,再扫一遍长度12的数组找区间,总复杂度O(n + 12),在机试场景里是更优解。

2.2 区间扫描的核心逻辑

核心逻辑可以写成这样:用一个长度为12的计数数组cnt,cnt[i]为1表示点数为i+3的牌在手牌中存在(0表示3,1表示4,以此类推),为0表示不存在。然后从头开始扫,跳过cnt为0的位置;遇到cnt为1时,就继续往右看能连续走到多远,记录下这段的起始位置和长度;最后在所有连续段里挑最长的那段。

这里有一个非常容易忽略的细节:多个等长连续段时,题目一般要求输出数值较小的一组。由于牌面是从3递增到A,我们在扫描时从左往右遍历,如果条件是len > maxLen才更新,那么等长的段不会覆盖前一个,保留下来的一定是更靠左、数值更小的那一段。如果写成len >= maxLen,就会取到更靠右的段,输出就错了。这个失误在失分点上排名很高。

扫描完成后,如果最长段长度小于5,按规则不能构成顺子,输出"无顺子"的约定标志;否则把该段所有牌面拼成一行输出。

2.3 复杂度账要算清楚

时间上:读入每张牌做一次映射,O(n);扫描12个计数位,最多12次循环,常数级别。总时间复杂度O(n),n是手牌数量,题目最多几十张牌,跑起来是微秒级。

空间上:一个长度12的int数组存计数,几个临时变量,空间复杂度O(1)(固定12个单元,不随输入规模变化)。相比排序法需要额外数组存储转换后的数值,这版方案在内存上也更干净。

这道题如果放在LeetCode里,难度大概在简单到中等之间。但在OD机试里,它考的是“能不能一次写对”,而不是“能不能想出解法”。因为思路并不难,难的是C语言实现细节不翻车。

3. C语言实现全解析

3.1 输入解析与牌面映射函数

先把输入这块拿下。牌面有12种合法值,加上2、小王、大王这些不参与顺子的牌。我建议用字符串逐个读取,每个字符串存到一个缓冲区里,然后调用映射函数判断。注意10这个牌面占两个字符,缓冲区至少要能装3个字节(两个字符加结尾的'\0')。

#include <stdio.h> #include <string.h> int cardIndex(const char *s) { if (strcmp(s, "3") == 0) return 0; if (strcmp(s, "4") == 0) return 1; if (strcmp(s, "5") == 0) return 2; if (strcmp(s, "6") == 0) return 3; if (strcmp(s, "7") == 0) return 4; if (strcmp(s, "8") == 0) return 5; if (strcmp(s, "9") == 0) return 6; if (strcmp(s, "10") == 0) return 7; if (strcmp(s, "J") == 0) return 8; if (strcmp(s, "Q") == 0) return 9; if (strcmp(s, "K") == 0) return 10; if (strcmp(s, "A") == 0) return 11; return -1; // 2、小王、大王等不参与顺子 }

这里用了一长串strcmp判断,看起来有点笨,但机试环境下这是最稳的写法,逻辑直白不容易写错。你也可以用switch配合单个字符判断,但10这个两位字符串会让switch变得别扭,反而增加出错概率。

映射函数返回-1的分支要处理干净。跑得快里2是单牌里最大的,但它不能出现在顺子中,大小王同样不能,所以这些牌读入后直接忽略,不写入计数数组。在机试的测试数据里,大小王通常不会出现,但万一出现,返回-1的逻辑也能兜住。

紧接着在主函数里循环读入手牌,逐张处理:

int main() { char buf[8]; int cnt[12] = {0}; while (scanf("%s", buf) != EOF) { int idx = cardIndex(buf); if (idx >= 0) { cnt[idx] = 1; } } // 后面接区间扫描逻辑 }

cnt[idx] = 1这行有个隐含语义:我们只关心这个点数是否存在,不关心它有几张。因为顺子由不同点数各一张组成,手牌里有3张7和手牌里有1张7,对顺子的贡献是一样的。如果题目改成“连对”或者“飞机带翅膀”,那就另当别论,但最长顺子判定不需要这个维度。

scanf读到EOF结束,天然适合OJ的管道输入模式。有些机试环境输入带换行和尾部空格,这种循环写法都能正确处理。

3.2 核心扫描逻辑

统计完成后,进入本题的心脏:找最长连续非零段。完整代码片段如下:

int maxLen = 0; int start = -1; int i = 0; while (i < 12) { if (cnt[i] == 0) { i++; continue; } int j = i; while (j < 12 && cnt[j] > 0) { j++; } int len = j - i; if (len > maxLen) { maxLen = len; start = i; } i = j; }

这个双循环的结构,外层负责定位每段连续1的起点,内层负责从起点一路推进到这段的终点。因为内层跳过的位置不会重复扫描,整体是线性时间。

注意len > maxLen用的是严格大于。这样当出现两个长度相同的顺子候选时,靠左的先被记录,靠右的不会覆盖它。按题目要求输出牌面最小的那组顺子,这个细节很关键。

还有一个细节:j循环结束后,直接把i赋值为j。这是必要的优化,也是逻辑完备性的体现——i到j-1这一段已经被扫过,不可能再成为新的候选起点。如果不这么做,i每次只加1,就会出现大量重复扫描,虽然12个元素影响不大,但代码习惯不好,遇到更长的数组时容易出性能问题。

扫描结束后,对结果做有效性判断:

if (maxLen < 5) { printf("No\n"); return 0; }

顺子最少5张牌,这是规则底线。有些变体题目可能要求输出-1或空行,以题目输出说明为准,我这里按常见的No处理。

3.3 输出格式控制

输出是这道题另一个容易翻车的位置。要求输出的牌面之间用空格分隔,行尾不能有多余空格(很多OJ对行尾空格判错)。

const char *names[] = { "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A" }; for (int k = start; k < start + maxLen; k++) { if (k > start) { printf(" "); } printf("%s", names[k]); } printf("\n");

if (k > start)这个判断确保第一个牌面前不输出空格,后续每张牌前补一个空格。这个手法比先拼字符串再统一输出简单,也不容易出错。有人喜欢在循环里先输出牌再输出空格,结果最后多了一个尾随空格,提交后提示Presentation Error,非常冤枉。

names数组的下标与cardIndex的返回值一一对应,这是整套方案能跑通的关键。映射进去是数字,输出时映射回字符串,两次映射是反向但同构的。

完整代码拼起来就是前面几个片段合集,约50行。这类长度在机试里属于非常友好,留出大量时间给后面的难题。

4. 用例实测与避坑记录

4.1 五组边界用例跑一遍

我在本地实际编译运行了几组数据,这里直接列出来对照分析。

输入手牌统计后关键情况预期输出
3 4 5 6 7 8 9 10 J Q K A 23到A全部出现,2被忽略,连续12位3 4 5 6 7 8 9 10 J Q K A
3 4 5 6 8 9 10 J Q K A3-6连续4张,8-A连续7张8 9 10 J Q K A
A 2 3 4 5 6 7 8 9 10 J Q KA单独,3-K连续11张3 4 5 6 7 8 9 10 J Q K
3 4 5 6 2 J Q K A3-6四张,J-A四张,均不足5No
3 3 4 5 6 7 7 8 9 10 J Q K A重复3和7,不额外增长顺子长度3 4 5 6 7 8 9 10 J Q K A

第一组是最基础的完整顺子,测试代码的映射和扫描主链路。第二组验证两点之间的断点能否正确切割,结果应该取右边更长的8-A段。第三组很有意思,因为2不能进顺子,把A和3-K隔成了两段,A单独长度1,3-K长度11,输出较长的那段。第四组验证长度不足5时的处理,两个连续段都只有4张,输出No。第五组带重复牌,3有两张、7有两张,计数数组把它们折叠成一个存在位,顺子长度不变,仍然是完整的12张。

第二组和第三组是用来验证“从前往后扫描保留靠左最长段”逻辑的好用例。如果代码里错写成>=,第一组不受影响,但遇到两个等长段时就会选靠右的,丢失正确性。

4.2 高频报错与排查思路

我在练习和帮别人review代码时,发现这道题的报错高度集中在几个地方,基本可以总结成一张速查表。

第一个高频坑:把2当顺子牌。有些版本规则中2可以和A连成A-2-3的循环顺子,但斗地主和跑得快明确不允许,2是单牌里最大的,不参与顺子。如果映射函数里给2返回了12,计数数组长度就要变13,输出阶段还得考虑2的位置,整套逻辑全乱。处理方式就是映射函数对2返回-1,直接忽略。

第二个高频坑:统计时写成cnt[idx]++而不是cnt[idx] = 1。这两个在“判断是否存在”的语义下结果一致,因为后续判断用的是cnt[j] > 0。但如果你不小心用cnt[j] == 1判断连续,手牌里某点数出现两次就会导致判断失败。我的建议是统计阶段直接= 1,语义更精准,不给后续留隐患。

第三个高频坑:char buf开小了。char buf[3]装不下字符串"10",因为还需要一个字节存'\0',至少要char buf[4]或更大。这个错误属于C语言经典越界,机试编译器不一定报错,但内存里相邻数据被覆盖后,可能出现各种诡异现象,比如cnt数组里的值莫名变化。排查时先看缓冲区大小。

第四个高频坑:忘了#include <string.h>,strcmp和strlen编译不过。机试环境通常是Linux GCC,头文件缺失会在编译阶段直接报错,心态容易崩。提前在本地把完整代码编译好再提交。

第五个高频坑:连续区间判断时越界。内层while循环的条件j < 12 && cnt[j] > 0,顺序不能反。如果把cnt[j] > 0放前面,当j等于12时会发生数组越界读取,读到的可能是上一次循环残留的脏数据,导致偶发错误。这个错误有时候能通过大部分测试用例,只在特定输入下暴露,排查起来非常恶心。养成把下标判断写在最前面的习惯。

4.3 机试环境下的提分细节

关于OD机试的C语言版本,有几点经验对新人有帮助。

输入读取上,用while (scanf("%s", buf) != EOF)是最省心的选择,不要用gets。gets在C11标准里已经被移除,机试环境不一定支持,而且它不检查缓冲区长度,很容易越界。scanf配合%s天然按空白字符切分,正好应对带空格的手牌输入。

测试策略上,写完代码不要急着提交。先在本地跑一遍我上面列的五组用例,再自己补几组极端数据,比如全部是2和大小王、只有4张连续牌、一张牌都没有(空输入)。空输入时cnt全零,maxLen保持0,输出No,代码不会崩。这些极端数据能提前暴露大部分边界问题。

代码风格上,变量名用有意义的单词,cnt、start、maxLen一眼能看懂。机试改卷虽然不看你代码风格,但实际调试时作用巨大。我见过不少人用a、b、c这种命名,出问题后自己都看不懂,更别提快速定位了。

时间分配上,这道题正常应该15分钟内写完并测试完。如果卡住超过20分钟,建议先跳过做后面的题,最后再回来补。机试是整体算分,不要在单题上无限消耗。

5. 踩坑后的个人体会

这类牌类题做多了会发现一个规律:游戏规则越复杂,算法核心往往越简单。斗地主系列看着唬人,剥掉外壳后就是哈希统计加区间扫描的组合拳。把这道最长顺子判定吃透,同系列的“连对判断”“飞机判定”思路都是相通的,区别只在统计维度从单张变成成对、成三张。

我个人练习这类题时,习惯先把牌面映射表写在纸上再动手敲代码,确保3到A的12个映射没有错位。这个准备工作看起来多余,实际能省下大量调试时间。另一个习惯是每次写完扫描逻辑,都会问自己一句:如果存在两个等长顺子,这段代码取的是哪一个?时刻把题目输出要求放在嘴边,就不容易写出方向性错误的代码。

最后给正在备考的朋友一个建议:机试的题量和难度都在一个可控范围内,真正拉开差距的不是会不会做,而是能不能稳定一次做对。把边界条件当成一等公民对待,每道题都在本地过一遍极端用例再交,这个习惯会让你少丢很多冤枉分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询