来研究下osu!的pp算法(Part_2)
上一部分我们提到了osu!中pp算法的各种指标,接下来,我们就来考虑一下,如何把osu!的pp算法延伸到OI题目难度的评估上。
那么,自然也就会想到如下的一些问题:
如何设计一个评价OI能力的算法?!
要回答这个问题,首先要对OI有一定的认识...毕竟OI和osu!本身还是有很大差别的,它们的共同点是都有一种固定的东西用于定性评价技巧(osu!是Ranked谱面,而OI就是各种各样的算法题目),所以在此基础上看了许多基础的知识和不同难度的算法题。
Yes, OI 也有自己的Wiki啦~(oi-wiki.org)(当然OI Wiki的编写也受到了CTF Wiki的影响,但本人对CTF并不感兴趣...而是热爱纯粹的编程...因为CTF需要学很多很多很多东西,感觉没有什么性价比...)
和osu!算法一样,我们需要先确定,如何评价一个人OI/编程能力的好坏呢?
最基本的思路就是根据能做出什么样难度的题目来进行评价,然后用类似osu!的pp算法把它们累加起来。
因此,我们也可以设计一个和osu!类似的算法,不过需要确定两个系数
(1)每个算法题的加权pp计算
weighted_pp = pp * alpha^(n-1)
其中n代表这道算法题目在个人做题记录中的排名
那么alpha取多少比较合适呢?
这就要考虑到每道题运用到的知识点都有所不同了..
alpha的选取决定了刷相同等级的pp,总pp所能到达的上限,因为我们知道
pp*(1 + alpha + alpha^2 + ...) = 1/(1 - alpha) * pp
如果alpha
取0.95,那么1/(1-alpha)
就等于20,如果alpha
取0.97,那么1/(1-alpha)
就约等于33.33。
这还需要进一步确定,不过我们暂且先把它设置成0.95(和osu!算法一致)
接下来,就是如何去评价一道题的总pp,以及如何根据个人表现来确定个人实际上能够得到的pp的问题了...
为此,我们研究了很多案例,总结了下面几点:
比如我们先从Leetcode的题库中选一些题目来大致看一下如何确定一道题的pp上限
一些难度为“简单”的题
- 两数之和(Leetcode的第一题)
- 解法:暴力解法 or 哈希表
- 如何评价本题的pp呢?
我们先来看这道题Leetcode官方给出的答案代码:
这是暴力题解的代码
class Solution: def twoSum(self, nums: List[int], target: int) -> List[int]: n = len(nums) for i in range(n): for j in range(i + 1, n): if nums[i] + nums[j] == target: return [i, j] return [] #作者:力扣官方题解 #链接:https://leetcode.cn/problems/two-sum/solutions/434597/liang-shu-zhi-he-by-leetcode-solution/
时间复杂度O(n^2)
,空间复杂度为O(1)
.
class Solution: def twoSum(self, nums: List[int], target: int) -> List[int]: hashtable = dict() for i, num in enumerate(nums): if target - num in hashtable: return [hashtable[target - num], i] hashtable[nums[i]] = i return [] #作者:力扣官方题解 #链接:https://leetcode.cn/problems/two-sum/solutions/434597/liang-shu-zhi-he-by-leetcode-solution/
时间复杂度为O(N)
,空间复杂度也是O(N)
.
osu!中确定一张谱面的pp采用的是分段和分不同指标进行评价的方法,这里要如何用类似的方法来评价呢?
2. LCP 01 猜数字
3. Fizz Buzz
4. 合并两个有序链表
5. x的平方根
osu!对于单个谱面的pp的三个指标的pp并不是直接相加y = a + b + c,而是 y = (a^n + b^n + cn)(1/n),其中n = 1.1。
接下来想办法搞到了osu-performance开源项目(Github里面有),然后我们分析一下代码(原来的代码是C++做的,虽然学过,但是还是看Python比较方便)
以下是ChatGPT生成的代码的一部分
import math class OsuScore: def __init__(self, scoreId, mode, userId, beatmapId, score, maxCombo, num300, num100, num50, numMiss, numGeki, numKatu, mods, beatmap): super().__init__(scoreId, mode, userId, beatmapId, score, maxCombo, num300, num100, num50, numMiss, numGeki, numKatu, mods) self.computeEffectiveMissCount(beatmap) self.computeAimValue(beatmap) self.computeSpeedValue(beatmap) self.computeAccuracyValue(beatmap) self.computeFlashlightValue(beatmap) self.computeTotalValue(beatmap) def TotalValue(self): return self._totalValue def Accuracy(self): if self.TotalHits() == 0: return 0 return max(0.0, min(1.0, (self._num50 * 50 + self._num100 * 100 + self._num300 * 300) / (self.TotalHits() * 300))) def TotalHits(self): #谱面的最大成功命中数,考虑了miss return self._num50 + self._num100 + self._num300 + self._numMiss def TotalSuccessfulHits(self): #成功命中数 return self._num50 + self._num100 + self._num300 def computeEffectiveMissCount(self, beatmap): #计算有效的Miss数量 comboBasedMissCount = 0.0 beatmapMaxCombo = beatmap.DifficultyAttribute(self._mods, Beatmap.MaxCombo) if beatmap.NumSliders() > 0: #如果滑条数 > 0 fullComboThreshold = beatmapMaxCombo - 0.1 * beatmap.NumSliders() #全连阈值为MaxCombo - 10% * 滑条数量(有点离谱,难道说如果小于这个数字就不能算全连了吗...) if self._maxCombo < fullComboThreshold: comboBasedMissCount = fullComboThreshold / max(1, self._maxCombo) #如果maxCombo = 400, 滑条数为100,那么阈值应该是390。 #此时,如果_maxCombo的值为389,那么Misscount就是390 / max(1,389) = 390/389 = 1. #否则就不执行这段代码,Misscount = 0. comboBasedMissCount = min(comboBasedMissCount, float(self._num100 + self._num50 + self._numMiss)) #这里假设num100,num50和nummiss分别是11,0,0,那么ComboMisscount就是min(390/389,11) = 390/389. self._effectiveMissCount = max(float(self._numMiss), comboBasedMissCount) #计算本局游戏的MissCount = max(0,390/389) = 390/389??? def computeTotalValue(self, beatmap): #计算一张图的总pp if (self._mods & EMods.Relax) > 0 or (self._mods & EMods.Relax2) > 0 or (self._mods & EMods.Autoplay) > 0: self._totalValue = 0 return multiplier = 1.14 if (self._mods & EMods.NoFail) > 0: #NoFail图的Multiplayer multiplier *= max(0.9, 1.0 - 0.02 * self._effectiveMissCount) numTotalHits = self.TotalHits() #Totalhits if (self._mods & EMods.SpunOut) > 0: #SpunOut Mod的Multiplayer multiplier *= 1.0 - math.pow(beatmap.NumSpinners() / float(numTotalHits), 0.85) self._totalValue = math.pow( math.pow(self._aimValue, 1.1) + math.pow(self._speedValue, 1.1) + math.pow(self._accuracyValue, 1.1) + math.pow(self._flashlightValue, 1.1), 1.0 / 1.1 ) * multiplier #我们刚刚看过的公式,只不过需要与Multiplier相乘,这里的Multiplier应该是起到归一化的作用,确保这个结果和a+b+c相等 def computeAimValue(self, beatmap): self._aimValue = math.pow(5.0 * max(1.0, beatmap.DifficultyAttribute(self._mods, Beatmap.Aim) / 0.0675) - 4.0, 3.0) / 100000.0 # 假设Beatmap.Aim = 1.4, _mods对其没有影响(NM)的情况下,_aimValue = (5 * 20.74(=1.4 / 0.075) - 4) ^ 3 / 100000 = 9.91026973 numTotalHits = self.TotalHits() lengthBonus = 0.95 + 0.4 * min(1.0, float(numTotalHits) / 2000.0) + (log10(float(numTotalHits) / 2000.0) * 0.5 if numTotalHits > 2000 else 0.0) self._aimValue *= lengthBonus #将lengthBonus=3000和lengthbonus=9000代入上式: #0.95 + 0.4 + log10(1.5) * 0.5 = 1.4380456 #0.95 + 0.4 + log10(4.5) * 0.5 = 1.6676606 if self._effectiveMissCount > 0: self._aimValue *= 0.97 * math.pow(1.0 - math.pow(self._effectiveMissCount / float(numTotalHits), 0.775), self._effectiveMissCount) #通过计算的MissCount将)aimvalue调整为这个形式 self._aimValue *= self.getComboScalingFactor(beatmap) #连击的Scaling Factor approachRate = beatmap.DifficultyAttribute(self._mods, Beatmap.AR) approachRateFactor = 0.0 if approachRate > 10.33: approachRateFactor = 0.3 * (approachRate - 10.33) elif approachRate < 8.0: approachRateFactor = 0.05 * (8.0 - approachRate) #感觉可以写成一个更简单的公式:approachFactor = max(0.3 * (AR-10.33), 0.05*(8-AR), 0),至于为什么,是因为它们函数的单调性..上午有分析过的,不过为了代码可维护性还是写成了分段的形式 self._aimValue *= 1.0 + approachRateFactor * lengthBonus #乘以Lengthbonus(上午似乎讨论过) if (self._mods & EMods.Hidden) > 0: self._aimValue *= 1.0 + 0.04 * (12.0 - approachRate) #HD带来的影响 #AR越大,HD带来的影响越小,直到最大的时候(AR11),此时HD只能提供4%的额外增幅(而如果是AR0,则是48%) estimateDifficultSliders = beatmap.NumSliders() * 0.15 #Sliders的Difficulty if beatmap.NumSliders() > 0: #如果存在滑条的话 maxCombo = beatmap.DifficultyAttribute(self._mods, Beatmap.MaxCombo) #假设maxCombo=400 estimateSliderEndsDropped = min(max(min(float(self._num100 + self._num50 + self._numMiss), maxCombo - self._maxCombo), 0.0), estimateDifficultSliders) #估计滑条断尾的个数,因为我们知道滑条断尾只会产生一个100,但不会断连(如果断头的话也是会产生一个100,但会断连),不过好复杂,居然套了三层min/max... sliderFactor = beatmap.DifficultyAttribute(self._mods, Beatmap.SliderFactor) sliderNerfFactor = (1.0 - sliderFactor) * math.pow(1.0 - estimateSliderEndsDropped / estimateDifficultSliders, 3) + sliderFactor self._aimValue *= sliderNerfFactor self._aimValue *= self.Accuracy() self._aimValue *= 0.98 + math.pow(beatmap.DifficultyAttribute(self._mods, Beatmap.OD), 2) / 2500 def getComboScalingFactor(self, beatmap): #连击的Scaling Factor = min((_maxcombo/maxcombo)^0.8,1).看起来是这一部分导致了miss之后pp的下降,而且是成反比例函数 maxCombo = beatmap.DifficultyAttribute(self._mods, Beatmap.MaxCombo) if maxCombo > 0: return min(math.pow(self._maxCombo, 0.8) / math.pow(maxCombo, 0.8), 1.0) return 1.0 # Add other methods as needed def compute_speed_value(beatmap, mods, num_50, num_total_hits, effective_miss_count, max_combo): # Step 1: Calculate base speed value - 和Aim一样先计算Speed的基础pp值 speed_value = (5.0 * max(1.0, beatmap.difficulty_attribute(mods, "Speed") / 0.0675) - 4.0) ** 3.0 / 100000.0 # Step 2: Length Bonus - 也有length奖励值 length_bonus = 0.95 + 0.4 * min(1.0, num_total_hits / 2000.0) + (log10(num_total_hits / 2000.0) * 0.5 if num_total_hits > 2000 else 0.0) speed_value *= length_bonus # Step 3: Miss Penalty - Miss惩罚,和Aim一样的 if effective_miss_count > 0: speed_value *= 0.97 * (1.0 - (effective_miss_count / num_total_hits) ** 0.775) ** effective_miss_count ** 0.875 # Step 4: Combo Scaling - 和之前的函数是一样的,这也能解释为什么miss后aim和speed的pp会下降,而玩家的maxcombo提升后,pp又会上去。(因为(a+c)/(b+c) > (a/b) if c>0,所以说即使有Miss,只要Maxcombo有提升,pp也会有提升) speed_value *= get_combo_scaling_factor(beatmap, mods, max_combo) # Step 5: Approach Rate Adjustment - AR对speed的影响 approach_rate = beatmap.difficulty_attribute(mods, "AR") approach_rate_factor = 0.3 * (approach_rate - 10.33) if approach_rate > 10.33 else 0.0 speed_value *= 1.0 + approach_rate_factor * length_bonus # Step 6: Hidden Correction - HD系数 if "Hidden" in mods: speed_value *= 1.0 + 0.04 * (12.0 - approach_rate) # Step 7: Accuracy and OD Adjustment relevant_total_diff = num_total_hits - beatmap.difficulty_attribute(mods, "SpeedNoteCount") # ... Other variable calculations speed_value *= (0.95 + (beatmap.difficulty_attribute(mods, "OD") ** 2) / 750) * ((accuracy() + relevant_accuracy) / 2.0) ** ((14.5 - max(beatmap.difficulty_attribute(mods, "OD"), 8.0)) / 2) # Step 8: 50 Penalty 对50个数比例的惩罚系数 speed_value *= 0.99 ** (0.0 if num_50 < num_total_hits / 500.0 else num_50 - num_total_hits / 500.0) return speed_value def compute_accuracy_value(beatmap, mods, num_300, num_100, num_50, total_hits, num_hit_objects_with_accuracy): better_accuracy_percentage = 0.0 # if beatmap.score_version() == Beatmap.EScoreVersion.ScoreV2: #Scorev2的pp num_hit_objects_with_accuracy = total_hits better_accuracy_percentage = accuracy() #应该是计算acc else: num_hit_objects_with_accuracy = beatmap.num_hit_circles() #根据圆圈个数 if num_hit_objects_with_accuracy > 0: better_accuracy_percentage = ((num_300 - (total_hits - num_hit_objects_with_accuracy)) * 6 + num_100 * 2 + num_50) / (num_hit_objects_with_accuracy * 6) else: better_accuracy_percentage = 0 if better_accuracy_percentage < 0: better_accuracy_percentage = 0 # Lots of arbitrary values from testing. # Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution. accuracy_value = ( math.pow(1.52163, beatmap.difficulty_attribute(mods, "OD")) * math.pow(better_accuracy_percentage, 24) * 2.83 ) # Bonus for many hitcircles - it's harder to keep good accuracy up for longer. accuracy_value *= min(1.15, pow(num_hit_objects_with_accuracy / 1000.0, 0.3)) # 比如说下面是OD分别为6,8,11时,不同acc下的accuracy_value。(不考虑其他因素) ''' Results for OD = 6: Accuracy: 100.0%, Accuracy Value: 35.126980 Accuracy: 99.5%, Accuracy Value: 31.145460 Accuracy: 99.0%, Accuracy Value: 27.598500 Accuracy: 98.5%, Accuracy Value: 24.440514 Accuracy: 98.0%, Accuracy Value: 21.630503 Accuracy: 75.0%, Accuracy Value: 0.035246 Results for OD = 8: Accuracy: 100.0%, Accuracy Value: 81.331528 Accuracy: 99.5%, Accuracy Value: 72.112885 Accuracy: 99.0%, Accuracy Value: 63.900404 Accuracy: 98.5%, Accuracy Value: 56.588537 Accuracy: 98.0%, Accuracy Value: 50.082356 Accuracy: 75.0%, Accuracy Value: 0.081607 Results for OD = 11: Accuracy: 100.0%, Accuracy Value: 286.540569 Accuracy: 99.5%, Accuracy Value: 254.062201 Accuracy: 99.0%, Accuracy Value: 225.128661 Accuracy: 98.5%, Accuracy Value: 199.368091 Accuracy: 98.0%, Accuracy Value: 176.446048 Accuracy: 75.0%, Accuracy Value: 0.287512 ''' if "Hidden" in mods: accuracy_value *= 1.08 if "Flashlight" in mods: accuracy_value *= 1.02 return accuracy_value # Example usage: # accuracy_value = compute_accuracy_value(beatmap, mods, num_300, num_100, num_50, total_hits, num_hit_objects_with_accuracy) # print(accuracy_value)
感觉好像有点思路了...总结来说,Aim和Speed的初始参数是取决于beatmap中Aim和Speed部分难度星级的,也和玩家表现有关;而Accuracy的计算是取决于OD和玩家表现的。后续增加了很多因素(如谱面物件个数等)的影响为这些因素提供了相应的奖励pp.
那么也就有了下一个问题,一张图的Aim和Speed是如何被计算出来的呢?或者说StarRating是如何计算的呢?
我们分析了一个可以用pip安装的包osu-sr-calculator的代码逻辑,得出了下面的SR计算的步骤:
-
初始化:
- 初始化 Aim 和 Speed 技能计算器(
Aim()
和Speed()
)。 - 定义每个小节的长度(
section_length
)和难度倍增系数(difficlty_multiplier
)。
- 初始化 Aim 和 Speed 技能计算器(
-
遍历 HitObjects:
- 对每个
HitObject
进行处理,判断是否进入了新的小节,若是则保存当前小节的峰值难度值。 - 调用 Aim 和 Speed 计算器的
process
方法,传入当前HitObject
进行技能值的更新。
- 对每个
-
计算技能值:
- 在处理过程中,Aim 和 Speed 计算器会累积技能值,并根据一些规则计算出峰值难度值。
- 在整个图谱遍历完成后,最后保存当前小节的峰值。
-
计算总体 Star Rating:
- 根据 Aim 和 Speed 计算器的最终难度值,通过一定的计算方式计算出 Aim、Speed 和总体的 Star Rating。
- 具体的计算方式包括取平方根、绝对值差值等。
-
返回结果:
- 返回一个包含 Aim、Speed 和总体 Star Rating 的字典。
总体来说,这个计算器通过考虑 Aim 和 Speed 技能,对每个小节内的 HitObject 进行处理,从而得到一张 osu! 图谱的 Star Rating。计算过程中考虑了距离、时间、角度等因素,最终得到的 Star Rating 是对整个图谱难度的一个综合评估。
那么现在来说,osu!地图计算pp的机理应该就都解析完了,不过,把它放在OI算法题难度测试里面,客观评价一道题的难度仍然是很有挑战的(因为要考虑的因素是不一样的)
通过查阅相关的资料,我们可以把解决算法题的步骤分成4个子步骤,因此可以基于这几个步骤来继续划分子项的pp.
(1)分析问题,理解题意——确定程序“做什么”
(2)建立模型,设计算法——考虑程序“如何去做”,即设计算法
(3)编写程序——采用C++/Python编写程序
(4)调试、编译运行和测试——避免编译错误、逻辑错误(一些测试用例没有错误输出)
然后我们发散思维...就能画出这样一个思维导图:
首先第一步会与哪些变量相关呢?
应该会与题干相关,那么我们就要考虑下题干的组成:
下面是一道简单的样例算法题目:LuoguP1001
A+B Problem
题目描述
输入两个整数
输入格式
两个以空格分开的整数。
输出格式
一个整数。
样例 #1
样例输入 #1
20 30
样例输出 #1
50
Leetcode的样例题目如下:
LCP 01. 猜数字
小A 和 小B 在玩猜数字。小B 每次从 1, 2, 3 中随机选择一个,小A 每次也从 1, 2, 3 中选择一个猜。他们一共进行三次这个游戏,请返回 小A 猜对了几次?
输入的guess数组为 小A 每次的猜测,answer数组为 小B 每次的选择。guess和answer的长度都等于3。
示例 1:
输入:guess = [1,2,3], answer = [1,2,3]
输出:3
解释:小A 每次都猜对了。
示例 2:
输入:guess = [2,2,3], answer = [3,2,1]
输出:1
解释:小A 只猜对了第二次。
限制:
guess
的长度 = 3
answer
的长度 = 3
guess
的元素取值为 {1, 2, 3} 之一。
answer
的元素取值为 {1, 2, 3} 之一。
总结来看,Leetcode的算法题结构包括题目描述、输入和输出示例,以及一些限制;Luogu也是差不多的(可能多了一些题目背景),Codeforces等平台应该也会有类似的方式:
去看了下之前程序设计新手赛的题目结构,基本上也是分成题目背景&描述、输入、输出三个部分。
那么它们会如何影响算法题难度呢?
(1)题目背景&描述————很显然,a.题目背景的字数(特指中文文字的个数)会增加题干的理解程度;b.题目中出现的不同变量个数会显著增加题干的理解程度;
(2)输入数据的复杂程度————可以分为几类:
——1:单行或者单组数据,以及任何长度相对固定的输入;
——2:单个数字(表示数据组数) + 多组单行数据;
——3:单个数字 + 多组多行固定结构数据;
——4:更复杂的输入情况,如单个数字 + 多组变长数据等;
(3)输出数据的复杂程度————也可以分为几类:
——1:单行或者单组数据
——2:和输入数据组数对应的组数相同的输出;
——3:其他不规则的输出形式
那么,接下来就是确定一条合适的曲线来定量描述这些关系了(虽然并不能特别完美,但是总比没有好一些),以及确认它对算法题总pp的影响是加性(一个独立因素)还是乘性(缩放因子)
题目背景..应该和中文文字个数成一个单调递增的关系,但是越往后增长越慢
应该可以把它设置成线性的一个函数!
变量个数的话,可以弄成一个二次函数...变量越多,奖励的pp越多。而变量个数pp的初始值可以由中文文字/英文单词个数确定。
因此:
pp_of_description = a * (variable - 2) ** 2 + b
其中a影响变量个数的影响,而b影响题目背景字数。
如果变量个数小于等于2,则pp_of_description等于1,而如果变量个数超过2,则按照上面的二次函数递增,具体来说
二次函数的顶点是(2,1)
当变量个数为3的时候,函数过(3,1.03)点
当变量个数为5的时候,函数过(5,1.12)点
接下来就是初中问题了!如何求二次函数的解析式!
通过Wolframalpha可以求得函数解析式是
y = 0.0005 x^2 + 0.0005x + 0.97
这个函数过上面的这三个点;
现在我们再把后边的b也考虑在内,并对变量b进行建模。
经过一番计算后,我们得到:
那么题干总的影响因子就应该是
y = a + b 其中a = 1, if 变量数小于等于2; a = 0.0005x^2 + 0.0005x + 0.97, if变量数大于2.
至于输入和输出样例这边,我想如果对算法题熟悉的话,这方面应该不需要特别关心,可以把它看成一个固定的偏移量。
输入:
(1)单行输入:pp增益为变量个数 * 0.5,(字符串的话,根据题意确定应该看成单个数据还是多个数据)
(2)单个数字 + 多组数据:pp增益为每组数据的长度 * 0.7
(3)单个数字 + 多组变长数据:pp增益为每一组变长数据长度 * 1.5
输出:
(1)单行数据输出:pp增益为变量个数 * 0.5
(2)多行数据输出(通常与数据个数有关),pp增益为每组数据长度 * 0.7
那么第一步就暂且处理完了,接下来是第二步,也就是设计算法这个步骤的评价指标,即“怎么做”部分所能提供的最大pp。
算法是指一套确定的、有限的、能够解决特定问题的流程。我们可以用算法的复杂程度来对这一步进行建模。
不过...如何评价设计一个“算法”的复杂程度呢?
从数据结构的角度:
(1)算法只用到了基本的四则运算;
(2)算法用到了一些数据结构;
(2a)算法用到了一些简单的数据结构;
(2b)算法用到了一些复杂的数据结构;
(2c)算法用到了一些数据结构的扩展;
(2d)算法结合了多种数据结构;
从所涉及到的算法角度:
(1)算法的逻辑中,只出现了顺序结构;
(2)算法的逻辑中,出现了一些分支结构;
(3)算法的逻辑中,出现了一些循环结构(或者迭代结构);
(3a)对单层循环;
(3b)对n层循环;
(4)算法的逻辑中,出现了一些递归结构;
(5)算法逻辑中,出现了动态规划思想;(递推结构);
从题目的复杂程度:
(1)单个步骤的处理;
(2)多个步骤的处理;
接下来,我们分情况讨论,设数据结构的pp大小为a,算法设计的大小pp为b,题目复杂程度的pp为c。
从数据结构角度:
(1)只出现四则运算时,按照程序中四则运算代码的行数 * 0.66来评定pp;
(2)使用简单的数据结构:
-1 数组:
一维数组的处理: +10 * 每一维数组的数据个数 +2pp
字符串也可以看成一维数组, 按照上面算法应该是+12pp.
如果是栈和队列,则在一维数组的基础上额外+3pp.
n维数组的处理: +10* 每一维数组的数据个数 * n * (n!) + 2pp(n>=2)
-2 链表
一维链表的处理:+12 * 每一维数组的数据个数(不算指针) +2pp
如果是一维双向链表,则 +18 每一维数据的数据个数(不算指针) +2pp
n维链表的处理: +12 * m * 每一维数组的数据个数(不算指针) * n * (n!) + 2pp (n>=2)
(其中如果是双向链表,m=1.5,否则m=1)
-3 树
对二叉树的处理:+15 * 每一个节点的数据个数(不包括指针) + 2pp(也包括了堆、平衡二叉树等数据结构)
对多叉树的处理:+(9+3n)每个节点的数据个数(不包括指针) + 2pp, 其中n是多叉树中可能的分支个数,但最大n不超过5
-4 图
对邻接表/逆邻接表: +20 * 每个节点的数据个数 + 3pp
对邻接矩阵: +20pp
-5 哈希表
类似一维数组和n维数组,系数分别为12和12* n *n!
(3)使用复杂的数据结构(或者数据结构的扩展:
一些复杂的数据结构本身概念上就难以看懂,因此使用这种复杂数据结构的算法题必然也会有更多的pp加成。(在原有基础的数据结构上提升30%的pp)
-6 并查集
-7 单调栈和单调队列
-8 平衡二叉树
-9 红黑树
-10 B+树
-11 伸展树
-12 ST表
-13 线段树等
(4)运用多种数据结构,比如LRU的哈希双向链表:
如果采用这种方式,那么对应题目在这一步的pp值将会是两种数据结构的pp相加。
从算法的角度:
(1)顺序结构的加成为2pp;(基本上每个算法都有的2pp)
(2)条件分支的加成为(最简分支个数 * 2)pp
(3)循环结构的加成为
单重循环:8pp
两重循环:24pp
三重循环:96pp
n重循环:(n+1)!4pp
对于非寻常的循环会给予40%的增益;
(4)递归结构的加成为:
9pp + 18pp(递归传递的变量个数 - 1)
(5)动态规划的加成为:
每一个转移方程的pp之和
每个转移方程的pp = 转移方程涉及的变量个数 * 1pp(如果是max/min操作的数组,只算一个操作数) + 运算步骤/操作步数 * 2pp
第三步:编写代码:
(1)根据代码长度给予一定pp奖励
在代码逻辑清晰的情况下(即不会故意压缩到一行或者拉伸到多行,不包含注释),如果:
有效代码行数小于20,则乘法增益为1.
有效代码行数在20~100之间,增益线性从1增大到1.16(y = 1/500 (x-20) + 1)
有效代码行数超过100行,则增益按照二次函数方式增长,比如当单个题目代码行数达到200行时,增益达到了1.64。(y = 0.000032x^2 + 0.0048x + 1.32)
(2)根据代码的性能判断
对于Leetcode题目,根据打败的用户数百分比,将这一项写成:
1.6 ** 难度基准 * 时间复杂度打败用户数 ** 2.4 * 空间复杂度打败用户数 ** 1.2 * 2.38
对于Luogu题目,则直接确定此项为
1.6 ** 难度基准 * 2.38.
(3)对每一行代码的正确性、作用和价值等进行综合判断,得到每一行代码的pp值。(比如说关键的一行代码、体现技巧更强的代码等都会得到更多的pp值,而不是很关键的代码就不会涉及到pp)
第四步:代码调试和运行
(1)如果测试用例中有正好达到范围上限的大样例,总的pp会乘以1.2
(2)如果测试用例中有边缘用例,总pp会乘以1.05
(实际测评当中,经常会假设(1)和(2)都是成立的)
(3)如果结果一次提交成功,则本题总pp乘以1.2,如果是n次提交成功,则总pp会乘以:(1 - (n/l)^0.775 )^n,其中l为代码行数,而n为提交次数(即没有加成)
(判断一次提交成功的CD是24小时,也就是说24小时后会刷新提交次数)
今天只不过是一个初步的设想..现在真的有点困了...> <明天再结合具体的实例考虑下细节的问题
不过,这只是一个初始的思路,所以还要根据具体问题再来确定,还需要考虑哪些东西。
先以一个简单的例子为例:
Example 01 分苹果
题目描述
有A个苹果,Tuffy拿走了其中的m个,Yuzu拿走了其中的n个,Oni拿走了剩下的所有苹果。问:
(1)Oni拿走了多少苹果?
(2)Yuzu和Tuffy所拿的苹果数之和和Yuzu与Oni所拿苹果之和,哪个更多?
输入样例:
一行3个数据,分别代表A,m,n
输出样例
一行2个数据,第一个数据为整数,回答问题(1);第二个数据为布尔值,回答问题(2)。
提示
数据满足:0 <= A <= 1*10^19
, m + n < A
.
(1)理解题意的pp增益:
根据公式 y = 0.005x^2 + 0.005x + 0.97 + b,以及b的分段函数可知,b = 3.6e-3,x = 3, pp增益为:
y = 0.005 * 9 + 0.005 * 3 + 0.97 + 0.0036 = 1.0336
(2)算法设计和实现的pp增益
显然,本题算法设计如下:
C++ 实现:
#include <iostream> int main() { long long A, m, n; // long long关键字 3pp(可以被视为设计算法步骤上的pp) std::cin >> A >> m >> n; //cin 的使用 0.3pp // (1) Oni拿走了多少苹果 long long oniApples = A - m - n; // A = m - n 0.67pp // (2) Yuzu和Tuffy所拿的苹果数之和Yuzu与Oni所拿苹果之和,哪个更多 long long lucasAndTuffySum = m + n; // sum = m + n 0.67pp bool lucasAndOniMore = lucasAndTuffySum > oniApples; // bool 变量的正确计算 0.67pp,把bool值简化为最简形式0.33pp std::cout << oniApples << " " << lucasAndOniMore << std::endl; // 正确的输出语法和格式 0.5pp(cpp的输出比python要复杂一些,所以这里也体现了出来) return 0; //返回0 0.2pp }
代码总pp数:6.01pp
Python 实现:
# (1) Oni拿走了多少苹果 def oni_apples(A, m, n): return A - m - n #计算正确 0.67pp # (2) Yuzu和Tuffy所拿的苹果数之和和Yuzu与Oni所拿苹果之和,哪个更多 def compare_sums(m, n): lucas_and_tuffy_sum = m + n #计算正确 0.67pp oni_apples = oni_apples_global # Assuming oni_apples_global is defined somewhere return lucas_and_tuffy_sum > oni_apples #bool 变量的正确计算 0.67pp,把bool值简化为最简形式0.33pp A, m, n = map(int, input().split()) #Python的输入比较有技巧,因此这里给正确输入奖励3pp # (1) Oni拿走了多少苹果 oni_apples_global = oni_apples(A, m, n) #此步骤不加pp,或者加少量pp,因为如果不用函数也能这样实现 0.05pp # (2) Yuzu和Tuffy所拿的苹果数之和和Yuzu与Oni所拿苹果之和,哪个更多 lucas_and_oni_more = compare_sums(m, n) #0.05pp print(oni_apples_global, lucas_and_oni_more) #正确输出 0.3pp
代码总pp数:5.74pp
当然,这仅仅是从代码正确性上(语法上)所给出的pp。
假设测试用例如下:(共有9组)
10 4 4 20 3 3 30 5 5 40 10 10 0 0 0 60 5 5 3919382747111 291929300 202999911 9213818388444 121391393 1219348449 333912818383331 13928482 10219391
并考虑这样的cpp程序
#include <iostream> int main() { int A, m, n; //这里没有考虑到long long,所以没有得到这一部分的pp,但是其他部分仍然得到了pp std::cin >> A >> m >> n; //cin 的使用 0.3pp // (1) Oni拿走了多少苹果 int oniApples = A - m - n; // A = m - n 0.67pp // (2) Yuzu和Tuffy所拿的苹果数之和和Yuzu与Oni所拿苹果之和,哪个更多 int lucasAndTuffySum = m + n; // sum = m + n 0.67pp bool lucasAndOniMore = lucasAndTuffySum > oniApples; // bool 变量的正确计算 0.67pp,把bool值简化为最简形式0.33pp std::cout << oniApples << " " << lucasAndOniMore << std::endl; // 正确的输出语法和格式 0.5pp(cpp的输出比python要复杂一些,所以这里也体现了出来) return 0; //返回0 0.2pp }
代码总pp数:3.01pp
上面仅仅是从代码正确性和算法设计上(语法上)所给出的pp。
假设我们用下面的公式计算算法调试的奖励pp:
我们可以类比osu!中计算准确度的方式:
accuracy_value = ( math.pow(1.52163, beatmap.difficulty_attribute(mods, "OD")) * math.pow(better_accuracy_percentage, 24) * 2.83 )
然后把数字调整一下:
accuracy_value - ( math.pow(1.2, 代码能拿到的最大pp) * math.pow((本次新通过的测试用例个数 / 测试用例总数), 10) * 提交权重 )
第一次提交时,提交权重为1.2
第二次及以后提交时,提交权重为(1 - ((n-1)/l)0.775)(n-1), 其中l为代码能达到的最大pp,而n是提交次数
那么根据上面的公式,上面的代码
#include <iostream> int main() { long long A, m, n; // long long关键字 3pp(可以被视为设计算法步骤上的pp) std::cin >> A >> m >> n; //cin 的使用 0.3pp // (1) Oni拿走了多少苹果 long long oniApples = A - m - n; // A = m - n 0.67pp // (2) Yuzu和Tuffy所拿的苹果数之和和Lucas与Oni所拿苹果之和,哪个更多 long long lucasAndTuffySum = m + n; // sum = m + n 0.67pp bool lucasAndOniMore = lucasAndTuffySum > oniApples; // bool 变量的正确计算 0.67pp,把bool值简化为最简形式0.33pp std::cout << oniApples << " " << lucasAndOniMore << std::endl; // 正确的输出语法和格式 0.5pp(cpp的输出比python要复杂一些,所以这里也体现了出来) return 0; //返回0 0.2pp }
在第一次提交后,计算这个算法的调试奖励pp.(注:我们已经计算出这段代码的总pp为6.01,假设官方题解的pp与之相同)
测试数据共有9组:
10 4 4 20 3 3 30 5 5 40 10 10 0 0 0 60 5 5 3919382747111 291929300 202999911 9213818388444 121391393 1219348449 333912818383331 13928482 10219391
为此ChatGPT还给出了对应的代码:
import math # 计算算法调试奖励pp的公式 def debug_pp(max_pp, passed_tests, total_tests, submission_weight): return accuracy_value - ( math.pow(1.2, max_pp) * math.pow((passed_tests / total_tests), 10) * submission_weight ) # 测试数据 tests = [ (10, 4, 4), (20, 3, 3), (30, 5, 5), (40, 10, 10), (0, 0, 0), (60, 5, 5), (3919382747111, 291929300, 202999911), (9213818388444, 121391393, 1219348449), (333912818383331, 13928482, 10219391) ] # 第一次提交 max_pp = 6.01 # 假设官方题解的pp与之相同 submission_weight = 1.2 passed_tests = 8 # 第一次提交,没有通过任何测试用例 # 计算调试奖励pp reward_pp = debug_pp(max_pp, passed_tests, len(tests), submission_weight) print("第一次提交调试奖励pp: if passed_tests = 8", debug_pp(max_pp, len(tests) - 1, len(tests), submission_weight)) print("第一次提交调试奖励pp: if passed_tests = 9", debug_pp(max_pp, len(tests), len(tests), submission_weight))
在此基础上扩展了一下,并调整了一下参数
import math # 计算算法调试奖励pp的公式 def debug_pp(max_pp, passed_tests, total_tests,submission_weight=1.2): return ( math.pow(1.1, (5 + max_pp)/1.1) * math.pow((passed_tests / total_tests), 2) ) # 测试数据 tests = [ (10, 4, 4), (20, 3, 3), (30, 5, 5), (40, 10, 10), (0, 0, 0), (60, 5, 5), (3919382747111, 291929300, 202999911), (9213818388444, 121391393, 1219348449), (333912818383331, 13928482, 10219391) ] # 第一次提交 max_pp = 6.01 # 假设官方题解的pp与之相同 submission_weight = 1.2 passed_tests = 8 # 第一次提交,没有通过任何测试用例 # 计算调试奖励pp reward_pp = debug_pp(max_pp, passed_tests, len(tests), submission_weight) print("第一次提交调试奖励pp: if passed_tests = 6", debug_pp(max_pp, len(tests) - 3, len(tests), submission_weight)) print("第一次提交调试奖励pp: if passed_tests = 8", debug_pp(max_pp, len(tests) - 1, len(tests), submission_weight)) print("第一次提交调试奖励pp: if passed_tests = 9", debug_pp(max_pp, len(tests), len(tests), submission_weight)) # 如果是较长或者技巧性较高的代码,一般第一次调试会奖励更多的pp print("第一次提交调试奖励pp: if acc = 30 / 40 and max_pp = 50", debug_pp(50, 30, 40, submission_weight)) print("第一次提交调试奖励pp: if acc = 35 / 40 and max_pp = 50", debug_pp(50, 35, 40, submission_weight)) print("第一次提交调试奖励pp: if acc = 39 / 40 and max_pp = 50", debug_pp(50, 39, 40, submission_weight)) print("第一次提交调试奖励pp: if acc = 40 / 40 and max_pp = 50", debug_pp(50, 40, 40, submission_weight)) # 一般来说,由于测试用例有限,我们会把基础pp计算出来,然后再乘以对应的系数: # 假设测试用例共有50个,处理一个总代码最大pp评分为45pp的算法 pp_list = [] for i in range(51): pp_list.append(debug_pp(45,i,50)) print(pp_list) # 计算所有测试用例通过的pp full_pass_pp = debug_pp(45, 50, 50) # 第一次提交通过了48个测试用例 first_pass_pp = debug_pp(45, 48, 50) * 1.2 # 第二次提交通过了所有测试用例 second_pass_pp = debug_pp(45, 50, 50) reward_second_pass = (debug_pp(45, 50, 50) - debug_pp(45, 48, 50)) * (1 - ((2-1)/(45)) ** 0.775) ** (2-1) reward_this_example = first_pass_pp + reward_second_pass # 假设第一次就提交了所有用例 all_pass_pp = debug_pp(45, 50, 50) reward_all_pass = debug_pp(45, 50, 50) * 1.2 # 打印结果 print("第一次提交通过了48个测试用例奖励pp:", first_pass_pp) print("第二次提交通过了所有测试用例奖励pp:", reward_second_pass) print("这种情况下的总pp:",reward_this_example) print("第一次就提交了所有用例奖励pp:", reward_all_pass) ''' 输出为:第一次提交通过了48个测试用例奖励pp: 80.61104371702946 第二次提交通过了所有测试用例奖励pp: 5.415561586671679 这种情况下的总pp: 86.02660530370113 第一次就提交了所有用例奖励pp: 87.46858042212398 可以发现,它很好地区分了这两种情况 ''' #如果只有10个测试用例,代码的技巧和长度共有60pp,那么输出有什么不同呢?(假设算法为60pp) pp_list = [] for i in range(11): pp_list.append(debug_pp(60,i,10)) print(pp_list) # 第一次提交通过了48个测试用例 first_pass_pp = debug_pp(60, 8, 10) * 1.2 # 第二次提交通过了所有测试用例 second_pass_pp = debug_pp(60, 10, 10) reward_second_pass = (debug_pp(60, 10, 10) - debug_pp(60, 8, 10)) * (1 - ((2-1)/(60)) ** 0.775) ** (2-1) reward_this_example = first_pass_pp + reward_second_pass # 假设第一次就提交了所有用例 all_pass_pp = debug_pp(60, 10, 10) reward_all_pass = debug_pp(60, 10, 10) * 1.2 print("第一次提交通过了8个测试用例奖励pp:", first_pass_pp) print("第二次提交通过了所有测试用例奖励pp:", reward_second_pass) print("这种情况下的总pp:",reward_this_example) print("第一次就提交了所有用例奖励pp:", reward_all_pass) # 但是这似乎有点太高了...不过真的有什么算法能到60pp吗? # 面向结果的编程暂时不考虑在内
对第一次提交给予1.2倍权重是为了鼓励编程的人能够充分考虑边缘条件,就和osu!中只要有miss就把pp权重从1立即降到0.97一样
不过这里由于可以多次提交,所以条件可能更宽松一些(?)
不同于osu!的miss处理机制,如果是第四次以后,权重不会下降到0,而是会有一个下限的惩罚权重,这个值是(1 - ((4-1)/(max_pp)) ** 0.775) ** (4-1).
对一个45pp的代码,这个值大概是原来pp的0.675倍。
对一个20pp的代码,这个值大概是原来pp的0.457倍。
对一个60pp的代码,这个值大概是原来pp的0.734倍。
但为了能进行区分,对其添加一个扰动,从第4次开始,每次提交都对新增的调试pp额外乘一个因子0.99。
如果编译错误,则通过测试用例数按照0计算,如果一个例子因为TLE、时间/空间复杂度超出限制等原因,也算没有通过
根据上述代码,我们可以知道:(假设最大pp为6.01pp, 9个测试用例)
首先提交了这个3.01pp的代码
#include <iostream> int main() { int A, m, n; //这里没有考虑到long long,所以没有得到这一部分的pp,但是其他部分仍然得到了pp std::cin >> A >> m >> n; //cin 的使用 0.3pp // (1) Oni拿走了多少苹果 int oniApples = A - m - n; // A = m - n 0.67pp // (2) Lucas和Tuffy所拿的苹果数之和和Lucas与Oni所拿苹果之和,哪个更多 int lucasAndTuffySum = m + n; // sum = m + n 0.67pp bool lucasAndOniMore = lucasAndTuffySum > oniApples; // bool 变量的正确计算 0.67pp,把bool值简化为最简形式0.33pp std::cout << oniApples << " " << lucasAndOniMore << std::endl; // 正确的输出语法和格式 0.5pp(cpp的输出比python要复杂一些,所以这里也体现了出来) return 0; //返回0 0.2pp }
然后有三个long long测试用例没过,本次提交的
代码:3.01pp
调试:1.15pp
然后你意识到需要给A,m,n添加long long限制,中间变量也是,于是代码算法设计的pp从3.01变成了最大的可能pp,即6.01.
根据上面的程序测试,第二次提交通过了所有用例,那么计算
增加的调试pp = 2.59 - 1.15 = 1.44pp
代码:6.01pp
调试:1.15pp + 1.44pp * (1 - (1/6.01)^0.775) ^ 1
后面的值大概等于0.75,那么这时的总调试pp为1.15pp + 1.08pp = 2.23pp;
而如果你一次就想到了long long int,那么你本题的总pp将会是这样的:
代码:6.01pp
调试:2.59pp
比上面多了0.36pp~
如果在一些比较复杂的代码,这个pp差距会很大的!
我们已经大致确定了“调试”部分pp的测试方式,但是“代码”部分如何确定pp还有待商榷
因为“代码”部分包含算法设计(创新性、技巧性等)和算法实现(正确性、简洁性)这两方面,而它们都需要设置独立的指标。
在本题中,如何对算法设计和算法实现部分进行拆分呢?我们以最大可能pp的代码进行分析:
#include <iostream> int main() { long long A, m, n; // long long关键字 0.1pp(实现) + 2.9pp(设计) std::cin >> A >> m >> n; //cin 的使用 0.3pp(实现) + 0pp(设计) // (1) Oni拿走了多少苹果 long long oniApples = A - m - n; // A = m - n 0.37pp(实现,考虑到写在一行代码上,而不是分两行,所以这里实际上比两行代码要多一些) + 0.8pp(设计,因为运算分两步,考虑到都是减法,性质相同,所以pp比1略少) // (2) Lucas和Tuffy所拿的苹果数之和和Lucas与Oni所拿苹果之和,哪个更多 long long lucasAndTuffySum = m + n; // sum = m + n 0.17pp(实现) + 0.5pp(设计) bool lucasAndOniMore = lucasAndTuffySum > oniApples; // 0.4pp(实现——因为考虑了题目的化简) + 0.35pp(设计) std::cout << oniApples << " " << lucasAndOniMore << std::endl; // 输出语法和格式 0.3pp(实现) + 0pp(设计) return 0; //返回0 0.2pp(实现) + 0pp(设计) }
因此我们的拆分如下:
属于“实现”的pp:0.1 + 0.3 + 0.37 + 0.17 + 0.4 + 0.3 + 0.2 = 1.84pp
属于“设计“的pp:2.9 + 0 + 0.8 + 0.5 + 0.35 + 0 + 0 = 4.55pp
代码总pp:6.39pp
那么基于这个基本的总pp计算最大的可能调试pp:
第一次提交调试奖励pp: if passed_tests = 6 1.1923943838410327
第一次提交调试奖励pp: if passed_tests = 8 2.119812237939614
第一次提交调试奖励pp: if passed_tests = 9 2.682887363642324
也就是说代码能获得的基础总pp为6.39 + 2.68 = 9.07pp
考虑题干的影响,我们刚刚计算出来的比例因子是1.0336,把最大的总pp * 1.0336,就得到了本题的最终pp,
9.07 * 1.0336 = 9.375pp
在后续的部分中,我们将尝试用代码来实现上面的这个框架(未完待续~)
本文作者:Yuzu_OvO(喵露露版)
本文链接:https://www.cnblogs.com/yuzusbase/p/17995667
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步