扫雷开发番外篇:随机碰撞 vs Knuth洗牌 vs Naive换牌,性能实测与底层剖析

本文通过C语言实现并实测三种布雷算法:随机碰撞法、Knuth-Durstenfeld洗牌法(Fisher-Yates)和朴素换牌法。测试表明,在低雷密度下三者性能接近,但高密度时随机碰撞法因重试次数激增导致性能急剧下降,而两种洗牌法保持O(N)稳定时间复杂度。实验采用一维数组建模以消除二维坐标计算干扰,强调算法设计对工业级系统健壮性的影响。

前言

在上一篇文章《从“一碰就碎”到“铜墙铁壁”》中,我的扫雷游戏 V3.0 算是彻底竣工了。但是在回顾布雷模块时,我产生了好奇:同样是随机生成 10 个雷,到底哪种算法最快?

大一新生往往只知道“能跑就行”,但作为软件工程专业的学生,我觉得有必要用数据说话。于是,我掏出 clock() 函数,对三种经典的布雷算法进行了一次“真刀真枪”的性能实测。

🎯 参赛选手介绍

本次测试的三位选手分别是:

  1. 随机碰撞法(Random Collision):最符合直觉的写法,闭着眼睛扔飞镖。随机生成坐标,如果有雷就重试,直到凑够数量。
//1.随机碰撞法
void set_mine_collision(char address[TOTAL])
{
	int count = MINECOUNT;
	while (count)
	{
		int pos = rand() % TOTAL;
		if (address[pos] != '1')
		{
			address[pos] = '1';
			count--;
		}
	}
}
  1. Knuth-Durstenfeld 洗牌法(Fisher-Yates):现代洗牌的标配。先把前 N 个格子标记为雷,然后从后往前,每次与前面(包括自己)的随机位置进行等概率交换。
//2.洗牌算法(Knuth-Durstenfeld Shuffle)(等概率)
void set_mine_shuffle(char address[TOTAL])
{
	for (int i = 0;i < MINECOUNT;i++)
	{
		address[i] = '1';
	}
	for (int i = TOTAL - 1;i > 0;i--)
	{
		int j = rand() % (i + 1);
		char temp = address[i];
		address[i] = address[j];
		address[j] = temp;
	}
}
  1. Naive 换牌法(朴素换牌):很多人以为的“洗牌”,实际上是把所有格子随机打乱,遍历每一个位置,与全局随机的一个位置交换。
//3.换牌算法(Naive Shuffle)(不严格等概率)
void set_mine_swap(char address[TOTAL])
{
	for (int i = 0;i < MINECOUNT;i++)
	{
		address[i] = '1';
	}
	for (int i = 0;i < TOTAL;i++)
	{
		int j = rand() % TOTAL;
		char temp = address[i];
		address[i] = address[j];
		address[j] = temp;
	}
}

💡 设计思考:为什么布雷算法要用一维数组?

看到这里的代码,可能有读者会问:扫雷明明是一个二维棋盘,为什么不直接在二维数组上布雷,反而要多此一举用一个一维数组 address 呢?

这其实是为了隔离变量并让算法更纯粹,主要有以下三个考量:

  1. 算法与数据的降维解耦:
    无论是随机碰撞还是洗牌算法,它们在本质上处理的都是线性的连续数据。使用一维数组,我们可以直接通过 rand() % TOTAL 获得一个纯粹的随机索引,洗牌算法的 swap 操作也只需要最简单的 temp = address[i]。
  2. 保证性能测试的公平性:
    如果直接在二维数组上布点,生成一次随机坐标需要 rand() % ROW + 1 和 rand() % COL + 1 两次取模运算。为了避免生成二维坐标带来的额外运算开销干扰我们对“布雷算法本身”的性能测试,一维数组是最公平的基准线。
  3. 一维到二维的完美映射:
    一维数组布的雷,可以通过简单的数学公式 r = i / COL + 1(除法代表“满几个换一行”),c = i % COL + 1(取余代表“该行排第几个”) 完美映射回二维数组的对应位置。这种“在一维空间算,在二维空间用”的降维打击思路,在图形学、矩阵计算中是非常常见的高效做法(虽然我还没用过)。

打个通俗的比方:一维数组就像是一副洗好的扑克牌,洗牌算法处理它最顺手;而二维数组是摊在桌面上的牌阵,用来给玩家点选。我们现在的操作就是“在后台洗完牌,再整齐地摆到桌面上”。

🛠️ 搭建测试框架(没有指针的无奈)

因为还没学函数指针,我只能用最原始但也最直观的方法:把每种算法跑 10000 次,用 clock() 记录总耗时。

小贴士:在每次调用布雷算法前,必须用 init_board 把棋盘全置为 '0',否则碰撞法在第二次循环时会面对一个充满雷的棋盘,直接死循环。

int test_times = 10000;
clock_t start, end;
double ms;

//==========1.随机碰撞法==========
start = clock();
for (int i = 0;i < test_times;i++)
{
	init_board(mine, '0');
	init_address(address);
	set_mine_collision(address);
	get_mine(mine, address);//布雷
}
end = clock();
ms = (double)(end - start) / CLOCKS_PER_SEC * 1000.0;
printf("随机碰撞法:测试 %d 次,耗时 %.2lf 毫秒\n", test_times, ms);

//==========2.洗牌算法(Knuth-Durstenfeld Shuffle)==========
start = clock();
for (int i = 0;i < test_times;i++)
{
	init_board(mine, '0');
	init_address(address);
	set_mine_shuffle(address);
	get_mine(mine, address);//布雷
}
end = clock();
ms = (double)(end - start) / CLOCKS_PER_SEC * 1000.0;
printf("Knuth洗牌法:测试 %d 次,耗时 %.2lf 毫秒\n", test_times, ms);

//==========3.换牌算法(Naive Shuffle)==========
start = clock();
for (int i = 0;i < test_times;i++)
{
	init_board(mine, '0');
	init_address(address);
	set_mine_swap(address);
	get_mine(mine, address);//布雷
}
end = clock();
ms = (double)(end - start) / CLOCKS_PER_SEC * 1000.0;
printf("Naive换牌法:测试 %d 次,耗时 %.2lf 毫秒\n", test_times, ms);

🤔 玄学现场:为什么测出来全是 0?

第一次按下运行键,结果直接给我整不会了。三种算法跑 100 次,耗时全是 0.00 毫秒。

全是 0

破案分析:

  1. 精度问题:Windows 下 clock() 的精度是毫秒级(CLOCKS_PER_SEC 等于 1000)。现在的 CPU 跑 100 次扫雷布雷连 1 毫秒都用不到,自然测不出来。
  2. Debug 模式:VS 默认是 Debug 模式,编译器塞了一大堆安全检查代码,拖慢了速度。

将测试次数改成 10000 次后,终于抓到了时间差:

时间差

🚀 极限施压:当碰撞法“原形毕露”

在低密度(81 个格子 10 个雷)时,三种方法耗时其实大差不差。因为碰撞法撞墙的概率很低,while 循环重试的次数极少。

但是,当我把 MINE_COUNT 提高到 70(相当于 81 个格子放 70 个雷,丧心病狂的高密度)时,差距瞬间拉开:

· 洗牌法依然稳定在 16ms 左右,因为它的循环次数是固定的 O(N)。
· Naive 换牌法依然稳如老狗,也是 16ms。
· 随机碰撞法耗时直接起飞(或卡顿明显)。因为雷密度高达 86%,每次找随机位置几乎必定撞墙,重试次数呈指数级上升。

deep seek生成

🔬 硬核探讨:每次运行结果都不一样?

在测试过程中我发现,即便是同样的代码,每次跑出来的结果都会有 1~2 毫秒的浮动。

上下浮动

这其实是所有做性能测试的人都会遇到的“玄学”:

  1. 操作系统调度:后台挂着微信、浏览器,CPU 随时会被其他进程抢走时间片。
  2. 时钟精度限制:1 毫秒的误差在 1000 次的样本下被放大。
  3. 随机数的薛定谔状态:碰撞法的运行时间完全取决于 rand() 函数的运气。

🏆 最终结论与心得

通过这次实测,我不仅坐实了 洗牌算法 O(N) 的绝对稳定性,也更深刻理解了算法选择对程序健壮性的影响。

在平时的小游戏里,随便用 while 撞墙也许没关系;但在实际工业级的高并发、高密度数据场景下,任何不可控的概率重试,都可能成为系统崩溃的导火索。

最后,用我在上一篇博客里的话结个尾:

代码是写出来的,但好代码是“改”出来的。 从碰撞到洗牌,变的是几行代码,提升的是对底层逻辑的敬畏心。

—— 期待早点学完指针,用函数指针把测试框架重构得更优雅的 大一新生。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值