基于动态规划的潜能觉醒数学模型
文章目录
- 问题描述
- 问题分析
问题描述
在游戏《植物大战僵尸 3》的“潜能觉醒”机制中,每个植物都有 5 个不同的潜能,一共有 14 种潜能,如下表所示。
| 编号 | 效果 | 增幅 |
|---|---|---|
| 1 | 攻击力增加 | 5 - 20 |
| 2 | 生命值增加 | 100 - 1000 |
| 3 | 受到伤害降低 | 5 - 50 |
| 4 | 攻击力增加 | 1% - 20% |
| 5 | 生命值增加 | 1% - 50% |
| 6 | 治疗量增加 | 6% - 15% |
| 7 | 阳光产出间隔降低 | 6% - 15% |
| 8 | 种植冷却降低 | 6% - 15% |
| 9 | 暴击率增加 | 1% - 25% |
| 10 | 暴击伤害增加 | 1% - 50% |
| 11 | 物理伤害增加 | 1% - 20% |
| 12 | 风属性伤害增加 | 1% - 20% |
| 13 | 火属性伤害增加 | 1% - 20% |
| 14 | 电属性伤害增加 | 1% - 20% |
其中潜能 3 和 8 的觉醒概率为 18\dfrac1881,其余类型的觉醒概率为 116\dfrac1{16}161。即设第 iii 个潜能觉醒的概率为 pip_ipi,则有 pi={1/8,i=3,81/16,其它p_i=\begin{cases}1/8,&i=3,8\\1/16,&其它\end{cases}pi={1/8,1/16,i=3,8其它。
对于某个特定的植物,只有一部分潜能是有效的,例如对于植物“向日葵”,潜能 7 是有效的,潜能 4 是无效的。此外,有效的潜能对于特定的植物,各类有效潜能的重要性也是不同的,例如对于植物“向日葵”,潜能 7 比潜能 5 更重要。为了便于分析,设第 iii 个潜能的满效用为 Wi,0≤Wi≤1W_i,0\le W_i\le1Wi,0≤Wi≤1。若觉醒的潜能效果未满,设该潜能的满效果为 mim_imi,此时效果为 xxx,则定义此时的效用 wi(x):=xmiWiw_i(x):=\dfrac x{m_i}W_iwi(x):=mixWi。其中,无效的潜能 Wi=0W_i=0Wi=0 且 max∣S∣=5∑i∈SWi=1\max\limits_{|S|=5}\sum\limits_{i\in S}W_i=1∣S∣=5maxi∈S∑Wi=1 (即最大的 5 个满效用之和为 1) ,则每个植物的潜能方案效用为 ∑i=15wki(xi)\sum\limits_{i=1}^5w_{k_i}(x_i)i=1∑5wki(xi),kik_iki 两两不同。
觉醒潜能需要消耗 2 类资源,潜能药剂和潜能锁定剂,其中前者每次觉醒潜能消耗 1 瓶,后者不是必需的,但如果需要延续原有的 nnn 个潜能,则消耗 3n−13^{n-1}3n−1 瓶。这两类资源的获取难度也是不同的,一般认为前者比后者更容易获得。为了便于分析,不妨设潜能药剂的获取成本为 1,潜能锁定剂的获取成本为 ccc。但由于不同方案消耗的资源也是不同的,对此,我们设某个潜能觉醒方案花费的成本为 C(n)=1+⌊3n−1⌋C(n)=1+\lfloor3^{n-1}\rfloorC(n)=1+⌊3n−1⌋,其效用期望为 EEE,而不使用锁定剂,只使用潜能药剂随机觉醒 C(n)C(n)C(n) 次的最大效用期望为 E0E_0E0,则我们定义该潜能觉醒方案的价值 v=E/E0v=E/E_0v=E/E0。
已知某植物各潜能的满效用为 Wi,i=1,…,14W_i,i=1,\dots,14Wi,i=1,…,14,当前的潜能方案为 ki,xi,i=1,…,5k_i,x_i,i=1,\dots,5ki,xi,i=1,…,5,其中 kik_iki 表示潜能的种类下标,xix_ixi 表示第 kik_iki 个潜能的效果。现锁定 nnn 个潜能进行潜能觉醒,问选取多大的 nnn,且选取哪几个潜能锁定,能达到潜能方案的价值最高?
问题分析
首先,由于每个植物只有 5 个潜能,一共有 25−1=312^5-1=3125−1=31 种锁定方案,直接对其暴力遍历即可。而对于每种选择 S={ki:i=1,…,n}S=\{k_i:i=1,\dots,n\}S={ki:i=1,…,n},其效用期望
E(S)=Ef(S)+Er(S)=∑i=1nwki+∑i∉Sp^iEr(S∪{i})E(S)=E_f(S)+E_r(S)=\sum_{i=1}^nw_{k_i}+\sum_{i\notin S}\hat p_iE_r(S\cup\{i\}) E(S)=Ef(S)+Er(S)=i=1∑nwki+i∈/S∑p^iEr(S∪{i})
其中 p^i=pi1−∑j∈Spj\hat p_i=\dfrac{p_i}{1-\sum\limits_{j\in S}p_j}p^i=1−j∈S∑pjpi 为新的选择概率。利用递归即可求得效用期望。
值得注意的是,这里并不是锁定效用最高的 nnn 个就是期望最高的,例如只有这 nnn 个潜能的满效用不为零,但这 nnn 个效果都是最低的,显然不固定这些比固定的期望更高。
由概率论的知识可知,nnn 个极大分布的分布函数 Fn(x)F_n(x)Fn(x) 是原分布函数 F(x)F(x)F(x) 的 nnn 次方。而觉醒 1 个潜能是一个由若干均匀分布组成的分布,而觉醒 1 个潜能方案是进行 5 次觉醒 1 个潜能并不重复,即进行 5 次不放回的随机抽样并相加。因此觉醒 1 次分布函数是一个分段线性函数 f(x)f(x)f(x),而觉醒 5 次则是一个分段多项式函数 F(x)F(x)F(x),从而我们可以知道觉醒 nnn 个潜能方案的极大分布的分布函数是 Fn(x)F^n(x)Fn(x)。而其中求 F(x)F(x)F(x) 是复杂的,且容易造成概率消失,即将大量小概率相加会因为计算机精度不足而忽略了小数。因此为了简化问题,在计算分布函数时我们假设 5 个潜能是独立的,从而此时使用 CCC 瓶潜能药剂的效用期望
E0=∫0+∞(1−FC(x))dxE_0=\int_0^{+\infty}(1-F^C(x)){\rm d}x E0=∫0+∞(1−FC(x))dx
由于在游戏中每个月的潜能觉醒最小任务要求为 50 次,而通过活动每个月能获得 2 瓶锁定剂,因此可假设 c0=50/2=25c_0=50/2=25c0=50/2=25。以熔火猕猴桃为例,其现有潜能为:

则编写程序如下:
#include <stdio.h>
#include <stdint.h>
#include <vector>
#include <set>
#include <algorithm>
#include <utility>
// using std::vector,std::pair;
typedef std::pair<double, double> Pair;
typedef std::vector<Pair> PairVector;
typedef std::vector<double> Vector;
typedef std::set<double> Set;
using std::sort;// 需要输入的数据
uint8_t w[14]{10, // 0: 攻击力增加1, // 1: 生命值增加1, // 2: 受到伤害降低10, // 3: 攻击力增加(%)1, // 4: 生命值增加(%)0, // 5: 治疗量增加0, // 6: 阳光产出间隔降低2, // 7: 种植冷却降低10, // 8: 暴击率增加10, // 9: 暴击伤害增加10, // 10: 物理伤害增加0, // 11: 风属性伤害增加10, // 12: 火属性伤害增加0 // 13: 电属性伤害增加
}; // 效用权重
uint8_t c0 = 25; // 锁定剂单价, 每个月可获得 2 个锁定剂,任务最低要求为 50 次觉醒,即取单价为 25// 固定数据
const uint8_t inf[14]{ 5,100,5,1,1,6,6,6,1,1,1,1,1,1 }; // 效果下界
const uint16_t sup[14]{ 20,1000,50,20,50,15,15,15,25,50,20,20,20,20 }; // 效果上界
const double e0[14]{ 0.625,0.55,0.55,0.525,0.51,0.7,0.7,0.7,0.52,0.51,0.525,0.525,0.525,0.525 }; // 原始期望 e0[i] = (inf[i] + sup[i]) / (2 * sup[i])
const uint8_t exp3[5]{ 0,3,9,27,81 };
uint16_t wSum; // 效用和
PairVector dist; // 不使用锁定剂的分布函数
const char *potStr[14]{ "攻击力增加","生命值增加","受到伤害降低","攻击力增加(%)","生命值增加(%)","治疗量增加","阳光产出间隔降低","种植冷却降低","暴击率增加","暴击伤害增加","物理伤害增加","风属性伤害增加","火属性伤害增加","电属性伤害增加" };inline uint16_t c(uint8_t n) {return 1 + exp3[n] * c0;
}inline double p(uint8_t i) {return i == 2 || i == 7 ? 0.125 : 0.0625;
}// typedef uint16_t IdChoise;// 全局选择方案
struct IdChoise {uint16_t x;IdChoise() :x(0) {}IdChoise(uint16_t a) :x(a) {}void set(uint8_t i) {x |= 1 << i;}IdChoise copySet(uint8_t i) {return IdChoise(x | 1 << i);}bool get(uint8_t i) {return x & 1 << i;}
};// 选择方案
struct Choise {uint8_t x;Choise() :x(0) {}bool operator[](uint8_t i) const {return x & 1 << i;}operator uint8_t() const {return x;}Choise operator++() {x++;return *this;}bool next() {if (x == 31) return false;x++;return true;}uint8_t fixNum() {return __builtin_popcount(x);}const char *print();
};// 潜能方案
struct Scheme {uint8_t id[5];uint16_t effect[5];Scheme(std::initializer_list<uint8_t> ids, std::initializer_list<uint16_t> effects) {// copy_n(ids.begin(),ids.end(),id);// copy_n(effects.begin(),effects.end(),effect);uint8_t i = 0;for (uint8_t t : ids) id[i++] = t;i = 0;for (uint16_t t : effects) effect[i++] = t;}IdChoise getChoise(Choise choise) {IdChoise res;for (uint8_t i = 0; i != 5; i++)if (choise[i])res.set(id[i]);return res;}double calFixedUtility(Choise choise);double getUtility();
};Scheme cur({ 10,5,3,9,2 }, // 潜能编号{ 20,14,20,45,37 } // 潜能效果
);const char *Choise::print() {static char buffer[150];char *p = buffer;bool f = false;for (uint8_t i = 0; i != 5; i++)if (x & 1 << i) {if (f) {*p++ = ',';*p++ = ' ';} elsef = true;const char *s = potStr[cur.id[i]];do *p++ = *s++; while (*s);}*p = '\0';return buffer;
}double Scheme::calFixedUtility(Choise choise) {double r = 0;for (uint8_t i = 0; i != 5; i++)if (choise[i]) {uint8_t t = id[i];r += double(w[t] * effect[i]) / sup[t];}return r;
}double Scheme::getUtility() {uint8_t i = id[0];double r = double(w[i] * effect[0]) / sup[i];for (uint8_t k = 1; k != 5; k++) {i = id[k];r += double(w[i] * effect[k]) / sup[i];}return r / wSum;
}// 计算无锁定效用
double calUtilityOnce() {double res = 0;for (uint8_t i = 0; i != 14; i++) res += (i == 2 || i == 7 ? w[i] << 1 : w[i]) * e0[i];return res / 16;
}// 计算锁定效用
double calUtilityOnce(IdChoise choise) {uint8_t s = 16;double res = 0;for (uint8_t i = 0; i != 14; i++)if (choise.get(i))s -= i == 2 || i == 7 ? 2 : 1;elseres += (i == 2 || i == 7 ? w[i] << 1 : w[i]) * e0[i];return res / s;
}double calUtilityWithoutDiv(IdChoise choise, uint8_t n) {if (n == 1) return calUtilityOnce(choise);uint8_t s = 16;double res = 0;for (uint8_t i = 0; i != 14; i++)if (choise.get(i))s -= i == 2 || i == 7 ? 2 : 1;elseres += (i == 2 || i == 7 ? 2 : 1) * (w[i] * e0[i] + calUtilityWithoutDiv(choise.copySet(i), n - 1));return res / s;
}inline double calUtility(Choise choise) {return (calUtilityWithoutDiv(cur.getChoise(choise), 5 - choise.fixNum()) + cur.calFixedUtility(choise)) / wSum;
}inline double calUtility() {return calUtilityWithoutDiv(IdChoise(), 5) / wSum;
}/*double calUtility() {double res = 0;for (uint8_t i = 0; i != 14; i++) res += (i == 2 || i == 7 ? w[i] << 1 : w[i]) * e0[i];return 5 * res / (wSum << 4);
}double calUtility(IdChoise choise) {uint8_t s = 16;double res = 0;for (uint8_t i = 0; i != 14; i++)if (choise.get(i))s -= i == 2 || i == 7 ? 2 : 1;elseres += (i == 2 || i == 7 ? w[i] << 1 : w[i]) * e0[i];return 5 * res / (s * wSum);
}*/Vector getPoints(double a[], double b[]) {Set points;points.insert(0.);for (uint8_t i = 0; i != 14; i++)if (w[i]) {double t = double(w[i]) / wSum;points.insert(a[i] = double(inf[i]) / sup[i] * t);points.insert(b[i] = t);}Vector u(points.begin(), points.end());sort(u.begin(), u.end());return move(u);
}void getF() {double a[14], b[14];Vector points(getPoints(a, b));for (double x : points) {double y = 0;for (uint8_t i = 0; i != 14; i++)if (w[i]) {double ai = a[i], bi = b[i];if (x > bi)y += p(i);else if (x > ai)y += p(i) * (x - ai) / (bi - ai);} elsey += p(i);dist.push_back(Pair(5 * x, y));}
}// x^e
double pow(double x, uint16_t e) {double r = e & 1 ? x : 1;while (e >>= 1) {x *= x;if (e & 1) r *= x;}return r;
}// x^e - y^e
double subPow(double x, double y, uint16_t e) {double r1, r2;if (e & 1) {r1 = x;r2 = y;} elser1 = r2 = 1;while (e >>= 1) {x *= x;y *= y;if (e & 1) {r1 *= x;r2 *= y;}}return r1 - r2;
}inline double integrate(const Pair &p1, const Pair &p2, uint16_t n) {return abs(p1.second - p2.second) < 1e-6 ? pow(p1.second, n) * (p2.first - p1.first) : subPow(p2.second, p1.second, n + 1) * (p2.first - p1.first) / (p2.second - p1.second) / (n + 1);
}double calUtility(uint16_t c) {uint8_t n = dist.size() - 1;double r = dist[n].first;for (uint8_t i = 0; i < n; i++) r -= integrate(dist[i], dist[i + 1], c);return r;
}void getWSum() {uint8_t a[14];for (uint8_t i = 0; i != 14; i++) a[i] = w[i];sort(a, a + 14, std::greater<int>());wSum = a[0] + a[1] + a[2] + a[3] + a[4];
}int main() {getWSum();getF();Choise choise, bestChoise[5];double bestUtility[5]{ 0 };printf("原始效用: %f.\n", cur.getUtility());printf("不使用锁定剂效用期望: %f, 价值为 1.\n", calUtility());while (choise.next()) {uint8_t n = choise.fixNum();double e = calUtility(choise);if (e > bestUtility[n]) {bestUtility[n] = e;bestChoise[n] = choise;}}for (uint8_t i = 1; i != 5; i++) printf("使用 %u 个锁定剂的最优效用期望: %f, 锁定的潜能为 %s, 价值为 %f.\n", i, bestUtility[i], bestChoise[i].print(), bestUtility[i] / calUtility(c(i)));return 0;
}
运行得结果:
原始效用: 0.594800.
不使用锁定剂效用期望: 0.238230, 价值为 1.
使用 1 个锁定剂的最优效用期望: 0.373869, 锁定的潜能为 物理伤害增加, 价值为 0.373869.
使用 2 个锁定剂的最优效用期望: 0.516153, 锁定的潜能为 物理伤害增加, 攻击力增加(%), 价值为 0.516153.
使用 3 个锁定剂的最优效用期望: 0.647065, 锁定的潜能为 物理伤害增加, 攻击力增加(%), 暴击伤害增加, 价值为 0.647065.
使用 4 个锁定剂的最优效用期望: 0.632182, 锁定的潜能为 物理伤害增加, 攻击力增加(%), 暴击伤害增加, 受到伤害降低, 价值为 0.632182.
从而可知在此现有潜能下,最优的觉醒潜能方案是不使用锁定剂。
