在编程中,有时候我们需要对数据进行随机打乱操作,比如卡牌游戏中的洗牌、抽奖程序中的随机排序等。
而实现真正公平且高效的随机打乱并非易事,那么,我们可以尝试使用 Fisher-Yates 算法解决这一问题。
什么是 Fisher-Yates 算法?
Fisher-Yates 算法(也称为 Knuth 洗牌算法)是一种用于随机打乱数组元素顺序的高效算法,它由英国统计学家 Ronald Fisher 和 Frank Yates 于 1938 年提出,后来经计算机科学家 Donald Knuth 改进并推广。该算法的核心思想是通过迭代方式,从数组末尾开始,将每个元素与它前面(包括自身)的随机一个元素进行交换,从而实现完全随机的排列。
为什么选择 Fisher-Yates 算法?
Fisher-Yates 算法相比其他洗牌方法有几个显著优势:
- 公平性:每个元素出现在每个位置的概率相等,确保了真正的随机性。
- 高效性:时间复杂度为 O (n),只需遍历一次数组。
- 空间效率:原地洗牌,不需要额外的存储空间,空间复杂度为 O (1)。
相比之下,一些直观但低效的方法(如生成随机序列后排序)不仅时间复杂度高(O (n log n)),还可能导致概率分布不均。
Fisher-Yates 洗牌算法原理
Fisher-Yates 算法的核心思想是通过逐步交换数组元素实现随机排列。算法从数组末尾开始,每次选择一个随机位置(范围从当前索引到数组起始位置),并将该位置的元素与当前索引位置的元素交换。这一过程确保每个元素在最终排列中出现的概率均等。
算法步骤
-
初始化数组
给定一个长度为n
的数组arr
,从最后一个元素(索引n-1
)开始逆向遍历。 -
随机选择交换位置
对于当前索引i
(从n-1
递减到1
),生成一个随机整数j
,满足0 ≤ j ≤ i
。 -
交换元素
将arr[i]
与arr[j]
交换,确保每个元素在未处理部分被选中的概率一致。
这个过程可以想象成:我们从牌堆底部开始,每次随机从剩下的牌中抽一张放到当前位置,然后继续处理前一张牌。
- 假设有一个长度为 n 的数组。
- 从数组的最后一个元素(索引 i = n-1)开始处理。
- 生成一个范围在 [0, i] 之间的随机整数 j。
- 交换数组中索引 i 和 j 处的元素。
- 将 i 的值减 1,重复步骤 3-4,直到 i 等于 0。
数学证明
每一步交换时,元素 arr[i]
被固定到最终位置的概率为 1/n
,且后续操作不会影响其位置。通过数学归纳法可证明所有排列的概率均为 1/n!
,满足均匀随机性。
应用场景
- 扑克牌洗牌、随机播放音乐列表等需要公平随机排列的场景。
- 机器学习中的数据集随机化处理。
实际应用案例:卡牌游戏洗牌
Fisher-Yates 算法最经典的应用就是卡牌游戏中的洗牌操作。以下是基于该算法完成斗地主洗牌发牌系统,展示如何使用该算法实现一副扑克牌的洗牌:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
// 常量定义
#define TOTAL_CARDS 54 // 总牌数
#define PLAYER_CARDS 17 // 每个玩家的牌数
#define BOTTOM_CARDS 3 // 底牌数量
#define SUIT_COUNT 4 // 花色数量
#define RANK_COUNT 13 // 每种花色的牌数
#define JOKER_COUNT 2 // 王牌数量
// 牌的花色和点数定义
const char *suits[] = {"红桃", "黑桃", "方块", "梅花"};
const char *ranks[] = {"3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A", "2"};
const char *jokers[] = {"小王", "大王"};
// 牌的结构体
typedef struct {
int suit; // 0-3:普通花色, 4:小王, 5:大王
int rank; // 0-12:普通牌点数, -1:王牌
} Card;
// 函数声明
void initDeck(Card *deck);
void shuffleDeck(Card *deck);
void dealCards(const Card *deck, Card *player1, Card *player2, Card *player3, Card *bottomCards);
void printCard(Card card);
void sortCards(Card *cards, int count);
int compareCards(const void *a, const void *b);
int main() {
Card deck[TOTAL_CARDS];
Card player1[PLAYER_CARDS], player2[PLAYER_CARDS], player3[PLAYER_CARDS];
Card bottomCards[BOTTOM_CARDS];
// 初始化并洗牌
initDeck(deck);
shuffleDeck(deck);
// 发牌
dealCards(deck, player1, player2, player3, bottomCards);
// 排序玩家的牌
sortCards(player1, PLAYER_CARDS);
sortCards(player2, PLAYER_CARDS);
sortCards(player3, PLAYER_CARDS);
// 打印结果
printf("玩家 1 的牌:\n");
for (int i = 0; i < PLAYER_CARDS; i++) {
printCard(player1[i]);
}
printf("\n\n");
printf("玩家 2 的牌:\n");
for (int i = 0; i < PLAYER_CARDS; i++) {
printCard(player2[i]);
}
printf("\n\n");
printf("玩家 3 的牌:\n");
for (int i = 0; i < PLAYER_CARDS; i++) {
printCard(player3[i]);
}
printf("\n\n");
printf("底牌:\n");
for (int i = 0; i < BOTTOM_CARDS; i++) {
printCard(bottomCards[i]);
}
printf("\n");
return 0;
}
// 初始化牌组
void initDeck(Card *deck) {
int index = 0;
// 初始化普通牌
for (int suit = 0; suit < SUIT_COUNT; suit++) {
for (int rank = 0; rank < RANK_COUNT; rank++) {
deck[index].suit = suit;
deck[index].rank = rank;
index++;
}
}
// 初始化王牌
deck[index].suit = 4; // 小王
deck[index].rank = -1;
index++;
deck[index].suit = 5; // 大王
deck[index].rank = -1;
}
// 洗牌(Fisher-Yates 算法)
void shuffleDeck(Card *deck) {
srand((unsigned)time(NULL));
for (int i = TOTAL_CARDS - 1; i > 0; i--) {
int j = rand() % (i + 1); // 生成0到i的随机数
// 交换牌
Card temp = deck[i];
deck[i] = deck[j];
deck[j] = temp;
}
}
// 发牌
void dealCards(const Card *deck, Card *player1, Card *player2, Card *player3, Card *bottomCards) {
int index = 0;
// 给三个玩家发牌
for (int i = 0; i < PLAYER_CARDS; i++) {
player1[i] = deck[index++];
player2[i] = deck[index++];
player3[i] = deck[index++];
}
// 发底牌
for (int i = 0; i < BOTTOM_CARDS; i++) {
bottomCards[i] = deck[index++];
}
}
// 打印单张牌
void printCard(Card card) {
if (card.suit >= 4) {
// 王牌
printf("%s\t", jokers[card.suit - 4]);
} else {
// 普通牌
printf("%s%s\t", suits[card.suit], ranks[card.rank]);
}
}
// 排序牌组
void sortCards(Card *cards, int count) {
qsort(cards, count, sizeof(Card), compareCards);
}
// 牌的比较函数(用于排序)
int compareCards(const void *a, const void *b) {
const Card *cardA = (const Card *)a;
const Card *cardB = (const Card *)b;
// 先比较点数(大小王特殊处理)
int valueA, valueB;
if (cardA->suit >= 4) {
valueA = 15 + (cardA->suit - 4); // 小王15,大王16
} else {
valueA = cardA->rank + 3; // 3是3,...,2是15
}
if (cardB->suit >= 4) {
valueB = 15 + (cardB->suit - 4);
} else {
valueB = cardB->rank + 3;
}
// 如果点数不同,直接比较点数
if (valueA != valueB) {
return valueA - valueB;
}
// 点数相同则比较花色
return cardA->suit - cardB->suit;
}
常见误区与注意事项
-
随机数生成器的初始化:在实际应用中,应确保随机数生成器只初始化一次,而不是在每次洗牌时都初始化。
-
伪随机性的局限:计算机生成的是伪随机数,对于加密级别的随机需求,需要使用专门的加密随机数生成器。
-
数组越界问题:确保随机索引 j 的范围是 [0, i],而不是 [0, n-1],否则会导致概率分布不均。
-
原地修改 vs 副本:根据需求决定是原地修改数组还是返回新数组,避免意外修改原数据。
总结
Fisher-Yates 算法是实现公平高效洗牌的最佳选择,它通过简单而巧妙的思路,确保了每个元素都有均等的机会出现在任何位置。无论是开发游戏、实现随机抽样,还是需要打乱数据顺序,Fisher-Yates 算法都是值得掌握的基础算法。
掌握这一算法不仅能帮助你写出更高效的代码,也能让你理解随机性在计算机科学中的重要性和实现方式。下次需要打乱数组时,不妨试试 Fisher-Yates 算法!