news 2026/10/13 1:43:32

C语言实现斗牛发牌器:Fisher-Yates洗牌算法与随机数实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言实现斗牛发牌器:Fisher-Yates洗牌算法与随机数实战

1. 为什么用C语言写一个斗牛发牌器是个好练手项目

斗牛这个纸牌玩法在不少地区的朋友聚会里都挺常见,规则不算复杂,但真要把它写成一个能跑的发牌器,涉及的东西其实比想象中多。我当初动手写这个东西,纯粹是因为那阵子在重新过一遍C语言的数组和随机数,想找个比"猜数字"稍微有点意思的题目练手。写完之后发现,这个项目麻雀虽小,五脏俱全——数组操作、随机数生成、洗牌算法、状态判断、格式化输出,全都能覆盖到。

先说说这个发牌器到底要干什么。一副牌去掉大小王,剩52张,四个花色各13张。斗牛一般是每人发5张牌,玩家根据这5张牌算出一个"牛几"的点数。发牌器的核心任务就两件事:一是把52张牌随机打乱,二是按顺序发给若干玩家,每人5张,并且保证不重复、不遗漏。听起来简单,但"随机"这两个字在C语言里就是个坑,后面会细说。

这个项目适合谁呢?我觉得有三类人值得动手写一遍。第一类是刚学完C语言基础语法,数组、循环、函数都过了一遍,但还没写过超过200行代码的练习生。第二类是想理解洗牌算法到底怎么回事的人,Fisher-Yates这个算法在很多地方都会用到,游戏、抽奖、随机抽样,底层逻辑是一样的。第三类是准备做更复杂的卡牌类项目,想先拿个小东西把发牌这块的逻辑跑通的人。

我写这个发牌器的时候,前后改了三版。第一版用rand() % 52直接生成随机下标,结果发现发出来的牌有重复;第二版换成了标记数组去重,能跑但效率低;第三版才用上标准的Fisher-Yates洗牌,代码短、逻辑清晰、分布均匀。这个过程本身就挺有记录价值的,所以我把整个思路和踩过的坑都整理出来。

2. 发牌器的整体设计思路与方案选型

2.1 数据结构怎么定:一张牌用什么表示

最直觉的想法是用一个整数表示一张牌,0到51,然后通过除法和取模算出花色和点数。比如牌号除以13得到花色(0到3),牌号对13取模得到点数(0到12)。这样做的好处是牌可以用一个int数组存,洗牌的时候直接交换整数就行,简单高效。

另一种做法是用结构体,每个结构体里放花色和点数两个字段。这样可读性更好,打印的时候不用再算,但洗牌的时候交换的是整个结构体,稍微麻烦一点,而且结构体数组在传参的时候要注意。

我最后选了整数表示法,原因是发牌器这个场景里,牌本身不需要携带太多信息,花色和点数都是展示层面的事,存的时候用最紧凑的方式就够了。而且整数数组在洗牌、发牌、排序这些操作上都更顺手。如果你后面想扩展成完整的斗牛游戏,需要记录每张牌的状态(比如是否已打出),那时候再考虑结构体也不迟。

具体来说,我用一个长度为52的int数组deck,初始时deck[i] = i,i从0到51。然后洗牌,然后按顺序取前5张给玩家1,接着5张给玩家2,以此类推。花色和点数的换算放在打印函数里做,这样数据层和展示层是分开的。

2.2 洗牌算法选哪个:为什么Fisher-Yates是标准答案

洗牌这件事,核心要求是"每种排列出现的概率相等"。听起来是废话,但很多随手写的洗牌算法其实做不到这一点。

最常见的错误写法是:生成两个随机下标i和j,交换deck[i]和deck[j],重复若干次。这种写法的问题在于,交换次数不够的时候分布不均匀,交换次数太多又浪费性能,而且到底交换多少次才够,没有一个明确的标准。更隐蔽的问题是,某些排列会比其他排列更容易出现,只是肉眼看不出来。

还有一种错误写法是:每次从原数组里随机抽一张放到新数组里,抽过的标记一下。这个思路是对的,但实现的时候如果用"随机抽到已抽过的就重抽"这种方式,当剩余牌越来越少的时候,重抽的概率越来越高,效率会急剧下降。52张牌的时候还好,如果是520张,最后几张牌可能要抽几百次才能抽到一张没抽过的。

Fisher-Yates算法的思路很干净:从数组最后一个元素开始,每次随机选一个从0到当前位置(包含当前位置)的下标,把那个元素和当前位置的元素交换。这样每个元素被放到每个位置的概率都是相等的,数学上可以证明最终每种排列的概率都是1/52!。

用代码写出来大概是这样:

for (int i = 51; i > 0; i--) { int j = rand() % (i + 1); int temp = deck[i]; deck[i] = deck[j]; deck[j] = temp; }

就这五行,没有多余的操作,不需要额外的标记数组,时间复杂度是O(n),空间复杂度是O(1)。我实测下来,洗一万次之后每张牌出现在每个位置上的频率基本均匀,没有明显的偏斜。

2.3 随机数种子怎么处理:srand到底该在哪调用

C语言的rand()函数生成的是伪随机数,如果不调用srand设置种子,每次程序运行生成的序列是一模一样的。对于发牌器来说,这意味着你每次运行程序,发出来的牌都是同一手,这显然不行。

srand的种子一般用time(NULL),也就是当前时间戳。但这里有个细节:time(NULL)的精度是秒,如果你在一秒内多次运行程序,种子是一样的,发出来的牌也一样。对于手动运行的程序来说这不是问题,但如果你写了个循环连续发牌,就得注意这一点。

我的做法是在main函数开头调用一次srand(time(NULL)),整个程序只调用这一次。千万不要在每次洗牌前都调用srand,因为如果两次洗牌发生在同一秒内,种子相同,洗出来的结果也完全相同。这个坑我踩过,当时写了个测试循环,发现每次发的牌都一样,查了半天才发现是srand调用位置的问题。

另外,rand() % n这种写法在n比较小的时候(比如n小于RAND_MAX的某个比例)分布是基本均匀的,但如果n很大,低位比特的随机性会差一些。对于52这个量级来说完全没问题,不用过度担心。如果你追求更高质量的随机数,可以考虑用random()或者自己实现一个更好的伪随机数生成器,但对于发牌器来说没必要。

3. 核心细节解析与实操要点

3.1 牌的编码与解码:花色和点数怎么算

前面说了用0到51的整数表示牌,现在说说怎么从整数还原出花色和点数。假设牌号是card,那么:

  • 花色 = card / 13,结果是0到3,分别对应黑桃、红桃、梅花、方块(顺序可以自己定)
  • 点数 = card % 13,结果是0到12,分别对应A、2、3、4、5、6、7、8、9、10、J、Q、K

这里有个细节要注意:斗牛里A算1点,J、Q、K都算10点,其他牌按面值算。所以在计算点数的时候,不能直接用card % 13,而是要做一个映射。我一般写一个函数来处理:

int getPointValue(int card) { int rank = card % 13; // 0到12 if (rank == 0) return 1; // A算1点 if (rank >= 10) return 10; // J、Q、K算10点 return rank + 1; // 2到10按面值 }

注意这里的rank是0到12,rank+1才是面值。rank为0时面值是A,rank为9时面值是10,rank为10、11、12时面值是J、Q、K。这个映射关系写的时候容易搞混,建议拿张纸画一下。

花色和点数的显示也需要一个映射表。我一般用两个字符串数组:

const char *suits[] = {"♠", "♥", "♣", "♦"}; const char *ranks[] = {"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"};

打印的时候就是suits[card / 13]和ranks[card % 13]拼起来。注意花色符号在某些终端里可能显示不正常,如果遇到乱码,可以换成"S"、"H"、"C"、"D"这样的字母表示。

3.2 发牌逻辑:怎么保证不重复不遗漏

洗牌完成之后,发牌就很简单了。用一个下标指针pos从0开始,每发一张牌就pos++。给玩家1发5张就是deck[0]到deck[4],玩家2就是deck[5]到deck[9],以此类推。

这里要处理的一个问题是玩家数量。如果玩家数量乘以5超过了52,就会越界。所以发牌之前要检查一下,如果玩家数大于10,就得提示错误或者只发前10个人的牌。斗牛一般是2到6个人玩,10个人已经是上限了,再多一副牌就不够了。

发牌的时候我习惯用一个二维数组来存每个玩家的手牌:

int hands[MAX_PLAYERS][5]; for (int p = 0; p < numPlayers; p++) { for (int c = 0; c < 5; c++) { hands[p][c] = deck[p * 5 + c]; } }

这样每个玩家的5张牌就存好了,后面要算牛几或者打印都很方便。注意MAX_PLAYERS要定义得足够大,比如10,然后实际使用的时候用numPlayers来控制。

3.3 输出格式:怎么打印才好看

发牌器的输出主要是给人看的,所以格式很重要。我一般会先打印一个分隔线,然后每个玩家一行,显示玩家编号和5张牌。比如:

=== 发牌结果 === 玩家1: ♠A ♥5 ♣10 ♦K ♠3 玩家2: ♥2 ♣7 ♦9 ♠J ♣Q ...

如果还想显示每个玩家的牛几,可以在后面加上。不过算牛几的逻辑稍微复杂一点,后面单独说。

打印的时候要注意对齐。如果花色符号占两个字符宽度,点数占1到2个字符宽度,直接用printf的%s拼接可能会对不齐。我的做法是每个玩家一行,牌之间用空格隔开,不追求严格的列对齐,看起来舒服就行。如果非要对齐,可以用固定宽度的格式化输出,比如%2s,但花色符号的宽度问题还是不好处理,所以一般不建议折腾。

4. 实操过程与核心环节实现

4.1 完整代码框架与逐步实现

先把整个程序的骨架搭出来。我一般会分成几个函数:初始化牌堆、洗牌、发牌、打印手牌、算牛几。main函数里按顺序调用。

#include <stdio.h> #include <stdlib.h> #include <time.h> #define DECK_SIZE 52 #define HAND_SIZE 5 #define MAX_PLAYERS 10 void initDeck(int *deck) { for (int i = 0; i < DECK_SIZE; i++) { deck[i] = i; } } void shuffleDeck(int *deck) { for (int i = DECK_SIZE - 1; i > 0; i--) { int j = rand() % (i + 1); int temp = deck[i]; deck[i] = deck[j]; deck[j] = temp; } } void dealCards(int *deck, int hands[][HAND_SIZE], int numPlayers) { for (int p = 0; p < numPlayers; p++) { for (int c = 0; c < HAND_SIZE; c++) { hands[p][c] = deck[p * HAND_SIZE + c]; } } } void printCard(int card) { const char *suits[] = {"S", "H", "C", "D"}; const char *ranks[] = {"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}; printf("%s%s", suits[card / 13], ranks[card % 13]); } void printHands(int hands[][HAND_SIZE], int numPlayers) { printf("=== 发牌结果 ===\n"); for (int p = 0; p < numPlayers; p++) { printf("玩家%d: ", p + 1); for (int c = 0; c < HAND_SIZE; c++) { printCard(hands[p][c]); printf(" "); } printf("\n"); } } int main() { srand(time(NULL)); int deck[DECK_SIZE]; int hands[MAX_PLAYERS][HAND_SIZE]; int numPlayers; printf("请输入玩家数量(2-10): "); scanf("%d", &numPlayers); if (numPlayers < 2 || numPlayers > MAX_PLAYERS) { printf("玩家数量不合法\n"); return 1; } initDeck(deck); shuffleDeck(deck); dealCards(deck, hands, numPlayers); printHands(hands, numPlayers); return 0; }

这个框架已经能跑起来了,输入玩家数量,发牌,打印。代码不长,但每个部分都有讲究。

4.2 算牛几的逻辑怎么加

发牌器本身不一定要算牛几,但加上这个功能会让项目更完整。斗牛的规则是:5张牌里选3张,如果这3张的点数之和是10的倍数,剩下的2张点数之和的个位数就是牛几。如果剩下2张之和也是10的倍数,那就是牛牛(牛10)。如果找不到任何3张牌之和是10的倍数,那就是没牛。

最直接的实现方式是枚举所有3张牌的组合。5张牌里选3张,一共有C(5,3)=10种组合。用三重循环就能枚举完:

int calcNiu(int *hand) { int points[HAND_SIZE]; for (int i = 0; i < HAND_SIZE; i++) { points[i] = getPointValue(hand[i]); } for (int i = 0; i < 3; i++) { for (int j = i + 1; j < 4; j++) { for (int k = j + 1; k < 5; k++) { int sum3 = points[i] + points[j] + points[k]; if (sum3 % 10 == 0) { int sum2 = 0; for (int m = 0; m < 5; m++) { if (m != i && m != j && m != k) { sum2 += points[m]; } } int niu = sum2 % 10; return niu == 0 ? 10 : niu; } } } } return 0; // 没牛 }

这段代码的逻辑是:先算出每张牌的点数,然后枚举所有3张牌的组合,找到第一组和为10的倍数的,就算剩下2张的和的个位数。如果个位数是0,返回10表示牛牛;否则返回个位数。如果所有组合都不满足,返回0表示没牛。

这里有个细节:斗牛里有时候会规定某些牌型有特殊倍数,比如五花牛、炸弹牛之类的。如果只是做个发牌器,不需要考虑这些,按基本规则算就行。如果要加,可以在calcNiu里加判断,但那就复杂了,建议先把基础版本跑通再说。

4.3 参数计算与选择过程

发牌器里涉及参数计算的地方不多,主要是玩家数量的校验和牌堆大小的计算。玩家数量乘以5不能超过52,所以最大玩家数是10。这个10是52除以5向下取整得到的。如果以后改成每人发7张牌(比如玩别的玩法),最大玩家数就变成7。所以最好把HAND_SIZE定义成宏,最大玩家数用DECK_SIZE / HAND_SIZE算出来,这样改的时候不用到处找。

随机数种子的选择前面说过了,用time(NULL)。洗牌循环的边界是DECK_SIZE - 1到1,因为最后一个元素不需要和自己交换。这些边界条件写的时候要小心,差一错误(off-by-one)是这类代码最常见的bug。

打印格式方面,我试过几种方案。最早是用花色符号,但在某些终端里显示成问号,后来改成字母。点数用字符串数组而不是直接打印数字,因为J、Q、K、A没法用数字表示。这些选择都是为了让输出更可读,没有绝对的对错,看你的使用场景。

5. 常见问题与排查技巧实录

5.1 发出来的牌有重复怎么办

这是新手最容易遇到的问题。如果你用rand() % 52生成牌号,然后直接存到手牌里,大概率会出现重复的牌。因为每次rand()都是独立的,可能连续几次生成同一个数。

正确的做法是先初始化一个包含所有52张牌的数组,然后洗牌,然后按顺序取。这样每张牌只出现一次,不可能重复。如果你已经写了随机生成的版本,改成洗牌版本就行,代码量差不多,但正确性有保证。

还有一种情况是洗牌算法写错了,比如交换的时候下标范围不对,导致某些牌被交换了多次而另一些没被交换到。检查方法是洗牌之后打印整个牌堆,看看是不是52张牌都在,有没有重复或缺失。我一般会在调试的时候加一段验证代码,用一个标记数组检查每张牌是否恰好出现一次。

5.2 每次运行发的牌都一样怎么办

这说明srand没有调用,或者调用的时候种子是固定的。检查一下main函数开头有没有srand(time(NULL))。如果有了但还是每次一样,可能是time(NULL)的精度问题,比如你在同一秒内连续运行。手动运行一般不会遇到,但如果是脚本批量运行,可以在种子后面加一点变化,比如srand(time(NULL) + getpid()),用进程号来区分。

还有一种可能是你把srand放在了洗牌函数里面,每次洗牌都重新设置种子。如果两次洗牌在同一秒内,种子相同,结果就一样。正确的做法是srand只在程序开始时调用一次。

5.3 玩家数量输入大了程序崩溃

这是数组越界的问题。如果玩家数量超过MAX_PLAYERS,hands数组就装不下了,写进去就会覆盖其他内存,导致程序崩溃或者行为异常。解决方法是在发牌之前检查numPlayers是否在合法范围内,不合法就提示并退出。

另外,scanf的返回值也要检查。如果用户输入的不是数字,scanf会失败,numPlayers保持未初始化的值,后面用这个值去循环就会出问题。稳妥的做法是检查scanf的返回值:

if (scanf("%d", &numPlayers) != 1) { printf("输入无效\n"); return 1; }

这个细节很多人会忽略,但在实际使用中挺重要的,尤其是把程序给别人用的时候。

5.4 常见问题速查表

问题现象可能原因排查方法解决方法
发牌有重复直接随机生成牌号打印所有手牌检查改用洗牌算法
每次结果相同未调用srand或种子固定检查srand调用位置在main开头调用srand(time(NULL))
程序崩溃玩家数量越界检查numPlayers范围加输入校验
花色显示乱码终端不支持特殊字符换终端测试改用字母表示花色
牛几算错点数映射错误手动验算几手牌检查getPointValue函数
洗牌不均匀洗牌算法有偏统计多次洗牌结果改用Fisher-Yates

5.5 几个实操心得

第一个心得是关于调试的。发牌器的输出是随机的,出了问题不好复现。我的做法是在调试阶段把srand的种子固定成一个常数,比如srand(12345),这样每次运行结果都一样,方便对比。等逻辑都对了,再改回time(NULL)。

第二个心得是关于代码组织的。一开始我把所有代码都写在main里,后来发现改起来很麻烦。拆成函数之后,每个函数只做一件事,测试的时候可以单独测。比如洗牌函数,我可以写个循环调用一万次,统计每张牌出现在每个位置的频率,验证均匀性。

第三个心得是关于扩展性的。发牌器写完之后,如果想加算牛几、比大小、记录分数这些功能,最好保持数据层和逻辑层分离。手牌用二维数组存,算牛几的函数只接收一个长度为5的数组,不依赖全局变量。这样以后想改成每人7张牌,只需要改HAND_SIZE和发牌逻辑,算牛几的函数稍微改一下就行。

第四个心得是关于随机数的。如果你对随机性要求特别高,比如要做个抽奖系统,rand()可能不够用。但在发牌器这个场景里,rand()完全够用,不用过度设计。我见过有人为了发牌器去实现Mersenne Twister,没必要,除非你有特殊的性能或质量要求。

6. 从发牌器延伸出去还能做什么

发牌器本身是个小工具,但它的核心逻辑——洗牌和发牌——可以用在很多地方。比如你想做一个抽奖程序,把奖品编号放进数组,洗牌,然后按顺序抽,逻辑是一样的。再比如你想做一个随机分组工具,把人员名单洗牌,然后按顺序分组,也是一样的思路。

如果想把这个发牌器扩展成完整的斗牛游戏,需要加的东西还不少。首先是玩家交互,让玩家决定是否要牌、下注之类的。然后是庄家和闲家的逻辑,斗牛里庄家和闲家的赔率不一样。再然后是多个玩家的比牌逻辑,要判断谁赢谁输。这些加起来代码量会翻好几倍,但核心的发牌部分已经搞定了。

另一个扩展方向是做成网络版,多个玩家通过网络连接,服务器发牌,客户端显示。这就涉及到网络编程了,复杂度高很多。如果只是想练手,建议先把单机版做扎实,再考虑网络版。

还有一个有意思的扩展是加图形界面。C语言做图形界面可以用一些轻量级的库,把发牌结果用图形化的方式展示出来,比控制台好看很多。不过这又是另一个领域了,看个人兴趣。

我个人觉得,发牌器这个项目最大的价值不在于它本身有多复杂,而在于它把C语言里几个核心概念串起来了:数组、循环、函数、随机数、输入输出。写完这个再去看其他项目,会发现很多地方都有类似的影子。而且这个项目足够小,一两个小时就能写完,成就感来得快,适合作为学习过程中的一个里程碑。

最后分享一个小技巧:如果你想让发牌结果看起来更"随机",可以在洗牌之后再加一步——随机决定从牌堆的哪个位置开始发牌。比如从第7张开始发,前6张弃掉。这样即使洗牌算法有微小的偏差,也会被这一步打散。当然这只是锦上添花,核心还是洗牌算法本身要正确。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 1:42:46

CAN通信学习路径:从物理层到应用层的避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:42:39

洛谷竞赛题解工程化:CMake驱动的可执行电子书

简介&#xff1a;本资源是面向程序设计竞赛初学者的系统性学习材料&#xff0c;源自《洛谷深入浅出程序设计竞赛&#xff08;基础篇&#xff09;》配套代码实践包&#xff0c;聚焦算法入门、数据结构实现与典型赛题解法训练&#xff0c;适用于高校编程新手、信息学奥赛备赛学生…

作者头像 李华
网站建设 2026/10/13 1:42:30

DPCM图像压缩原理与工程实践:从空间相关性到实时编码

1. 这不是“压缩算法课后习题”&#xff0c;而是一次对图像信息本质的重新触摸差分脉冲编码调制&#xff08;DPCM&#xff09;在数字图像处理课程里常被归为“实验五”&#xff0c;名字听起来像教科书里的一个编号&#xff0c;学生抄完代码、跑出PSNR值、交了报告就扔进回收站。…

作者头像 李华
网站建设 2026/10/13 1:42:12

Leggedgym—config配置指南

config配置指南 LeggedRobot基本配置文件 文件概述 `LeggedRobotCfg` 类 `LeggedRobotCfgPPO` 类 总结 go2基本配置文件 文件结构概述 `Go2RoughCfg` 类 `env` 子类 `terrain` 子类 `init_state` 子类 `commands` 子类 `control` 子类 `asset` 子类 `domain_rand` 子类 `reward…

作者头像 李华
网站建设 2026/10/13 1:41:42

人工智能会议清单精读与修改指南:从通读到投稿规划

1. 从一份清单说起&#xff1a;为什么“会议清单”值得逐条精读和动手改第一次看到《30个必知的人工智能会议清单》这个标题&#xff0c;我下意识以为又是一份“收藏即学会”的链接合集。真正打开逐条读下去才发现&#xff0c;这类清单的价值根本不在“知道有哪些会”&#xff…

作者头像 李华
网站建设 2026/10/13 1:40:25

基于Python的豆瓣书籍与电影知识图谱问答系统构建实战

简介&#xff1a;这是一套基于Python的豆瓣书籍与电影类别知识图谱问答系统完整项目包&#xff0c;面向计算机、数学、电子信息等专业的学生与开发者&#xff0c;适合用作课程设计、期末大作业或毕业设计的学习参考。资源包含全部源码、数据库、数据集与示例图片&#xff0c;下…

作者头像 李华