简介:这份《原神抽卡模拟器.zip》面向希望用C++复刻游戏祈愿流程的学习者与原神玩家,提供无需消耗游戏货币即可体验角色、武器抽取过程的练习项目,重点在于把抽卡概率、保底规则与随机数生成转化为可运行的程序逻辑。压缩包共38个文件、约4.04MB,包含cpp、hpp与h源码,txt、tlog日志,sln、vcxproj工程配置,以及exe、pdb等编译调试产物,便于查看工程结构与运行结果。已有1606人学习下载。读者可从中参考祈愿池数据组织、随机抽取实现、保底计数器设计、抽卡记录与出货率统计,以及登录和MD5相关模块的写法;配套说明文档与日志也有助于理解项目构建、运行输出和调试过程,适合作为C++面向对象、容器与随机数库的综合作业或课程设计参考。
1. 一个 C++ 抽卡模拟器能有多较真:从压缩包里翻出的四层概率模型
把「十连必出四星」写成一句if的人,最后都会在统计那一步翻车。这个 C++ 抽卡模拟器压缩包解开后东西不算多:一份卡池配置、一个抽卡核心、一层控制台界面,加上存档和统计。它干的事很具体——把公告里那句「基础出率 0.6%,含保底综合 1.6%」拆成能被编译器执行、能被蒙特卡洛验证的状态机。适合三类人:想练 C++ 工程化又不想写图书管理系统的、要给自己的项目做数值原型的、以及被「为什么八十抽还不出来」折磨过想亲手算一遍期望的。下面按拆包复现的顺序讲。
2. 卡池与概率模型:把公告里的「出率」拆成四层状态机
新手最容易犯的错,是把「五星概率 0.6%」当成一个常数写死在代码里,然后拿if (r < 0.006)一路判到底。这样写出来的东西,单抽看着没问题,跑一万次统计就露馅:综合出率永远对不上 1.6%。原因不在随机数,在模型——真实的卡池从来不是单一概率,而是基础出率、软保底、硬保底、大小保底四层叠起来的条件概率,每一抽的实际概率都依赖你之前垫了多少。
2.1 四层状态拆解:每一抽的概率都是「上一次」的函数
先把四层说清楚,这是整个项目的地基:
第一层是基础出率,也就是不触发任何保底时的原始概率,通常是个很小的数,0.5%~0.8% 这一档。第二层是软保底,从第 N 抽开始,每多抽一次概率就往上加一档,常见做法是从第 74 抽起每次加 6%,一直加到硬保底。第三层是硬保底,抽到这个数必出,概率直接等于 1,不再走随机判定。第四层是大小保底,决定出的是不是「本期角色」,本质是一个布尔状态机:歪了下次必中,中了下次回到 50/50。
这四层不是并列的,是串联的。所以正确的实现顺序永远是:先算这一抽的实际五星概率 → 判定是否出五星 → 出五星后再判定是否 UP → 重置/递增各级计数器。顺序错了,怎么调参都对不上。
常见的参数表长这样,具体数值都能在配置文件里改,下面的典型值只是我拿来跑通验证的一组:
| 字段 | 含义 | 典型值 | 改动后的影响 |
|---|---|---|---|
baseRate | 未触发软保底的单抽五星率 | 0.006 | 直接决定期望抽数的主要部分 |
softPityStart | 从第几抽开始概率爬升 | 74 | 调小会让综合出率虚高,调大则方差变大 |
softPityStep | 每抽爬升的绝对增量 | 0.06 | 太大三抽就满概率,太小硬保底前基本不变 |
hardPity | 必出的抽数上限 | 90 | 必须严格大于softPityStart,否则软保底是死代码 |
upRate | 出五星时命中 UP 的概率 | 0.5 | 影响「大保底」期望,不影响五星期望 |
fourStarPity | 四星保底抽数 | 10 | 与十连保底逻辑强耦合 |
fourStarRate | 四星基础出率 | 0.051 | 要和五星概率做互斥处理 |
注意,五星和四星不是独立事件。四星的判定必须发生在「这一抽没出五星」之后,否则你的四星出率会变成p4 × (1 - p5)的隐含值,统计出来永远偏低一点点——这点偏差在十万次采样下非常明显。
2.2 用配置结构体代替 if-else 硬编码
拆包后我最先动的地方,是把散落在draw()里的魔法数字全抽出来,做成一个PoolConfig。理由很实际:这类模拟器九成的改动需求都是「换个卡池参数再跑一遍」,如果参数写死在分支里,改一次就要全局搜一遍0.006,漏一处就是一次血泪经验。
#include <algorithm> #include <cmath> // 卡池参数:所有数值都可以从配置文件读入,代码里不出现硬编码 struct PoolConfig { double baseRate = 0.006; // 未触发软保底的单抽五星率 int softPityStart = 74; // 从第几抽起概率开始爬升 double softPityStep = 0.06; // 每抽爬升的绝对增量 int hardPity = 90; // 硬保底,必出 double upRate = 0.5; // 出五星时命中 UP 的概率 int fourStarPity = 10; // 四星保底抽数 double fourStarRate = 0.051; // 四星基础出率 }; // pity 表示"距离上次五星已经抽了多少次",第一次抽时 pity == 0 // 返回值是"这一抽"的实际五星概率,区间 [0, 1] inline double currentFiveStarRate(const PoolConfig& cfg, int pity) { const int nth = pity + 1; // 本次是第几抽 if (nth >= cfg.hardPity) return 1.0; // 硬保底:直接满概率 if (nth < cfg.softPityStart) return cfg.baseRate; const int over = nth - cfg.softPityStart + 1; // 超出软保底起点的抽数 return std::min(1.0, cfg.baseRate + cfg.softPityStep * over); }这段代码有三个参数值得盯住。
pity的语义是「已经抽了几次还没出」,所以判定时用的是pity + 1。这个+1是经典翻车点:写成pity会导致软保底整体延后一抽,硬保底变成 91 抽才出,而单抽看代码完全看不出来,只有跑到第 90 抽才暴露。
硬保底那一行必须是return 1.0,不能走std::min算出来的近似值。因为后续判定用的是r < p,而r的取值是[0, 1),理论上p = 1.0时必然成立,但只要你前面有任何一步浮点误差把上限压到 0.9999999,就存在极小概率漏判——这种 bug 一年可能遇到一次,遇到一次你能查三天。
std::min兜底是为了防止参数被改坏。有人把softPityStep调到 0.2,第 80 抽概率就超过 1 了,discrete_distribution拿到大于 1 的权重不会报错,只会静默地改变分布,非常难查。
2.3 概率表自检:累计和、单调性与边界
配置化之后紧接着就是校验。我在入口处加了一个validate(),任何一条不满足就直接拒绝启动,而不是带着错误的表跑出「看起来正常」的结果。
#include <numeric> #include <string> #include <vector> // 星级内部各物品的权重表,返回 true 表示可用 bool validateWeights(const std::vector<double>& w, std::string& why) { if (w.empty()) { why = "权重表为空"; return false; } double sum = std::accumulate(w.begin(), w.end(), 0.0); // 手写小数很容易凑出 0.998 或 1.002,容差放到 1e-6 足够 if (std::fabs(sum - 1.0) > 1e-6) { why = "累计和不为 1,实际为 " + std::to_string(sum); return false; } for (double x : w) { if (x < 0.0) { why = "存在负权重"; return false; } } return true; }累计和这条卡得最严。我见过一个卡池把五个角色的权重写成0.3 / 0.25 / 0.2 / 0.15 / 0.1,看着是 1.0,浮点加起来是0.9999999999999999,discrete_distribution会把它当成合法输入,最后一个角色永远抽不到——因为它的区间被压缩到了浮点精度以下。
单调性检查针对的是currentFiveStarRate:写个循环从pity = 0跑到hardPity,断言rate(n+1) >= rate(n),并且最后一项必须等于 1.0。这条断言拦住过一次我把softPityStart和hardPity配反的低级错误,当时程序照样能跑,只是出率曲线是个倒 V 形。
提示:
validate()只在 Debug 构建里跑全量检查,Release 里保留累计和那一项即可,否则每次启动都要遍历几千个权重,控制台启动会有明显停顿感。
3. 随机数引擎:把rand()换成 mt19937 之后概率才第一次对上
概率模型修好之后,我跑了一百万次采样,结果综合出率 1.58%,比理论值低了一点点。差得不多,但方向是单向的,不像随机波动。查了两小时,问题出在rand() % 100 < 1这一句上——这也是绝大多数 C++ 小项目里的默认写法。
3.1rand() % n的三宗罪
第一宗是取模偏置。RAND_MAX在不同平台上是 32767 或 2147483647,取模之后前面若干个桶会比后面的桶多分到一个值。拿% 100举例,2147483648 % 100 = 48,于是 0~47 这 48 个桶各多出一次命中机会,偏差量级是1/21474836。听起来微不足道,但配合「一抽 0.6%」这种小概率,相对误差就被放大了——你实际跑出来的五星率会比 0.6% 高出万分之一左右,恰好就是观测到的那个偏移。
第二宗是低位周期短。很多实现的rand()低位比特周期性极差,而% n恰好只保留低位。表现是:把种子固定之后,连续抽卡会出现明显的规律性重复,做「十连结果」这种成组输出时尤其扎眼。
第三宗是不可复现。rand()用全局状态,你没法在存档里保存「当前随机数流走到哪了」。玩家退出再进,随机序列就断片了,想复现某次抽卡结果做客服核查,根本做不到。
| 对比项 | rand() % n | mt19937_64+ 标准分布 |
|---|---|---|
| 取模偏置 | 有,n不整除RAND_MAX+1时必然存在 | 无,分布库内部做了拒绝采样 |
| 周期 | 实现相关,常见 2^31 量级 | 2^19937-1 |
| 状态可序列化 | 否,全局状态 | 是,引擎对象可整体存档 |
| 跨平台一致性 | 否,RAND_MAX与实现有关 | 是,标准规定了输出序列 |
| 单次耗时 | 更快 | 慢 2~5 倍,抽卡场景完全无感 |
最后一行是很多人纠结的点。抽卡模拟器一秒钟最多跑几千次,mt19937的开销在整条链路上根本排不进前三,相比之下std::string拼接和std::cout同步才是大头。为了省这点时间换来一个没法复现的结果,不划算。
3.2mt19937_64与分布库的标准写法
我一般把引擎包一层薄壳,只暴露三个能力:取[0,1)的浮点、按权重取离散值、导出当前种子。这样上层代码不需要知道底下用的是哪个引擎,将来换pcg64之类也只是一处改动。
#include <cstdint> #include <random> #include <vector> class Rng { public: explicit Rng(uint64_t seed) : seed_(seed), eng_(seed) {} // [0, 1) 均匀浮点,用于概率判定 double uniform01() { return std::generate_canonical<double, 53>(eng_); } // 按权重取下标,weights 必须已通过 validateWeights // 分布对象按需构造,权重稳定时可缓存到成员里 size_t pick(const std::vector<double>& weights) { std::discrete_distribution<size_t> dist(weights.begin(), weights.end()); return dist(eng_); } uint64_t seed() const { return seed_; } private: uint64_t seed_; std::mt19937_64 eng_; };generate_canonical<double, 53>的第二个模板参数是有效位数,53 是double尾数位宽,取 53 意味着用尽全部精度组合出[0,1),不会出现「概率小于 2^-32 的事件永远不触发」这种隐形下限。有人图省事写eng_() / (double)UINT64_MAX,结果能取到 1.0,r < p在p = 1.0时不成立,硬保底又漏了。
std::discrete_distribution每次构造都要对权重做一次前缀和,十连抽十次就是十次重建。物品数量上百时这点开销会开始显现,解决办法是把分布对象提到Pool里缓存,权重变化时才重建。
注意:
discrete_distribution对权重和非常敏感。传入{0.3, 0.25, 0.2, 0.15, 0.1}时它会自己归一化,所以即使和是 0.9999 也能跑,但归一化之后你原本设计的比例已经被悄悄改了。校验那一步不能省。
3.3 种子管理:把 seed 写进存档而不是只打印
可复现的关键在种子。设计上有两个选择:要么用std::random_device取真随机种子,要么让玩家手动指定。我一般两个都留——正常游玩走random_device,调试和出问题时走手动指定。
#include <random> Rng makeRng(uint64_t forcedSeed, bool useForced) { // random_device 在部分平台上可能退化为确定性实现, // 所以额外混入时间戳作为兜底,保证两次启动不会撞种子 uint64_t s = forcedSeed; if (!useForced) { std::random_device rd; s = (static_cast<uint64_t>(rd()) << 32) ^ rd(); s ^= static_cast<uint64_t>(std::chrono::steady_clock::now().time_since_epoch().count()); } return Rng(s); }Rng里那个seed_成员容易被忽略,但它是存档的必需品:只存「种子」不存「引擎当前状态」,读档后随机序列会从头开始,等于把已经抽过的卡再抽一遍。要真正做到断点续抽,得序列化引擎状态(std::mt19937_64支持<<和>>,输出是一长串十进制数),或者更简单的做法——每次抽卡前把当前引擎状态连同pity5、pity4、guaranteedUp一起快照到存档里。文本格式可以读,二进制格式体积小,抽卡这种数据量我倾向用文本,出问题时能直接肉眼核对。
4. 抽卡核心:单抽、十连和保底记录记账的代码骨架
模型清楚了、随机数干净了,剩下的就是把这套东西落到一个函数上。这一步看着简单,实际是整个包里改动最频繁的地方,因为所有规则都在这里汇合。
4.1GachaState:把「垫了多少抽」变成可存可读的结构体
状态结构体的第一原则是:所有会跨抽次携带的东西都必须在这里,不能藏在函数静态变量里。
#include <cstdint> #include <string> struct GachaState { int pity5 = 0; // 距上次五星已抽次数 int pity4 = 0; // 距上次四星已抽次数 bool guaranteedUp = false; // 大保底:下次五星必为 UP uint64_t seed = 0; // 本次会话的种子,用于复现 uint64_t draws = 0; // 累计抽数,统计用 }; struct DrawResult { int star = 3; // 3 / 4 / 5 bool isUp = false; // 仅五星有意义 std::string name; // 物品名 };guaranteedUp这个布尔量是大小保底的全部实现。它只有两个转换:出五星且isUp == false时置true,出五星且isUp == true时置false。中间那些没出五星的抽次一次都不能碰它,否则大保底会被莫名其妙地清掉。
draws看着是冗余字段,其实是排查问题的抓手。每次统计异常,第一件事就是拿draws和日志条数对一遍,对不上说明有分支提前return了。
4.2 单抽函数:先定星级,再定物品
顺序问题在这里体现得最明显。正确的做法是两级抽签:第一次抽签决定星级,第二次抽签在星级内部决定具体物品。之所以不能一把梭,是因为星级内部的权重是各自归一化的,跟星级概率是两套数。
DrawResult drawOne(GachaState& st, const PoolConfig& cfg, Rng& rng, const Pool& pool) { st.draws++; // 第一级:判定星级 const double p5 = currentFiveStarRate(cfg, st.pity5); if (rng.uniform01() < p5) { // 大保底优先:上一轮歪了这次必中,不再掷骰 const bool up = st.guaranteedUp || (rng.uniform01() < cfg.upRate); st.guaranteedUp = !up; // 歪了置 true,中了置 false st.pity5 = 0; st.pity4++; // 五星不和四星保底共用计数器,但仍要推进 return DrawResult{5, up, pool.pickFiveStar(up, rng)}; } st.pity5++; // 第二级:四星判定,只在没出五星时进行 const double p4 = (st.pity4 + 1 >= cfg.fourStarPity) ? 1.0 : cfg.fourStarRate; if (rng.uniform01() < p4) { st.pity4 = 0; return DrawResult{4, false, pool.pickFourStar(rng)}; } st.pity4++; return DrawResult{3, false, pool.pickThreeStar(rng)}; }几个参数和细节说明。
GachaState&必须是引用。写成传值的话,函数内所有计数改动出了作用域就丢了,表现是「保底永远不生效、每一抽都按基础出率算」,综合出率会稳定在 0.6% 左右,跟理论值差三倍,很好认。
st.guaranteedUp || (...)用的是短路求值,大保底状态下根本不会消耗随机数。这一点在复现时很重要——如果改成先掷骰再判断,同样的种子会走出不同的序列,日志对不上。
四星判定里那个st.pity4 + 1 >= cfg.fourStarPity,+1的道理和五星那边一样。出五星时pity4自增而不是清零,是因为四星保底只被「出四星」这件事重置,五星不占用四星保底名额。这个设计各家不一样,如果你的规则是「五星也算四星保底」,那就在五星分支里把pity4也清零,但要保证十连保底逻辑跟着改。
4.3 十连:计数不能重置,保底要回写
十连最常见的错误写法,是在循环里每次都新建一个GachaState。这么写出来的十连,等价于十次独立单抽,四星保底形同虚设。
#include <vector> std::vector<DrawResult> drawTen(GachaState& st, const PoolConfig& cfg, Rng& rng, const Pool& pool) { std::vector<DrawResult> out; out.reserve(10); // 关键:十次抽卡共用同一个 state,计数器连续推进 for (int i = 0; i < 10; ++i) { out.push_back(drawOne(st, cfg, rng, pool)); } // 十连保底:整组没有四星及以上时,把最后一抽升级 bool has4Plus = false; for (const auto& r : out) { if (r.star >= 4) { has4Plus = true; break; } } if (!has4Plus) { out.back().star = 4; out.back().isUp = false; out.back().name = pool.pickFourStar(rng); st.pity4 = 0; // 升级同样消耗了四星保底,计数必须回写 } return out; }这段里最容易漏的是最后那行st.pity4 = 0。升级动作发生在drawOne之外,如果不手动回写,状态里还记着「已经垫了 10 抽没出四星」,下一抽的四星概率直接顶到 1.0,等于白送一个四星。这个 bug 在单次十连里看不出来,只有连续抽几十次才会暴露。
另一个边界是「升级的最后一抽如果本来就是四星」——那种情况下has4Plus早就是true,根本不会进升级分支,所以不用担心覆盖掉已有结果。但如果你的保底规则是「十连必出四星,且最后一抽不低于四星」,那就得单独处理,别直接套这段。
4.4 存档:文本序列化比二进制省事
抽卡模拟器的存档数据量极小,几十个字节,用文本最划算。
#include <fstream> #include <sstream> // 存档格式:每行 key=value,方便肉眼核对和手工改 bool saveState(const GachaState& st, const std::string& path) { std::ofstream f(path); if (!f) return false; f << "seed=" << st.seed << "\n" << "draws=" << st.draws << "\n" << "pity5=" << st.pity5 << "\n" << "pity4=" << st.pity4 << "\n" << "guaranteedUp=" << (st.guaranteedUp ? 1 : 0) << "\n"; return static_cast<bool>(f); } bool loadState(GachaState& st, const std::string& path) { std::ifstream f(path); if (!f) return false; std::string line; while (std::getline(f, line)) { std::istringstream is(line); std::string key; if (!std::getline(is, key, '=')) continue; std::string val; std::getline(is, val); if (key == "seed") st.seed = std::stoull(val); else if (key == "draws") st.draws = std::stoull(val); else if (key == "pity5") st.pity5 = std::stoi(val); else if (key == "pity4") st.pity4 = std::stoi(val); else if (key == "guaranteedUp") st.guaranteedUp = (val == "1"); } return true; }std::stoi和std::stoull在输入不合法时会抛std::invalid_argument,存档被手改坏就会崩。稳妥的做法是包一层try/catch,解析失败就当作新档处理,而不是让程序直接退出。这一点在给别人分发的时候尤其重要——总有人会去改pity5试试能不能跳过保底。
5. 避坑实况:五个把概率算错的现场和它们的排查路径
下面这五条都是我在复现和改参数时真实撞上的。顺序按「从最容易发现到最难发现」排,最后一条花了我一整个下午。
5.1 出率统计偏低:概率表累计和不是 1
现象:跑十万次采样,各星级分布加起来是 0.9987,少了大概 0.13%,且缺失的部分全部集中在权重最小的那个物品上,它一次都没出现过。
原因:手写权重时凑了一组0.32 / 0.28 / 0.20 / 0.12 / 0.06 / 0.02,看着是 1.0,实际浮点累加是 0.9999999999999998。discrete_distribution内部做前缀和后,最后一个区间的上界恰好等于自己的下界,长度为零。
解决:在加载配置后立刻调用validateWeights,容差 1e-6,不通过就拒绝启动并打印实际和。同时给每个权重加一个「最小可见权重」下限,低于 1e-6 的直接判为配置错误——这种量级的权重本来也不可能被抽到,留着只会制造疑惑。
5.2 十连出两个五星:状态被复制而不是引用
现象:单抽完全正常,一进十连就出问题,偶尔一组十连里冒出两个五星,概率上极不合理。
原因:drawTen内部写了auto s = st;,本意是「复制一份临时的」,结果十次循环都在这份副本上累加,每次循环开头又没重置,逻辑上变成了十次独立判定,同时还把外层的st保护得死死的,pity5从头到尾都是 0。
解决:把drawOne和drawTen的状态参数统一改成GachaState&,并且在函数签名上就不接受按值传递。更进一步,把GachaState的拷贝构造delete掉,编译期直接堵死这条路。
5.3 每次启动抽卡序列一模一样:种子没初始化
现象:关掉程序重开,前二十抽的结果和上次完全一致,连物品名都一样。
原因:代码里留着std::srand(std::time(nullptr));的历史遗留,但抽卡逻辑已经换成了mt19937,而mt19937的构造函数如果传固定常量(比如Rng rng(42)),每次启动都会从同一个位置开始。
解决:抽卡引擎的种子走random_device加时间戳混合,同时保留手动指定入口。改完之后顺手把rand()相关的调用全部删掉——留着迟早有人误用。
5.4 硬保底第 90 抽还是不出:保底靠概率判定
现象:日志里清楚地记录着pity5 = 89,下一抽判完还是三星。
原因:currentFiveStarRate在硬保底那一档返回的是std::min(1.0, ...)算出来的值,而某次改参数把softPityStep调成了 0.05,理论上第 90 抽是0.006 + 0.05 * 17 = 0.856,根本没到 1.0。代码里没有单独的「硬保底直接返回」分支,全部依赖软保底爬升自然触顶。
解决:在currentFiveStarRate最前面加一条if (nth >= cfg.hardPity) return 1.0;,让硬保底成为一个独立分支而不是软保底的推论。同时在validate()里断言「软保底爬到硬保底时必须已经 >= 1.0」,两条路互为保险。
5.5 控制台卡名全是问号:UTF-8 与代码页不一致
现象:g++编译一切正常,运行起来中文物品名显示成????,五星和三星的区分度直接没了。
原因:源文件保存为 UTF-8,字符串字面量也是 UTF-8 字节序列,但 Windows 控制台默认代码页是 936(GBK),std::cout原样输出字节流,终端按 GBK 解码就全乱了。
解决:程序启动时调一次SetConsoleOutputCP(CP_UTF8),并把std::cout的 locale 设成".UTF-8"。跨平台的话用条件编译,非 Windows 平台这行跳过。如果不想引入平台相关代码,退而求其次把源码保存成 GBK,但这会在 Linux 上反过来出问题,不推荐。
| 排查入口 | 先看什么 | 常见结论 |
|---|---|---|
| 综合出率对不上 | 权重累计和、软保底爬升曲线 | 配置错,不是随机数错 |
| 保底不生效 | GachaState是否按引用传递 | 传值导致计数丢失 |
| 结果不可复现 | 种子来源、引擎是否被重新构造 | 固定种子或未序列化引擎状态 |
| 中文乱码 | 源文件编码 vs 控制台代码页 | 输出前设置代码页 |
6. 用蒙特卡洛把概率表验到底:一百万次抽卡的校验习惯
改完上面这些,我一般不会急着看界面,而是先跑一个无界面的校验程序:直接构造GachaState和Rng,循环抽一千万次,只统计三件事——五星总数、UP 命中数、平均每五星抽数。
#include <cstdio> void monteCarlo(const PoolConfig& cfg, const Pool& pool, uint64_t n) { GachaState st; // 从零开始,模拟新号 Rng rng(20240501ULL); // 固定种子,跑两次结果必须一致 uint64_t five = 0, up = 0; for (uint64_t i = 0; i < n; ++i) { DrawResult r = drawOne(st, cfg, rng, pool); if (r.star == 5) { five++; if (r.isUp) up++; } } std::printf("五星出率 = %.6f%% 期望抽数 = %.2f UP 占比 = %.4f\n", five * 100.0 / n, five ? static_cast<double>(n) / five : 0.0, five ? static_cast<double>(up) / five : 0.0); }跑一千万次大概几秒钟。判读结果有三条经验:五星出率应该落在 1.5%~1.7% 之间,低于 1.5% 说明软保底没生效,高于 1.8% 多半是硬保底分支被重复触发;期望抽数在 62~65 之间是正常区间,明显偏小要怀疑softPityStart被改过;UP 占比应该贴近 0.5,如果偏差超过 0.01,去查guaranteedUp有没有在某条分支里被意外清掉。
同样的种子连跑两次,结果必须逐位一致。这条我当成硬性验收标准——只要两次不一致,说明代码里还有没被状态覆盖的随机源,后面所有统计都不能信。真正的出率验证还要加一步卡方检验,把十万次结果按星级分桶算卡方值,自由度取桶数减一,p 值小于 0.01 就说明分布显著偏离,回去查权重表。
被这个 0.6% 坑过一次之后,我养成了一个习惯:任何涉及概率的改动,先跑固定种子的一百万次采样存档,改完再跑一遍,两份结果做 diff。肉眼看代码永远看不出「这一行少了个+1」,但统计数字会立刻告诉你。希望帮到你。
本文还有配套的精品资源,点击获取