面试:C++实现完美洗牌算法(乱序)
1、乱序算法核心:等概率的全排列
洗牌算法的唯一目标,是将一个长度为 n 的数组,打乱成 n! 种全排列中的任意一种,并且确保每种排列出现的概率都是 1/n!。
证明"真的乱"的黄金准则
算法执行过程中产生的不同结果路径,必须有 n! 种可能。这个准则可以帮我们快速判断算法是否正确。
例如,对于一副54张的扑克牌,洗牌结果应有 54! 种可能。上面错误的算法,每一步都有54种选择,总共会产生 54^54 种可能,这个数字不能被 54! 整除,意味着概率分布不均。
现在,让我们看看真正的解决方案。
2、主流洗牌算法的C++实现
方法一:Fisher-Yates 洗牌算法 (古典版)
cpp
#include <vector>
#include <cstdlib>
#include <ctime>
using namespace std;
vector<int> fisherYatesShuffle(const vector<int>& original) {
vector<int> result;
vector<int> temp = original;
srand(time(nullptr)); // 初始化随机种子
while (!temp.empty()) {
// 从剩余牌中随机选择一张
int randIndex = rand() % temp.size();
// 将选中的牌移到结果数组中
result.push_back(temp[randIndex]);
// 从原数组中移除选中的牌
temp.erase(temp.begin() + randIndex);
}
return result;
}
这个算法模拟我们从牌堆中随机抽出一张,放到新牌堆顶部的过程。虽然直观,但需要额外的 O(n) 存储空间,且因为要频繁删除数组元素,时间复杂度为 O(n²)。
方法二:Knuth-Durstenfeld 洗牌算法 (现代改进版)
这是 Fisher-Yates算法的原地(in-place)改进版本,也是目前最经典、应用最广泛的洗牌算法。
cpp
#include <vector>
#include <random>
#include <algorithm>
#include <chrono>
using namespace std;
void knuthShuffle(vector<int>& cards) {
// 使用高质量随机数生成器
unsigned seed = chrono::system_clock::now().time_since_epoch().count();
mt19937 g(seed);
// 从后向前遍历
for (int i = cards.size() - 1; i > 0; i--) {
// 生成 [0, i] 范围内的均匀随机整数
uniform_int_distribution<int> dist(0, i);
int j = dist(g);
// 交换当前位置和随机位置
swap(cards[i], cards[j]);
}
}
使用C++标准库的优雅实现
cpp
#include <vector>
#include <random>
#include <algorithm>
using namespace std;
void stlShuffle(vector<int>& cards) {
// 真随机数生成器
random_device rd;
mt19937 g(rd());
// 标准库洗牌算法
shuffle(cards.begin(), cards.end(), g);
}
为什么Knuth算法是正确的?
对于任意一张牌,它被放在倒数第 k 个位置的概率是 1/n。通过数学归纳法可以严格证明,最终每个位置出现每张牌的概率都相等。
方法三:Inside-Out 算法 (保留原数组版)
适用于需要保留原始数组的场景。
cpp
vector<int> insideOutShuffle(const vector<int>& original) {
if (original.empty()) return {};
vector<int> result(original.size());
result[0] = original[0];
unsigned seed = chrono::system_clock::now().time_since_epoch().count();
mt19937 g(seed);
for (size_t i = 1; i < original.size(); i++) {
uniform_int_distribution<int> dist(0, i);
int j = dist(g);
if (j != static_cast<int>(i)) {
result[i] = result[j];
}
result[j] = original[i];
}
return result;
}
这个算法同样能在 O(n) 时间内完成,需要 O(n) 的额外空间,但保持了原数组不变。
方法四:蓄水池抽样算法 (处理数据流)
这是洗牌算法的一个特殊变体,用于处理 数据流 或 总数未知 的集合。
cpp
#include <vector>
#include <random>
using namespace std;
// 从数据流中随机抽取k个元素
vector<int> reservoirSampling(vector<int>& dataStream, int k) {
if (dataStream.empty() || k <= 0) return {};
vector<int> reservoir(k);
random_device rd;
mt19937 g(rd());
// 先填充前k个元素
for (int i = 0; i < k && i < dataStream.size(); i++) {
reservoir[i] = dataStream[i];
}
// 处理剩余元素
for (size_t i = k; i < dataStream.size(); i++) {
// 生成 [0, i] 的随机数
uniform_int_distribution<int> dist(0, i);
int j = dist(g);
// 如果随机数小于k,替换蓄水池中的元素
if (j < k) {
reservoir[j] = dataStream[i];
}
}
return reservoir;
}
应用场景:从海量日志中随机抽样分析、从无限的数据流中随机取样。
3、算法对比与面试选择指南
下表总结了这几种算法的关键特性,帮助你在不同场景下做出选择:
| 特性 | Fisher-Yates (古典) | Knuth-Durstenfeld | Inside-Out | 蓄水池抽样 |
|---|---|---|---|---|
| 核心操作 | 抽牌,建新堆 | 原地交换 | 内部交换,保留原数组 | 等概率替换 |
| 空间复杂度 | O(n) | O(1) | O(n) | O(k) |
| 时间复杂度 | O(n²) | O(n) | O(n) | O(n) |
| 是否原地 | 否 | 是 | 否 | 是(相对于水池) |
| C++关键实现 | vector.erase() | swap() + 随机索引 | 双数组操作 | 条件替换 |
| 最佳适用场景 | 教学理解原理 | 通用场景,面试首选 | 需保留原始数据 | 数据流/未知大小 |
面试回答模板
一句话 :"在C++中,我会使用Knuth-Durstenfeld洗牌算法,通过从后向前遍历并将当前元素与前方随机元素交换,确保每个排列出现的概率严格相等。时间复杂度O(n),空间复杂度O(1)。在实际编码中,我会使用<random>库而不是rand()来保证随机质量。"
4、拓展应用:洗牌算法的实际应用场景
洗牌算法不仅用于扑克牌游戏,在许多实际工程场景中都有应用:
- 音乐播放器的随机播放:确保每首歌等概率播放,避免重复
- 广告轮播系统:公平地展示不同广告
- 测试数据生成:随机化测试用例顺序
- 机器学习:训练数据集的随机化
cpp
// 实际应用:音乐播放器随机播放
class MusicPlayer {
private:
vector<string> playlist;
vector<string> shuffledList;
public:
void shufflePlaylist() {
shuffledList = playlist;
random_device rd;
mt19937 g(rd());
::shuffle(shuffledList.begin(), shuffledList.end(), g);
}
string getNextSong() {
// 从shuffledList中获取歌曲
static size_t index = 0;
if (index >= shuffledList.size()) {
reshuffle();
index = 0;
}
return shuffledList[index++];
}
};
5、关于随机性的重要注意事项
在C++中实现洗牌算法时,有几个关键点需要注意:
- 不要使用
rand():它的随机性质量差,周期短 - 选择合适的随机数引擎 :
mt19937适合大多数场景 - 正确设置分布范围:确保均匀分布
- 考虑线程安全:在多线程环境中使用thread_local随机引擎
cpp
// 线程安全的洗牌函数
void threadSafeShuffle(vector<int>& cards) {
// 每个线程有自己的随机引擎
static thread_local mt19937 generator(random_device{}());
for (int i = cards.size() - 1; i > 0; i--) {
uniform_int_distribution<int> dist(0, i);
int j = dist(generator);
swap(cards[i], cards[j]);
}
}
